/** * Code-backed GLSL shader fills + effects — canonical persisted format. * * Unlike the preset-only `shader-presets.ts` catalog (which approximates * shaders as CSS gradients when persisting), this module defines a fully * code-backed representation: the GLSL fragment source lives IN the screen * HTML, readable and editable in the Code panel, and a small self-contained * WebGL runtime (embedded in the same HTML) renders it live — in the editor, * in shared links, and in exported standalone HTML. * * ── Persisted format (v1) ──────────────────────────────────────────────── * * 1. One definition block per shader, anywhere in the document (by * convention just before ``): * * * * The uniforms manifest rides inside a leading GLSL block comment — valid * GLSL, so the whole block body is directly compilable, and the Code panel * shows one readable, self-documenting artifact. * * 2. Elements reference a shader by id: * data-an-shader-fill="an-shader-x1y2z3" (canvas behind content) * data-an-shader-effect="an-shader-x1y2z3" (overlay above content) * Optional per-element uniform overrides (Figma's paint-instance * properties): data-an-shader-uniforms='{"u_speed":2}' * For fills, the element's inline `background` holds a static fallback * color so the artifact still renders without JS/WebGL. * * 3. The runtime is embedded once per document: * " ); } const SHADER_BLOCK_RE = new RegExp( ']*type\\s*=\\s*"' + escapeRegExp(SHADER_SCRIPT_TYPE) + '"[^>]*)>([\\s\\S]*?)<\\/script\\s*>', "gi", ); function readAttrFrom(attrs: string, name: string): string | null { const match = new RegExp(name + '\\s*=\\s*"([^"]*)"', "i").exec(attrs); return match ? unescapeAttr(match[1]) : null; } /** Parse every shader definition block out of a screen HTML document. */ export function listShadersInHtml(html: string): GlslShaderDef[] { const defs: GlslShaderDef[] = []; if (typeof html !== "string" || html.length === 0) return defs; SHADER_BLOCK_RE.lastIndex = 0; let match: RegExpExecArray | null; while ((match = SHADER_BLOCK_RE.exec(html))) { const attrs = match[1]; const id = readAttrFrom(attrs, "data-shader-id"); if (!id || !ID_RE.test(id)) continue; const body = parseShaderBlockBody(match[2]); defs.push({ id, name: readAttrFrom(attrs, "data-shader-name") || id, mode: readAttrFrom(attrs, "data-shader-mode") === "effect" ? "effect" : "fill", glsl: body.glsl, uniforms: body.uniforms, }); } return defs; } /** Find one shader definition by id. */ export function getShaderFromHtml( html: string, id: string, ): GlslShaderDef | undefined { return listShadersInHtml(html).find((def) => def.id === id); } function findShaderBlockSpan( html: string, id: string, ): { start: number; end: number } | null { SHADER_BLOCK_RE.lastIndex = 0; let match: RegExpExecArray | null; while ((match = SHADER_BLOCK_RE.exec(html))) { if (readAttrFrom(match[1], "data-shader-id") === id) { return { start: match.index, end: match.index + match[0].length }; } } return null; } /** Insert or replace a shader definition block in the document. */ export function upsertShaderInHtml(html: string, def: GlslShaderDef): string { const block = serializeShaderScriptBlock(def); const existing = findShaderBlockSpan(html, def.id); if (existing) { return html.slice(0, existing.start) + block + html.slice(existing.end); } const bodyClose = html.toLowerCase().lastIndexOf(""); if (bodyClose !== -1) { return html.slice(0, bodyClose) + block + "\n" + html.slice(bodyClose); } const htmlClose = html.toLowerCase().lastIndexOf(""); if (htmlClose !== -1) { return html.slice(0, htmlClose) + block + "\n" + html.slice(htmlClose); } return html + "\n" + block + "\n"; } /** Remove a shader definition block (references are left untouched). */ export function removeShaderFromHtml(html: string, id: string): string { const span = findShaderBlockSpan(html, id); if (!span) return html; let start = span.start; // Also consume the preceding line's indentation/newline for tidiness. while (start > 0 && (html[start - 1] === " " || html[start - 1] === "\t")) { start--; } let end = span.end; if (html[end] === "\r") end++; if (html[end] === "\n") end++; return html.slice(0, start) + html.slice(end); } // --------------------------------------------------------------------------- // Runtime embedding // --------------------------------------------------------------------------- /** The compiled, self-contained WebGL runtime (see shader-runtime.bridge.ts). */ export const SHADER_RUNTIME_SOURCE: string = shaderRuntimeBridgeScript; export function buildShaderRuntimeScriptTag(): string { return ( `" ); } const RUNTIME_BLOCK_RE = new RegExp( "]*>[\\s\\S]*?<\\/script\\s*>", "i", ); /** * Ensure the document embeds the current shader runtime exactly once, * upgrading any previously-embedded copy in place. Idempotent. */ export function ensureShaderRuntime(html: string): string { const tag = buildShaderRuntimeScriptTag(); const existing = RUNTIME_BLOCK_RE.exec(html); if (existing) { if (existing[0] === tag) return html; return ( html.slice(0, existing.index) + tag + html.slice(existing.index + existing[0].length) ); } const bodyClose = html.toLowerCase().lastIndexOf(""); if (bodyClose !== -1) { return html.slice(0, bodyClose) + tag + "\n" + html.slice(bodyClose); } const htmlClose = html.toLowerCase().lastIndexOf(""); if (htmlClose !== -1) { return html.slice(0, htmlClose) + tag + "\n" + html.slice(htmlClose); } return html + "\n" + tag + "\n"; } /** True when the document has no shader references left. */ export function htmlHasShaderReferences(html: string): boolean { return ( html.includes(SHADER_FILL_ATTR + '="') || html.includes(SHADER_EFFECT_ATTR + '="') ); } /** Remove definition blocks that no element references any more. */ export function pruneUnusedShaders(html: string): string { let out = html; for (const def of listShadersInHtml(html)) { const fillRef = `${SHADER_FILL_ATTR}="${escapeAttr(def.id)}"`; const effectRef = `${SHADER_EFFECT_ATTR}="${escapeAttr(def.id)}"`; if (!out.includes(fillRef) && !out.includes(effectRef)) { out = removeShaderFromHtml(out, def.id); } } return out; } // --------------------------------------------------------------------------- // Element annotation (pure string transforms, no DOM) // --------------------------------------------------------------------------- /** * Locate the bounds of the open tag containing the given index. Scans * backward for "<" and forward for ">" while respecting quoted attribute * values. Returns null when the index isn't inside a plausible tag. */ function findTagBounds( html: string, indexInsideTag: number, ): { start: number; end: number } | null { let start = -1; for (let i = indexInsideTag; i >= 0; i--) { const ch = html[i]; if (ch === "<") { start = i; break; } if (ch === ">") return null; } if (start === -1) return null; if (!/[a-zA-Z]/.test(html[start + 1] ?? "")) return null; let quote: string | null = null; for (let i = start + 1; i < html.length; i++) { const ch = html[i]; if (quote) { if (ch === quote) quote = null; continue; } if (ch === '"' || ch === "'") { quote = ch; continue; } if (ch === ">") return { start, end: i + 1 }; } return null; } function tagNameOf(tagText: string): string { const match = /^<\s*([a-zA-Z][a-zA-Z0-9-]*)/.exec(tagText); return match ? match[1].toLowerCase() : ""; } function removeAttr(tagText: string, name: string): string { return tagText.replace( new RegExp("\\s+" + name + "\\s*=\\s*(\"[^\"]*\"|'[^']*'|[^\\s/>]+)", "gi"), "", ); } /** Upsert one declaration inside an inline style attribute. */ function upsertStyleProperty( tagText: string, property: string, value: string, ): string { const styleRe = /(\sstyle\s*=\s*")([^"]*)(")/i; const styleMatch = styleRe.exec(tagText); const decl = `${property}: ${value}`; if (!styleMatch) { const insertAt = tagText.endsWith("/>") ? tagText.length - 2 : tagText.length - 1; return ( tagText.slice(0, insertAt) + ` style="${escapeAttr(decl)}"` + tagText.slice(insertAt) ); } const existing = unescapeAttr(styleMatch[2]); const kept = existing .split(";") .map((part) => part.trim()) .filter( (part) => part.length > 0 && !new RegExp("^" + escapeRegExp(property) + "\\s*:", "i").test(part), ); kept.push(decl); const next = kept.join("; "); return ( tagText.slice(0, styleMatch.index) + styleMatch[1] + escapeAttr(next) + styleMatch[3] + tagText.slice(styleMatch.index + styleMatch[0].length) ); } function locateNodeTag( html: string, nodeId: string, ): { start: number; end: number; tagText: string } | null { const marker = new RegExp( 'data-agent-native-node-id\\s*=\\s*"' + escapeRegExp(nodeId) + '"', ).exec(html); if (!marker) return null; const bounds = findTagBounds(html, marker.index); if (!bounds) return null; return { start: bounds.start, end: bounds.end, tagText: html.slice(bounds.start, bounds.end), }; } export interface AnnotateShaderNodeOptions { nodeId: string; shaderId: string; mode: GlslShaderMode; /** Per-element uniform overrides serialized to data-an-shader-uniforms. */ values?: Record; /** * Static fallback background written to the element's inline style * (fill mode only). Unsafe values are neutralized to #808080. When * omitted, the element's existing background is left untouched — pass it * on the initial apply, omit it for knob-value-only updates. */ fallbackColor?: string; } export interface HtmlTransformResult { html: string; changed: boolean; errors: string[]; } /** * Point an element (located by its stable data-agent-native-node-id) at a * shader definition. Replaces any previous shader annotation OF THE SAME * MODE on the node — a fill and an effect can coexist on one element, * mirroring Figma's paint/effect stacking. */ export function annotateNodeWithShader( html: string, options: AnnotateShaderNodeOptions, ): HtmlTransformResult { const errors: string[] = []; if (!ID_RE.test(options.shaderId)) { return { html, changed: false, errors: ["invalid shader id"] }; } const located = locateNodeTag(html, options.nodeId); if (!located) { return { html, changed: false, errors: [ `no element with data-agent-native-node-id="${options.nodeId}" found`, ], }; } const tagName = tagNameOf(located.tagText); if (VOID_TAGS.has(tagName)) { return { html, changed: false, errors: [ `<${tagName}> cannot host a shader canvas — wrap it in a container ` + "element and apply the shader there", ], }; } const isEffect = options.mode === "effect"; const refAttr = isEffect ? SHADER_EFFECT_ATTR : SHADER_FILL_ATTR; const valuesAttr = isEffect ? SHADER_EFFECT_UNIFORMS_ATTR : SHADER_UNIFORMS_ATTR; let tagText = located.tagText; tagText = removeAttr(tagText, refAttr); tagText = removeAttr(tagText, valuesAttr); let insertion = ` ${refAttr}="${escapeAttr(options.shaderId)}"`; if (options.values && Object.keys(options.values).length > 0) { const json = JSON.stringify(options.values); insertion += ` ${valuesAttr}="${escapeAttr(json)}"`; } const selfClosing = tagText.endsWith("/>"); const insertAt = selfClosing ? tagText.length - 2 : tagText.length - 1; tagText = tagText.slice(0, insertAt) + insertion + tagText.slice(insertAt); if (options.mode === "fill" && options.fallbackColor !== undefined) { tagText = upsertStyleProperty( tagText, "background", sanitizeShaderFallbackColor(options.fallbackColor), ); } const nextHtml = html.slice(0, located.start) + tagText + html.slice(located.end); return { html: nextHtml, changed: nextHtml !== html, errors }; } /** * Remove shader annotations from an element (fallback background stays). * Pass `mode` to clear only the fill or only the effect; omit to clear both. */ export function clearNodeShader( html: string, nodeId: string, mode?: GlslShaderMode, ): HtmlTransformResult { const located = locateNodeTag(html, nodeId); if (!located) { return { html, changed: false, errors: [`no element with data-agent-native-node-id="${nodeId}" found`], }; } let tagText = located.tagText; if (mode !== "effect") { tagText = removeAttr(tagText, SHADER_FILL_ATTR); tagText = removeAttr(tagText, SHADER_UNIFORMS_ATTR); } if (mode !== "fill") { tagText = removeAttr(tagText, SHADER_EFFECT_ATTR); tagText = removeAttr(tagText, SHADER_EFFECT_UNIFORMS_ATTR); } const nextHtml = html.slice(0, located.start) + tagText + html.slice(located.end); return { html: nextHtml, changed: nextHtml !== html, errors: [] }; } /** Parse every element ↔ shader reference out of the document. */ export function listShaderMounts(html: string): GlslShaderMountRef[] { const mounts: GlslShaderMountRef[] = []; if (typeof html !== "string" || html.length === 0) return mounts; const refRe = new RegExp( "\\s(" + SHADER_FILL_ATTR + "|" + SHADER_EFFECT_ATTR + ')\\s*=\\s*"([^"]*)"', "g", ); let match: RegExpExecArray | null; while ((match = refRe.exec(html))) { const bounds = findTagBounds(html, match.index + 1); if (!bounds) continue; const tagText = html.slice(bounds.start, bounds.end); const shaderId = unescapeAttr(match[2]); if (!ID_RE.test(shaderId)) continue; const mode: GlslShaderMode = match[1] === SHADER_EFFECT_ATTR ? "effect" : "fill"; const valuesRaw = readAttrFrom( tagText, mode === "effect" ? SHADER_EFFECT_UNIFORMS_ATTR : SHADER_UNIFORMS_ATTR, ); let values: Record | undefined; if (valuesRaw) { try { const parsed = JSON.parse(valuesRaw) as Record< string, GlslUniformValue >; if (parsed && typeof parsed === "object") values = parsed; } catch { values = undefined; } } mounts.push({ nodeId: readAttrFrom(tagText, "data-agent-native-node-id"), shaderId, mode, values, }); } return mounts; } // --------------------------------------------------------------------------- // High-level apply (the one-call transform the inspector + agent use) // --------------------------------------------------------------------------- export interface ApplyShaderOptions { nodeId: string; def: GlslShaderDef; /** Per-element uniform value overrides. */ values?: Record; /** Fill-mode static fallback background color. */ fallbackColor?: string; } /** * The complete persistence transform: upsert the definition block, embed or * upgrade the runtime, and annotate the target element. Pure string → string; * callers persist the result through their normal write path * (apply-source-edit / edit-design / collab). */ export function applyShaderToHtml( html: string, options: ApplyShaderOptions, ): HtmlTransformResult { const validation = validateShaderDef(options.def); if (!validation.valid) { return { html, changed: false, errors: validation.errors }; } let next = upsertShaderInHtml(html, options.def); next = ensureShaderRuntime(next); const annotated = annotateNodeWithShader(next, { nodeId: options.nodeId, shaderId: options.def.id, mode: options.def.mode, values: options.values, fallbackColor: options.fallbackColor, }); if (annotated.errors.length > 0) { return { html, changed: false, errors: annotated.errors }; } return { html: annotated.html, changed: annotated.html !== html, errors: [], }; } /** * Remove a shader from an element and garbage-collect any now-unreferenced * definition blocks (the runtime tag stays — it is inert without mounts). * Pass `mode` to remove only the fill or only the effect; omit for both. */ export function removeShaderFromNode( html: string, nodeId: string, mode?: GlslShaderMode, ): HtmlTransformResult { const cleared = clearNodeShader(html, nodeId, mode); if (cleared.errors.length > 0) return cleared; const pruned = pruneUnusedShaders(cleared.html); return { html: pruned, changed: pruned !== html, errors: [] }; } /** Default uniform values (manifest values) for fresh knob state. */ export function defaultUniformValues( def: GlslShaderDef, ): Record { const out: Record = {}; for (const [name, u] of Object.entries(def.uniforms)) { out[name] = u.value; } return out; }