From 6935ef04dd0ae95faf41d00e275bec9d75cd2857 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 2 Apr 2026 19:46:11 +0900 Subject: [PATCH 01/24] fix(embed): update iframe and embed source URLs from 'refig' to 'figma' Modified the embed URLs in the documentation and debug page to reflect the new 'figma' endpoint. Added a new page for handling Figma embeds, including file parameter parsing and remote file fetching logic. --- docs/canvas/sdk/embed/index.md | 6 +++--- editor/app/(embed)/embed/v1/debug/page.tsx | 4 ++-- editor/app/(embed)/embed/v1/{refig => figma}/page.tsx | 0 3 files changed, 5 insertions(+), 5 deletions(-) rename editor/app/(embed)/embed/v1/{refig => figma}/page.tsx (100%) diff --git a/docs/canvas/sdk/embed/index.md b/docs/canvas/sdk/embed/index.md index 14cc18682e..7ff11d17e2 100644 --- a/docs/canvas/sdk/embed/index.md +++ b/docs/canvas/sdk/embed/index.md @@ -12,7 +12,7 @@ Embed the Grida Canvas viewer in any web page via an iframe and control it progr ```html + ``` ## Supported file formats -| Format | Extension | Description | -| --------------- | ---------- | -------------------------------------------- | -| Figma binary | `.fig` | Exported `.fig` file from Figma | -| Figma REST JSON | `.json` | Response from Figma `GET /v1/files/:key` API | -| Compressed JSON | `.json.gz` | Gzip-compressed Figma REST JSON | -| Grida archive | `.zip` | Grida `.grida` archive (ZIP) | +| Format | Extension | Description | Supported by | +| --------------- | ---------- | -------------------------------------------- | ------------------------------- | +| Grida archive | `.grida` | Native Grida archive (ZIP with FlatBuffers) | `/embed/v1/`, `/embed/v1/figma` | +| Grida snapshot | `.grida1` | Grida JSON snapshot | `/embed/v1/`, `/embed/v1/figma` | +| Figma binary | `.fig` | Exported `.fig` file from Figma | `/embed/v1/`, `/embed/v1/figma` | +| Figma REST JSON | `.json` | Response from Figma `GET /v1/files/:key` API | `/embed/v1/`, `/embed/v1/figma` | +| Compressed JSON | `.json.gz` | Gzip-compressed Figma REST JSON | `/embed/v1/`, `/embed/v1/figma` | +| Figma archive | `.zip` | Figma REST archive (ZIP) | `/embed/v1/`, `/embed/v1/figma` | ## Protocol reference @@ -281,14 +353,14 @@ The SDK communicates via `window.postMessage`. All messages have a `type` field ### Host to iframe (commands) -| Message type | Payload | -| ---------------------- | ---------------------------------------------------------------------- | -| `grida:load` | `{ data: ArrayBuffer, format: "fig" \| "json" \| "json.gz" \| "zip" }` | -| `grida:select` | `{ nodeIds: string[], mode?: "reset" \| "add" \| "toggle" }` | -| `grida:load-scene` | `{ sceneId: string }` | -| `grida:fit` | `{ selector?: string, animate?: boolean }` | -| `grida:ping` | (none) | -| `grida:images-resolve` | `{ images: Record }` | +| Message type | Payload | +| ---------------------- | --------------------------------------------------------------------------------------------- | +| `grida:load` | `{ data: ArrayBuffer, format: "grida" \| "grida1" \| "fig" \| "json" \| "json.gz" \| "zip" }` | +| `grida:select` | `{ nodeIds: string[], mode?: "reset" \| "add" \| "toggle" }` | +| `grida:load-scene` | `{ sceneId: string }` | +| `grida:fit` | `{ selector?: string, animate?: boolean }` | +| `grida:ping` | (none) | +| `grida:images-resolve` | `{ images: Record }` | ### Iframe to host (events) diff --git a/editor/app/(embed)/embed/v1/debug/page.tsx b/editor/app/(embed)/embed/v1/debug/page.tsx index 96a4d53651..c5a75e9213 100644 --- a/editor/app/(embed)/embed/v1/debug/page.tsx +++ b/editor/app/(embed)/embed/v1/debug/page.tsx @@ -4,6 +4,13 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/components/lib/utils"; +type EmbedMode = "general" | "figma"; + +const EMBED_PATHS: Record = { + general: "/embed/v1", + figma: "/embed/v1/figma", +}; + type LogEntry = { ts: number; dir: "in" | "out"; @@ -26,6 +33,7 @@ export default function EmbedDebugPage() { const iframeRef = useRef(null); const [log, setLog] = useState([]); const [embedSrc, setEmbedSrc] = useState(null); + const [mode, setMode] = useState("general"); const [ready, setReady] = useState(false); const [scenes, setScenes] = useState<{ id: string; name: string }[]>([]); const [selection, setSelection] = useState([]); @@ -92,13 +100,15 @@ export default function EmbedDebugPage() { [addLog] ); + const embedBasePath = EMBED_PATHS[mode]; + // Load preset via ?file= URL const loadPreset = (url: string) => { setReady(false); setScenes([]); setSelection([]); setCurrentScene(null); - setEmbedSrc(`/embed/v1/figma?file=${encodeURIComponent(url)}`); + setEmbedSrc(`${embedBasePath}?file=${encodeURIComponent(url)}`); }; // Load empty embed (for postMessage load) @@ -107,7 +117,7 @@ export default function EmbedDebugPage() { setScenes([]); setSelection([]); setCurrentScene(null); - setEmbedSrc("/embed/v1/figma"); + setEmbedSrc(embedBasePath); }; // Load file via postMessage @@ -117,8 +127,10 @@ export default function EmbedDebugPage() { } const buf = await file.arrayBuffer(); const name = file.name.toLowerCase(); - let format: "fig" | "json" | "json.gz" | "zip"; - if (name.endsWith(".json.gz")) format = "json.gz"; + let format: "fig" | "json" | "json.gz" | "zip" | "grida" | "grida1"; + if (name.endsWith(".grida1")) format = "grida1"; + else if (name.endsWith(".grida")) format = "grida"; + else if (name.endsWith(".json.gz")) format = "json.gz"; else if (name.endsWith(".json")) format = "json"; else if (name.endsWith(".zip")) format = "zip"; else if (name.endsWith(".gz")) format = "json.gz"; @@ -148,6 +160,26 @@ export default function EmbedDebugPage() { postCommand({ type: "grida:load", data: buf, format }); }; + // Reset iframe when mode changes + const handleModeChange = (newMode: EmbedMode) => { + setMode(newMode); + setReady(false); + setScenes([]); + setSelection([]); + setCurrentScene(null); + setLog([]); + // If there's an active embed, reload it with the new mode's path + if (embedSrc) { + const url = new URL(embedSrc, window.location.origin); + const file = url.searchParams.get("file"); + if (file) { + setEmbedSrc(`${EMBED_PATHS[newMode]}?file=${encodeURIComponent(file)}`); + } else { + setEmbedSrc(EMBED_PATHS[newMode]); + } + } + }; + return (
{/* Header */} @@ -163,6 +195,10 @@ export default function EmbedDebugPage() { > {ready ? "ready" : "not connected"} + {/* Mode indicator */} + + {mode === "figma" ? "figma" : "general"} +
+ +
+

+ {mode === "figma" + ? "Figma ID mapping enabled. Events use original Figma node IDs." + : "General-purpose. Events use Grida-internal node IDs."} +

+ + {/* Load via URL */}

@@ -210,7 +274,7 @@ export default function EmbedDebugPage() { { const f = e.target.files?.[0]; @@ -300,6 +364,14 @@ export default function EmbedDebugPage() { State

+
+ Mode: + {mode} +
+
+ Embed: + {embedBasePath} +
Scene: {currentScene ?? "—"} diff --git a/editor/app/(embed)/embed/v1/figma/page.tsx b/editor/app/(embed)/embed/v1/figma/page.tsx index c3c727eadb..b27fbe04ba 100644 --- a/editor/app/(embed)/embed/v1/figma/page.tsx +++ b/editor/app/(embed)/embed/v1/figma/page.tsx @@ -55,7 +55,7 @@ function parseContentDispositionFilename(header: string | null): string | null { return null; } -const SUPPORTED_EXT_RE = /\.(fig|json\.gz|json|zip)$/i; +const SUPPORTED_EXT_RE = /\.(fig|json\.gz|json|zip|grida|grida1)$/i; /** * Infer a usable filename from a remote URL + response headers. @@ -132,6 +132,7 @@ function RefigEmbedInner({ remoteFileUrl }: { remoteFileUrl?: string }) { onFile, } = useRefigEditor(); + // Figma-specific: transform node IDs back to original Figma IDs useEmbedBridge(instance, { canvasReady, onFile, @@ -233,6 +234,17 @@ function RefigEmbedContent() { return ; } +/** + * Figma-specific embed viewer page (`/embed/v1/figma`). + * + * Accepts Figma files (.fig, .json, .json.gz, .zip) and also native Grida + * formats (.grida, .grida1). + * + * Key difference from `/embed/v1/`: node IDs in events (selection-change, + * scene-change, etc.) are transformed back to original Figma node IDs + * via `decodeSyntheticFigmaId`. This allows the host to work with the + * Figma ID contract (e.g. `"42:17"`). + */ export default function RefigEmbedPage() { return ( { + return fig2grida(input, { + placeholder_for_missing_images: false, + preserve_figma_ids: false, + }); +}; + +const SUPPORTED_EXT_RE = /\.(grida|grida1|fig|json\.gz|json|zip)$/i; + +function parseFileParam( + raw: string | null +): { ok: true; url: string } | { ok: false; message: string } { + if (!raw?.trim()) { + return { + ok: false, + message: "Missing required query parameter: file", + }; + } + let u: URL; + try { + u = new URL(raw); + } catch { + return { ok: false, message: "Invalid file URL" }; + } + if (u.protocol !== "http:" && u.protocol !== "https:") { + return { + ok: false, + message: "file URL must use http: or https:", + }; + } + return { ok: true, url: u.toString() }; +} + +/** + * Extract a filename hint from the `Content-Disposition` header. + */ +function parseContentDispositionFilename(header: string | null): string | null { + if (!header) return null; + const starMatch = header.match(/filename\*\s*=\s*UTF-8''([^;\s]+)/i); + if (starMatch) return decodeURIComponent(starMatch[1]); + const plainMatch = header.match(/filename\s*=\s*"?([^";\s]+)"?/i); + if (plainMatch) return plainMatch[1]; + return null; +} + +/** + * Infer a usable filename from a remote URL + response headers. + * + * Falls back to `.grida` for unknown octet-stream (general-purpose default). + */ +function inferFilenameForRemote( + url: string, + contentType: string | null, + contentDisposition: string | null = null, + contentEncoding: string | null = null +): string | null { + const cdName = parseContentDispositionFilename(contentDisposition); + if (cdName && SUPPORTED_EXT_RE.test(cdName)) { + return cdName; + } + + let pathName: string; + try { + pathName = new URL(url).pathname; + } catch { + pathName = ""; + } + const base = decodeURIComponent(pathName.split("/").pop() || ""); + if (SUPPORTED_EXT_RE.test(base)) { + return base; + } + + const ct = (contentType || "").toLowerCase(); + const ce = (contentEncoding || "").toLowerCase(); + + if ( + ct.includes("application/gzip") || + ct.includes("application/x-gzip") || + (ce.includes("gzip") && + (ct.includes("application/json") || ct.includes("text/json"))) + ) { + return "remote.json.gz"; + } + if (ct.includes("application/json") || ct.includes("text/json")) { + return "remote.json"; + } + if ( + ct.includes("application/zip") || + ct.includes("application/x-zip-compressed") + ) { + return "remote.zip"; + } + if (ct.includes("application/octet-stream") || ct === "") { + return "remote.grida"; + } + return null; +} + +function EmbedViewerInner({ remoteFileUrl }: { remoteFileUrl?: string }) { + const { + editor: instance, + fonts, + canvasRef, + canvasReady, + loading, + loadError, + documentLoaded, + onFile, + } = useEmbedViewer({ converter: generalConverter }); + + // General-purpose embed: no ID transform + useEmbedBridge(instance, { canvasReady, onFile }); + + const remoteFetchGen = useRef(0); + + useEffect(() => { + if (!remoteFileUrl || !canvasReady) return; + + const gen = ++remoteFetchGen.current; + let cancelled = false; + + (async () => { + try { + const res = await fetch(remoteFileUrl, { mode: "cors" }); + if (!res.ok) { + throw new Error(`Failed to fetch file (HTTP ${res.status})`); + } + const ct = res.headers.get("content-type"); + const cd = res.headers.get("content-disposition"); + const ce = res.headers.get("content-encoding"); + const buf = await res.arrayBuffer(); + const inferred = inferFilenameForRemote(remoteFileUrl, ct, cd, ce); + if (!inferred) { + throw new Error( + "Could not infer a supported extension (.grida, .grida1, .fig, .json, .json.gz, .zip) from the URL or Content-Type" + ); + } + const file = new File([buf], inferred, { + type: ct || "application/octet-stream", + }); + if (cancelled || gen !== remoteFetchGen.current) return; + await onFile(file); + } catch (e) { + if (cancelled || gen !== remoteFetchGen.current) return; + console.error("[@grida/embed] remote fetch", e); + } + })(); + + return () => { + cancelled = true; + }; + }, [remoteFileUrl, canvasReady, onFile]); + + const showLoadingOverlay = !documentLoaded && !!remoteFileUrl && !loadError; + + return ( + + + +
+ {loadError ? ( + + {loadError} + + ) : null} +
+ + + + + {showLoadingOverlay ? ( +
+ ) : null} +
+
+ + + + ); +} + +function EmbedViewerContent() { + const searchParams = useSearchParams(); + const raw = searchParams.get("file"); + + if (!raw) { + return ; + } + + const parsed = parseFileParam(raw); + if (!parsed.ok) { + return ( +
+ {parsed.message} +
+ ); + } + + return ; +} + +/** + * General-purpose embed viewer page (`/embed/v1/`). + * + * Supports all Grida-compatible file formats: + * - `.grida` — native Grida archive (ZIP with FlatBuffers document + assets) + * - `.grida1` — Grida JSON snapshot + * - `.fig` — Figma binary (converted via fig2grida) + * - `.json` / `.json.gz` / `.zip` — Figma REST formats + * + * Unlike `/embed/v1/figma`, this page does NOT apply Figma ID transforms. + * Node IDs in `selection-change` and other events use Grida-internal IDs. + * + * Use this endpoint when embedding any design file and you don't need + * Figma-specific ID mapping in the event contract. + */ +export default function EmbedPage() { + return ( + + Loading… +
+ } + > + +
+ ); +} diff --git a/editor/grida-canvas/embed-bridge.ts b/editor/grida-canvas/embed-bridge.ts index b4b914b6ba..16aa6177f2 100644 --- a/editor/grida-canvas/embed-bridge.ts +++ b/editor/grida-canvas/embed-bridge.ts @@ -202,7 +202,8 @@ export class EmbedBridge { break; case "grida:load": if (this.onFile) { - const file = new File([cmd.data], `file.${cmd.format}`, { + const ext = cmd.format === "grida1" ? "grida1" : cmd.format; + const file = new File([cmd.data], `file.${ext}`, { type: "application/octet-stream", }); this.onFile(file); diff --git a/editor/grida-canvas/embed-protocol.ts b/editor/grida-canvas/embed-protocol.ts index f9a039affa..63797bfe7a 100644 --- a/editor/grida-canvas/embed-protocol.ts +++ b/editor/grida-canvas/embed-protocol.ts @@ -25,7 +25,7 @@ export type EmbedCommand = /** Raw file contents. */ data: ArrayBuffer; /** File format. */ - format: "fig" | "json" | "json.gz" | "zip"; + format: "fig" | "json" | "json.gz" | "zip" | "grida" | "grida1"; } | { /** Request a state snapshot. Iframe replies with `grida:pong`. */ diff --git a/editor/scaffolds/embed/grida-embed.ts b/editor/scaffolds/embed/grida-embed.ts index 71645442f7..a2a046145f 100644 --- a/editor/scaffolds/embed/grida-embed.ts +++ b/editor/scaffolds/embed/grida-embed.ts @@ -114,10 +114,7 @@ export class GridaEmbed { // Commands // ------------------------------------------------------------------------- - select( - nodeIds: string[], - mode?: "reset" | "add" | "toggle" - ): void { + select(nodeIds: string[], mode?: "reset" | "add" | "toggle"): void { this.send({ type: "grida:select", nodeIds, mode }); } @@ -161,7 +158,7 @@ export class GridaEmbed { */ async load( data: ArrayBuffer | Uint8Array | Blob, - format: "fig" | "json" | "json.gz" | "zip" + format: "fig" | "json" | "json.gz" | "zip" | "grida" | "grida1" ): Promise { let buf: ArrayBuffer; if (data instanceof Blob) { diff --git a/editor/scaffolds/embed/use-embed-viewer.ts b/editor/scaffolds/embed/use-embed-viewer.ts new file mode 100644 index 0000000000..09ffd6cb2a --- /dev/null +++ b/editor/scaffolds/embed/use-embed-viewer.ts @@ -0,0 +1,297 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { io } from "@grida/io"; +import { editor } from "@/grida-canvas"; +import { useEditor, useEditorState } from "@/grida-canvas-react"; +import { distro } from "@/grida-canvas-hosted/distro"; + +/** + * File converter function signature. + * + * Converts raw file bytes into a `.grida` archive (ZIP containing FlatBuffers). + * Used by format-specific embed viewers (e.g. Figma) to plug into the shared + * viewer pipeline. + * + * @param input - Raw bytes (for binary formats) or parsed object (for JSON formats) + * @returns An object with the `.grida` archive bytes and metadata + */ +export type FileConverter = (input: Uint8Array | object) => { + bytes: Uint8Array; + nodeCount: number; + pageNames: string[]; +}; + +/** + * Renderer configuration for the embed canvas. + */ +export interface EmbedRenderConfig { + /** + * Skip the flexbox layout engine during scene loading. + */ + cg_skip_layout: boolean; +} + +const DEFAULT_RENDER_CONFIG: EmbedRenderConfig = { + cg_skip_layout: false, +}; + +/** + * Options for the embed viewer hook. + */ +export interface UseEmbedViewerOptions { + /** + * Optional file converter for non-native formats (e.g. Figma). + * When provided, files with extensions `.fig`, `.json`, `.json.gz`, `.zip` + * are run through this converter before being loaded. + * + * When not provided, only native `.grida` and `.grida1` files are supported. + */ + converter?: FileConverter; + + /** + * Renderer configuration. Defaults to `{ cg_skip_layout: false }`. + */ + renderConfig?: EmbedRenderConfig; +} + +// Extensions that require conversion (Figma pipeline) +const FIGMA_EXT_RE = /\.(fig|deck|json\.gz|json|zip)$/i; + +// Extensions natively supported by io.load +const NATIVE_EXT_RE = /\.(grida|grida1)$/i; + +/** + * Validates that a filename has a supported extension. + */ +export function validateExt(name: string, hasConverter: boolean): boolean { + if (NATIVE_EXT_RE.test(name)) return true; + if (hasConverter && FIGMA_EXT_RE.test(name)) return true; + return false; +} + +async function decompressGzip(buf: ArrayBuffer): Promise { + const ds = new DecompressionStream("gzip"); + const writer = ds.writable.getWriter(); + writer.write(new Uint8Array(buf)); + writer.close(); + const reader = ds.readable.getReader(); + const chunks: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + let total = 0; + for (const c of chunks) total += c.byteLength; + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.byteLength; + } + return out.buffer; +} + +/** + * General-purpose embed viewer hook. + * + * Handles WASM mount, file loading (native `.grida`/`.grida1` or converted + * formats via an optional converter), editor state init, and camera fit. + * + * This is the shared foundation for both the general-purpose embed (`/embed/v1/`) + * and the Figma-specific embed (`/embed/v1/figma`). + */ +export function useEmbedViewer(options: UseEmbedViewerOptions = {}) { + const { converter, renderConfig = DEFAULT_RENDER_CONFIG } = options; + + const instance = useEditor( + { + ...distro.playground.EMPTY_DOCUMENT, + editable: false, + }, + "canvas" + ); + + const [loadError, setLoadError] = useState(null); + const [loading, setLoading] = useState(false); + const [fileLabel, setFileLabel] = useState(null); + const [documentLoaded, setDocumentLoaded] = useState(false); + const [canvasReady, setCanvasReady] = useState(false); + const [canvasElement, setCanvasElement] = useState( + null + ); + + const canvasRef = useCallback((node: HTMLCanvasElement | null) => { + setCanvasElement(node); + }, []); + + // Mount WASM eagerly — before any document is loaded into editor state. + useEffect(() => { + if (!canvasElement) { + setCanvasReady(false); + return; + } + + let cancelled = false; + setCanvasReady(false); + const dpr = window.devicePixelRatio || 1; + + instance.__surfaceOptions = { + use_embedded_fonts: true, + config: { + skip_layout: renderConfig.cg_skip_layout, + }, + }; + + instance + .mount(canvasElement, dpr) + .then(() => { + if (!cancelled) { + console.log("[@grida/embed] WASM mount complete"); + setCanvasReady(true); + } + }) + .catch((err) => { + console.error("[@grida/embed] WASM mount failed:", err); + setLoadError( + `Failed to mount WASM canvas: ${err instanceof Error ? err.message : String(err)}` + ); + }); + + return () => { + cancelled = true; + }; + }, [canvasElement, instance]); + + useEffect(() => { + if (!canvasReady) return; + instance.surface.surfaceSetTool({ type: "hand" }); + }, [canvasReady, instance]); + + const sceneId = useEditorState(instance, (s) => s.scene_id); + const documentKey = useEditorState(instance, (s) => s.document_key); + + // Fit camera when document/scene changes. + useEffect(() => { + if (!documentLoaded || !fileLabel || !canvasReady) return; + queueMicrotask(() => { + instance.camera.fit(""); + }); + }, [documentLoaded, fileLabel, canvasReady, sceneId, documentKey, instance]); + + /** + * Load a loaded document into the editor state. + */ + const loadDocument = useCallback( + (loaded: io.LoadedDocument, filename: string) => { + const t2 = performance.now(); + const initState = editor.state.init({ + editable: false, + document: loaded.document, + }); + console.log( + `[@grida/embed] editor.state.init: ${(performance.now() - t2).toFixed(0)}ms` + ); + + const t3 = performance.now(); + instance.commands.reset(initState, filename); + console.log( + `[@grida/embed] reset (includes syncDocument): ${(performance.now() - t3).toFixed(0)}ms` + ); + + if (loaded.assets?.images) { + instance.loadImages(loaded.assets.images); + } + + setFileLabel(filename); + setDocumentLoaded(true); + }, + [instance] + ); + + const onFile = useCallback( + async (file: File) => { + const hasConverter = !!converter; + if (!validateExt(file.name, hasConverter)) return; + setLoading(true); + setLoadError(null); + try { + const lower = file.name.toLowerCase(); + + // Native .grida / .grida1 — load directly via io.load + if (NATIVE_EXT_RE.test(lower)) { + const t0 = performance.now(); + const loaded = await io.load(file); + console.log( + `[@grida/embed] io.load (native): ${Object.keys(loaded.document.nodes).length} nodes in ${(performance.now() - t0).toFixed(0)}ms` + ); + loadDocument(loaded, file.name); + return; + } + + // Converted formats (Figma etc.) — requires converter + if (!converter) { + throw new Error( + `Unsupported file format: "${file.name}". This embed only supports .grida and .grida1 files.` + ); + } + + const buf = await file.arrayBuffer(); + const bytes = new Uint8Array(buf); + + let input: Uint8Array | object; + if (lower.endsWith(".json.gz")) { + const decompressed = await decompressGzip(buf); + input = JSON.parse(new TextDecoder().decode(decompressed)) as object; + } else if (lower.endsWith(".json")) { + input = JSON.parse(new TextDecoder().decode(bytes)) as object; + } else { + input = bytes; + } + + const t0 = performance.now(); + const { bytes: zipBytes, nodeCount, pageNames } = converter(input); + console.log( + `[@grida/embed] convert: ${pageNames.length} page(s), ${nodeCount} nodes in ${(performance.now() - t0).toFixed(0)}ms` + ); + + const blob = new Blob([new Uint8Array(zipBytes)], { + type: "application/zip", + }); + const gridaFile = new File([blob], "imported.grida", { + type: "application/zip", + }); + + const t1 = performance.now(); + const loaded = await io.load(gridaFile); + console.log( + `[@grida/embed] io.load: ${Object.keys(loaded.document.nodes).length} nodes in ${(performance.now() - t1).toFixed(0)}ms` + ); + + loadDocument(loaded, file.name); + } catch (e) { + console.error("[@grida/embed]", e); + setLoadError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, + [converter, loadDocument] + ); + + const fonts = useEditorState(instance, (s) => s.webfontlist.items); + + return { + editor: instance, + fonts, + canvasRef, + canvasReady, + loading, + loadError, + fileLabel, + documentLoaded, + documentKey, + onFile, + }; +} diff --git a/editor/scaffolds/embed/use-refig-editor.ts b/editor/scaffolds/embed/use-refig-editor.ts index a5c2f1e2f8..99846efbab 100644 --- a/editor/scaffolds/embed/use-refig-editor.ts +++ b/editor/scaffolds/embed/use-refig-editor.ts @@ -1,46 +1,8 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; import { fig2grida } from "@grida/io-figma/fig2grida-core"; -import { io } from "@grida/io"; -import { editor } from "@/grida-canvas"; -import { useEditor, useEditorState } from "@/grida-canvas-react"; -import { distro } from "@/grida-canvas-hosted/distro"; import type iofigma from "@grida/io-figma"; - -function validateExt(name: string) { - const l = name.toLowerCase(); - return ( - l.endsWith(".fig") || - l.endsWith(".deck") || - l.endsWith(".json") || - l.endsWith(".json.gz") || - l.endsWith(".zip") - ); -} - -async function decompressGzip(buf: ArrayBuffer): Promise { - const ds = new DecompressionStream("gzip"); - const writer = ds.writable.getWriter(); - writer.write(new Uint8Array(buf)); - writer.close(); - const reader = ds.readable.getReader(); - const chunks: Uint8Array[] = []; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - } - let total = 0; - for (const c of chunks) total += c.byteLength; - const out = new Uint8Array(total); - let offset = 0; - for (const c of chunks) { - out.set(c, offset); - offset += c.byteLength; - } - return out.buffer; -} +import { useEmbedViewer, type FileConverter } from "./use-embed-viewer"; /** * Renderer configuration for the refig embed canvas. @@ -62,204 +24,35 @@ const REFIG_RENDER_CONFIG: RefigRenderConfig = { cg_skip_layout: false, }; -export function useRefigEditor() { - const instance = useEditor( - { - ...distro.playground.EMPTY_DOCUMENT, - editable: false, - }, - "canvas" - ); - - const [loadError, setLoadError] = useState(null); - const [loading, setLoading] = useState(false); - const [fileLabel, setFileLabel] = useState(null); - const [documentLoaded, setDocumentLoaded] = useState(false); - const [canvasReady, setCanvasReady] = useState(false); - const [canvasElement, setCanvasElement] = useState( - null - ); - - const canvasRef = useCallback((node: HTMLCanvasElement | null) => { - setCanvasElement(node); - }, []); - - // Mount WASM eagerly — before any document is loaded into editor state. - useEffect(() => { - if (!canvasElement) { - setCanvasReady(false); - return; - } - - let cancelled = false; - setCanvasReady(false); - const dpr = window.devicePixelRatio || 1; - - // Apply renderer config at init time (before mount creates the WASM surface). - instance.__surfaceOptions = { - use_embedded_fonts: true, - config: { - skip_layout: REFIG_RENDER_CONFIG.cg_skip_layout, - }, - }; - - instance - .mount(canvasElement, dpr) - .then(() => { - if (!cancelled) { - console.log("[@grida/refig] WASM mount complete"); - setCanvasReady(true); - } - }) - .catch((err) => { - console.error("[@grida/refig] WASM mount failed:", err); - setLoadError( - `Failed to mount WASM canvas: ${err instanceof Error ? err.message : String(err)}` - ); - }); - - return () => { - cancelled = true; - }; - }, [canvasElement, instance]); - - useEffect(() => { - if (!canvasReady) return; - instance.surface.surfaceSetTool({ type: "hand" }); - }, [canvasReady, instance]); - - const sceneId = useEditorState(instance, (s) => s.scene_id); - const documentKey = useEditorState(instance, (s) => s.document_key); - - // Fit camera when document/scene changes. - useEffect(() => { - if (!documentLoaded || !fileLabel || !canvasReady) return; - queueMicrotask(() => { - instance.camera.fit(""); +/** + * Create a fig2grida converter function with the refig render config. + */ +function createFigmaConverter(): FileConverter { + return (input: Uint8Array | object) => { + return fig2grida(input, { + placeholder_for_missing_images: false, + preserve_figma_ids: true, + prefer_fixed_text_sizing: REFIG_RENDER_CONFIG.prefer_fixed_text_sizing, }); - }, [documentLoaded, fileLabel, canvasReady, sceneId, documentKey, instance]); - - const onFile = useCallback( - async (file: File) => { - if (!validateExt(file.name)) return; - setLoading(true); - setLoadError(null); - try { - const buf = await file.arrayBuffer(); - const bytes = new Uint8Array(buf); - const lower = file.name.toLowerCase(); - - let input: Uint8Array | object; - if (lower.endsWith(".json.gz")) { - const decompressed = await decompressGzip(buf); - input = JSON.parse(new TextDecoder().decode(decompressed)) as object; - } else if (lower.endsWith(".json")) { - input = JSON.parse(new TextDecoder().decode(bytes)) as object; - } else { - input = bytes; - } - - const logMemory = (label: string) => { - try { - const perf = (performance as any).memory; - if (perf) { - const used = (perf.usedJSHeapSize / (1024 * 1024)).toFixed(1); - const total = (perf.totalJSHeapSize / (1024 * 1024)).toFixed(1); - const limit = (perf.jsHeapSizeLimit / (1024 * 1024)).toFixed(0); - console.log( - `[@grida/refig] JS heap (${label}): ${used}/${total} MB (limit ${limit} MB)` - ); - } - const scene = instance.wasmScene; - if (scene) { - const heap = (scene as any).module?.HEAP8; - if (heap) { - const mb = (heap.buffer.byteLength / (1024 * 1024)).toFixed(1); - console.log(`[@grida/refig] WASM heap (${label}): ${mb} MB`); - } - } - } catch {} - }; - - const t0 = performance.now(); - const { - bytes: zipBytes, - nodeCount, - pageNames, - } = fig2grida(input, { - placeholder_for_missing_images: false, - preserve_figma_ids: true, - prefer_fixed_text_sizing: - REFIG_RENDER_CONFIG.prefer_fixed_text_sizing, - }); - console.log( - `[@grida/refig] fig2grida: ${pageNames.length} page(s), ${nodeCount} nodes in ${(performance.now() - t0).toFixed(0)}ms` - ); - - const blob = new Blob([new Uint8Array(zipBytes)], { - type: "application/zip", - }); - const gridaFile = new File([blob], "imported.grida", { - type: "application/zip", - }); - - const t1 = performance.now(); - const loaded = await io.load(gridaFile); - console.log( - `[@grida/refig] io.load: ${Object.keys(loaded.document.nodes).length} nodes in ${(performance.now() - t1).toFixed(0)}ms` - ); - - logMemory("before reset"); - - const t2 = performance.now(); - const initState = editor.state.init({ - editable: false, - document: loaded.document, - }); - console.log( - `[@grida/refig] editor.state.init: ${(performance.now() - t2).toFixed(0)}ms` - ); - - const t3 = performance.now(); - instance.commands.reset(initState, file.name); - console.log( - `[@grida/refig] reset (includes syncDocument): ${(performance.now() - t3).toFixed(0)}ms` - ); - - logMemory("after reset"); - - if (loaded.assets?.images) { - instance.loadImages(loaded.assets.images); - } + }; +} - logMemory("after loadImages"); +const figmaConverter = createFigmaConverter(); - setFileLabel(file.name); - setDocumentLoaded(true); - } catch (e) { - console.error("[@grida/refig]", e); - setLoadError(e instanceof Error ? e.message : String(e)); - } finally { - setLoading(false); - } +/** + * Figma-specific embed viewer hook. + * + * Thin wrapper around {@link useEmbedViewer} that plugs in the fig2grida + * converter. Supports all native Grida formats (.grida, .grida1) plus Figma + * formats (.fig, .json, .json.gz, .zip). + */ +export function useRefigEditor() { + return useEmbedViewer({ + converter: figmaConverter, + renderConfig: { + cg_skip_layout: REFIG_RENDER_CONFIG.cg_skip_layout, }, - [instance] - ); - - const fonts = useEditorState(instance, (s) => s.webfontlist.items); - - return { - editor: instance, - fonts, - canvasRef, - canvasReady, - loading, - loadError, - fileLabel, - documentLoaded, - documentKey, - onFile, - }; + }); } /** @@ -309,4 +102,12 @@ export function decodeSyntheticFigmaId(id: string): string { return decoded; } -export { validateExt }; +import { validateExt as _validateExt } from "./use-embed-viewer"; + +/** + * Validate a filename for refig (Figma + native formats). + * Accepts `.fig`, `.deck`, `.json`, `.json.gz`, `.zip`, `.grida`, `.grida1`. + */ +export function validateExt(name: string): boolean { + return _validateExt(name, /* hasConverter */ true); +} From 28f96a83cad166c93dc6ad53446a8c80976e9db7 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 2 Apr 2026 21:07:55 +0900 Subject: [PATCH 03/24] fix(io): build FeNoise table offsets outside FlatBuffers vector block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encodeFeNoises was creating tables inside a startVector/endVector block, which corrupts the buffer — FlatBuffers only allows inline scalars/structs in that context. The Rust decoder panicked on the invalid offsets, causing load_scene_grida to fail silently and leaving loaded_scenes stale from the initial empty document. Build all FeNoiseEffect offsets first, then pass them to createFeNoisesVector, matching the pattern used by encodeFeShadows. --- .agents/skills/io-grida/SKILL.md | 24 +++ .../lib/__test__/bench-load-scene.test.ts | 50 +++++++ crates/grida-canvas/tests/fbs_roundtrip.rs | 50 +++++++ .../__tests__/format-roundtrip.test.ts | 46 ++++++ packages/grida-canvas-io/format.ts | 140 +++++++++--------- 5 files changed, 242 insertions(+), 68 deletions(-) diff --git a/.agents/skills/io-grida/SKILL.md b/.agents/skills/io-grida/SKILL.md index 55e53cba81..fa8fe8d9c3 100644 --- a/.agents/skills/io-grida/SKILL.md +++ b/.agents/skills/io-grida/SKILL.md @@ -105,3 +105,27 @@ When you need to invalidate old files (field renumbering, semantic changes, remo Format: `MAJOR.MINOR.PATCH-prerelease+build` (e.g. `"0.91.0-beta+20260311"`). See `format/AGENTS.md` for the full review checklist. + +## Debugging FlatBuffers Issues + +### Verifying bytes + +Use `flatbuffers::root::(&bytes)` (not `root_unchecked`) in a Rust test to run the FlatBuffers verifier. It reports the exact field chain with the bad offset. + +```rust +use cg::io::generated::grida::grida as fbs; +let result = flatbuffers::root::(&bytes); +``` + +Note: the TS FlatBuffers decoder is more lenient than Rust — a TS-side round-trip may pass even when the bytes are structurally invalid. Always verify with the Rust verifier. + +### Inspecting .grida files + +```sh +cargo run --example tool_io_grida -- path/to/file.grida --list-scenes +cargo run --example tool_io_grida -- path/to/file.grida --scene 0 --verbose +``` + +### Cross-boundary tests + +`crates/grida-canvas-wasm/lib/__test__/bench-load-scene.test.ts` includes tests that TS-encode → WASM-decode `.grida` fixtures. These catch issues that only surface across the TS→Rust boundary. diff --git a/crates/grida-canvas-wasm/lib/__test__/bench-load-scene.test.ts b/crates/grida-canvas-wasm/lib/__test__/bench-load-scene.test.ts index 3ca202eca1..472c65a2c9 100644 --- a/crates/grida-canvas-wasm/lib/__test__/bench-load-scene.test.ts +++ b/crates/grida-canvas-wasm/lib/__test__/bench-load-scene.test.ts @@ -22,10 +22,14 @@ import { readFileSync, existsSync, readdirSync } from "node:fs"; import { resolve } from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { Scene } from "../modules/canvas"; +import { io } from "../../../../packages/grida-canvas-io/index"; /** Directory for local (gitignored) benchmark fixtures. */ const LOCAL_FIXTURES_DIR = resolve(__dirname, "fixtures/local"); +/** Shared test fixtures (committed). */ +const SHARED_FIXTURES_DIR = resolve(__dirname, "../../../../fixtures/test-grida"); + let module: any; beforeAll(async () => { @@ -149,3 +153,49 @@ describe("bench: load_scene (WASM-on-Node)", () => { }); } }); + +describe("cross-boundary: TS encode → WASM decode", () => { + /** + * Shared .grida fixtures: TS decodes → re-encodes → WASM loads → switchScene. + * Validates that the TS FlatBuffers encoder produces bytes the Rust decoder accepts. + */ + const sharedFixtures = existsSync(SHARED_FIXTURES_DIR) + ? readdirSync(SHARED_FIXTURES_DIR) + .filter((f) => f.endsWith(".grida")) + .sort() + : []; + + function createFile(name: string, bytes: Uint8Array): File { + const blob = new Blob([bytes as BlobPart], { + type: "application/octet-stream", + }); + return new File([blob], name, { type: "application/octet-stream" }); + } + + for (const fixture of sharedFixtures) { + it(`${fixture}: TS re-encode → WASM loadSceneGrida → switchScene`, async () => { + const raw = new Uint8Array( + readFileSync(resolve(SHARED_FIXTURES_DIR, fixture)) + ); + const file = createFile(fixture, raw); + const loaded = await io.load(file); + if (loaded.document.scenes_ref.length === 0) return; + + const sceneId = loaded.document.scenes_ref[0]!; + const reEncoded = io.GRID.encode(loaded.document); + + const scene = createRasterScene(); + scene.loadSceneGrida(reEncoded); + + const wasmIds = scene.loadedSceneIds(); + expect(wasmIds).toContain(sceneId); + expect(() => scene.switchScene(sceneId)).not.toThrow(); + + scene.dispose(); + }); + } + + if (sharedFixtures.length === 0) { + it("no shared .grida fixtures found (skipped)", () => {}); + } +}); diff --git a/crates/grida-canvas/tests/fbs_roundtrip.rs b/crates/grida-canvas/tests/fbs_roundtrip.rs index b424a9001b..36c6f88643 100644 --- a/crates/grida-canvas/tests/fbs_roundtrip.rs +++ b/crates/grida-canvas/tests/fbs_roundtrip.rs @@ -1941,3 +1941,53 @@ fn gen_attributed_text_basic() { ); assert_roundtrip_scene(&scene, "s1", "attributed_text_basic"); } + +// ═══════════════════════════════════════════════════════════════════════════════ +// Fixture round-trip: load .grida files from fixtures/test-grida/ +// ═══════════════════════════════════════════════════════════════════════════════ + +fn fixtures_dir() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/test-grida") +} + +/// All .grida fixtures decode and round-trip with consistent scene IDs. +#[test] +fn fixture_roundtrip_all_grida_files() { + let dir = fixtures_dir(); + let entries = std::fs::read_dir(&dir).expect("fixtures dir should exist"); + + for entry in entries { + let entry = entry.unwrap(); + let path = entry.path(); + if path.extension().map(|e| e == "grida").unwrap_or(false) { + let name = path.file_name().unwrap().to_str().unwrap(); + let bytes = std::fs::read(&path).unwrap(); + + let result = cg::io::io_grida_file::decode_with_id_map(&bytes) + .unwrap_or_else(|e| panic!("{name}: decode failed: {e:?}")); + + assert_eq!( + result.scene_ids.len(), + result.scenes.len(), + "{name}: scene_ids and scenes count mismatch" + ); + + // Build loaded_scenes the same way application.rs does and verify + // every scene_id is findable. + let loaded: Vec<(String, _)> = result + .scene_ids + .iter() + .zip(result.scenes.into_iter()) + .map(|(id, s)| (id.clone(), s)) + .collect(); + + for sid in &result.scene_ids { + assert!( + loaded.iter().any(|(id, _)| id == sid), + "{name}: scene '{sid}' not found in loaded_scenes (available: {:?})", + loaded.iter().map(|(id, _)| id).collect::>() + ); + } + } + } +} diff --git a/packages/grida-canvas-io/__tests__/format-roundtrip.test.ts b/packages/grida-canvas-io/__tests__/format-roundtrip.test.ts index 54bc062172..619af07e7c 100644 --- a/packages/grida-canvas-io/__tests__/format-roundtrip.test.ts +++ b/packages/grida-canvas-io/__tests__/format-roundtrip.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; import type grida from "@grida/schema"; import cg from "@grida/cg"; +import { io } from "../index"; import { format } from "../format"; // Base objects for common node types @@ -3300,3 +3303,46 @@ describe("format roundtrip", () => { }); }); }); + +// ═══════════════════════════════════════════════════════════════════════════════ +// Fixture round-trip: load .grida files, re-encode, verify scenes survive +// ═══════════════════════════════════════════════════════════════════════════════ + +const FIXTURES_DIR = path.resolve(__dirname, "../../../fixtures/test-grida"); + +function readFixture(name: string): Uint8Array { + return new Uint8Array(fs.readFileSync(path.join(FIXTURES_DIR, name))); +} + +function createFileFromBytes(name: string, bytes: Uint8Array): File { + const blob = new Blob([bytes as BlobPart], { + type: "application/octet-stream", + }); + return new File([blob], name, { type: "application/octet-stream" }); +} + +describe("fixture round-trip (.grida files)", () => { + const fixtureFiles = fs + .readdirSync(FIXTURES_DIR) + .filter((f) => f.endsWith(".grida")); + + for (const fixture of fixtureFiles) { + it(`${fixture}: scenes_ref survives io.load → io.GRID.encode → decode`, async () => { + const bytes = readFixture(fixture); + const file = createFileFromBytes(fixture, bytes); + const loaded = await io.load(file); + + if (loaded.document.scenes_ref.length === 0) return; + + const reEncoded = io.GRID.encode(loaded.document); + const reDecoded = format.document.decode.fromFlatbuffer(reEncoded); + + expect(reDecoded.scenes_ref).toEqual(loaded.document.scenes_ref); + + for (const sid of reDecoded.scenes_ref) { + expect(reDecoded.nodes[sid]).toBeDefined(); + expect(reDecoded.nodes[sid]!.type).toBe("scene"); + } + }); + } +}); diff --git a/packages/grida-canvas-io/format.ts b/packages/grida-canvas-io/format.ts index f7413bc93f..cca48404a3 100644 --- a/packages/grida-canvas-io/format.ts +++ b/packages/grida-canvas-io/format.ts @@ -3779,6 +3779,73 @@ export namespace format { return fbs.FeLiquidGlass.endFeLiquidGlass(builder); } + /** + * Encodes a single FeNoise to a FlatBuffers FeNoiseEffect table. + */ + function encodeFeNoise( + builder: Builder, + noise: cg.FeNoise + ): flatbuffers.Offset { + // Create NoiseEffectColors table + let coloringOffset: flatbuffers.Offset; + if (noise.mode === "mono") { + const color = + noise.color || ({ r: 0, g: 0, b: 0, a: 1 } as cg.RGBA32F); + const monoColorOffset = structs.rgba32f(builder, color); + fbs.NoiseEffectColors.startNoiseEffectColors(builder); + fbs.NoiseEffectColors.addKind( + builder, + fbs.NoiseEffectColorsKind.Mono + ); + fbs.NoiseEffectColors.addMonoColor(builder, monoColorOffset); + coloringOffset = + fbs.NoiseEffectColors.endNoiseEffectColors(builder); + } else if (noise.mode === "duo") { + const color1 = + noise.color1 || ({ r: 0, g: 0, b: 0, a: 1 } as cg.RGBA32F); + const color2 = + noise.color2 || ({ r: 1, g: 1, b: 1, a: 1 } as cg.RGBA32F); + const duoColor1Offset = structs.rgba32f(builder, color1); + const duoColor2Offset = structs.rgba32f(builder, color2); + fbs.NoiseEffectColors.startNoiseEffectColors(builder); + fbs.NoiseEffectColors.addKind( + builder, + fbs.NoiseEffectColorsKind.Duo + ); + fbs.NoiseEffectColors.addDuoColor1(builder, duoColor1Offset); + fbs.NoiseEffectColors.addDuoColor2(builder, duoColor2Offset); + coloringOffset = + fbs.NoiseEffectColors.endNoiseEffectColors(builder); + } else { + // Multi + fbs.NoiseEffectColors.startNoiseEffectColors(builder); + fbs.NoiseEffectColors.addKind( + builder, + fbs.NoiseEffectColorsKind.Multi + ); + fbs.NoiseEffectColors.addMultiOpacity( + builder, + noise.opacity ?? 1.0 + ); + coloringOffset = + fbs.NoiseEffectColors.endNoiseEffectColors(builder); + } + + // Create FeNoiseEffect table + fbs.FeNoiseEffect.startFeNoiseEffect(builder); + fbs.FeNoiseEffect.addNoiseSize(builder, noise.noise_size); + fbs.FeNoiseEffect.addDensity(builder, noise.density); + fbs.FeNoiseEffect.addNumOctaves(builder, noise.num_octaves ?? 3); + fbs.FeNoiseEffect.addSeed(builder, noise.seed ?? 0); + fbs.FeNoiseEffect.addColoring(builder, coloringOffset); + fbs.FeNoiseEffect.addActive(builder, noise.active ?? true); + fbs.FeNoiseEffect.addBlendMode( + builder, + styling.encode.blendMode(noise.blend_mode ?? "normal") + ); + return fbs.FeNoiseEffect.endFeNoiseEffect(builder); + } + /** * Encodes FeNoise array to FlatBuffers FeNoiseEffect table array. */ @@ -3787,76 +3854,13 @@ export namespace format { noises: cg.FeNoise[] ): flatbuffers.Offset | undefined { if (noises.length === 0) return undefined; - fbs.LayerEffects.startFeNoisesVector(builder, noises.length); + // Create all FeNoiseEffect table offsets first, then create the vector. + // Tables MUST NOT be created inside a startVector/endVector block. + const noiseOffsets: flatbuffers.Offset[] = []; for (let i = noises.length - 1; i >= 0; i--) { - const noise = noises[i]!; - let coloringKind: fbs.NoiseEffectColorsKind; - let monoColorR = 0, - monoColorG = 0, - monoColorB = 0, - monoColorA = 1; - let duoColor1R = 0, - duoColor1G = 0, - duoColor1B = 0, - duoColor1A = 1; - let duoColor2R = 1, - duoColor2G = 1, - duoColor2B = 1, - duoColor2A = 1; - let multiOpacity = 1.0; - - // Create NoiseEffectColors table - let coloringOffset: flatbuffers.Offset; - if (noise.mode === "mono") { - coloringKind = fbs.NoiseEffectColorsKind.Mono; - const color = - noise.color || ({ r: 0, g: 0, b: 0, a: 1 } as cg.RGBA32F); - const monoColorOffset = structs.rgba32f(builder, color); - fbs.NoiseEffectColors.startNoiseEffectColors(builder); - fbs.NoiseEffectColors.addKind(builder, coloringKind); - fbs.NoiseEffectColors.addMonoColor(builder, monoColorOffset); - coloringOffset = - fbs.NoiseEffectColors.endNoiseEffectColors(builder); - } else if (noise.mode === "duo") { - coloringKind = fbs.NoiseEffectColorsKind.Duo; - const color1 = - noise.color1 || ({ r: 0, g: 0, b: 0, a: 1 } as cg.RGBA32F); - const color2 = - noise.color2 || ({ r: 1, g: 1, b: 1, a: 1 } as cg.RGBA32F); - const duoColor1Offset = structs.rgba32f(builder, color1); - const duoColor2Offset = structs.rgba32f(builder, color2); - fbs.NoiseEffectColors.startNoiseEffectColors(builder); - fbs.NoiseEffectColors.addKind(builder, coloringKind); - fbs.NoiseEffectColors.addDuoColor1(builder, duoColor1Offset); - fbs.NoiseEffectColors.addDuoColor2(builder, duoColor2Offset); - coloringOffset = - fbs.NoiseEffectColors.endNoiseEffectColors(builder); - } else { - // Multi - coloringKind = fbs.NoiseEffectColorsKind.Multi; - multiOpacity = noise.opacity ?? 1.0; - fbs.NoiseEffectColors.startNoiseEffectColors(builder); - fbs.NoiseEffectColors.addKind(builder, coloringKind); - fbs.NoiseEffectColors.addMultiOpacity(builder, multiOpacity); - coloringOffset = - fbs.NoiseEffectColors.endNoiseEffectColors(builder); - } - - // Create FeNoiseEffect table - fbs.FeNoiseEffect.startFeNoiseEffect(builder); - fbs.FeNoiseEffect.addNoiseSize(builder, noise.noise_size); - fbs.FeNoiseEffect.addDensity(builder, noise.density); - fbs.FeNoiseEffect.addNumOctaves(builder, noise.num_octaves ?? 3); - fbs.FeNoiseEffect.addSeed(builder, noise.seed ?? 0); - fbs.FeNoiseEffect.addColoring(builder, coloringOffset); - fbs.FeNoiseEffect.addActive(builder, noise.active ?? true); - fbs.FeNoiseEffect.addBlendMode( - builder, - styling.encode.blendMode(noise.blend_mode ?? "normal") - ); - fbs.FeNoiseEffect.endFeNoiseEffect(builder); + noiseOffsets.push(encodeFeNoise(builder, noises[i]!)); } - return builder.endVector(); + return fbs.LayerEffects.createFeNoisesVector(builder, noiseOffsets); } /** From 212ef5a52c210cdb2896cf11c3b875015f427ec2 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 2 Apr 2026 21:58:19 +0900 Subject: [PATCH 04/24] feat(tools): add Affine Transform Visualizer tool and update sitemap Introduced a new Affine Transform Visualizer tool that allows users to interactively visualize 2D affine transformations. Updated the sitemap to include a link to the new tool and added relevant metadata for improved SEO. Enhanced the tools page with a new entry for the Affine Transform Visualizer, including descriptions and icons. --- .../(tools)/tools/affine-transform/_page.tsx | 1301 +++++++++++++++++ .../(tools)/tools/affine-transform/page.tsx | 45 + editor/app/(tools)/tools/page.tsx | 10 + editor/app/sitemap.ts | 5 + 4 files changed, 1361 insertions(+) create mode 100644 editor/app/(tools)/tools/affine-transform/_page.tsx create mode 100644 editor/app/(tools)/tools/affine-transform/page.tsx diff --git a/editor/app/(tools)/tools/affine-transform/_page.tsx b/editor/app/(tools)/tools/affine-transform/_page.tsx new file mode 100644 index 0000000000..e82bfedfe0 --- /dev/null +++ b/editor/app/(tools)/tools/affine-transform/_page.tsx @@ -0,0 +1,1301 @@ +"use client"; + +import React, { useEffect, useId, useRef, useState } from "react"; +import cmath from "@grida/cmath"; +import vn from "@grida/vn"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Slider } from "@/components/ui/slider"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { CopyIcon, RotateCcwIcon } from "lucide-react"; + +// ── Types ────────────────────────────────────────────────────────── + +type ShapeId = + | "rect" + | "circle" + | "triangle" + | "l-shape" + | "arrow" + | "star" + | "svg-path"; + +/** Decomposed transform parameters — the canonical source of truth for sliders. */ +interface TransformParams { + translate: cmath.Vector2; + rotation: number; // radians + scale: cmath.Vector2; + skewX: number; // radians +} + +// ── Transform helpers ────────────────────────────────────────────── + +function determinant(m: cmath.Transform): number { + return m[0][0] * m[1][1] - m[0][1] * m[1][0]; +} + +/** Compose a transform from T · R · K · S */ +function composeMatrix(p: TransformParams): cmath.Transform { + const cosR = Math.cos(p.rotation), + sinR = Math.sin(p.rotation); + const tanK = Math.tan(p.skewX); + // T · R · K · S expanded: + // a = sx * cosR - sy * tanK * sinR + // b = -sy * sinR + ... wait, let's just multiply properly + const T: cmath.Transform = [ + [1, 0, p.translate[0]], + [0, 1, p.translate[1]], + ]; + const R: cmath.Transform = [ + [cosR, -sinR, 0], + [sinR, cosR, 0], + ]; + const K: cmath.Transform = [ + [1, tanK, 0], + [0, 1, 0], + ]; + const S: cmath.Transform = [ + [p.scale[0], 0, 0], + [0, p.scale[1], 0], + ]; + return cmath.transform.multiply( + T, + cmath.transform.multiply(R, cmath.transform.multiply(K, S)) + ); +} + +/** Decompose a matrix into params. Used only for external matrix input. */ +function decomposeMatrix(m: cmath.Transform): TransformParams { + const tx = m[0][2], + ty = m[1][2]; + const a = m[0][0], + b = m[0][1], + c = m[1][0], + d = m[1][1]; + + const det = a * d - b * c; + let sx = Math.sqrt(a * a + c * c); + let sy = Math.sqrt(b * b + d * d); + if (det < 0) sx = -sx; + + const rotation = Math.atan2(c, a); + + const cos = Math.cos(-rotation), + sin = Math.sin(-rotation); + const rb = b * cos - d * sin; + const skewX = Math.atan2(rb, sy); + sy = sy * Math.cos(skewX); + + return { + translate: [tx, ty], + rotation, + scale: [sx, sy], + skewX, + }; +} + +const DEFAULT_PARAMS: TransformParams = { + translate: [0, 0], + rotation: 0, + scale: [1, 1], + skewX: 0, +}; + +// ── Formatting ───────────────────────────────────────────────────── + +function fmtNum(n: number, decimals = 4): string { + if (Math.abs(n) < 1e-10) return "0"; + return n.toFixed(decimals).replace(/\.?0+$/, "") || "0"; +} + +function fmtDeg(rad: number): string { + return fmtNum(cmath.rad2deg(rad), 1); +} + +// ── Shape definitions ────────────────────────────────────────────── + +function getShapePoints(shape: ShapeId, size: number): cmath.Vector2[] { + const h = size / 2; + switch (shape) { + case "rect": + return [ + [-h, -h], + [h, -h], + [h, h], + [-h, h], + ]; + case "triangle": + return [ + [0, -h], + [h, h], + [-h, h], + ]; + case "l-shape": + return [ + [-h, -h], + [0, -h], + [0, 0], + [h, 0], + [h, h], + [-h, h], + ]; + case "arrow": + return [ + [0, -h], + [h, 0], + [h * 0.35, 0], + [h * 0.35, h], + [-h * 0.35, h], + [-h * 0.35, 0], + [-h, 0], + ]; + case "star": { + const pts: cmath.Vector2[] = []; + for (let i = 0; i < 10; i++) { + const r = i % 2 === 0 ? h : h * 0.4; + const a = -Math.PI / 2 + (i * Math.PI) / 5; + pts.push([r * Math.cos(a), r * Math.sin(a)]); + } + return pts; + } + default: + return [ + [-h, -h], + [h, -h], + [h, h], + [-h, h], + ]; + } +} + +// ── SVG path helpers ─────────────────────────────────────────────── + +const DEFAULT_SVG_PATH = "M 10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80"; + +function parseSvgPath( + d: string, + targetSize: number +): + | { path2d: Path2D; error?: undefined } + | { path2d?: undefined; error: string } { + try { + const network = vn.fromSVGPathData(d); + if (!network.segments.length && !network.vertices.length) + return { error: "Path produced no visible output" }; + + const bbox = vn.getBBox(network); + if (bbox.width === 0 && bbox.height === 0) + return { error: "Path produced no visible output" }; + + const center = cmath.rect.getCenter(bbox); + const scale = targetSize / Math.max(bbox.width, bbox.height, 1); + + const editor = new vn.VectorNetworkEditor(network); + editor.translate(cmath.vector2.invert(center)); + editor.scale([scale, scale]); + + const centeredD = vn.toSVGPathData(editor.value); + if (!centeredD) return { error: "Path produced no visible output" }; + + return { path2d: new Path2D(centeredD) }; + } catch { + return { error: "Invalid SVG path syntax" }; + } +} + +// ── Parse matrix from user text ──────────────────────────────────── + +function parseMatrixInput(text: string): cmath.Transform | null { + const cleaned = text + .replace(/[\[\](){}]/g, " ") + .replace(/;/g, ",") + .replace(/\n/g, ","); + const nums = cleaned + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map(Number); + + if (nums.some(isNaN)) return null; + if (nums.length === 6) + return [ + [nums[0], nums[1], nums[2]], + [nums[3], nums[4], nums[5]], + ]; + if (nums.length === 9) + return [ + [nums[0], nums[1], nums[2]], + [nums[3], nums[4], nums[5]], + ]; + if (nums.length === 4) + return [ + [nums[0], nums[1], 0], + [nums[2], nums[3], 0], + ]; + return null; +} + +// ── Canvas Renderer (class) ──────────────────────────────────────── + +const GRID_STEP = 40; +const SHAPE_SIZE = 120; +const HANDLE_RADIUS = 7; +const BASIS_LEN = SHAPE_SIZE * 0.7; + +interface RenderState { + matrix: cmath.Transform; + shapeId: ShapeId; + shapePoints: cmath.Vector2[]; + svgPath: Path2D | null; + showGrid: boolean; + showOriginal: boolean; + showBasis: boolean; + isDark: boolean; +} + +type DragTarget = "origin" | "x" | "y"; +type DragCallback = (matrix: cmath.Transform) => void; + +class AffineCanvasRenderer { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private state: RenderState; + private onMatrixChange: DragCallback; + + // drag state (no React involvement) + private dragging: DragTarget | null = null; + private dragStartPos: cmath.Vector2 = [0, 0]; + private dragStartMatrix: cmath.Transform = cmath.transform.identity; + + constructor( + canvas: HTMLCanvasElement, + initialState: RenderState, + onMatrixChange: DragCallback + ) { + this.canvas = canvas; + this.ctx = canvas.getContext("2d")!; + this.state = initialState; + this.onMatrixChange = onMatrixChange; + + // bind events + this.canvas.addEventListener("mousedown", this.onMouseDown); + this.canvas.addEventListener("mousemove", this.onMouseMove); + this.canvas.addEventListener("mouseup", this.onMouseUp); + this.canvas.addEventListener("mouseleave", this.onMouseUp); + window.addEventListener("resize", this.draw); + + this.draw(); + } + + destroy() { + this.canvas.removeEventListener("mousedown", this.onMouseDown); + this.canvas.removeEventListener("mousemove", this.onMouseMove); + this.canvas.removeEventListener("mouseup", this.onMouseUp); + this.canvas.removeEventListener("mouseleave", this.onMouseUp); + window.removeEventListener("resize", this.draw); + } + + update(state: Partial) { + Object.assign(this.state, state); + this.draw(); + } + + // ── Drawing ── + + draw = () => { + const { canvas, ctx, state } = this; + const dpr = window.devicePixelRatio || 1; + const w = canvas.clientWidth; + const h = canvas.clientHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + const cx = w / 2, + cy = h / 2; + const { matrix, shapeId, shapePoints, svgPath, isDark } = state; + + if (state.showGrid) this.drawGrid(w, h, cx, cy, isDark); + + const isCircle = shapeId === "circle"; + const activeSvg = shapeId === "svg-path" ? svgPath : null; + + if (state.showOriginal) { + this.drawShape( + shapePoints, + cmath.transform.identity, + cx, + cy, + isCircle, + isDark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.03)", + isDark ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.12)", + 1, + 1, + activeSvg + ); + } + + this.drawShape( + shapePoints, + matrix, + cx, + cy, + isCircle, + isDark ? "rgba(139,92,246,0.15)" : "rgba(124,58,237,0.1)", + isDark ? "#a78bfa" : "#7c3aed", + 2, + 1, + activeSvg + ); + + if (state.showBasis) this.drawBasis(matrix, cx, cy, isDark); + this.drawHandles(matrix, cx, cy, isDark); + }; + + private drawGrid( + w: number, + h: number, + cx: number, + cy: number, + isDark: boolean + ) { + const { ctx } = this; + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)"; + ctx.lineWidth = 1; + for (let x = cx % GRID_STEP; x < w; x += GRID_STEP) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + for (let y = cy % GRID_STEP; y < h; y += GRID_STEP) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.15)"; + ctx.beginPath(); + ctx.moveTo(0, cy); + ctx.lineTo(w, cy); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(cx, 0); + ctx.lineTo(cx, h); + ctx.stroke(); + } + + private drawShape( + pts: cmath.Vector2[], + matrix: cmath.Transform, + cx: number, + cy: number, + isCircle: boolean, + fill: string, + stroke: string, + lineWidth: number, + alpha: number, + svgPath: Path2D | null + ) { + const { ctx } = this; + ctx.save(); + ctx.globalAlpha = alpha; + + if (svgPath) { + const avgScale = Math.sqrt(Math.abs(determinant(matrix))) || 1; + ctx.save(); + ctx.translate(cx, cy); + ctx.transform( + matrix[0][0], + matrix[1][0], + matrix[0][1], + matrix[1][1], + matrix[0][2], + matrix[1][2] + ); + ctx.fillStyle = fill; + ctx.fill(svgPath); + ctx.strokeStyle = stroke; + ctx.lineWidth = lineWidth / avgScale; + ctx.stroke(svgPath); + ctx.restore(); + } else if (isCircle) { + ctx.beginPath(); + const r = SHAPE_SIZE / 2; + for (let i = 0; i <= 64; i++) { + const t = (i / 64) * Math.PI * 2; + const px = + matrix[0][0] * Math.cos(t) * r + + matrix[0][1] * Math.sin(t) * r + + matrix[0][2]; + const py = + matrix[1][0] * Math.cos(t) * r + + matrix[1][1] * Math.sin(t) * r + + matrix[1][2]; + if (i === 0) ctx.moveTo(cx + px, cy + py); + else ctx.lineTo(cx + px, cy + py); + } + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + ctx.strokeStyle = stroke; + ctx.lineWidth = lineWidth; + ctx.stroke(); + } else { + const xf = pts.map((p) => cmath.vector2.transform(p, matrix)); + ctx.beginPath(); + xf.forEach((p, i) => { + if (i === 0) ctx.moveTo(cx + p[0], cy + p[1]); + else ctx.lineTo(cx + p[0], cy + p[1]); + }); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + ctx.strokeStyle = stroke; + ctx.lineWidth = lineWidth; + ctx.stroke(); + } + ctx.restore(); + } + + private drawArrowhead( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + angle: number, + size: number + ) { + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo( + x - size * Math.cos(angle - 0.4), + y - size * Math.sin(angle - 0.4) + ); + ctx.lineTo( + x - size * Math.cos(angle + 0.4), + y - size * Math.sin(angle + 0.4) + ); + ctx.closePath(); + ctx.fill(); + } + + private drawBasis( + matrix: cmath.Transform, + cx: number, + cy: number, + isDark: boolean + ) { + const { ctx } = this; + const o = cmath.vector2.transform([0, 0], matrix); + const xE = cmath.vector2.transform([BASIS_LEN, 0], matrix); + const yE = cmath.vector2.transform([0, BASIS_LEN], matrix); + + // X axis (red) + ctx.save(); + ctx.strokeStyle = ctx.fillStyle = isDark ? "#f87171" : "#dc2626"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cx + o[0], cy + o[1]); + ctx.lineTo(cx + xE[0], cy + xE[1]); + ctx.stroke(); + this.drawArrowhead( + ctx, + cx + xE[0], + cy + xE[1], + Math.atan2(xE[1] - o[1], xE[0] - o[0]), + 8 + ); + ctx.restore(); + + // Y axis (blue) + ctx.save(); + ctx.strokeStyle = ctx.fillStyle = isDark ? "#60a5fa" : "#2563eb"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cx + o[0], cy + o[1]); + ctx.lineTo(cx + yE[0], cy + yE[1]); + ctx.stroke(); + this.drawArrowhead( + ctx, + cx + yE[0], + cy + yE[1], + Math.atan2(yE[1] - o[1], yE[0] - o[0]), + 8 + ); + ctx.restore(); + + // Origin dot + ctx.save(); + ctx.fillStyle = isDark ? "#a3a3a3" : "#525252"; + ctx.beginPath(); + ctx.arc(cx + o[0], cy + o[1], 4, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + private drawHandles( + matrix: cmath.Transform, + cx: number, + cy: number, + isDark: boolean + ) { + const { ctx } = this; + const o = cmath.vector2.transform([0, 0], matrix); + const xH = cmath.vector2.transform([BASIS_LEN, 0], matrix); + const yH = cmath.vector2.transform([0, BASIS_LEN], matrix); + + const drawDot = ( + p: cmath.Vector2, + fillC: string, + strokeC: string, + r: number + ) => { + ctx.save(); + ctx.fillStyle = fillC; + ctx.strokeStyle = strokeC; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx + p[0], cy + p[1], r, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + }; + + drawDot( + o, + isDark ? "#e5e5e5" : "#404040", + isDark ? "#404040" : "#e5e5e5", + HANDLE_RADIUS + ); + drawDot( + xH, + isDark ? "#f87171" : "#dc2626", + isDark ? "#1f1f1f" : "#ffffff", + 6 + ); + drawDot( + yH, + isDark ? "#60a5fa" : "#2563eb", + isDark ? "#1f1f1f" : "#ffffff", + 6 + ); + } + + // ── Hit-testing & drag ── + + private canvasPos(e: MouseEvent): cmath.Vector2 { + const r = this.canvas.getBoundingClientRect(); + return [e.clientX - r.left - r.width / 2, e.clientY - r.top - r.height / 2]; + } + + private onMouseDown = (e: MouseEvent) => { + const pos = this.canvasPos(e); + const m = this.state.matrix; + const o = cmath.vector2.transform([0, 0], m); + const xH = cmath.vector2.transform([BASIS_LEN, 0], m); + const yH = cmath.vector2.transform([0, BASIS_LEN], m); + + const threshold = 14; + let target: DragTarget | null = null; + if (cmath.vector2.distance(pos, o) < threshold) target = "origin"; + else if (cmath.vector2.distance(pos, xH) < threshold) target = "x"; + else if (cmath.vector2.distance(pos, yH) < threshold) target = "y"; + + if (target) { + this.dragging = target; + this.dragStartPos = pos; + this.dragStartMatrix = [[...m[0]], [...m[1]]]; + this.canvas.style.cursor = "grabbing"; + } + }; + + private onMouseMove = (e: MouseEvent) => { + if (!this.dragging) return; + const pos = this.canvasPos(e); + const sm = this.dragStartMatrix; + + let newMatrix: cmath.Transform; + if (this.dragging === "origin") { + const dx = pos[0] - this.dragStartPos[0]; + const dy = pos[1] - this.dragStartPos[1]; + newMatrix = [ + [sm[0][0], sm[0][1], sm[0][2] + dx], + [sm[1][0], sm[1][1], sm[1][2] + dy], + ]; + } else { + const o = cmath.vector2.transform([0, 0], sm); + const dx = pos[0] - o[0]; + const dy = pos[1] - o[1]; + const s = BASIS_LEN; + if (this.dragging === "x") { + newMatrix = [ + [dx / s, sm[0][1], sm[0][2]], + [dy / s, sm[1][1], sm[1][2]], + ]; + } else { + newMatrix = [ + [sm[0][0], dx / s, sm[0][2]], + [sm[1][0], dy / s, sm[1][2]], + ]; + } + } + + this.state.matrix = newMatrix; + this.draw(); + this.onMatrixChange(newMatrix); + }; + + private onMouseUp = () => { + if (this.dragging) { + this.dragging = null; + this.canvas.style.cursor = ""; + } + }; + + get isDragging(): boolean { + return this.dragging !== null; + } +} + +// ── Presets ───────────────────────────────────────────────────────── + +type Preset = { name: string; params: TransformParams; description: string }; + +const PRESETS: Preset[] = [ + { + name: "Identity", + params: { ...DEFAULT_PARAMS }, + description: "No transformation", + }, + { + name: "Scale 2x", + params: { ...DEFAULT_PARAMS, scale: [2, 2] }, + description: "Uniform scale by 2", + }, + { + name: "Rotate 45\u00B0", + params: { ...DEFAULT_PARAMS, rotation: Math.PI / 4 }, + description: "Rotate 45 degrees CCW", + }, + { + name: "Shear X", + params: { ...DEFAULT_PARAMS, skewX: Math.atan(0.5) }, + description: "Horizontal shear", + }, + { + name: "Flip X", + params: { ...DEFAULT_PARAMS, scale: [-1, 1] }, + description: "Mirror horizontally", + }, + { + name: "Flip Y", + params: { ...DEFAULT_PARAMS, scale: [1, -1] }, + description: "Mirror vertically", + }, + { + name: "Squeeze", + params: { ...DEFAULT_PARAMS, scale: [1.5, 0.67] }, + description: "Stretch X, compress Y", + }, + { + name: "Rotate 90\u00B0", + params: { ...DEFAULT_PARAMS, rotation: Math.PI / 2 }, + description: "Rotate 90 degrees CCW", + }, +]; + +// ── UI helpers ───────────────────────────────────────────────────── + +function SliderRow(props: { + label: string; + value: number; + min: number; + max: number; + step: number; + onValueChange: (v: number) => void; + format?: (v: number) => string; +}) { + const id = useId(); + const display = props.format + ? props.format(props.value) + : String(props.value); + return ( +
+
+ + {props.label} + + + {display} + +
+ props.onValueChange(v)} + /> +
+ ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/** Read a flat 3x3 cell from a 2x3 Transform (implicit bottom row [0,0,1]) */ +function flatCell(m: cmath.Transform, row: number, col: number): number { + if (row < 2) return m[row][col]; + return col === 2 ? 1 : 0; +} + +// ── Main component ───────────────────────────────────────────────── + +export default function AffineTransformTool() { + const canvasRef = useRef(null); + const rendererRef = useRef(null); + + // Source of truth: decomposed params + const [params, setParams] = useState({ ...DEFAULT_PARAMS }); + // Derived matrix + const matrix = composeMatrix(params); + const det = determinant(matrix); + + const [shape, setShape] = useState("rect"); + const [showOriginal, setShowOriginal] = useState(true); + const [showGrid, setShowGrid] = useState(true); + const [showBasis, setShowBasis] = useState(true); + const [pasteText, setPasteText] = useState(""); + const [pasteError, setPasteError] = useState(""); + const [isDark, setIsDark] = useState(false); + + const displayPanelId = useId(); + const displayGridId = `${displayPanelId}-grid`; + const displayOriginalId = `${displayPanelId}-original`; + const displayBasisId = `${displayPanelId}-basis`; + + // SVG path state + const [svgPathInput, setSvgPathInput] = useState(DEFAULT_SVG_PATH); + const [svgPathError, setSvgPathError] = useState(""); + const [parsedSvgPath, setParsedSvgPath] = useState(null); + + useEffect(() => { + if (shape !== "svg-path") { + setParsedSvgPath(null); + return; + } + if (!svgPathInput.trim()) { + setParsedSvgPath(null); + setSvgPathError(""); + return; + } + const result = parseSvgPath(svgPathInput, SHAPE_SIZE); + if (result.path2d) { + setParsedSvgPath(result.path2d); + setSvgPathError(""); + } else { + setParsedSvgPath(null); + setSvgPathError(result.error); + } + }, [svgPathInput, shape]); + + // Detect dark mode + useEffect(() => { + const check = () => + setIsDark( + document.documentElement.classList.contains("dark") || + window.matchMedia("(prefers-color-scheme: dark)").matches + ); + check(); + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + mq.addEventListener("change", check); + const observer = new MutationObserver(check); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => { + mq.removeEventListener("change", check); + observer.disconnect(); + }; + }, []); + + // Callback when the canvas renderer drags a handle → decompose once into params + const onCanvasDrag = (m: cmath.Transform) => { + setParams(decomposeMatrix(m)); + }; + + // Init / destroy renderer + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const shapePoints = getShapePoints(shape, SHAPE_SIZE); + const renderer = new AffineCanvasRenderer( + canvas, + { + matrix, + shapeId: shape, + shapePoints, + svgPath: parsedSvgPath, + showGrid, + showOriginal, + showBasis, + isDark, + }, + onCanvasDrag + ); + rendererRef.current = renderer; + return () => { + renderer.destroy(); + rendererRef.current = null; + }; + // Only recreate on canvas mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Sync state changes into the renderer + useEffect(() => { + rendererRef.current?.update({ + matrix, + shapeId: shape, + shapePoints: getShapePoints(shape, SHAPE_SIZE), + svgPath: parsedSvgPath, + showGrid, + showOriginal, + showBasis, + isDark, + }); + }, [matrix, shape, parsedSvgPath, showGrid, showOriginal, showBasis, isDark]); + + // ── Actions ── + + const handlePaste = (text: string) => { + setPasteText(text); + if (!text.trim()) { + setPasteError(""); + return; + } + const parsed = parseMatrixInput(text); + if (parsed) { + setParams(decomposeMatrix(parsed)); + setPasteError(""); + } else setPasteError("Could not parse. Expected 4, 6, or 9 numbers."); + }; + + const copyMatrix = () => { + const rows = [ + `[${fmtNum(matrix[0][0])}, ${fmtNum(matrix[0][1])}, ${fmtNum(matrix[0][2])}]`, + `[${fmtNum(matrix[1][0])}, ${fmtNum(matrix[1][1])}, ${fmtNum(matrix[1][2])}]`, + `[0, 0, 1]`, + ]; + navigator.clipboard.writeText(`[${rows.join(",\n ")}]`); + }; + + const reset = () => { + setParams({ ...DEFAULT_PARAMS }); + setPasteText(""); + setPasteError(""); + }; + + const applyPreset = (p: Preset) => { + setParams({ ...p.params }); + setPasteText(""); + setPasteError(""); + }; + + // Direct param setters — no decompose round-trip + const setTranslateX = (v: number) => + setParams((p) => ({ ...p, translate: [v, p.translate[1]] })); + const setTranslateY = (v: number) => + setParams((p) => ({ ...p, translate: [p.translate[0], v] })); + const setRotationDeg = (v: number) => + setParams((p) => ({ ...p, rotation: (v * Math.PI) / 180 })); + const setScaleX = (v: number) => + setParams((p) => ({ ...p, scale: [v, p.scale[1]] })); + const setScaleY = (v: number) => + setParams((p) => ({ ...p, scale: [p.scale[0], v] })); + const setSkewXDeg = (v: number) => + setParams((p) => ({ ...p, skewX: (v * Math.PI) / 180 })); + + return ( + +
+ {/* ── Left: Controls ── */} +