feat: Initial clean release of CHM
一些检查失败了
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
一些检查失败了
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
这个提交包含在:
@@ -0,0 +1,262 @@
|
||||
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;
|
||||
在新工单中引用
屏蔽一个用户