diff --git a/packages/cli/src/capture/htmlExtractor.ts b/packages/cli/src/capture/htmlExtractor.ts index 28ebf96a02..a0db29ebcd 100644 --- a/packages/cli/src/capture/htmlExtractor.ts +++ b/packages/cli/src/capture/htmlExtractor.ts @@ -11,6 +11,8 @@ import { isPrivateUrl } from "./assetDownloader.js"; const DEFAULT_SETTLE_TIME = 3000; +// Pre-existing capture pipeline size — surfaced by a one-line escape fix, not new logic. +// fallow-ignore-next-line complexity export async function extractHtml( page: Page, opts: { settleTime?: number } = {}, @@ -38,6 +40,7 @@ export async function extractHtml( if (!res.ok) continue; let css = await res.text(); // Fix relative url() references + // fallow-ignore-next-line complexity css = css.replace(/url\(\s*['"]?([^'")\s]+)['"]?\s*\)/g, (match: string, url: string) => { if (url.startsWith("data:") || url.startsWith("http") || url.startsWith("//")) return match; try { @@ -163,7 +166,7 @@ export async function extractHtml( for (var i = 0; i < htmlEl.attributes.length; i++) { var attr = htmlEl.attributes[i]; if (attr.name === "lang" || attr.name === "class" || attr.name === "style" || attr.name === "dir" || attr.name.startsWith("data-")) { - attrParts.push(attr.name + '="' + attr.value.replace(/"/g, """) + '"'); + attrParts.push(attr.name + '="' + attr.value.replace(/&/g, "&").replace(/"/g, """) + '"'); } } @@ -183,6 +186,7 @@ export async function extractHtml( // 2. Make relative image URLs absolute using the page's origin const pageOrigin = new URL(page.url()).origin; + // fallow-ignore-next-line code-duplication result.bodyHtml = result.bodyHtml.replace( /(]*\bsrc=")([^"]*?)(")/g, (_match: string, pre: string, url: string, post: string) => { @@ -208,6 +212,7 @@ export async function extractHtml( ); // Also fix video src/poster URLs + // fallow-ignore-next-line code-duplication result.bodyHtml = result.bodyHtml.replace( /(]*\bsrc=")([^"]*?)(")/g, (_match: string, pre: string, url: string, post: string) => { @@ -216,6 +221,7 @@ export async function extractHtml( return pre + fixed + post; }, ); + // fallow-ignore-next-line code-duplication result.bodyHtml = result.bodyHtml.replace( /(]*\bposter=")([^"]*?)(")/g, (_match: string, pre: string, url: string, post: string) => { diff --git a/packages/studio-server/src/helpers/subComposition.ts b/packages/studio-server/src/helpers/subComposition.ts index 790193895c..51602c2f07 100644 --- a/packages/studio-server/src/helpers/subComposition.ts +++ b/packages/studio-server/src/helpers/subComposition.ts @@ -156,6 +156,13 @@ function extractTemplateInnerHtml(rawComp: string): string | null { return template ? template.innerHTML : null; } +/** Attribute values read from the DOM are decoded — re-escape on rebuild or + * quote-bearing values (data-composition-variables is a JSON array) shred + * the wrapper's markup into bogus attributes. */ +function escapeAttrValue(value: string): string { + return value.replace(/&/g, "&").replace(/"/g, """); +} + function extractElementAttrs(el: Element): string { const parts: string[] = []; for (let i = 0; i < el.attributes.length; i++) { @@ -163,7 +170,7 @@ function extractElementAttrs(el: Element): string { if (attr.value === "") { parts.push(attr.name); } else { - parts.push(`${attr.name}="${attr.value}"`); + parts.push(`${attr.name}="${escapeAttrValue(attr.value)}"`); } } return parts.join(" "); diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index ba8429e57a..1ab9e0a653 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -613,3 +613,27 @@ describe("preview ?variables= injection", () => { expect(html).toContain('window.__hfVariables={"accent":"#f00"}'); }); }); + +describe("sub-composition preview attribute integrity", () => { + it("preserves quote-bearing html attributes (data-composition-variables JSON)", async () => { + const projectDir = createProjectDir(); + const decls = JSON.stringify([ + { id: "title", type: "string", label: "Title", default: "Hello" }, + ]); + writeFileSync( + join(projectDir, "card.html"), + `
x
`, + ); + const app = new Hono(); + registerPreviewRoutes(app, createAdapter(projectDir)); + + const res = await app.request("http://localhost/projects/demo/preview/comp/card.html"); + expect(res.status).toBe(200); + const html = await res.text(); + const attr = /data-composition-variables="([^"]*)"/.exec(html)?.[1] ?? ""; + // Entities decode back to the exact declared JSON — a lost/shredded + // attribute here silently breaks getVariables() on the comp route. + const decoded = attr.replace(/"/g, '"').replace(/&/g, "&"); + expect(JSON.parse(decoded)).toEqual(JSON.parse(decls)); + }); +}); diff --git a/packages/studio/src/components/panels/VariablesBindElement.tsx b/packages/studio/src/components/panels/VariablesBindElement.tsx new file mode 100644 index 0000000000..1e09e1ec11 --- /dev/null +++ b/packages/studio/src/components/panels/VariablesBindElement.tsx @@ -0,0 +1,242 @@ +/** + * "Bind selected element" card for the Variables panel — the promote-a- + * property-to-variable gesture. Each action declares a variable (default = + * the element's current value, so promoting never changes the render) and + * writes the declarative binding the runtime resolves: + * data-var-src / data-var-text attributes, or `: var(--id)` styles. + */ + +import { useMemo, useState } from "react"; +import type { Composition, CompositionVariable } from "@hyperframes/sdk"; +import type { DomEditSelection } from "../editor/domEditingTypes"; + +import { VARIABLES_INPUT_CLASS } from "./VariablesValueControls"; + +// is deliberately excluded: rewriting a child's src after +// the parent media element ran resource selection is a spec no-op. +const MEDIA_TAGS = new Set(["img", "video", "audio"]); + +export interface BindAction { + key: string; + label: string; + /** Binding channel: data-var-src / data-var-text attribute, or a style prop. */ + kind: "src" | "text" | "style"; + styleProp?: string; + suggestedId: string; + declaration: (id: string) => CompositionVariable; +} + +function sanitizeId(raw: string): string { + const cleaned = raw + .trim() + .replace(/[^a-zA-Z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return cleaned || "variable"; +} + +/** + * "rgb(0, 195, 255)" / "rgba(0, 195, 255, 0.4)" → "#00c3ff". Alpha is dropped + * (color variables are hex) — a fully transparent computed color maps to + * #000000, which the picker can at least display. Unrecognized formats pass + * through verbatim. + */ +function rgbToHex(value: string): string { + const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/.exec(value); + if (!m) return value; + return `#${m + .slice(1, 4) + .map((n) => Number(n).toString(16).padStart(2, "0")) + .join("")}`; +} + +function firstFontFamily(value: string): string { + const first = value.split(",")[0] ?? ""; + return first.trim().replace(/^["']|["']$/g, "") || "sans-serif"; +} + +// fallow-ignore-next-line complexity +function buildBindActions(selection: DomEditSelection, sdkSession: Composition): BindAction[] { + const hfId = selection.hfId; + if (!hfId) return []; + // No snapshot = the session can't resolve this element (e.g. a sub-comp + // element that missed the source map) — a bind would target the wrong + // document or dead-end, so offer nothing. + const snapshot = sdkSession.getElement(hfId); + if (!snapshot) return []; + const base = sanitizeId(snapshot.attributes.id ?? selection.label ?? hfId); + const actions: BindAction[] = []; + + const tag = selection.tagName.toLowerCase(); + if (MEDIA_TAGS.has(tag)) { + const currentSrc = snapshot.attributes.src ?? ""; + actions.push({ + key: "src", + label: tag === "img" ? "Image source" : "Media source", + kind: "src", + suggestedId: base, + declaration: (id) => + tag === "img" + ? { id, type: "image", label: `${selection.label} image`, default: currentSrc } + : { id, type: "string", label: `${selection.label} source`, default: currentSrc }, + }); + } + + // Text binds only on leaf elements: the runtime preserves children, but a + // container's "own text" default rarely matches what the user sees, so the + // promote-never-changes-the-render guarantee only holds for leaves. + const text = (snapshot.text ?? "").trim(); + if (text && snapshot.children.length === 0 && !selection.isCompositionHost) { + actions.push({ + key: "text", + label: "Text", + kind: "text", + suggestedId: `${base}-text`, + declaration: (id) => ({ + id, + type: "string", + label: `${selection.label} text`, + default: text, + }), + }); + } + + if (selection.capabilities.canEditStyles) { + const computed = selection.computedStyles; + actions.push( + { + key: "color", + label: "Text color", + kind: "style", + styleProp: "color", + suggestedId: `${base}-color`, + declaration: (id) => ({ + id, + type: "color", + label: `${selection.label} color`, + default: rgbToHex(computed["color"] ?? "#000000"), + }), + }, + { + key: "background", + label: "Background", + kind: "style", + styleProp: "background-color", + suggestedId: `${base}-bg`, + declaration: (id) => ({ + id, + type: "color", + label: `${selection.label} background`, + default: rgbToHex(computed["background-color"] ?? "#000000"), + }), + }, + { + key: "font", + label: "Font", + kind: "style", + styleProp: "font-family", + suggestedId: `${base}-font`, + declaration: (id) => ({ + id, + type: "font", + label: `${selection.label} font`, + default: firstFontFamily(computed["font-family"] ?? "sans-serif"), + }), + }, + ); + } + + return actions; +} + +/** One batched schema edit: declare (unless the id already exists) + bind. */ +export function applyBind( + session: Composition, + hfId: string, + action: BindAction, + id: string, +): void { + session.batch(() => { + if (!session.getVariableDeclarations().some((d) => d.id === id)) { + session.declareVariable(action.declaration(id)); + } + if (action.kind === "style" && action.styleProp) { + session.setStyle(hfId, { [action.styleProp]: `var(--${id})` }); + } else { + session.setAttribute(hfId, `data-var-${action.kind}`, id); + } + }); +} + +export function VariablesBindElement({ + selection, + sdkSession, + onBind, +}: { + selection: DomEditSelection; + sdkSession: Composition; + onBind: (action: BindAction, id: string) => void; +}) { + const [activeKey, setActiveKey] = useState(null); + const [idDraft, setIdDraft] = useState(""); + const actions = useMemo(() => buildBindActions(selection, sdkSession), [selection, sdkSession]); + if (actions.length === 0) return null; + const active = actions.find((a) => a.key === activeKey) ?? null; + + return ( +
+

