From a11e0ec498c6a129111468a423cc44e96351ad96 Mon Sep 17 00:00:00 2001 From: eezd Date: Fri, 16 Oct 2026 19:19:16 +0800 Subject: [PATCH] Fix redo shortcut on Windows, table rendering at doc start, and save on tab close - Bind Mod-Shift-z to redo on all platforms (CodeMirror only binds it on Linux/macOS) - Render tables based on editor focus so a note starting with a table is not shown as source - Send the save request without waiting for the IndexedDB draft write, and flush on pagehide; Firefox could drop the last edit when a tab was closed right after typing - Fall back to clipboardData.items when pasting images with an empty files list --- web/src/app/workspace.ts | 7 ++++- web/src/editor/index.ts | 6 ++-- web/src/editor/keymap.test.ts | 20 +++++++++++++ web/src/editor/paste.ts | 6 +++- web/src/editor/table.test.ts | 19 ++++++++++++ web/src/editor/table.ts | 55 +++++++++++++++++++++++++++++++---- 6 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 web/src/editor/keymap.test.ts create mode 100644 web/src/editor/table.test.ts diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts index c250f73..414920f 100644 --- a/web/src/app/workspace.ts +++ b/web/src/app/workspace.ts @@ -197,9 +197,12 @@ export async function flush(path: string, keepalive = false): Promise { const seq = s.seq const baseRev = tab.rev s.inflight = (async () => { + // 草稿与请求并行:页面关闭时等待 IndexedDB 写完再发请求,请求往往来不及发出 + // 草稿只是兜底,写入失败不影响保存本身 + const draftSaved = drafts.put({ path, content, baseRev, updatedAt: Date.now() }).catch((err) => console.warn('写入本地草稿失败', err)) try { - await drafts.put({ path, content, baseRev, updatedAt: Date.now() }) const rev = await api.writeFile(path, content, baseRev, keepalive) + await draftSaved tab.rev = rev tab.saveError = undefined s.savedSeq = seq @@ -597,9 +600,11 @@ export async function startWorkspace() { () => [ws.tabs.map((t) => t.path), ws.active] as const, ([paths, active]) => localStorage.setItem(TABS_KEY, JSON.stringify({ paths, active })), ) + // 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') void flushAll(true) }) + window.addEventListener('pagehide', () => void flushAll(true)) window.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault() diff --git a/web/src/editor/index.ts b/web/src/editor/index.ts index 4a440dd..9722e6a 100644 --- a/web/src/editor/index.ts +++ b/web/src/editor/index.ts @@ -1,4 +1,4 @@ -import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { defaultKeymap, history, historyKeymap, indentWithTab, redo } from '@codemirror/commands' import { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown' import { EditorState, Prec, type Extension } from '@codemirror/state' import { drawSelection, EditorView, keymap, type ViewUpdate } from '@codemirror/view' @@ -31,7 +31,9 @@ export function createEditorState(opts: EditorStateOptions): EditorState { livePreview, codeBlockHighlight, tableEditing, - keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]), + // CodeMirror 只在 Linux/Mac 上把 Ctrl/Cmd+Shift+Z 绑定为重做,Windows 上会退化成撤销; + // 与 Typora、VS Code 保持一致,所有平台都支持 + keymap.of([indentWithTab, { key: 'Mod-Shift-z', run: redo, preventDefault: true }, ...defaultKeymap, ...historyKeymap]), EditorView.contentAttributes.of({ spellcheck: 'false', autocapitalize: 'sentences' }), ] if (opts.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage)) diff --git a/web/src/editor/keymap.test.ts b/web/src/editor/keymap.test.ts new file mode 100644 index 0000000..d0c03ea --- /dev/null +++ b/web/src/editor/keymap.test.ts @@ -0,0 +1,20 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' + +describe('快捷键', () => { + it('Windows 上 Ctrl+Shift+Z 为重做', async () => { + // CodeMirror 在模块加载时读取平台,需先设置再导入 + Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true }) + const { undo } = await import('@codemirror/commands') + const { EditorView, runScopeHandlers } = await import('@codemirror/view') + const { createEditorState } = await import('./index') + const view = new EditorView({ parent: document.body, state: createEditorState({ doc: '' }) }) + view.dispatch({ changes: { from: 0, insert: 'abc' } }) + undo(view) + expect(view.state.doc.toString()).toBe('') + const event = new KeyboardEvent('keydown', { key: 'Z', code: 'KeyZ', keyCode: 90, ctrlKey: true, shiftKey: true }) + expect(runScopeHandlers(view, event, 'editor')).toBe(true) + expect(view.state.doc.toString()).toBe('abc') + view.destroy() + }) +}) diff --git a/web/src/editor/paste.ts b/web/src/editor/paste.ts index ce94c9f..4f7469f 100644 --- a/web/src/editor/paste.ts +++ b/web/src/editor/paste.ts @@ -42,7 +42,11 @@ export function pasteImages(opts: PasteImagesOptions): Extension { } return EditorView.domEventHandlers({ paste(e, view) { - const files = [...(e.clipboardData?.files ?? [])] + // 个别浏览器/来源只在 items 中提供图片 + let files = [...(e.clipboardData?.files ?? [])] + if (!files.length) { + files = [...(e.clipboardData?.items ?? [])].filter((i) => i.kind === 'file').map((i) => i.getAsFile()).filter((f) => f !== null) + } if (!insert(view, files)) return false e.preventDefault() return true diff --git a/web/src/editor/table.test.ts b/web/src/editor/table.test.ts new file mode 100644 index 0000000..962e432 --- /dev/null +++ b/web/src/editor/table.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { createEditorState } from './index' +import { setEditorFocus, tableField } from './table' + +const doc = '| a | b |\n| --- | --- |\n| 1 | 2 |\n\n正文' + +describe('表格渲染', () => { + it('光标在文首但编辑器未聚焦时,开头的表格仍渲染', () => { + const state = createEditorState({ doc }) + expect(state.selection.main.head).toBe(0) + expect(state.field(tableField).size).toBe(1) + }) + it('编辑器聚焦且光标在表格内时显示源码,移出后恢复渲染', () => { + let state = createEditorState({ doc }).update({ effects: setEditorFocus.of(true) }).state + expect(state.field(tableField).size).toBe(0) + state = state.update({ selection: { anchor: doc.length } }).state + expect(state.field(tableField).size).toBe(1) + }) +}) diff --git a/web/src/editor/table.ts b/web/src/editor/table.ts index f301a3c..3e40c0b 100644 --- a/web/src/editor/table.ts +++ b/web/src/editor/table.ts @@ -1,7 +1,7 @@ import { isolateHistory, redo, undo } from '@codemirror/commands' import { syntaxTree } from '@codemirror/language' -import { StateField, type EditorState, type Range } from '@codemirror/state' -import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view' +import { StateEffect, StateField, type EditorState, type Range } from '@codemirror/state' +import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from '@codemirror/view' import type { SyntaxNode } from '@lezer/common' import { renderInlineMarkdown } from '../markdown/html' @@ -402,10 +402,42 @@ function bindCell(view: EditorView, wrap: HTMLElement, cell: HTMLElement) { // ---- 装饰 ---- -// 块级替换装饰跨越多行,只能由 StateField 提供,不能来自 ViewPlugin +// StateField 拿不到视图的焦点状态,由视图插件把焦点变化同步进状态 +const setEditorFocus = StateEffect.define() + +const editorFocused = StateField.define({ + create: () => false, + update(value, tr) { + for (const e of tr.effects) if (e.is(setEditorFocus)) value = e.value + return value + }, +}) + +const focusTracker = ViewPlugin.fromClass( + class { + constructor(readonly view: EditorView) { + this.sync() + } + update(u: ViewUpdate) { + if (u.focusChanged) this.sync() + } + // 不能在 update 中同步 dispatch,推迟到微任务 + sync() { + queueMicrotask(() => { + const focused = this.view.hasFocus + if (this.view.state.field(editorFocused, false) !== undefined && this.view.state.field(editorFocused) !== focused) { + this.view.dispatch({ effects: setEditorFocus.of(focused) }) + } + }) + } + }, +) + +// 块级替换装饰跨越多行,只能由 StateField 提供,不能来自 ViewPlugin。 +// 与其他语法一致:编辑器获得焦点且光标在表格内时才显示源码(打开笔记时光标默认在文首,不应因此展开开头的表格) function buildTables(state: EditorState): DecorationSet { const out: Range[] = [] - const sel = state.selection.ranges + const sel = state.field(editorFocused) ? state.selection.ranges : [] // 只渲染顶层表格;引用、列表内的表格以源码显示 for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) { if (node.name !== 'Table') continue @@ -417,11 +449,22 @@ function buildTables(state: EditorState): DecorationSet { return Decoration.set(out) } -export const tableEditing = StateField.define({ +export const tableField = StateField.define({ create: buildTables, update(value, tr) { - if (tr.docChanged || tr.selection || syntaxTree(tr.startState) !== syntaxTree(tr.state)) return buildTables(tr.state) + if ( + tr.docChanged || + tr.selection || + tr.effects.some((e) => e.is(setEditorFocus)) || + syntaxTree(tr.startState) !== syntaxTree(tr.state) + ) { + return buildTables(tr.state) + } return value }, provide: (f) => EditorView.decorations.from(f), }) + +export { setEditorFocus } + +export const tableEditing = [editorFocused, tableField, focusTracker]