diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index baa3443389..c254516b27 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -318,8 +318,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { app.get("/api/events", (c) => { return streamSSE(c, async (stream) => { - const listener = () => { - stream.writeSSE({ event: "file-change", data: "{}" }).catch(() => {}); + const listener = (path: string) => { + stream.writeSSE({ event: "file-change", data: JSON.stringify({ path }) }).catch(() => {}); }; watcher.addListener(listener); while (true) { diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index e6a24ddb0c..527c3a4828 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -255,9 +255,11 @@ describe("bundleToSingleHtml", () => { const host = document.querySelector("#scene-host"); expect(host?.getAttribute("data-composition-id")).toBe("scene"); + expect(host?.getAttribute("data-composition-file")).toBe("compositions/scene.html"); expect(host?.getAttribute("data-start")).toBe("intro"); expect(host?.getAttribute("data-width")).toBe("1920"); expect(host?.querySelector(".title")?.textContent).toBe("Scene"); + expect(host?.querySelector(".title")?.closest("[data-composition-file]")).toBe(host); expect( Array.from(host?.children ?? []).some( (child) => child.getAttribute("data-composition-id") === "scene", diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index d37048aaa5..c374672eb5 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -522,12 +522,14 @@ export async function bundleToSingleHtml( const innerH = innerRoot.getAttribute("data-height"); if (innerW && !hostEl.getAttribute("data-width")) hostEl.setAttribute("data-width", innerW); if (innerH && !hostEl.getAttribute("data-height")) hostEl.setAttribute("data-height", innerH); + innerRoot.setAttribute("data-composition-file", src); for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove(); hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || ""; } else { for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove(); hostEl.innerHTML = contentDoc.body.innerHTML || ""; } + hostEl.setAttribute("data-composition-file", src); hostEl.removeAttribute("data-composition-src"); } diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index d7daa58c1f..ca89518a22 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -668,6 +668,7 @@ describe("template-wrapped sub-composition media offsets", () => { const host = document.querySelector("#scene-host"); expect(host?.getAttribute("data-composition-id")).toBe("scene"); + expect(host?.getAttribute("data-composition-file")).toBe("compositions/scene.html"); expect(host?.getAttribute("data-start")).toBe("20"); expect(host?.getAttribute("data-width")).toBe("640"); expect(host?.querySelector(".title")?.textContent).toBe("Scene"); @@ -715,6 +716,10 @@ describe("template-wrapped sub-composition media offsets", () => { const host = document.querySelector("#scene-host"); expect(host?.getAttribute("data-composition-id")).toBeNull(); + expect(host?.getAttribute("data-composition-file")).toBe("compositions/scene.html"); + expect( + host?.querySelector('[data-composition-id="scene"]')?.getAttribute("data-composition-file"), + ).toBe("compositions/scene.html"); expect(host?.querySelector('[data-composition-id="scene"] .title')?.textContent).toBe("Scene"); expect(compiled.html).toContain('var __hfCompId = "scene";'); }); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 7adeb2e825..307a0f3e44 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -681,6 +681,7 @@ function inlineSubCompositions( const innerH = innerRoot.getAttribute("data-height"); if (innerW && !host.getAttribute("data-width")) host.setAttribute("data-width", innerW); if (innerH && !host.getAttribute("data-height")) host.setAttribute("data-height", innerH); + innerRoot.setAttribute("data-composition-file", srcPath); innerRoot.querySelectorAll("style, script").forEach((el) => el.remove()); host.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || ""; } else { @@ -688,6 +689,7 @@ function inlineSubCompositions( host.innerHTML = contentDoc.toString(); } + host.setAttribute("data-composition-file", srcPath); host.removeAttribute("data-composition-src"); // Set explicit pixel dimensions on the host element so children using diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 669f7e1829..4afe69ae03 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -88,9 +88,13 @@ import { import { STUDIO_MANUAL_EDITS_PATH, applyStudioManualEditManifest, + emptyStudioManualEditManifest, installStudioManualEditSeekReapply, + isStudioManualEditManifestPath, parseStudioManualEditManifest, + readStudioFileChangePath, serializeStudioManualEditManifest, + type StudioManualEditManifest, upsertStudioBoxSizeEdit, upsertStudioPathOffsetEdit, upsertStudioRotationEdit, @@ -1077,6 +1081,16 @@ export function StudioApp() { const domEditSaveTimestampRef = useRef(0); const domTextCommitVersionRef = useRef(0); const domEditSaveQueueRef = useRef(Promise.resolve()); + const studioManualEditManifestRef = useRef( + emptyStudioManualEditManifest(), + ); + const studioManualEditRevisionRef = useRef(0); + const applyStudioManualEditsToPreviewRef = useRef< + (iframe?: HTMLIFrameElement | null, options?: { forceFromDisk?: boolean }) => Promise + >(async () => {}); + const studioManualEditProjectRef = useRef(projectId); + const activeCompPathRef = useRef(activeCompPath); + activeCompPathRef.current = activeCompPath; const queueDomEditSave = useCallback((save: () => Promise) => { const queuedSave = domEditSaveQueueRef.current.catch(() => undefined).then(save); @@ -1097,8 +1111,18 @@ export function StudioApp() { // those changes are already applied to the iframe DOM and a full reload // would flash the preview. useMountEffect(() => { - const handler = () => { - if (Date.now() - domEditSaveTimestampRef.current < 1200) return; + const handler = (payload?: unknown) => { + const changedPath = readStudioFileChangePath(payload); + const recentDomEditSave = Date.now() - domEditSaveTimestampRef.current < 1200; + if (isStudioManualEditManifestPath(changedPath)) { + if (!recentDomEditSave) { + void applyStudioManualEditsToPreviewRef.current(previewIframeRef.current, { + forceFromDisk: true, + }); + } + return; + } + if (recentDomEditSave) return; if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 400); }; @@ -1114,6 +1138,15 @@ export function StudioApp() { projectIdRef.current = projectId; domEditSelectionRef.current = domEditSelection; + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + const previousProjectId = studioManualEditProjectRef.current; + studioManualEditProjectRef.current = projectId; + if (!previousProjectId || previousProjectId === projectId) return; + studioManualEditManifestRef.current = emptyStudioManualEditManifest(); + studioManualEditRevisionRef.current += 1; + }, [projectId]); + // Load file tree when projectId changes. // Note: This is one of the few places where useEffect with deps is acceptable — // it's data fetching tied to a prop change. Ideally this would use a data-fetching @@ -1619,8 +1652,8 @@ export function StudioApp() { [writeProjectFile], ); - const applyStudioManualEditsToPreview = useCallback( - async (iframe: HTMLIFrameElement | null = previewIframeRef.current) => { + const applyCurrentStudioManualEditsToPreview = useCallback( + (iframe: HTMLIFrameElement | null = previewIframeRef.current) => { if (!iframe) return; let doc: Document | null = null; try { @@ -1631,10 +1664,12 @@ export function StudioApp() { if (!doc) return; const previewDoc = doc; - const content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH).catch(() => ""); - const manifest = parseStudioManualEditManifest(content); const applyManifest = () => { - applyStudioManualEditManifest(previewDoc, manifest, activeCompPath); + applyStudioManualEditManifest( + previewDoc, + studioManualEditManifestRef.current, + activeCompPathRef.current, + ); }; const applyAndInstallSeekHooks = () => { applyManifest(); @@ -1652,7 +1687,90 @@ export function StudioApp() { win?.setTimeout?.(applyAndInstallSeekHooks, 1000); win?.setTimeout?.(applyAndInstallSeekHooks, 2000); }, - [activeCompPath, readOptionalProjectFile], + [], + ); + + const applyStudioManualEditsToPreview = useCallback( + async ( + iframe: HTMLIFrameElement | null = previewIframeRef.current, + options?: { forceFromDisk?: boolean }, + ) => { + const readRevision = studioManualEditRevisionRef.current; + applyCurrentStudioManualEditsToPreview(iframe); + const content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH).catch(() => ""); + if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) { + studioManualEditManifestRef.current = parseStudioManualEditManifest(content); + if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1; + applyCurrentStudioManualEditsToPreview(iframe); + return; + } + }, + [applyCurrentStudioManualEditsToPreview, readOptionalProjectFile], + ); + applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview; + + const commitStudioManualEditManifestOptimistically = useCallback( + ( + updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest, + options: { label: string; coalesceKey: string }, + ) => { + const previousManifest = studioManualEditManifestRef.current; + const nextManifest = updateManifest(previousManifest); + const previousContent = serializeStudioManualEditManifest(previousManifest); + const nextContent = serializeStudioManualEditManifest(nextManifest); + if (nextContent === previousContent) return; + + const revision = studioManualEditRevisionRef.current + 1; + studioManualEditRevisionRef.current = revision; + studioManualEditManifestRef.current = nextManifest; + applyCurrentStudioManualEditsToPreview(previewIframeRef.current); + + const save = async () => { + const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH); + const diskManifest = parseStudioManualEditManifest(originalContent); + const nextDiskManifest = updateManifest(diskManifest); + const nextDiskContent = serializeStudioManualEditManifest(nextDiskManifest); + if (nextDiskContent === originalContent) return; + + const pid = projectIdRef.current; + if (!pid) throw new Error("No active project"); + domEditSaveTimestampRef.current = Date.now(); + await saveProjectFilesWithHistory({ + projectId: pid, + label: options.label, + kind: "manual", + coalesceKey: options.coalesceKey, + files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent }, + readFile: async () => originalContent, + writeFile: writeProjectFile, + recordEdit: editHistory.recordEdit, + }); + domEditSaveTimestampRef.current = Date.now(); + + if (studioManualEditRevisionRef.current === revision) { + studioManualEditManifestRef.current = nextDiskManifest; + applyCurrentStudioManualEditsToPreview(previewIframeRef.current); + } + }; + + void queueDomEditSave(save).catch((error) => { + if (studioManualEditRevisionRef.current === revision) { + studioManualEditRevisionRef.current += 1; + studioManualEditManifestRef.current = previousManifest; + applyCurrentStudioManualEditsToPreview(previewIframeRef.current); + } + const message = error instanceof Error ? error.message : "Failed to save manual edit"; + showToast(message); + }); + }, + [ + applyCurrentStudioManualEditsToPreview, + editHistory.recordEdit, + queueDomEditSave, + readOptionalProjectFile, + showToast, + writeProjectFile, + ], ); const syncHistoryPreviewAfterApply = useCallback( @@ -1662,7 +1780,7 @@ export function StudioApp() { changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH); if (manualManifestOnly) { - await applyStudioManualEditsToPreview(previewIframeRef.current); + await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true }); return; } @@ -1934,96 +2052,42 @@ export function StudioApp() { ); const handleDomPathOffsetCommit = useCallback( - async (selection: DomEditSelection, next: { x: number; y: number }) => { - const save = async () => { - const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH); - const manifest = parseStudioManualEditManifest(originalContent); - const nextContent = serializeStudioManualEditManifest( - upsertStudioPathOffsetEdit(manifest, selection, next), - ); - - if (nextContent === originalContent) return; - - const pid = projectIdRef.current; - if (!pid) throw new Error("No active project"); - await saveProjectFilesWithHistory({ - projectId: pid, + (selection: DomEditSelection, next: { x: number; y: number }) => { + commitStudioManualEditManifestOptimistically( + (manifest) => upsertStudioPathOffsetEdit(manifest, selection, next), + { label: "Move layer", - kind: "manual", coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, - files: { [STUDIO_MANUAL_EDITS_PATH]: nextContent }, - readFile: async () => originalContent, - writeFile: writeProjectFile, - recordEdit: editHistory.recordEdit, - }); - domEditSaveTimestampRef.current = Date.now(); - }; - - await queueDomEditSave(save); + }, + ); }, - [editHistory.recordEdit, queueDomEditSave, readOptionalProjectFile, writeProjectFile], + [commitStudioManualEditManifestOptimistically], ); const handleDomBoxSizeCommit = useCallback( - async (selection: DomEditSelection, next: { width: number; height: number }) => { - const save = async () => { - const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH); - const manifest = parseStudioManualEditManifest(originalContent); - const nextContent = serializeStudioManualEditManifest( - upsertStudioBoxSizeEdit(manifest, selection, next), - ); - - if (nextContent === originalContent) return; - - const pid = projectIdRef.current; - if (!pid) throw new Error("No active project"); - await saveProjectFilesWithHistory({ - projectId: pid, + (selection: DomEditSelection, next: { width: number; height: number }) => { + commitStudioManualEditManifestOptimistically( + (manifest) => upsertStudioBoxSizeEdit(manifest, selection, next), + { label: "Resize layer box", - kind: "manual", coalesceKey: `box-size:${getDomEditTargetKey(selection)}`, - files: { [STUDIO_MANUAL_EDITS_PATH]: nextContent }, - readFile: async () => originalContent, - writeFile: writeProjectFile, - recordEdit: editHistory.recordEdit, - }); - domEditSaveTimestampRef.current = Date.now(); - }; - - await queueDomEditSave(save); + }, + ); }, - [editHistory.recordEdit, queueDomEditSave, readOptionalProjectFile, writeProjectFile], + [commitStudioManualEditManifestOptimistically], ); const handleDomRotationCommit = useCallback( - async (selection: DomEditSelection, next: { angle: number }) => { - const save = async () => { - const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH); - const manifest = parseStudioManualEditManifest(originalContent); - const nextContent = serializeStudioManualEditManifest( - upsertStudioRotationEdit(manifest, selection, next), - ); - - if (nextContent === originalContent) return; - - const pid = projectIdRef.current; - if (!pid) throw new Error("No active project"); - await saveProjectFilesWithHistory({ - projectId: pid, + (selection: DomEditSelection, next: { angle: number }) => { + commitStudioManualEditManifestOptimistically( + (manifest) => upsertStudioRotationEdit(manifest, selection, next), + { label: "Rotate layer", - kind: "manual", coalesceKey: `rotation:${getDomEditTargetKey(selection)}`, - files: { [STUDIO_MANUAL_EDITS_PATH]: nextContent }, - readFile: async () => originalContent, - writeFile: writeProjectFile, - recordEdit: editHistory.recordEdit, - }); - domEditSaveTimestampRef.current = Date.now(); - }; - - await queueDomEditSave(save); + }, + ); }, - [editHistory.recordEdit, queueDomEditSave, readOptionalProjectFile, writeProjectFile], + [commitStudioManualEditManifestOptimistically], ); const handleDomStyleCommit = useCallback( diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index 14845e6ce2..76dca64a97 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -10,6 +10,7 @@ import { applyStudioRotationDraft, beginStudioManualEditGesture, captureStudioBoxSize, + captureStudioPathOffset, captureStudioRotation, endStudioManualEditGesture, isStudioManualEditGestureCurrent, @@ -20,6 +21,7 @@ import { restoreStudioPathOffset, restoreStudioRotation, type StudioBoxSizeSnapshot, + type StudioPathOffsetSnapshot, type StudioRotationSnapshot, } from "./manualEdits"; @@ -201,10 +203,8 @@ interface GestureState { startY: number; centerX: number; centerY: number; - initialStyleTranslate: string; + initialPathOffset: StudioPathOffsetSnapshot; initialRotation: StudioRotationSnapshot; - initialOffsetX: string; - initialOffsetY: string; initialBoxSize: StudioBoxSizeSnapshot; originLeft: number; originTop: number; @@ -369,10 +369,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({ startY: e.clientY, centerX, centerY, - initialStyleTranslate: sel.element.style.getPropertyValue("translate"), + initialPathOffset: captureStudioPathOffset(sel.element), initialRotation: captureStudioRotation(sel.element), - initialOffsetX: sel.element.style.getPropertyValue("--hf-studio-offset-x"), - initialOffsetY: sel.element.style.getPropertyValue("--hf-studio-offset-y"), initialBoxSize: captureStudioBoxSize(sel.element), originLeft: rect.left, originTop: rect.top, @@ -468,11 +466,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY); if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) { - restoreStudioPathOffset(sel.element, { - translate: g.initialStyleTranslate, - x: g.initialOffsetX, - y: g.initialOffsetY, - }); + restoreStudioPathOffset(sel.element, g.initialPathOffset); endStudioManualEditGesture(sel.element, g.manualEditDragToken); if (box) { box.style.left = `${g.originLeft}px`; @@ -525,11 +519,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ g.manualEditDragToken && isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken) ) { - restoreStudioPathOffset(sel.element, { - translate: g.initialStyleTranslate, - x: g.initialOffsetX, - y: g.initialOffsetY, - }); + restoreStudioPathOffset(sel.element, g.initialPathOffset); } }) .finally(() => { @@ -586,11 +576,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const g = gestureRef.current; const sel = selectionRef.current; if (g?.mode === "path-offset" && sel) { - restoreStudioPathOffset(sel.element, { - translate: g.initialStyleTranslate, - x: g.initialOffsetX, - y: g.initialOffsetY, - }); + restoreStudioPathOffset(sel.element, g.initialPathOffset); endStudioManualEditGesture(sel.element, g.manualEditDragToken); } if (g?.mode === "box-size" && sel) { diff --git a/packages/studio/src/components/editor/manualEdits.test.ts b/packages/studio/src/components/editor/manualEdits.test.ts index 1790cd9723..079c8a206e 100644 --- a/packages/studio/src/components/editor/manualEdits.test.ts +++ b/packages/studio/src/components/editor/manualEdits.test.ts @@ -19,7 +19,9 @@ import { emptyStudioManualEditManifest, endStudioManualEditGesture, installStudioManualEditSeekReapply, + isStudioManualEditManifestPath, parseStudioManualEditManifest, + readStudioFileChangePath, readStudioBoxSize, readStudioPathOffset, readStudioRotation, @@ -82,6 +84,15 @@ function mockBoundingRect(element: HTMLElement, width: number, height: number): }) as DOMRect; } +function mockComputedStyle(element: HTMLElement, values: Record): void { + const win = element.ownerDocument.defaultView; + if (!win) throw new Error("defaultView fixture missing"); + win.getComputedStyle = ((target: Element) => + ({ + getPropertyValue: (property: string) => (target === element ? (values[property] ?? "") : ""), + }) as CSSStyleDeclaration) as typeof win.getComputedStyle; +} + describe("studio manual edits", () => { it("upserts path offsets by stable target", () => { const manifest = upsertStudioPathOffsetEdit( @@ -183,6 +194,21 @@ describe("studio manual edits", () => { ); }); + it("recognizes manual edit manifest file-change payloads", () => { + expect(readStudioFileChangePath({ path: ".hyperframes/studio-manual-edits.json" })).toBe( + ".hyperframes/studio-manual-edits.json", + ); + expect(readStudioFileChangePath({ data: '{"path":"nested/file.html"}' })).toBe( + "nested/file.html", + ); + expect( + isStudioManualEditManifestPath( + "/Users/example/project/.hyperframes/studio-manual-edits.json", + ), + ).toBe(true); + expect(isStudioManualEditManifestPath("index.html")).toBe(false); + }); + it("applies offsets through CSS translate longhand", () => { const document = createDocument(`
`); const card = document.getElementById("card") as HTMLElement; @@ -195,6 +221,74 @@ describe("studio manual edits", () => { expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP); }); + it("preserves authored inline translate as the additive path offset base", () => { + const document = createDocument(`
`); + const card = document.getElementById("card") as HTMLElement; + + applyStudioPathOffset(card, { x: 14, y: -8 }); + + expect(card.style.getPropertyValue("translate")).toContain("calc(10px +"); + expect(card.style.getPropertyValue("translate")).toContain("calc(20px +"); + expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP); + expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_Y_PROP); + }); + + it("preserves stylesheet-authored transform longhands as additive bases", () => { + const document = createDocument(`
`); + const card = document.getElementById("card") as HTMLElement; + mockComputedStyle(card, { + translate: "10px 20px", + rotate: "8deg", + }); + + applyStudioPathOffset(card, { x: 14, y: -8 }); + applyStudioRotation(card, { angle: 12 }); + + expect(card.style.getPropertyValue("translate")).toContain("calc(10px +"); + expect(card.style.getPropertyValue("translate")).toContain("calc(20px +"); + expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP); + expect(card.style.getPropertyValue("rotate")).toContain("8deg"); + expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP); + }); + + it("clears computed transform bases without freezing them inline", () => { + const document = createDocument(`
`); + const card = document.getElementById("card") as HTMLElement; + mockComputedStyle(card, { + translate: "10px 20px", + rotate: "8deg", + }); + + applyStudioPathOffset(card, { x: 14, y: -8 }); + applyStudioRotation(card, { angle: 12 }); + + expect( + applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"), + ).toBe(0); + + expect(card.style.getPropertyValue("translate")).toBe(""); + expect(card.style.getPropertyValue("rotate")).toBe(""); + }); + + it("does not compound stale studio variables as authored transform bases", () => { + const document = createDocument(`
`); + const card = document.getElementById("card") as HTMLElement; + + card.style.setProperty( + "translate", + `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`, + ); + card.style.setProperty("rotate", `var(${STUDIO_ROTATION_PROP}, 0deg)`); + + applyStudioPathOffset(card, { x: 14, y: -8 }); + applyStudioRotation(card, { angle: 12 }); + + expect(card.style.getPropertyValue("translate")).toBe( + `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`, + ); + expect(card.style.getPropertyValue("rotate")).toBe(`var(${STUDIO_ROTATION_PROP}, 0deg)`); + }); + it("applies box sizes through CSS dimensions and flex sizing overrides", () => { const document = createDocument(`
@@ -239,8 +333,8 @@ describe("studio manual edits", () => { expect(readStudioRotation(card)).toEqual({ angle: 24.2 }); expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.2deg"); - expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP); expect(card.style.getPropertyValue("rotate")).toContain("8deg"); + expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP); applyStudioRotationDraft(card, { angle: -12.26 }); expect(readStudioRotation(card)).toEqual({ angle: -12.3 }); @@ -438,6 +532,47 @@ describe("studio manual edits", () => { expect(readStudioBoxSize(nestedSecondTile)).toEqual({ width: 220, height: 80 }); }); + it("resolves manifest targets inside composition-file hosts without composition ids", () => { + const document = createDocument(` +
+
+
+
+
+
+ `); + const htmlElement = document.defaultView?.HTMLElement; + if (!htmlElement) throw new Error("HTMLElement fixture missing"); + const cards = Array.from(document.getElementsByTagName("*")).filter( + (element): element is HTMLElement => element instanceof htmlElement && element.id === "card", + ); + const rootCard = cards[0]; + const nestedCard = cards[1]; + if (!rootCard || !nestedCard) { + throw new Error("anonymous composition fixture missing"); + } + + const manifest = parseStudioManualEditManifest(`{ + "version": 1, + "edits": [ + { + "kind": "path-offset", + "target": { + "sourceFile": "scenes/anonymous.html", + "selector": "#card", + "id": "card" + }, + "x": 24, + "y": 12 + } + ] + }`); + + expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1); + expect(readStudioPathOffset(rootCard)).toEqual({ x: 0, y: 0 }); + expect(readStudioPathOffset(nestedCard)).toEqual({ x: 24, y: 12 }); + }); + it("applies and clears manifest box sizes while restoring authored inline size", () => { const document = createDocument(`
@@ -493,6 +628,7 @@ describe("studio manual edits", () => { expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1); expect(readStudioRotation(card)).toEqual({ angle: 37.5 }); expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP); + expect(card.style.getPropertyValue("rotate")).toContain("8deg"); expect( applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"), @@ -518,6 +654,20 @@ describe("studio manual edits", () => { expect(card.style.getPropertyValue("translate")).toBe(""); }); + it("restores authored inline translate when clearing offsets", () => { + const document = createDocument(`
`); + const card = document.getElementById("card") as HTMLElement; + + applyStudioPathOffset(card, { x: 24, y: 12 }); + expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP); + + expect( + applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"), + ).toBe(0); + + expect(card.style.getPropertyValue("translate")).toBe("10px 20px"); + }); + it("does not replay the manifest over an active manual edit gesture", () => { const document = createDocument(`
`); const card = document.getElementById("card") as HTMLElement; diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts index 45dc6cdfc5..b1b7d16e8b 100644 --- a/packages/studio/src/components/editor/manualEdits.ts +++ b/packages/studio/src/components/editor/manualEdits.ts @@ -10,6 +10,8 @@ const STUDIO_PATH_OFFSET_ATTR = "data-hf-studio-path-offset"; const STUDIO_MANUAL_EDIT_GESTURE_ATTR = "data-hf-studio-manual-edit-gesture"; const STUDIO_BOX_SIZE_ATTR = "data-hf-studio-box-size"; const STUDIO_ROTATION_ATTR = "data-hf-studio-rotation"; +const STUDIO_ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate"; +const STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR = "data-hf-studio-original-inline-translate"; const STUDIO_ORIGINAL_WIDTH_ATTR = "data-hf-studio-original-width"; const STUDIO_ORIGINAL_HEIGHT_ATTR = "data-hf-studio-original-height"; const STUDIO_ORIGINAL_MIN_WIDTH_ATTR = "data-hf-studio-original-min-width"; @@ -24,6 +26,7 @@ const STUDIO_ORIGINAL_SCALE_ATTR = "data-hf-studio-original-scale"; const STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR = "data-hf-studio-original-transform-origin"; const STUDIO_ORIGINAL_DISPLAY_ATTR = "data-hf-studio-original-display"; const STUDIO_ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate"; +const STUDIO_ORIGINAL_INLINE_ROTATE_ATTR = "data-hf-studio-original-inline-rotate"; const STUDIO_MANUAL_EDITS_APPLY_PROP = "__hfStudioManualEditsApply"; const STUDIO_MANUAL_EDITS_WRAPPED_SEEK_PROP = "__hfStudioManualEditsWrapped"; let studioManualEditGestureId = 0; @@ -200,6 +203,47 @@ export function serializeStudioManualEditManifest(manifest: StudioManualEditMani return `${JSON.stringify(manifest, null, 2)}\n`; } +function normalizeStudioFileChangePath(path: string): string { + return path + .trim() + .replace(/\\/g, "/") + .replace(/^\.?\//, ""); +} + +function readStudioFileChangePathFromValue(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("{")) { + try { + return readStudioFileChangePathFromValue(JSON.parse(trimmed) as unknown); + } catch { + return normalizeStudioFileChangePath(trimmed); + } + } + return normalizeStudioFileChangePath(trimmed); + } + + if (!value || typeof value !== "object") return null; + const record = value as Record; + if (typeof record.path === "string") return normalizeStudioFileChangePath(record.path); + if (typeof record.filePath === "string") return normalizeStudioFileChangePath(record.filePath); + if ("data" in record) return readStudioFileChangePathFromValue(record.data); + return null; +} + +export function readStudioFileChangePath(payload: unknown): string | null { + return readStudioFileChangePathFromValue(payload); +} + +export function isStudioManualEditManifestPath(path: string | null): boolean { + if (!path) return false; + const normalized = normalizeStudioFileChangePath(path); + return ( + normalized === STUDIO_MANUAL_EDITS_PATH || normalized.endsWith(`/${STUDIO_MANUAL_EDITS_PATH}`) + ); +} + function selectionTarget(selection: DomEditSelection): StudioManualEditTarget { return { sourceFile: selection.sourceFile || "index.html", @@ -337,7 +381,67 @@ export function isStudioManualEditGestureCurrent(element: HTMLElement, token: st return element.getAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR) === token; } -function writeStudioPathOffsetVars(element: HTMLElement, offset: { x: number; y: number }): void { +function splitTopLevelWhitespace(value: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const char of value.trim()) { + if (char === "(") depth += 1; + if (char === ")") depth = Math.max(0, depth - 1); + if (/\s/.test(char) && depth === 0) { + if (current) parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + return parts; +} + +function composeTranslateValue(element: HTMLElement, x: string, y: string): string { + const original = element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR)?.trim(); + if (!original || original === "none") return `${x} ${y}`; + + const parts = splitTopLevelWhitespace(original); + if (parts.length === 1) return `calc(${parts[0]} + ${x}) ${y}`; + if (parts.length === 2) return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y})`; + if (parts.length === 3) { + return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y}) ${parts[2]}`; + } + return `${x} ${y}`; +} + +function readTransformLonghandBase(element: HTMLElement, property: "translate" | "rotate"): string { + const value = readStyleOrComputed(element, property).trim(); + return value === "none" ? "" : value; +} + +function prepareStudioPathOffsetBase(element: HTMLElement, updateBase: boolean): void { + const inlineTranslate = element.style.getPropertyValue("translate"); + const currentTranslate = readTransformLonghandBase(element, "translate"); + const hasMarker = element.hasAttribute(STUDIO_PATH_OFFSET_ATTR); + const wasResetByAnimation = !styleUsesStudioOffset(currentTranslate); + if (!hasMarker) { + element.setAttribute( + STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, + styleUsesStudioOffset(inlineTranslate) ? "" : inlineTranslate, + ); + element.setAttribute( + STUDIO_ORIGINAL_TRANSLATE_ATTR, + wasResetByAnimation ? currentTranslate : "", + ); + } else if (updateBase && wasResetByAnimation && !isStudioManualEditGestureActive(element)) { + element.setAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR, currentTranslate); + } +} + +function writeStudioPathOffsetVars( + element: HTMLElement, + offset: { x: number; y: number }, + options: { updateBase?: boolean } = {}, +): void { + prepareStudioPathOffsetBase(element, options.updateBase ?? true); element.setAttribute(STUDIO_PATH_OFFSET_ATTR, "true"); element.style.setProperty(STUDIO_OFFSET_X_PROP, `${Math.round(offset.x)}px`); element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${Math.round(offset.y)}px`); @@ -395,11 +499,28 @@ function writeStudioBoxSizeVars( element.style.setProperty(STUDIO_HEIGHT_PROP, `${Math.round(Math.max(1, size.height))}px`); } -function writeStudioRotationVars(element: HTMLElement, rotation: { angle: number }): void { - if (!element.hasAttribute(STUDIO_ROTATION_ATTR)) { - element.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, element.style.getPropertyValue("rotate")); +function prepareStudioRotationBase(element: HTMLElement, updateBase: boolean): void { + const inlineRotate = element.style.getPropertyValue("rotate"); + const currentRotate = readTransformLonghandBase(element, "rotate"); + const hasMarker = element.hasAttribute(STUDIO_ROTATION_ATTR); + const wasResetByAnimation = !styleUsesStudioRotation(currentRotate); + if (!hasMarker) { + element.setAttribute( + STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, + styleUsesStudioRotation(inlineRotate) ? "" : inlineRotate, + ); + element.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, wasResetByAnimation ? currentRotate : ""); + } else if (updateBase && wasResetByAnimation && !isStudioManualEditGestureActive(element)) { + element.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, currentRotate); } +} +function writeStudioRotationVars( + element: HTMLElement, + rotation: { angle: number }, + options: { updateBase?: boolean } = {}, +): void { + prepareStudioRotationBase(element, options.updateBase ?? true); element.setAttribute(STUDIO_ROTATION_ATTR, "true"); element.style.setProperty(STUDIO_ROTATION_PROP, `${roundRotationAngle(rotation.angle)}deg`); } @@ -501,7 +622,11 @@ export function applyStudioPathOffset( writeStudioPathOffsetVars(element, offset); element.style.setProperty( "translate", - `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`, + composeTranslateValue( + element, + `var(${STUDIO_OFFSET_X_PROP}, 0px)`, + `var(${STUDIO_OFFSET_Y_PROP}, 0px)`, + ), ); } @@ -509,8 +634,11 @@ export function applyStudioPathOffsetDraft( element: HTMLElement, offset: { x: number; y: number }, ): void { - writeStudioPathOffsetVars(element, offset); - element.style.setProperty("translate", `${Math.round(offset.x)}px ${Math.round(offset.y)}px`); + writeStudioPathOffsetVars(element, offset, { updateBase: false }); + element.style.setProperty( + "translate", + composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`), + ); } export function applyStudioBoxSize( @@ -536,7 +664,7 @@ export function applyStudioRotation(element: HTMLElement, rotation: { angle: num } export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle: number }): void { - writeStudioRotationVars(element, rotation); + writeStudioRotationVars(element, rotation, { updateBase: false }); element.style.setProperty( "rotate", composeStudioRotationValue(element, `${roundRotationAngle(rotation.angle)}deg`), @@ -548,11 +676,13 @@ function clearStudioPathOffset(element: HTMLElement): void { element.hasAttribute(STUDIO_PATH_OFFSET_ATTR) || styleUsesStudioOffset(element.style.getPropertyValue("translate")) ) { - element.style.removeProperty("translate"); + restoreOriginalTranslateProperty(element); } element.style.removeProperty(STUDIO_OFFSET_X_PROP); element.style.removeProperty(STUDIO_OFFSET_Y_PROP); element.removeAttribute(STUDIO_PATH_OFFSET_ATTR); + element.removeAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR); + element.removeAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR); } function clearStudioRotation(element: HTMLElement): void { @@ -566,6 +696,7 @@ function clearStudioRotation(element: HTMLElement): void { element.style.removeProperty(STUDIO_ROTATION_PROP); element.removeAttribute(STUDIO_ROTATION_ATTR); element.removeAttribute(STUDIO_ORIGINAL_ROTATE_ATTR); + element.removeAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR); } function restoreOriginalBoxSizeProperty( @@ -593,12 +724,21 @@ function restoreOriginalBoxSizeProperty( } function restoreOriginalRotationProperty(element: HTMLElement): void { - const original = element.getAttribute(STUDIO_ORIGINAL_ROTATE_ATTR); + const original = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR); if (original == null || original === "") element.style.removeProperty("rotate"); else element.style.setProperty("rotate", original); + element.removeAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR); element.removeAttribute(STUDIO_ORIGINAL_ROTATE_ATTR); } +function restoreOriginalTranslateProperty(element: HTMLElement): void { + const original = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR); + if (original == null || original === "") element.style.removeProperty("translate"); + else element.style.setProperty("translate", original); + element.removeAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR); + element.removeAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR); +} + function clearStudioBoxSize(element: HTMLElement): void { if ( element.hasAttribute(STUDIO_BOX_SIZE_ATTR) || @@ -630,11 +770,6 @@ function clearStudioBoxSize(element: HTMLElement): void { element.removeAttribute(STUDIO_BOX_SIZE_ATTR); } -function parseStoredOffset(value: string): number { - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : 0; -} - export interface StudioBoxSizeSnapshot { width: string; height: string; @@ -672,6 +807,16 @@ export interface StudioRotationSnapshot { studioRotation: string; marker: string | null; originalRotate: string | null; + originalInlineRotate: string | null; +} + +export interface StudioPathOffsetSnapshot { + translate: string; + x: string; + y: string; + marker: string | null; + originalTranslate: string | null; + originalInlineTranslate: string | null; } export function captureStudioBoxSize(element: HTMLElement): StudioBoxSizeSnapshot { @@ -714,6 +859,18 @@ export function captureStudioRotation(element: HTMLElement): StudioRotationSnaps studioRotation: element.style.getPropertyValue(STUDIO_ROTATION_PROP), marker: element.getAttribute(STUDIO_ROTATION_ATTR), originalRotate: element.getAttribute(STUDIO_ORIGINAL_ROTATE_ATTR), + originalInlineRotate: element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR), + }; +} + +export function captureStudioPathOffset(element: HTMLElement): StudioPathOffsetSnapshot { + return { + translate: element.style.getPropertyValue("translate"), + x: element.style.getPropertyValue(STUDIO_OFFSET_X_PROP), + y: element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP), + marker: element.getAttribute(STUDIO_PATH_OFFSET_ATTR), + originalTranslate: element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR), + originalInlineTranslate: element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR), }; } @@ -771,20 +928,13 @@ export function restoreStudioRotation( restoreStyleProperty(element, STUDIO_ROTATION_PROP, previous.studioRotation); restoreAttribute(element, STUDIO_ROTATION_ATTR, previous.marker); restoreAttribute(element, STUDIO_ORIGINAL_ROTATE_ATTR, previous.originalRotate); + restoreAttribute(element, STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, previous.originalInlineRotate); } export function restoreStudioPathOffset( element: HTMLElement, - previous: { translate: string; x: string; y: string }, + previous: StudioPathOffsetSnapshot, ): void { - const hadStudioOffset = - Boolean(previous.x) || Boolean(previous.y) || styleUsesStudioOffset(previous.translate); - - applyStudioPathOffset(element, { - x: parseStoredOffset(previous.x), - y: parseStoredOffset(previous.y), - }); - if (previous.translate) element.style.setProperty("translate", previous.translate); else element.style.removeProperty("translate"); @@ -794,7 +944,13 @@ export function restoreStudioPathOffset( if (previous.y) element.style.setProperty(STUDIO_OFFSET_Y_PROP, previous.y); else element.style.removeProperty(STUDIO_OFFSET_Y_PROP); - if (!hadStudioOffset) element.removeAttribute(STUDIO_PATH_OFFSET_ATTR); + restoreAttribute(element, STUDIO_PATH_OFFSET_ATTR, previous.marker); + restoreAttribute(element, STUDIO_ORIGINAL_TRANSLATE_ATTR, previous.originalTranslate); + restoreAttribute( + element, + STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, + previous.originalInlineTranslate, + ); } function markSeekWrapped(fn: (time: number) => unknown): void { @@ -869,29 +1025,18 @@ function sourceFileMatches(activeCompositionPath: string | null, sourceFile: str return activeCompositionPath === sourceFile; } -function findClosestByAttribute(el: HTMLElement, attributeNames: string[]): HTMLElement | null { - let current: HTMLElement | null = el; - while (current) { - const candidate = current; - if (attributeNames.some((attribute) => candidate.hasAttribute(attribute))) { - return candidate; - } - current = current.parentElement; - } - return null; -} - function getManualEditSourceFileForElement( el: HTMLElement, activeCompositionPath: string | null, ): string { - const ownerRoot = findClosestByAttribute(el, ["data-composition-id"]); - return ( - ownerRoot?.getAttribute("data-composition-file") ?? - ownerRoot?.getAttribute("data-composition-src") ?? - activeCompositionPath ?? - "index.html" - ); + let current: HTMLElement | null = el; + while (current) { + const sourceFile = + current.getAttribute("data-composition-file") ?? current.getAttribute("data-composition-src"); + if (sourceFile) return sourceFile; + current = current.parentElement; + } + return activeCompositionPath ?? "index.html"; } function elementMatchesManualEditSourceFile( @@ -907,23 +1052,21 @@ function queryManualEditSelectorCandidates( selector: string, htmlElement: typeof HTMLElement, ): HTMLElement[] { + const isCandidate = (element: Element): element is HTMLElement => element instanceof htmlElement; + const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1]; if (className) { return Array.from(doc.getElementsByTagName("*")).filter( (element): element is HTMLElement => - element instanceof htmlElement && element.classList.contains(className), + isCandidate(element) && element.classList.contains(className), ); } if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) { - return Array.from(doc.getElementsByTagName(selector)).filter( - (element): element is HTMLElement => element instanceof htmlElement, - ); + return Array.from(doc.getElementsByTagName(selector)).filter(isCandidate); } - return Array.from(doc.querySelectorAll(selector)).filter( - (element): element is HTMLElement => element instanceof htmlElement, - ); + return Array.from(doc.querySelectorAll(selector)).filter(isCandidate); } function resolveManualEditTarget( @@ -982,10 +1125,13 @@ function collectStudioManualEditElements(doc: Document): HTMLElement[] { element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR) || element.hasAttribute(STUDIO_BOX_SIZE_ATTR) || element.hasAttribute(STUDIO_ROTATION_ATTR) || + element.hasAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR) || + element.hasAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR) || element.hasAttribute(STUDIO_ORIGINAL_MIN_WIDTH_ATTR) || element.hasAttribute(STUDIO_ORIGINAL_FLEX_BASIS_ATTR) || element.hasAttribute(STUDIO_ORIGINAL_SCALE_ATTR) || element.hasAttribute(STUDIO_ORIGINAL_ROTATE_ATTR) || + element.hasAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR) || Boolean(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || Boolean(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || Boolean(element.style.getPropertyValue(STUDIO_WIDTH_PROP)) || diff --git a/packages/studio/src/components/editor/manualEditsRenderScript.test.ts b/packages/studio/src/components/editor/manualEditsRenderScript.test.ts index 6ec886d9a3..d8a5ff1473 100644 --- a/packages/studio/src/components/editor/manualEditsRenderScript.test.ts +++ b/packages/studio/src/components/editor/manualEditsRenderScript.test.ts @@ -185,4 +185,132 @@ describe("createStudioManualEditsRenderBodyScript", () => { expect(rootCard.style.getPropertyValue("rotate")).toBe(""); expect(nestedCard.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation"); }); + + it("applies render edits inside composition-file hosts without composition ids", () => { + const window = new Window(); + window.document.body.innerHTML = ` +
+
+
+
+
+
+ `; + const cards = Array.from(window.document.getElementsByTagName("*")).filter( + (element): element is HTMLElement => + element instanceof window.HTMLElement && element.id === "card", + ); + const rootCard = cards[0]; + const nestedCard = cards[1]; + if (!rootCard || !nestedCard) { + throw new Error("anonymous composition render fixture missing"); + } + + const script = createStudioManualEditsRenderBodyScript( + JSON.stringify({ + version: 1, + edits: [ + { + kind: "path-offset", + target: { sourceFile: "scenes/anonymous.html", id: "card" }, + x: 12, + y: 24, + }, + ], + }), + ); + if (!script) throw new Error("script fixture missing"); + + runScript(window, script); + + expect(rootCard.style.getPropertyValue("translate")).toBe(""); + expect(nestedCard.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x"); + }); + + it("preserves computed transform longhands as render edit bases", () => { + const window = new Window(); + window.document.body.innerHTML = `
`; + const card = window.document.getElementById("card"); + if (!(card instanceof window.HTMLElement)) { + throw new Error("card fixture missing"); + } + + const script = createStudioManualEditsRenderBodyScript( + JSON.stringify({ + version: 1, + edits: [ + { + kind: "path-offset", + target: { sourceFile: "index.html", id: "card" }, + x: 12, + y: 24, + }, + { + kind: "rotation", + target: { sourceFile: "index.html", id: "card" }, + angle: 15, + }, + ], + }), + ); + if (!script) throw new Error("script fixture missing"); + + const computedStyle = (element: Element) => + ({ + getPropertyValue: (property: string) => { + if (element !== card) return ""; + if (property === "translate") return "10px 20px"; + if (property === "rotate") return "8deg"; + return ""; + }, + }) as CSSStyleDeclaration; + + runScript(window, script, computedStyle); + + expect(card.style.getPropertyValue("translate")).toContain("calc(10px +"); + expect(card.style.getPropertyValue("translate")).toContain("calc(20px +"); + expect(card.style.getPropertyValue("rotate")).toContain("8deg"); + expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation"); + }); + + it("does not compound stale studio variables during render reapply", () => { + const window = new Window(); + window.document.body.innerHTML = ` +
+ `; + const card = window.document.getElementById("card"); + if (!(card instanceof window.HTMLElement)) { + throw new Error("card fixture missing"); + } + + const script = createStudioManualEditsRenderBodyScript( + JSON.stringify({ + version: 1, + edits: [ + { + kind: "path-offset", + target: { sourceFile: "index.html", id: "card" }, + x: 12, + y: 24, + }, + { + kind: "rotation", + target: { sourceFile: "index.html", id: "card" }, + angle: 15, + }, + ], + }), + ); + if (!script) throw new Error("script fixture missing"); + + runScript(window, script); + + expect(card.style.getPropertyValue("translate")).toBe( + "var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)", + ); + expect(card.style.getPropertyValue("rotate")).toBe("var(--hf-studio-rotation, 0deg)"); + }); }); diff --git a/packages/studio/src/components/editor/manualEditsRenderScript.ts b/packages/studio/src/components/editor/manualEditsRenderScript.ts index 0bf7b3fd76..3ea8980fca 100644 --- a/packages/studio/src/components/editor/manualEditsRenderScript.ts +++ b/packages/studio/src/components/editor/manualEditsRenderScript.ts @@ -9,6 +9,11 @@ function studioManualEditsRenderRuntime(manifestContent: string): void { const WIDTH_PROP = "--hf-studio-width"; const HEIGHT_PROP = "--hf-studio-height"; const ROTATION_PROP = "--hf-studio-rotation"; + const PATH_OFFSET_ATTR = "data-hf-studio-path-offset"; + const BOX_SIZE_ATTR = "data-hf-studio-box-size"; + const ROTATION_ATTR = "data-hf-studio-rotation"; + const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate"; + const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate"; const WRAPPED_SEEK_PROP = "__hfStudioManualEditsWrapped"; const finiteNumber = (value: unknown): number | null => @@ -27,51 +32,115 @@ function studioManualEditsRenderRuntime(manifestContent: string): void { const manifestEdits = Array.isArray(parsedManifest?.edits) ? parsedManifest.edits : []; if (manifestEdits.length === 0) return; - const findClosestByAttribute = ( - element: HTMLElement, - attributeNames: string[], - ): HTMLElement | null => { + const sourceFileForElement = (element: HTMLElement): string => { let current: HTMLElement | null = element; while (current) { - const candidate = current; - if (attributeNames.some((attribute) => candidate.hasAttribute(attribute))) { - return candidate; - } + const sourceFile = + current.getAttribute("data-composition-file") ?? + current.getAttribute("data-composition-src"); + if (sourceFile) return sourceFile; current = current.parentElement; } - return null; - }; - - const sourceFileForElement = (element: HTMLElement): string => { - const ownerRoot = findClosestByAttribute(element, ["data-composition-id"]); - return ( - ownerRoot?.getAttribute("data-composition-file") ?? - ownerRoot?.getAttribute("data-composition-src") ?? - "index.html" - ); + return "index.html"; }; const elementMatchesSourceFile = (element: HTMLElement, sourceFile: string): boolean => sourceFileForElement(element) === sourceFile; + const styleUsesStudioOffset = (value: string): boolean => + value.includes(OFFSET_X_PROP) || value.includes(OFFSET_Y_PROP); + + const styleUsesStudioRotation = (value: string): boolean => value.includes(ROTATION_PROP); + + const splitTopLevelWhitespace = (value: string): string[] => { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const char of value.trim()) { + if (char === "(") depth += 1; + if (char === ")") depth = Math.max(0, depth - 1); + if (/\s/.test(char) && depth === 0) { + if (current) parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + return parts; + }; + + const composeTranslate = (element: HTMLElement, x: string, y: string): string => { + const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim(); + if (!original || original === "none") return `${x} ${y}`; + + const parts = splitTopLevelWhitespace(original); + if (parts.length === 1) return `calc(${parts[0]} + ${x}) ${y}`; + if (parts.length === 2) return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y})`; + if (parts.length === 3) { + return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y}) ${parts[2]}`; + } + return `${x} ${y}`; + }; + + const readStyleOrComputed = (element: HTMLElement, property: string): string => { + try { + return ( + element.style.getPropertyValue(property) || + getComputedStyle(element).getPropertyValue(property) + ); + } catch { + return element.style.getPropertyValue(property); + } + }; + + const readTransformLonghandBase = ( + element: HTMLElement, + property: "translate" | "rotate", + ): string => { + const value = readStyleOrComputed(element, property).trim(); + return value === "none" ? "" : value; + }; + + const preparePathOffsetBase = (element: HTMLElement): void => { + const currentTranslate = readTransformLonghandBase(element, "translate"); + const hasMarker = element.hasAttribute(PATH_OFFSET_ATTR); + const wasResetByAnimation = !styleUsesStudioOffset(currentTranslate); + if (!hasMarker) { + element.setAttribute(ORIGINAL_TRANSLATE_ATTR, wasResetByAnimation ? currentTranslate : ""); + } else if (wasResetByAnimation) { + element.setAttribute(ORIGINAL_TRANSLATE_ATTR, currentTranslate); + } + }; + + const prepareRotationBase = (element: HTMLElement): void => { + const currentRotate = readTransformLonghandBase(element, "rotate"); + const hasMarker = element.hasAttribute(ROTATION_ATTR); + const wasResetByAnimation = !styleUsesStudioRotation(currentRotate); + if (!hasMarker) { + element.setAttribute(ORIGINAL_ROTATE_ATTR, wasResetByAnimation ? currentRotate : ""); + } else if (wasResetByAnimation) { + element.setAttribute(ORIGINAL_ROTATE_ATTR, currentRotate); + } + }; + const querySelectorCandidates = (selector: string): HTMLElement[] => { + const isCandidate = (element: Element): element is HTMLElement => + element instanceof HTMLElement; + const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1]; if (className) { return Array.from(document.getElementsByTagName("*")).filter( (element): element is HTMLElement => - element instanceof HTMLElement && element.classList.contains(className), + isCandidate(element) && element.classList.contains(className), ); } if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) { - return Array.from(document.getElementsByTagName(selector)).filter( - (element): element is HTMLElement => element instanceof HTMLElement, - ); + return Array.from(document.getElementsByTagName(selector)).filter(isCandidate); } - return Array.from(document.querySelectorAll(selector)).filter( - (element): element is HTMLElement => element instanceof HTMLElement, - ); + return Array.from(document.querySelectorAll(selector)).filter(isCandidate); }; const resolveTarget = (edit: Record): HTMLElement | null => { @@ -118,7 +187,7 @@ function studioManualEditsRenderRuntime(manifestContent: string): void { /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim()); const composeRotation = (element: HTMLElement, rotationValue: string): string => { - const original = element.getAttribute("data-hf-studio-original-rotate")?.trim(); + const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim(); if (!original || original === "none" || !isSimpleRotateAngle(original)) { return rotationValue; } @@ -129,10 +198,14 @@ function studioManualEditsRenderRuntime(manifestContent: string): void { const x = finiteNumber(edit.x); const y = finiteNumber(edit.y); if (x == null || y == null) return; - element.setAttribute("data-hf-studio-path-offset", "true"); + preparePathOffsetBase(element); + element.setAttribute(PATH_OFFSET_ATTR, "true"); element.style.setProperty(OFFSET_X_PROP, `${Math.round(x)}px`); element.style.setProperty(OFFSET_Y_PROP, `${Math.round(y)}px`); - element.style.setProperty("translate", `var(${OFFSET_X_PROP}, 0px) var(${OFFSET_Y_PROP}, 0px)`); + element.style.setProperty( + "translate", + composeTranslate(element, `var(${OFFSET_X_PROP}, 0px)`, `var(${OFFSET_Y_PROP}, 0px)`), + ); }; const readParentFlexBasisPixels = ( @@ -157,7 +230,7 @@ function studioManualEditsRenderRuntime(manifestContent: string): void { width: Math.round(Math.max(1, width)), height: Math.round(Math.max(1, height)), }; - element.setAttribute("data-hf-studio-box-size", "true"); + element.setAttribute(BOX_SIZE_ATTR, "true"); element.style.setProperty(WIDTH_PROP, `${rounded.width}px`); element.style.setProperty(HEIGHT_PROP, `${rounded.height}px`); element.style.setProperty("box-sizing", "border-box"); @@ -182,13 +255,8 @@ function studioManualEditsRenderRuntime(manifestContent: string): void { const applyRotation = (element: HTMLElement, edit: Record): void => { const angle = finiteNumber(edit.angle); if (angle == null) return; - if (!element.hasAttribute("data-hf-studio-rotation")) { - element.setAttribute( - "data-hf-studio-original-rotate", - element.style.getPropertyValue("rotate"), - ); - } - element.setAttribute("data-hf-studio-rotation", "true"); + prepareRotationBase(element); + element.setAttribute(ROTATION_ATTR, "true"); element.style.setProperty(ROTATION_PROP, `${roundRotationAngle(angle)}deg`); element.style.setProperty("rotate", composeRotation(element, `var(${ROTATION_PROP}, 0deg)`)); }; diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index 53bb57d34a..50eaa87192 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -751,10 +751,13 @@ function devProjectApi(): Plugin { const isProjectFile = realProjectPaths.some((p) => filePath.startsWith(p)); if ( isProjectFile && - (filePath.endsWith(".html") || filePath.endsWith(".css") || filePath.endsWith(".js")) + (filePath.endsWith(".html") || + filePath.endsWith(".css") || + filePath.endsWith(".js") || + filePath.endsWith(".json")) ) { console.log(`[Studio] File changed: ${filePath}`); - server.ws.send({ type: "custom", event: "hf:file-change", data: {} }); + server.ws.send({ type: "custom", event: "hf:file-change", data: { path: filePath } }); } }); },