artemislin_memo-lite/editor.ts
ArtemisLin 0aee640f36 N7 Task 4: 草稿暂存 + 文件流 vault 全局标签补全 + #tag 高亮
- 草稿暂存(方案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>
2026-07-15 03:50:15 -04:00

157 lines
4.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}