diff --git a/package-lock.json b/package-lock.json index 44b12ec..9d3821a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,10 @@ "@codemirror/view": "^6.35.0", "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", + "@types/dompurify": "^3.0.5", "codemirror": "^6.0.1", + "dompurify": "^3.3.1", + "marked": "^17.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", "zustand": "^4.5.0" @@ -1986,6 +1989,15 @@ "@types/ms": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2093,6 +2105,12 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/verror": { "version": "1.10.11", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", @@ -3356,6 +3374,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", @@ -4741,6 +4768,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.3.tgz", + "integrity": "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", diff --git a/package.json b/package.json index acb26c4..390e604 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,10 @@ "@codemirror/view": "^6.35.0", "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", + "@types/dompurify": "^3.0.5", "codemirror": "^6.0.1", + "dompurify": "^3.3.1", + "marked": "^17.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", "zustand": "^4.5.0" diff --git a/src/renderer/components/AIChat/Chat.css b/src/renderer/components/AIChat/Chat.css index 3d72ac2..6f3d319 100644 --- a/src/renderer/components/AIChat/Chat.css +++ b/src/renderer/components/AIChat/Chat.css @@ -85,6 +85,88 @@ padding: 10px 12px; } +/* Assistant messages use rendered Markdown — no pre-wrap needed */ +.chat-message-assistant .chat-message-content { + white-space: normal; +} + +.chat-message-markdown { + word-break: break-word; +} + +.chat-message-markdown p { + margin: 0 0 14px; +} +.chat-message-markdown p:last-child { + margin-bottom: 0; +} + +.chat-message-markdown h1, +.chat-message-markdown h2, +.chat-message-markdown h3 { + color: var(--heading-color); + font-family: var(--font-serif); + font-weight: 600; + margin: 12px 0 4px; + line-height: 1.3; +} +.chat-message-markdown h1 { font-size: 15px; } +.chat-message-markdown h2 { font-size: 14px; } +.chat-message-markdown h3 { font-size: 13px; } + +.chat-message-markdown ul, +.chat-message-markdown ol { + margin: 4px 0 8px; + padding-left: 18px; +} +.chat-message-markdown li { + margin-bottom: 3px; +} + +.chat-message-markdown code { + font-family: monospace; + font-size: 12px; + background: rgba(255, 255, 255, 0.07); + border-radius: 3px; + padding: 1px 4px; +} + +.chat-message-markdown pre { + background: rgba(0, 0, 0, 0.3); + border-radius: 5px; + padding: 10px 12px; + overflow-x: auto; + margin: 6px 0; +} +.chat-message-markdown pre code { + background: none; + padding: 0; + font-size: 12px; +} + +.chat-message-markdown blockquote { + border-left: 3px solid var(--accent); + margin: 6px 0; + padding: 2px 10px; + color: var(--text-secondary); + font-style: italic; +} + +.chat-message-markdown strong { + color: var(--text-primary); + font-weight: 600; +} + +.chat-message-markdown em { + color: var(--text-secondary); +} + +.chat-message-markdown hr { + border: none; + border-top: 1px solid var(--border); + margin: 10px 0; +} + .chat-message-user .chat-message-content { background: var(--user-message-bg); } diff --git a/src/renderer/components/AIChat/ChatMessageItem.tsx b/src/renderer/components/AIChat/ChatMessageItem.tsx index 24c83f7..f535915 100644 --- a/src/renderer/components/AIChat/ChatMessageItem.tsx +++ b/src/renderer/components/AIChat/ChatMessageItem.tsx @@ -1,17 +1,35 @@ +import { useMemo } from 'react' +import { marked } from 'marked' +import DOMPurify from 'dompurify' import type { ChatMessage } from '../../types/editor' +marked.setOptions({ breaks: true }) + interface Props { message: ChatMessage } export function ChatMessageItem({ message }: Props): JSX.Element { + const html = useMemo(() => { + if (message.role !== 'assistant' || !message.content) return null + const raw = marked.parse(message.content) as string + return DOMPurify.sanitize(raw) + }, [message.role, message.content]) + return (
{message.role === 'user' ? 'You' : 'Editor AI'}
- {message.content || } + {html ? ( +
+ ) : ( + message.content || + )}
) diff --git a/src/renderer/components/Editor/MarkdownEditor.tsx b/src/renderer/components/Editor/MarkdownEditor.tsx index b3a470e..c1a0c47 100644 --- a/src/renderer/components/Editor/MarkdownEditor.tsx +++ b/src/renderer/components/Editor/MarkdownEditor.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import { EditorView, Decoration, type DecorationSet } from '@codemirror/view' -import { EditorState, StateField, StateEffect, RangeSetBuilder } from '@codemirror/state' +import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment } from '@codemirror/state' import { markdown } from '@codemirror/lang-markdown' import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language' import { history, defaultKeymap, historyKeymap } from '@codemirror/commands' @@ -44,12 +44,14 @@ const annotationField = StateField.define({ provide: (f) => EditorView.decorations.from(f) }) -// Dark gothic theme for CodeMirror -const hohoffTheme = EditorView.theme( +const themeCompartment = new Compartment() + +function buildTheme(fontSize: number): ReturnType { + return EditorView.theme( { '&': { height: '100%', - fontSize: '15px', + fontSize: `${fontSize}px`, backgroundColor: 'transparent', color: 'var(--text-primary)' }, @@ -59,7 +61,7 @@ const hohoffTheme = EditorView.theme( overflow: 'auto' }, '.cm-content': { - padding: '24px 32px', + padding: '16px 20px', maxWidth: '740px', margin: '0 auto', caretColor: 'var(--accent)' @@ -67,7 +69,7 @@ const hohoffTheme = EditorView.theme( '.cm-cursor': { borderLeftColor: 'var(--accent)' }, '.cm-selectionBackground': { backgroundColor: 'rgba(167,139,95,0.2)' }, '&.cm-focused .cm-selectionBackground': { backgroundColor: 'rgba(167,139,95,0.3)' }, - '.cm-line': { padding: '0' }, + '.cm-line': { padding: '0', marginBottom: '6px' }, '.cm-gutters': { display: 'none' }, '.cm-activeLine': { backgroundColor: 'transparent' }, '.cm-activeLineGutter': { backgroundColor: 'transparent' }, @@ -97,12 +99,13 @@ const hohoffTheme = EditorView.theme( } }, { dark: true } -) + ) +} export function MarkdownEditor(): JSX.Element { const containerRef = useRef(null) const viewRef = useRef(null) - const { activeFilePath, activeFileContent, setContent, annotations } = useEditorStore() + const { activeFilePath, activeFileContent, setContent, annotations, fontSize } = useEditorStore() // Initialize CodeMirror once useEffect(() => { @@ -117,7 +120,7 @@ export function MarkdownEditor(): JSX.Element { markdown(), syntaxHighlighting(defaultHighlightStyle), annotationField, - hohoffTheme, + themeCompartment.of(buildTheme(fontSize)), EditorView.lineWrapping, EditorView.updateListener.of((update) => { if (update.docChanged) { @@ -158,6 +161,13 @@ export function MarkdownEditor(): JSX.Element { view.dispatch({ effects: setAnnotationsEffect.of(annotations) }) }, [annotations]) + // Reconfigure theme when font size changes + useEffect(() => { + const view = viewRef.current + if (!view) return + view.dispatch({ effects: themeCompartment.reconfigure(buildTheme(fontSize)) }) + }, [fontSize]) + return (
{!activeFilePath && ( diff --git a/src/renderer/components/Toolbar/AnalysisToolbar.tsx b/src/renderer/components/Toolbar/AnalysisToolbar.tsx index b5e83a0..fd0ed3a 100644 --- a/src/renderer/components/Toolbar/AnalysisToolbar.tsx +++ b/src/renderer/components/Toolbar/AnalysisToolbar.tsx @@ -31,7 +31,9 @@ export function AnalysisToolbar(): JSX.Element { setAIError, chatHistory, projectWordCount, - setProjectWordCount + setProjectWordCount, + fontSize, + setFontSize } = useEditorStore() useEffect(() => { @@ -154,6 +156,24 @@ export function AnalysisToolbar(): JSX.Element {
+
+ + +
{activeFilePath && ( void + + // Editor font size + fontSize: number + setFontSize: (size: number) => void } export const useEditorStore = create((set, get) => ({ @@ -122,5 +126,12 @@ export const useEditorStore = create((set, get) => ({ setAnalysisMode: (analysisMode) => set({ analysisMode }), projectWordCount: 0, - setProjectWordCount: (projectWordCount) => set({ projectWordCount }) + setProjectWordCount: (projectWordCount) => set({ projectWordCount }), + + fontSize: Number(localStorage.getItem('editorFontSize')) || 15, + setFontSize: (size) => { + const clamped = Math.max(11, Math.min(24, size)) + localStorage.setItem('editorFontSize', String(clamped)) + set({ fontSize: clamped }) + } }))