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",
"@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",

View File

@@ -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"

View File

@@ -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);
}

View File

@@ -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 (
<div className={`chat-message chat-message-${message.role}`}>
<div className="chat-message-label">
{message.role === 'user' ? 'You' : 'Editor AI'}
</div>
<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>
)

View File

@@ -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<DecorationSet>({
provide: (f) => EditorView.decorations.from(f)
})
// Dark gothic theme for CodeMirror
const hohoffTheme = EditorView.theme(
const themeCompartment = new Compartment()
function buildTheme(fontSize: number): ReturnType<typeof EditorView.theme> {
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<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(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 (
<div className="editor-container">
{!activeFilePath && (

View File

@@ -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 {
</div>
<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 && (
<span
className="toolbar-wordcount"

View File

@@ -96,6 +96,37 @@
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 {
color: var(--text-muted);
font-size: 11px;

View File

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