From d744d6c049ea6d76e5bde98ca3ee1aafba87ee30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E5=BF=97=E5=86=9B?= <851516902@qq.com> Date: Tue, 19 May 2026 16:37:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9B=E4=BD=9C=E8=A7=86=E5=9B=BE=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 12 +- src/renderer/src/App.tsx | 10 +- src/renderer/src/ContentArea.tsx | 4 + src/renderer/src/EditorPane.tsx | 21 +- src/renderer/src/LiveLineView.tsx | 300 ++++++++++++++++++--- src/renderer/src/MarkdownFormatToolbar.tsx | 33 +-- src/renderer/src/PreviewArticle.tsx | 59 +++- src/renderer/src/index.css | 19 ++ src/renderer/src/liveDocHistory.ts | 48 ++++ src/renderer/src/liveDocumentSegments.ts | 63 ++++- src/renderer/src/liveShortcuts.ts | 92 +++++++ src/renderer/src/liveTextActions.ts | 146 ++++++++++ 12 files changed, 753 insertions(+), 54 deletions(-) create mode 100644 src/renderer/src/liveDocHistory.ts create mode 100644 src/renderer/src/liveShortcuts.ts create mode 100644 src/renderer/src/liveTextActions.ts diff --git a/electron/main.ts b/electron/main.ts index 92eff5f..58e8233 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -157,8 +157,16 @@ function buildMenu(): Menu { { label: '编辑', submenu: [ - { label: '撤销', accelerator: 'CmdOrCtrl+Z', role: 'undo' }, - { label: '重做', accelerator: process.platform === 'darwin' ? 'Cmd+Shift+Z' : 'Ctrl+Y', role: 'redo' }, + { + label: '撤销', + accelerator: 'CmdOrCtrl+Z', + click: () => sendMenu(null, 'edit-undo') + }, + { + label: '重做', + accelerator: process.platform === 'darwin' ? 'Cmd+Shift+Z' : 'Ctrl+Y', + click: () => sendMenu(null, 'edit-redo') + }, { type: 'separator' }, { label: '剪切', accelerator: 'CmdOrCtrl+X', role: 'cut' }, { label: '复制', accelerator: 'CmdOrCtrl+C', role: 'copy' }, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d937026..b9f64c7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1108,6 +1108,12 @@ ${previewHtml} case 'toggle-read': cycleViewMode() break + case 'edit-undo': + editorRef.current?.undo() ?? document.execCommand('undo') + break + case 'edit-redo': + editorRef.current?.redo() ?? document.execCommand('redo') + break case 'help-about': window.alert( " Huangzhijun's Reader 0.1 — 本地 Markdown 阅读与编辑 \n \n 作者:黄志军 \n 支持与协助(wx):liaofan199404" @@ -1154,7 +1160,7 @@ ${previewHtml} e.preventDefault() cycleViewMode() } - if ((e.ctrlKey || e.metaKey) && e.key === 'b') { + if ((e.ctrlKey || e.metaKey) && e.key === 'b' && viewMode !== 'live') { e.preventDefault() setSidebarHidden((v) => !v) } @@ -1218,7 +1224,7 @@ ${previewHtml} } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [searchVisible, saveActive, newTabAction, activeId, requestCloseTab, cycleViewMode, openFileDialog, saveActiveAs, exportHtml, openFolderDialog, refreshDir]) + }, [searchVisible, saveActive, newTabAction, activeId, requestCloseTab, cycleViewMode, openFileDialog, saveActiveAs, exportHtml, openFolderDialog, refreshDir, viewMode]) if (!api) { return ( diff --git a/src/renderer/src/ContentArea.tsx b/src/renderer/src/ContentArea.tsx index b3ec874..f2c3d5c 100644 --- a/src/renderer/src/ContentArea.tsx +++ b/src/renderer/src/ContentArea.tsx @@ -181,6 +181,7 @@ export function ContentArea(props: ContentAreaProps): React.ReactElement { {viewMode === 'live' ? ( activeTab ? ( ) : (
diff --git a/src/renderer/src/EditorPane.tsx b/src/renderer/src/EditorPane.tsx index 275261e..9acd5b0 100644 --- a/src/renderer/src/EditorPane.tsx +++ b/src/renderer/src/EditorPane.tsx @@ -1,5 +1,12 @@ import * as React from 'react' -import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { + defaultKeymap, + history, + historyKeymap, + indentWithTab, + redo, + undo +} from '@codemirror/commands' import { markdown } from '@codemirror/lang-markdown' import { EditorState } from '@codemirror/state' import { @@ -13,6 +20,8 @@ import { applyMarkdownAction } from './markdown-actions' export type MarkdownEditorHandle = { applyAction: (id: string) => void | Promise + undo: () => boolean + redo: () => boolean searchText: (query: string) => boolean getView: () => EditorView | null getScrollTop: () => number @@ -86,6 +95,16 @@ export const EditorPane = React.forwardRef( requestColor: requestColorRef.current }) }, + undo: (): boolean => { + const view = viewRef.current + if (!view) return false + return undo(view) + }, + redo: (): boolean => { + const view = viewRef.current + if (!view) return false + return redo(view) + }, searchText: (query: string): boolean => { const view = viewRef.current if (!view || !query.trim()) return false diff --git a/src/renderer/src/LiveLineView.tsx b/src/renderer/src/LiveLineView.tsx index bf3985a..57df3ac 100644 --- a/src/renderer/src/LiveLineView.tsx +++ b/src/renderer/src/LiveLineView.tsx @@ -8,6 +8,18 @@ import { type LiveBlockSegment, type LiveSegment } from './liveDocumentSegments' +import type { MarkdownEditorHandle } from './EditorPane' +import type { ActionDeps } from './markdown-actions' +import { applyLiveMarkdownAction } from './liveTextActions' +import { + canRedoDoc, + canUndoDoc, + createDocHistory, + pushDocHistory, + redoDoc, + undoDoc +} from './liveDocHistory' +import { isLiveModalAction, liveEditorCommand } from './liveShortcuts' type Props = { content: string @@ -21,6 +33,9 @@ type Props = { onOpenMarkdown?: (path: string, anchorId?: string) => void onOpenExternal?: (url: string) => void onChange: (next: string) => void + requestText?: (title: string, defaultValue: string) => Promise + requestImageDetails?: () => Promise<{ url: string; alt: string } | null> + requestColor?: () => Promise } function splitLines(doc: string): string[] { @@ -52,7 +67,7 @@ function resizeTextarea(ta: HTMLTextAreaElement): void { type BlockPreviewProps = { html: string baseFilePath: string | null - blockKind: 'code' | 'diagram' + blockKind: 'code' | 'diagram' | 'table' resolveRelativePath?: (base: string, href: string) => Promise onOpenMarkdown?: (path: string, anchorId?: string) => void onOpenExternal?: (url: string) => void @@ -79,19 +94,25 @@ function LiveBlockPreview({ ) } -export function LiveLineView({ - content, - baseFilePath, - activeId, - gotoLine, - previewMaxWidth, - scrollRef, - articleRef, - resolveRelativePath, - onOpenMarkdown, - onOpenExternal, - onChange -}: Props): React.ReactElement { +export const LiveLineView = React.forwardRef(function LiveLineView( + { + content, + baseFilePath, + activeId, + gotoLine, + previewMaxWidth, + scrollRef, + articleRef, + resolveRelativePath, + onOpenMarkdown, + onOpenExternal, + onChange, + requestText, + requestImageDetails, + requestColor + }, + ref +): React.ReactElement { const lines = React.useMemo(() => splitLines(content), [content]) const segments = React.useMemo(() => parseLiveDocumentSegments(content), [content]) const [editingKey, setEditingKey] = React.useState(null) @@ -99,6 +120,176 @@ export function LiveLineView({ const inputRef = React.useRef(null) const innerScrollRef = React.useRef(null) const skipBlurCommitRef = React.useRef(false) + const lastActiveLineRef = React.useRef(0) + const pendingActionRef = React.useRef(null) + const docHistoryRef = React.useRef(createDocHistory(content)) + const selfChangeRef = React.useRef(false) + const actionDepsRef = React.useRef({ + requestText: (title, def) => Promise.resolve(window.prompt(title, def)) + }) + actionDepsRef.current = { + requestText: + requestText ?? + ((title: string, def: string) => Promise.resolve(window.prompt(title, def))), + requestImageDetails, + requestColor + } + + const applyToTextarea = React.useCallback( + async (id: string): Promise => { + const ta = inputRef.current + if (!ta) return + if (isLiveModalAction(id)) skipBlurCommitRef.current = true + try { + const result = await applyLiveMarkdownAction( + draft, + { start: ta.selectionStart, end: ta.selectionEnd }, + id, + actionDepsRef.current + ) + if (!result) return + setDraft(result.value) + requestAnimationFrame(() => { + ta.focus() + ta.setSelectionRange(result.selection.start, result.selection.end) + resizeTextarea(ta) + }) + } finally { + if (isLiveModalAction(id)) skipBlurCommitRef.current = false + } + }, + [draft] + ) + + const applyDocumentChange = React.useCallback( + (next: string): void => { + if (next !== docHistoryRef.current.current) { + docHistoryRef.current = pushDocHistory(docHistoryRef.current, next) + } + selfChangeRef.current = true + onChange(next) + }, + [onChange] + ) + + const performDocUndo = React.useCallback((): boolean => { + const next = undoDoc(docHistoryRef.current) + if (!next) return false + docHistoryRef.current = next + skipBlurCommitRef.current = true + setEditingKey(null) + applyDocumentChange(next.current) + return true + }, [applyDocumentChange]) + + const performDocRedo = React.useCallback((): boolean => { + const next = redoDoc(docHistoryRef.current) + if (!next) return false + docHistoryRef.current = next + skipBlurCommitRef.current = true + setEditingKey(null) + applyDocumentChange(next.current) + return true + }, [applyDocumentChange]) + + const isLiveTextareaFocused = React.useCallback((): boolean => { + const root = innerScrollRef.current + const active = document.activeElement + if (!root || !(active instanceof HTMLTextAreaElement)) return false + if (!root.contains(active)) return false + return ( + active.classList.contains('live-line-input') || + active.classList.contains('live-block-input') + ) + }, []) + + /** 整篇撤销/重做:非创作行内编辑框,且焦点不在其它表单控件上 */ + const shouldUseDocHistory = React.useCallback((): boolean => { + if (isLiveTextareaFocused()) return false + const active = document.activeElement + if (!active) return true + if (active instanceof HTMLInputElement || active instanceof HTMLSelectElement) { + return false + } + if (active instanceof HTMLElement && active.isContentEditable) return false + return true + }, [isLiveTextareaFocused]) + + const runToolbarAction = React.useCallback( + async (id: string): Promise => { + if (editingKey && inputRef.current) { + await applyToTextarea(id) + return + } + + let line = lastActiveLineRef.current + if (line < 0 || line >= lines.length) line = 0 + + pendingActionRef.current = id + setDraft(lines[line] ?? '') + setEditingKey(editKey({ type: 'line', line })) + }, + [editingKey, lines, applyToTextarea] + ) + + const handleEditorCommand = React.useCallback( + (e: KeyboardEvent): boolean => { + const cmd = liveEditorCommand(e) + if (!cmd) return false + + if (cmd.type === 'undo') { + if (!shouldUseDocHistory()) return false + if (!canUndoDoc(docHistoryRef.current)) return false + e.preventDefault() + e.stopPropagation() + performDocUndo() + return true + } + + if (cmd.type === 'redo') { + if (!shouldUseDocHistory()) return false + if (!canRedoDoc(docHistoryRef.current)) return false + e.preventDefault() + e.stopPropagation() + performDocRedo() + return true + } + + e.preventDefault() + e.stopPropagation() + void runToolbarAction(cmd.id) + return true + }, + [shouldUseDocHistory, performDocUndo, performDocRedo, runToolbarAction] + ) + + React.useImperativeHandle( + ref, + () => ({ + applyAction: async (id: string) => { + await runToolbarAction(id) + }, + undo: (): boolean => { + if (!shouldUseDocHistory()) { + return document.execCommand('undo') + } + return performDocUndo() + }, + redo: (): boolean => { + if (!shouldUseDocHistory()) { + return document.execCommand('redo') + } + return performDocRedo() + }, + searchText: (_query: string): boolean => false, + getView: (): null => null, + getScrollTop: (): number => innerScrollRef.current?.scrollTop ?? 0, + setScrollTop: (top: number): void => { + if (innerScrollRef.current) innerScrollRef.current.scrollTop = top + } + }), + [runToolbarAction, shouldUseDocHistory, performDocUndo, performDocRedo] + ) const setScrollRef = React.useCallback( (el: HTMLDivElement | null) => { @@ -111,9 +302,21 @@ export function LiveLineView({ ) React.useEffect(() => { + docHistoryRef.current = createDocHistory(content) + selfChangeRef.current = false setEditingKey(null) }, [activeId]) + React.useEffect(() => { + if (selfChangeRef.current) { + selfChangeRef.current = false + docHistoryRef.current = { ...docHistoryRef.current, current: content } + return + } + docHistoryRef.current = createDocHistory(content) + setEditingKey(null) + }, [content]) + React.useEffect(() => { if (!editingKey) return const ta = inputRef.current @@ -122,7 +325,31 @@ export function LiveLineView({ const len = ta.value.length ta.setSelectionRange(len, len) resizeTextarea(ta) - }, [editingKey]) + + const pending = pendingActionRef.current + if (pending) { + pendingActionRef.current = null + void applyToTextarea(pending) + } + }, [editingKey, applyToTextarea]) + + React.useEffect(() => { + const root = innerScrollRef.current + if (!root) return + const onKeyDown = (e: KeyboardEvent): void => { + const cmd = liveEditorCommand(e) + if (cmd?.type === 'undo' || cmd?.type === 'redo') { + handleEditorCommand(e) + return + } + const active = document.activeElement + const inLive = root.contains(active) || root.contains(e.target as Node) + if (!inLive) return + handleEditorCommand(e) + } + window.addEventListener('keydown', onKeyDown, true) + return () => window.removeEventListener('keydown', onKeyDown, true) + }, [handleEditorCommand]) React.useEffect(() => { if (!gotoLine) return @@ -161,18 +388,20 @@ export function LiveLineView({ } else { nextLines = applyBlockEdit(lines, target.startLine, target.endLine, text) } - onChange(joinLines(nextLines)) + applyDocumentChange(joinLines(nextLines)) setEditingKey(null) }, - [lines, onChange] + [lines, applyDocumentChange] ) const startLineEdit = React.useCallback((line: number): void => { + lastActiveLineRef.current = line setDraft(lines[line] ?? '') setEditingKey(editKey({ type: 'line', line })) }, [lines]) const startBlockEdit = React.useCallback((seg: LiveBlockSegment): void => { + lastActiveLineRef.current = seg.startLine setDraft(seg.text) setEditingKey(editKey({ type: 'block', startLine: seg.startLine, endLine: seg.endLine })) }, []) @@ -216,6 +445,7 @@ export function LiveLineView({ resizeTextarea(e.target) }} onKeyDown={(e) => { + if (handleEditorCommand(e.nativeEvent)) return if (e.key === 'Escape') { e.preventDefault() cancelEdit() @@ -226,14 +456,13 @@ export function LiveLineView({ } }} onBlur={() => { - if (skipBlurCommitRef.current) { - skipBlurCommitRef.current = false - return - } + if (skipBlurCommitRef.current) return commitEdit(target, draft) }} /> - Esc 取消 · Ctrl+Enter 保存 + + Esc 取消 · Ctrl+Enter 保存 · Ctrl+Z/Y 撤销重做(行内编辑时用浏览器撤销) +
) } @@ -261,6 +490,7 @@ export function LiveLineView({ resizeTextarea(e.target) }} onKeyDown={(e) => { + if (handleEditorCommand(e.nativeEvent)) return if (e.key === 'Escape') { e.preventDefault() cancelEdit() @@ -271,15 +501,17 @@ export function LiveLineView({ } }} onBlur={() => { - if (skipBlurCommitRef.current) { - skipBlurCommitRef.current = false - return - } + if (skipBlurCommitRef.current) return commitEdit(target, draft) }} /> - {seg.blockKind === 'diagram' ? '图表源码' : '代码块'} · Esc 取消 · Ctrl+Enter 保存 + {seg.blockKind === 'diagram' + ? '图表源码' + : seg.blockKind === 'table' + ? '表格 Markdown' + : '代码块'} + · Esc 取消 · Ctrl+Enter 保存 · Ctrl+Z/Y 撤销重做 ) @@ -291,7 +523,11 @@ export function LiveLineView({ if (editingKey === key) return renderBlockEditor(seg) const title = - seg.blockKind === 'diagram' ? '点击编辑图表(整块)' : '点击编辑代码块(整块)' + seg.blockKind === 'diagram' + ? '点击编辑图表(整块)' + : seg.blockKind === 'table' + ? '点击编辑表格(整块)' + : '点击编辑代码块(整块)' return (
- {seg.blockKind === 'diagram' ? '点击编辑图表' : '点击编辑代码'} + {seg.blockKind === 'diagram' + ? '点击编辑图表' + : seg.blockKind === 'table' + ? '点击编辑表格' + : '点击编辑代码'}
) @@ -361,4 +601,4 @@ export function LiveLineView({ ) -} +}) diff --git a/src/renderer/src/MarkdownFormatToolbar.tsx b/src/renderer/src/MarkdownFormatToolbar.tsx index 9661319..4d1aed4 100644 --- a/src/renderer/src/MarkdownFormatToolbar.tsx +++ b/src/renderer/src/MarkdownFormatToolbar.tsx @@ -1,34 +1,34 @@ import * as React from 'react' import type { MarkdownEditorHandle } from './EditorPane' -const GROUPS: { label: string; buttons: { id: string; label: string }[] }[] = [ +const GROUPS: { label: string; buttons: { id: string; label: string; title?: string }[] }[] = [ { label: '标题', buttons: [ - { id: 'h1', label: 'H1' }, - { id: 'h2', label: 'H2' }, - { id: 'h3', label: 'H3' }, - { id: 'h4', label: 'H4' }, - { id: 'h5', label: 'H5' }, - { id: 'h6', label: 'H6' } + { id: 'h1', label: 'H1', title: '一级标题 (Ctrl+Shift+1)' }, + { id: 'h2', label: 'H2', title: '二级标题 (Ctrl+Shift+2)' }, + { id: 'h3', label: 'H3', title: '三级标题 (Ctrl+Shift+3)' }, + { id: 'h4', label: 'H4', title: '四级标题 (Ctrl+Shift+4)' }, + { id: 'h5', label: 'H5', title: '五级标题 (Ctrl+Shift+5)' }, + { id: 'h6', label: 'H6', title: '六级标题 (Ctrl+Shift+6)' } ] }, { label: '格式', buttons: [ - { id: 'bold', label: 'B' }, - { id: 'italic', label: 'I' }, - { id: 'strike', label: 'S' }, - { id: 'code', label: '`' }, - { id: 'mark', label: '高亮' }, - { id: 'color', label: '颜色' } + { id: 'bold', label: 'B', title: '加粗 (Ctrl+B)' }, + { id: 'italic', label: 'I', title: '斜体 (Ctrl+I)' }, + { id: 'strike', label: 'S', title: '删除线 (Ctrl+Shift+X)' }, + { id: 'code', label: '`', title: '行内代码 (Ctrl+`)' }, + { id: 'mark', label: '高亮', title: '高亮 (Ctrl+M)' }, + { id: 'color', label: '颜色', title: '文字颜色 (Ctrl+L)' } ] }, { label: '元素', buttons: [ - { id: 'link', label: '链接' }, - { id: 'image', label: '图片' }, + { id: 'link', label: '链接', title: '链接 (Ctrl+K)' }, + { id: 'image', label: '图片', title: '图片 (Ctrl+Shift+I)' }, { id: 'codeBlock', label: '代码块' }, { id: 'table', label: '表格' }, { id: 'hr', label: '分隔线' }, @@ -71,7 +71,8 @@ export function MarkdownFormatToolbar({ editorRef }: Props): React.ReactElement