Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 96 additions & 57 deletions components/RichTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>,
) => {
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<RichTextEditorHandle, RichTextEditorProps>(
({ html, onChange, readOnly = false, onScroll, onFocusChange }, ref) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -860,13 +903,17 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
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],
Expand Down Expand Up @@ -911,7 +958,7 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
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) => {
Expand All @@ -920,15 +967,7 @@ const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
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],
Expand Down
11 changes: 11 additions & 0 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
14 changes: 9 additions & 5 deletions services/storageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,14 @@ export const storageService = {
* @param content The string content to append.
*/
appendLogToFile: async (content: string): Promise<void> => {
// 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.');
},
};
};
1 change: 1 addition & 0 deletions types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ declare global {
getAppVersion: () => Promise<string>;
getPlatform: () => Promise<string>;
getLogPath: () => Promise<string>;
appendLog: (content: string) => Promise<{ success: boolean; error?: string; canceled?: boolean; filePath?: string }>;
openExecutableFolder: () => Promise<{ success: boolean; path?: string; error?: string }>;
renderPlantUML: (
diagram: string,
Expand Down