一些检查失败了
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
263 行
8.4 KiB
TypeScript
263 行
8.4 KiB
TypeScript
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;
|