diff --git a/electron/main.ts b/electron/main.ts index 6f64cbd..92eff5f 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -182,7 +182,7 @@ function buildMenu(): Menu { return Menu.buildFromTemplate(template) } -function createWindow(): BrowserWindow { +function createWindow(_pendingPaths?: string[]): BrowserWindow { const preloadPath = getPreloadPath() const iconPath = resolveAppIconPath() let icon: NativeImage | undefined @@ -218,7 +218,8 @@ function createWindow(): BrowserWindow { }) if (process.env.ELECTRON_RENDERER_URL) { - void win.loadURL(process.env.ELECTRON_RENDERER_URL) + const url = new URL(process.env.ELECTRON_RENDERER_URL) + void win.loadURL(url.toString()) } else { void win.loadFile(getRendererIndexHtml()) } @@ -353,34 +354,79 @@ ipcMain.on('myreader:allow-close', (event) => { let pendingOpenPaths: string[] = [] -app.whenReady().then(() => { - Menu.setApplicationMenu(buildMenu()) - - const args = process.argv.slice(1) - for (const arg of args) { +const gotTheLock = app.requestSingleInstanceLock() + +function collectFilePathsFromArgv(): void { + for (const arg of process.argv) { + if (arg === process.argv[0]) continue + if (arg.startsWith('--')) continue if (existsSync(arg) && isMarkdownFile(arg)) { - pendingOpenPaths.push(arg) + if (!pendingOpenPaths.includes(arg)) { + pendingOpenPaths.push(arg) + } } } - - const win = createWindow() - - win.webContents.on('dom-ready', () => { - if (pendingOpenPaths.length > 0) { - setTimeout(() => { - win.webContents.send('myreader:open-files', pendingOpenPaths) - pendingOpenPaths = [] - }, 500) - } - }) - - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow() - } - }) +} + +ipcMain.handle('myreader:get-pending-files', (): string[] => { + const paths = pendingOpenPaths.slice() + pendingOpenPaths = [] + return paths }) +ipcMain.on('myreader:get-pending-files-sync', (event) => { + event.returnValue = pendingOpenPaths.slice() + pendingOpenPaths = [] +}) + +if (!gotTheLock) { + app.quit() +} else { + app.on('second-instance', (_evt, argv) => { + const win = BrowserWindow.getAllWindows()[0] + if (win) { + if (win.isMinimized()) win.restore() + win.focus() + for (const arg of argv) { + if (arg === argv[0]) continue + if (arg.startsWith('--')) continue + if (existsSync(arg) && isMarkdownFile(arg)) { + if (!win.webContents.isDestroyed()) { + win.webContents.send('myreader:open-files', [arg]) + } + } + } + } + }) + + app.on('open-file', (_evt, path) => { + if (!isMarkdownFile(path)) return + const win = BrowserWindow.getAllWindows()[0] + if (win && !win.webContents.isDestroyed() && !win.webContents.isLoading()) { + win.webContents.send('myreader:open-files', [path]) + return + } + const key = path.replace(/\\/g, '/').toLowerCase() + if (!pendingOpenPaths.some((p) => p.replace(/\\/g, '/').toLowerCase() === key)) { + pendingOpenPaths.push(path) + } + }) + + app.whenReady().then(() => { + Menu.setApplicationMenu(buildMenu()) + + collectFilePathsFromArgv() + + createWindow() + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow() + } + }) + }) +} + app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() diff --git a/electron/preload.ts b/electron/preload.ts index 4221576..cbdc425 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -7,6 +7,17 @@ import type { SaveDialogResult } from './ipc-types.js' +const bufferedOpenFiles: string[][] = [] +let openFilesHandler: ((paths: string[]) => void) | null = null + +ipcRenderer.on('myreader:open-files', (_e, paths: string[]) => { + if (openFilesHandler) { + openFilesHandler(paths) + } else { + bufferedOpenFiles.push(paths) + } +}) + export interface MyReaderApi { openFileDialog: () => Promise openFolderDialog: () => Promise @@ -16,7 +27,6 @@ export interface MyReaderApi { writeFile: (filePath: string, content: string) => Promise listDirectory: (dirPath: string) => Promise parentDirectory: (dirPath: string) => Promise - /** 相对当前 Markdown 文件解析内链 href → 绝对路径(外链原样返回) */ resolveRelativePath: (baseFilePath: string, href: string) => Promise openExternal: (url: string) => Promise defaultDirectory: () => Promise @@ -26,6 +36,7 @@ export interface MyReaderApi { onRequestClose: (handler: () => void) => () => void onMenuAction: (handler: (action: string) => void) => () => void onOpenFiles: (handler: (paths: string[]) => void) => () => void + getPendingFilesIPC: () => Promise } const api: MyReaderApi = { @@ -67,13 +78,24 @@ const api: MyReaderApi = { } }, onOpenFiles: (handler: (paths: string[]) => void) => { - const listener = (_e: unknown, paths: string[]): void => { - handler(paths) - } - ipcRenderer.on('myreader:open-files', listener) + openFilesHandler = handler return () => { - ipcRenderer.removeListener('myreader:open-files', listener) + openFilesHandler = null } + }, + getPendingFilesIPC: async (): Promise => { + const fromMain = await ipcRenderer.invoke('myreader:get-pending-files') + const fromBuffer = bufferedOpenFiles.flat() + bufferedOpenFiles.length = 0 + const seen = new Set() + const merged: string[] = [] + for (const p of [...fromMain, ...fromBuffer]) { + const key = p.replace(/\\/g, '/').toLowerCase() + if (seen.has(key)) continue + seen.add(key) + merged.push(p) + } + return merged } } @@ -81,4 +103,4 @@ try { contextBridge.exposeInMainWorld('myreader', api) } catch (e) { console.error('[MyReader] preload contextBridge failed', e) -} +} \ No newline at end of file diff --git a/package.json b/package.json index 08ad63e..1b340bd 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,18 @@ "electronDownload": { "mirror": "https://npmmirror.com/mirrors/electron/" }, + "fileAssociations": [ + { + "ext": "md", + "name": "Markdown", + "description": "Markdown 文件" + }, + { + "ext": "markdown", + "name": "Markdown", + "description": "Markdown 文件" + } + ], "win": { "icon": "resources/app-icon.png", "target": [ diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e36dc67..1e6a032 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,8 +1,6 @@ import * as React from 'react' -import { EditorPane, type MarkdownEditorHandle } from './EditorPane' +import type { MarkdownEditorHandle } from './EditorPane' import type { EditorView } from '@codemirror/view' -import { MarkdownFormatToolbar } from './MarkdownFormatToolbar' -import { PreviewArticle } from './PreviewArticle' import { extractOutline, previewLineCenterInShell, @@ -12,12 +10,13 @@ import { } from './markdown' import { addFileHistory, + clearFileHistory, loadFileHistory, removeFileHistory } from './fileHistory' import { BACKGROUNDS, FONTS, validBackgroundId, validFontId, type ViewMode } from './appearance' -import { COLOR_SWATCH_ROWS } from './colorPresets' import type { Tab } from './types' +import { AppModals } from './AppModals' import { clamp, newTab, tabLabel, isDirty, loadViewMode, viewModeLabel, loadPreviewLineHeight, normalizePathKey } from './utils' import { BG_KEY, FONT_KEY, VIEW_KEY, BROWSE_KEY, SIDEBAR_W_KEY, SIDEBAR_HIDDEN_KEY, @@ -26,6 +25,7 @@ import { import { TabBar } from './TabBar' import { Sidebar } from './Sidebar' import { StatsPanel } from './StatsPanel' +import { ContentArea } from './ContentArea' type SessionTabRow = { path: string | null; content: string; saved: string } type SessionV1 = { v: 1; activeIndex: number; tabs: SessionTabRow[]; bgId?: string; fontId?: string } @@ -109,6 +109,8 @@ export default function App(): React.ReactElement { } | null>(null) const readPreviewOuterRef = React.useRef(null) const readPreviewArticleRef = React.useRef(null) + const livePreviewOuterRef = React.useRef(null) + const livePreviewArticleRef = React.useRef(null) const hybridPreviewRef = React.useRef(null) const hybridPreviewShellRef = React.useRef(null) const hybridPreviewArticleRef = React.useRef(null) @@ -135,6 +137,10 @@ export default function App(): React.ReactElement { const tabsRef = React.useRef(tabs) tabsRef.current = tabs + const openingPathsRef = React.useRef>(new Set()) + const startupFilesHandledRef = React.useRef(false) + const scrollPositionsRef = React.useRef>(new Map()) + const prevActiveIdRef = React.useRef(null) const refreshDir = React.useCallback(async (): Promise => { if (!api || !browseDir) { @@ -174,49 +180,57 @@ export default function App(): React.ReactElement { } let cancelled = false const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('timeout')), 5000) + setTimeout(() => reject(new Error('timeout')), 800) }) - + void (async () => { try { const raw = localStorage.getItem(SESSION_KEY) - if (!raw) return - const data = JSON.parse(raw) as Partial - if (data.v !== 1 || !Array.isArray(data.tabs)) return - const restored: Tab[] = [] - for (const row of data.tabs) { - if (cancelled) break - if (!row || typeof row !== 'object') continue - const id = crypto.randomUUID() - const content = typeof row.content === 'string' ? row.content : '' - const saved = typeof row.saved === 'string' ? row.saved : content - if (row.path) { - try { - await Promise.race([api.readFile(row.path), timeoutPromise]) - restored.push({ id, filePath: row.path, content, savedContent: saved }) - } catch { - /* missing or unreadable or timeout */ + if (raw) { + const data = JSON.parse(raw) as Partial + if (data.v === 1 && Array.isArray(data.tabs)) { + const restored: Tab[] = [] + const seenPaths = new Set() + const rows = data.tabs.slice(0, 20) + for (const row of rows) { + if (cancelled) break + if (!row || typeof row !== 'object') continue + if (row.path) { + const pathKey = normalizePathKey(row.path) + if (seenPaths.has(pathKey)) continue + seenPaths.add(pathKey) + } + const id = crypto.randomUUID() + const content = typeof row.content === 'string' ? row.content : '' + const saved = typeof row.saved === 'string' ? row.saved : content + if (row.path) { + try { + await Promise.race([api.readFile(row.path), timeoutPromise]) + restored.push({ id, filePath: row.path, content, savedContent: saved }) + } catch { + restored.push({ id, filePath: row.path, content, savedContent: saved }) + } + } else { + restored.push({ id, filePath: null, content, savedContent: saved }) + } + } + if (!cancelled && restored.length) { + setTabs(restored) + let idx = typeof data.activeIndex === 'number' ? data.activeIndex : 0 + if (!Number.isFinite(idx)) idx = 0 + idx = Math.max(0, Math.min(idx, restored.length - 1)) + setActiveId(restored[idx]!.id) + } + if (!cancelled && typeof data.bgId === 'string') { + const b = validBackgroundId(data.bgId) + if (b) setBgId(b) + } + if (!cancelled && typeof data.fontId === 'string') { + const f = validFontId(data.fontId) + if (f) setFontId(f) } - } else { - restored.push({ id, filePath: null, content, savedContent: saved }) } } - if (cancelled) return - setTabs(restored) - if (restored.length) { - let idx = typeof data.activeIndex === 'number' ? data.activeIndex : 0 - if (!Number.isFinite(idx)) idx = 0 - idx = Math.max(0, Math.min(idx, restored.length - 1)) - setActiveId(restored[idx]!.id) - } - if (typeof data.bgId === 'string') { - const b = validBackgroundId(data.bgId) - if (b) setBgId(b) - } - if (typeof data.fontId === 'string') { - const f = validFontId(data.fontId) - if (f) setFontId(f) - } } catch { /* ignore corrupt session */ } finally { @@ -331,20 +345,6 @@ export default function App(): React.ReactElement { if (textPrompt) setTextPromptInput(textPrompt.defaultValue) }, [textPrompt]) - React.useEffect(() => { - if (!api || !sessionHydrated) return - - const handleOpenFiles = (paths: string[]) => { - paths.forEach(path => { - void openPathInTab(path) - }) - } - - const unsubscribe = api.onOpenFiles(handleOpenFiles) - - return unsubscribe - }, [api, sessionHydrated]) - React.useEffect(() => { if (imageInsert) { setImageUrlInput('') @@ -451,36 +451,57 @@ export default function App(): React.ReactElement { [activeLineCount] ) + const scrollPreviewToLine = React.useCallback( + ( + scrollEl: HTMLElement | null, + article: HTMLElement | null, + line: number, + onHybridPulse?: () => void + ): void => { + if (!scrollEl || !article) return + scrollPreviewToSourceLine(scrollEl, article, line, 'smooth', activeLineCount) + onHybridPulse?.() + }, + [activeLineCount] + ) + const navigateToOutline = React.useCallback( (item: OutlineItem) => { const line = item.line + const seq = Date.now() + if (viewMode === 'live') { + setGotoLine({ line, seq }) + return + } if (viewMode === 'read') { requestAnimationFrame(() => { - const scrollEl = readPreviewOuterRef.current - const article = readPreviewArticleRef.current - if (scrollEl && article) { - scrollPreviewToSourceLine(scrollEl, article, line, 'smooth', activeLineCount) - } + requestAnimationFrame(() => { + scrollPreviewToLine(readPreviewOuterRef.current, readPreviewArticleRef.current, line) + }) }) return } - setGotoLine({ line, seq: Date.now() }) + setGotoLine({ line, seq }) if (viewMode === 'hybrid') { requestAnimationFrame(() => { - const scrollEl = hybridPreviewRef.current - const article = hybridPreviewArticleRef.current - const shell = hybridPreviewShellRef.current - if (scrollEl && article) { - scrollPreviewToSourceLine(scrollEl, article, line, 'smooth', activeLineCount) - } - if (article && shell) { - const topPx = previewLineCenterInShell(article, shell, line, activeLineCount) - if (topPx !== null) setHybridLinePulse({ topPx, seq: Date.now() }) - } + requestAnimationFrame(() => { + scrollPreviewToLine( + hybridPreviewRef.current, + hybridPreviewArticleRef.current, + line, + () => { + const article = hybridPreviewArticleRef.current + const shell = hybridPreviewShellRef.current + if (!article || !shell) return + const topPx = previewLineCenterInShell(article, shell, line, activeLineCount) + if (topPx !== null) setHybridLinePulse({ topPx, seq }) + } + ) + }) }) } }, - [viewMode, activeLineCount] + [viewMode, activeLineCount, scrollPreviewToLine] ) const outline = React.useMemo(() => extractOutline(activeDoc), [activeDoc]) @@ -524,6 +545,28 @@ export default function App(): React.ReactElement { const editorMountKey = activeTab ? `${activeTab.id}-${viewMode}` : `empty-${viewMode}` + React.useEffect(() => { + const prevId = prevActiveIdRef.current + if (prevId && prevId !== activeId) { + const editor = editorRef.current + if (editor) { + scrollPositionsRef.current.set(prevId, editor.getScrollTop()) + } + } + prevActiveIdRef.current = activeId + }, [activeId]) + + React.useEffect(() => { + if (!activeId) return + const saved = scrollPositionsRef.current.get(activeId) + if (saved !== undefined && saved > 0) { + requestAnimationFrame(() => { + const editor = editorRef.current + if (editor) editor.setScrollTop(saved) + }) + } + }, [editorMountKey]) + const scrollPreviewToAnchorId = React.useCallback( (anchorId: string, mode: ViewMode = viewMode): void => { const id = decodeURIComponent(anchorId) @@ -550,35 +593,78 @@ export default function App(): React.ReactElement { const openPathInTab = React.useCallback( async (path: string, anchorId?: string): Promise => { if (!api) return + const key = normalizePathKey(path) + if (openingPathsRef.current.has(key)) return + + const existing = tabsRef.current.find( + (t) => t.filePath && normalizePathKey(t.filePath) === key + ) + if (existing) { + setActiveId(existing.id) + setBrowseDir(await api.parentDirectory(path)) + recordFileOpen(path) + if (anchorId) scrollPreviewToAnchorId(anchorId) + return + } + + openingPathsRef.current.add(key) try { - const key = normalizePathKey(path) - const existing = tabsRef.current.find( - (t) => t.filePath && normalizePathKey(t.filePath) === key - ) - if (existing) { - setActiveId(existing.id) - setBrowseDir(await api.parentDirectory(path)) - recordFileOpen(path) - if (anchorId) scrollPreviewToAnchorId(anchorId) - return - } const content = await api.readFile(path) const parent = await api.parentDirectory(path) setBrowseDir(parent) const nt: Tab = { id: crypto.randomUUID(), filePath: path, content, savedContent: content } - setTabs((prev) => [...prev, nt]) - setActiveId(nt.id) + let openedId = nt.id + setTabs((prev) => { + const dup = prev.find((t) => t.filePath && normalizePathKey(t.filePath) === key) + if (dup) { + openedId = dup.id + return prev + } + return [...prev, nt] + }) + setActiveId(openedId) setViewMode('read') recordFileOpen(path) if (anchorId) scrollPreviewToAnchorId(anchorId, 'read') } catch (e) { const msg = e instanceof Error ? e.message : String(e) window.alert(`无法打开文件:${msg}`) + } finally { + openingPathsRef.current.delete(key) } }, [api, recordFileOpen, scrollPreviewToAnchorId] ) + React.useEffect(() => { + if (!api || !sessionHydrated) return + + const openPaths = (paths: string[]): void => { + const seen = new Set() + for (const path of paths) { + const key = normalizePathKey(path) + if (seen.has(key)) continue + seen.add(key) + void openPathInTab(path) + } + } + + const handleOpenFiles = (paths: string[]): void => { + openPaths(paths) + } + + const unsubscribe = api.onOpenFiles(handleOpenFiles) + + if (!startupFilesHandledRef.current) { + startupFilesHandledRef.current = true + void api.getPendingFilesIPC().then((ipcFiles) => { + openPaths(ipcFiles) + }) + } + + return unsubscribe + }, [api, sessionHydrated, openPathInTab]) + const openFileDialog = React.useCallback(async (): Promise => { if (!api) return const r = await api.openFileDialog() @@ -700,8 +786,17 @@ export default function App(): React.ReactElement { setActiveId('') }, []) + const onReorderTabs = React.useCallback((fromIndex: number, toIndex: number): void => { + setTabs((prev) => { + const next = [...prev] + const [moved] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, moved) + return next + }) + }, []) + const newTabAction = React.useCallback((): void => { - setViewMode('edit') + setViewMode('live') const nt = newTab() setTabs((prev) => [...prev, nt]) setActiveId(nt.id) @@ -726,7 +821,7 @@ export default function App(): React.ReactElement { }, [activeId, tabs]) const cycleViewMode = React.useCallback((): void => { - setViewMode((m) => (m === 'edit' ? 'read' : m === 'read' ? 'hybrid' : 'edit')) + setViewMode((m) => (m === 'read' ? 'hybrid' : m === 'hybrid' ? 'live' : 'read')) }, []) const openFolderDialog = React.useCallback(async (): Promise => { @@ -1133,333 +1228,75 @@ ${previewHtml} ) } - const renderPreviewArticle = (articleRef?: React.Ref): React.ReactElement => ( - api.resolveRelativePath(base, href)} - onOpenMarkdown={(path, anchorId) => openPathInTab(path, anchorId)} - onOpenExternal={(url) => api.openExternal(url)} - /> - ) - const closePromptTab = closePromptTabId ? tabsRef.current.find((t) => t.id === closePromptTabId) : undefined return (
- {closePromptTabId && closePromptTab ? ( -
setClosePromptTabId(null)} - > -
e.stopPropagation()} - > -

关闭标签

-

- 「{tabLabel(closePromptTab)}」有未保存的更改,是否保存? -

-
- - - -
-
-
- ) : null} - - {textPrompt ? ( -
{ - if (e.target === e.currentTarget) { - textPrompt.resolve(null) - setTextPrompt(null) - } - }} - > -
e.stopPropagation()} - > -

{textPrompt.title}

- setTextPromptInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - textPrompt.resolve(textPromptInput) - setTextPrompt(null) - } - if (e.key === 'Escape') { - e.preventDefault() - textPrompt.resolve(null) - setTextPrompt(null) - } - }} - autoFocus - /> -
- - -
-
-
- ) : null} - - {imageInsert ? ( -
{ - if (e.target === e.currentTarget) { - imageInsert.resolve(null) - setImageInsert(null) - } - }} - > -
e.stopPropagation()} - > -

插入图片

-

支持网络地址或本地图片文件(Markdown 中保存为 file:// 链接)。

- -
- -
- -
- - -
-
-
- ) : null} - - {colorPicker ? ( -
{ - if (e.target === e.currentTarget) { - colorPicker.resolve(null) - setColorPicker(null) - } - }} - > -
e.stopPropagation()} - > -

文字颜色

-
- 取色器 - setColorHexInput(e.target.value.toLowerCase())} - aria-label="色谱" - /> -
- -
- {COLOR_SWATCH_ROWS.map((row, ri) => ( -
- {row.map((hex) => ( -
- ))} -
-
- - -
-
-
- ) : null} - - {histCtxMenu ? ( -
e.stopPropagation()} - > - -
- ) : null} - - {ctxMenu ? ( -
e.stopPropagation()} - > - - {ctxMenu.kind === 'file' ? ( - - ) : null} -
- ) : null} + setClosePromptTabId(null)} + onClosePromptSave={() => { + void (async () => { + if (!closePromptTabId) return + const ok = await saveTabById(closePromptTabId) + if (!ok) return + removeTabById(closePromptTabId) + setClosePromptTabId(null) + })() + }} + onClosePromptDiscard={() => { + if (!closePromptTabId) return + removeTabById(closePromptTabId) + setClosePromptTabId(null) + }} + textPrompt={textPrompt} + textPromptInput={textPromptInput} + onTextPromptInput={setTextPromptInput} + onTextPromptDismiss={() => { + textPrompt?.resolve(null) + setTextPrompt(null) + }} + onTextPromptConfirm={() => { + textPrompt?.resolve(textPromptInput) + setTextPrompt(null) + }} + imageInsert={imageInsert} + imageUrlInput={imageUrlInput} + imageAltInput={imageAltInput} + onImageUrlInput={setImageUrlInput} + onImageAltInput={setImageAltInput} + onImageInsertDismiss={() => { + imageInsert?.resolve(null) + setImageInsert(null) + }} + onImageInsertConfirm={() => { + const url = imageUrlInput.trim() + if (!url || !imageInsert) return + imageInsert.resolve({ url, alt: imageAltInput.trim() || '图片' }) + setImageInsert(null) + }} + onPickLocalImage={pickLocalImage} + colorPicker={colorPicker} + colorHexInput={colorHexInput} + onColorHexInput={setColorHexInput} + onColorPickerDismiss={() => { + colorPicker?.resolve(null) + setColorPicker(null) + }} + onColorPickerConfirm={() => { + colorPicker?.resolve(colorHexInput.trim() || '#333333') + setColorPicker(null) + }} + histCtxMenu={histCtxMenu} + onHistCtxRemove={(path) => setFileHistory((prev) => removeFileHistory(prev, path))} + onHistCtxDismiss={() => setHistCtxMenu(null)} + ctxMenu={ctxMenu} + onCtxRename={(path) => void renamePathEntry(path)} + onCtxDelete={(path) => void deleteFileAt(path)} + />
@@ -1488,7 +1326,7 @@ ${previewHtml} - )} - {searchVisible ? ( -
- setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - setSearchVisible(false) - setSearchQuery('') - } - if (e.key === 'Enter') { - e.preventDefault() - if (!searchQuery.trim()) return - if (viewMode === 'read') { - const article = readPreviewArticleRef.current - if (article) { - const text = article.textContent || '' - const idx = text.toLowerCase().indexOf(searchQuery.toLowerCase()) - if (idx >= 0) { - const range = document.createRange() - const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT) - let currentIdx = 0 - let node: Text | null = null - while ((node = walker.nextNode() as Text | null)) { - const len = node.textContent?.length || 0 - if (currentIdx + len > idx) { - range.setStart(node, idx - currentIdx) - range.setEnd(node, idx - currentIdx + searchQuery.length) - range.startContainer.parentElement?.scrollIntoView({ behavior: 'smooth', block: 'center' }) - break - } - currentIdx += len - } - } - } - return - } - if (viewMode === 'edit') { - editorRef.current?.searchText(searchQuery) - return - } - const view = hybridEditorViewRef.current - if (view) { - const doc = view.state.doc.toString() - const idx = doc.toLowerCase().indexOf(searchQuery.toLowerCase()) - if (idx >= 0) { - view.dispatch({ - selection: { anchor: idx, head: idx + searchQuery.length }, - scrollIntoView: true - }) - } - } - } - }} - autoFocus - /> - - -
- ) : null} - -
- {(viewMode === 'edit' || viewMode === 'hybrid') && activeTab ? ( - - ) : null} - {viewMode === 'read' ? ( - activeTab ? ( -
-
- {renderPreviewArticle(readPreviewArticleRef)} -
-
-
- ) : ( -
-
📖
-
请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。
-
支持拖放 .md 文件到此处打开 · Ctrl+F 搜索 · Ctrl+S 保存
-
- 📂 - 拖放 Markdown 文件到此处打开 -
-
- ) - ) : null} - - {viewMode === 'edit' ? ( - activeTab ? ( - - ) : ( -
-
✏️
-
请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。
-
支持拖放 .md 文件到此处打开 · Ctrl+F 搜索 · Ctrl+S 保存
-
- 📂 - 拖放 Markdown 文件到此处打开 -
-
- ) - ) : null} - - {viewMode === 'hybrid' ? ( - activeTab ? ( -
-
- -
-
-
-
- {hybridLinePulse ? ( -
- ) : null} - {renderPreviewArticle(hybridPreviewArticleRef)} -
-
-
- ) : ( -
-
📝
-
请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。
-
支持拖放 .md 文件到此处打开 · Ctrl+F 搜索 · Ctrl+S 保存
-
- 📂 - 拖放 Markdown 文件到此处打开 -
-
- ) - ) : null} -
+ api.resolveRelativePath(base, href)} + onOpenMarkdown={(path, anchorId) => openPathInTab(path, anchorId)} + onOpenExternal={(url) => api.openExternal(url)} + onDrop={handleDrop} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onEditorChange={onEditorChange} + onGotoLine={gotoLine} + onRequestText={requestText} + onRequestImageDetails={requestImageDetails} + onRequestColor={requestColor} + onStartPreviewWidthResize={startPreviewWidthResize} + onStartHybridSplitResize={startHybridSplitResize} + onHybridViewReady={onHybridViewReady} + onHybridCursorLine={onHybridCursorLine} + onSetSearchVisible={setSearchVisible} + onSetSearchQuery={setSearchQuery} + /> {activeTab && !statsHidden ? ( void + onClosePromptSave: () => void + onClosePromptDiscard: () => void + textPrompt: { title: string; defaultValue: string; resolve: (v: string | null) => void } | null + textPromptInput: string + onTextPromptInput: (v: string) => void + onTextPromptDismiss: () => void + onTextPromptConfirm: () => void + imageInsert: { resolve: (v: { url: string; alt: string } | null) => void } | null + imageUrlInput: string + imageAltInput: string + onImageUrlInput: (v: string) => void + onImageAltInput: (v: string) => void + onImageInsertDismiss: () => void + onImageInsertConfirm: () => void + onPickLocalImage: () => void + colorPicker: { resolve: (v: string | null) => void } | null + colorHexInput: string + onColorHexInput: (v: string) => void + onColorPickerDismiss: () => void + onColorPickerConfirm: () => void + histCtxMenu: HistCtxMenu | null + onHistCtxRemove: (path: string) => void + onHistCtxDismiss: () => void + ctxMenu: CtxMenu | null + onCtxRename: (path: string) => void + onCtxDelete: (path: string) => void +} + +export function AppModals({ + closePromptTab, + closePromptTabId, + onClosePromptDismiss, + onClosePromptSave, + onClosePromptDiscard, + textPrompt, + textPromptInput, + onTextPromptInput, + onTextPromptDismiss, + onTextPromptConfirm, + imageInsert, + imageUrlInput, + imageAltInput, + onImageUrlInput, + onImageAltInput, + onImageInsertDismiss, + onImageInsertConfirm, + onPickLocalImage, + colorPicker, + colorHexInput, + onColorHexInput, + onColorPickerDismiss, + onColorPickerConfirm, + histCtxMenu, + onHistCtxRemove, + onHistCtxDismiss, + ctxMenu, + onCtxRename, + onCtxDelete +}: Props): React.ReactElement { + return ( + <> + {closePromptTabId && closePromptTab ? ( +
+
e.stopPropagation()}> +

关闭标签

+

「{tabLabel(closePromptTab)}」有未保存的更改,是否保存?

+
+ + + +
+
+
+ ) : null} + + {textPrompt ? ( +
{ + if (e.target === e.currentTarget) onTextPromptDismiss() + }} + > +
e.stopPropagation()}> +

{textPrompt.title}

+ onTextPromptInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + onTextPromptConfirm() + } + if (e.key === 'Escape') { + e.preventDefault() + onTextPromptDismiss() + } + }} + autoFocus + /> +
+ + +
+
+
+ ) : null} + + {imageInsert ? ( +
{ + if (e.target === e.currentTarget) onImageInsertDismiss() + }} + > +
e.stopPropagation()} + > +

