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
@@ -197,9 +197,12 @@ export async function flush(path: string, keepalive = false): Promise<void> {
|
|||||||
const seq = s.seq
|
const seq = s.seq
|
||||||
const baseRev = tab.rev
|
const baseRev = tab.rev
|
||||||
s.inflight = (async () => {
|
s.inflight = (async () => {
|
||||||
|
// 草稿与请求并行:页面关闭时等待 IndexedDB 写完再发请求,请求往往来不及发出
|
||||||
|
// 草稿只是兜底,写入失败不影响保存本身
|
||||||
|
const draftSaved = drafts.put({ path, content, baseRev, updatedAt: Date.now() }).catch((err) => console.warn('写入本地草稿失败', err))
|
||||||
try {
|
try {
|
||||||
await drafts.put({ path, content, baseRev, updatedAt: Date.now() })
|
|
||||||
const rev = await api.writeFile(path, content, baseRev, keepalive)
|
const rev = await api.writeFile(path, content, baseRev, keepalive)
|
||||||
|
await draftSaved
|
||||||
tab.rev = rev
|
tab.rev = rev
|
||||||
tab.saveError = undefined
|
tab.saveError = undefined
|
||||||
s.savedSeq = seq
|
s.savedSeq = seq
|
||||||
@@ -597,9 +600,11 @@ export async function startWorkspace() {
|
|||||||
() => [ws.tabs.map((t) => t.path), ws.active] as const,
|
() => [ws.tabs.map((t) => t.path), ws.active] as const,
|
||||||
([paths, active]) => localStorage.setItem(TABS_KEY, JSON.stringify({ paths, active })),
|
([paths, active]) => localStorage.setItem(TABS_KEY, JSON.stringify({ paths, active })),
|
||||||
)
|
)
|
||||||
|
// 页面隐藏/关闭前尽力发出待保存的修改(部分浏览器关闭标签页时只触发 pagehide)
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'hidden') void flushAll(true)
|
if (document.visibilityState === 'hidden') void flushAll(true)
|
||||||
})
|
})
|
||||||
|
window.addEventListener('pagehide', () => void flushAll(true))
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -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 { markdownKeymap, markdownLanguage } from '@codemirror/lang-markdown'
|
||||||
import { EditorState, Prec, type Extension } from '@codemirror/state'
|
import { EditorState, Prec, type Extension } from '@codemirror/state'
|
||||||
import { drawSelection, EditorView, keymap, type ViewUpdate } from '@codemirror/view'
|
import { drawSelection, EditorView, keymap, type ViewUpdate } from '@codemirror/view'
|
||||||
@@ -31,7 +31,9 @@ export function createEditorState(opts: EditorStateOptions): EditorState {
|
|||||||
livePreview,
|
livePreview,
|
||||||
codeBlockHighlight,
|
codeBlockHighlight,
|
||||||
tableEditing,
|
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' }),
|
EditorView.contentAttributes.of({ spellcheck: 'false', autocapitalize: 'sentences' }),
|
||||||
]
|
]
|
||||||
if (opts.resolveImage) extensions.push(resolveImageUrl.of(opts.resolveImage))
|
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({
|
return EditorView.domEventHandlers({
|
||||||
paste(e, view) {
|
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
|
if (!insert(view, files)) return false
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return true
|
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 { isolateHistory, redo, undo } from '@codemirror/commands'
|
||||||
import { syntaxTree } from '@codemirror/language'
|
import { syntaxTree } from '@codemirror/language'
|
||||||
import { StateField, type EditorState, type Range } from '@codemirror/state'
|
import { StateEffect, StateField, type EditorState, type Range } from '@codemirror/state'
|
||||||
import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view'
|
import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from '@codemirror/view'
|
||||||
import type { SyntaxNode } from '@lezer/common'
|
import type { SyntaxNode } from '@lezer/common'
|
||||||
import { renderInlineMarkdown } from '../markdown/html'
|
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 {
|
function buildTables(state: EditorState): DecorationSet {
|
||||||
const out: Range<Decoration>[] = []
|
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) {
|
for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) {
|
||||||
if (node.name !== 'Table') continue
|
if (node.name !== 'Table') continue
|
||||||
@@ -417,11 +449,22 @@ function buildTables(state: EditorState): DecorationSet {
|
|||||||
return Decoration.set(out)
|
return Decoration.set(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tableEditing = StateField.define<DecorationSet>({
|
export const tableField = StateField.define<DecorationSet>({
|
||||||
create: buildTables,
|
create: buildTables,
|
||||||
update(value, tr) {
|
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
|
return value
|
||||||
},
|
},
|
||||||
provide: (f) => EditorView.decorations.from(f),
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export { setEditorFocus }
|
||||||
|
|
||||||
|
export const tableEditing = [editorFocused, tableField, focusTracker]
|
||||||
Reference in new issue
Block a user