创作视图优化
This commit is contained in:
+10
-2
@@ -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' },
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +94,8 @@ function LiveBlockPreview({
|
||||
)
|
||||
}
|
||||
|
||||
export function LiveLineView({
|
||||
export const LiveLineView = React.forwardRef<MarkdownEditorHandle, Props>(function LiveLineView(
|
||||
{
|
||||
content,
|
||||
baseFilePath,
|
||||
activeId,
|
||||
@@ -90,8 +106,13 @@ export function LiveLineView({
|
||||
resolveRelativePath,
|
||||
onOpenMarkdown,
|
||||
onOpenExternal,
|
||||
onChange
|
||||
}: Props): React.ReactElement {
|
||||
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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, `)})`)
|
||||
}
|
||||
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, `})`)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user