插入图片

+

支持网络地址或本地图片文件(Markdown 中保存为 file:// 链接)。

+ +
+ +
+ +
+ + +
+
+
+ ) : null} + + {colorPicker ? ( +
{ + if (e.target === e.currentTarget) onColorPickerDismiss() + }} + > +
e.stopPropagation()} + > +

文字颜色

+
+ 取色器 + onColorHexInput(e.target.value.toLowerCase())} + aria-label="色谱" + /> +
+ +
+ {COLOR_SWATCH_ROWS.map((row, ri) => ( +
+ {row.map((hex) => ( +
+ ))} +
+
+ + +
+
+
+ ) : null} + + {histCtxMenu ? ( +
e.stopPropagation()} + > + +
+ ) : null} + + {ctxMenu ? ( +
e.stopPropagation()} + > + + {ctxMenu.kind === 'file' ? ( + + ) : null} +
+ ) : null} + + ) +} diff --git a/src/renderer/src/ContentArea.tsx b/src/renderer/src/ContentArea.tsx new file mode 100644 index 0000000..460d80f --- /dev/null +++ b/src/renderer/src/ContentArea.tsx @@ -0,0 +1,266 @@ +import * as React from 'react' +import type { EditorView } from '@codemirror/view' +import type { MarkdownEditorHandle } from './EditorPane' +import { EditorPane } from './EditorPane' +import { LiveLineView } from './LiveLineView' +import { MarkdownFormatToolbar } from './MarkdownFormatToolbar' +import { PreviewArticle } from './PreviewArticle' +import type { Tab } from './types' +import type { ViewMode } from './appearance' + +type ContentAreaProps = { + viewMode: ViewMode + activeTab: Tab | null + dragOver: boolean + previewMaxWidth: number + splitPct: number + searchQuery: string + searchVisible: boolean + editorMountKey: string + editorRef: React.RefObject + readPreviewArticleRef: React.RefObject + readPreviewOuterRef: React.RefObject + hybridPreviewArticleRef: React.RefObject + hybridPreviewRef: React.RefObject + hybridPreviewShellRef: React.RefObject + hybridEditorViewRef: React.RefObject + hybridLinePulse: { seq: number; topPx: number } | null + previewHtml: string + baseFilePath: string | null + activeId: string + resolveRelativePath: (base: string, href: string) => Promise + onOpenMarkdown: (path: string, anchorId?: string) => void + onOpenExternal: (url: string) => void + onDrop: (e: React.DragEvent) => void + onDragOver: (e: React.DragEvent) => void + onDragLeave: (e: React.DragEvent) => void + onEditorChange: (val: string) => void + onGotoLine: { line: number; seq: number } | null + onRequestText: (title: string, defaultValue: string) => Promise + onRequestImageDetails: () => Promise<{ url: string; alt: string } | null> + onRequestColor: () => Promise + livePreviewOuterRef?: React.RefObject + livePreviewArticleRef?: React.RefObject + onStartPreviewWidthResize: (e: React.MouseEvent) => void + onStartHybridSplitResize: (e: React.MouseEvent) => void + onHybridViewReady: (view: EditorView | null) => void + onHybridCursorLine: (line: number) => void + onSetSearchVisible: (v: boolean) => void + onSetSearchQuery: (q: string) => void +} + +function scrollArticleToQuery(article: HTMLElement, query: string): void { + const text = article.textContent || '' + const idx = text.toLowerCase().indexOf(query.toLowerCase()) + if (idx < 0) return + const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT) + let currentIdx = 0 + let node: Text | null = null + while ((node = walker.nextNode() as Text | null)) { + const len = node.textContent?.length || 0 + if (currentIdx + len > idx) { + node.parentElement?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + break + } + currentIdx += len + } +} + +export function ContentArea(props: ContentAreaProps): React.ReactElement { + const { + viewMode, activeTab, dragOver, previewMaxWidth, + splitPct, searchQuery, searchVisible, + editorMountKey, editorRef, + readPreviewArticleRef, readPreviewOuterRef, + hybridPreviewArticleRef, hybridPreviewRef, hybridPreviewShellRef, + hybridEditorViewRef, hybridLinePulse, + previewHtml, baseFilePath, activeId, resolveRelativePath, + onOpenMarkdown, onOpenExternal, + onDrop, onDragOver, onDragLeave, + onEditorChange, onGotoLine, + onRequestText, onRequestImageDetails, onRequestColor, + livePreviewOuterRef, livePreviewArticleRef, + onStartPreviewWidthResize, onStartHybridSplitResize, + onHybridViewReady, onHybridCursorLine, + onSetSearchVisible, onSetSearchQuery + } = props + + const renderPreviewArticle = (articleRef?: React.Ref): React.ReactElement => ( + + ) + + const runSearch = (): void => { + if (!searchQuery.trim()) return + if (viewMode === 'read') { + const article = readPreviewArticleRef.current + if (article) scrollArticleToQuery(article, searchQuery) + return + } + if (viewMode === 'live') { + const article = livePreviewArticleRef?.current + if (article) scrollArticleToQuery(article, searchQuery) + return + } + hybridEditorViewRef.current?.searchText?.(searchQuery) + } + + return ( + <> + {searchVisible ? ( +
+ onSetSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + onSetSearchVisible(false) + onSetSearchQuery('') + } + if (e.key === 'Enter') { + e.preventDefault() + runSearch() + } + }} + autoFocus + /> + + +
+ ) : null} + +
+ {viewMode === 'hybrid' && activeTab ? ( + + ) : null} + {viewMode === 'read' ? ( + activeTab ? ( +
+
+ {renderPreviewArticle(readPreviewArticleRef)} +
+
+
+ ) : ( +
+
📖
+
请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。
+
支持拖放 .md 文件到此处打开 · Ctrl+F 搜索 · Ctrl+S 保存
+
+ 📂 + 拖放 Markdown 文件到此处打开 +
+
+ ) + ) : null} + + {viewMode === 'live' ? ( + activeTab ? ( + + ) : ( +
+
🎨
+
请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。
+
支持拖放 .md 文件到此处打开 · Ctrl+F 搜索 · Ctrl+S 保存
+
+ 📂 + 拖放 Markdown 文件到此处打开 +
+
+ ) + ) : null} + + {viewMode === 'hybrid' ? ( + activeTab ? ( +
+
+ +
+
+
+
+ {hybridLinePulse ? ( +
+ ) : null} + {renderPreviewArticle(hybridPreviewArticleRef)} +
+
+
+ ) : ( +
+
📝
+
请从侧栏选择文件、使用工具栏「打开」,或点击「新标签」开始编辑。
+
支持拖放 .md 文件到此处打开 · Ctrl+F 搜索 · Ctrl+S 保存
+
+ 📂 + 拖放 Markdown 文件到此处打开 +
+
+ ) + ) : null} +
+ + ) +} diff --git a/src/renderer/src/EditorPane.tsx b/src/renderer/src/EditorPane.tsx index c8a9ff2..275261e 100644 --- a/src/renderer/src/EditorPane.tsx +++ b/src/renderer/src/EditorPane.tsx @@ -1,5 +1,4 @@ import * as React from 'react' -import { EditorSelection } from '@codemirror/state' import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' import { markdown } from '@codemirror/lang-markdown' import { EditorState } from '@codemirror/state' @@ -10,11 +9,14 @@ import { lineNumbers, placeholder } from '@codemirror/view' +import { applyMarkdownAction } from './markdown-actions' export type MarkdownEditorHandle = { applyAction: (id: string) => void | Promise searchText: (query: string) => boolean getView: () => EditorView | null + getScrollTop: () => number + setScrollTop: (top: number) => void } type Props = { @@ -32,211 +34,6 @@ type Props = { requestColor?: () => Promise } -function getMainSelection(view: EditorView): { from: number; to: number } { - const r = view.state.selection.main - return { from: r.from, to: r.to } -} - -function wrap(view: EditorView, left: string, right: string): void { - const { from, to } = getMainSelection(view) - const sel = view.state.sliceDoc(from, to) - view.dispatch({ - changes: { from, to, insert: left + sel + right }, - selection: EditorSelection.range(from + left.length, from + left.length + sel.length) - }) - view.focus() -} - -function insertAtCursor(view: EditorView, text: string): void { - const { from, to } = getMainSelection(view) - view.dispatch({ - changes: { from, to, insert: text }, - selection: EditorSelection.cursor(from + text.length) - }) - view.focus() -} - -function insertLines(view: EditorView, text: string): void { - insertAtCursor(view, text) -} - -function linePrefixOnMainLine(view: EditorView, prefix: string): void { - const pos = view.state.selection.main.head - const line = view.state.doc.lineAt(pos) - const lineText = line.text - const hashes = /^#+\s/.exec(lineText)?.[0] ?? '' - let rest = lineText - if (hashes) rest = lineText.slice(hashes.length) - const newLine = prefix + rest - view.dispatch({ - changes: { from: line.from, to: line.to, insert: newLine }, - selection: EditorSelection.cursor(line.from + newLine.length) - }) - view.focus() -} - -function escapeMarkdownLinkFragment(s: string): string { - return s.replace(/\\/g, '\\\\').replace(/\[/g, '\\[').replace(/\]/g, '\\]') -} - -function sanitizeInlineColor(c: string): string { - const t = c.trim() - if (/^#[0-9a-fA-F]{3}$/.test(t)) { - const r = t[1]! - const g = t[2]! - const b = t[3]! - return `#${r}${r}${g}${g}${b}${b}` - } - if (/^#[0-9a-fA-F]{6}$/.test(t) || /^#[0-9a-fA-F]{8}$/.test(t)) return t.toLowerCase() - if (/^rgba?\(\s*[\d.\s%,]+\s*\)$/.test(t)) return t - if (/^hsla?\(\s*[\d.\s%,deg]+\s*\)$/.test(t)) return t - if (/^[\w-]{1,40}$/.test(t)) return t - return '#333333' -} - -function sanitizeLinkUrl(url: string): string { - const t = url.trim() || 'https://' - if (/^file:/i.test(t)) return `<${t.replace(/[<>]/g, '')}>` - return t.replace(/\)/g, '%29') -} - -type ActionDeps = { - requestText: (title: string, def: string) => Promise - requestImageDetails?: () => Promise<{ url: string; alt: string } | null> - requestColor?: () => Promise -} - -async function applyMarkdownAction(view: EditorView, id: string, deps: ActionDeps): Promise { - const { requestText, requestImageDetails, requestColor } = deps - try { - switch (id) { - case 'bold': - wrap(view, '**', '**') - break - case 'italic': - wrap(view, '*', '*') - break - case 'strike': - wrap(view, '~~', '~~') - break - case 'code': - wrap(view, '`', '`') - break - case 'codeBlock': - insertLines(view, '\n```\n\n```\n') - break - case 'link': { - const url = await requestText('链接地址', 'https://') - if (url === null) return - const { from, to } = getMainSelection(view) - const rawLabel = view.state.sliceDoc(from, to) || '链接文字' - const label = escapeMarkdownLinkFragment(rawLabel) - const insert = `[${label}](${sanitizeLinkUrl(url)})` - view.dispatch({ - changes: { from, to, insert }, - selection: EditorSelection.cursor(from + insert.length) - }) - view.focus() - break - } - case 'image': { - if (requestImageDetails) { - const det = await requestImageDetails() - if (!det?.url?.trim()) return - const alt = (det.alt ?? '图片').trim() || '图片' - insertAtCursor( - view, - `![${escapeMarkdownLinkFragment(alt)}](${sanitizeLinkUrl(det.url.trim())})` - ) - break - } - const url = await requestText('图片地址', 'https://') - if (url === null) return - const rawAlt = await requestText('替代文字', '图片') - if (rawAlt === null) return - const alt = escapeMarkdownLinkFragment(rawAlt || '图片') - insertAtCursor(view, `![${alt}](${sanitizeLinkUrl(url)})`) - break - } - case 'h1': - linePrefixOnMainLine(view, '# ') - break - case 'h2': - linePrefixOnMainLine(view, '## ') - break - case 'h3': - linePrefixOnMainLine(view, '### ') - break - case 'h4': - linePrefixOnMainLine(view, '#### ') - break - case 'h5': - linePrefixOnMainLine(view, '##### ') - break - case 'h6': - linePrefixOnMainLine(view, '###### ') - break - case 'ul': - linePrefixOnMainLine(view, '- ') - break - case 'ol': - linePrefixOnMainLine(view, '1. ') - break - case 'task': - linePrefixOnMainLine(view, '- [ ] ') - break - case 'quote': - linePrefixOnMainLine(view, '> ') - break - case 'hr': - insertLines(view, '\n---\n') - break - case 'table': - insertLines( - view, - '\n| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| | | |\n' - ) - break - case 'mark': - wrap(view, '', '') - break - case 'color': { - if (requestColor) { - const c = await requestColor() - if (c === null || !c.trim()) return - const color = sanitizeInlineColor(c) - wrap(view, ``, '') - break - } - const c = await requestText('颜色(如 #e11 或 red)', '#dc2626') - if (c === null || !c.trim()) return - const color = sanitizeInlineColor(c) - wrap(view, ``, '') - break - } - case 'sup': - wrap(view, '', '') - break - case 'sub': - wrap(view, '', '') - break - case 'kbd': - wrap(view, '', '') - break - case 'details': - insertLines(view, '\n
\n标题\n内容\n
\n') - break - case 'footnote': - insertLines(view, '[^1]\n\n[^1]: 脚注内容\n') - break - default: - break - } - } catch (e) { - console.error('[MyReader] toolbar action failed', id, e) - } -} - export const EditorPane = React.forwardRef( function EditorPane( { @@ -303,7 +100,15 @@ export const EditorPane = React.forwardRef( } return false }, - getView: (): EditorView | null => viewRef.current + getView: (): EditorView | null => viewRef.current, + getScrollTop: (): number => { + const view = viewRef.current + return view ? view.scrollDOM.scrollTop : 0 + }, + setScrollTop: (top: number): void => { + const view = viewRef.current + if (view) view.scrollDOM.scrollTop = top + } }), [] ) diff --git a/src/renderer/src/Icons.tsx b/src/renderer/src/Icons.tsx new file mode 100644 index 0000000..7e3d9a0 --- /dev/null +++ b/src/renderer/src/Icons.tsx @@ -0,0 +1,43 @@ +import * as React from 'react' + +type IconProps = { + className?: string + size?: number +} + +export function FolderIcon({ className, size = 18 }: IconProps): React.ReactElement { + return ( + + + + ) +} + +export function FileIcon({ className, size = 18 }: IconProps): React.ReactElement { + return ( + + + + + ) +} + +export function MdFileIcon({ className, size = 18 }: IconProps): React.ReactElement { + return ( + + + + + + + ) +} + +export function HistoryFileIcon({ className, size = 18 }: IconProps): React.ReactElement { + return ( + + + + + ) +} \ No newline at end of file diff --git a/src/renderer/src/LiveLineView.tsx b/src/renderer/src/LiveLineView.tsx new file mode 100644 index 0000000..bf3985a --- /dev/null +++ b/src/renderer/src/LiveLineView.tsx @@ -0,0 +1,364 @@ +import * as React from 'react' +import { renderMarkdown } from './markdown' +import { PreviewArticle } from './PreviewArticle' +import { + applyBlockEdit, + applyLineEdit, + parseLiveDocumentSegments, + type LiveBlockSegment, + type LiveSegment +} from './liveDocumentSegments' + +type Props = { + content: string + baseFilePath: string | null + activeId: string + gotoLine: { line: number; seq: number } | null + previewMaxWidth: number + scrollRef?: React.RefObject + articleRef?: React.RefObject + resolveRelativePath?: (base: string, href: string) => Promise + onOpenMarkdown?: (path: string, anchorId?: string) => void + onOpenExternal?: (url: string) => void + onChange: (next: string) => void +} + +function splitLines(doc: string): string[] { + return doc.split(/\r?\n/) +} + +function joinLines(lines: string[]): string { + return lines.join('\n') +} + +function linePreviewHtml(line: string, baseFilePath: string | null): string { + if (!line.trim()) return '' + return renderMarkdown(line, { baseFilePath }) +} + +type EditTarget = + | { type: 'line'; line: number } + | { type: 'block'; startLine: number; endLine: number } + +function editKey(target: EditTarget): string { + return target.type === 'line' ? `line:${target.line}` : `block:${target.startLine}` +} + +function resizeTextarea(ta: HTMLTextAreaElement): void { + ta.style.height = 'auto' + ta.style.height = `${ta.scrollHeight}px` +} + +type BlockPreviewProps = { + html: string + baseFilePath: string | null + blockKind: 'code' | 'diagram' + resolveRelativePath?: (base: string, href: string) => Promise + onOpenMarkdown?: (path: string, anchorId?: string) => void + onOpenExternal?: (url: string) => void +} + +function LiveBlockPreview({ + html, + baseFilePath, + blockKind, + resolveRelativePath, + onOpenMarkdown, + onOpenExternal +}: BlockPreviewProps): React.ReactElement { + return ( +
+ +
+ ) +} + +export function LiveLineView({ + content, + baseFilePath, + activeId, + gotoLine, + previewMaxWidth, + scrollRef, + articleRef, + resolveRelativePath, + onOpenMarkdown, + onOpenExternal, + onChange +}: Props): React.ReactElement { + const lines = React.useMemo(() => splitLines(content), [content]) + const segments = React.useMemo(() => parseLiveDocumentSegments(content), [content]) + const [editingKey, setEditingKey] = React.useState(null) + const [draft, setDraft] = React.useState('') + const inputRef = React.useRef(null) + const innerScrollRef = React.useRef(null) + const skipBlurCommitRef = React.useRef(false) + + const setScrollRef = React.useCallback( + (el: HTMLDivElement | null) => { + innerScrollRef.current = el + if (scrollRef && 'current' in scrollRef) { + ;(scrollRef as React.MutableRefObject).current = el + } + }, + [scrollRef] + ) + + React.useEffect(() => { + setEditingKey(null) + }, [activeId]) + + React.useEffect(() => { + if (!editingKey) return + const ta = inputRef.current + if (!ta) return + ta.focus() + const len = ta.value.length + ta.setSelectionRange(len, len) + resizeTextarea(ta) + }, [editingKey]) + + React.useEffect(() => { + if (!gotoLine) return + const line = gotoLine.line + const root = innerScrollRef.current + let row = root?.querySelector(`[data-source-line="${line}"]`) + if (!row && root) { + for (const el of root.querySelectorAll('[data-source-line]')) { + const start = Number(el.getAttribute('data-source-line')) + const endRaw = el.getAttribute('data-source-line-end') + const end = endRaw !== null && Number.isFinite(Number(endRaw)) ? Number(endRaw) : start + if (line >= start && line <= end) { + row = el + break + } + } + } + row?.scrollIntoView({ behavior: 'smooth', block: 'center' }) + row?.classList.add('live-block--flash', 'live-line--flash') + const t = window.setTimeout(() => { + row?.classList.remove('live-block--flash', 'live-line--flash') + }, 1600) + return () => window.clearTimeout(t) + }, [gotoLine]) + + const cancelEdit = React.useCallback((): void => { + skipBlurCommitRef.current = true + setEditingKey(null) + }, []) + + const commitEdit = React.useCallback( + (target: EditTarget, text: string): void => { + let nextLines: string[] + if (target.type === 'line') { + nextLines = applyLineEdit(lines, target.line, text) + } else { + nextLines = applyBlockEdit(lines, target.startLine, target.endLine, text) + } + onChange(joinLines(nextLines)) + setEditingKey(null) + }, + [lines, onChange] + ) + + const startLineEdit = React.useCallback((line: number): void => { + setDraft(lines[line] ?? '') + setEditingKey(editKey({ type: 'line', line })) + }, [lines]) + + const startBlockEdit = React.useCallback((seg: LiveBlockSegment): void => { + setDraft(seg.text) + setEditingKey(editKey({ type: 'block', startLine: seg.startLine, endLine: seg.endLine })) + }, []) + + const lineHtmlCache = React.useMemo(() => { + const map = new Map() + for (const seg of segments) { + if (seg.kind === 'line') { + map.set(seg.line, linePreviewHtml(seg.text, baseFilePath)) + } + } + return map + }, [segments, baseFilePath]) + + const blockHtmlCache = React.useMemo(() => { + const map = new Map() + for (const seg of segments) { + if (seg.kind === 'block') { + map.set(seg.startLine, renderMarkdown(seg.text, { baseFilePath })) + } + } + return map + }, [segments, baseFilePath]) + + const renderLineEditor = (line: number): React.ReactElement => { + const target: EditTarget = { type: 'line', line } + return ( +
+