feat(stage6): 专注/打字机模式、查找替换、全文搜索 UI、快捷键中心、多窗口

This commit is contained in:
Muse
2026-11-13 23:09:32 +08:00
parent db158fa4da
commit c251903fa2
16 changed files with 1175 additions and 50 deletions

View File

@@ -1,5 +1,6 @@
import { Suspense, lazy, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core'
import TitleBar from './components/TitleBar'
import MenuBar, { type MenuDef } from './components/MenuBar'
import TabBar from './components/TabBar'
@@ -7,6 +8,10 @@ import Sidebar from './components/Sidebar'
import StatusBar from './components/StatusBar'
import EditorPane from './components/EditorPane'
import type { SourceEditorApi } from './components/SourceEditor'
import FindBar from './components/FindBar'
import type { EditorPaneApi } from './components/EditorPane'
import type { FindOptions } from './editor/search'
import { comboFromEvent, resolveShortcuts } from './utils/shortcuts'
// 源码编辑器(CodeMirror)懒加载:不进首屏主包,仅切到源码模式时加载
const SourceEditor = lazy(() => import('./components/SourceEditor'))
import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync'
@@ -51,9 +56,9 @@ const UI_KEY = 'new-markdown:ui'
function loadRecent(): string[] {
try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as string[] } catch { return [] }
}
interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string }
interface UiState { theme: 'light' | 'dark'; sidebar: boolean; sidebarWidth: number; autosave: boolean; autosaveSeconds: number; docTheme: string; customCss: string; focusMode: boolean; typewriter: boolean; shortcuts: Record<string, string> }
function loadUi(): UiState {
const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '' }
const defaults: UiState = { theme: 'light', sidebar: true, sidebarWidth: 250, autosave: true, autosaveSeconds: 30, docTheme: 'default', customCss: '', focusMode: false, typewriter: false, shortcuts: {} }
try {
return { ...defaults, ...JSON.parse(localStorage.getItem(UI_KEY) ?? '{}') }
} catch { return defaults }
@@ -71,7 +76,7 @@ export default function App() {
const tab = activeTab(state)
const [workspaceRoot, setWorkspaceRoot] = useState<string | null>(null)
const [sidebarMode, setSidebarMode] = useState<'files' | 'outline'>('outline')
const [sidebarMode, setSidebarMode] = useState<'files' | 'outline' | 'search'>('outline')
const [recent, setRecent] = useState<string[]>(loadRecent)
const [cursor, setCursor] = useState<CursorInfo>({ line: 1, col: 1 })
const [sourceMode, setSourceMode] = useState(false)
@@ -108,8 +113,52 @@ export default function App() {
const jumpRef = useRef<(text: string) => void>(() => undefined)
const anchorApiRef = useRef<{ getAnchor: () => CursorAnchor | null; setCursorByAnchor: (a: CursorAnchor) => void } | null>(null)
const sourceApiRef = useRef<SourceEditorApi | null>(null)
const sourceModeRef = useRef(sourceMode)
sourceModeRef.current = sourceMode
const pendingAnchorRef = useRef<CursorAnchor | null>(null)
const [sourceCursorOffset, setSourceCursorOffset] = useState(0)
const paneFindRef = useRef<EditorPaneApi | null>(null)
const [findUi, setFindUi] = useState<{ open: boolean; replace: boolean; query: string; replacement: string; opts: FindOptions; count: number; current: number; error: string | null }>({
open: false, replace: false, query: '', replacement: '',
opts: { caseSensitive: false, wholeWord: false, regex: false }, count: 0, current: -1, error: null,
})
const findUiRef = useRef(findUi)
findUiRef.current = findUi
const pendingFindRef = useRef<string | null>(null)
const activeFindApi = useCallback((): Pick<EditorPaneApi, 'findAll' | 'gotoMatch' | 'replaceCurrent' | 'replaceAll' | 'clearFind'> | SourceEditorApi | null => {
return sourceModeRef.current ? sourceApiRef.current : paneFindRef.current
}, [])
const runFind = useCallback((query: string, opts: FindOptions, goFirst = true) => {
const api = activeFindApi()
if (!api || !query) {
setFindUi((f) => ({ ...f, count: 0, current: -1, error: null }))
return
}
try {
const count = api.findAll(query, opts)
const current = count > 0 && goFirst ? api.gotoMatch(0) : -1
setFindUi((f) => ({ ...f, count, current, error: null }))
} catch (e) {
setFindUi((f) => ({ ...f, count: 0, current: -1, error: e instanceof Error ? e.message : String(e) }))
}
}, [activeFindApi])
const openFind = useCallback((replace: boolean, preset?: string) => {
const selection = !sourceModeRef.current && editorRef.current
? (() => { try { const st = editorRef.current?.action((ctx) => { const v = ctx.get(editorViewCtx); return v.state.doc.textBetween(v.state.selection.from, v.state.selection.to) }); return typeof st === 'string' ? st : '' } catch { return '' } })()
: ''
const query = preset ?? (selection && !selection.includes('\n') ? selection : findUiRef.current.query)
setFindUi((f) => ({ ...f, open: true, replace, query }))
window.setTimeout(() => runFind(query, findUiRef.current.opts), 30)
}, [runFind])
const closeFind = useCallback(() => {
activeFindApi()?.clearFind()
setFindUi((f) => ({ ...f, open: false, count: 0, current: -1, error: null }))
}, [activeFindApi])
const tauriMode = isTauri()
useEffect(() => {
@@ -117,6 +166,27 @@ export default function App() {
store.saveUiPref(JSON.stringify(ui))
}, [ui])
// 工作区搜索点击结果:文件打开后自动打开查找条并定位
useEffect(() => {
if (!tab || !pendingFindRef.current) return
const query = pendingFindRef.current
pendingFindRef.current = null
const timer = window.setTimeout(() => openFind(false, query), 350)
return () => window.clearTimeout(timer)
}, [tab?.id, openFind])
// 查找条开着时切换标签/模式:对新上下文重算命中
const findContextKey = `${tab?.id ?? ''}:${sourceMode ? 'src' : 'doc'}`
const lastFindContextRef = useRef(findContextKey)
useEffect(() => {
if (lastFindContextRef.current === findContextKey) return
lastFindContextRef.current = findContextKey
if (findUiRef.current.open && findUiRef.current.query) {
const timer = window.setTimeout(() => runFind(findUiRef.current.query, findUiRef.current.opts), 60)
return () => window.clearTimeout(timer)
}
}, [findContextKey, runFind])
const effectiveDocTheme = (tab ? docOverrides[tab.id] : undefined) ?? ui.docTheme
useEffect(() => {
document.documentElement.dataset.docTheme = effectiveDocTheme
@@ -170,6 +240,28 @@ export default function App() {
}
}, [tauriMode, openPath])
const openSearchResult = useCallback((path: string, query: string) => {
pendingFindRef.current = query
void openPath(path)
}, [openPath ])
const openInNewWindow = useCallback(async (path: string) => {
if (!isTauri()) return
try {
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
const label = `doc-${Date.now()}`
new WebviewWindow(label, {
url: `index.html?file=${encodeURIComponent(path)}`,
title: baseName(path),
width: 1280,
height: 820,
minWidth: 920,
minHeight: 600,
decorations: false,
})
} catch (e) { setError(`打开新窗口失败:${String(e)}`) }
}, [])
const openFolder = useCallback(async () => {
const folder = await pickFolder()
if (folder) {
@@ -281,6 +373,9 @@ export default function App() {
const plan = planRecovery(records, (path) => disk.get(path) ?? null)
for (const key of plan.purgeKeys) void store.deleteRecovery(key)
if (!cancelled && plan.offer.length > 0) setRecoveryOffer(plan.offer)
// 多窗口:新窗口通过 ?file= 参数打开指定文档(阶段 6)
const startupFile = new URLSearchParams(window.location.search).get('file')
if (!cancelled && startupFile) void openPath(startupFile)
})()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -498,32 +593,43 @@ export default function App() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (isImeComposing(e)) return // IME 组合期间全局快捷键一律不触发(阶段 2 守卫)
const mod = e.ctrlKey || e.metaKey
if (!mod) return
const key = e.key.toLowerCase()
const inTextField = (e.target as HTMLElement)?.tagName === 'TEXTAREA' || (e.target as HTMLElement)?.tagName === 'INPUT'
if (key === 'n' && !inTextField) { e.preventDefault(); newDoc() }
else if (key === 'o' && !inTextField) { e.preventDefault(); void openFile() }
else if (key === 's' && tab) { e.preventDefault(); void saveTab(tab, e.shiftKey) }
else if (key === 'w' && tab) { e.preventDefault(); closeTab(tab.id) }
else if (key === 'l' && e.shiftKey) { e.preventDefault(); setUi((u) => ({ ...u, sidebar: !u.sidebar })) }
else if (key === 'k' && !e.shiftKey && !inTextField) { e.preventDefault(); setModal('link') }
else if (key === 'p' && !e.shiftKey) { e.preventDefault(); printRef.current() }
else if (key === 't' && !e.shiftKey && !inTextField) { e.preventDefault(); withEditor((ed) => editorCommands.insertTable(ed)) }
else if (key === 'i' && e.shiftKey) { e.preventDefault(); void insertImage() }
else if (key === 'q' && e.shiftKey) { e.preventDefault(); withEditor(editorCommands.blockquote) }
else if (key === 'k' && e.shiftKey) { e.preventDefault(); withEditor(editorCommands.codeBlock) }
else if ((key === '=' || key === '+')) { e.preventDefault(); setZoom((z) => Math.min(1.4, +(z + 0.1).toFixed(2))) }
else if (key === '-') { e.preventDefault(); setZoom((z) => Math.max(0.7, +(z - 0.1).toFixed(2))) }
else if (/^[0-6]$/.test(key) && !inTextField) {
e.preventDefault()
const level = Number(key)
withEditor((ed) => (level === 0 ? editorCommands.paragraph(ed) : editorCommands.heading(ed, level)))
if (e.key === 'Escape' && findUiRef.current.open) { e.preventDefault(); closeFind(); return }
if (!e.ctrlKey && !e.metaKey && !e.altKey) return
const combo = comboFromEvent(e)
if (!combo) return
const actionId = resolveShortcuts(ui.shortcuts).get(combo)
if (!actionId) return
const target = e.target as HTMLElement | null
const inTextField = target?.tagName === 'TEXTAREA' || target?.tagName === 'INPUT'
const run = (fn: () => void) => { e.preventDefault(); fn() }
switch (actionId) {
case 'new': if (!inTextField) run(newDoc); break
case 'open': if (!inTextField) run(() => void openFile()); break
case 'save': if (tab) run(() => void saveTab(tab, false)); break
case 'saveAs': if (tab) run(() => void saveTab(tab, true)); break
case 'closeTab': if (tab) run(() => closeTab(tab.id)); break
case 'sidebar': run(() => setUi((u) => ({ ...u, sidebar: !u.sidebar }))); break
case 'find': if (tab) run(() => openFind(false)); break
case 'replace': if (tab) run(() => openFind(true)); break
case 'link': if (!inTextField) run(() => setModal('link')); break
case 'print': run(() => printRef.current()); break
case 'table': if (!inTextField) run(() => withEditor((ed) => editorCommands.insertTable(ed))); break
case 'image': run(() => void insertImage()); break
case 'blockquote': run(() => withEditor(editorCommands.blockquote)); break
case 'codeBlock': run(() => withEditor(editorCommands.codeBlock)); break
case 'paragraph': if (!inTextField) run(() => withEditor(editorCommands.paragraph)); break
case 'zoomIn': run(() => setZoom((z) => Math.min(1.4, +(z + 0.1).toFixed(2)))); break
case 'zoomOut': run(() => setZoom((z) => Math.max(0.7, +(z - 0.1).toFixed(2)))); break
default:
if (/^heading[1-6]$/.test(actionId) && !inTextField) {
const level = Number(actionId.slice('heading'.length))
run(() => withEditor((ed) => editorCommands.heading(ed, level)))
}
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [tab, newDoc, openFile, saveTab, closeTab, withEditor])
}, [tab, newDoc, openFile, saveTab, closeTab, withEditor, ui.shortcuts, openFind, closeFind])
const pasteFromClipboard = useCallback(async () => {
if (sourceMode) {
@@ -701,6 +807,7 @@ export default function App() {
{ label: '保存', shortcut: 'Ctrl+S', disabled: !tab, onClick: () => tab && void saveTab(tab, false) },
{ label: '另存为…', shortcut: 'Ctrl+Shift+S', disabled: !tab, onClick: () => tab && void saveTab(tab, true) },
{ label: '关闭标签页', shortcut: 'Ctrl+W', disabled: !tab, onClick: () => tab && closeTab(tab.id) },
{ label: '在新窗口打开当前文档', disabled: !tab?.path || !tauriMode, onClick: () => tab?.path && void openInNewWindow(tab.path) },
{ divider: true },
{ label: '导入 HTML / Word…', onClick: () => void importFileFlow() },
{ label: '导出…', disabled: !tab, onClick: () => setExportOpen(true) },
@@ -717,6 +824,9 @@ export default function App() {
{ label: '复制', onClick: () => document.execCommand('copy') },
{ label: '粘贴', onClick: () => { void pasteFromClipboard() } },
{ label: '全选', onClick: () => document.execCommand('selectAll') },
{ divider: true },
{ label: '查找', shortcut: 'Ctrl+F', disabled: !tab, onClick: () => openFind(false) },
{ label: '替换', shortcut: 'Ctrl+H', disabled: !tab, onClick: () => openFind(true) },
],
},
{
@@ -760,6 +870,8 @@ export default function App() {
{ label: '源代码模式', checked: sourceMode, disabled: !tab, onClick: toggleSourceMode },
{ label: '深色主题', checked: ui.theme === 'dark', onClick: () => setUi((u) => ({ ...u, theme: u.theme === 'dark' ? 'light' : 'dark' })) },
{ label: `自动保存(${ui.autosaveSeconds} 秒)`, checked: ui.autosave, onClick: () => setUi((u) => ({ ...u, autosave: !u.autosave })) },
{ label: '专注模式', checked: ui.focusMode, disabled: sourceMode, onClick: () => setUi((u) => ({ ...u, focusMode: !u.focusMode })) },
{ label: '打字机模式', checked: ui.typewriter, disabled: sourceMode, onClick: () => setUi((u) => ({ ...u, typewriter: !u.typewriter })) },
{ divider: true },
{ label: '放大', shortcut: 'Ctrl+=', onClick: () => setZoom((z) => Math.min(1.4, +(z + 0.1).toFixed(2))) },
{ label: '缩小', shortcut: 'Ctrl+-', onClick: () => setZoom((z) => Math.max(0.7, +(z - 0.1).toFixed(2))) },
@@ -780,11 +892,11 @@ export default function App() {
{
label: '帮助',
items: [
{ label: '快捷键说明', onClick: () => setModal('shortcuts') },
{ label: '快捷键设置…', onClick: () => setModal('shortcuts') },
{ label: '关于 new-markdown', onClick: () => setModal('about') },
],
},
], [tab, recent, sourceMode, ui, sidebarMode, tauriMode, newDoc, openFile, openFolder, openPath, saveTab, closeTab, withEditor, toggleSourceMode, insertImage, insertAttachment, pasteFromClipboard, importFileFlow, printDoc])
], [tab, recent, sourceMode, ui, sidebarMode, tauriMode, newDoc, openFile, openFolder, openPath, saveTab, closeTab, withEditor, toggleSourceMode, insertImage, insertAttachment, pasteFromClipboard, importFileFlow, printDoc, openFind, openInNewWindow])
const pendingTitles = pendingClose
? state.tabs.filter((t) => pendingClose.ids.includes(t.id) && isDirty(t)).map((t) => t.title).join('、')
@@ -823,6 +935,7 @@ export default function App() {
refreshSignal={treeRefresh}
outline={outline}
onJumpHeading={jumpHeading}
onOpenSearchResult={openSearchResult}
tauriMode={tauriMode}
/>
</div>
@@ -856,8 +969,11 @@ export default function App() {
initialMarkdown={tab.content}
onMarkdown={(id, md) => dispatch({ type: 'updateContent', id, content: md })}
onCursor={setCursor}
focusMode={ui.focusMode}
typewriter={ui.typewriter}
onImageFile={handleImageFile}
onReady={(api) => {
paneFindRef.current = api.editor ? api : null
editorRef.current = api.editor
jumpRef.current = api.jumpToHeading
anchorApiRef.current = api.editor ? { getAnchor: api.getAnchor, setCursorByAnchor: api.setCursorByAnchor } : null
@@ -868,6 +984,38 @@ export default function App() {
}}
/>
)}
{findUi.open && tab && (
<FindBar
query={findUi.query}
replacement={findUi.replacement}
showReplace={findUi.replace}
opts={findUi.opts}
count={findUi.count}
current={findUi.current}
error={findUi.error}
onQueryChange={(value) => { setFindUi((f) => ({ ...f, query: value })); runFind(value, findUiRef.current.opts) }}
onReplacementChange={(value) => setFindUi((f) => ({ ...f, replacement: value }))}
onToggleReplace={() => setFindUi((f) => ({ ...f, replace: !f.replace }))}
onOptsChange={(opts) => { setFindUi((f) => ({ ...f, opts })); runFind(findUiRef.current.query, opts) }}
onNext={() => { const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current + 1); setFindUi((f) => ({ ...f, current: cur })) } }}
onPrev={() => { const api = activeFindApi(); if (api) { const cur = api.gotoMatch(findUiRef.current.current - 1); setFindUi((f) => ({ ...f, current: cur })) } }}
onReplace={() => {
const api = activeFindApi()
if (!api || !api.replaceCurrent(findUiRef.current.replacement)) return
const count = api.findAll(findUiRef.current.query, findUiRef.current.opts)
const current = count > 0 ? api.gotoMatch(Math.min(findUiRef.current.current, count - 1)) : -1
setFindUi((f) => ({ ...f, count, current }))
}}
onReplaceAll={() => {
const api = activeFindApi()
if (!api) return
const replaced = api.replaceAll(findUiRef.current.replacement)
if (replaced > 0) flash(`已替换 ${replaced} 处`)
setFindUi((f) => ({ ...f, count: 0, current: -1 }))
}}
onClose={closeFind}
/>
)}
</section>
</div>
<StatusBar
@@ -1005,7 +1153,7 @@ export default function App() {
/>
)}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'shortcuts' && <ShortcutsModal onClose={() => setModal(null)} />}
{modal === 'shortcuts' && <ShortcutsModal overrides={ui.shortcuts} onChange={(shortcuts) => setUi((u) => ({ ...u, shortcuts }))} onClose={() => setModal(null)} />}
{modal === 'link' && (
<LinkModal
onCancel={() => setModal(null)}

View File

@@ -5,12 +5,19 @@ import { editorViewCtx } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state'
import { createMarkdownEditor, getMarkdown, type CursorInfo } from '../editor/editor'
import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync'
import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search'
import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins'
export interface EditorPaneApi {
editor: Editor | null
jumpToHeading: (text: string) => void
getAnchor: () => CursorAnchor | null
setCursorByAnchor: (anchor: CursorAnchor) => void
findAll: (query: string, opts: FindOptions) => number
gotoMatch: (index: number) => number
replaceCurrent: (replacement: string) => boolean
replaceAll: (replacement: string) => number
clearFind: () => void
}
interface Props {
@@ -20,6 +27,8 @@ interface Props {
onCursor: (cursor: CursorInfo) => void
onReady: (api: EditorPaneApi) => void
onImageFile?: (file: File) => Promise<string>
focusMode: boolean
typewriter: boolean
}
const EMPTY_API: EditorPaneApi = {
@@ -27,11 +36,21 @@ const EMPTY_API: EditorPaneApi = {
jumpToHeading: () => undefined,
getAnchor: () => null,
setCursorByAnchor: () => undefined,
findAll: () => 0,
gotoMatch: () => -1,
replaceCurrent: () => false,
replaceAll: () => 0,
clearFind: () => undefined,
}
/** 单标签编辑器宿主:挂载时以标签内容建模,卸载前回写最终序列化文本,保证切标签不丢内容。 */
export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCursor, onReady, onImageFile }: Props) {
export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCursor, onReady, onImageFile, focusMode, typewriter }: Props) {
const wrapRef = useRef<HTMLDivElement>(null)
const findRef = useRef<{ matches: MatchRange[]; current: number; query: string; opts: FindOptions | null }>({
matches: [], current: -1, query: '', opts: null,
})
const modesRef = useRef({ focusMode, typewriter })
modesRef.current = { focusMode, typewriter }
const editorObjRef = useRef<Editor | null>(null)
const [tableBar, setTableBar] = useState<{ top: number; left: number } | null>(null)
@@ -51,9 +70,31 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
useEffect(() => {
if (!hostRef.current) return
const centerCursorLine = () => {
// 打字机模式:把光标行滚到编辑区垂直中央附近
window.requestAnimationFrame(() => {
const container = wrapRef.current?.closest('.editor-scroll') as HTMLElement | null
if (!container) return
try {
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const coords = view.coordsAtPos(view.state.selection.from)
const rect = container.getBoundingClientRect()
const lineCenter = (coords.top + coords.bottom) / 2 - rect.top + container.scrollTop
const target = lineCenter - container.clientHeight / 2
if (Math.abs(target - container.scrollTop) > 6) {
container.scrollTo({ top: target, behavior: 'smooth' })
}
})
} catch { /* 视图未就绪时跳过 */ }
})
}
const editor = createMarkdownEditor(hostRef.current, initialRef.current, {
onMarkdown: (md) => callbacksRef.current.onMarkdown(tabId, md),
onCursor: (c) => callbacksRef.current.onCursor(c),
onCursor: (c) => {
callbacksRef.current.onCursor(c)
if (modesRef.current.typewriter) centerCursorLine()
},
onTableContext: (inTable, el) => { tableElRef.current = el; if (inTable) updateTableBar(); else setTableBar(null) },
}, { onImageFile: (file) => callbacksRef.current.onImageFile ? callbacksRef.current.onImageFile(file) : Promise.resolve(URL.createObjectURL(file)) })
editorObjRef.current = editor
@@ -94,8 +135,72 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
})
} catch { /* 定位失败静默:不打断模式切换 */ }
}
const findAll = (query: string, opts: FindOptions): number => {
try {
const matches = editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
return findInIndex(buildTextIndex(view.state.doc), query, opts)
})
findRef.current = { matches, current: matches.length ? 0 : -1, query, opts }
return matches.length
} catch (e) {
throw e instanceof Error ? e : new Error(String(e))
}
}
const gotoMatch = (index: number): number => {
const found = findRef.current
if (found.matches.length === 0) return -1
const wrapped = ((index % found.matches.length) + found.matches.length) % found.matches.length
found.current = wrapped
const m = found.matches[wrapped]
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, m.from, m.to)).scrollIntoView())
view.focus()
})
return wrapped
}
const replaceCurrent = (replacement: string): boolean => {
const found = findRef.current
if (found.current < 0 || !found.opts) return false
const m = found.matches[found.current]
if (!m) return false
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
view.dispatch(view.state.tr.insertText(replacement, m.from, m.to))
view.focus()
})
const previous = found.current
const count = findAll(found.query, found.opts)
if (count > 0) gotoMatch(Math.min(previous, count - 1))
return true
}
const replaceAll = (replacement: string): number => {
const found = findRef.current
if (found.matches.length === 0) return 0
const total = found.matches.length
const reversed = [...found.matches].reverse()
editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const tr = view.state.tr
for (const m of reversed) tr.insertText(replacement, m.from, m.to)
view.dispatch(tr)
view.focus()
})
findRef.current = { matches: [], current: -1, query: found.query, opts: found.opts }
return total
}
const clearFind = () => {
findRef.current = { matches: [], current: -1, query: '', opts: null }
}
editor.create()
.then(() => callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor }))
.then(() => {
setFocusModeEnabled(modesRef.current.focusMode)
try {
editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never))
} catch { /* 忽略 */ }
callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind })
})
.catch((e) => console.error('编辑器创建失败', e))
const scrollParent = wrapRef.current?.closest('.editor-scroll')
const onScroll = () => { if (editorObjRef.current) updateTableBar() }
@@ -112,6 +217,16 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onCurso
}
}, [tabId, updateTableBar])
// 专注模式切换时立即刷新当前编辑器装饰
useEffect(() => {
setFocusModeEnabled(focusMode)
const editor = editorObjRef.current
if (!editor) return
try {
editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never))
} catch { /* 编辑器未就绪时跳过,创建完成时会再应用 */ }
}, [focusMode])
return (
<div className="editor-pane-wrap" ref={wrapRef}>
{tableBar && editorObjRef.current && (

View File

@@ -0,0 +1,84 @@
import { useEffect, useRef } from 'react'
import type { FindOptions } from '../editor/search'
interface Props {
query: string
replacement: string
showReplace: boolean
opts: FindOptions
count: number
current: number // -1 表示无当前命中
error: string | null
onQueryChange: (value: string) => void
onReplacementChange: (value: string) => void
onToggleReplace: () => void
onOptsChange: (opts: FindOptions) => void
onNext: () => void
onPrev: () => void
onReplace: () => void
onReplaceAll: () => void
onClose: () => void
}
/** 查找/替换条(阶段 6):渲染与源码双模式共用,命中/替换由外部 API 执行。 */
export default function FindBar(p: Props) {
const queryRef = useRef<HTMLInputElement>(null)
useEffect(() => {
queryRef.current?.focus()
queryRef.current?.select()
}, [])
const toggle = (key: keyof FindOptions, label: string, title: string) => (
<button
className={`find-toggle ${p.opts[key] ? 'on' : ''}`}
title={title}
onClick={() => p.onOptsChange({ ...p.opts, [key]: !p.opts[key] })}
>
{label}
</button>
)
return (
<div className="findbar">
<div className="findbar-row">
<button className="find-icon" title={p.showReplace ? '收起替换' : '展开替换'} onClick={p.onToggleReplace}>
{p.showReplace ? '▾' : '▸'}
</button>
<input
ref={queryRef}
className="input find-input"
placeholder="查找"
value={p.query}
onChange={(e) => p.onQueryChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? p.onPrev() : p.onNext() }
if (e.key === 'Escape') { e.preventDefault(); p.onClose() }
}}
/>
{toggle('caseSensitive', 'Aa', '区分大小写')}
{toggle('wholeWord', '整词', '全词匹配')}
{toggle('regex', '.*', '正则表达式')}
<span className="find-count">
{p.error ? p.error : p.query ? (p.count === 0 ? '无结果' : `${p.current + 1} / ${p.count}`) : ''}
</span>
<button className="find-icon" title="上一个 (Shift+Enter)" onClick={p.onPrev}>↑</button>
<button className="find-icon" title="下一个 (Enter)" onClick={p.onNext}>↓</button>
<button className="find-icon" title="关闭 (Esc)" onClick={p.onClose}>✕</button>
</div>
{p.showReplace && (
<div className="findbar-row">
<span className="find-spacer" />
<input
className="input find-input"
placeholder="替换为"
value={p.replacement}
onChange={(e) => p.onReplacementChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); p.onClose() } }}
/>
<button className="find-action" onClick={p.onReplace}>替换</button>
<button className="find-action" onClick={p.onReplaceAll}>全部替换</button>
</div>
)}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { SHORTCUT_DEFS, BUILTIN_SHORTCUTS, comboFromEvent, comboOwner, effectiveCombos, prettyCombo } from '../utils/shortcuts'
function Shell({ children, onCancel }: { children: React.ReactNode; onCancel?: () => void }) {
return (
@@ -38,27 +39,76 @@ export function AboutModal({ onClose }: { onClose: () => void }) {
)
}
const SHORTCUTS: [string, string][] = [
['新建文档', 'Ctrl+N'], ['打开文件', 'Ctrl+O'], ['保存', 'Ctrl+S'], ['另存为', 'Ctrl+Shift+S'],
['关闭标签页', 'Ctrl+W'], ['撤销 / 重做', 'Ctrl+Z / Ctrl+Y'],
['加粗 / 斜体', 'Ctrl+B / Ctrl+I'], ['插入链接', 'Ctrl+K'],
['段落 / 一级标题', 'Ctrl+0 / Ctrl+1'], ['二级 ~ 六级标题', 'Ctrl+2 ~ Ctrl+6'],
['引用', 'Ctrl+Shift+Q'], ['代码块', 'Ctrl+Shift+K'],
['显示/隐藏侧边栏', 'Ctrl+Shift+L'], ['放大 / 缩小', 'Ctrl+= / Ctrl+-'],
]
/** 快捷键设置中心(阶段 6):应用级快捷键可改绑/解绑/恢复默认,改绑时检测冲突。 */
export function ShortcutsModal({ overrides, onChange, onClose }: {
overrides: Record<string, string>
onChange: (overrides: Record<string, string>) => void
onClose: () => void
}) {
const [recordingId, setRecordingId] = useState<string | null>(null)
const [conflict, setConflict] = useState<{ id: string; combo: string; ownerLabel: string; ownerId: string } | null>(null)
const combos = effectiveCombos(overrides)
useEffect(() => {
if (!recordingId) return
const onKey = (e: KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.key === 'Escape') { setRecordingId(null); return }
const combo = comboFromEvent(e)
if (!combo) return
const owner = comboOwner(combo, overrides, recordingId)
setRecordingId(null)
if (owner) {
setConflict({ id: recordingId, combo, ownerLabel: owner.label, ownerId: owner.id })
} else {
onChange({ ...overrides, [recordingId]: combo })
}
}
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
}, [recordingId, overrides, onChange])
const applyConflict = () => {
if (!conflict) return
onChange({ ...overrides, [conflict.id]: conflict.combo, [conflict.ownerId]: '' })
setConflict(null)
}
export function ShortcutsModal({ onClose }: { onClose: () => void }) {
return (
<Shell onCancel={onClose}>
<h3>快捷键</h3>
<h3>快捷键设置</h3>
<div className="shortcut-list">
{SHORTCUT_DEFS.map((def) => {
const combo = combos.get(def.id) ?? ''
const customized = overrides[def.id] !== undefined
return (
<div className="shortcut-row" key={def.id}>
<span className="shortcut-name">{def.label}</span>
<kbd>{recordingId === def.id ? '请按键…' : combo ? prettyCombo(combo) : '(未绑定)'}</kbd>
<button onClick={() => { setConflict(null); setRecordingId(def.id) }}>修改</button>
<button disabled={!combo} onClick={() => onChange({ ...overrides, [def.id]: '' })}>解绑</button>
<button disabled={!customized} onClick={() => { const next = { ...overrides }; delete next[def.id]; onChange(next) }}>默认</button>
</div>
)
})}
</div>
{conflict && (
<p className="modal-note conflict-note">
<b>{prettyCombo(conflict.combo)}</b> 已被「{conflict.ownerLabel}」占用。
<button onClick={applyConflict}>覆盖并解绑对方</button>
<button onClick={() => setConflict(null)}>取消</button>
</p>
)}
<h4 className="shortcut-subtitle">编辑器内置(不可改绑)</h4>
<table className="shortcuts-table">
<tbody>
{SHORTCUTS.map(([name, key]) => (
{BUILTIN_SHORTCUTS.map(([name, key]) => (
<tr key={name}><td>{name}</td><td><kbd>{key}</kbd></td></tr>
))}
</tbody>
</table>
<p className="modal-note">需求表里 Ctrl+0 同时挂了「段落」与「恢复默认缩放」,本实现取「段落」(与 Typora 一致),恢复缩放走视图菜单,避免同键双义。阶段 6 设置中心提供快捷键自定义与冲突检测。</p>
<p className="modal-note">Ctrl+0 取「段落」(与 Typora 一致),恢复默认缩放走视图菜单。设置即时生效并随偏好保存。</p>
<div className="modal-actions"><button className="primary" onClick={onClose}>关闭</button></div>
</Shell>
)

View File

@@ -1,11 +1,12 @@
import { useCallback, useEffect, useState } from 'react'
import { listDir, type DirEntry } from '../services/platform'
import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform'
import type { OutlineItem } from '../utils/text'
import { baseName } from '../utils/text'
interface Props {
mode: 'files' | 'outline'
onModeChange: (mode: 'files' | 'outline') => void
mode: 'files' | 'outline' | 'search'
onModeChange: (mode: 'files' | 'outline' | 'search') => void
onOpenSearchResult?: (path: string, query: string) => void
workspaceRoot: string | null
activePath: string | null
onOpenFile: (path: string) => void
@@ -25,8 +26,12 @@ export default function Sidebar(p: Props) {
<div className="sidebar-tabs">
<button className={p.mode === 'files' ? 'active' : ''} onClick={() => p.onModeChange('files')}>文件</button>
<button className={p.mode === 'outline' ? 'active' : ''} onClick={() => p.onModeChange('outline')}>大纲</button>
<button className={p.mode === 'search' ? 'active' : ''} onClick={() => p.onModeChange('search')}>搜索</button>
</div>
{p.mode === 'outline' ? (
{p.mode === 'search' ? (
<WorkspaceSearch root={p.workspaceRoot} tauriMode={p.tauriMode} onOpen={p.onOpenSearchResult} />
) : null}
{p.mode === 'search' ? null : p.mode === 'outline' ? (
<div className="outline">
{p.outline.length === 0 && <div className="sidebar-empty">当前文档暂无标题</div>}
{p.outline.map((o, i) => (
@@ -162,3 +167,74 @@ function TreeLevel({ dir, depth, activePath, onOpenFile, refreshKey, onEntryMenu
</div>
)
}
function WorkspaceSearch({ root, tauriMode, onOpen }: {
root: string | null
tauriMode: boolean
onOpen?: (path: string, query: string) => void
}) {
const [query, setQuery] = useState('')
const [caseSensitive, setCaseSensitive] = useState(false)
const [results, setResults] = useState<WorkspaceSearchFile[] | null>(null)
const [searching, setSearching] = useState(false)
const [error, setError] = useState<string | null>(null)
const run = async () => {
if (!root || !query.trim()) return
setSearching(true)
setError(null)
try {
setResults(await workspaceSearch(root, query.trim(), caseSensitive))
} catch (e) {
setError(String(e))
} finally {
setSearching(false)
}
}
if (!tauriMode) return <div className="sidebar-empty">Web 降级模式不支持工作区搜索。</div>
if (!root) return <div className="sidebar-empty">先打开一个文件夹,再搜索其中的文档。</div>
const total = results?.reduce((acc, f) => acc + f.matches.length, 0) ?? 0
return (
<div className="ws-search">
<div className="ws-search-box">
<input
className="input"
placeholder="搜索工作区文档…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void run() }}
/>
<button className="primary" disabled={searching || !query.trim()} onClick={() => void run()}>
{searching ? '搜索中…' : '搜索'}
</button>
</div>
<label className="ws-search-case">
<input type="checkbox" checked={caseSensitive} onChange={(e) => setCaseSensitive(e.target.checked)} />
区分大小写
</label>
{error && <div className="sidebar-empty">{error}</div>}
{results && (
<div className="ws-search-summary">{results.length === 0 ? '没有找到匹配。' : `${results.length} 个文件 · ${total} 处匹配`}</div>
)}
<div className="ws-search-results">
{results?.map((file) => (
<div key={file.path} className="ws-search-file">
<div className="ws-search-name" title={file.path}>{file.name}</div>
{file.matches.map((m, i) => (
<button
key={i}
className="ws-search-hit"
onClick={() => onOpen?.(file.path, query.trim())}
title={`${file.path}:${m.line}`}
>
<span className="ws-search-line">{m.line}</span>
<span className="ws-search-text">{m.text}</span>
</button>
))}
</div>
))}
</div>
</div>
)
}

View File

@@ -7,11 +7,52 @@ import { languages } from '@codemirror/language-data'
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language'
import { oneDark } from '@codemirror/theme-one-dark'
import { anchorFromMarkdown, type CursorAnchor } from '../editor/cursorSync'
import type { FindOptions } from '../editor/search'
export interface SourceEditorApi {
jumpToLine: (line: number) => void
getAnchor: () => CursorAnchor | null
focus: () => void
findAll: (query: string, opts: FindOptions) => number
gotoMatch: (index: number) => number
replaceCurrent: (replacement: string) => boolean
replaceAll: (replacement: string) => number
clearFind: () => void
}
/** 在纯文本上复用同一套查找语义(明文/整词/正则),返回偏移命中。 */
function findInText(text: string, query: string, opts: FindOptions): { from: number; to: number }[] {
if (!query) return []
const hits: { from: number; to: number }[] = []
if (opts.regex) {
let re: RegExp
try {
re = new RegExp(query, opts.caseSensitive ? 'g' : 'gi')
} catch (e) {
throw new Error(`正则表达式无效:${e instanceof Error ? e.message : String(e)}`)
}
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
if (m[0].length === 0) { re.lastIndex += 1; continue }
hits.push({ from: m.index, to: m.index + m[0].length })
if (hits.length >= 5000) break
}
return hits
}
const haystack = opts.caseSensitive ? text : text.toLowerCase()
const needle = opts.caseSensitive ? query : query.toLowerCase()
let from = 0
while (from <= haystack.length - needle.length) {
const hit = haystack.indexOf(needle, from)
if (hit < 0) break
const end = hit + needle.length
const wholeOk = !opts.wholeWord
|| (!/[A-Za-z0-9_]/.test(text[hit - 1] ?? '') && !/[A-Za-z0-9_]/.test(text[end] ?? ''))
if (wholeOk) hits.push({ from: hit, to: end })
from = hit + Math.max(1, needle.length)
if (hits.length >= 5000) break
}
return hits
}
interface Props {
@@ -64,6 +105,29 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset,
})
viewRef.current = view
view.focus()
let matches: { from: number; to: number }[] = []
let current = -1
let lastQuery = ''
let lastOpts: FindOptions | null = null
const findAll = (query: string, opts: FindOptions): number => {
matches = findInText(view.state.doc.toString(), query, opts)
current = matches.length ? 0 : -1
lastQuery = query
lastOpts = opts
return matches.length
}
const gotoMatch = (index: number): number => {
if (matches.length === 0) return -1
const wrapped = ((index % matches.length) + matches.length) % matches.length
current = wrapped
const m = matches[wrapped]
view.dispatch({
selection: { anchor: m.from, head: m.to },
effects: EditorView.scrollIntoView(m.from, { y: 'center' }),
})
view.focus()
return wrapped
}
callbacksRef.current.onReady({
jumpToLine: (line) => {
const target = Math.max(1, Math.min(line + 1, view.state.doc.lines))
@@ -73,6 +137,30 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset,
},
getAnchor: () => anchorFromMarkdown(view.state.doc.toString(), view.state.selection.main.head),
focus: () => view.focus(),
findAll,
gotoMatch,
replaceCurrent: (replacement) => {
if (current < 0 || !lastOpts) return false
const m = matches[current]
if (!m) return false
view.dispatch({ changes: { from: m.from, to: m.to, insert: replacement } })
view.focus()
const previous = current
const count = findAll(lastQuery, lastOpts)
if (count > 0) gotoMatch(Math.min(previous, count - 1))
return true
},
replaceAll: (replacement) => {
if (matches.length === 0) return 0
const total = matches.length
const changes = [...matches].reverse().map((m) => ({ from: m.from, to: m.to, insert: replacement }))
view.dispatch({ changes })
view.focus()
matches = []
current = -1
return total
},
clearFind: () => { matches = []; current = -1 },
})
return () => {
// 卸载前回写最终文本,避免切换瞬间丢失最后一次输入

View File

@@ -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, selectionInTable } from './plugins'
import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, selectionInTable } from './plugins'
import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews'
export interface CursorInfo { line: number; col: number }
@@ -81,6 +81,7 @@ export function createMarkdownEditor(root: HTMLElement, initialMarkdown: string,
.use(createSyntaxRevealPlugin())
.use(createCodeBlockTabPlugin())
.use(createTableTabPlugin())
.use(createFocusDimPlugin())
.use(createListItemViewPlugin())
.use(createCodeBlockViewPlugin())
.use(math)

View File

@@ -166,6 +166,58 @@ export function applyCodeBlockIndent(view: MinimalView, outdent: boolean): boole
return true
}
// ————————————————— 专注模式(阶段 6):非当前顶层块变淡 —————————————————
let focusModeEnabled = false
export function setFocusModeEnabled(value: boolean) {
focusModeEnabled = value
}
export function isFocusModeEnabled() {
return focusModeEnabled
}
const focusKey = new PluginKey('new-markdown-focus')
export function createFocusDimPlugin() {
return $prose(() => new Plugin({
key: focusKey,
state: {
init: () => DecorationSet.empty,
apply: (tr, old, _oldState, newState) => {
if (!focusModeEnabled) return DecorationSet.empty
if (!tr.selectionSet && !tr.docChanged && !tr.getMeta(focusKey)) return old
const { doc, selection } = newState
const anchor = selection.$from
// 找选区所在的顶层块范围
let activeFrom = -1
let activeTo = -1
if (anchor.depth >= 1) {
activeFrom = anchor.before(1)
activeTo = anchor.after(1)
}
const decorations: Decoration[] = []
doc.forEach((node, offset) => {
const from = offset
const to = offset + node.nodeSize
if (from !== activeFrom || to !== activeTo) {
decorations.push(Decoration.node(from, to, { class: 'focus-dim' }))
}
})
return DecorationSet.create(doc, decorations)
},
},
props: {
decorations: (state) => focusKey.getState(state) as DecorationSet,
},
}))
}
/** 触发专注装饰立即重算(开关切换时调用)。 */
export function refreshFocusDecorations(view: { state: never; dispatch: (tr: never) => void }) {
const v = view as unknown as { state: { tr: { setMeta: (k: unknown, val: unknown) => unknown } }; dispatch: (tr: unknown) => void }
v.dispatch(v.state.tr.setMeta(focusKey, true))
}
// ————————————————— 表格 Tab 导航(阶段 3) —————————————————
interface AncestorLike {

102
src/editor/search.ts Normal file
View File

@@ -0,0 +1,102 @@
// 文档内查找(阶段 6):把 ProseMirror 文档拍平成「字符 → 文档位置」索引,
// 在索引上做纯字符串/正则匹配,再把命中映射回文档位置(含跨文本节点、避开跨块命中)。
// 纯逻辑与索引构建分离,便于单测;编辑器侧的选区/替换在 EditorPane 里走事务。
export interface FindOptions {
caseSensitive: boolean
wholeWord: boolean
regex: boolean
}
export interface TextIndex {
text: string
/** 与 text 等长:每字符的文档位置;块间分隔符为 -1 */
positions: number[]
}
interface PmTextNode {
isText: boolean
text?: string
}
interface PmDocLike {
descendants: (fn: (node: PmTextNode, pos: number) => void | boolean) => void
}
export function buildTextIndex(doc: PmDocLike): TextIndex {
const chars: string[] = []
const positions: number[] = []
let lastEnd = -1
doc.descendants((node, pos) => {
if (!node.isText || !node.text) return
if (lastEnd >= 0 && pos > lastEnd) {
// 与上一文本段之间隔着块边界:放一个分隔符,跨块命中会被过滤掉
chars.push('\n')
positions.push(-1)
}
for (let i = 0; i < node.text.length; i++) {
chars.push(node.text[i])
positions.push(pos + i)
}
lastEnd = pos + node.text.length
})
return { text: chars.join(''), positions }
}
export interface MatchRange {
/** 索引内起止(映射前) */
start: number
end: number
/** 文档位置 */
from: number
to: number
}
const WORD_CHAR = /[A-Za-z0-9_]/
function isWholeWordAt(text: string, start: number, end: number): boolean {
const before = start > 0 ? text[start - 1] : ''
const after = end < text.length ? text[end] : ''
return !WORD_CHAR.test(before) && !WORD_CHAR.test(after)
}
export function findInIndex(index: TextIndex, query: string, opts: FindOptions): MatchRange[] {
if (!query) return []
const ranges: { start: number; end: number }[] = []
if (opts.regex) {
let re: RegExp
try {
re = new RegExp(query, opts.caseSensitive ? 'g' : 'gi')
} catch (e) {
throw new Error(`正则表达式无效:${e instanceof Error ? e.message : String(e)}`)
}
let m: RegExpExecArray | null
while ((m = re.exec(index.text)) !== null) {
if (m[0].length === 0) {
re.lastIndex += 1
continue
}
ranges.push({ start: m.index, end: m.index + m[0].length })
if (ranges.length >= 5000) break
}
} else {
const haystack = opts.caseSensitive ? index.text : index.text.toLowerCase()
const needle = opts.caseSensitive ? query : query.toLowerCase()
let from = 0
while (from <= haystack.length - needle.length) {
const hit = haystack.indexOf(needle, from)
if (hit < 0) break
ranges.push({ start: hit, end: hit + needle.length })
from = hit + Math.max(1, needle.length)
if (ranges.length >= 5000) break
}
}
const out: MatchRange[] = []
for (const r of ranges) {
if (opts.wholeWord && !opts.regex && !isWholeWordAt(index.text, r.start, r.end)) continue
const fromPos = index.positions[r.start]
const toPos = index.positions[r.end - 1]
if (fromPos < 0 || toPos < 0) continue // 跨块命中,跳过
out.push({ ...r, from: fromPos, to: toPos + 1 })
}
return out
}

View File

@@ -274,3 +274,20 @@ export function pickAndReadWebImport(): Promise<{ name: string; text?: string; b
input.click()
})
}
// ————————————————— 阶段 6:工作区全文搜索 —————————————————
export interface WorkspaceSearchMatch {
line: number
text: string
}
export interface WorkspaceSearchFile {
path: string
name: string
matches: WorkspaceSearchMatch[]
}
export async function workspaceSearch(root: string, query: string, caseSensitive: boolean): Promise<WorkspaceSearchFile[]> {
return invoke<WorkspaceSearchFile[]>('workspace_search', { root, query, caseSensitive })
}

View File

@@ -266,3 +266,36 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.seconds-input { width: 72px; margin: 0 6px; }
.modal-actions.left { align-items: stretch; }
.modal-actions.left button { text-align: left; }
.ws-search { display: flex; flex-direction: column; gap: 8px; padding: 10px; min-height: 0; }
.ws-search-box { display: flex; gap: 6px; }
.ws-search-box .input { flex: 1; min-width: 0; }
.ws-search-case { font-size: 12px; color: var(--text-dim); display: flex; gap: 5px; align-items: center; }
.ws-search-summary { font-size: 12px; color: var(--text-dim); }
.ws-search-results { overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
.ws-search-name { font-weight: 600; font-size: 12.5px; margin-bottom: 3px; }
.ws-search-hit { display: flex; gap: 7px; width: 100%; text-align: left; border: 0; background: transparent; padding: 3px 6px; border-radius: 6px; cursor: pointer; font-size: 12.5px; align-items: baseline; }
.ws-search-hit:hover { background: var(--accent-weak); }
.ws-search-line { color: var(--text-dim); flex: none; min-width: 26px; text-align: right; }
.ws-search-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.findbar { position: absolute; top: 8px; right: 14px; z-index: 40; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14); padding: 6px 8px; display: flex; flex-direction: column; gap: 6px; min-width: 380px; }
.findbar-row { display: flex; align-items: center; gap: 5px; }
.find-input { flex: 1; min-width: 130px; height: 28px; }
.find-toggle { border: 1px solid transparent; background: transparent; border-radius: 6px; padding: 3px 7px; font-size: 12px; cursor: pointer; color: var(--text-dim); }
.find-toggle.on { background: var(--accent-weak); color: var(--accent); border-color: var(--accent); }
.find-icon { border: 0; background: transparent; border-radius: 6px; padding: 3px 7px; cursor: pointer; color: var(--text-dim); }
.find-icon:hover { background: var(--accent-weak); }
.find-count { font-size: 12px; color: var(--text-dim); min-width: 74px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.find-action { border: 1px solid var(--border); background: var(--panel); border-radius: 6px; padding: 4px 10px; font-size: 12.5px; cursor: pointer; }
.find-action:hover { background: var(--accent-weak); }
.find-spacer { width: 26px; flex: none; }
.focus-dim { opacity: 0.32; transition: opacity 0.25s ease; }
.shortcut-list { max-height: 46vh; overflow-y: auto; display: flex; flex-direction: column; }
.shortcut-row { display: flex; align-items: center; gap: 7px; padding: 4px 2px; border-bottom: 1px solid var(--border); font-size: 13px; }
.shortcut-row .shortcut-name { flex: 1; }
.shortcut-row kbd { min-width: 118px; text-align: center; }
.shortcut-row button { border: 1px solid var(--border); background: var(--panel); border-radius: 6px; padding: 2px 8px; font-size: 12px; cursor: pointer; }
.shortcut-row button:disabled { opacity: 0.4; cursor: default; }
.shortcut-subtitle { margin: 12px 0 4px; font-size: 13px; }
.conflict-note { display: flex; align-items: center; gap: 8px; }
.findbar { position: absolute; }
.editor-scroll { position: relative; }

113
src/utils/shortcuts.ts Normal file
View File

@@ -0,0 +1,113 @@
// 快捷键注册表(阶段 6):应用级快捷键集中定义,支持用户覆盖与冲突处理。
// 组合键统一格式:ctrl/alt/shift 以 '+' 连接 + 小写主键,如 'ctrl+shift+s'。
export interface ShortcutDef {
id: string
label: string
defaultCombo: string
}
export const SHORTCUT_DEFS: ShortcutDef[] = [
{ id: 'new', label: '新建文档', defaultCombo: 'ctrl+n' },
{ id: 'open', label: '打开文件', defaultCombo: 'ctrl+o' },
{ id: 'save', label: '保存', defaultCombo: 'ctrl+s' },
{ id: 'saveAs', label: '另存为', defaultCombo: 'ctrl+shift+s' },
{ id: 'closeTab', label: '关闭标签页', defaultCombo: 'ctrl+w' },
{ id: 'find', label: '查找', defaultCombo: 'ctrl+f' },
{ id: 'replace', label: '替换', defaultCombo: 'ctrl+h' },
{ id: 'sidebar', label: '显示/隐藏侧边栏', defaultCombo: 'ctrl+shift+l' },
{ id: 'link', label: '插入链接', defaultCombo: 'ctrl+k' },
{ id: 'image', label: '插入图片', defaultCombo: 'ctrl+shift+i' },
{ id: 'table', label: '插入表格', defaultCombo: 'ctrl+t' },
{ id: 'blockquote', label: '引用', defaultCombo: 'ctrl+shift+q' },
{ id: 'codeBlock', label: '代码块', defaultCombo: 'ctrl+shift+k' },
{ id: 'paragraph', label: '段落', defaultCombo: 'ctrl+0' },
...[1, 2, 3, 4, 5, 6].map((lv) => ({
id: `heading${lv}`,
label: `${'一二三四五六'[lv - 1]}级标题`,
defaultCombo: `ctrl+${lv}`,
})),
{ id: 'print', label: '打印 / 导出 PDF', defaultCombo: 'ctrl+p' },
{ id: 'zoomIn', label: '放大', defaultCombo: 'ctrl+=' },
{ id: 'zoomOut', label: '缩小', defaultCombo: 'ctrl+-' },
]
/** 编辑器内置快捷键(Milkdown/ProseMirror 提供,本中心只展示、不可改绑)。 */
export const BUILTIN_SHORTCUTS: [string, string][] = [
['加粗', 'Ctrl+B'],
['斜体', 'Ctrl+I'],
['撤销 / 重做', 'Ctrl+Z / Ctrl+Y'],
['表格内跳转单元格', 'Tab / Shift+Tab'],
['代码块缩进', 'Tab / Shift+Tab'],
['源码模式切换', '菜单/工具栏按钮'],
]
export function comboFromEvent(e: { ctrlKey: boolean; metaKey: boolean; altKey: boolean; shiftKey: boolean; key: string }): string | null {
const key = e.key
if (['Control', 'Meta', 'Alt', 'Shift'].includes(key)) return null
let main = key.length === 1 ? key.toLowerCase() : key.toLowerCase()
if (main === ' ') main = 'space'
if (main === '+') main = '='
const parts: string[] = []
if (e.ctrlKey || e.metaKey) parts.push('ctrl')
if (e.altKey) parts.push('alt')
if (e.shiftKey) parts.push('shift')
parts.push(main)
return parts.join('+')
}
/** 计算生效绑定:动作 id -> 组合键(覆盖值 '' 表示解绑)。 */
export function effectiveCombos(overrides: Record<string, string>): Map<string, string> {
const map = new Map<string, string>()
for (const def of SHORTCUT_DEFS) {
const override = overrides[def.id]
const combo = override === undefined ? def.defaultCombo : override
if (combo) map.set(def.id, combo)
}
return map
}
/** 组合键 -> 动作 id;冲突时后出现的覆盖(用户覆盖优先于默认)。 */
export function resolveShortcuts(overrides: Record<string, string>): Map<string, string> {
const byId = effectiveCombos(overrides)
const byCombo = new Map<string, string>()
// 先放默认值,再放用户覆盖,保证覆盖胜出
for (const def of SHORTCUT_DEFS) {
if (overrides[def.id] === undefined) {
const combo = byId.get(def.id)
if (combo) byCombo.set(combo, def.id)
}
}
for (const def of SHORTCUT_DEFS) {
if (overrides[def.id] !== undefined) {
const combo = byId.get(def.id)
if (combo) byCombo.set(combo, def.id)
}
}
return byCombo
}
/** 组合键的展示形式:'ctrl+shift+s' -> 'Ctrl+Shift+S'。 */
export function prettyCombo(combo: string): string {
if (!combo) return '(未绑定)'
return combo
.split('+')
.map((part) => {
if (part === 'ctrl') return 'Ctrl'
if (part === 'alt') return 'Alt'
if (part === 'shift') return 'Shift'
if (part === 'space') return 'Space'
return part.length === 1 ? part.toUpperCase() : part
})
.join('+')
}
/** 查组合键当前被哪个动作占用(排除指定动作自身)。 */
export function comboOwner(combo: string, overrides: Record<string, string>, excludeId?: string): ShortcutDef | null {
const byId = effectiveCombos(overrides)
for (const def of SHORTCUT_DEFS) {
if (def.id === excludeId) continue
if (byId.get(def.id) === combo) return def
}
return null
}