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