diff --git a/frontend/src/views/editor/extensions/basicSetup.ts b/frontend/src/views/editor/extensions/basicSetup.ts
index 36d08c9..d2fefb2 100644
--- a/frontend/src/views/editor/extensions/basicSetup.ts
+++ b/frontend/src/views/editor/extensions/basicSetup.ts
@@ -25,6 +25,8 @@ import {highlightSelectionMatches} from '@codemirror/search';
import {autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap} from '@codemirror/autocomplete';
import {lintKeymap} from '@codemirror/lint';
import { vscodeSearch, customSearchKeymap, searchVisibilityField } from '../plugins/vscodeSearch';
+
+import { hyperLink } from '../plugins/hyperlink';
// 基本编辑器设置,包含常用扩展
export const createBasicSetup = (): Extension[] => {
return [
@@ -32,6 +34,8 @@ export const createBasicSetup = (): Extension[] => {
vscodeSearch,
searchVisibilityField,
+ hyperLink,
+
// 基础UI
lineNumbers(),
highlightActiveLineGutter(),
diff --git a/frontend/src/views/editor/plugins/hyperlink/index.ts b/frontend/src/views/editor/plugins/hyperlink/index.ts
new file mode 100644
index 0000000..3c98f35
--- /dev/null
+++ b/frontend/src/views/editor/plugins/hyperlink/index.ts
@@ -0,0 +1,218 @@
+import {
+ ViewPlugin,
+ EditorView,
+ Decoration,
+ DecorationSet,
+ MatchDecorator,
+ WidgetType,
+ ViewUpdate,
+} from '@codemirror/view';
+import { Extension, Range } from '@codemirror/state';
+
+const pathStr = ``;
+const defaultRegexp = /\b((?:https?|ftp):\/\/[^\s/$.?#].[^\s]*)\b/gi;
+
+export interface HyperLinkState {
+ at: number;
+ url: string;
+ anchor: HyperLinkExtensionOptions['anchor'];
+}
+
+class HyperLinkIcon extends WidgetType {
+ private readonly state: HyperLinkState;
+ constructor(state: HyperLinkState) {
+ super();
+ this.state = state;
+ }
+ eq(other: HyperLinkIcon) {
+ return this.state.url === other.state.url && this.state.at === other.state.at;
+ }
+ toDOM() {
+ const wrapper = document.createElement('a');
+ wrapper.href = this.state.url;
+ wrapper.target = '_blank';
+ wrapper.innerHTML = pathStr;
+ wrapper.className = 'cm-hyper-link-icon';
+ wrapper.rel = 'nofollow';
+ wrapper.title = this.state.url;
+ const anchor = this.state.anchor && this.state.anchor(wrapper);
+ return anchor || wrapper;
+ }
+}
+
+function hyperLinkDecorations(view: EditorView, anchor?: HyperLinkExtensionOptions['anchor']) {
+ const widgets: Array> = [];
+ const doc = view.state.doc.toString();
+ let match;
+
+ while ((match = defaultRegexp.exec(doc)) !== null) {
+ const from = match.index;
+ const to = from + match[0].length;
+
+ const linkMark = Decoration.mark({
+ class: 'cm-hyper-link-text',
+ attributes: {
+ 'data-url': match[0]
+ }
+ });
+ widgets.push(linkMark.range(from, to));
+
+ const widget = Decoration.widget({
+ widget: new HyperLinkIcon({
+ at: to,
+ url: match[0],
+ anchor,
+ }),
+ side: 1,
+ });
+ widgets.push(widget.range(to));
+ }
+
+ return Decoration.set(widgets);
+}
+
+const linkDecorator = (
+ regexp?: RegExp,
+ matchData?: Record,
+ matchFn?: (str: string, input: string, from: number, to: number) => string,
+ anchor?: HyperLinkExtensionOptions['anchor'],
+) =>
+ new MatchDecorator({
+ regexp: regexp || defaultRegexp,
+ decorate: (add, from, to, match, view) => {
+ const url = match[0];
+ let urlStr = matchFn && typeof matchFn === 'function' ? matchFn(url, match.input, from, to) : url;
+ if (matchData && matchData[url]) {
+ urlStr = matchData[url];
+ }
+ const start = to,
+ end = to;
+ const linkIcon = new HyperLinkIcon({ at: start, url: urlStr, anchor });
+
+ add(from, to, Decoration.mark({
+ class: 'cm-hyper-link-text cm-hyper-link-underline',
+ attributes: {
+ 'data-url': urlStr
+ }
+ }));
+ add(start, end, Decoration.widget({ widget: linkIcon, side: 1 }));
+ },
+ });
+
+export type HyperLinkExtensionOptions = {
+ regexp?: RegExp;
+ match?: Record;
+ handle?: (value: string, input: string, from: number, to: number) => string;
+ anchor?: (dom: HTMLAnchorElement) => HTMLAnchorElement;
+ showIcon?: boolean;
+};
+
+export function hyperLinkExtension({ regexp, match, handle, anchor, showIcon = true }: HyperLinkExtensionOptions = {}) {
+ return ViewPlugin.fromClass(
+ class HyperLinkView {
+ decorator?: MatchDecorator;
+ decorations: DecorationSet;
+ constructor(view: EditorView) {
+ if (regexp) {
+ this.decorator = linkDecorator(regexp, match, handle, anchor);
+ this.decorations = this.decorator.createDeco(view);
+ } else {
+ this.decorations = hyperLinkDecorations(view, anchor);
+ }
+ }
+ update(update: ViewUpdate) {
+ if (update.docChanged || update.viewportChanged) {
+ if (regexp && this.decorator) {
+ this.decorations = this.decorator.updateDeco(update, this.decorations);
+ } else {
+ this.decorations = hyperLinkDecorations(update.view, anchor);
+ }
+ }
+ }
+ },
+ {
+ decorations: (v) => v.decorations,
+ },
+ );
+}
+
+export const hyperLinkStyle = EditorView.baseTheme({
+ '.cm-hyper-link-text': {
+ color: '#0969da',
+ cursor: 'pointer',
+ transition: 'color 0.2s ease',
+ textDecoration: 'none',
+ '&:hover': {
+ color: '#0550ae',
+ textDecoration: 'underline',
+ }
+ },
+
+ '.cm-hyper-link-underline': {
+ textDecoration: 'underline',
+ textDecorationColor: '#0969da',
+ textDecorationThickness: '1px',
+ textUnderlineOffset: '2px',
+ },
+
+ '.cm-hyper-link-icon': {
+ display: 'inline-block',
+ verticalAlign: 'middle',
+ marginLeft: '0.2ch',
+ color: '#656d76',
+ textDecoration: 'none',
+ opacity: 0.7,
+ transition: 'opacity 0.2s ease, color 0.2s ease',
+ '&:hover': {
+ opacity: 1,
+ color: '#0969da',
+ }
+ },
+
+ '.cm-hyper-link-icon svg': {
+ display: 'block',
+ width: '14px',
+ height: '14px',
+ },
+
+ '.cm-editor.cm-focused .cm-hyper-link-text': {
+ color: '#58a6ff',
+ '&:hover': {
+ color: '#79c0ff',
+ }
+ },
+
+ '.cm-editor.cm-focused .cm-hyper-link-underline': {
+ textDecorationColor: '#58a6ff',
+ },
+
+ '.cm-editor.cm-focused .cm-hyper-link-icon': {
+ color: '#8b949e',
+ '&:hover': {
+ color: '#58a6ff',
+ }
+ }
+});
+
+export const hyperLinkClickHandler = EditorView.domEventHandlers({
+ click: (event, view) => {
+ const target = event.target as HTMLElement;
+
+ if (target.classList.contains('cm-hyper-link-text')) {
+ const url = target.getAttribute('data-url');
+ if (url) {
+ window.open(url, '_blank', 'noopener,noreferrer');
+ event.preventDefault();
+ return true;
+ }
+ }
+
+ return false;
+ }
+});
+
+export const hyperLink: Extension = [
+ hyperLinkExtension(),
+ hyperLinkStyle,
+ hyperLinkClickHandler
+];
\ No newline at end of file
diff --git a/frontend/src/views/editor/plugins/vscodeSearch/FindReplaceControl.ts b/frontend/src/views/editor/plugins/vscodeSearch/FindReplaceControl.ts
index 01342d7..8b362cf 100644
--- a/frontend/src/views/editor/plugins/vscodeSearch/FindReplaceControl.ts
+++ b/frontend/src/views/editor/plugins/vscodeSearch/FindReplaceControl.ts
@@ -75,6 +75,14 @@ export class CustomSearchPanel {
}
}
+ private setSearchFieldError(hasError: boolean): void {
+ if (hasError) {
+ this.searchField.classList.add('error');
+ } else {
+ this.searchField.classList.remove('error');
+ }
+ }
+
private charBefore(str: string, index: number) {
return str.slice(findClusterBreak(str, index, false), index)
}
@@ -117,13 +125,24 @@ export class CustomSearchPanel {
const cursorPos = state.selection.main.head;
let query = getSearchQuery(state);
-
if (query.regexp) {
- this.regexCursor = new RegExpCursor(state.doc, query.search)
- this.searchCursor = undefined;
+ try {
+ this.regexCursor = new RegExpCursor(state.doc, query.search)
+ this.searchCursor = undefined;
+ } catch (error) {
+ // 如果正则表达式无效,清空匹配结果并显示错误状态
+ console.warn("Invalid regular expression:", query.search, error);
+ this.matches = [];
+ this.currentMatch = 0;
+ this.totalMatches = 0;
+ this.updateMatchCount();
+ this.regexCursor = undefined;
+ this.searchCursor = undefined;
+ this.setSearchFieldError(true);
+ return;
+ }
}
else {
-
let cursor = new SearchCursor(state.doc, query.search);
if (cursor !== this.searchCursor) {
this.searchCursor = cursor;
@@ -148,20 +167,24 @@ export class CustomSearchPanel {
}
}
else if (this.regexCursor) {
+ try {
+ const matchWord = this.regexpWordTest(state.charCategorizer(state.selection.main.head))
- const matchWord = this.regexpWordTest(state.charCategorizer(state.selection.main.head))
+ while (!this.regexCursor.done) {
+ this.regexCursor.next();
- while (!this.regexCursor.done) {
- this.regexCursor.next();
+ if (!this.regexCursor.done) {
+ const { from, to, match } = this.regexCursor.value;
- if (!this.regexCursor.done) {
- const { from, to, match } = this.regexCursor.value;
-
- if (!query.wholeWord || matchWord(from, to, match)) {
- this.matches.push({ from, to });
+ if (!query.wholeWord || matchWord(from, to, match)) {
+ this.matches.push({ from, to });
+ }
}
-
}
+ } catch (error) {
+ // 如果正则表达式执行时出错,清空匹配结果
+ console.warn("Error executing regular expression:", error);
+ this.matches = [];
}
}
@@ -170,6 +193,7 @@ export class CustomSearchPanel {
if (this.matches.length === 0) {
this.updateMatchCount();
+ this.setSearchFieldError(false);
return;
}
// Find the match closest to the current cursor
@@ -183,7 +207,7 @@ export class CustomSearchPanel {
}
}
this.updateMatchCount();
-
+ this.setSearchFieldError(false);
requestAnimationFrame(() => {
const match = this.matches[this.currentMatch];
@@ -197,19 +221,24 @@ export class CustomSearchPanel {
}
commit() {
- const newQuery = new SearchQuery({
- search: this.searchField.value,
- replace: this.replaceField.value,
- caseSensitive: this.matchCase,
- regexp: this.useRegex,
- wholeWord: this.matchWord,
- })
-
- let query = getSearchQuery(this.view.state)
- if (!newQuery.eq(query)) {
- this.view.dispatch({
- effects: setSearchQuery.of(newQuery)
+ try {
+ const newQuery = new SearchQuery({
+ search: this.searchField.value,
+ replace: this.replaceField.value,
+ caseSensitive: this.matchCase,
+ regexp: this.useRegex,
+ wholeWord: this.matchWord,
})
+
+ let query = getSearchQuery(this.view.state)
+ if (!newQuery.eq(query)) {
+ this.view.dispatch({
+ effects: setSearchQuery.of(newQuery)
+ })
+ }
+ } catch (error) {
+ // 如果创建SearchQuery时出错(通常是无效的正则表达式),记录错误但不中断程序
+ console.warn("Error creating search query:", error);
}
}
diff --git a/frontend/src/views/editor/plugins/vscodeSearch/commands.ts b/frontend/src/views/editor/plugins/vscodeSearch/commands.ts
index ecc5552..d0be9d3 100644
--- a/frontend/src/views/editor/plugins/vscodeSearch/commands.ts
+++ b/frontend/src/views/editor/plugins/vscodeSearch/commands.ts
@@ -54,7 +54,6 @@ export const deleteCharacterFowards: Command = (view) => {
};
export const showSearchVisibilityCommand: Command = (view) => {
- console.log("SHOW");
view.dispatch({
effects: SearchVisibilityEffect.of(true) // Dispatch the effect to show the search
});
diff --git a/frontend/src/views/editor/plugins/vscodeSearch/theme.ts b/frontend/src/views/editor/plugins/vscodeSearch/theme.ts
index f876f9d..28ed83e 100644
--- a/frontend/src/views/editor/plugins/vscodeSearch/theme.ts
+++ b/frontend/src/views/editor/plugins/vscodeSearch/theme.ts
@@ -179,6 +179,10 @@ const lightTheme: Theme = {
".find-input:focus, .replace-input:focus": {
borderColor: "var(--cm-caret, #1e51db)",
},
+ ".find-input.error": {
+ borderColor: "#ff4444 !important",
+ backgroundColor: "#fff5f5 !important",
+ },
".search-controls div:hover": {
backgroundColor: "var(--cm-gutter-foreground, #e1e1e1)"
},
@@ -215,6 +219,10 @@ const darkTheme = {
".find-input:focus, .replace-input:focus": {
borderColor: "var(--cm-caret, #1e51db)",
},
+ ".find-input.error": {
+ borderColor: "#ff6b6b !important",
+ backgroundColor: "#3d2626 !important",
+ },
".search-controls div:hover": {
backgroundColor: "var(--cm-gutter-foreground, #3c3c3c)"
},