From a272abd139f10c6b6caeb0596da56ffb6632ba4e Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Tue, 25 Nov 2025 18:43:33 +0100 Subject: [PATCH] Improve rich text error handling and log persistence --- components/RichTextEditor.tsx | 153 +++++++++++++++++++++------------- electron/main.ts | 11 +++ electron/preload.ts | 1 + services/storageService.ts | 14 ++-- types.ts | 1 + 5 files changed, 118 insertions(+), 62 deletions(-) diff --git a/components/RichTextEditor.tsx b/components/RichTextEditor.tsx index c39f0f5..0c00670 100644 --- a/components/RichTextEditor.tsx +++ b/components/RichTextEditor.tsx @@ -56,6 +56,7 @@ import { UNDO_COMMAND, type EditorState, type LexicalEditor, + $createTextNode, } from 'lexical'; import IconButton from './IconButton'; import ContextMenuComponent, { type MenuItem as ContextMenuItem } from './ContextMenu'; @@ -285,19 +286,26 @@ const ToolbarPlugin: React.FC<{ }); }, [editor]); - const toggleLink = useCallback(() => { - if (readOnly) { - return; - } - if (isLink) { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); - return; - } - const url = window.prompt('Enter URL'); - if (url) { - editor.dispatchCommand(TOGGLE_LINK_COMMAND, url); - } - }, [editor, isLink, readOnly]); + const toggleLink = useCallback(() => { + if (readOnly) { + return; + } + if (isLink) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + return; + } + + const promptFn = typeof window.prompt === 'function' ? window.prompt.bind(window) : null; + if (!promptFn) { + console.warn('Link insertion prompt is unavailable in this environment.'); + return; + } + + const url = promptFn('Enter URL'); + if (url) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, url); + } + }, [editor, isLink, readOnly]); const insertImage = useCallback( (payload: ImagePayload) => { @@ -617,34 +625,7 @@ const HtmlContentSynchronizer: React.FC<{ html: string; lastAppliedHtmlRef: Reac const [editor] = useLexicalComposerContext(); useEffect(() => { - const normalizedIncoming = html.trim(); - - editor.update(() => { - const root = $getRoot(); - const currentHtml = $generateHtmlFromNodes(editor).trim(); - - if (currentHtml === normalizedIncoming) { - lastAppliedHtmlRef.current = normalizedIncoming; - return; - } - - if (normalizedIncoming === lastAppliedHtmlRef.current && currentHtml !== '') { - return; - } - - root.clear(); - - if (!normalizedIncoming) { - lastAppliedHtmlRef.current = ''; - return; - } - - const parser = new DOMParser(); - const dom = parser.parseFromString(normalizedIncoming, 'text/html'); - const nodes = $generateNodesFromDOM(editor, dom); - nodes.forEach(node => root.append(node)); - lastAppliedHtmlRef.current = normalizedIncoming; - }); + applyHtmlToEditor(editor, html, lastAppliedHtmlRef); }, [editor, html, lastAppliedHtmlRef]); return null; @@ -799,6 +780,68 @@ const ClipboardImagePlugin: React.FC<{ readOnly: boolean }> = ({ readOnly }) => return null; }; +const sanitizeDomFromHtml = (html: string): Document => { + const parser = new DOMParser(); + const dom = parser.parseFromString(html, 'text/html'); + dom.querySelectorAll('script,style').forEach(node => node.remove()); + return dom; +}; + +const fallbackToPlainText = (text: string) => { + const parser = new DOMParser(); + const dom = parser.parseFromString(text, 'text/html'); + return (dom.body.textContent || '').trim(); +}; + +const applyHtmlToEditor = ( + editor: LexicalEditor, + html: string, + lastAppliedHtmlRef: React.MutableRefObject, +) => { + const normalizedIncoming = html.trim(); + + editor.update(() => { + const root = $getRoot(); + const currentHtml = $generateHtmlFromNodes(editor).trim(); + + if (currentHtml === normalizedIncoming) { + lastAppliedHtmlRef.current = normalizedIncoming; + return; + } + + if (normalizedIncoming === lastAppliedHtmlRef.current && currentHtml !== '') { + return; + } + + root.clear(); + + if (!normalizedIncoming) { + lastAppliedHtmlRef.current = ''; + root.append($createParagraphNode()); + return; + } + + try { + const dom = sanitizeDomFromHtml(normalizedIncoming); + const nodes = $generateNodesFromDOM(editor, dom); + if (nodes.length === 0) { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode('')); + root.append(paragraph); + } else { + nodes.forEach(node => root.append(node)); + } + lastAppliedHtmlRef.current = normalizedIncoming; + } catch (error) { + console.error('Failed to sync HTML content into the rich text editor.', error); + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(fallbackToPlainText(normalizedIncoming))); + root.append(paragraph); + lastAppliedHtmlRef.current = paragraph.getTextContent(); + } + }); +}; + const RichTextEditor = forwardRef( ({ html, onChange, readOnly = false, onScroll, onFocusChange }, ref) => { const scrollContainerRef = useRef(null); @@ -860,13 +903,17 @@ const RichTextEditor = forwardRef( const handleChange = useCallback( (editorState: EditorState, editor: LexicalEditor) => { editorState.read(() => { - const generated = $generateHtmlFromNodes(editor); - const normalized = generated.trim(); - if (normalized === lastAppliedHtmlRef.current) { - return; + try { + const generated = $generateHtmlFromNodes(editor); + const normalized = generated.trim(); + if (normalized === lastAppliedHtmlRef.current) { + return; + } + lastAppliedHtmlRef.current = normalized; + onChange(normalized); + } catch (error) { + console.error('Failed to serialize rich text content to HTML.', error); } - lastAppliedHtmlRef.current = normalized; - onChange(normalized); }); }, [onChange], @@ -911,7 +958,7 @@ const RichTextEditor = forwardRef( editable: !readOnly, theme: RICH_TEXT_THEME, onError: (error: Error) => { - throw error; + console.error('Rich text editor encountered an error.', error); }, nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, LinkNode, ImageNode], editorState: (editor: LexicalEditor) => { @@ -920,15 +967,7 @@ const RichTextEditor = forwardRef( lastAppliedHtmlRef.current = ''; return; } - const parser = new DOMParser(); - const dom = parser.parseFromString(initialHtml, 'text/html'); - editor.update(() => { - const root = $getRoot(); - root.clear(); - const nodes = $generateNodesFromDOM(editor, dom); - nodes.forEach(node => root.append(node)); - lastAppliedHtmlRef.current = initialHtml; - }); + applyHtmlToEditor(editor, initialHtml, lastAppliedHtmlRef); }, }), [readOnly], diff --git a/electron/main.ts b/electron/main.ts index 17401a2..ab16b38 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -656,6 +656,17 @@ ipcMain.handle('app:get-version', () => app.getVersion()); // Fix: Error on line 145 is resolved by importing 'platform' from 'process'. ipcMain.handle('app:get-platform', () => platform); ipcMain.handle('app:get-log-path', () => log.transports.file.getFile().path); +ipcMain.handle('log:append', async (_, content: string) => { + const logFilePath = log.transports.file.getFile().path; + try { + await fs.mkdir(path.dirname(logFilePath), { recursive: true }); + await fs.appendFile(logFilePath, content, 'utf-8'); + return { success: true, filePath: logFilePath }; + } catch (error) { + console.error('Failed to append to log file:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } +}); ipcMain.handle('app:open-executable-folder', async () => { const execDir = path.dirname(process.execPath); try { diff --git a/electron/preload.ts b/electron/preload.ts index 4392342..0cb6010 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -60,6 +60,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getAppVersion: () => ipcRenderer.invoke('app:get-version'), getPlatform: () => ipcRenderer.invoke('app:get-platform'), getLogPath: () => ipcRenderer.invoke('app:get-log-path'), + appendLog: (content: string) => ipcRenderer.invoke('log:append', content), openExecutableFolder: () => ipcRenderer.invoke('app:open-executable-folder'), renderPlantUML: (diagram: string, format: 'svg' = 'svg') => ipcRenderer.invoke('plantuml:render-svg', diagram, format), updaterSetAllowPrerelease: (allow: boolean) => ipcRenderer.send('updater:set-allow-prerelease', allow), diff --git a/services/storageService.ts b/services/storageService.ts index e349172..fda9f2d 100644 --- a/services/storageService.ts +++ b/services/storageService.ts @@ -67,10 +67,14 @@ export const storageService = { * @param content The string content to append. */ appendLogToFile: async (content: string): Promise => { - // This feature is not fully implemented in the electron backend. - // Logging a warning to avoid silent failures. - if (window.electronAPI) { - console.warn('appendLogToFile is not implemented in the Electron backend.'); + if (window.electronAPI?.appendLog) { + const result = await window.electronAPI.appendLog(content); + if (!result?.success && !result?.canceled) { + throw new Error(result?.error || 'Failed to append log content.'); + } + return; } + + console.warn('Appending logs is only supported in the desktop application.'); }, -}; \ No newline at end of file +}; diff --git a/types.ts b/types.ts index ab734c1..6f5e75b 100644 --- a/types.ts +++ b/types.ts @@ -36,6 +36,7 @@ declare global { getAppVersion: () => Promise; getPlatform: () => Promise; getLogPath: () => Promise; + appendLog: (content: string) => Promise<{ success: boolean; error?: string; canceled?: boolean; filePath?: string }>; openExecutableFolder: () => Promise<{ success: boolean; path?: string; error?: string }>; renderPlantUML: ( diagram: string,