display word counts

This commit is contained in:
2026-02-21 10:13:02 +10:00
parent 9cf57fe508
commit 7a7b2732f7
8 changed files with 226 additions and 12 deletions

39
package-lock.json generated
View File

@@ -17,7 +17,10 @@
"@codemirror/view": "^6.35.0", "@codemirror/view": "^6.35.0",
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"@types/dompurify": "^3.0.5",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"dompurify": "^3.3.1",
"marked": "^17.0.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"zustand": "^4.5.0" "zustand": "^4.5.0"
@@ -1986,6 +1989,15 @@
"@types/ms": "*" "@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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2093,6 +2105,12 @@
"@types/node": "*" "@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": { "node_modules/@types/verror": {
"version": "1.10.11", "version": "1.10.11",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
@@ -3356,6 +3374,15 @@
"node": ">=8" "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": { "node_modules/dotenv": {
"version": "9.0.2", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-9.0.2.tgz",
@@ -4741,6 +4768,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/matcher": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",

View File

@@ -31,7 +31,10 @@
"@codemirror/view": "^6.35.0", "@codemirror/view": "^6.35.0",
"@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0", "@electron-toolkit/utils": "^3.0.0",
"@types/dompurify": "^3.0.5",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"dompurify": "^3.3.1",
"marked": "^17.0.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"zustand": "^4.5.0" "zustand": "^4.5.0"

View File

@@ -85,6 +85,88 @@
padding: 10px 12px; 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 { .chat-message-user .chat-message-content {
background: var(--user-message-bg); background: var(--user-message-bg);
} }

View File

@@ -1,17 +1,35 @@
import { useMemo } from 'react'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { ChatMessage } from '../../types/editor' import type { ChatMessage } from '../../types/editor'
marked.setOptions({ breaks: true })
interface Props { interface Props {
message: ChatMessage message: ChatMessage
} }
export function ChatMessageItem({ message }: Props): JSX.Element { 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 ( return (
<div className={`chat-message chat-message-${message.role}`}> <div className={`chat-message chat-message-${message.role}`}>
<div className="chat-message-label"> <div className="chat-message-label">
{message.role === 'user' ? 'You' : 'Editor AI'} {message.role === 'user' ? 'You' : 'Editor AI'}
</div> </div>
<div className="chat-message-content"> <div className="chat-message-content">
{message.content || <span className="chat-streaming-cursor"></span>} {html ? (
<div
className="chat-message-markdown"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : (
message.content || <span className="chat-streaming-cursor"></span>
)}
</div> </div>
</div> </div>
) )

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { EditorView, Decoration, type DecorationSet } from '@codemirror/view' 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 { markdown } from '@codemirror/lang-markdown'
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language' import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language'
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands' import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
@@ -44,12 +44,14 @@ const annotationField = StateField.define<DecorationSet>({
provide: (f) => EditorView.decorations.from(f) provide: (f) => EditorView.decorations.from(f)
}) })
// Dark gothic theme for CodeMirror const themeCompartment = new Compartment()
const hohoffTheme = EditorView.theme(
function buildTheme(fontSize: number): ReturnType<typeof EditorView.theme> {
return EditorView.theme(
{ {
'&': { '&': {
height: '100%', height: '100%',
fontSize: '15px', fontSize: `${fontSize}px`,
backgroundColor: 'transparent', backgroundColor: 'transparent',
color: 'var(--text-primary)' color: 'var(--text-primary)'
}, },
@@ -59,7 +61,7 @@ const hohoffTheme = EditorView.theme(
overflow: 'auto' overflow: 'auto'
}, },
'.cm-content': { '.cm-content': {
padding: '24px 32px', padding: '16px 20px',
maxWidth: '740px', maxWidth: '740px',
margin: '0 auto', margin: '0 auto',
caretColor: 'var(--accent)' caretColor: 'var(--accent)'
@@ -67,7 +69,7 @@ const hohoffTheme = EditorView.theme(
'.cm-cursor': { borderLeftColor: 'var(--accent)' }, '.cm-cursor': { borderLeftColor: 'var(--accent)' },
'.cm-selectionBackground': { backgroundColor: 'rgba(167,139,95,0.2)' }, '.cm-selectionBackground': { backgroundColor: 'rgba(167,139,95,0.2)' },
'&.cm-focused .cm-selectionBackground': { backgroundColor: 'rgba(167,139,95,0.3)' }, '&.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-gutters': { display: 'none' },
'.cm-activeLine': { backgroundColor: 'transparent' }, '.cm-activeLine': { backgroundColor: 'transparent' },
'.cm-activeLineGutter': { backgroundColor: 'transparent' }, '.cm-activeLineGutter': { backgroundColor: 'transparent' },
@@ -97,12 +99,13 @@ const hohoffTheme = EditorView.theme(
} }
}, },
{ dark: true } { dark: true }
) )
}
export function MarkdownEditor(): JSX.Element { export function MarkdownEditor(): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null) const viewRef = useRef<EditorView | null>(null)
const { activeFilePath, activeFileContent, setContent, annotations } = useEditorStore() const { activeFilePath, activeFileContent, setContent, annotations, fontSize } = useEditorStore()
// Initialize CodeMirror once // Initialize CodeMirror once
useEffect(() => { useEffect(() => {
@@ -117,7 +120,7 @@ export function MarkdownEditor(): JSX.Element {
markdown(), markdown(),
syntaxHighlighting(defaultHighlightStyle), syntaxHighlighting(defaultHighlightStyle),
annotationField, annotationField,
hohoffTheme, themeCompartment.of(buildTheme(fontSize)),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged) { if (update.docChanged) {
@@ -158,6 +161,13 @@ export function MarkdownEditor(): JSX.Element {
view.dispatch({ effects: setAnnotationsEffect.of(annotations) }) view.dispatch({ effects: setAnnotationsEffect.of(annotations) })
}, [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 ( return (
<div className="editor-container"> <div className="editor-container">
{!activeFilePath && ( {!activeFilePath && (

View File

@@ -31,7 +31,9 @@ export function AnalysisToolbar(): JSX.Element {
setAIError, setAIError,
chatHistory, chatHistory,
projectWordCount, projectWordCount,
setProjectWordCount setProjectWordCount,
fontSize,
setFontSize
} = useEditorStore() } = useEditorStore()
useEffect(() => { useEffect(() => {
@@ -154,6 +156,24 @@ export function AnalysisToolbar(): JSX.Element {
</div> </div>
<div className="toolbar-right"> <div className="toolbar-right">
<div className="toolbar-fontsize">
<button
className="toolbar-fontsize-btn"
onClick={() => setFontSize(fontSize - 1)}
disabled={fontSize <= 11}
title="Decrease font size"
>
A
</button>
<button
className="toolbar-fontsize-btn"
onClick={() => setFontSize(fontSize + 1)}
disabled={fontSize >= 24}
title="Increase font size"
>
A+
</button>
</div>
{activeFilePath && ( {activeFilePath && (
<span <span
className="toolbar-wordcount" className="toolbar-wordcount"

View File

@@ -96,6 +96,37 @@
max-width: 260px; max-width: 260px;
} }
.toolbar-fontsize {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.toolbar-fontsize-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
font-size: 11px;
font-family: var(--font-sans);
padding: 2px 6px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
line-height: 1.4;
}
.toolbar-fontsize-btn:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--text-muted);
}
.toolbar-fontsize-btn:disabled {
opacity: 0.3;
cursor: default;
}
.toolbar-wordcount { .toolbar-wordcount {
color: var(--text-muted); color: var(--text-muted);
font-size: 11px; font-size: 11px;

View File

@@ -38,6 +38,10 @@ interface EditorState {
// Word counts // Word counts
projectWordCount: number projectWordCount: number
setProjectWordCount: (count: number) => void setProjectWordCount: (count: number) => void
// Editor font size
fontSize: number
setFontSize: (size: number) => void
} }
export const useEditorStore = create<EditorState>((set, get) => ({ export const useEditorStore = create<EditorState>((set, get) => ({
@@ -122,5 +126,12 @@ export const useEditorStore = create<EditorState>((set, get) => ({
setAnalysisMode: (analysisMode) => set({ analysisMode }), setAnalysisMode: (analysisMode) => set({ analysisMode }),
projectWordCount: 0, 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 })
}
})) }))