✨ Added hyperlink extension
This commit is contained in:
@@ -25,6 +25,8 @@ import {highlightSelectionMatches} from '@codemirror/search';
|
|||||||
import {autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap} from '@codemirror/autocomplete';
|
import {autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap} from '@codemirror/autocomplete';
|
||||||
import {lintKeymap} from '@codemirror/lint';
|
import {lintKeymap} from '@codemirror/lint';
|
||||||
import { vscodeSearch, customSearchKeymap, searchVisibilityField } from '../plugins/vscodeSearch';
|
import { vscodeSearch, customSearchKeymap, searchVisibilityField } from '../plugins/vscodeSearch';
|
||||||
|
|
||||||
|
import { hyperLink } from '../plugins/hyperlink';
|
||||||
// 基本编辑器设置,包含常用扩展
|
// 基本编辑器设置,包含常用扩展
|
||||||
export const createBasicSetup = (): Extension[] => {
|
export const createBasicSetup = (): Extension[] => {
|
||||||
return [
|
return [
|
||||||
@@ -32,6 +34,8 @@ export const createBasicSetup = (): Extension[] => {
|
|||||||
vscodeSearch,
|
vscodeSearch,
|
||||||
searchVisibilityField,
|
searchVisibilityField,
|
||||||
|
|
||||||
|
hyperLink,
|
||||||
|
|
||||||
// 基础UI
|
// 基础UI
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
highlightActiveLineGutter(),
|
highlightActiveLineGutter(),
|
||||||
|
218
frontend/src/views/editor/plugins/hyperlink/index.ts
Normal file
218
frontend/src/views/editor/plugins/hyperlink/index.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import {
|
||||||
|
ViewPlugin,
|
||||||
|
EditorView,
|
||||||
|
Decoration,
|
||||||
|
DecorationSet,
|
||||||
|
MatchDecorator,
|
||||||
|
WidgetType,
|
||||||
|
ViewUpdate,
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { Extension, Range } from '@codemirror/state';
|
||||||
|
|
||||||
|
const pathStr = `<svg viewBox="0 0 1024 1024" width="16" height="16" fill="currentColor"><path d="M607.934444 417.856853c-6.179746-6.1777-12.766768-11.746532-19.554358-16.910135l-0.01228 0.011256c-6.986111-6.719028-16.47216-10.857279-26.930349-10.857279-21.464871 0-38.864146 17.400299-38.864146 38.864146 0 9.497305 3.411703 18.196431 9.071609 24.947182l-0.001023 0c0.001023 0.001023 0.00307 0.00307 0.005117 0.004093 2.718925 3.242857 5.953595 6.03853 9.585309 8.251941 3.664459 3.021823 7.261381 5.997598 10.624988 9.361205l3.203972 3.204995c40.279379 40.229237 28.254507 109.539812-12.024871 149.820214L371.157763 796.383956c-40.278355 40.229237-105.761766 40.229237-146.042167 0l-3.229554-3.231601c-40.281425-40.278355-40.281425-105.809861 0-145.991002l75.93546-75.909877c9.742898-7.733125 15.997346-19.668968 15.997346-33.072233 0-23.312962-18.898419-42.211381-42.211381-42.211381-8.797363 0-16.963347 2.693342-23.725354 7.297197-0.021489-0.045025-0.044002-0.088004-0.066515-0.134053l-0.809435 0.757247c-2.989077 2.148943-5.691629 4.669346-8.025791 7.510044l-78.913281 73.841775c-74.178443 74.229608-74.178443 195.632609 0 269.758863l3.203972 3.202948c74.178443 74.127278 195.529255 74.127278 269.707698 0l171.829484-171.880649c74.076112-74.17435 80.357166-191.184297 6.282077-265.311575L607.934444 417.856853z"></path><path d="M855.61957 165.804257l-3.203972-3.203972c-74.17742-74.178443-195.528232-74.178443-269.706675 0L410.87944 334.479911c-74.178443 74.178443-78.263481 181.296089-4.085038 255.522628l3.152806 3.104711c3.368724 3.367701 6.865361 6.54302 10.434653 9.588379 2.583848 2.885723 5.618974 5.355985 8.992815 7.309476 0.025583 0.020466 0.052189 0.041956 0.077771 0.062422l0.011256-0.010233c5.377474 3.092431 11.608386 4.870938 18.257829 4.870938 20.263509 0 36.68962-16.428158 36.68962-36.68962 0-5.719258-1.309832-11.132548-3.645017-15.95846l0 0c-4.850471-10.891048-13.930267-17.521049-20.210297-23.802102l-3.15383-3.102664c-40.278355-40.278355-24.982998-98.79612 15.295358-139.074476l171.930791-171.830507c40.179095-40.280402 105.685018-40.280402 145.965419 0l3.206018 3.152806c40.279379 40.281425 40.279379 105.838513 0 146.06775l-75.686796 75.737962c-10.296507 7.628748-16.97358 19.865443-16.97358 33.662681 0 23.12365 18.745946 41.87062 41.87062 41.87062 8.048303 0 15.563464-2.275833 21.944801-6.211469 0.048095 0.081864 0.093121 0.157589 0.141216 0.240477l1.173732-1.083681c3.616364-2.421142 6.828522-5.393847 9.529027-8.792247l79.766718-73.603345C929.798013 361.334535 929.798013 239.981676 855.61957 165.804257z"></path></svg>`;
|
||||||
|
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<Range<Decoration>> = [];
|
||||||
|
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<string, string>,
|
||||||
|
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<string, string>;
|
||||||
|
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
|
||||||
|
];
|
@@ -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) {
|
private charBefore(str: string, index: number) {
|
||||||
return str.slice(findClusterBreak(str, index, false), index)
|
return str.slice(findClusterBreak(str, index, false), index)
|
||||||
}
|
}
|
||||||
@@ -117,13 +125,24 @@ export class CustomSearchPanel {
|
|||||||
const cursorPos = state.selection.main.head;
|
const cursorPos = state.selection.main.head;
|
||||||
let query = getSearchQuery(state);
|
let query = getSearchQuery(state);
|
||||||
|
|
||||||
|
|
||||||
if (query.regexp) {
|
if (query.regexp) {
|
||||||
this.regexCursor = new RegExpCursor(state.doc, query.search)
|
try {
|
||||||
this.searchCursor = undefined;
|
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 {
|
else {
|
||||||
|
|
||||||
let cursor = new SearchCursor(state.doc, query.search);
|
let cursor = new SearchCursor(state.doc, query.search);
|
||||||
if (cursor !== this.searchCursor) {
|
if (cursor !== this.searchCursor) {
|
||||||
this.searchCursor = cursor;
|
this.searchCursor = cursor;
|
||||||
@@ -148,20 +167,24 @@ export class CustomSearchPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (this.regexCursor) {
|
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) {
|
if (!this.regexCursor.done) {
|
||||||
this.regexCursor.next();
|
const { from, to, match } = this.regexCursor.value;
|
||||||
|
|
||||||
if (!this.regexCursor.done) {
|
if (!query.wholeWord || matchWord(from, to, match)) {
|
||||||
const { from, to, match } = this.regexCursor.value;
|
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) {
|
if (this.matches.length === 0) {
|
||||||
this.updateMatchCount();
|
this.updateMatchCount();
|
||||||
|
this.setSearchFieldError(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Find the match closest to the current cursor
|
// Find the match closest to the current cursor
|
||||||
@@ -183,7 +207,7 @@ export class CustomSearchPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.updateMatchCount();
|
this.updateMatchCount();
|
||||||
|
this.setSearchFieldError(false);
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const match = this.matches[this.currentMatch];
|
const match = this.matches[this.currentMatch];
|
||||||
@@ -197,19 +221,24 @@ export class CustomSearchPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
commit() {
|
commit() {
|
||||||
const newQuery = new SearchQuery({
|
try {
|
||||||
search: this.searchField.value,
|
const newQuery = new SearchQuery({
|
||||||
replace: this.replaceField.value,
|
search: this.searchField.value,
|
||||||
caseSensitive: this.matchCase,
|
replace: this.replaceField.value,
|
||||||
regexp: this.useRegex,
|
caseSensitive: this.matchCase,
|
||||||
wholeWord: this.matchWord,
|
regexp: this.useRegex,
|
||||||
})
|
wholeWord: this.matchWord,
|
||||||
|
|
||||||
let query = getSearchQuery(this.view.state)
|
|
||||||
if (!newQuery.eq(query)) {
|
|
||||||
this.view.dispatch({
|
|
||||||
effects: setSearchQuery.of(newQuery)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -54,7 +54,6 @@ export const deleteCharacterFowards: Command = (view) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const showSearchVisibilityCommand: Command = (view) => {
|
export const showSearchVisibilityCommand: Command = (view) => {
|
||||||
console.log("SHOW");
|
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
effects: SearchVisibilityEffect.of(true) // Dispatch the effect to show the search
|
effects: SearchVisibilityEffect.of(true) // Dispatch the effect to show the search
|
||||||
});
|
});
|
||||||
|
@@ -179,6 +179,10 @@ const lightTheme: Theme = {
|
|||||||
".find-input:focus, .replace-input:focus": {
|
".find-input:focus, .replace-input:focus": {
|
||||||
borderColor: "var(--cm-caret, #1e51db)",
|
borderColor: "var(--cm-caret, #1e51db)",
|
||||||
},
|
},
|
||||||
|
".find-input.error": {
|
||||||
|
borderColor: "#ff4444 !important",
|
||||||
|
backgroundColor: "#fff5f5 !important",
|
||||||
|
},
|
||||||
".search-controls div:hover": {
|
".search-controls div:hover": {
|
||||||
backgroundColor: "var(--cm-gutter-foreground, #e1e1e1)"
|
backgroundColor: "var(--cm-gutter-foreground, #e1e1e1)"
|
||||||
},
|
},
|
||||||
@@ -215,6 +219,10 @@ const darkTheme = {
|
|||||||
".find-input:focus, .replace-input:focus": {
|
".find-input:focus, .replace-input:focus": {
|
||||||
borderColor: "var(--cm-caret, #1e51db)",
|
borderColor: "var(--cm-caret, #1e51db)",
|
||||||
},
|
},
|
||||||
|
".find-input.error": {
|
||||||
|
borderColor: "#ff6b6b !important",
|
||||||
|
backgroundColor: "#3d2626 !important",
|
||||||
|
},
|
||||||
".search-controls div:hover": {
|
".search-controls div:hover": {
|
||||||
backgroundColor: "var(--cm-gutter-foreground, #3c3c3c)"
|
backgroundColor: "var(--cm-gutter-foreground, #3c3c3c)"
|
||||||
},
|
},
|
||||||
|
Reference in New Issue
Block a user