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>, createNote?: (title: string) => Promise<{ id: string; title: string } | null>, ): CompletionSource => { return async (context: CompletionContext): Promise => { 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;