From c0f03e48dc6f94d6dcbd027f72685ec08747596e Mon Sep 17 00:00:00 2001 From: Muse Date: Sat, 14 Nov 2026 16:06:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E6=96=B0=E5=BB=BA=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E8=87=AA=E5=8A=A8=E8=81=9A=E7=84=A6=20+=20=E7=A9=BA?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=8D=A0=E4=BD=8D=20+=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E9=9D=A2=E5=A1=AB=E6=BB=A1=EF=BC=88docs/23=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新建/打开/恢复的标签就绪后自动把光标送进编辑器(弹窗/查找栏打开时 不抢);切回标签恢复锚点时一并聚焦。空文档首段显示「开始输入…」 占位。编辑面改为撑满窗格高度,下方空白处可点击落光标;host 显式 width:100% 防 flex 收缩窄缝。 --- docs/23-new-doc-focus.md | 29 +++++++++++++++++++++++ src/App.tsx | 41 ++++++++++++++++++++++++++------- src/editor/editor.ts | 3 ++- src/editor/plugins.ts | 18 +++++++++++++++ src/styles.css | 10 +++++++- tests/empty-placeholder.test.ts | 29 +++++++++++++++++++++++ 6 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 docs/23-new-doc-focus.md create mode 100644 tests/empty-placeholder.test.ts diff --git a/docs/23-new-doc-focus.md b/docs/23-new-doc-focus.md new file mode 100644 index 0000000..d740973 --- /dev/null +++ b/docs/23-new-doc-focus.md @@ -0,0 +1,29 @@ +# 新建文档无光标修复(2026-11-14) + +## 现象 + +点新建文档进来后没有明显的输入界面、没有输入光标:新标签被创建并激活, +但从没有人把键盘焦点送进编辑器;空文档一片空白,连点哪里能写都看不出。 + +## 修复 + +1. **新建/打开自动聚焦**:新建文档、打开文件(对话框/文件树/Web 选择)、 + 恢复草稿这几条创建路径给标签打 fresh 标记;其编辑器就绪(窗格 onReady) + 或激活生效时自动 focus 并落在文档开头。切回已有标签恢复锚点时也一并 + 聚焦(原先只恢复了选区状态、DOM 焦点不在编辑器里,照样打不了字)。 + 弹窗打开或查找栏打开时不抢焦点(maybeFocusEditor 守卫)。 +2. **空文档占位**:新增 createEmptyPlaceholderPlugin——整篇只剩一段空段落 + 时给它挂 .doc-placeholder,CSS ::before 显示「开始输入…」(颜色取文档 + 墨色的 38% 混淡)。有 tests/empty-placeholder.test.ts 锁定。 +3. **编辑面填满窗格**:.editor-pane-wrap 改弹性列布局,编辑面撑满窗格 + 高度,空/短文档下方的大片区域同样可点击落光标。注意 flex 列里 host + 必须显式 width:100%——margin:auto 会让它按内容收缩,空文档时整列缩成 + 179px 窄缝(实测踩过并已加注释)。 + +## 验证 + +- 无头实测(深色护眼主题):Ctrl+N 后活动元素与选区都在编辑器内、 + 占位文案可见、行列显示 1:1;直接打字落字;鼠标点到内容下方空白处 + 焦点与光标回到文档、可继续输入;几何量测 host 880 居中、PM 784 宽 + 撑满高度。 +- vitest 96 通过 + 2 跳过;tsc 0;构建通过。 diff --git a/src/App.tsx b/src/App.tsx index 63aa597..2670f78 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -124,6 +124,15 @@ export default function App() { const editorRef = useRef(null) const jumpRef = useRef<(text: string) => void>(() => undefined) const anchorApiRef = useRef<{ getAnchor: () => CursorAnchor | null; setCursorByAnchor: (a: CursorAnchor) => void } | null>(null) + // 刚新建/刚打开的标签:其编辑器就绪后应自动获得光标(新建文档进来没有光标是缺陷)。 + const freshFocusRef = useRef>(new Set()) + const findOpenRef = useRef(false) + /** 把键盘焦点送进编辑器;弹窗或查找栏打开时不抢焦点 */ + const maybeFocusEditor = (editor: Editor | null | undefined) => { + if (!editor) return + if (anyModalRef.current || findOpenRef.current) return + focusEditor(editor) + } const sourceApiRef = useRef(null) const sourceModeRef = useRef(sourceMode) sourceModeRef.current = sourceMode @@ -256,6 +265,7 @@ export default function App() { encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom, }) const existing = stateRef.current.tabs.find((t) => samePath(t.path, path)) + if (!existing) freshFocusRef.current.add(created.id) dispatch({ type: 'open', tab: created }) const stat = await statFile(path) if (stat.exists) baselinesRef.current.set(existing?.id ?? created.id, { mtimeMs: stat.mtimeMs, size: stat.size }) @@ -264,7 +274,9 @@ export default function App() { }, [pushRecent]) const newDoc = useCallback(() => { - dispatch({ type: 'open', tab: createTab({ title: '未命名', content: '' }) }) + const created = createTab({ title: '未命名', content: '' }) + freshFocusRef.current.add(created.id) + dispatch({ type: 'open', tab: created }) setSourceMode(false) }, []) @@ -275,7 +287,9 @@ export default function App() { } else { const picked = await pickAndReadWebFile() if (picked) { - dispatch({ type: 'open', tab: createTab({ title: picked.name.replace(/\.(md|markdown|txt)$/i, ''), content: picked.content }) }) + const created = createTab({ title: picked.name.replace(/\.(md|markdown|txt)$/i, ''), content: picked.content }) + freshFocusRef.current.add(created.id) + dispatch({ type: 'open', tab: created }) } } }, [tauriMode, openPath]) @@ -447,6 +461,7 @@ export default function App() { encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom, }) const existing = stateRef.current.tabs.find((t) => samePath(t.path, item.path)) + if (!existing) freshFocusRef.current.add(created.id) dispatch({ type: 'open', tab: created }) const targetId = existing?.id ?? created.id dispatch({ type: 'updateContent', id: targetId, content: item.content }) @@ -457,6 +472,7 @@ export default function App() { } catch { /* 文件已不存在时按未命名草稿恢复 */ } } const created = createTab({ title: item.title, content: '' }) + freshFocusRef.current.add(created.id) dispatch({ type: 'open', tab: created }) dispatch({ type: 'updateContent', id: created.id, content: item.content }) finish() @@ -834,9 +850,13 @@ export default function App() { editorRef.current = api?.editor ?? null jumpRef.current = api?.jumpToHeading ?? (() => undefined) anchorApiRef.current = api ? { getAnchor: api.getAnchor, setCursorByAnchor: api.setCursorByAnchor } : null - if (api && pendingAnchorRef.current) { - api.setCursorByAnchor(pendingAnchorRef.current) - pendingAnchorRef.current = null + if (api) { + const hadAnchor = Boolean(pendingAnchorRef.current) + if (hadAnchor) { + api.setCursorByAnchor(pendingAnchorRef.current as CursorAnchor) + pendingAnchorRef.current = null + } + if (hadAnchor || (activeIdForAlive && freshFocusRef.current.delete(activeIdForAlive))) maybeFocusEditor(api.editor) } }, [activeIdForAlive]) @@ -1071,6 +1091,7 @@ export default function App() { modal || appearanceOpen || exportOpen || pendingClose || conflict || promptSpec || confirmSpec || quitOpen || (recoveryOffer.length > 0 && !recoveryDismissed), ) + findOpenRef.current = findUi.open // 大纲与状态栏计数只随正文变化重算(原先每次光标移动/击键都对全文重算); // 大纲再经 useDeferredValue 让高频输入时延后提取,不堵击键路径。 @@ -1298,9 +1319,13 @@ export default function App() { editorRef.current = api.editor jumpRef.current = api.jumpToHeading anchorApiRef.current = api.editor ? { getAnchor: api.getAnchor, setCursorByAnchor: api.setCursorByAnchor } : null - if (api.editor && pendingAnchorRef.current) { - api.setCursorByAnchor(pendingAnchorRef.current) - pendingAnchorRef.current = null + if (api.editor) { + const hadAnchor = Boolean(pendingAnchorRef.current) + if (hadAnchor) { + api.setCursorByAnchor(pendingAnchorRef.current as CursorAnchor) + pendingAnchorRef.current = null + } + if (hadAnchor || freshFocusRef.current.delete(t.id)) maybeFocusEditor(api.editor) } }} /> diff --git a/src/editor/editor.ts b/src/editor/editor.ts index b6f29be..aba4acd 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -12,7 +12,7 @@ import { clipboard } from '@milkdown/kit/plugin/clipboard' import { indent } from '@milkdown/kit/plugin/indent' import { math } from '@milkdown/plugin-math' import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload' -import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, createPlainTextPastePlugin, createImageDisplayPlugin, selectionInTable } from './plugins' +import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, createPlainTextPastePlugin, createImageDisplayPlugin, createEmptyPlaceholderPlugin, selectionInTable } from './plugins' import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews' export interface CursorInfo { line: number; col: number } @@ -90,6 +90,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string, .use(createCodeBlockTabPlugin()) .use(createTableTabPlugin()) .use(createFocusDimPlugin()) + .use(createEmptyPlaceholderPlugin()) .use(createListItemViewPlugin()) .use(createCodeBlockViewPlugin()) .use(math) diff --git a/src/editor/plugins.ts b/src/editor/plugins.ts index d9afc41..1d31d83 100644 --- a/src/editor/plugins.ts +++ b/src/editor/plugins.ts @@ -210,6 +210,24 @@ export function isFocusModeEnabled() { } const focusKey = new PluginKey('new-markdown-focus') +const emptyPlaceholderKey = new PluginKey('new-markdown-empty-placeholder') + +/** 空文档占位:整篇只有一段空段落时给它挂占位类,CSS ::before 显示「开始输入…」, + * 让新建文档有一个明确的输入界面(否则空页里连光标落点都看不见)。 */ +export function createEmptyPlaceholderPlugin() { + return $prose(() => new Plugin({ + key: emptyPlaceholderKey, + props: { + decorations: (state) => { + const { doc } = state + if (doc.childCount !== 1) return DecorationSet.empty + const first = doc.firstChild + if (!first || first.type.name !== 'paragraph' || first.content.size !== 0) return DecorationSet.empty + return DecorationSet.create(doc, [Decoration.node(0, first.nodeSize, { class: 'doc-placeholder' })]) + }, + }, + })) +} interface FocusState { set: DecorationSet diff --git a/src/styles.css b/src/styles.css index fec0cf5..b1ad7d1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -182,7 +182,15 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius .milkdown .ProseMirror li.task-item { list-style: none; display: flex; align-items: flex-start; gap: 2px; margin-left: -1.1em; } .milkdown .ProseMirror .task-checkbox { margin-top: 0.42em; width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; flex: none; } .milkdown .ProseMirror li[data-checked='true'] .task-content { color: var(--text-dim); } -.editor-pane-wrap { position: relative; min-height: 100%; } +.editor-pane-wrap { position: relative; min-height: 100%; display: flex; flex-direction: column; align-items: center; } +/* 编辑面填满窗格高度:空文档/短文档时下方大片区域也是可点击的输入面, + 点任意处光标都会落进文档(原先只有内容实际占的那一条能点)。 + host 必须显式 width:100%:flex 列里 margin:auto 会让它按内容收缩, + 空文档时整列会缩成一条窄缝(实测踩过)。 */ +.editor-host { flex: 1; width: 100%; display: flex; flex-direction: column; } +.editor-host .milkdown { flex: 1; display: flex; flex-direction: column; } +.milkdown .ProseMirror { flex: 1; } +.milkdown .ProseMirror .doc-placeholder::before { content: '开始输入…'; float: left; height: 0; pointer-events: none; color: color-mix(in srgb, var(--doc-ink, #24292f) 38%, transparent); } .table-toolbar { position: absolute; z-index: 30; display: flex; gap: 2px; align-items: center; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 3px; box-shadow: 0 6px 18px rgba(0,0,0,0.12); } .table-toolbar button { border: 0; background: transparent; padding: 4px 8px; border-radius: 6px; cursor: pointer; font-size: 12.5px; white-space: nowrap; } .table-toolbar button:hover { background: var(--accent-weak); } diff --git a/tests/empty-placeholder.test.ts b/tests/empty-placeholder.test.ts new file mode 100644 index 0000000..825b057 --- /dev/null +++ b/tests/empty-placeholder.test.ts @@ -0,0 +1,29 @@ +// 空文档占位:空文档首段挂 .doc-placeholder(CSS 显示「开始输入…」),有内容时不挂。 +import { describe, it, expect, afterEach } from 'vitest' +import type { Editor } from '@milkdown/kit/core' +import { createMarkdownEditor } from '../src/editor/editor' + +let editors: Editor[] = [] +afterEach(async () => { + for (const e of editors) { try { await e.destroy() } catch { /* ignore */ } } + editors = [] + document.body.innerHTML = '' +}) + +async function load(md: string): Promise { + const root = document.createElement('div') + document.body.appendChild(root) + const editor = createMarkdownEditor(root, md) + editors.push(editor) + await editor.create() + return root +} + +describe('空文档占位', () => { + it('空文档有占位类,非空文档没有', async () => { + const emptyRoot = await load('') + expect(emptyRoot.querySelector('.doc-placeholder')).not.toBeNull() + const filledRoot = await load('# 标题\n\n正文') + expect(filledRoot.querySelector('.doc-placeholder')).toBeNull() + }) +})