|
73 | 73 | } |
74 | 74 |
|
75 | 75 | // ── 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. |
76 | 86 | 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' } }, |
85 | 139 | ]; |
86 | 140 | 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 | + } |
87 | 202 | const PRESETS = [ |
88 | 203 | { label: 'OG default 1200 × 630', w: 1200, h: 630 }, |
89 | 204 | { label: 'HD 1920 × 1080', w: 1920, h: 1080 }, |
|
253 | 368 | root = opts.root; |
254 | 369 | api = opts.api; |
255 | 370 | 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(); |
256 | 377 | build(); |
257 | 378 | 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 () => { |
260 | 384 | fitToContainer(); |
| 385 | + try { if (document.fonts && document.fonts.ready) await document.fonts.ready; } catch { /* */ } |
261 | 386 | redraw(); |
262 | 387 | }); |
263 | 388 | } |
|
1346 | 1471 | } |
1347 | 1472 |
|
1348 | 1473 | function appendTextControls(l) { |
1349 | | - // Font family. |
| 1474 | + // Font family — grouped picker. Optgroups keep the long list |
| 1475 | + // navigable; we group by f.group. |
1350 | 1476 | const fam = el('select', { class: 'ce-ctx-input', title: 'Font', |
1351 | 1477 | onchange: () => cmd('text-style', () => l.family = fam.value) }); |
| 1478 | + const groups = new Map(); // preserve insertion order |
1352 | 1479 | 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); |
1354 | 1499 | } |
| 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 | + }); |
1355 | 1524 | // Size. |
1356 | 1525 | const size = el('input', { |
1357 | 1526 | type: 'number', class: 'ce-ctx-input ce-ctx-size', |
|
1386 | 1555 | // Shadow toggle. |
1387 | 1556 | const shadow = ctxIcon('☼', 'Drop shadow', () => cmd('text-style', () => { l.shadow = !l.shadow; })); |
1388 | 1557 | 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); |
1390 | 1559 | } |
1391 | 1560 |
|
1392 | 1561 | function appendBoxControls(l) { |
|
1642 | 1811 | updateSizeLabel(); syncPresetSelect(); |
1643 | 1812 | fitToContainer(); |
1644 | 1813 | }); |
| 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 | + } |
1645 | 1832 | } |
1646 | 1833 |
|
1647 | 1834 | // ── add layer (button or DnD) ──────────────────────────────── |
|
0 commit comments