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:
1 parent
b3f05b501a
commit
a11e0ec498
6 files changed
+103
-10
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
Reference in new issue
Block a user