Skip to content

Commit bcd3f0d

Browse files
r8a5claude
andcommitted
Cover editor: add Google Fonts (curated set + custom-name input)
Curated picker: Editorial serif — Playfair Display, Lora, Cormorant Garamond, Crimson Pro, EB Garamond, Libre Baskerville Geometric sans — Inter, Space Grotesk, DM Sans, Manrope, Plus Jakarta Sans, Outfit Display — Bebas Neue, Anton, Oswald, Bowlby One, Archivo Black Mono — JetBrains Mono, IBM Plex Mono, Space Mono System — system-ui sans, Georgia serif, system mono The font dropdown now uses <optgroup> headers to keep the long list navigable. Selecting a curated font is instant — they're loaded as a single Google Fonts CSS request at editor init. For anything outside the curated set, a small text input next to the font dropdown accepts any Google Fonts family name. Enter triggers a load (sanitised: letters/digits/spaces only, length 2-40), waits on document.fonts.load() so the font is actually available before the canvas redraws, then sets the selected layer's family to it. Two fix-ups to support custom fonts cleanly: - The font picker includes a one-off "Custom" optgroup containing whatever family the current layer is using, so a layer previously styled with a custom font isn't silently downgraded on re-select. - loadTemplateSpec() pre-warms any custom fonts referenced by text layers in the loaded template before redrawing — without this a template that used "Roboto Slab" rendered in Georgia on first paint. Initial canvas paint now waits on document.fonts.ready so the first render doesn't flash in the fallback family. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a92a02e commit bcd3f0d

1 file changed

Lines changed: 200 additions & 13 deletions

File tree

public/cover-editor.js

Lines changed: 200 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,132 @@
7373
}
7474

