创作视图优化

This commit is contained in:
2026-05-19 16:37:44 +08:00
parent 0f104ffed3
commit d744d6c049
12 changed files with 753 additions and 54 deletions
+10 -2
View File
@@ -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' },
+8 -2
View File
@@ -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 (
+4
View File
@@ -181,6 +181,7 @@ export function ContentArea(props: ContentAreaProps): React.ReactElement {
{viewMode === 'live' ? (
activeTab ? (
<LiveLineView
ref={editorRef}
content={activeTab.content}
baseFilePath={baseFilePath}
activeId={activeId}
@@ -192,6 +193,9 @@ export function ContentArea(props: ContentAreaProps): React.ReactElement {
onOpenMarkdown={onOpenMarkdown}
onOpenExternal={onOpenExternal}
onChange={onEditorChange}
requestText={onRequestText}
requestImageDetails={onRequestImageDetails}
requestColor={onRequestColor}
/>
) : (
<div className="content-empty">
+20 -1
View File
@@ -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<void>
undo: () => boolean
redo: () => boolean
searchText: (query: string) => boolean
getView: () => EditorView | null
getScrollTop: () => number
@@ -86,6 +95,16 @@ export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
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
+270 -30
View File
@@ -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<string | null>
requestImageDetails?: () => Promise<{ url: string; alt: string } | null>
requestColor?: () => Promise<string | null>
}
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<string>
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<MarkdownEditorHandle, Props>(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<string | null>(null)
@@ -99,6 +120,176 @@ export function LiveLineView({
const inputRef = React.useRef<HTMLTextAreaElement | null>(null)
const innerScrollRef = React.useRef<HTMLDivElement | null>(null)
const skipBlurCommitRef = React.useRef(false)
const lastActiveLineRef = React.useRef(0)
const pendingActionRef = React.useRef<string | null>(null)
const docHistoryRef = React.useRef(createDocHistory(content))
const selfChangeRef = React.useRef(false)
const actionDepsRef = React.useRef<ActionDeps>({
requestText: (title, def) => Promise.resolve(window.prompt(title, def))
})
actionDepsRef.current = {
requestText:
requestText ??
((title: string, def: string) => Promise.resolve<string | null>(window.prompt(title, def))),
requestImageDetails,
requestColor
}
const applyToTextarea = React.useCallback(
async (id: string): Promise<void> => {
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<void> => {
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)
}}
/>
<span className="live-line-edit-hint">Esc · Ctrl+Enter </span>
<span className="live-line-edit-hint">
Esc · Ctrl+Enter · Ctrl+Z/Y
</span>
</div>
)
}
@@ -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)
}}
/>
<span className="live-line-edit-hint">
{seg.blockKind === 'diagram' ? '图表源码' : '代码块'} · Esc · Ctrl+Enter
{seg.blockKind === 'diagram'
? '图表源码'
: seg.blockKind === 'table'
? '表格 Markdown'
: '代码块'}
· Esc · Ctrl+Enter · Ctrl+Z/Y
</span>
</div>
)
@@ -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 (
<div
@@ -322,7 +558,11 @@ export function LiveLineView({
onOpenExternal={onOpenExternal}
/>
<span className="live-block-edit-badge" aria-hidden>
{seg.blockKind === 'diagram' ? '点击编辑图表' : '点击编辑代码'}
{seg.blockKind === 'diagram'
? '点击编辑图表'
: seg.blockKind === 'table'
? '点击编辑表格'
: '点击编辑代码'}
</span>
</div>
)
@@ -361,4 +601,4 @@ export function LiveLineView({
</div>
</div>
)
}
})
+17 -16
View File
@@ -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
<button
key={b.id}
type="button"
title={b.label}
title={b.title ?? b.label}
onMouseDown={(e) => e.preventDefault()}
onClick={() => void editorRef.current?.applyAction(b.id)}
>
{b.label}
+58 -1
View File
@@ -16,7 +16,15 @@ function ensureMermaid(): void {
fontFamily: 'inherit'
},
flowchart: { useMaxWidth: false, htmlLabels: true, wrappingWidth: 220 },
sequence: { useMaxWidth: false, wrap: true, width: 200 },
sequence: {
useMaxWidth: false,
wrap: true,
wrapPadding: 14,
width: 200,
noteMargin: 12,
noteAlign: 'left',
messageMargin: 40
},
er: { useMaxWidth: false },
gantt: { useMaxWidth: false },
mindmap: { useMaxWidth: false }
@@ -90,6 +98,53 @@ async function renderMermaidIn(root: HTMLElement, isStale: () => boolean): Promi
}
}
/** 序列图 Note:按实际文字包围盒收紧背景 rect(修复背景比文字短/窄导致遮挡) */
function fixSequenceDiagramNotes(svg: SVGSVGElement): void {
const margin = 12
svg.querySelectorAll('g[data-et="note"]').forEach((group) => {
if (!(group instanceof SVGGElement)) return
const rect = group.querySelector('rect')
if (!rect) return
const texts = group.querySelectorAll<SVGTextElement>('text')
if (texts.length === 0) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const text of texts) {
let bb: DOMRect
try {
bb = text.getBBox()
} catch {
continue
}
if (!Number.isFinite(bb.width) || !Number.isFinite(bb.height)) continue
if (bb.width <= 0 && bb.height <= 0) continue
minX = Math.min(minX, bb.x)
minY = Math.min(minY, bb.y)
maxX = Math.max(maxX, bb.x + bb.width)
maxY = Math.max(maxY, bb.y + bb.height)
}
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return
const pad = margin
const x = Math.floor(minX - pad)
const y = Math.floor(minY - pad)
const w = Math.ceil(maxX - minX + pad * 2)
const h = Math.ceil(maxY - minY + pad * 2)
rect.setAttribute('x', String(x))
rect.setAttribute('y', String(y))
rect.setAttribute('width', String(Math.max(w, 24)))
rect.setAttribute('height', String(Math.max(h, 20)))
})
}
function diagramAvailableWidth(wrap: HTMLElement): number {
let w = wrap.clientWidth
if (w < 80) {
@@ -104,6 +159,8 @@ function tuneMermaidDiagram(host: HTMLElement, wrap: HTMLElement): void {
const svg = host.querySelector('svg')
if (!svg) return
fixSequenceDiagramNotes(svg)
svg.style.transform = ''
svg.style.transformOrigin = ''
host.style.minHeight = ''
+19
View File
@@ -1831,6 +1831,16 @@ html[data-bg='22'] .app-root {
overflow: visible;
}
/* 序列图 Note:避免文字被背景裁切 */
.preview .md-diagram-wrap .mermaid-render-host svg g[data-et='note'] {
overflow: visible;
}
.preview .md-diagram-wrap .mermaid-render-host svg g[data-et='note'] text,
.preview .md-diagram-wrap .mermaid-render-host svg g[data-et='note'] .noteText {
overflow: visible;
}
.preview .md-diagram-wrap .mermaid-error {
margin: 0;
padding: 12px;
@@ -2585,6 +2595,15 @@ html[data-bg='22'] .app-root {
padding: 20px 16px 24px;
}
.live-block-preview-inner--table .md-table-wrap {
margin: 0;
width: 100%;
}
.live-block--table.live-block--preview {
padding: 8px 0;
}
.live-block--preview .live-block-preview-inner a {
pointer-events: auto;
}
+48
View File
@@ -0,0 +1,48 @@
/** 创作视图整篇文档的撤销 / 重做栈(与 CodeMirror history 行为对齐) */
const MAX_HISTORY = 100
export type DocHistory = {
undoStack: string[]
redoStack: string[]
current: string
}
export function createDocHistory(initial: string): DocHistory {
return { undoStack: [], redoStack: [], current: initial }
}
/** 提交新文档内容;将变更前的 current 压入 undo,并清空 redo。 */
export function pushDocHistory(h: DocHistory, next: string): DocHistory {
if (next === h.current) return h
const undoStack = [...h.undoStack, h.current].slice(-MAX_HISTORY)
return { undoStack, redoStack: [], current: next }
}
export function undoDoc(h: DocHistory): DocHistory | null {
if (h.undoStack.length === 0) return null
const previous = h.undoStack[h.undoStack.length - 1]!
return {
undoStack: h.undoStack.slice(0, -1),
redoStack: [...h.redoStack, h.current],
current: previous
}
}
export function redoDoc(h: DocHistory): DocHistory | null {
if (h.redoStack.length === 0) return null
const next = h.redoStack[h.redoStack.length - 1]!
return {
undoStack: [...h.undoStack, h.current],
redoStack: h.redoStack.slice(0, -1),
current: next
}
}
export function canUndoDoc(h: DocHistory): boolean {
return h.undoStack.length > 0
}
export function canRedoDoc(h: DocHistory): boolean {
return h.redoStack.length > 0
}
+61 -2
View File
@@ -8,7 +8,7 @@ export type LiveLineSegment = {
export type LiveBlockSegment = {
kind: 'block'
blockKind: 'code' | 'diagram'
blockKind: 'code' | 'diagram' | 'table'
startLine: number
endLine: number
text: string
@@ -22,7 +22,52 @@ function isFenceClose(line: string, fenceChar: string, fenceLen: number): boolea
return new RegExp(`^\\s*\\${fenceChar}{${fenceLen},}\\s*$`).test(line)
}
/** 将 Markdown 源文拆成普通行段与围栏代码块(含 Mermaid)段。 */
/** GFM 表格分隔行:| --- | :---: | ---: | */
export function isTableSeparatorRow(line: string): boolean {
const t = line.trim()
if (!t || !/-{3,}/.test(t)) return false
let body = t
if (body.startsWith('|')) body = body.slice(1)
if (body.endsWith('|')) body = body.slice(0, -1)
const cells = body.length > 0 ? body.split('|') : [t]
if (cells.length === 0) return false
return cells.every((cell) => /^\s*:?-{3,}:?\s*$/.test(cell))
}
function splitTableCells(line: string): string[] {
let t = line.trim()
if (t.startsWith('|')) t = t.slice(1)
if (t.endsWith('|')) t = t.slice(0, -1)
return t.split('|')
}
/** 表格数据行(含表头行) */
export function isTableRow(line: string): boolean {
const t = line.trim()
if (!t) return false
if (FENCE_OPEN_RE.test(line)) return false
if (isTableSeparatorRow(line)) return true
if (!t.includes('|')) return false
return splitTableCells(line).length >= 2
}
/** 从 start 起是否为完整 GFM 表格(表头 + 分隔行 + 可选数据行) */
export function scanTableBlockEnd(lines: string[], start: number): number | null {
if (start + 1 >= lines.length) return null
if (!isTableRow(lines[start] ?? '') || !isTableSeparatorRow(lines[start + 1] ?? '')) {
return null
}
let end = start + 1
while (end + 1 < lines.length) {
const next = lines[end + 1] ?? ''
if (!next.trim()) break
if (!isTableRow(next) || isTableSeparatorRow(next)) break
end++
}
return end
}
/** 将 Markdown 源文拆成普通行段、围栏块(含 Mermaid)与表格块。 */
export function parseLiveDocumentSegments(src: string): LiveSegment[] {
const lines = src.split(/\r?\n/)
const segments: LiveSegment[] = []
@@ -52,6 +97,20 @@ export function parseLiveDocumentSegments(src: string): LiveSegment[] {
continue
}
const tableEnd = scanTableBlockEnd(lines, i)
if (tableEnd !== null) {
const text = lines.slice(i, tableEnd + 1).join('\n')
segments.push({
kind: 'block',
blockKind: 'table',
startLine: i,
endLine: tableEnd,
text
})
i = tableEnd + 1
continue
}
segments.push({ kind: 'line', line: i, text: line })
i++
}
+92
View File
@@ -0,0 +1,92 @@
/** 创作视图快捷键:格式、撤销/重做、编辑类(与 CodeMirror defaultKeymap / historyKeymap 对齐) */
const MODAL_ACTIONS = new Set(['link', 'image', 'color'])
export function isLiveModalAction(id: string): boolean {
return MODAL_ACTIONS.has(id)
}
export type LiveEditorCommand =
| { type: 'format'; id: string }
| { type: 'undo' }
| { type: 'redo' }
function liveFormatShortcut(e: KeyboardEvent): string | null {
if (!(e.ctrlKey || e.metaKey) || e.altKey) return null
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key
if (e.shiftKey) {
switch (key) {
case 'x':
return 'strike'
case 'k':
return 'codeBlock'
case 'i':
return 'image'
case 'h':
return 'hr'
case 't':
return 'table'
case 'q':
return 'quote'
case '7':
return 'details'
default:
break
}
if (key >= '1' && key <= '6') {
return `h${key}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
}
return null
}
switch (key) {
case 'b':
return 'bold'
case 'i':
return 'italic'
case 'k':
return 'link'
case '`':
return 'code'
case 'm':
return 'mark'
case 'l':
return 'color'
case 'u':
return 'ul'
case '>':
return 'quote'
default:
return null
}
}
/**
* 解析创作视图快捷键。
* 撤销/重做:编辑框内由浏览器原生处理;未编辑时由文档历史栈处理。
*/
export function liveEditorCommand(e: KeyboardEvent): LiveEditorCommand | null {
if (!(e.ctrlKey || e.metaKey) || e.altKey) return null
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key
if (key === 'z') {
return e.shiftKey ? { type: 'redo' } : { type: 'undo' }
}
if (key === 'y') {
return { type: 'redo' }
}
const formatId = liveFormatShortcut(e)
if (formatId) return { type: 'format', id: formatId }
return null
}
/** @deprecated 使用 liveEditorCommand */
export function liveShortcutAction(e: KeyboardEvent): string | null {
const cmd = liveEditorCommand(e)
return cmd?.type === 'format' ? cmd.id : null
}
+146
View File
@@ -0,0 +1,146 @@
import {
escapeMarkdownLinkFragment,
sanitizeInlineColor,
sanitizeLinkUrl,
type ActionDeps
} from './markdown-actions'
export type TextSelection = { start: number; end: number }
export type TextEditResult = {
value: string
selection: TextSelection
}
function wrap(
value: string,
sel: TextSelection,
left: string,
right: string,
placeholder = ''
): TextEditResult {
const selected = value.slice(sel.start, sel.end)
const inner = selected || placeholder
const next = value.slice(0, sel.start) + left + inner + right + value.slice(sel.end)
const start = sel.start + left.length
return { value: next, selection: { start, end: start + inner.length } }
}
function insert(
value: string,
sel: TextSelection,
text: string
): TextEditResult {
const next = value.slice(0, sel.start) + text + value.slice(sel.end)
const pos = sel.start + text.length
return { value: next, selection: { start: pos, end: pos } }
}
function linePrefix(value: string, prefix: string): TextEditResult {
let rest = value
rest = rest.replace(/^#+\s*/, '')
rest = rest.replace(/^[-*+]\s+(\[[ xX]\]\s+)?/, '')
rest = rest.replace(/^>\s*/, '')
rest = rest.replace(/^\d+\.\s+/, '')
const next = prefix + rest
return { value: next, selection: { start: next.length, end: next.length } }
}
export async function applyLiveMarkdownAction(
value: string,
sel: TextSelection,
id: string,
deps: ActionDeps
): Promise<TextEditResult | null> {
const { requestText, requestImageDetails, requestColor } = deps
try {
switch (id) {
case 'bold':
return wrap(value, sel, '**', '**', '文本')
case 'italic':
return wrap(value, sel, '*', '*', '文本')
case 'strike':
return wrap(value, sel, '~~', '~~', '文本')
case 'code':
return wrap(value, sel, '`', '`', '代码')
case 'mark':
return wrap(value, sel, '<mark>', '</mark>', '高亮')
case 'sup':
return wrap(value, sel, '<sup>', '</sup>', '上标')
case 'sub':
return wrap(value, sel, '<sub>', '</sub>', '下标')
case 'kbd':
return wrap(value, sel, '<kbd>', '</kbd>', '键')
case 'h1':
return linePrefix(value, '# ')
case 'h2':
return linePrefix(value, '## ')
case 'h3':
return linePrefix(value, '### ')
case 'h4':
return linePrefix(value, '#### ')
case 'h5':
return linePrefix(value, '##### ')
case 'h6':
return linePrefix(value, '###### ')
case 'ul':
return linePrefix(value, '- ')
case 'ol':
return linePrefix(value, '1. ')
case 'task':
return linePrefix(value, '- [ ] ')
case 'quote':
return linePrefix(value, '> ')
case 'codeBlock':
return insert(value, sel, '```\n\n```')
case 'hr':
return insert(value, sel, '\n---\n')
case 'table':
return insert(value, sel, '\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| | | |\n')
case 'details':
return insert(value, sel, '\n<details>\n<summary>标题</summary>\n内容\n</details>\n')
case 'footnote':
return insert(value, sel, '[^1]\n\n[^1]: 脚注内容')
case 'link': {
const url = await requestText('链接地址', 'https://')
if (url === null || !url.trim()) return null
const selected = value.slice(sel.start, sel.end)
let label = selected
if (!label.trim()) {
const prompted = await requestText('链接文字', '链接文字')
if (prompted === null) return null
label = prompted.trim() || '链接文字'
}
const text = `[${escapeMarkdownLinkFragment(label)}](${sanitizeLinkUrl(url.trim())})`
return insert(value, sel, text)
}
case 'image': {
if (requestImageDetails) {
const det = await requestImageDetails()
if (!det?.url?.trim()) return null
const alt = escapeMarkdownLinkFragment((det.alt ?? '图片').trim() || '图片')
return insert(value, sel, `![${alt}](${sanitizeLinkUrl(det.url.trim())})`)
}
const url = await requestText('图片地址', 'https://')
if (url === null) return null
const rawAlt = await requestText('替代文字', '图片')
if (rawAlt === null) return null
const alt = escapeMarkdownLinkFragment(rawAlt || '图片')
return insert(value, sel, `![${alt}](${sanitizeLinkUrl(url)})`)
}
case 'color': {
let colorInput: string | null = null
if (requestColor) colorInput = await requestColor()
else colorInput = await requestText('颜色(如 #e11 或 red', '#dc2626')
if (colorInput === null || !colorInput.trim()) return null
const color = sanitizeInlineColor(colorInput)
return wrap(value, sel, `<span style="color:${color}">`, '</span>', '文本')
}
default:
return null
}
} catch (e) {
console.error('[MyReader] live toolbar action failed', id, e)
return null
}
}