mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart theming: user font upload (FontFace-from-IndexedDB) + variable-font weight support
This commit is contained in:
@@ -75,3 +75,53 @@
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Managed list of user-uploaded fonts under the Type panel's font control. */
|
||||
.fontList {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-2) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.fontItem {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.fontNameWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fontName {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* "Variable" tag on a variable-font row in the managed list — passive chrome,
|
||||
bordered rather than filled (arch 09 §4). */
|
||||
.fontBadge {
|
||||
flex: none;
|
||||
padding: 1px var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fontMeta {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -8,10 +8,17 @@
|
||||
* the per-domain panels (Axes, Legend); this panel is family, size, and weight.
|
||||
*/
|
||||
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { THEME_FONT_OPTIONS } from '@core/custom-theme';
|
||||
import { type FontAxis, fontFamilyStack, isVariableFont } from '@core/font-asset';
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import { humanizeBytes } from '@core/storage-estimate';
|
||||
import { asNumber, type ConfigPath, getConfigValue } from '@core/theme-controls';
|
||||
import { removeFont, uploadFontFiles } from '../services/fonts';
|
||||
import { confirm } from '../stores/ConfirmStore';
|
||||
import { useConfigSetter, useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { useFontStore } from '../stores/FontStore';
|
||||
import { Button } from './Button';
|
||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||
import { ControlSection, NumberRow, SelectRow } from './ThemeFields';
|
||||
import styles from './ThemeFields.module.css';
|
||||
@@ -24,24 +31,36 @@ const AXIS_LABEL_SIZE: ConfigPath = ['axis', 'labelFontSize'];
|
||||
const AXIS_LABEL_WEIGHT: ConfigPath = ['axis', 'labelFontWeight'];
|
||||
|
||||
/**
|
||||
* Font options, each labelled in its own family so the dropdown previews the
|
||||
* typeface (the type analogue of the color dropdowns' swatches). The roster
|
||||
* (THEME_FONT_OPTIONS) is loaded before render by the chart-renderer's font gate.
|
||||
* The built-in roster, each labelled in its own family so the dropdown previews
|
||||
* the typeface (the type analogue of the color dropdowns' swatches). Loaded
|
||||
* before render by the chart-renderer's font gate. User-uploaded fonts are merged
|
||||
* ahead of these at render (see `TypeControls`).
|
||||
*/
|
||||
const FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
labelStyle: { fontFamily: value },
|
||||
}));
|
||||
const ROSTER_FONT_OPTIONS: SelectControlOption<string>[] = THEME_FONT_OPTIONS.map(
|
||||
({ value, label }) => ({ value, label, labelStyle: { fontFamily: value } }),
|
||||
);
|
||||
|
||||
// Numeric font weights as a tri-state enum; '' is the unset (default) sentinel.
|
||||
type Weight = '' | '400' | '500' | '600' | '700' | 'custom';
|
||||
/** A variable font's badge text — its weight range when present, else just "Variable". */
|
||||
function variableBadge(axes: FontAxis[] | undefined): string {
|
||||
const wght = axes?.find((a) => a.tag === 'wght');
|
||||
return wght ? `Variable ${wght.min}–${wght.max}` : 'Variable';
|
||||
}
|
||||
|
||||
// Numeric font weights as an enum; '' is the unset (default) sentinel. The full
|
||||
// 100–900 range is offered so a variable font's weight axis is fully selectable;
|
||||
// a static face simply faux-renders the weights it doesn't physically carry.
|
||||
type Weight = '' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' | 'custom';
|
||||
const weightOptions: SelectControlOption<Weight>[] = [
|
||||
{ value: '', label: 'Theme default' },
|
||||
{ value: '100', label: 'Thin' },
|
||||
{ value: '200', label: 'Extra Light' },
|
||||
{ value: '300', label: 'Light' },
|
||||
{ value: '400', label: 'Normal' },
|
||||
{ value: '500', label: 'Medium' },
|
||||
{ value: '600', label: 'Semibold' },
|
||||
{ value: '700', label: 'Bold' },
|
||||
{ value: '800', label: 'Extra Bold' },
|
||||
{ value: '900', label: 'Black' },
|
||||
];
|
||||
const weightValue = (v: unknown): Weight => {
|
||||
if (v === undefined) return '';
|
||||
@@ -57,37 +76,112 @@ const weightValue = (v: unknown): Weight => {
|
||||
|
||||
export function TypeControls({ config }: { config: JsonObject }) {
|
||||
const set = useConfigSetter();
|
||||
const fonts = useFontStore((s) => s.fonts);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// User uploads first, then a divider, then the built-in roster — so a brand's
|
||||
// own faces lead the list (SelectControl's `dividerBefore` draws the boundary).
|
||||
const fontOptions = useMemo<SelectControlOption<string>[]>(() => {
|
||||
const userOptions = fonts.map((f) => ({
|
||||
value: fontFamilyStack(f.family),
|
||||
label: f.family,
|
||||
labelStyle: { fontFamily: fontFamilyStack(f.family) },
|
||||
}));
|
||||
const roster = ROSTER_FONT_OPTIONS.map((o, i) =>
|
||||
i === 0 && userOptions.length > 0 ? { ...o, dividerBefore: true } : o,
|
||||
);
|
||||
return [...userOptions, ...roster];
|
||||
}, [fonts]);
|
||||
|
||||
const currentFont =
|
||||
typeof config.font === 'string' ? FONT_OPTIONS.find((o) => o.value === config.font) : undefined;
|
||||
typeof config.font === 'string' ? fontOptions.find((o) => o.value === config.font) : undefined;
|
||||
|
||||
const setWeight = (path: ConfigPath) => (w: Weight) =>
|
||||
set(path, w === '' ? undefined : Number(w));
|
||||
|
||||
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = e.target.files;
|
||||
if (picked && picked.length > 0) void uploadFontFiles(picked);
|
||||
e.target.value = ''; // let the same file be re-picked after a removal
|
||||
};
|
||||
|
||||
const onRemoveFont = async (id: number, family: string) => {
|
||||
const ok = await confirm({
|
||||
title: 'Remove font',
|
||||
message:
|
||||
`Remove "${family}"? Charts using it fall back to a default font. ` +
|
||||
'This only removes the uploaded file from Astrolabe.',
|
||||
confirmLabel: 'Remove',
|
||||
danger: true,
|
||||
});
|
||||
if (ok) removeFont(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<ControlSection title="Font family" hint="Applied to every text slot in the config.">
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Font</span>
|
||||
<SelectControl
|
||||
id="theme-builder-font"
|
||||
label="Font family"
|
||||
heading="Apply font"
|
||||
options={FONT_OPTIONS}
|
||||
value={currentFont?.value}
|
||||
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
|
||||
triggerContent={
|
||||
<>
|
||||
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
|
||||
{currentFont?.label ?? 'Apply font…'}
|
||||
</span>
|
||||
<span className={styles.caret} aria-hidden="true">
|
||||
▾
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
triggerTitle="Write one font family into every font slot of the config"
|
||||
/>
|
||||
<div className={styles.control}>
|
||||
<SelectControl
|
||||
id="theme-builder-font"
|
||||
label="Font family"
|
||||
heading="Apply font"
|
||||
options={fontOptions}
|
||||
value={currentFont?.value}
|
||||
onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)}
|
||||
triggerContent={
|
||||
<>
|
||||
<span style={currentFont ? { fontFamily: currentFont.value } : undefined}>
|
||||
{currentFont?.label ?? 'Apply font…'}
|
||||
</span>
|
||||
<span className={styles.caret} aria-hidden="true">
|
||||
▾
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
triggerTitle="Write one font family into every font slot of the config"
|
||||
/>
|
||||
<Button variant="ghost" onClick={() => fileRef.current?.click()}>
|
||||
Upload font…
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".woff2,.woff,.ttf,.otf"
|
||||
multiple
|
||||
hidden
|
||||
onChange={onPickFiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{fonts.length > 0 && (
|
||||
<ul className={styles.fontList}>
|
||||
{fonts.map((f) => (
|
||||
<li key={f.id} className={styles.fontItem}>
|
||||
<span className={styles.fontNameWrap}>
|
||||
<span
|
||||
className={styles.fontName}
|
||||
style={{ fontFamily: fontFamilyStack(f.family) }}
|
||||
>
|
||||
{f.family}
|
||||
</span>
|
||||
{isVariableFont(f.axes) && (
|
||||
<span className={styles.fontBadge}>{variableBadge(f.axes)}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.fontMeta}>{humanizeBytes(f.size)}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void onRemoveFont(f.id, f.family)}
|
||||
aria-label={`Remove ${f.family}`}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Title">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { IDBFactory } from 'fake-indexeddb';
|
||||
import {
|
||||
DATASETS_STORE,
|
||||
FONTS_STORE,
|
||||
SNIPPETS_STORE,
|
||||
THEMES_STORE,
|
||||
_resetDbForTests,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
put,
|
||||
} from './db';
|
||||
|
||||
const ALL_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE];
|
||||
const ALL_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE, FONTS_STORE];
|
||||
|
||||
/** Open the raw database at `version` with a custom (or absent) upgrade body. */
|
||||
function rawOpen(version: number, upgrade?: (db: IDBDatabase) => void): Promise<IDBDatabase> {
|
||||
|
||||
@@ -11,16 +11,18 @@ const DB_NAME = 'astrolabe';
|
||||
/**
|
||||
* Store-layout version. Bump only when the set of object stores / indexes
|
||||
* changes — independent of per-record schema versions (see snippet-migrations).
|
||||
* v2 added the `themes` store (custom chart themes).
|
||||
* v2 added the `themes` store (custom chart themes); v3 added the `fonts` store
|
||||
* (user-uploaded font faces — binary bytes, stored verbatim by structured clone).
|
||||
*/
|
||||
const DB_VERSION = 2;
|
||||
const DB_VERSION = 3;
|
||||
|
||||
export const SNIPPETS_STORE = 'snippets';
|
||||
export const DATASETS_STORE = 'datasets';
|
||||
export const THEMES_STORE = 'themes';
|
||||
export const FONTS_STORE = 'fonts';
|
||||
|
||||
/** Every object store the app expects — the open-time verification checklist. */
|
||||
const EXPECTED_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE] as const;
|
||||
const EXPECTED_STORES = [SNIPPETS_STORE, DATASETS_STORE, THEMES_STORE, FONTS_STORE] as const;
|
||||
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ export function readTextFile(file: File): Promise<string> {
|
||||
return file.text();
|
||||
}
|
||||
|
||||
/** Read a picked file's raw bytes (rejects on an unreadable file) — for font uploads. */
|
||||
export function readBinaryFile(file: File): Promise<ArrayBuffer> {
|
||||
return file.arrayBuffer();
|
||||
}
|
||||
|
||||
/** Copy `text` to the clipboard (rejects when the browser blocks access). The
|
||||
* one place outside a component that touches the clipboard API. */
|
||||
export function copyText(text: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* FontFace registration seam (docs/architecture/02; arch 00 — only
|
||||
* infrastructure touches browser APIs).
|
||||
*
|
||||
* Turns stored FontAsset bytes into live `FontFace`s on `document.fonts`, so the
|
||||
* chart renderer's font gate (`ensureFontsLoaded` in chart-renderer.ts) and the
|
||||
* Type-panel dropdown resolve user fonts exactly like the @fontsource roster.
|
||||
* Registration is keyed by font id (so a delete removes precisely the right
|
||||
* face), idempotent (re-registering an id replaces its face), and a no-op where
|
||||
* the Font Loading API is absent (tests, SSR).
|
||||
*
|
||||
* A static file is one weight, registered with default descriptors (normal/400);
|
||||
* a config requesting bold falls back to the browser's faux-bold. A **variable**
|
||||
* file is registered with its parsed weight/width ranges (`variableFontDescriptors`),
|
||||
* so the whole weight axis renders from one upload and the Type panel's weight
|
||||
* controls drive it for real.
|
||||
*/
|
||||
|
||||
import { type FontAsset, variableFontDescriptors } from '@core/font-asset';
|
||||
|
||||
/** id → the live face we added, so we can remove exactly it on delete/replace. */
|
||||
const registered = new Map<number, FontFace>();
|
||||
|
||||
/** Register (or replace) a stored font as a live `FontFace` on `document.fonts`. */
|
||||
export function registerFontAsset(asset: FontAsset): void {
|
||||
if (typeof document === 'undefined' || !document.fonts) return;
|
||||
unregisterFont(asset.id); // replace any prior face for this id (re-import)
|
||||
let face: FontFace;
|
||||
try {
|
||||
// Variable fonts get weight/width ranges so the browser resolves a requested
|
||||
// weight against the axis; a static font passes no descriptors (normal/400).
|
||||
face = new FontFace(asset.family, asset.data, variableFontDescriptors(asset.axes));
|
||||
} catch {
|
||||
return; // a malformed family name or unreadable source — nothing to register
|
||||
}
|
||||
registered.set(asset.id, face);
|
||||
document.fonts.add(face);
|
||||
// Decode eagerly so the next chart render finds the face ready. A decode
|
||||
// failure is non-fatal — the chart renders with fallback metrics — which is
|
||||
// arch 02's sanctioned graceful-fallback case, kept explicit, not buried.
|
||||
face.load().catch(() => {});
|
||||
}
|
||||
|
||||
/** Register a batch (startup hydration). */
|
||||
export function registerFontAssets(assets: ReadonlyArray<FontAsset>): void {
|
||||
for (const asset of assets) registerFontAsset(asset);
|
||||
}
|
||||
|
||||
/** Remove a previously-registered face by font id. No-op if not registered. */
|
||||
export function unregisterFont(id: number): void {
|
||||
if (typeof document === 'undefined' || !document.fonts) return;
|
||||
const face = registered.get(id);
|
||||
if (face) {
|
||||
document.fonts.delete(face);
|
||||
registered.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CURRENT_FONT_VERSION } from '@core/font-asset';
|
||||
import { migrateFontAsset } from './font-migrations';
|
||||
|
||||
const buf = (n: number): ArrayBuffer => new Uint8Array(n).buffer;
|
||||
|
||||
describe('migrateFontAsset', () => {
|
||||
it('passes a current record through unchanged (plus version stamp)', () => {
|
||||
const record = {
|
||||
id: 4,
|
||||
version: CURRENT_FONT_VERSION,
|
||||
family: 'Brand Sans',
|
||||
data: buf(2048),
|
||||
format: 'woff2' as const,
|
||||
fileName: 'Brand-Sans.woff2',
|
||||
source: 'file' as const,
|
||||
size: 2048,
|
||||
created: '2026-06-15T10:00:00.000Z',
|
||||
modified: '2026-06-15T10:00:00.000Z',
|
||||
};
|
||||
expect(migrateFontAsset(record)).toEqual(record);
|
||||
});
|
||||
|
||||
it('fills missing or invalid fields with safe defaults', () => {
|
||||
const migrated = migrateFontAsset({ id: '9', fileName: 'x.ttf' });
|
||||
expect(migrated.id).toBe(9);
|
||||
expect(migrated.version).toBe(CURRENT_FONT_VERSION);
|
||||
expect(migrated.family).toBe('Custom font');
|
||||
expect(migrated.format).toBe('ttf'); // recovered from the file extension
|
||||
expect(migrated.data).toBeInstanceOf(ArrayBuffer);
|
||||
expect(migrated.size).toBe(0);
|
||||
expect(migrated.source).toBe('file');
|
||||
});
|
||||
|
||||
it('keeps valid variation axes and drops a malformed axes value', () => {
|
||||
const withAxes = migrateFontAsset({
|
||||
id: 2,
|
||||
family: 'Fixel',
|
||||
data: buf(1),
|
||||
format: 'ttf',
|
||||
fileName: 'Fixel.ttf',
|
||||
axes: [{ tag: 'wght', min: 100, default: 400, max: 900 }],
|
||||
});
|
||||
expect(withAxes.axes).toEqual([{ tag: 'wght', min: 100, default: 400, max: 900 }]);
|
||||
|
||||
const badAxes = migrateFontAsset({
|
||||
id: 3,
|
||||
family: 'X',
|
||||
data: buf(1),
|
||||
format: 'ttf',
|
||||
fileName: 'x.ttf',
|
||||
axes: 'not-an-array',
|
||||
});
|
||||
expect(badAxes.axes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps unknown fields written by a newer build', () => {
|
||||
const migrated = migrateFontAsset({
|
||||
id: 1,
|
||||
family: 'Next',
|
||||
data: buf(1),
|
||||
format: 'otf',
|
||||
fileName: 'n.otf',
|
||||
futureField: 'kept',
|
||||
});
|
||||
expect((migrated as unknown as Record<string, unknown>).futureField).toBe('kept');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Read-time migration for FontAsset records (docs/architecture/02 §4).
|
||||
*
|
||||
* Mirrors snippet/dataset/theme migrations: every font read from storage passes
|
||||
* through `migrateFontAsset`, which fills missing/invalid fields and stamps the
|
||||
* current version. Unknown fields are tolerated (spread the original, only fill
|
||||
* gaps) so a record written by a newer build round-trips without loss.
|
||||
*/
|
||||
|
||||
import {
|
||||
CURRENT_FONT_VERSION,
|
||||
detectFontFormat,
|
||||
type FontAsset,
|
||||
type FontAxis,
|
||||
type FontFormat,
|
||||
type FontSource,
|
||||
} from '@core/font-asset';
|
||||
|
||||
const FORMATS: ReadonlyArray<FontFormat> = ['woff2', 'woff', 'ttf', 'otf'];
|
||||
|
||||
/** Accept a stored `axes` array only if it's shaped like FontAxis records. */
|
||||
function migrateAxes(raw: unknown): FontAxis[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const axes = raw.filter(
|
||||
(a): a is FontAxis =>
|
||||
!!a &&
|
||||
typeof (a as FontAxis).tag === 'string' &&
|
||||
typeof (a as FontAxis).min === 'number' &&
|
||||
typeof (a as FontAxis).max === 'number',
|
||||
);
|
||||
return axes.length > 0 ? axes : undefined;
|
||||
}
|
||||
|
||||
/** Upgrade a raw stored record to the current FontAsset shape. */
|
||||
export function migrateFontAsset(raw: unknown): FontAsset {
|
||||
const r = { ...(raw as Record<string, unknown>) };
|
||||
const fileName = typeof r.fileName === 'string' ? r.fileName : '';
|
||||
const format: FontFormat = FORMATS.includes(r.format as FontFormat)
|
||||
? (r.format as FontFormat)
|
||||
: (detectFontFormat(fileName) ?? 'woff2');
|
||||
const data = r.data instanceof ArrayBuffer ? r.data : new ArrayBuffer(0);
|
||||
const source: FontSource = r.source === 'google' ? 'google' : 'file';
|
||||
const axes = migrateAxes(r.axes);
|
||||
delete r.axes; // re-attach the validated value below (or omit it entirely)
|
||||
return {
|
||||
...r,
|
||||
id: typeof r.id === 'number' ? r.id : Number(r.id),
|
||||
version: CURRENT_FONT_VERSION,
|
||||
family: typeof r.family === 'string' ? r.family : 'Custom font',
|
||||
data,
|
||||
format,
|
||||
fileName,
|
||||
source,
|
||||
...(axes ? { axes } : {}),
|
||||
size: typeof r.size === 'number' ? r.size : data.byteLength,
|
||||
created: typeof r.created === 'string' ? r.created : new Date(0).toISOString(),
|
||||
modified: typeof r.modified === 'string' ? r.modified : new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Font asset persistence adapter (docs/architecture/02; scope doc §4 → fonts).
|
||||
*
|
||||
* The typed seam between the FontStore and IndexedDB's `fonts` object store.
|
||||
* Exposes plain async functions returning domain `FontAsset` objects and migrates
|
||||
* every record on read — same contract as dataset-store / theme-store. The
|
||||
* payload is binary (`ArrayBuffer`), stored verbatim by the structured-clone
|
||||
* algorithm the generic `put`/`getAll` already use; no special handling needed.
|
||||
*/
|
||||
|
||||
import { CURRENT_FONT_VERSION, type FontAsset } from '@core/font-asset';
|
||||
import { FONTS_STORE, del, getAll, put } from './db';
|
||||
import { migrateFontAsset } from './font-migrations';
|
||||
|
||||
/** Load every uploaded font, upgrading each record to the current shape. */
|
||||
export async function loadFonts(): Promise<FontAsset[]> {
|
||||
const records = await getAll<unknown>(FONTS_STORE);
|
||||
return records.map(migrateFontAsset);
|
||||
}
|
||||
|
||||
/** Persist a font asset at the current schema version. Propagates failures. */
|
||||
export async function saveFont(font: FontAsset): Promise<void> {
|
||||
await put(FONTS_STORE, { ...font, version: CURRENT_FONT_VERSION });
|
||||
}
|
||||
|
||||
/** Permanently remove a font asset by id. */
|
||||
export async function deleteFont(id: number): Promise<void> {
|
||||
await del(FONTS_STORE, id);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* User font persistence wiring (docs/architecture/01 §5; scope doc §4 → fonts).
|
||||
*
|
||||
* The font sibling of `theme-persistence.ts`: a startup subscriber that diffs the
|
||||
* `fonts` array against the previous snapshot and writes upserts/deletes through
|
||||
* to the IndexedDB adapter. The store stays browser-free; failures surface as a
|
||||
* toast rather than silent loss. Fonts change on explicit add/delete, so there is
|
||||
* no debounce.
|
||||
*/
|
||||
|
||||
import { deleteFont, saveFont } from '../infrastructure/font-store';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useFontStore } from '../stores/FontStore';
|
||||
|
||||
type Unsubscribe = () => void;
|
||||
|
||||
function fontError(op: 'save' | 'delete', err: unknown) {
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: op === 'delete' ? "Couldn't delete the font" : "Couldn't save the font",
|
||||
message:
|
||||
'A storage error stopped Astrolabe from completing the last font change, so it may not ' +
|
||||
'survive a reload. If this keeps happening, your browser may be blocking local storage.',
|
||||
detail: err instanceof Error ? `Font ${op} failed: ${err.name}: ${err.message}` : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist font upserts and deletions whenever the array changes. */
|
||||
export function wireFontPersistence(): Unsubscribe {
|
||||
let prevFonts = useFontStore.getState().fonts;
|
||||
return useFontStore.subscribe((s) => {
|
||||
const next = s.fonts;
|
||||
if (next === prevFonts) return;
|
||||
const prev = prevFonts;
|
||||
prevFonts = next;
|
||||
|
||||
for (const old of prev) {
|
||||
if (!next.some((n) => n.id === old.id)) {
|
||||
deleteFont(old.id).catch((err) => fontError('delete', err));
|
||||
}
|
||||
}
|
||||
for (const n of next) {
|
||||
const old = prev.find((p) => p.id === n.id);
|
||||
if (old !== n) saveFont(n).catch((err) => fontError('save', err));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -11,17 +11,22 @@
|
||||
import type { Snippet } from '@core/snippet';
|
||||
import type { Dataset } from '@core/dataset';
|
||||
import type { CustomTheme } from '@core/custom-theme';
|
||||
import type { FontAsset } from '@core/font-asset';
|
||||
import { loadSnippets } from '../infrastructure/snippet-store';
|
||||
import { loadDatasets } from '../infrastructure/dataset-store';
|
||||
import { loadCustomThemes } from '../infrastructure/theme-store';
|
||||
import { loadFonts } from '../infrastructure/font-store';
|
||||
import { registerFontAssets } from '../infrastructure/font-faces';
|
||||
import { storageErrorNotification } from '../services/storage-errors';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { useFontStore } from '../stores/FontStore';
|
||||
import { wireSnippetPersistence } from './snippet-persistence';
|
||||
import { wireDatasetPersistence } from './dataset-persistence';
|
||||
import { wireThemePersistence } from './theme-persistence';
|
||||
import { wireFontPersistence } from './font-persistence';
|
||||
import { startRouting } from '../modals/UrlStateSync';
|
||||
import { startEventRouter } from './EventRouter';
|
||||
|
||||
@@ -61,15 +66,31 @@ export async function initApp(): Promise<void> {
|
||||
notify(storageErrorNotification('load', err));
|
||||
}
|
||||
|
||||
// User-uploaded font faces (scope doc §4). Same failure posture: a font that
|
||||
// can't be loaded just won't be offered or rendered, falling back to the stack's
|
||||
// generic family.
|
||||
let fonts: FontAsset[] = [];
|
||||
try {
|
||||
fonts = await loadFonts();
|
||||
} catch (err) {
|
||||
notify(storageErrorNotification('load', err));
|
||||
}
|
||||
|
||||
useSnippetStore.getState().hydrate(snippets);
|
||||
useDatasetStore.getState().hydrate(datasets);
|
||||
useCustomThemeStore.getState().hydrate(themes);
|
||||
useFontStore.getState().hydrate(fonts);
|
||||
|
||||
// Register the loaded faces on document.fonts BEFORE the first chart render so
|
||||
// the renderer's font gate (ensureFontsLoaded) can resolve them like the roster.
|
||||
registerFontAssets(fonts);
|
||||
|
||||
// Wire persistence AFTER hydrate so write-through's baseline is the loaded set
|
||||
// — otherwise it would redundantly re-save every record on each startup.
|
||||
wireSnippetPersistence();
|
||||
wireDatasetPersistence();
|
||||
wireThemePersistence();
|
||||
wireFontPersistence();
|
||||
|
||||
// Routing starts AFTER hydrate so the on-load hash restore can resolve snippet
|
||||
// / dataset ids against the loaded stores (spec §01E, docs/architecture/04).
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Font upload service (scope doc §4 → User font upload) — orchestration over the
|
||||
* pure core helpers, the FontStore, and the browser seams.
|
||||
*
|
||||
* The deterministic work (format detection, family derivation, uniqueness) lives
|
||||
* in `@core/font-asset` + `@core/naming`; this layer reads the file bytes through
|
||||
* the infrastructure adapter, validates, commits to the store (the persistence
|
||||
* subscriber writes through to IndexedDB), registers the live `FontFace`, and
|
||||
* reports one summary toast. Deleting a font unregisters its face and removes the
|
||||
* record (the subscriber deletes it from IndexedDB).
|
||||
*/
|
||||
|
||||
import {
|
||||
createFontAsset,
|
||||
detectFontFormat,
|
||||
familyNameFromFileName,
|
||||
MAX_FONT_BYTES,
|
||||
parseFontAxes,
|
||||
} from '@core/font-asset';
|
||||
import { makeUniqueName } from '@core/naming';
|
||||
import { humanizeBytes } from '@core/storage-estimate';
|
||||
import { readBinaryFile } from '../infrastructure/file-transfer';
|
||||
import { registerFontAsset, unregisterFont } from '../infrastructure/font-faces';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useFontStore } from '../stores/FontStore';
|
||||
|
||||
/** Why a picked file was skipped, for the summary toast. */
|
||||
interface Skip {
|
||||
fileName: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one or more picked font files to the library. Each file is validated
|
||||
* independently (unsupported format / over the size cap are skipped, not fatal),
|
||||
* given a unique family name, stored, and registered. Reports how many were added
|
||||
* and why any were skipped — never throws on a bad file.
|
||||
*/
|
||||
export async function uploadFontFiles(files: FileList | File[]): Promise<void> {
|
||||
const list = Array.from(files);
|
||||
if (list.length === 0) return;
|
||||
|
||||
const added: string[] = [];
|
||||
const skipped: Skip[] = [];
|
||||
|
||||
for (const file of list) {
|
||||
const format = detectFontFormat(file.name);
|
||||
if (!format) {
|
||||
skipped.push({ fileName: file.name, reason: 'unsupported format' });
|
||||
continue;
|
||||
}
|
||||
|
||||
let data: ArrayBuffer;
|
||||
try {
|
||||
data = await readBinaryFile(file);
|
||||
} catch {
|
||||
skipped.push({ fileName: file.name, reason: 'could not be read' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.byteLength > MAX_FONT_BYTES) {
|
||||
skipped.push({
|
||||
fileName: file.name,
|
||||
reason: `larger than ${humanizeBytes(MAX_FONT_BYTES)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unique within the library (and within this batch — the store grew on the
|
||||
// previous iteration, so re-read its families each time).
|
||||
const family = makeUniqueName(
|
||||
familyNameFromFileName(file.name),
|
||||
useFontStore.getState().fonts.map((f) => f.family),
|
||||
);
|
||||
// Detect variation axes (variable fonts) so the face registers with the
|
||||
// right weight/width ranges — uncompressed ttf/otf only; others stay static.
|
||||
const axes = parseFontAxes(data, format);
|
||||
const stored = useFontStore
|
||||
.getState()
|
||||
.add(createFontAsset({ family, data, format, fileName: file.name, axes }));
|
||||
registerFontAsset(stored);
|
||||
added.push(family);
|
||||
}
|
||||
|
||||
reportUpload(added, skipped);
|
||||
}
|
||||
|
||||
/** Remove a font: unregister its live face, then drop the record. */
|
||||
export function removeFont(id: number): void {
|
||||
unregisterFont(id);
|
||||
useFontStore.getState().remove(id);
|
||||
}
|
||||
|
||||
/** One summary toast covering what was added and what was skipped. */
|
||||
function reportUpload(added: string[], skipped: Skip[]): void {
|
||||
const skips = skipped.length
|
||||
? `Skipped ${skipped.map((s) => `${s.fileName} (${s.reason})`).join(', ')}.`
|
||||
: '';
|
||||
|
||||
if (added.length === 0) {
|
||||
notify({
|
||||
kind: skipped.length ? 'error' : 'info',
|
||||
title: skipped.length ? "Couldn't add the font" : 'No fonts added',
|
||||
message:
|
||||
skips || 'Choose a .woff2, .woff, .ttf, or .otf file to add a font to your chart themes.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const noun = added.length === 1 ? 'font' : 'fonts';
|
||||
const message = `Added ${added.join(', ')}. ${skips}`.trim();
|
||||
notify({
|
||||
kind: skipped.length ? 'warning' : 'success',
|
||||
title: `Added ${added.length} ${noun}`,
|
||||
message,
|
||||
});
|
||||
}
|
||||
@@ -60,6 +60,10 @@ export function exportWorkspace(now: Date = new Date()): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO(fonts §08): the envelope does not yet carry user-uploaded FontAsset
|
||||
// bytes, so a theme/snippet referencing an uploaded font imports on another
|
||||
// machine with the fallback family. Embed the referenced faces (base64) here —
|
||||
// best done with task #3's shared font-bytes→embeddable-string helper.
|
||||
const envelope = buildExportEnvelope(snippets, datasets, themes, { now });
|
||||
downloadJson(exportFilename(now), JSON.stringify(envelope, null, 2));
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createFontAsset, type FontAsset } from '@core/font-asset';
|
||||
import { useFontStore } from './FontStore';
|
||||
|
||||
const asset = (family: string, id = 1): FontAsset =>
|
||||
createFontAsset({
|
||||
family,
|
||||
data: new Uint8Array(8).buffer,
|
||||
format: 'woff2',
|
||||
fileName: `${family}.woff2`,
|
||||
id,
|
||||
});
|
||||
|
||||
beforeEach(() => useFontStore.getState().reset());
|
||||
|
||||
describe('FontStore', () => {
|
||||
it('add assigns the next free id (one past the max)', () => {
|
||||
const a = useFontStore.getState().add(asset('A'));
|
||||
const b = useFontStore.getState().add(asset('B'));
|
||||
expect(a.id).toBe(1);
|
||||
expect(b.id).toBe(2);
|
||||
expect(useFontStore.getState().fonts.map((f) => f.family)).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('addFonts reassigns ids and appends without clobbering', () => {
|
||||
useFontStore.getState().add(asset('Existing'));
|
||||
useFontStore.getState().addFonts([asset('X', 1), asset('Y', 1)]);
|
||||
const fonts = useFontStore.getState().fonts;
|
||||
expect(fonts.map((f) => f.id)).toEqual([1, 2, 3]);
|
||||
expect(fonts.map((f) => f.family)).toEqual(['Existing', 'X', 'Y']);
|
||||
});
|
||||
|
||||
it('remove drops only the matching id; hydrate replaces wholesale', () => {
|
||||
const a = useFontStore.getState().add(asset('A'));
|
||||
useFontStore.getState().add(asset('B'));
|
||||
useFontStore.getState().remove(a.id);
|
||||
expect(useFontStore.getState().fonts.map((f) => f.family)).toEqual(['B']);
|
||||
useFontStore.getState().hydrate([asset('Loaded', 5)]);
|
||||
expect(useFontStore.getState().fonts).toHaveLength(1);
|
||||
expect(useFontStore.getState().fonts[0].family).toBe('Loaded');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* User font library (scope doc §4 → User font upload; spec §04 → Chart theme).
|
||||
*
|
||||
* The durable collection of uploaded font faces, referenced by `family` from a
|
||||
* config's font slots. Same two-layer shape as DatasetStore / CustomThemeStore:
|
||||
* low-level mutators (`add`/`addFonts`/`remove`) are the single place the `fonts`
|
||||
* array changes; a startup subscriber writes them through to IndexedDB.
|
||||
*
|
||||
* Persistence is NOT done here — `font-persistence.ts` observes this store and
|
||||
* writes through to the adapter — and neither is `FontFace` registration (a
|
||||
* browser side effect, owned by `services/fonts.ts` + `infrastructure/font-faces`),
|
||||
* so the store stays browser-free.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { FontAsset } from '@core/font-asset';
|
||||
|
||||
export interface FontState {
|
||||
fonts: FontAsset[];
|
||||
|
||||
/** Replace the library from storage. */
|
||||
hydrate: (fonts: FontAsset[]) => void;
|
||||
/** Add a fully-formed asset, assigning the next free id. Returns the stored record. */
|
||||
add: (font: FontAsset) => FontAsset;
|
||||
/** Batch add (import path): reassign ids from the store's authority and append. */
|
||||
addFonts: (incoming: FontAsset[]) => void;
|
||||
/** Remove a font by id. */
|
||||
remove: (id: number) => void;
|
||||
/** Reset to initial state (tests). */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/** Next free numeric id — one past the max (same contract as `nextDatasetId`/`nextThemeId`). */
|
||||
function nextFontId(fonts: ReadonlyArray<FontAsset>): number {
|
||||
return fonts.reduce((max, f) => Math.max(max, f.id), 0) + 1;
|
||||
}
|
||||
|
||||
export const useFontStore = create<FontState>((set, get) => ({
|
||||
fonts: [],
|
||||
|
||||
hydrate: (fonts) => set({ fonts }),
|
||||
|
||||
add: (font) => {
|
||||
const withId = { ...font, id: nextFontId(get().fonts) };
|
||||
set((s) => ({ fonts: [...s.fonts, withId] }));
|
||||
return withId;
|
||||
},
|
||||
|
||||
addFonts: (incoming) => {
|
||||
if (incoming.length === 0) return;
|
||||
set((s) => {
|
||||
let nextId = nextFontId(s.fonts);
|
||||
const withIds = incoming.map((f) => ({ ...f, id: nextId++ }));
|
||||
return { fonts: [...s.fonts, ...withIds] };
|
||||
});
|
||||
},
|
||||
|
||||
remove: (id) => set((s) => ({ fonts: s.fonts.filter((f) => f.id !== id) })),
|
||||
|
||||
reset: () => set({ fonts: [] }),
|
||||
}));
|
||||
Reference in New Issue
Block a user