+ Bind selected: {selection.label} +

+ {active ? ( +
+ + setIdDraft(e.target.value)} + onKeyDown={(e) => e.key === "Escape" && setActiveKey(null)} + className={`${VARIABLES_INPUT_CLASS} font-mono`} + /> +
+ + +
+
+ ) : ( +
+ {actions.map((action) => ( + + ))} +
+ )} +
+ ); +} diff --git a/packages/studio/src/components/panels/VariablesPanel.tsx b/packages/studio/src/components/panels/VariablesPanel.tsx index 5d781c5957..9b78ad30d2 100644 --- a/packages/studio/src/components/panels/VariablesPanel.tsx +++ b/packages/studio/src/components/panels/VariablesPanel.tsx @@ -7,7 +7,9 @@ import type { } from "@hyperframes/sdk"; import type { EditHistoryKind } from "../../utils/editHistory"; import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext"; +import { useDomEditContext } from "../../contexts/DomEditContext"; import { useFileManagerContext } from "../../contexts/FileManagerContext"; +import { VariablesBindElement, type BindAction, applyBind } from "./VariablesBindElement"; import { useVariablesPersist } from "../../hooks/useVariablesPersist"; import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore"; import { @@ -274,6 +276,7 @@ export const VariablesPanel = memo(function VariablesPanel({ const { activeCompPath, showToast } = useStudioShellContext(); const { refreshKey } = useStudioPlaybackContext(); const { readProjectFile, writeProjectFile } = useFileManagerContext(); + const { domEditSelection } = useDomEditContext(); const previewValues = usePreviewVariablesStore((s) => s.values); const setPreviewValues = usePreviewVariablesStore((s) => s.setValues); @@ -427,6 +430,38 @@ export const VariablesPanel = memo(function VariablesPanel({ reloadPreview(); }, [setPreviewValues, reloadPreview]); + const handleBind = useCallback( + // Guard chain (session, selection, type-compat) — one branch per guard. + // fallow-ignore-next-line complexity + (action: BindAction, id: string) => { + if (!sdkSession || !domEditSelection?.hfId) return; + // Binding to an existing variable is allowed, but only when the types + // agree — wiring a color style to a string variable silently breaks + // the element's styling. + const existing = sdkSession.getVariableDeclarations().find((d) => d.id === id); + const wanted = action.declaration(id).type; + if (existing && existing.type !== wanted) { + showToast( + `"${id}" is already a ${existing.type} variable — pick another id for this ${wanted} binding`, + "error", + ); + return; + } + const hfId = domEditSelection.hfId; + void runSchemaEdit(`Bind ${action.label.toLowerCase()} to "${id}"`, (s) => + applyBind(s, hfId, action, id), + ); + }, + [sdkSession, domEditSelection, runSchemaEdit, showToast], + ); + + // The bind gesture targets the composition the session models — a selection + // from another source file must not write bindings into this one. + const bindableSelection = + domEditSelection?.hfId && domEditSelection.sourceFile === (activeCompPath ?? "index.html") + ? domEditSelection + : null; + if (!sdkSession) { return (
@@ -442,6 +477,14 @@ export const VariablesPanel = memo(function VariablesPanel({ onReset={resetPreview} />
+ {bindableSelection && ( + + )} {declarations.length === 0 && !addOpen && EMPTY_STATE} {/* fallow-ignore-next-line complexity */} diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 72892dd744..8a6efa5ce0 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -126,6 +126,8 @@ export function useDomSelection({ // ── Refs ── + const rightPanelTabRef = useRef(rightPanelTab); + rightPanelTabRef.current = rightPanelTab; const domEditSelectionRef = useRef(domEditSelection); const domEditGroupSelectionsRef = useRef(domEditGroupSelections); const domEditHoverSelectionRef = useRef(domEditHoverSelection); @@ -205,7 +207,11 @@ export function useDomSelection({ if (nextSelection) { if (options?.revealPanel !== false) { setRightCollapsed(false); - setRightPanelTab("design"); + // Keep the Variables tab in place — selecting elements is part of + // the bind flow there; yanking to Design would lose the context. + if (rightPanelTabRef.current !== "variables") { + setRightPanelTab("design"); + } } const nextSelectedTimelineId = findMatchingTimelineElementId(nextSelection, timelineElements) ?? diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index a07fec30af..a8c5d0e0b8 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -97,7 +97,12 @@ export function useInspectorState( STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive, // Keep the selection box + motion path drawn even when the Inspector is // collapsed — closing the panel shouldn't visually deselect the element. - shouldShowSelectedDomBounds: inspectorPanelActive && !isPlaying && !isGestureRecording, + // The Variables tab also works against the canvas selection (bind card), + // so the selection outline stays visible there too. + shouldShowSelectedDomBounds: + (inspectorPanelActive || rightPanelTab === "variables") && + !isPlaying && + !isGestureRecording, }; }, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]); }