fix(editor): 新建文档自动聚焦 + 空文档占位 + 编辑面填满(docs/23)

新建/打开/恢复的标签就绪后自动把光标送进编辑器(弹窗/查找栏打开时
不抢);切回标签恢复锚点时一并聚焦。空文档首段显示「开始输入…」
占位。编辑面改为撑满窗格高度,下方空白处可点击落光标;host 显式
width:100% 防 flex 收缩窄缝。
This commit is contained in:
Muse
2026-11-14 16:06:27 +08:00
parent d637f0a849
commit c0f03e48dc
6 changed files with 120 additions and 10 deletions

29
docs/23-new-doc-focus.md Normal file
View File

@@ -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;构建通过。

View File

@@ -124,6 +124,15 @@ export default function App() {
const editorRef = useRef<Editor | null>(null) const editorRef = useRef<Editor | null>(null)
const jumpRef = useRef<(text: string) => void>(() => undefined) const jumpRef = useRef<(text: string) => void>(() => undefined)
const anchorApiRef = useRef<{ getAnchor: () => CursorAnchor | null; setCursorByAnchor: (a: CursorAnchor) => void } | null>(null) const anchorApiRef = useRef<{ getAnchor: () => CursorAnchor | null; setCursorByAnchor: (a: CursorAnchor) => void } | null>(null)
// 刚新建/刚打开的标签:其编辑器就绪后应自动获得光标(新建文档进来没有光标是缺陷)。
const freshFocusRef = useRef<Set<string>>(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<SourceEditorApi | null>(null) const sourceApiRef = useRef<SourceEditorApi | null>(null)
const sourceModeRef = useRef(sourceMode) const sourceModeRef = useRef(sourceMode)
sourceModeRef.current = sourceMode sourceModeRef.current = sourceMode
@@ -256,6 +265,7 @@ export default function App() {
encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom, encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom,
}) })
const existing = stateRef.current.tabs.find((t) => samePath(t.path, path)) const existing = stateRef.current.tabs.find((t) => samePath(t.path, path))
if (!existing) freshFocusRef.current.add(created.id)
dispatch({ type: 'open', tab: created }) dispatch({ type: 'open', tab: created })
const stat = await statFile(path) const stat = await statFile(path)
if (stat.exists) baselinesRef.current.set(existing?.id ?? created.id, { mtimeMs: stat.mtimeMs, size: stat.size }) 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]) }, [pushRecent])
const newDoc = useCallback(() => { 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) setSourceMode(false)
}, []) }, [])
@@ -275,7 +287,9 @@ export default function App() {
} else { } else {
const picked = await pickAndReadWebFile() const picked = await pickAndReadWebFile()
if (picked) { 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]) }, [tauriMode, openPath])
@@ -447,6 +461,7 @@ export default function App() {
encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom, encoding: file.encoding, lineEnding: file.lineEnding, hadBom: file.hadBom,
}) })
const existing = stateRef.current.tabs.find((t) => samePath(t.path, item.path)) const existing = stateRef.current.tabs.find((t) => samePath(t.path, item.path))
if (!existing) freshFocusRef.current.add(created.id)
dispatch({ type: 'open', tab: created }) dispatch({ type: 'open', tab: created })
const targetId = existing?.id ?? created.id const targetId = existing?.id ?? created.id
dispatch({ type: 'updateContent', id: targetId, content: item.content }) dispatch({ type: 'updateContent', id: targetId, content: item.content })
@@ -457,6 +472,7 @@ export default function App() {
} catch { /* 文件已不存在时按未命名草稿恢复 */ } } catch { /* 文件已不存在时按未命名草稿恢复 */ }
} }
const created = createTab({ title: item.title, content: '' }) const created = createTab({ title: item.title, content: '' })
freshFocusRef.current.add(created.id)
dispatch({ type: 'open', tab: created }) dispatch({ type: 'open', tab: created })
dispatch({ type: 'updateContent', id: created.id, content: item.content }) dispatch({ type: 'updateContent', id: created.id, content: item.content })
finish() finish()
@@ -834,9 +850,13 @@ export default function App() {
editorRef.current = api?.editor ?? null editorRef.current = api?.editor ?? null
jumpRef.current = api?.jumpToHeading ?? (() => undefined) jumpRef.current = api?.jumpToHeading ?? (() => undefined)
anchorApiRef.current = api ? { getAnchor: api.getAnchor, setCursorByAnchor: api.setCursorByAnchor } : null anchorApiRef.current = api ? { getAnchor: api.getAnchor, setCursorByAnchor: api.setCursorByAnchor } : null
if (api && pendingAnchorRef.current) { if (api) {
api.setCursorByAnchor(pendingAnchorRef.current) const hadAnchor = Boolean(pendingAnchorRef.current)
pendingAnchorRef.current = null if (hadAnchor) {
api.setCursorByAnchor(pendingAnchorRef.current as CursorAnchor)
pendingAnchorRef.current = null
}
if (hadAnchor || (activeIdForAlive && freshFocusRef.current.delete(activeIdForAlive))) maybeFocusEditor(api.editor)
} }
}, [activeIdForAlive]) }, [activeIdForAlive])
@@ -1071,6 +1091,7 @@ export default function App() {
modal || appearanceOpen || exportOpen || pendingClose || conflict modal || appearanceOpen || exportOpen || pendingClose || conflict
|| promptSpec || confirmSpec || quitOpen || (recoveryOffer.length > 0 && !recoveryDismissed), || promptSpec || confirmSpec || quitOpen || (recoveryOffer.length > 0 && !recoveryDismissed),
) )
findOpenRef.current = findUi.open
// 大纲与状态栏计数只随正文变化重算(原先每次光标移动/击键都对全文重算); // 大纲与状态栏计数只随正文变化重算(原先每次光标移动/击键都对全文重算);
// 大纲再经 useDeferredValue 让高频输入时延后提取,不堵击键路径。 // 大纲再经 useDeferredValue 让高频输入时延后提取,不堵击键路径。
@@ -1298,9 +1319,13 @@ export default function App() {
editorRef.current = api.editor editorRef.current = api.editor
jumpRef.current = api.jumpToHeading jumpRef.current = api.jumpToHeading
anchorApiRef.current = api.editor ? { getAnchor: api.getAnchor, setCursorByAnchor: api.setCursorByAnchor } : null anchorApiRef.current = api.editor ? { getAnchor: api.getAnchor, setCursorByAnchor: api.setCursorByAnchor } : null
if (api.editor && pendingAnchorRef.current) { if (api.editor) {
api.setCursorByAnchor(pendingAnchorRef.current) const hadAnchor = Boolean(pendingAnchorRef.current)
pendingAnchorRef.current = null if (hadAnchor) {
api.setCursorByAnchor(pendingAnchorRef.current as CursorAnchor)
pendingAnchorRef.current = null
}
if (hadAnchor || freshFocusRef.current.delete(t.id)) maybeFocusEditor(api.editor)
} }
}} }}
/> />

