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
This commit is contained in:
eezd committed 2026-10-16 19:19:16 +08:00
1 parent b3f05b501a
commit a11e0ec498
6 files changed
+103 -10

No files matched your search

+6 -1
View File
@@ -197,9 +197,12 @@ export async function flush(path: string, keepalive = false): Promise<void> {
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()
+4 -2
View File
@@ -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))
+20
View File
@@ -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()
})
})
+5 -1
View File
@@ -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
+19
View File
@@ -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)
})
})
+49 -6
View File
@@ -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<boolean>()
const editorFocused = StateField.define<boolean>({
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<Decoration>[] = []
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<DecorationSet>({
export const tableField = StateField.define<DecorationSet>({
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]