From 93d1e3383ce47e45157d70f9ba6251239dae6bc2 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:24:02 +0100 Subject: feat: major editor overhaul (assets, properties, timeline, fonts) (#709) * feat: major editor overhaul (assets, properties, timeline, fonts) Refactor editor core systems to standardize UI architecture and improve performance. Assets & Properties: - Replace monolithic property items with composable `Section` architecture. - Add specialized sections for Transform, Blending, and Text. - Implement `NumberField` with scrubbing and math evaluation. - Add new ColorPicker with EyeDropper and multiple format support. - Standardize asset panels using new `PanelView` layout. Fonts & Stickers: - Implement custom font atlas/sprite system for high-performance previews. - Add virtualized FontPicker with search and favorites. - Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes). - Standardize sticker IDs to `provider:value` format. Timeline & Interaction: - Convert bookmarks to rich objects with notes, colors, and duration. - Refactor drag-and-drop to use Command pattern (enabling proper undo/redo). - Add Shift modifier to disable snapping during moves/resizes. - Add new overlays for layout guides and text editing. Renderer: - Add support for multi-line text, custom line-height, and letter-spacing. - Implement global composite operation (blend modes). - Update sticker node to resolve dynamic provider IDs. Infrastructure: - Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks. - Update global styles and core UI components (Button, Input, Popover). * add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors * fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling * deleted shadcn components with errors * formatting * fix linter issues * migrate from next middleware to proxy * add missing component back * add breadcrumb back * chore: add @radix-ui/react-primitive deps * chore: more deps * chore: add missing env vars to bun-ci * next env --- apps/web/scripts/generate-font-sprites.ts | 281 ++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 apps/web/scripts/generate-font-sprites.ts (limited to 'apps/web/scripts/generate-font-sprites.ts') diff --git a/apps/web/scripts/generate-font-sprites.ts b/apps/web/scripts/generate-font-sprites.ts new file mode 100644 index 00000000..144d68ec --- /dev/null +++ b/apps/web/scripts/generate-font-sprites.ts @@ -0,0 +1,281 @@ +/** + * Generates font sprite atlas for the font picker. + * + * Downloads Google Fonts from Fontsource, renders each font name as a sprite, + * packs them into chunk images, and outputs a JSON atlas + AVIF images. + * + * Run: npx tsx scripts/generate-font-sprites.ts + * Deps: @napi-rs/canvas sharp (install as devDependencies) + */ + +import { createCanvas, GlobalFonts } from "@napi-rs/canvas"; +import sharp from "sharp"; +import { mkdir, writeFile, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const FONT_SIZE = 24; +const ROW_HEIGHT = 40; +const CANVAS_WIDTH = 1200; +const MAX_CHUNK_HEIGHT = 800; +const PADDING_X = 14; +const WIDTH_BUFFER = 8; +const CONCURRENT_DOWNLOADS = 30; + +const OUTPUT_DIR = join(__dirname, "..", "public", "fonts"); +const CACHE_DIR = join(__dirname, "..", ".font-cache"); +const FONTSOURCE_API = "https://api.fontsource.org/v1/fonts"; + +interface FontsourceFont { + id: string; + family: string; + subsets: string[]; + weights: number[]; + styles: string[]; + category: string; + type: string; +} + +interface MeasuredFont { + family: string; + width: number; + styles: string[]; +} + +interface PackedFont { + family: string; + x: number; + y: number; + w: number; +} + +interface AtlasEntry { + x: number; + y: number; + w: number; + ch: number; + s: string[]; +} + +async function fetchFontList(): Promise { + console.log("Fetching font list from Fontsource..."); + const response = await fetch(FONTSOURCE_API); + if (!response.ok) throw new Error(`Fontsource API error: ${response.status}`); + + const data: FontsourceFont[] = await response.json(); + const filtered = data.filter( + (font) => + font.type === "google" && + font.subsets.includes("latin") && + font.weights.includes(400), + ); + + console.log( + ` ${filtered.length} Google fonts with Latin subset + 400 weight`, + ); + return filtered; +} + +async function downloadFont({ id }: { id: string }): Promise { + const cachePath = join(CACHE_DIR, `${id}.woff2`); + + if (existsSync(cachePath)) { + return readFile(cachePath); + } + + // Fontsource CDN serves woff2 for all Google fonts + const url = `https://cdn.jsdelivr.net/fontsource/fonts/${id}@latest/latin-400-normal.woff2`; + try { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const buffer = Buffer.from(await response.arrayBuffer()); + await writeFile(cachePath, buffer); + return buffer; + } catch { + return null; + } +} + +async function downloadAllFonts({ + fonts, + concurrency, +}: { + fonts: FontsourceFont[]; + concurrency: number; +}): Promise> { + console.log( + `Downloading ${fonts.length} font files (${concurrency} concurrent)...`, + ); + const results = new Map(); + let nextIndex = 0; + let completed = 0; + + async function worker() { + while (nextIndex < fonts.length) { + const index = nextIndex++; + const font = fonts[index]; + const buffer = await downloadFont({ id: font.id }); + if (buffer) results.set(font.id, buffer); + completed++; + if (completed % 100 === 0 || completed === fonts.length) { + process.stdout.write(`\r ${completed}/${fonts.length}`); + } + } + } + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + console.log(`\n Downloaded ${results.size}/${fonts.length} fonts`); + return results; +} + +function measureFonts({ + fonts, + fontBuffers, +}: { + fonts: FontsourceFont[]; + fontBuffers: Map; +}): MeasuredFont[] { + console.log("Registering fonts and measuring text..."); + const measured: MeasuredFont[] = []; + const canvas = createCanvas(CANVAS_WIDTH, ROW_HEIGHT); + const ctx = canvas.getContext("2d"); + + for (const font of fonts) { + const buffer = fontBuffers.get(font.id); + if (!buffer) continue; + + try { + const ok = GlobalFonts.register(buffer, font.family); + if (!ok) continue; + + ctx.font = `${FONT_SIZE}px "${font.family}"`; + const metrics = ctx.measureText(font.family); + const width = Math.ceil(metrics.width) + WIDTH_BUFFER; + + const styles: string[] = []; + for (const weight of font.weights) { + if (font.styles.includes("normal")) styles.push(String(weight)); + if (font.styles.includes("italic")) styles.push(`${weight}i`); + } + + measured.push({ family: font.family, width, styles }); + } catch { + // skip fonts that fail to register + } + } + + measured.sort((a, b) => a.family.localeCompare(b.family)); + console.log(` ${measured.length} fonts measured`); + return measured; +} + +function packIntoChunks({ measured }: { measured: MeasuredFont[] }): { + atlas: Record; + chunks: PackedFont[][]; +} { + console.log("Packing into sprite chunks..."); + const atlas: Record = {}; + const chunks: PackedFont[][] = [[]]; + let chunkIndex = 0; + let cursorX = 0; + let cursorY = 0; + + for (const font of measured) { + // New row if doesn't fit horizontally + if (cursorX + font.width > CANVAS_WIDTH) { + cursorX = 0; + cursorY += ROW_HEIGHT; + } + + // New chunk if doesn't fit vertically + if (cursorY + ROW_HEIGHT > MAX_CHUNK_HEIGHT) { + chunkIndex++; + chunks.push([]); + cursorX = 0; + cursorY = 0; + } + + // Same data goes to BOTH the atlas and the chunk render list + const x = cursorX; + const y = cursorY; + + atlas[font.family] = { + x, + y, + w: font.width, + ch: chunkIndex, + s: font.styles, + }; + chunks[chunkIndex].push({ family: font.family, x, y, w: font.width }); + + cursorX += font.width + PADDING_X; + } + + console.log(` ${chunks.length} chunks`); + return { atlas, chunks }; +} + +async function renderChunks({ + chunks, +}: { + chunks: PackedFont[][]; +}): Promise { + console.log("Rendering sprite chunks..."); + + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const chunkHeight = Math.max(...chunk.map((f) => f.y)) + ROW_HEIGHT; + + const canvas = createCanvas(CANVAS_WIDTH, chunkHeight); + const ctx = canvas.getContext("2d"); + + for (const font of chunk) { + ctx.font = `${FONT_SIZE}px "${font.family}"`; + ctx.fillStyle = "#000000"; + ctx.textBaseline = "middle"; + ctx.fillText(font.family, font.x, font.y + ROW_HEIGHT / 2); + } + + const pngBuffer = canvas.toBuffer("image/png"); + await sharp(pngBuffer) + .avif({ quality: 80 }) + .toFile(join(OUTPUT_DIR, `font-chunk-${i}.avif`)); + + console.log( + ` Chunk ${i}: ${chunk.length} fonts, ${CANVAS_WIDTH}×${chunkHeight}`, + ); + } +} + +async function main() { + await mkdir(OUTPUT_DIR, { recursive: true }); + await mkdir(CACHE_DIR, { recursive: true }); + + const fonts = await fetchFontList(); + const fontBuffers = await downloadAllFonts({ + fonts, + concurrency: CONCURRENT_DOWNLOADS, + }); + const measured = measureFonts({ fonts, fontBuffers }); + const { atlas, chunks } = packIntoChunks({ measured }); + await renderChunks({ chunks }); + + // Write atlas JSON (compact for smaller file size) + await writeFile( + join(OUTPUT_DIR, "font-atlas.json"), + JSON.stringify({ fonts: atlas }), + ); + + const totalFonts = Object.keys(atlas).length; + console.log( + `\nDone! ${totalFonts} fonts in ${chunks.length} chunks → ${OUTPUT_DIR}`, + ); +} + +main().catch((error) => { + console.error("Failed:", error); + process.exit(1); +}); -- cgit v1.3.1