创作视图优化
This commit is contained in:
+10
-2
@@ -157,8 +157,16 @@ function buildMenu(): Menu {
|
|||||||
{
|
{
|
||||||
label: '编辑',
|
label: '编辑',
|
||||||
submenu: [
|
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' },
|
{ type: 'separator' },
|
||||||
{ label: '剪切', accelerator: 'CmdOrCtrl+X', role: 'cut' },
|
{ label: '剪切', accelerator: 'CmdOrCtrl+X', role: 'cut' },
|
||||||
{ label: '复制', accelerator: 'CmdOrCtrl+C', role: 'copy' },
|
{ label: '复制', accelerator: 'CmdOrCtrl+C', role: 'copy' },
|
||||||
|
|||||||
@@ -1108,6 +1108,12 @@ ${previewHtml}
|
|||||||
case 'toggle-read':
|
case 'toggle-read':
|
||||||
cycleViewMode()
|
cycleViewMode()
|
||||||
break
|
break
|
||||||
|
case 'edit-undo':
|
||||||
|
editorRef.current?.undo() ?? document.execCommand('undo')
|
||||||
|
break
|
||||||
|
case 'edit-redo':
|
||||||
|
editorRef.current?.redo() ?? document.execCommand('redo')
|
||||||
|
break
|
||||||
case 'help-about':
|
case 'help-about':
|
||||||
window.alert(
|
window.alert(
|
||||||
" Huangzhijun's Reader 0.1 — 本地 Markdown 阅读与编辑 \n \n 作者:黄志军 \n 支持与协助(wx):liaofan199404"
|
" Huangzhijun's Reader 0.1 — 本地 Markdown 阅读与编辑 \n \n 作者:黄志军 \n 支持与协助(wx):liaofan199404"
|
||||||
@@ -1154,7 +1160,7 @@ ${previewHtml}
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
cycleViewMode()
|
cycleViewMode()
|
||||||
}
|
}
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'b') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'b' && viewMode !== 'live') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSidebarHidden((v) => !v)
|
setSidebarHidden((v) => !v)
|
||||||
}
|
}
|
||||||
@@ -1218,7 +1224,7 @@ ${previewHtml}
|
|||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
return () => window.removeEventListener('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) {
|
if (!api) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ export function ContentArea(props: ContentAreaProps): React.ReactElement {
|
|||||||
{viewMode === 'live' ? (
|
{viewMode === 'live' ? (
|
||||||
activeTab ? (
|
activeTab ? (
|
||||||
<LiveLineView
|
<LiveLineView
|
||||||
|
ref={editorRef}
|
||||||
content={activeTab.content}
|
content={activeTab.content}
|
||||||
baseFilePath={baseFilePath}
|
baseFilePath={baseFilePath}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
@@ -192,6 +193,9 @@ export function ContentArea(props: ContentAreaProps): React.ReactElement {
|
|||||||
onOpenMarkdown={onOpenMarkdown}
|
onOpenMarkdown={onOpenMarkdown}
|
||||||
onOpenExternal={onOpenExternal}
|
onOpenExternal={onOpenExternal}
|
||||||
onChange={onEditorChange}
|
onChange={onEditorChange}
|
||||||
|
requestText={onRequestText}
|
||||||
|
requestImageDetails={onRequestImageDetails}
|
||||||
|
requestColor={onRequestColor}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="content-empty">
|
<div className="content-empty">
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import * as React from 'react'
|
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 { markdown } from '@codemirror/lang-markdown'
|
||||||
import { EditorState } from '@codemirror/state'
|
import { EditorState } from '@codemirror/state'
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +20,8 @@ import { applyMarkdownAction } from './markdown-actions'
|
|||||||
|
|
||||||
export type MarkdownEditorHandle = {
|
export type MarkdownEditorHandle = {
|
||||||
applyAction: (id: string) => void | Promise<void>
|
applyAction: (id: string) => void | Promise<void>
|
||||||
|
undo: () => boolean
|
||||||
|
redo: () => boolean
|
||||||
searchText: (query: string) => boolean
|
searchText: (query: string) => boolean
|
||||||
getView: () => EditorView | null
|
getView: () => EditorView | null
|
||||||
getScrollTop: () => number
|
getScrollTop: () => number
|
||||||
@@ -86,6 +95,16 @@ export const EditorPane = React.forwardRef<MarkdownEditorHandle, Props>(
|
|||||||
requestColor: requestColorRef.current
|
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 => {
|
searchText: (query: string): boolean => {
|
||||||
const view = viewRef.current
|
const view = viewRef.current
|
||||||
if (!view || !query.trim()) return false
|
if (!view || !query.trim()) return false
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ import {
|
|||||||
type LiveBlockSegment,
|
type LiveBlockSegment,
|
||||||
type LiveSegment
|
type LiveSegment
|
||||||
} from './liveDocumentSegments'
|
} 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 = {
|
type Props = {
|
||||||
content: string
|
content: string
|
||||||
@@ -21,6 +33,9 @@ type Props = {
|
|||||||
onOpenMarkdown?: (path: string, anchorId?: string) => void
|
onOpenMarkdown?: (path: string, anchorId?: string) => void
|
||||||
onOpenExternal?: (url: string) => void
|
onOpenExternal?: (url: string) => void
|
||||||
onChange: (next: 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[] {
|
function splitLines(doc: string): string[] {
|
||||||
@@ -52,7 +67,7 @@ function resizeTextarea(ta: HTMLTextAreaElement): void {
|
|||||||
type BlockPreviewProps = {
|
type BlockPreviewProps = {
|
||||||
html: string
|
html: string
|
||||||
baseFilePath: string | null
|
baseFilePath: string | null
|
||||||
blockKind: 'code' | 'diagram'
|
blockKind: 'code' | 'diagram' | 'table'
|
||||||
resolveRelativePath?: (base: string, href: string) => Promise<string>
|
resolveRelativePath?: (base: string, href: string) => Promise<string>
|
||||||
onOpenMarkdown?: (path: string, anchorId?: string) => void
|
onOpenMarkdown?: (path: string, anchorId?: string) => void
|
||||||
onOpenExternal?: (url: string) => void
|
onOpenExternal?: (url: string) => void
|
||||||
@@ -79,19 +94,25 @@ function LiveBlockPreview({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LiveLineView({
|
export const LiveLineView = React.forwardRef<MarkdownEditorHandle, Props>(function LiveLineView(
|
||||||
content,
|
{
|
||||||
baseFilePath,
|
content,
|
||||||
activeId,
|
baseFilePath,
|
||||||
gotoLine,
|
activeId,
|
||||||
previewMaxWidth,
|
gotoLine,
|
||||||
scrollRef,
|
previewMaxWidth,
|
||||||
articleRef,
|
scrollRef,
|
||||||
resolveRelativePath,
|
articleRef,
|
||||||
onOpenMarkdown,
|
resolveRelativePath,
|
||||||
onOpenExternal,
|
onOpenMarkdown,
|
||||||
onChange
|
onOpenExternal,
|
||||||
}: Props): React.ReactElement {
|
onChange,
|
||||||
|
requestText,
|
||||||
|
requestImageDetails,
|
||||||
|
requestColor
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
): React.ReactElement {
|
||||||
const lines = React.useMemo(() => splitLines(content), [content])
|
const lines = React.useMemo(() => splitLines(content), [content])
|
||||||
const segments = React.useMemo(() => parseLiveDocumentSegments(content), [content])
|
const segments = React.useMemo(() => parseLiveDocumentSegments(content), [content])
|
||||||
const [editingKey, setEditingKey] = React.useState<string | null>(null)
|
const [editingKey, setEditingKey] = React.useState<string | null>(null)
|
||||||
@@ -99,6 +120,176 @@ export function LiveLineView({
|
|||||||
const inputRef = React.useRef<HTMLTextAreaElement | null>(null)
|
const inputRef = React.useRef<HTMLTextAreaElement | null>(null)
|
||||||
const innerScrollRef = React.useRef<HTMLDivElement | null>(null)
|
const innerScrollRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
const skipBlurCommitRef = React.useRef(false)
|
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(
|
const setScrollRef = React.useCallback(
|
||||||
(el: HTMLDivElement | null) => {
|
(el: HTMLDivElement | null) => {
|
||||||
@@ -111,9 +302,21 @@ export function LiveLineView({
|
|||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
docHistoryRef.current = createDocHistory(content)
|
||||||
|
selfChangeRef.current = false
|
||||||
setEditingKey(null)
|
setEditingKey(null)
|
||||||
}, [activeId])
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (!editingKey) return
|
if (!editingKey) return
|
||||||
const ta = inputRef.current
|
const ta = inputRef.current
|
||||||
@@ -122,7 +325,31 @@ export function LiveLineView({
|
|||||||
const len = ta.value.length
|
const len = ta.value.length
|
||||||
ta.setSelectionRange(len, len)
|
ta.setSelectionRange(len, len)
|
||||||
resizeTextarea(ta)
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!gotoLine) return
|
if (!gotoLine) return
|
||||||
@@ -161,18 +388,20 @@ export function LiveLineView({
|
|||||||
} else {
|
} else {
|
||||||
nextLines = applyBlockEdit(lines, target.startLine, target.endLine, text)
|
nextLines = applyBlockEdit(lines, target.startLine, target.endLine, text)
|
||||||
}
|
}
|
||||||
onChange(joinLines(nextLines))
|
applyDocumentChange(joinLines(nextLines))
|
||||||
setEditingKey(null)
|
setEditingKey(null)
|
||||||
},
|
},
|
||||||
[lines, onChange]
|
[lines, applyDocumentChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
const startLineEdit = React.useCallback((line: number): void => {
|
const startLineEdit = React.useCallback((line: number): void => {
|
||||||
|
lastActiveLineRef.current = line
|
||||||
setDraft(lines[line] ?? '')
|
setDraft(lines[line] ?? '')
|
||||||
setEditingKey(editKey({ type: 'line', line }))
|
setEditingKey(editKey({ type: 'line', line }))
|
||||||
}, [lines])
|
}, [lines])
|
||||||
|
|
||||||
const startBlockEdit = React.useCallback((seg: LiveBlockSegment): void => {
|
const startBlockEdit = React.useCallback((seg: LiveBlockSegment): void => {
|
||||||
|
lastActiveLineRef.current = seg.startLine
|
||||||
setDraft(seg.text)
|
setDraft(seg.text)
|
||||||
setEditingKey(editKey({ type: 'block', startLine: seg.startLine, endLine: seg.endLine }))
|
setEditingKey(editKey({ type: 'block', startLine: seg.startLine, endLine: seg.endLine }))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -216,6 +445,7 @@ export function LiveLineView({
|
|||||||
resizeTextarea(e.target)
|
resizeTextarea(e.target)
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
if (handleEditorCommand(e.nativeEvent)) return
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
cancelEdit()
|
cancelEdit()
|
||||||
@@ -226,14 +456,13 @@ export function LiveLineView({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (skipBlurCommitRef.current) {
|
if (skipBlurCommitRef.current) return
|
||||||
skipBlurCommitRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
commitEdit(target, draft)
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -261,6 +490,7 @@ export function LiveLineView({
|
|||||||
resizeTextarea(e.target)
|
resizeTextarea(e.target)
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
if (handleEditorCommand(e.nativeEvent)) return
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
cancelEdit()
|
cancelEdit()
|
||||||
@@ -271,15 +501,17 @@ export function LiveLineView({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (skipBlurCommitRef.current) {
|
if (skipBlurCommitRef.current) return
|
||||||
skipBlurCommitRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
commitEdit(target, draft)
|
commitEdit(target, draft)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="live-line-edit-hint">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -291,7 +523,11 @@ export function LiveLineView({
|
|||||||
if (editingKey === key) return renderBlockEditor(seg)
|
if (editingKey === key) return renderBlockEditor(seg)
|
||||||
|
|
||||||
const title =
|
const title =
|
||||||
seg.blockKind === 'diagram' ? '点击编辑图表(整块)' : '点击编辑代码块(整块)'
|
seg.blockKind === 'diagram'
|
||||||
|
? '点击编辑图表(整块)'
|
||||||
|
: seg.blockKind === 'table'
|
||||||
|
? '点击编辑表格(整块)'
|
||||||
|
: '点击编辑代码块(整块)'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -322,7 +558,11 @@ export function LiveLineView({
|
|||||||
onOpenExternal={onOpenExternal}
|
onOpenExternal={onOpenExternal}
|
||||||
/>
|
/>
|
||||||
<span className="live-block-edit-badge" aria-hidden>
|
<span className="live-block-edit-badge" aria-hidden>
|
||||||
{seg.blockKind === 'diagram' ? '点击编辑图表' : '点击编辑代码'}
|
{seg.blockKind === 'diagram'
|
||||||
|
? '点击编辑图表'
|
||||||
|
: seg.blockKind === 'table'
|
||||||
|
? '点击编辑表格'
|
||||||
|
: '点击编辑代码'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -361,4 +601,4 @@ export function LiveLineView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import type { MarkdownEditorHandle } from './EditorPane'
|
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: '标题',
|
label: '标题',
|
||||||
buttons: [
|
buttons: [
|
||||||
{ id: 'h1', label: 'H1' },
|
{ id: 'h1', label: 'H1', title: '一级标题 (Ctrl+Shift+1)' },
|
||||||
{ id: 'h2', label: 'H2' },
|
{ id: 'h2', label: 'H2', title: '二级标题 (Ctrl+Shift+2)' },
|
||||||
{ id: 'h3', label: 'H3' },
|
{ id: 'h3', label: 'H3', title: '三级标题 (Ctrl+Shift+3)' },
|
||||||
{ id: 'h4', label: 'H4' },
|
{ id: 'h4', label: 'H4', title: '四级标题 (Ctrl+Shift+4)' },
|
||||||
{ id: 'h5', label: 'H5' },
|
{ id: 'h5', label: 'H5', title: '五级标题 (Ctrl+Shift+5)' },
|
||||||
{ id: 'h6', label: 'H6' }
|
{ id: 'h6', label: 'H6', title: '六级标题 (Ctrl+Shift+6)' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '格式',
|
label: '格式',
|
||||||
buttons: [
|
buttons: [
|
||||||
{ id: 'bold', label: 'B' },
|
{ id: 'bold', label: 'B', title: '加粗 (Ctrl+B)' },
|
||||||
{ id: 'italic', label: 'I' },
|
{ id: 'italic', label: 'I', title: '斜体 (Ctrl+I)' },
|
||||||
{ id: 'strike', label: 'S' },
|
{ id: 'strike', label: 'S', title: '删除线 (Ctrl+Shift+X)' },
|
||||||
{ id: 'code', label: '`' },
|
{ id: 'code', label: '`', title: '行内代码 (Ctrl+`)' },
|
||||||
{ id: 'mark', label: '高亮' },
|
{ id: 'mark', label: '高亮', title: '高亮 (Ctrl+M)' },
|
||||||
{ id: 'color', label: '颜色' }
|
{ id: 'color', label: '颜色', title: '文字颜色 (Ctrl+L)' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '元素',
|
label: '元素',
|
||||||
buttons: [
|
buttons: [
|
||||||
{ id: 'link', label: '链接' },
|
{ id: 'link', label: '链接', title: '链接 (Ctrl+K)' },
|
||||||
{ id: 'image', label: '图片' },
|
{ id: 'image', label: '图片', title: '图片 (Ctrl+Shift+I)' },
|
||||||
{ id: 'codeBlock', label: '代码块' },
|
{ id: 'codeBlock', label: '代码块' },
|
||||||
{ id: 'table', label: '表格' },
|
{ id: 'table', label: '表格' },
|
||||||
{ id: 'hr', label: '分隔线' },
|
{ id: 'hr', label: '分隔线' },
|
||||||
@@ -71,7 +71,8 @@ export function MarkdownFormatToolbar({ editorRef }: Props): React.ReactElement
|
|||||||
<button
|
<button
|
||||||
key={b.id}
|
key={b.id}
|
||||||
type="button"
|
type="button"
|
||||||
title={b.label}
|
title={b.title ?? b.label}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => void editorRef.current?.applyAction(b.id)}
|
onClick={() => void editorRef.current?.applyAction(b.id)}
|
||||||
>
|
>
|
||||||
{b.label}
|
{b.label}
|
||||||
|
|||||||
@@ -16,7 +16,15 @@ function ensureMermaid(): void {
|
|||||||
fontFamily: 'inherit'
|
fontFamily: 'inherit'
|
||||||
},
|
},
|
||||||
flowchart: { useMaxWidth: false, htmlLabels: true, wrappingWidth: 220 },
|
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 },
|
er: { useMaxWidth: false },
|
||||||
gantt: { useMaxWidth: false },
|
gantt: { useMaxWidth: false },
|
||||||
mindmap: { 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 {
|
function diagramAvailableWidth(wrap: HTMLElement): number {
|
||||||
let w = wrap.clientWidth
|
let w = wrap.clientWidth
|
||||||
if (w < 80) {
|
if (w < 80) {
|
||||||
@@ -104,6 +159,8 @@ function tuneMermaidDiagram(host: HTMLElement, wrap: HTMLElement): void {
|
|||||||
const svg = host.querySelector('svg')
|
const svg = host.querySelector('svg')
|
||||||
if (!svg) return
|
if (!svg) return
|
||||||
|
|
||||||
|
fixSequenceDiagramNotes(svg)
|
||||||
|
|
||||||
svg.style.transform = ''
|
svg.style.transform = ''
|
||||||
svg.style.transformOrigin = ''
|
svg.style.transformOrigin = ''
|
||||||
host.style.minHeight = ''
|
host.style.minHeight = ''
|
||||||
|
|||||||
@@ -1831,6 +1831,16 @@ html[data-bg='22'] .app-root {
|
|||||||
overflow: visible;
|
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 {
|
.preview .md-diagram-wrap .mermaid-error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -2585,6 +2595,15 @@ html[data-bg='22'] .app-root {
|
|||||||
padding: 20px 16px 24px;
|
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 {
|
.live-block--preview .live-block-preview-inner a {
|
||||||
pointer-events: auto;
|
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 = {
|
export type LiveBlockSegment = {
|
||||||
kind: 'block'
|
kind: 'block'
|
||||||
blockKind: 'code' | 'diagram'
|
blockKind: 'code' | 'diagram' | 'table'
|
||||||
startLine: number
|
startLine: number
|
||||||
endLine: number
|
endLine: number
|
||||||
text: string
|
text: string
|
||||||
@@ -22,7 +22,52 @@ function isFenceClose(line: string, fenceChar: string, fenceLen: number): boolea
|
|||||||
return new RegExp(`^\\s*\\${fenceChar}{${fenceLen},}\\s*$`).test(line)
|
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[] {
|
export function parseLiveDocumentSegments(src: string): LiveSegment[] {
|
||||||
const lines = src.split(/\r?\n/)
|
const lines = src.split(/\r?\n/)
|
||||||
const segments: LiveSegment[] = []
|
const segments: LiveSegment[] = []
|
||||||
@@ -52,6 +97,20 @@ export function parseLiveDocumentSegments(src: string): LiveSegment[] {
|
|||||||
continue
|
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 })
|
segments.push({ kind: 'line', line: i, text: line })
|
||||||
i++
|
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