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">
|
||||
|
||||
Reference in New Issue
Block a user