From 022f6233e83a367a2b5b1305e5fe2f8b49927ef8 Mon Sep 17 00:00:00 2001 From: Calum Knott Date: Sun, 1 Nov 2026 15:56:04 +0000 Subject: [PATCH] Pluggable font stacks: setFontStacks/resetFontStacks/fontStackOf --- packages/core/src/editor.js | 4 +-- packages/core/src/index.js | 4 +-- packages/core/src/palette.js | 21 +++++++++++++++ packages/core/src/shapes.js | 9 ++++--- packages/core/test/palette.test.js | 42 ++++++++++++++++++++++++++++-- packages/core/types/index.d.ts | 13 ++++++++- 6 files changed, 82 insertions(+), 11 deletions(-) diff --git a/packages/core/src/editor.js b/packages/core/src/editor.js index 236e459..2b4f780 100644 --- a/packages/core/src/editor.js +++ b/packages/core/src/editor.js @@ -4,7 +4,7 @@ // Dependency-free ESM: runs in any modern browser as-is, no build step. import { Store, newId } from './store.js' -import { themeOf, SIZES, FONT_SIZES, FONTS, GEO_IDS, COLOR_IDS, GRID_IDS, GRID_STEP, GRID_BAND, GRID_MAJOR } from './palette.js' +import { themeOf, SIZES, FONT_SIZES, FONTS, GEO_IDS, COLOR_IDS, GRID_IDS, GRID_STEP, GRID_BAND, GRID_MAJOR, fontStackOf } from './palette.js' import { localBounds, pageBounds, toLocal, drawShape, hitShape, marqueeHits, scaleShape, textLayout, noteLayout, NOTE_W, sampleLinePts, resetTextLayouts, @@ -1084,7 +1084,7 @@ export class Editor { } else if (shape.type === 'geo' && ed.field === 'label') { const p = shape.props const fs = FONT_SIZES[p.labelSize || 's'] * (p.labelScale || 1) - const fam = FONTS[p.font || 'draw'] + const fam = fontStackOf(p.font || 'draw') pos = this.pageToScreen(shape.x + 8, shape.y + 8) w = p.w - 16 h = p.h - 16 diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 79a44f7..cf143a4 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -6,8 +6,8 @@ export { Editor, TOOLS } from './editor.js' export { Store, newId, isDiffEmpty, invertDiff, composeDiff, packDiff, unpackDiff, streamDiffs } from './store.js' export { buildUI } from './ui.js' export { - themeOf, THEMES, COLOR_IDS, SIZE_IDS, DASH_IDS, FILL_IDS, GEO_IDS, GRID_IDS, - SIZES, FONT_SIZES, FONTS, + themeOf, THEMES, COLOR_IDS, SIZE_IDS, DASH_IDS, FILL_IDS, FONT_IDS, GEO_IDS, GRID_IDS, + SIZES, FONT_SIZES, FONTS, setFontStacks, resetFontStacks, fontStackOf, } from './palette.js' export { pageBounds, localBounds, drawShape, hitShape } from './shapes.js' export { strokeOutline, inkOptions } from './freehand.js' diff --git a/packages/core/src/palette.js b/packages/core/src/palette.js index 98e3b6c..a92082e 100644 --- a/packages/core/src/palette.js +++ b/packages/core/src/palette.js @@ -105,6 +105,7 @@ export const NOTE_FONT_SIZES = { s: 16, m: 20, l: 26, xl: 32 } export const DASH_IDS = ['draw', 'solid', 'dashed', 'dotted'] export const FILL_IDS = ['none', 'semi', 'solid', 'pattern'] +export const FONT_IDS = ['draw', 'sans', 'serif', 'mono'] export const FONTS = { // 'Quickdraw Hand' is Libre Baskerville, bundled via quickdraw.css; the rest are @@ -115,6 +116,26 @@ export const FONTS = { mono: "'SF Mono', ui-monospace, Menlo, monospace", } +// Pluggable font stacks: remap any style id to a full CSS font-family stack +// (or register a brand-new id) — `setFontStacks({ sans: "'Inter', ..." })`. +// Everything that measures or draws type resolves through fontStackOf(), so +// one call reconfigures the editor, the UI previews and headless export +// alike. Process-global, like the backend injection: configure once at +// startup. Custom ids work anywhere a `font` prop is read. +const fontOverrides = {} +export function setFontStacks(stacks = {}) { + for (const [id, stack] of Object.entries(stacks)) { + if (typeof id !== 'string' || !id) throw new TypeError('font id must be a non-empty string') + if (typeof stack !== 'string' || !stack.trim()) throw new TypeError(`font stack for '${id}' must be a non-empty CSS font-family string`) + fontOverrides[id] = stack + } + return { ...fontOverrides } +} +export function resetFontStacks() { + for (const k of Object.keys(fontOverrides)) delete fontOverrides[k] +} +export const fontStackOf = (id) => fontOverrides[id] ?? FONTS[id] ?? FONTS.draw + export const GEO_IDS = ['rectangle', 'ellipse', 'triangle', 'diamond', 'hexagon', 'star', 'cloud'] // Highlighter: wide translucent band that multiplies into the paper. diff --git a/packages/core/src/shapes.js b/packages/core/src/shapes.js index 2fb5ed0..bfcf1bd 100644 --- a/packages/core/src/shapes.js +++ b/packages/core/src/shapes.js @@ -5,7 +5,8 @@ // Dependency-free ESM (see palette.js). import { - SIZES, INK_SIZES, FONT_SIZES, NOTE_FONT_SIZES, FONTS, HIGHLIGHT_ALPHA, HIGHLIGHT_SCALE, + SIZES, INK_SIZES, FONT_SIZES, NOTE_FONT_SIZES, HIGHLIGHT_ALPHA, HIGHLIGHT_SCALE, + fontStackOf, } from './palette.js' import { ptsBounds, rotWith, distToPolyline, pointInPolygon, pointInEllipse, @@ -159,7 +160,7 @@ export function textLayout(shape) { const hit = layoutCache.get(p) if (hit) return hit const fontSize = FONT_SIZES[p.size] * (p.scale || 1) - const font = FONTS[p.font || 'draw'] + const font = fontStackOf(p.font || 'draw') const lh = fontSize * 1.32 const maxW = p.autosize === false && p.w ? p.w : 0 const lines = wrapLines(p.text, font, fontSize, maxW) @@ -174,7 +175,7 @@ export function noteLayout(shape) { const hit = layoutCache.get(p) if (hit) return hit const fontSize = NOTE_FONT_SIZES[p.size] - const font = FONTS[p.font || 'draw'] + const font = fontStackOf(p.font || 'draw') const lh = fontSize * 1.35 const lines = wrapLines(p.text, font, fontSize, NOTE_W - NOTE_PAD * 2) const textH = lines.length * lh @@ -190,7 +191,7 @@ function geoLabelLayout(shape) { let hit = layoutCache.get(key) if (hit) return hit const fontSize = FONT_SIZES[p.labelSize || 's'] * (p.labelScale || 1) - const font = FONTS[p.font || 'draw'] + const font = fontStackOf(p.font || 'draw') const lh = fontSize * 1.3 const lines = wrapLines(p.label, font, fontSize, Math.max(24, p.w - LABEL_PAD * 2)) hit = { lines, fontSize, font, lh, textH: lines.length * lh } diff --git a/packages/core/test/palette.test.js b/packages/core/test/palette.test.js index de2b655..f9a9133 100644 --- a/packages/core/test/palette.test.js +++ b/packages/core/test/palette.test.js @@ -1,7 +1,8 @@ -import { describe, it, expect } from 'vitest' +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from 'vitest' import { THEMES, themeOf, COLOR_IDS, SIZE_IDS, DASH_IDS, FILL_IDS, GEO_IDS, GRID_IDS, - SIZES, FONT_SIZES, FONTS, + SIZES, FONT_SIZES, FONTS, FONT_IDS, setFontStacks, resetFontStacks, fontStackOf, } from '../src/palette.js' describe('palette', () => { @@ -50,3 +51,40 @@ describe('palette', () => { } }) }) + +describe('font stacks', () => { + afterEach(() => resetFontStacks()) + + it('defaults resolve every rostered id; unknown ids fall back to draw', () => { + for (const id of FONT_IDS) expect(fontStackOf(id)).toBe(FONTS[id]) + expect(fontStackOf('nope')).toBe(FONTS.draw) + expect(fontStackOf()).toBe(FONTS.draw) + }) + + it('setFontStacks remaps ids (and registers new ones); reset restores', () => { + setFontStacks({ sans: "'Inter', sans-serif", brand: "'Brand', serif" }) + expect(fontStackOf('sans')).toBe("'Inter', sans-serif") + expect(fontStackOf('brand')).toBe("'Brand', serif") + expect(fontStackOf('draw')).toBe(FONTS.draw) + resetFontStacks() + expect(fontStackOf('sans')).toBe(FONTS.sans) + expect(fontStackOf('brand')).toBe(FONTS.draw) + }) + + it('rejects empty ids and stacks', () => { + expect(() => setFontStacks({ '': 'serif' })).toThrow(TypeError) + expect(() => setFontStacks({ sans: '' })).toThrow(TypeError) + expect(() => setFontStacks({ sans: 42 })).toThrow(TypeError) + }) + + it('text layout follows the override', async () => { + const { textLayout } = await import('../src/shapes.js') + const shape = (font) => ({ + id: 't', typeName: 'shape', type: 'text', x: 0, y: 0, rot: 0, z: 1, + props: { text: 'hi', size: 'm', color: 'black', font }, + }) + expect(textLayout(shape('sans')).font).toBe(FONTS.sans) + setFontStacks({ sans: "'Inter', sans-serif" }) + expect(textLayout(shape('sans')).font).toBe("'Inter', sans-serif") + }) +}) diff --git a/packages/core/types/index.d.ts b/packages/core/types/index.d.ts index f283c44..f9c69b5 100644 --- a/packages/core/types/index.d.ts +++ b/packages/core/types/index.d.ts @@ -13,7 +13,7 @@ export type ColorId = export type SizeId = 's' | 'm' | 'l' | 'xl' export type DashId = 'draw' | 'solid' | 'dashed' | 'dotted' export type FillId = 'none' | 'semi' | 'solid' | 'pattern' -export type FontId = 'draw' | 'sans' | 'serif' | 'mono' +export type FontId = 'draw' | 'sans' | 'serif' | 'mono' | (string & {}) export type GeoId = 'rectangle' | 'ellipse' | 'triangle' | 'diamond' | 'hexagon' | 'star' | 'cloud' export type ThemeId = 'light' | 'dark' export type GridId = 'none' | 'lines' | 'ruled' | 'dots' | 'crosses' | 'iso' @@ -105,6 +105,17 @@ export const THEMES: Record export const SIZES: Record export const FONT_SIZES: Record export const FONTS: Record +/** + * Remap font style ids to full CSS font-family stacks (or register new ids): + * setFontStacks({ sans: "'Inter', system-ui, sans-serif" }). One call + * reconfigures measurement, drawing, the editor textarea and UI previews. + * Process-global: configure once at startup. + */ +export function setFontStacks(stacks: Record): Record +/** Clear all custom font stacks set with setFontStacks. */ +export function resetFontStacks(): void +/** Resolve a font style id to its stack (custom override, bundled default, else draw). */ +export function fontStackOf(id?: string): string export function themeOf(id?: string): Theme export function newId(prefix?: string): string