From 4844ccdf58e40a6bba92f2666d09dfead34a1f1f Mon Sep 17 00:00:00 2001 From: landaiqing Date: Tue, 10 Jun 2025 21:28:50 +0800 Subject: [PATCH] :sparkles: Added hyperlink extension --- .../src/views/editor/extensions/basicSetup.ts | 4 + .../views/editor/plugins/hyperlink/index.ts | 218 ++++++++++++++++++ .../vscodeSearch/FindReplaceControl.ts | 81 ++++--- .../editor/plugins/vscodeSearch/commands.ts | 1 - .../editor/plugins/vscodeSearch/theme.ts | 8 + 5 files changed, 285 insertions(+), 27 deletions(-) create mode 100644 frontend/src/views/editor/plugins/hyperlink/index.ts 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)" },