View File

@@ -12,7 +12,7 @@ import { clipboard } from '@milkdown/kit/plugin/clipboard'
import { indent } from '@milkdown/kit/plugin/indent' import { indent } from '@milkdown/kit/plugin/indent'
import { math } from '@milkdown/plugin-math' import { math } from '@milkdown/plugin-math'
import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload' 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' import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews'
export interface CursorInfo { line: number; col: number } export interface CursorInfo { line: number; col: number }
@@ -90,6 +90,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
.use(createCodeBlockTabPlugin()) .use(createCodeBlockTabPlugin())
.use(createTableTabPlugin()) .use(createTableTabPlugin())
.use(createFocusDimPlugin()) .use(createFocusDimPlugin())
.use(createEmptyPlaceholderPlugin())
.use(createListItemViewPlugin()) .use(createListItemViewPlugin())
.use(createCodeBlockViewPlugin()) .use(createCodeBlockViewPlugin())
.use(math) .use(math)

View File

@@ -210,6 +210,24 @@ export function isFocusModeEnabled() {
} }
const focusKey = new PluginKey('new-markdown-focus') 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 { interface FocusState {
set: DecorationSet set: DecorationSet

View File

@@ -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 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 .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); } .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 { 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 { 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); } .table-toolbar button:hover { background: var(--accent-weak); }

View File

@@ -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<HTMLElement> {
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()
})
})