mirror of
https://github.com/artemislin/memo-lite.git
synced 2026-07-22 08:29:05 +00:00
- 草稿暂存(方案A): destroy 时未保存标题/正文存 fileFlow.draft,重开自动恢复; editor 未创建时不覆写已落盘草稿(修审查发现的高危数据丢失);保存成功即清草稿 - fileFlow 设置深合并: 旧 data.json 缺新字段自动回填,断开 DEFAULT_FILE_FLOW 共享引用 - 标签补全: TagSuggestionMenu 从 view.ts 抽到 tag-suggestion.ts(修循环依赖), show 加光标锚点参数、三段式命中高亮、destroy 精简;文件流接入 metadataCache.getTags() 全 vault 标签源(嵌套完整路径、频次排序、前缀优先包含匹配、 空前缀出常用8条)、debounce 150ms、方向键导航、隐藏态守卫、菜单滚动条误收修复 - 文件流正文 tagHighlight 开启(谷雨拍板推翻 spec §5) - 审查: 20 agent,17 条发现,4 项修复(1 高危数据丢失、1 中危悬空定时器) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
157 lines
4.1 KiB
TypeScript
157 lines
4.1 KiB
TypeScript
import { EditorView, keymap, placeholder } from '@codemirror/view';
|
||
import { EditorState, EditorSelection, Prec } from '@codemirror/state';
|
||
import { history, historyKeymap, defaultKeymap } from '@codemirror/commands';
|
||
import { tagHighlightPlugin } from './cm-tag-highlight';
|
||
|
||
export type MemoEditorArrowKey =
|
||
| 'ArrowUp'
|
||
| 'ArrowDown'
|
||
| 'Enter'
|
||
| 'Tab'
|
||
| 'Escape';
|
||
|
||
export interface MemoEditorOptions {
|
||
parent: HTMLElement;
|
||
placeholder?: string;
|
||
initialValue?: string;
|
||
onChange?: (value: string) => void;
|
||
onSubmit?: () => void;
|
||
// Return true to consume the key (e.g. when a suggestion menu is open).
|
||
// Return false to let CodeMirror handle the key normally.
|
||
onArrowKey?: (key: MemoEditorArrowKey) => boolean;
|
||
// 默认 true;传 false 可关闭 #tag 高亮
|
||
tagHighlight?: boolean;
|
||
}
|
||
|
||
const memoEditorTheme = EditorView.theme({
|
||
'&': {
|
||
border: '1px solid var(--background-modifier-border)',
|
||
borderRadius: '5px',
|
||
backgroundColor: 'var(--background-primary)',
|
||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.05)',
|
||
minHeight: '100px',
|
||
maxHeight: '200px',
|
||
fontSize: '13px',
|
||
},
|
||
'&.cm-focused': { outline: 'none' },
|
||
'.cm-scroller': {
|
||
fontFamily: 'inherit',
|
||
overflow: 'auto',
|
||
},
|
||
'.cm-content': {
|
||
padding: '8px',
|
||
minHeight: '80px',
|
||
caretColor: 'var(--text-normal)',
|
||
color: 'var(--text-normal)',
|
||
lineHeight: '1.2',
|
||
},
|
||
'.cm-line': { padding: '0', lineHeight: '1.2' },
|
||
'.cm-placeholder': { color: 'var(--text-muted)' },
|
||
// 标签高亮(cm-tag-highlight 给 #tag 加 .memo-lite-tag class)
|
||
'.cm-content .memo-lite-tag': {
|
||
color: '#40c463',
|
||
backgroundColor: 'rgba(64, 196, 99, 0.1)',
|
||
fontWeight: '500',
|
||
borderRadius: '4px',
|
||
padding: '0 2px',
|
||
},
|
||
});
|
||
|
||
export class MemoEditor {
|
||
readonly view: EditorView;
|
||
|
||
constructor(opts: MemoEditorOptions) {
|
||
// 注:opts.onSubmit 接口保留,但 Ctrl+Enter 触发被 Obsidian 全局 hotkey 拦截,
|
||
// 已知问题记入 HANDOFF.md,本轮不修。菜单导航键走下面的 CM6 keymap。
|
||
const customKeys = Prec.highest(keymap.of([
|
||
{
|
||
key: 'ArrowUp',
|
||
run: () => opts.onArrowKey?.('ArrowUp') ?? false,
|
||
},
|
||
{
|
||
key: 'ArrowDown',
|
||
run: () => opts.onArrowKey?.('ArrowDown') ?? false,
|
||
},
|
||
{
|
||
key: 'Enter',
|
||
run: () => opts.onArrowKey?.('Enter') ?? false,
|
||
},
|
||
{
|
||
key: 'Tab',
|
||
run: () => opts.onArrowKey?.('Tab') ?? false,
|
||
},
|
||
{
|
||
key: 'Escape',
|
||
run: () => opts.onArrowKey?.('Escape') ?? false,
|
||
},
|
||
]));
|
||
|
||
const changeListener = EditorView.updateListener.of((update) => {
|
||
if (update.docChanged && opts.onChange) {
|
||
opts.onChange(update.state.doc.toString());
|
||
}
|
||
});
|
||
|
||
const extensions = [
|
||
history(),
|
||
customKeys,
|
||
keymap.of(historyKeymap),
|
||
keymap.of(defaultKeymap),
|
||
placeholder(opts.placeholder ?? ''),
|
||
EditorView.lineWrapping,
|
||
changeListener,
|
||
...(opts.tagHighlight === false ? [] : [tagHighlightPlugin]),
|
||
memoEditorTheme,
|
||
];
|
||
|
||
this.view = new EditorView({
|
||
parent: opts.parent,
|
||
state: EditorState.create({
|
||
doc: opts.initialValue ?? '',
|
||
extensions,
|
||
}),
|
||
});
|
||
}
|
||
|
||
getValue(): string {
|
||
return this.view.state.doc.toString();
|
||
}
|
||
|
||
setValue(value: string): void {
|
||
this.view.dispatch({
|
||
changes: { from: 0, to: this.view.state.doc.length, insert: value },
|
||
});
|
||
}
|
||
|
||
clear(): void {
|
||
this.setValue('');
|
||
}
|
||
|
||
focus(): void {
|
||
this.view.focus();
|
||
}
|
||
|
||
getCursorPos(): number {
|
||
return this.view.state.selection.main.head;
|
||
}
|
||
|
||
setCursorPos(pos: number): void {
|
||
const clamped = Math.max(0, Math.min(pos, this.view.state.doc.length));
|
||
this.view.dispatch({ selection: EditorSelection.cursor(clamped) });
|
||
}
|
||
|
||
replaceRange(from: number, to: number, insert: string): void {
|
||
this.view.dispatch({
|
||
changes: { from, to, insert },
|
||
selection: EditorSelection.cursor(from + insert.length),
|
||
});
|
||
}
|
||
|
||
getDom(): HTMLElement {
|
||
return this.view.dom;
|
||
}
|
||
|
||
destroy(): void {
|
||
this.view.destroy();
|
||
}
|
||
}
|