文件
mynode/packages/editor/CodeMirror/extensions/wikiLinkAutocompleteExtension.ts
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

263 行
8.4 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

import {
autocompletion,
CompletionContext,
CompletionResult,
CompletionSource,
startCompletion,
completionKeymap,
} from '@codemirror/autocomplete';
import { Extension } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { RenderedContentContext } from './rendering/types';
export interface WikiLinkMatch {
query: string;
matchLength: number;
trigger: '[[' | '@';
}
export interface WikiNoteItem {
id: string;
title: string;
parentTitle?: string;
section?: string;
}
/**
* 匹配光标前的 [[ / 【【 或 @ / @ 触发词及后续跟随的关键词
* 支持:
* 1. [[关键词 或 【【关键词(全角中文括号)
* 2. @关键词 或 @关键词(全角中文@,前置必须为行首、空格或常用中文/西文开标点,严格防止误伤普通邮箱如 test@domain.com)
*/
export const matchWikiLinkTrigger = (textBefore: string): WikiLinkMatch | null => {
// 匹配 [[ 或 【【 语法
const bracketMatch = textBefore.match(/(?:\[\[|【【)([^\]】\n]*)$/);
if (bracketMatch) {
return {
query: bracketMatch[1],
matchLength: bracketMatch[0].length,
trigger: '[[',
};
}
// 匹配 @ 或 @ 语法(必须位于行首或前置为空格/常见开标点)
const atMatch = textBefore.match(/(?:^|[\s([{\'"‘“(《「『【〔〖〘〚])([@@])([^\s@@\n)}\]\'"’”》」』】〕〗〙㛛]*)$/);
if (atMatch) {
const fullMatch = atMatch[0];
const atChar = atMatch[1];
const atOffset = fullMatch.lastIndexOf(atChar);
return {
query: atMatch[2],
matchLength: fullMatch.length - atOffset,
trigger: '@',
};
}
return null;
};
/**
* 创建双链与 @ 快速提及补全数据源
*/
export const createWikiLinkCompletionSource = (
searchNotes: (query: string) => Promise<Array<WikiNoteItem>>,
createNote?: (title: string) => Promise<{ id: string; title: string } | null>,
): CompletionSource => {
return async (context: CompletionContext): Promise<CompletionResult | null> => {
const line = context.state.doc.lineAt(context.pos);
const textBefore = context.state.doc.sliceString(line.from, context.pos);
const match = matchWikiLinkTrigger(textBefore);
if (!match) return null;
const from = context.pos - match.matchLength;
const notes = await searchNotes(match.query);
if (!notes || notes.length === 0) return null;
return {
from,
options: notes.map((note, index) => {
const noteTitle = note.title || '无标题笔记';
const isCreate = note.id.startsWith('__CREATE_');
const isJournal = note.parentTitle?.includes('日志') || note.parentTitle?.includes('日记') || note.title.match(/^\d{4}-\d{2}-\d{2}/);
let optionType = 'text';
if (isCreate) optionType = 'create';
else if (isJournal) optionType = 'journal';
return {
label: noteTitle,
detail: note.parentTitle ? (note.parentTitle.startsWith('📅') || note.parentTitle.startsWith('📁') || note.parentTitle.startsWith('✨') ? note.parentTitle : `📁 ${note.parentTitle}`) : undefined,
type: optionType,
section: note.section,
boost: 100 - index,
apply: (view, _completion, applyFrom, applyTo) => {
if (isCreate && createNote) {
void (async () => {
const created = await createNote(noteTitle);
const targetId = created ? created.id : '';
const targetTitle = created ? created.title : noteTitle;
if (targetId) {
const replacement = `[${targetTitle}](:/${targetId})`;
view.dispatch({
changes: { from: applyFrom, to: applyTo, insert: replacement },
selection: { anchor: applyFrom + replacement.length },
userEvent: 'input.complete',
});
}
})();
} else {
const replacement = `[${noteTitle}](:/${note.id})`;
view.dispatch({
changes: { from: applyFrom, to: applyTo, insert: replacement },
selection: { anchor: applyFrom + replacement.length },
userEvent: 'input.complete',
});
}
},
};
}),
filter: false,
};
};
};
const wikiLinkAutocompleteExtension = (context: RenderedContentContext): Extension => {
if (!context.searchNotes) return [];
const source = createWikiLinkCompletionSource(
context.searchNotes.bind(context),
context.createNote ? context.createNote.bind(context) : undefined,
);
return [
autocompletion({
override: [source],
activateOnTyping: true,
maxRenderedOptions: 15,
}),
keymap.of(completionKeymap),
// 监听普通输入法字符触发
EditorView.inputHandler.of((view, _from, _to, text) => {
if (text === '@' || text === '@' || text === '[' || text === '【') {
setTimeout(() => {
startCompletion(view);
}, 20);
}
return false;
}),
// 监听中文输入法完成(compositionend),输入例如「今天」确认后即刻触发补全
EditorView.domEventHandlers({
compositionend: (_event, view) => {
setTimeout(() => {
const pos = view.state.selection.main.head;
const line = view.state.doc.lineAt(pos);
const textBefore = view.state.doc.sliceString(line.from, pos);
if (matchWikiLinkTrigger(textBefore)) {
startCompletion(view);
}
}, 40);
},
}),
// 监听文本变化(包括拼音输入法、粘贴、撤销等)
EditorView.updateListener.of(update => {
if (update.docChanged) {
const pos = update.state.selection.main.head;
const line = update.state.doc.lineAt(pos);
const textBefore = update.state.doc.sliceString(line.from, pos);
if (matchWikiLinkTrigger(textBefore)) {
setTimeout(() => {
startCompletion(update.view);
}, 20);
}
}
}),
// 现代化 macOS / Apple Notes / Raycast 风格浮窗样式设计
EditorView.theme({
'.cm-tooltip-autocomplete': {
border: '1px solid var(--joplin-divider-color, rgba(0, 0, 0, 0.12))',
borderRadius: '12px',
boxShadow: '0 18px 42px rgba(0, 0, 0, 0.15), 0 3px 10px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(0, 0, 0, 0.03)',
padding: '6px',
backgroundColor: 'var(--joplin-background-color3, #ffffff)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
zIndex: '1000 !important',
minWidth: '280px',
maxWidth: '440px',
fontFamily: '-apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
},
'.cm-tooltip-autocomplete ul': {
maxHeight: '300px',
listStyle: 'none',
margin: '0',
padding: '0',
overflowY: 'auto',
},
'.cm-tooltip-autocomplete ul li': {
borderRadius: '8px',
padding: '7px 10px',
margin: '1px 0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '13px',
cursor: 'pointer',
lineHeight: '1.4',
transition: 'background-color 0.12s ease',
},
'.cm-tooltip-autocomplete ul li[aria-selected]': {
backgroundColor: 'var(--joplin-selected-color2, #007aff) !important',
color: '#ffffff !important',
},
'.cm-tooltip-autocomplete ul li[aria-selected] .cm-completionDetail': {
backgroundColor: 'rgba(255, 255, 255, 0.22) !important',
color: '#ffffff !important',
},
'.cm-tooltip-autocomplete ul li[aria-selected] .cm-completionMatchedText': {
color: '#ffffff !important',
textDecoration: 'underline',
},
'.cm-tooltip-autocomplete .cm-completionLabel': {
fontWeight: '500',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: '1',
},
'.cm-tooltip-autocomplete .cm-completionDetail': {
fontStyle: 'normal',
fontSize: '11px',
fontWeight: '500',
padding: '2px 7px',
borderRadius: '6px',
backgroundColor: 'var(--joplin-background-color-hover3, rgba(0, 0, 0, 0.05))',
color: 'var(--joplin-color-faded, #666)',
marginLeft: '8px',
whiteSpace: 'nowrap',
flexShrink: '0',
},
'.cm-tooltip-autocomplete completion-section': {
display: 'block',
fontSize: '11px',
fontWeight: '600',
color: 'var(--joplin-color-faded, #888)',
padding: '6px 8px 3px 8px',
letterSpacing: '0.03em',
},
'.cm-tooltip-autocomplete::after': {
content: '"↑↓ 切换 · ↵ 引用 · Esc 关闭"',
display: 'block',
fontSize: '10.5px',
color: 'var(--joplin-color-faded, #999)',
borderTop: '1px solid var(--joplin-divider-color, rgba(0, 0, 0, 0.08))',
padding: '6px 6px 2px 6px',
marginTop: '4px',
textAlign: 'right',
letterSpacing: '0.02em',
},
}),
];
};
export default wikiLinkAutocompleteExtension;