7575
// ── constants ────────────────────────────────────────────────────
76+
// Fonts available in the editor. Each entry has:
77+
// label — display name in the picker
78+
// value — CSS font-family stack
79+
// gf — Google Fonts family + weights to load (or null for system)
80+
// group — grouping for the picker
81+
//
82+
// The Google Fonts <link> is injected once at editor init by
83+
// injectFontStylesheet(). For ad-hoc fonts the user types in
84+
// the "custom font" input, loadGoogleFont() appends a new
85+
// stylesheet at runtime.
7686
const FONT_FAMILIES = [
77-
{ label: 'System', value: 'system-ui, -apple-system, Segoe UI, sans-serif' },
78-
{ label: 'Inter', value: 'Inter, sans-serif' },
79-
{ label: 'Georgia', value: 'Georgia, serif' },
80-
{ label: 'Times', value: '"Times New Roman", serif' },
81-
{ label: 'Helvetica', value: '"Helvetica Neue", Arial, sans-serif' },
82-
{ label: 'Courier', value: '"Courier New", monospace' },
83-
{ label: 'Trebuchet', value: '"Trebuchet MS", sans-serif' },
84-
{ label: 'Impact', value: 'Impact, sans-serif' },
87+
// System ─────────────────────────────────────────────
88+
{ group: 'System', label: 'System sans', value: 'system-ui, -apple-system, Segoe UI, sans-serif' },
89+
{ group: 'System', label: 'System serif', value: 'Georgia, "Times New Roman", serif' },
90+
{ group: 'System', label: 'System mono', value: 'ui-monospace, "JetBrains Mono", Menlo, monospace' },
91+
92+
// Editorial serifs ───────────────────────────────────
93+
{ group: 'Editorial serif', label: 'Playfair Display', value: '"Playfair Display", Georgia, serif',
94+
gf: { family: 'Playfair Display', weights: '400;500;600;700;800;900' } },
95+
{ group: 'Editorial serif', label: 'Lora', value: 'Lora, Georgia, serif',
96+
gf: { family: 'Lora', weights: '400;500;600;700' } },
97+
{ group: 'Editorial serif', label: 'Cormorant Garamond', value: '"Cormorant Garamond", Garamond, serif',
98+
gf: { family: 'Cormorant Garamond', weights: '300;400;500;600;700' } },
99+
{ group: 'Editorial serif', label: 'Crimson Pro', value: '"Crimson Pro", Garamond, serif',
100+
gf: { family: 'Crimson Pro', weights: '300;400;500;600;700;800;900' } },
101+
{ group: 'Editorial serif', label: 'EB Garamond', value: '"EB Garamond", Garamond, serif',
102+
gf: { family: 'EB Garamond', weights: '400;500;600;700;800' } },
103+
{ group: 'Editorial serif', label: 'Libre Baskerville', value: '"Libre Baskerville", Georgia, serif',
104+
gf: { family: 'Libre Baskerville', weights: '400;700' } },
105+
106+
// Geometric sans ─────────────────────────────────────
107+
{ group: 'Geometric sans', label: 'Inter', value: 'Inter, sans-serif',
108+
gf: { family: 'Inter', weights: '300;400;500;600;700;800;900' } },
109+
{ group: 'Geometric sans', label: 'Space Grotesk', value: '"Space Grotesk", sans-serif',
110+
gf: { family: 'Space Grotesk', weights: '300;400;500;600;700' } },
111+
{ group: 'Geometric sans', label: 'DM Sans', value: '"DM Sans", sans-serif',
112+
gf: { family: 'DM Sans', weights: '400;500;700' } },
113+
{ group: 'Geometric sans', label: 'Manrope', value: 'Manrope, sans-serif',
114+
gf: { family: 'Manrope', weights: '300;400;500;600;700;800' } },
115+
{ group: 'Geometric sans', label: 'Plus Jakarta Sans', value: '"Plus Jakarta Sans", sans-serif',
116+
gf: { family: 'Plus Jakarta Sans', weights: '300;400;500;600;700;800' } },
117+
{ group: 'Geometric sans', label: 'Outfit', value: 'Outfit, sans-serif',
118+
gf: { family: 'Outfit', weights: '300;400;500;600;700;800;900' } },
119+
120+
// Display / impact ───────────────────────────────────
121+
{ group: 'Display', label: 'Bebas Neue', value: '"Bebas Neue", Impact, sans-serif',
122+
gf: { family: 'Bebas Neue', weights: '400' } },
123+
{ group: 'Display', label: 'Anton', value: 'Anton, Impact, sans-serif',
124+
gf: { family: 'Anton', weights: '400' } },
125+
{ group: 'Display', label: 'Oswald', value: 'Oswald, Impact, sans-serif',
126+
gf: { family: 'Oswald', weights: '300;400;500;600;700' } },
127+
{ group: 'Display', label: 'Bowlby One', value: '"Bowlby One", Impact, sans-serif',
128+
gf: { family: 'Bowlby One', weights: '400' } },
129+
{ group: 'Display', label: 'Archivo Black', value: '"Archivo Black", Impact, sans-serif',
130+
gf: { family: 'Archivo Black', weights: '400' } },
131+
132+
// Mono / technical ───────────────────────────────────
133+
{ group: 'Mono', label: 'JetBrains Mono', value: '"JetBrains Mono", monospace',
134+
gf: { family: 'JetBrains Mono', weights: '400;500;600;700;800' } },
135+
{ group: 'Mono', label: 'IBM Plex Mono', value: '"IBM Plex Mono", monospace',
136+
gf: { family: 'IBM Plex Mono', weights: '300;400;500;600;700' } },
137+
{ group: 'Mono', label: 'Space Mono', value: '"Space Mono", monospace',
138+
gf: { family: 'Space Mono', weights: '400;700' } },
85139
];
86140
const FONT_WEIGHTS = ['300', '400', '500', '600', '700', '800'];
141+
142+
// Track which Google Font families are already loaded so we don't
143+
// append duplicate <link> tags every time the user re-selects a font.
144+
const loadedFontFamilies = new Set();
145+
146+
// Inject the master Google Fonts stylesheet for all curated fonts
147+
// at editor init. Built as a single URL with families separated by
148+
// `&family=…` so one request loads everything.
149+
function injectFontStylesheet() {
150+
const families = FONT_FAMILIES
151+
.filter((f) => f.gf)
152+
.map((f) => `family=${encodeURIComponent(f.gf.family).replace(/%20/g, '+')}:wght@${f.gf.weights}`);
153+
if (!families.length) return;
154+
families.forEach((f) => loadedFontFamilies.add(f.split('@')[0]));
155+
const href = 'https://fonts.googleapis.com/css2?' + families.join('&') + '&display=swap';
156+
// Avoid duplicate tags if the editor mounts twice.
157+
if (document.querySelector(`link[data-ce-fonts]`)) return;
158+
const link = document.createElement('link');
159+
link.rel = 'stylesheet';
160+
link.href = href;
161+
link.dataset.ceFonts = '1';
162+
document.head.appendChild(link);
163+
// Preconnect too, for faster first paint.
164+
if (!document.querySelector('link[rel="preconnect"][href="https://fonts.gstatic.com"]')) {
165+
const pc = document.createElement('link');
166+
pc.rel = 'preconnect';
167+
pc.href = 'https://fonts.gstatic.com';
168+
pc.crossOrigin = 'anonymous';
169+
document.head.appendChild(pc);
170+
}
171+
}
172+
173+
// Load an ad-hoc Google Font by family name. Sanitises the input
174+
// (Google Fonts names are letters / digits / spaces only — anything
175+
// else is either a typo or an attempt to inject). Resolves after
176+
// the font is actually ready for use on the canvas.
177+
async function loadGoogleFont(family) {
178+
const clean = String(family || '').trim().replace(/\s+/g, ' ');
179+
if (!/^[A-Za-z0-9 ]{2,40}$/.test(clean)) return false;
180+
const key = `family=${encodeURIComponent(clean).replace(/%20/g, '+')}`;
181+
if (loadedFontFamilies.has(key.split('@')[0])) return true;
182+
loadedFontFamilies.add(key.split('@')[0]);
183+
184+
const href = `https://fonts.googleapis.com/css2?${key}:wght@300;400;500;600;700;800&display=swap`;
185+
const link = document.createElement('link');
186+
link.rel = 'stylesheet';
187+
link.href = href;
188+
document.head.appendChild(link);
189+
190+
// Wait for the @font-face declarations to parse, then for the
191+
// actual font file to be available for layout.
192+
try {
193+
if (document.fonts && document.fonts.load) {
194+
// load() forces a fetch + decode of the specified font at
195+
// 16px — once that resolves the font is usable everywhere.
196+
await document.fonts.load(`16px "${clean}"`);
197+
await document.fonts.ready;
198+
}
199+
} catch { /* network or unknown-font; fall through */ }
200+
return true;
201+
}
87202
const PRESETS = [
88203
{ label: 'OG default 1200 × 630', w: 1200, h: 630 },
89204
{ label: 'HD 1920 × 1080', w: 1920, h: 1080 },
@@ -253,11 +368,21 @@
253368
root = opts.root;
254369
api = opts.api;
255370
glue = opts.glue || {};
371+
// Kick the Google Fonts request as early as possible so they're
372+
// ready by the time the user wires up a real text layer. The
373+
// ones used in saved templates are loaded by the same mechanism
374+
// (curated set covers them, plus loadGoogleFont fills in any
375+
// missing ones referenced by spec.layers).
376+
injectFontStylesheet();
256377
build();
257378
bindGlobalKeys();
258-
// Initial render once mounted.
259-
requestAnimationFrame(() => {
379+
// Initial render once mounted. We hold the first paint until
380+
// document.fonts.ready so text doesn't flash in the fallback
381+
// family. Cheap on subsequent loads (already-loaded fonts
382+
// resolve immediately).
383+
requestAnimationFrame(async () => {
260384
fitToContainer();
385+
try { if (document.fonts && document.fonts.ready) await document.fonts.ready; } catch { /* */ }
261386
redraw();
262387
});
263388
}
@@ -1346,12 +1471,56 @@
13461471
}
13471472

13481473
function appendTextControls(l) {
1349-
// Font family.
1474+
// Font family — grouped picker. Optgroups keep the long list
1475+
// navigable; we group by f.group.
13501476
const fam = el('select', { class: 'ce-ctx-input', title: 'Font',
13511477
onchange: () => cmd('text-style', () => l.family = fam.value) });
1478+
const groups = new Map(); // preserve insertion order
13521479
for (const f of FONT_FAMILIES) {
1353-
fam.appendChild(el('option', { value: f.value, selected: l.family === f.value }, f.label));
1480+
const g = f.group || 'Other';
1481+
if (!groups.has(g)) groups.set(g, []);
1482+
groups.get(g).push(f);
1483+
}
1484+
// If the layer's current family is one we don't have in the
1485+
// curated list (e.g. a previously-loaded custom Google Font),
1486+
// we still want it selectable — add a one-off option for it.
1487+
const known = new Set(FONT_FAMILIES.map((f) => f.value));
1488+
if (l.family && !known.has(l.family)) {
1489+
const custom = el('optgroup', { label: 'Custom' });
1490+
custom.appendChild(el('option', { value: l.family, selected: true }, l.family.replace(/^"|"$/g, '').split(',')[0]));
1491+
fam.appendChild(custom);
1492+
}
1493+
for (const [groupName, items] of groups) {
1494+
const og = el('optgroup', { label: groupName });
1495+
for (const f of items) {
1496+
og.appendChild(el('option', { value: f.value, selected: l.family === f.value }, f.label));
1497+
}
1498+
fam.appendChild(og);
13541499
}
1500+
// "Custom Google Font" mini-input. User types a family name,
1501+
// hits Enter; we load it and switch the selected layer to it.
1502+
const customIn = el('input', {
1503+
type: 'text', class: 'ce-ctx-input', placeholder: 'or paste a Google Font name…',
1504+
style: { width: '160px' }, title: 'Type any Google Font family (e.g. "Roboto Slab") and press Enter',
1505+
});
1506+
customIn.addEventListener('keydown', async (e) => {
1507+
if (e.key !== 'Enter') return;
1508+
e.preventDefault();
1509+
const name = customIn.value.trim();
1510+
if (!name) return;
1511+
customIn.disabled = true;
1512+
const ok = await loadGoogleFont(name);
1513+
customIn.disabled = false;
1514+
if (!ok) {
1515+
customIn.value = ''; customIn.placeholder = 'Invalid name — letters/digits/spaces only';
1516+
setTimeout(() => { customIn.placeholder = 'or paste a Google Font name…'; }, 2500);
1517+
return;
1518+
}
1519+
cmd('text-style', () => {
1520+
l.family = `"${name}", sans-serif`;
1521+
});
1522+
customIn.value = '';
1523+
});
13551524
// Size.
13561525
const size = el('input', {
13571526
type: 'number', class: 'ce-ctx-input ce-ctx-size',
@@ -1386,7 +1555,7 @@
13861555
// Shadow toggle.
13871556
const shadow = ctxIcon('☼', 'Drop shadow', () => cmd('text-style', () => { l.shadow = !l.shadow; }));
13881557
shadow.classList.toggle('is-on', !!l.shadow);
1389-
contextToolbar.append(fam, size, el('span', { class: 'ce-ctx-sep' }), bold, italic, align, color, shadow);
1558+
contextToolbar.append(fam, customIn, size, el('span', { class: 'ce-ctx-sep' }), bold, italic, align, color, shadow);
13901559
}
13911560

13921561
function appendBoxControls(l) {
@@ -1642,6 +1811,24 @@
16421811
updateSizeLabel(); syncPresetSelect();
16431812
fitToContainer();
16441813
});
1814+
// Pre-warm any custom fonts referenced by text layers in this
1815+
// template. The curated set is already loaded via injectFont-
1816+
// Stylesheet(); anything else (a font the user typed into the
1817+
// custom input in a previous session) needs an explicit load
1818+
// before redraw or it'll render in the fallback family.
1819+
const known = new Set(FONT_FAMILIES.map((f) => f.value));
1820+
const customFamilies = new Set();
1821+
for (const l of state.template.layers) {
1822+
if (l.kind === 'text' && l.family && !known.has(l.family)) {
1823+
// Extract the first quoted name from the stack, e.g.
1824+
// '"Roboto Slab", sans-serif' → 'Roboto Slab'.
1825+
const m = String(l.family).match(/^"([^"]+)"/) || String(l.family).match(/^([^,]+)/);
1826+
if (m) customFamilies.add(m[1].trim());
1827+
}
1828+
}
1829+
if (customFamilies.size) {
1830+
Promise.all([...customFamilies].map(loadGoogleFont)).then(() => redraw());
1831+
}
16451832
}
16461833

16471834
// ── add layer (button or DnD) ────────────────────────────────

0 commit comments

Comments
 (0)