diff --git a/packages/review-editor/components/DiffHunkPreview.tsx b/packages/review-editor/components/DiffHunkPreview.tsx index 1731b5f30..a49f43c62 100644 --- a/packages/review-editor/components/DiffHunkPreview.tsx +++ b/packages/review-editor/components/DiffHunkPreview.tsx @@ -6,6 +6,7 @@ import { useTheme } from '@plannotator/ui/components/ThemeProvider'; import { useConfigValue } from '@plannotator/ui/config'; import { useReviewState } from '../dock/ReviewStateContext'; import { resolveSyntaxTheme, buildLineBgOverrides } from '../hooks/usePierreTheme'; +import { buildPierreFontCSS } from '../utils/pierreFontCss'; interface DiffHunkPreviewProps { /** Raw diff hunk string (unified diff format). */ @@ -31,11 +32,7 @@ function buildPierreCSS( const fg = styles.getPropertyValue('--foreground').trim(); if (!bg || !fg) return ''; - const fontCSS = (fontFamily || fontSize) ? ` - pre, code, [data-line-content], [data-column-number] { - ${fontFamily ? `font-family: '${fontFamily}', monospace !important;` : ''} - ${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''} - }` : ''; + const fontCSS = buildPierreFontCSS(fontFamily, fontSize); return ` :host, [data-diff], [data-file], [data-diffs-header], [data-error-wrapper], [data-virtualizer-buffer] { diff --git a/packages/review-editor/hooks/usePierreTheme.ts b/packages/review-editor/hooks/usePierreTheme.ts index 655febc00..db36477b1 100644 --- a/packages/review-editor/hooks/usePierreTheme.ts +++ b/packages/review-editor/hooks/usePierreTheme.ts @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import type { DiffLineBgIntensity } from '@plannotator/shared/config'; import { useTheme } from '@plannotator/ui/components/ThemeProvider'; import { useConfigValue } from '@plannotator/ui/config'; +import { buildPierreFontCSS } from '../utils/pierreFontCss'; export const SHIKI_THEME_MAP: Record = { 'andromeeda': { dark: 'andromeeda', light: null }, @@ -185,11 +186,7 @@ export function usePierreTheme(options?: { fontFamily?: string; fontSize?: strin const primary = styles.getPropertyValue('--primary').trim(); if (!bg || !fg) return; - const fontCSS = fontFamily || fontSize ? ` - pre, code, [data-line-content], [data-column-number] { - ${fontFamily ? `font-family: '${fontFamily}', monospace !important;` : ''} - ${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''} - }` : ''; + const fontCSS = buildPierreFontCSS(fontFamily, fontSize); setPierreTheme({ type: resolvedMode, diff --git a/packages/review-editor/utils/pierreFontCss.ts b/packages/review-editor/utils/pierreFontCss.ts new file mode 100644 index 000000000..4fdd29822 --- /dev/null +++ b/packages/review-editor/utils/pierreFontCss.ts @@ -0,0 +1,17 @@ +/** + * Font override CSS for Pierre's shadow DOM. + * + * Single home for the font-family/font-size injection block shared by + * usePierreTheme (live diff theme) and DiffHunkPreview (synchronous tooltip + * theme). fontFamily is free text since custom fonts (#851) — escape so a + * name containing quotes/backslashes can't break out of the CSS string. + */ +export function buildPierreFontCSS(fontFamily?: string, fontSize?: string): string { + const safeFontFamily = fontFamily?.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + if (!safeFontFamily && !fontSize) return ''; + return ` + pre, code, [data-line-content], [data-column-number] { + ${safeFontFamily ? `font-family: '${safeFontFamily}', monospace !important;` : ''} + ${fontSize ? `font-size: ${fontSize} !important; line-height: 1.5 !important;` : ''} + }`; +} diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 79b8e6752..cc33bc227 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -1,9 +1,10 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import type { Origin } from '@plannotator/shared/agents'; import type { DiffLineBgIntensity } from '@plannotator/shared/config'; import { configStore, useConfigValue } from '../config'; import { loadDiffFont } from '../utils/diffFonts'; +import { isFontInstalled } from '../utils/fontDetect'; import { TaterSpritePullup } from './TaterSpritePullup'; import { getIdentity, regenerateIdentity, setCustomIdentity } from '../utils/identity'; import { GitUser } from '../icons/GitUser'; @@ -228,6 +229,18 @@ const GitTab: React.FC = () => { ); }; +/** Sentinel for the "Custom…" select option — never persisted as a font name. */ +const CUSTOM_FONT_SENTINEL = '__custom__'; + +function isBuiltInFont(value: string): boolean { + return DIFF_FONT_OPTIONS.some((opt) => opt.value === value); +} + +/** Inline style rendering UI chrome in the chosen font (sanitized — free text). */ +function fontPreviewStyle(family: string): React.CSSProperties { + return { fontFamily: `'${family.replace(/['"\\]/g, '')}', monospace` }; +} + const ReviewDisplayTab: React.FC = () => { const diffStyle = useConfigValue('diffStyle'); const diffOverflow = useConfigValue('diffOverflow'); @@ -240,11 +253,69 @@ const ReviewDisplayTab: React.FC = () => { const diffFontFamily = useConfigValue('diffFontFamily'); const diffFontSize = useConfigValue('diffFontSize'); + // Custom font: progressive disclosure. customFontMode keeps the input open + // while the user picks "Custom…" or has a non-built-in font saved. + const [customFontMode, setCustomFontMode] = useState( + () => !!diffFontFamily && !isBuiltInFont(diffFontFamily), + ); + const [customFontDraft, setCustomFontDraft] = useState( + () => (diffFontFamily && !isBuiltInFont(diffFontFamily) ? diffFontFamily : ''), + ); + const customFontSaveTimer = useRef | undefined>(undefined); + // Pending debounced value. Non-null means "typed but not yet saved" — it is + // flushed (not discarded) on unmount, and cancelled when a built-in is + // picked so a stale timer can't overwrite the new selection. + const pendingFontSave = useRef(null); + const customActive = customFontMode || (!!diffFontFamily && !isBuiltInFont(diffFontFamily)); + // Width-measurement check — deterministic per machine, no permissions. + const customFontInstalled = useMemo( + () => (customFontDraft.trim() ? isFontInstalled(customFontDraft) : null), + [customFontDraft], + ); + // Load font for the preview swatch useEffect(() => { if (diffFontFamily) loadDiffFont(diffFontFamily); }, [diffFontFamily]); + // Adopt external changes (late server hydration, edits from elsewhere) into + // the draft — but never clobber typing that hasn't been saved yet. + useEffect(() => { + if (pendingFontSave.current !== null) return; + if (diffFontFamily && !isBuiltInFont(diffFontFamily) && diffFontFamily !== customFontDraft.trim()) { + setCustomFontDraft(diffFontFamily); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [diffFontFamily]); + + const cancelPendingFontSave = () => { + clearTimeout(customFontSaveTimer.current); + pendingFontSave.current = null; + }; + + // Flush a pending save on unmount so closing the panel mid-debounce + // doesn't lose the typed font. + useEffect(() => () => { + clearTimeout(customFontSaveTimer.current); + if (pendingFontSave.current !== null) { + configStore.set('diffFontFamily', pendingFontSave.current); + } + }, []); + + const handleCustomFontInput = (value: string) => { + setCustomFontDraft(value); + // Debounce the save — configStore.set writes the cookie and POSTs + // /api/config; per-keystroke writes would spam both. + clearTimeout(customFontSaveTimer.current); + pendingFontSave.current = value.trim(); + customFontSaveTimer.current = setTimeout(() => { + if (pendingFontSave.current !== null) { + configStore.set('diffFontFamily', pendingFontSave.current); + pendingFontSave.current = null; + } + }, 400); + }; + return ( <> {/* Font Family */} @@ -254,19 +325,54 @@ const ReviewDisplayTab: React.FC = () => {
Font family for diff code lines
+ {customActive && ( +
+ handleCustomFontInput(e.target.value)} + placeholder="Font name as installed, e.g. Iosevka" + autoFocus={!customFontDraft} + spellCheck={false} + className="w-full px-3 py-1.5 text-sm rounded-md bg-muted/50 border border-border text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-ring" + style={customFontDraft ? fontPreviewStyle(customFontDraft) : undefined} + /> +
+ {!customFontDraft.trim() ? ( + Any font installed on this machine works — type its family name. + ) : customFontInstalled === true ? ( + ✓ Found on this machine + ) : customFontInstalled === false ? ( + Not found — diffs will fall back to monospace + ) : null} +
+
+ )} {diffFontFamily && (
Preview: const x = fn(42);
diff --git a/packages/ui/utils/fontDetect.ts b/packages/ui/utils/fontDetect.ts new file mode 100644 index 000000000..115d65510 --- /dev/null +++ b/packages/ui/utils/fontDetect.ts @@ -0,0 +1,58 @@ +/** + * Local font detection via width measurement. + * + * Renders a glyph-diverse sample string with the candidate family in front of + * each generic fallback (monospace, serif, sans-serif) and compares measured + * widths against the fallback alone. If any pair differs, the browser resolved + * the candidate font — it is installed (or already loaded as a webfont). + * + * This observes actual rendering rather than querying font-availability APIs, + * so it is deterministic for a given machine + browser + installed-font set. + * `document.fonts.check()` is NOT used: its spec answers "would this render + * without a font load," which browsers answer inconsistently for families the + * page never registered. + * + * Known limits: + * - The name must be the family name as the OS reports it ("Iosevka Custom", + * not a file name). + * - A font installed mid-session is picked up on the next call (no event). + */ + +const SAMPLE = "mmmmmmmmmmlli0O1Il@#WQ"; +const SIZE = "72px"; +const GENERICS = ["monospace", "serif", "sans-serif"] as const; + +let ctx: CanvasRenderingContext2D | null | undefined; + +function getContext(): CanvasRenderingContext2D | null { + if (ctx !== undefined) return ctx; + try { + ctx = document.createElement("canvas").getContext("2d"); + } catch { + ctx = null; + } + return ctx; +} + +/** + * Check whether a font family resolves on this machine. + * + * Returns `true` / `false` from width measurement, or `null` when measurement + * is unavailable (no canvas) — callers should treat `null` as "unknown" and + * skip any installed/missing hint. + */ +export function isFontInstalled(family: string): boolean | null { + const name = family.trim().replace(/["']/g, ""); + if (!name) return false; + + const c = getContext(); + if (!c) return null; + + for (const generic of GENERICS) { + c.font = `${SIZE} ${generic}`; + const base = c.measureText(SAMPLE).width; + c.font = `${SIZE} "${name}", ${generic}`; + if (c.measureText(SAMPLE).width !== base) return true; + } + return false; +}