diff --git a/.filesize-allowlist b/.filesize-allowlist index a9261f39f..b4dbc51b4 100644 --- a/.filesize-allowlist +++ b/.filesize-allowlist @@ -1,3 +1,5 @@ packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/hooks/useManifestPersistence.ts packages/studio/src/player/components/PlayerControls.tsx +packages/studio/src/components/editor/manualEdits.test.ts +packages/studio/src/components/editor/manualEditsDom.ts diff --git a/packages/core/src/inline-scripts/parityContract.ts b/packages/core/src/inline-scripts/parityContract.ts index a57165656..9af34d4eb 100644 --- a/packages/core/src/inline-scripts/parityContract.ts +++ b/packages/core/src/inline-scripts/parityContract.ts @@ -24,6 +24,9 @@ export const MEDIA_VISUAL_STYLE_PROPERTIES = [ "mask-repeat", "transform", "transform-origin", + "translate", + "rotate", + "scale", "box-sizing", ] as const; diff --git a/packages/core/src/lint/rules/core.ts b/packages/core/src/lint/rules/core.ts index f1fa46662..b4991d234 100644 --- a/packages/core/src/lint/rules/core.ts +++ b/packages/core/src/lint/rules/core.ts @@ -319,4 +319,56 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ } return findings; }, + + // pointer_events_none + ({ tags, styles }) => { + const findings: HyperframeLintFinding[] = []; + const reported = new Set(); + + for (const tag of tags) { + if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) continue; + const inlineStyle = readAttr(tag.raw, "style") ?? ""; + if (!/pointer-events\s*:\s*none/i.test(inlineStyle)) continue; + const id = readAttr(tag.raw, "id"); + const key = id ?? tag.raw; + if (reported.has(key)) continue; + reported.add(key); + findings.push({ + code: "pointer_events_none", + severity: "info", + message: `<${tag.name}${id ? ` id="${id}"` : ""}> has \`pointer-events: none\` in its inline style. Elements with this property are harder to select in the Studio preview.`, + elementId: id || undefined, + fixHint: + "If this element should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.", + snippet: truncateSnippet(tag.raw), + }); + } + + for (const style of styles) { + let root: postcss.Root; + try { + root = postcss.parse(style.content); + } catch { + continue; + } + root.walkDecls("pointer-events", (decl) => { + if (decl.value.trim().toLowerCase() !== "none") return; + const rule = decl.parent; + if (!rule || rule.type !== "rule") return; + const selector = (rule as postcss.Rule).selector; + if (reported.has(selector)) return; + reported.add(selector); + findings.push({ + code: "pointer_events_none", + severity: "info", + message: `\`${selector}\` sets \`pointer-events: none\`. Elements matching this selector are harder to select in the Studio preview.`, + selector, + fixHint: + "If these elements should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.", + }); + }); + } + + return findings; + }, ]; diff --git a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts index 444bb9f0b..0ca7b4b72 100644 --- a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts +++ b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts @@ -12,6 +12,217 @@ export function createStudioManualEditsRenderBodyScript( return `(${studioManualEditsRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`; } +/** + * Returns a self-contained IIFE string that re-applies studio position edits + * (translate, rotate) after every GSAP seek by querying data attributes baked + * into the HTML. Works without a JSON manifest — positions are already inlined + * as CSS custom properties on the elements. + */ +export function createStudioPositionSeekReapplyScript(): string { + return `(${studioPositionSeekReapplyRuntime.toString()})();`; +} + +function studioPositionSeekReapplyRuntime(): void { + const OFFSET_X_PROP = "--hf-studio-offset-x"; + const OFFSET_Y_PROP = "--hf-studio-offset-y"; + const ROTATION_PROP = "--hf-studio-rotation"; + const PATH_OFFSET_ATTR = "data-hf-studio-path-offset"; + 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_PROP = "__hfStudioPositionSeekReapplyWrapped"; + + if ( + !document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') && + !document.querySelector("[" + ROTATION_ATTR + '="true"]') + ) + return; + + 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) { + const z = parts.length >= 3 ? " " + parts[2] : ""; + return "calc(" + parts[0] + " + " + x + ") calc(" + parts[1] + " + " + y + ")" + z; + } + return x + " " + y; + }; + + const isSimpleRotateAngle = (value: string): boolean => + /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim()); + + const composeRotation = (element: HTMLElement, rotationValue: string): string => { + const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim(); + if (!original || original === "none" || !isSimpleRotateAngle(original)) return rotationValue; + return "calc(" + original + " + " + rotationValue + ")"; + }; + + const reapplyAll = (): void => { + const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]'); + for (let i = 0; i < offsetEls.length; i++) { + const el = offsetEls[i] as HTMLElement; + if (!(el instanceof HTMLElement)) continue; + const x = el.style.getPropertyValue(OFFSET_X_PROP); + const y = el.style.getPropertyValue(OFFSET_Y_PROP); + if (x || y) { + el.style.setProperty( + "translate", + composeTranslate( + el, + "var(" + OFFSET_X_PROP + ", 0px)", + "var(" + OFFSET_Y_PROP + ", 0px)", + ), + ); + } + } + const rotEls = document.querySelectorAll("[" + ROTATION_ATTR + '="true"]'); + for (let i = 0; i < rotEls.length; i++) { + const el = rotEls[i] as HTMLElement; + if (!(el instanceof HTMLElement)) continue; + const rot = el.style.getPropertyValue(ROTATION_PROP); + if (rot) { + el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)")); + } + } + }; + + const runtimeWindow = window as Window & { + __hf?: Record; + __player?: Record; + }; + + const isWrapped = (fn: (time: number) => unknown): boolean => + Boolean((fn as unknown as Record)[WRAPPED_PROP]); + + const markWrapped = (fn: (time: number) => unknown): void => { + try { + Object.defineProperty(fn, WRAPPED_PROP, { + configurable: false, + enumerable: false, + value: true, + }); + } catch { + try { + (fn as unknown as Record)[WRAPPED_PROP] = true; + } catch { + /* ignore */ + } + } + }; + + const wrapFn = (get: () => unknown, set: (fn: (time: number) => unknown) => void): boolean => { + const fn = get(); + if (typeof fn !== "function") return false; + const seek = fn as (time: number) => unknown; + if (isWrapped(seek)) { + reapplyAll(); + return true; + } + const wrapped = function (this: unknown, time: number): unknown { + const result = seek.call(this, time); + reapplyAll(); + return result; + }; + markWrapped(wrapped); + set(wrapped); + reapplyAll(); + return true; + }; + + const wrapSeekFunctions = (): boolean => { + const a = wrapFn( + () => runtimeWindow.__hf?.["seek"], + (fn) => { + if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn; + }, + ); + const b = wrapFn( + () => runtimeWindow.__player?.["renderSeek"], + (fn) => { + if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn; + }, + ); + return a || b; + }; + + const installSeekTrap = ( + obj: Record | undefined, + key: string, + getter: () => unknown, + setter: (fn: (time: number) => unknown) => void, + ): void => { + if (!obj) return; + try { + let current = obj[key]; + Object.defineProperty(obj, key, { + configurable: true, + enumerable: true, + get() { + return current; + }, + set(value: unknown) { + current = value; + if (typeof value === "function" && !isWrapped(value as (time: number) => unknown)) { + wrapFn(getter, setter); + } + }, + }); + } catch { + /* non-configurable — fall back to polling */ + } + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => reapplyAll(), { once: true }); + } else { + reapplyAll(); + } + + wrapSeekFunctions(); + installSeekTrap( + runtimeWindow.__hf, + "seek", + () => runtimeWindow.__hf?.["seek"], + (fn) => { + if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn; + }, + ); + installSeekTrap( + runtimeWindow.__player as Record | undefined, + "renderSeek", + () => runtimeWindow.__player?.["renderSeek"], + (fn) => { + if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn; + }, + ); + let remaining = 120; + const interval = setInterval(() => { + wrapSeekFunctions(); + remaining -= 1; + if (remaining <= 0) clearInterval(interval); + }, 50); +} + function studioManualEditsRenderRuntime( manifestContent: string, activeCompositionPath: string | null, diff --git a/packages/core/src/studio-api/index.ts b/packages/core/src/studio-api/index.ts index 9ac338b30..bbc77f69d 100644 --- a/packages/core/src/studio-api/index.ts +++ b/packages/core/src/studio-api/index.ts @@ -8,6 +8,7 @@ export { getElementScreenshotClip, type ScreenshotClip } from "./helpers/screens export { STUDIO_MANUAL_EDITS_PATH, createStudioManualEditsRenderBodyScript, + createStudioPositionSeekReapplyScript, type StudioManualEditsRenderScriptOptions, } from "./helpers/manualEditsRenderScript.js"; export { diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts index a8cf5293d..cd2ba50e3 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/core/src/studio-api/routes/preview.ts @@ -143,6 +143,21 @@ async function transformPreviewHtml( } } +function resolveProjectMainHtml( + projectDir: string, + projectId: string, +): { html: string; compositionPath: string } | null { + const indexPath = join(projectDir, "index.html"); + if (existsSync(indexPath)) { + return { html: readFileSync(indexPath, "utf-8"), compositionPath: "index.html" }; + } + const blockHtmlPath = join(projectDir, `${projectId}.html`); + if (existsSync(blockHtmlPath)) { + return { html: readFileSync(blockHtmlPath, "utf-8"), compositionPath: `${projectId}.html` }; + } + return null; +} + export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void { const previewCacheHeaders = (etag: string) => ({ "Cache-Control": "private, no-cache", @@ -163,10 +178,12 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi try { let bundled = await adapter.bundle(project.dir); + let mainCompositionPath = "index.html"; if (!bundled) { - const indexPath = resolve(project.dir, "index.html"); - if (!existsSync(indexPath)) return c.text("not found", 404); - bundled = readFileSync(indexPath, "utf-8"); + const main = resolveProjectMainHtml(project.dir, project.id); + if (!main) return c.text("not found", 404); + bundled = main.html; + mainCompositionPath = main.compositionPath; } // Inject runtime if not already present (check URL pattern and bundler attribute) @@ -187,21 +204,21 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi } bundled = injectStudioPreviewAugmentations( - await transformPreviewHtml(bundled, adapter, project, "index.html"), + await transformPreviewHtml(bundled, adapter, project, mainCompositionPath), adapter, project.dir, - "index.html", + mainCompositionPath, ); return c.html(bundled, 200, previewCacheHeaders(etag)); } catch { - const file = resolve(project.dir, "index.html"); - if (existsSync(file)) { + const main = resolveProjectMainHtml(project.dir, project.id); + if (main) { return c.html( injectStudioPreviewAugmentations( - await transformPreviewHtml(readFileSync(file, "utf-8"), adapter, project, "index.html"), + await transformPreviewHtml(main.html, adapter, project, main.compositionPath), adapter, project.dir, - "index.html", + main.compositionPath, ), 200, previewCacheHeaders(etag), diff --git a/packages/engine/src/services/videoFrameInjector.ts b/packages/engine/src/services/videoFrameInjector.ts index 187a08f19..ddc79722a 100644 --- a/packages/engine/src/services/videoFrameInjector.ts +++ b/packages/engine/src/services/videoFrameInjector.ts @@ -525,30 +525,66 @@ export async function queryElementStacking( if (!htmlEl) continue; mat = mat.translate(htmlEl.offsetLeft, htmlEl.offsetTop); const cs = window.getComputedStyle(htmlEl); - if (cs.transform && cs.transform !== "none") { - const origin = cs.transformOrigin.split(" "); - const ox = resolveLength(origin[0] ?? "0", htmlEl.offsetWidth); - const oy = resolveLength(origin[1] ?? "0", htmlEl.offsetHeight); - try { - const t = new DOMMatrix(cs.transform); - if ( - Number.isFinite(t.a) && - Number.isFinite(t.b) && - Number.isFinite(t.c) && - Number.isFinite(t.d) && - Number.isFinite(t.e) && - Number.isFinite(t.f) - ) { - mat = mat.translate(ox, oy).multiply(t).translate(-ox, -oy); + const origin = cs.transformOrigin.split(" "); + const ox = resolveLength(origin[0] ?? "0", htmlEl.offsetWidth); + const oy = resolveLength(origin[1] ?? "0", htmlEl.offsetHeight); + const individualTransform = composeIndividualTransforms(cs); + const hasIndividual = individualTransform !== null; + const hasTransform = cs.transform && cs.transform !== "none"; + if (hasIndividual || hasTransform) { + mat = mat.translate(ox, oy); + if (hasIndividual) mat = mat.multiply(individualTransform); + if (hasTransform) { + try { + const t = new DOMMatrix(cs.transform); + if ( + Number.isFinite(t.a) && + Number.isFinite(t.b) && + Number.isFinite(t.c) && + Number.isFinite(t.d) && + Number.isFinite(t.e) && + Number.isFinite(t.f) + ) { + mat = mat.multiply(t); + } + } catch { + // DOMMatrix constructor throws on malformed input — skip. } - } catch { - // DOMMatrix constructor throws on malformed input — skip ancestor. } + mat = mat.translate(-ox, -oy); } } return mat.toString(); } + function composeIndividualTransforms(cs: CSSStyleDeclaration): DOMMatrix | null { + const translate = cs.getPropertyValue("translate").trim(); + const rotate = cs.getPropertyValue("rotate").trim(); + const scale = cs.getPropertyValue("scale").trim(); + const hasTranslate = translate && translate !== "none"; + const hasRotate = rotate && rotate !== "none"; + const hasScale = scale && scale !== "none"; + if (!hasTranslate && !hasRotate && !hasScale) return null; + let m = new DOMMatrix(); + if (hasTranslate) { + const parts = translate.split(/\s+/); + const tx = parseFloat(parts[0] ?? "0") || 0; + const ty = parseFloat(parts[1] ?? "0") || 0; + if (tx !== 0 || ty !== 0) m = m.translate(tx, ty); + } + if (hasRotate) { + const deg = parseFloat(rotate) || 0; + if (deg !== 0) m = m.rotate(deg); + } + if (hasScale) { + const parts = scale.split(/\s+/); + const sx = parseFloat(parts[0] ?? "1") || 1; + const sy = parseFloat(parts[1] ?? String(sx)) || sx; + if (sx !== 1 || sy !== 1) m = m.scale(sx, sy); + } + return m; + } + function resolveLength(value: string, basis: number): number { if (value.endsWith("%")) { const pct = parseFloat(value) / 100; diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 486b68b1c..4c44d534e 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -38,6 +38,7 @@ import { import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js"; import type { Page } from "puppeteer-core"; import { injectDeterministicFontFaces } from "./deterministicFonts.js"; +import { createStudioPositionSeekReapplyScript } from "@hyperframes/core/studio-api/manual-edits-render-script"; export interface CompiledComposition { html: string; @@ -993,7 +994,20 @@ export async function compileForRender( // Collect assets that resolve outside projectDir (e.g. ../shared-assets/hero.png). // These can't be served by the file server, so we map them to paths the // orchestrator will copy into the compiled output directory. - const { html, externalAssets } = collectExternalAssets(assembledHtml, projectDir); + const { html: htmlWithAssets, externalAssets } = collectExternalAssets(assembledHtml, projectDir); + + // Inject studio position seek re-apply script when positions are baked into HTML. + // GSAP overwrites the `translate` CSS property on every frame seek; this script + // re-asserts the CSS custom property var() form after each seek so dragged + // positions survive frame-by-frame rendering without a JSON sidecar. + const HF_POSITION_ATTRS = ['data-hf-studio-path-offset="true"', 'data-hf-studio-rotation="true"']; + const hasPositionEdits = HF_POSITION_ATTRS.some((attr) => htmlWithAssets.includes(attr)); + const html = hasPositionEdits + ? htmlWithAssets.replace( + /<\/body>/i, + ``, + ) + : htmlWithAssets; // Parse main HTML elements const mainVideos = parseVideoElements(html); diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index eca3c9bb5..6454ce02a 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -144,6 +144,8 @@ export function StudioApp() { recordEdit: editHistory.recordEdit, previewIframeRef, activeCompPathRef, + domEditSaveTimestampRef, + reloadPreview: () => setRefreshKey((k) => k + 1), }); const timelineEditing = useTimelineEditing({ @@ -197,12 +199,9 @@ export function StudioApp() { setRightPanelTab: panelLayout.setRightPanelTab, showToast, refreshPreviewDocumentVersion, - commitStudioManualEditManifestOptimistically: - manifestPersistence.commitStudioManualEditManifestOptimistically, + queueDomEditSave: manifestPersistence.queueDomEditSave, commitStudioMotionManifestOptimistically: manifestPersistence.commitStudioMotionManifestOptimistically, - applyCurrentStudioManualEditsToPreview: - manifestPersistence.applyCurrentStudioManualEditsToPreview, applyCurrentStudioMotionToPreview: manifestPersistence.applyCurrentStudioMotionToPreview, readProjectFile: fileManager.readProjectFile, writeProjectFile: fileManager.writeProjectFile, diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index e5ee751ca..64a169ac8 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -59,7 +59,6 @@ export function StudioRightPanel({ handleDomTextFieldStyleCommit, handleDomAddTextField, handleDomRemoveTextField, - handleDomManualEditsReset, handleAskAgent, handleDomMotionCommit, handleDomMotionClear, @@ -173,7 +172,6 @@ export function StudioRightPanel({ onSetTextFieldStyle={handleDomTextFieldStyleCommit} onAddTextField={handleDomAddTextField} onRemoveTextField={handleDomRemoveTextField} - onResetManualEdits={handleDomManualEditsReset} onAskAgent={handleAskAgent} onImportAssets={handleImportFiles} fontAssets={fontAssets} diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index d04e8e2d8..cf6303465 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -113,6 +113,7 @@ describe("DomEditOverlay", () => { capabilities: { canEditText: true, canEditLayout: true, + canMove: true, canApplyManualOffset: true, canApplyManualSize: false, canApplyManualRotation: false, diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index bb4b3d4d0..bd659a582 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -138,6 +138,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ const gestures = createDomEditOverlayGestureHandlers({ overlayRef, + iframeRef, boxRef, selectionRef, overlayRectRef, @@ -307,7 +308,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ cursor: allowCanvasMovement && groupCanMove ? "move" : "default", }} onPointerDown={(e) => { - if (!allowCanvasMovement || e.shiftKey) return; + if (!allowCanvasMovement || !groupCanMove || e.shiftKey) return; gestures.startGroupDrag(e); }} onMouseDown={suppressBoxMouseDown} diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 5cf12bd5c..73947bf61 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -1,5 +1,5 @@ import { memo } from "react"; -import { Eye, Layers, MessageSquare, Move, RotateCcw, X } from "../../icons/SystemIcons"; +import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons"; import { collectDomEditLayerItems, getDomEditLayerKey, @@ -46,7 +46,6 @@ interface PropertyPanelProps { onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void; onAddTextField: (afterFieldKey?: string) => string | Promise | null; onRemoveTextField: (fieldKey: string) => void; - onResetManualEdits: (element: DomEditSelection) => void; onAskAgent: () => void; onImportAssets?: (files: FileList) => Promise; fontAssets?: ImportedFontAsset[]; @@ -134,7 +133,6 @@ export const PropertyPanel = memo(function PropertyPanel({ onSetTextFieldStyle, onAddTextField, onRemoveTextField, - onResetManualEdits, onAskAgent, onImportAssets, fontAssets = [], @@ -146,30 +144,32 @@ export const PropertyPanel = memo(function PropertyPanel({ if (!element) { return ( -
- {multiSelectCount > 1 ? ( - <> - -

- {multiSelectCount} elements selected -

-

- Select a single element to edit its properties. Click an element in the preview or use - the timeline layer panel. -

- - ) : ( - <> - -

- Select an element in the preview. -

-

- The inspector is tuned for element edits with safer geometry controls, color picking, - and cleaner grouped layer controls. -

- - )} +
+
+ {multiSelectCount > 1 ? ( + <> + +

+ {multiSelectCount} elements selected +

+

+ Select a single element to edit its properties. Click an element in the preview or + use the timeline layer panel. +

+ + ) : ( + <> + +

+ Select an element in the preview. +

+

+ The inspector is tuned for element edits with safer geometry controls, color + picking, and cleaner grouped layer controls. +

+ + )} +
); } @@ -253,15 +253,6 @@ export const PropertyPanel = memo(function PropertyPanel({ {copiedAgentPrompt ? "Prompt copied" : "Ask agent"} -
diff --git a/packages/studio/src/components/editor/domEditingElement.ts b/packages/studio/src/components/editor/domEditingElement.ts index a6a040143..96d33ea12 100644 --- a/packages/studio/src/components/editor/domEditingElement.ts +++ b/packages/studio/src/components/editor/domEditingElement.ts @@ -118,6 +118,7 @@ export function getDomLayerPatchTarget( activeCompositionPath: string | null, ): Pick | null { if (!isInspectableLayerElement(el)) return null; + if (el.hasAttribute("data-composition-id")) return null; const selector = buildStableSelector(el); if (!selector) return null; diff --git a/packages/studio/src/components/editor/manualEdits.test.ts b/packages/studio/src/components/editor/manualEdits.test.ts index 4be1db5dd..8492ef746 100644 --- a/packages/studio/src/components/editor/manualEdits.test.ts +++ b/packages/studio/src/components/editor/manualEdits.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from "vitest"; import { Window } from "happy-dom"; -import type { DomEditSelection } from "./domEditing"; import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, @@ -8,7 +7,6 @@ import { STUDIO_WIDTH_PROP, applyStudioBoxSize, applyStudioBoxSizeDraft, - applyStudioManualEditManifest, applyStudioPathOffset, applyStudioPathOffsetDraft, applyStudioRotation, @@ -16,22 +14,17 @@ import { beginStudioManualEditGesture, captureStudioBoxSize, captureStudioRotation, - emptyStudioManualEditManifest, + clearStudioBoxSize, + clearStudioPathOffset, + clearStudioRotation, endStudioManualEditGesture, installStudioManualEditSeekReapply, - isStudioManualEditManifestPath, - parseStudioManualEditManifest, - readStudioFileChangePath, readStudioBoxSize, + readStudioFileChangePath, readStudioPathOffset, readStudioRotation, - removeStudioManualEditsForSelection, restoreStudioBoxSize, restoreStudioRotation, - serializeStudioManualEditManifest, - upsertStudioBoxSizeEdit, - upsertStudioPathOffsetEdit, - upsertStudioRotationEdit, } from "./manualEdits"; function createDocument(markup: string): Document { @@ -40,36 +33,6 @@ function createDocument(markup: string): Document { return window.document; } -function createSelection(): DomEditSelection { - return { - element: {} as HTMLElement, - id: "card", - selector: "#card", - selectorIndex: undefined, - sourceFile: "index.html", - compositionPath: "index.html", - compositionSrc: undefined, - isCompositionHost: false, - label: "Card", - tagName: "div", - boundingBox: { x: 0, y: 0, width: 100, height: 100 }, - textContent: null, - dataAttributes: {}, - inlineStyles: {}, - computedStyles: {}, - textFields: [], - capabilities: { - canSelect: true, - canEditStyles: true, - canMove: false, - canResize: false, - canApplyManualOffset: true, - canApplyManualSize: true, - canApplyManualRotation: true, - }, - }; -} - function mockBoundingRect(element: HTMLElement, width: number, height: number): void { element.getBoundingClientRect = () => ({ @@ -95,149 +58,13 @@ function mockComputedStyle(element: HTMLElement, values: Record) } describe("studio manual edits", () => { - it("upserts path offsets by stable target", () => { - const manifest = upsertStudioPathOffsetEdit( - emptyStudioManualEditManifest(), - createSelection(), - { - x: 12.4, - y: 30.6, - }, - ); - const updated = upsertStudioPathOffsetEdit(manifest, createSelection(), { - x: 20, - y: 42, - }); - - expect(updated.edits).toHaveLength(1); - expect(updated.edits[0]).toMatchObject({ - kind: "path-offset", - target: { sourceFile: "index.html", selector: "#card", id: "card" }, - x: 20, - y: 42, - }); - }); - - it("upserts box sizes without replacing path offsets for the same target", () => { - const selection = createSelection(); - const manifest = upsertStudioPathOffsetEdit(emptyStudioManualEditManifest(), selection, { - x: 12, - y: 30, - }); - const updated = upsertStudioBoxSizeEdit(manifest, selection, { - width: 240.4, - height: 120.6, - }); - const resized = upsertStudioBoxSizeEdit(updated, selection, { - width: 260, - height: 140, - }); - - expect(resized.edits).toHaveLength(2); - expect(resized.edits).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: "path-offset", x: 12, y: 30 }), - expect.objectContaining({ kind: "box-size", width: 260, height: 140 }), - ]), - ); - }); - - it("upserts rotations without replacing other manual edits for the same target", () => { - const selection = createSelection(); - const manifest = upsertStudioPathOffsetEdit(emptyStudioManualEditManifest(), selection, { - x: 12, - y: 30, - }); - const resized = upsertStudioBoxSizeEdit(manifest, selection, { - width: 240, - height: 120, - }); - const rotated = upsertStudioRotationEdit(resized, selection, { angle: 32.34 }); - const updated = upsertStudioRotationEdit(rotated, selection, { angle: -14.96 }); - - expect(updated.edits).toHaveLength(3); - expect(updated.edits).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: "path-offset", x: 12, y: 30 }), - expect.objectContaining({ kind: "box-size", width: 240, height: 120 }), - expect.objectContaining({ kind: "rotation", angle: -15 }), - ]), - ); - }); - - it("removes all manual edits for the selected target", () => { - const selection = createSelection(); - const otherSelection = { - ...createSelection(), - id: "other-card", - selector: "#other-card", - label: "Other card", - }; - const moved = upsertStudioPathOffsetEdit(emptyStudioManualEditManifest(), selection, { - x: 12, - y: 30, - }); - const resized = upsertStudioBoxSizeEdit(moved, selection, { - width: 240, - height: 120, - }); - const rotated = upsertStudioRotationEdit(resized, selection, { angle: 32 }); - const manifest = upsertStudioPathOffsetEdit(rotated, otherSelection, { x: 4, y: 8 }); - - const updated = removeStudioManualEditsForSelection(manifest, selection); - - expect(updated.edits).toHaveLength(1); - expect(updated.edits[0]).toMatchObject({ - kind: "path-offset", - target: { id: "other-card", selector: "#other-card" }, - x: 4, - y: 8, - }); - }); - - it("round-trips valid manifest entries and drops invalid entries", () => { - const content = serializeStudioManualEditManifest({ - version: 1, - edits: [ - { - kind: "path-offset", - target: { sourceFile: "index.html", selector: "#card", id: "card" }, - x: 10, - y: 20, - }, - { - kind: "box-size", - target: { sourceFile: "index.html", selector: "#card", id: "card" }, - width: 320, - height: 180, - }, - { - kind: "rotation", - target: { sourceFile: "index.html", selector: "#card", id: "card" }, - angle: 22.5, - }, - ], - }); - - expect(parseStudioManualEditManifest(content).edits).toHaveLength(3); - expect(parseStudioManualEditManifest('{ "edits": [{ "kind": "path-offset" }] }').edits).toEqual( - [], - ); - }); - - it("recognizes manual edit manifest file-change payloads", () => { + it("recognizes studio 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", () => { @@ -293,9 +120,8 @@ describe("studio manual edits", () => { applyStudioPathOffset(card, { x: 14, y: -8 }); applyStudioRotation(card, { angle: 12 }); - expect( - applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"), - ).toBe(0); + clearStudioPathOffset(card); + clearStudioRotation(card); expect(card.style.getPropertyValue("translate")).toBe(""); expect(card.style.getPropertyValue("rotate")).toBe(""); @@ -383,54 +209,6 @@ describe("studio manual edits", () => { expect(card.style.getPropertyValue("transform-origin")).toBe("center center"); }); - it("does not recapture a studio rotation draft as the authored base", () => { - const document = createDocument(`
`); - const card = document.getElementById("card") as HTMLElement; - const manifest = parseStudioManualEditManifest(`{ - "version": 1, - "edits": [ - { - "kind": "rotation", - "target": { "sourceFile": "index.html", "selector": "#card", "id": "card" }, - "angle": 35 - } - ] - }`); - - applyStudioRotation(card, { angle: 12 }); - applyStudioRotationDraft(card, { angle: 35 }); - expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + 35deg)"); - - expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1); - - expect(card.style.getPropertyValue("rotate")).toBe( - `calc(8deg + var(${STUDIO_ROTATION_PROP}, 0deg))`, - ); - }); - - it("does not treat a base-free studio rotation draft as authored rotation", () => { - const document = createDocument(`
`); - const card = document.getElementById("card") as HTMLElement; - const manifest = parseStudioManualEditManifest(`{ - "version": 1, - "edits": [ - { - "kind": "rotation", - "target": { "sourceFile": "index.html", "selector": "#card", "id": "card" }, - "angle": 35 - } - ] - }`); - - applyStudioRotation(card, { angle: 12 }); - applyStudioRotationDraft(card, { angle: 35 }); - expect(card.style.getPropertyValue("rotate")).toBe("35deg"); - - expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1); - - expect(card.style.getPropertyValue("rotate")).toBe(`var(${STUDIO_ROTATION_PROP}, 0deg)`); - }); - it("uses height for flex-basis inside column flex containers", () => { const document = createDocument(`
@@ -523,9 +301,7 @@ describe("studio manual edits", () => { expect(tween._startAt.vars).toEqual({ x: -240, y: -20 }); expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP); - expect( - applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"), - ).toBe(0); + clearStudioPathOffset(card); expect(tween.vars).toMatchObject({ x: 0, y: 10, @@ -537,197 +313,48 @@ describe("studio manual edits", () => { expect(card.style.getPropertyValue("translate")).toBe(""); }); - it("applies manifest offsets to matching preview elements", () => { - const document = createDocument(`
`); - const manifest = parseStudioManualEditManifest(`{ - "version": 1, - "edits": [ - { - "kind": "path-offset", - "target": { "sourceFile": "index.html", "selector": "#card", "id": "card" }, - "x": 32, - "y": 18 - } - ] - }`); - - expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1); - expect(readStudioPathOffset(document.getElementById("card") as HTMLElement)).toEqual({ - x: 32, - y: 18, - }); - }); + it("clears path offsets and restores authored inline translate", () => { + const document = createDocument(`
`); + const card = document.getElementById("card") as HTMLElement; - it("resolves manifest targets within the matching source file", () => { - 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]; - const tiles = Array.from(document.getElementsByTagName("*")).filter( - (element): element is HTMLElement => - element instanceof htmlElement && element.classList.contains("tile"), - ); - const nestedSecondTile = tiles[2]; - if (!rootCard || !nestedCard || !nestedSecondTile) { - throw new Error("source-scoped fixture missing"); - } - - const manifest = parseStudioManualEditManifest(`{ - "version": 1, - "edits": [ - { - "kind": "path-offset", - "target": { - "sourceFile": "scenes/nested.html", - "selector": "#card", - "id": "card" - }, - "x": 48, - "y": 16 - }, - { - "kind": "box-size", - "target": { - "sourceFile": "scenes/nested.html", - "selector": ".tile", - "selectorIndex": 1 - }, - "width": 220, - "height": 80 - } - ] - }`); - - expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(2); - expect(readStudioPathOffset(rootCard)).toEqual({ x: 0, y: 0 }); - expect(readStudioPathOffset(nestedCard)).toEqual({ x: 48, y: 16 }); - expect(readStudioBoxSize(nestedSecondTile)).toEqual({ width: 220, height: 80 }); - }); + applyStudioPathOffset(card, { x: 24, y: 12 }); + expect(card.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP); - 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 }); + clearStudioPathOffset(card); + + expect(card.style.getPropertyValue("translate")).toBe("10px 20px"); }); - it("applies nested source edits while previewing a non-index parent composition", () => { - const document = createDocument(` -
-
-
-
-
-
- `); - const parentCard = document.getElementById("parent-card") as HTMLElement; - const childCard = document.getElementById("child-card") as HTMLElement; - const manifest = parseStudioManualEditManifest(`{ - "version": 1, - "edits": [ - { - "kind": "path-offset", - "target": { - "sourceFile": "scenes/parent.html", - "selector": "#parent-card", - "id": "parent-card" - }, - "x": 12, - "y": 8 - }, - { - "kind": "path-offset", - "target": { - "sourceFile": "scenes/child.html", - "selector": "#child-card", - "id": "child-card" - }, - "x": 36, - "y": 18 - } - ] - }`); - - expect(applyStudioManualEditManifest(document, manifest, "scenes/parent.html")).toBe(2); - expect(readStudioPathOffset(parentCard)).toEqual({ x: 12, y: 8 }); - expect(readStudioPathOffset(childCard)).toEqual({ x: 36, y: 18 }); + it("clears stale offsets applied directly to the DOM", () => { + const document = createDocument(`
`); + const card = document.getElementById("card") as HTMLElement; + + applyStudioPathOffset(card, { x: 24, y: 12 }); + expect(readStudioPathOffset(card)).toEqual({ x: 24, y: 12 }); + + clearStudioPathOffset(card); + + expect(readStudioPathOffset(card)).toEqual({ x: 0, y: 0 }); + expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe(""); + expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe(""); + expect(card.style.getPropertyValue("translate")).toBe(""); }); - it("applies and clears manifest box sizes while restoring authored inline size", () => { + it("clears box sizes and restores authored inline size", () => { const document = createDocument(`
`); - const manifest = parseStudioManualEditManifest(`{ - "version": 1, - "edits": [ - { - "kind": "box-size", - "target": { "sourceFile": "index.html", "selector": "#card", "id": "card" }, - "width": 320, - "height": 180 - } - ] - }`); const card = document.getElementById("card") as HTMLElement; mockBoundingRect(card, 160, 90); - expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1); + applyStudioBoxSize(card, { width: 320, height: 180 }); expect(readStudioBoxSize(card)).toEqual({ width: 320, height: 180 }); expect(card.style.getPropertyValue("width")).toBe("320px"); - expect(card.style.getPropertyValue("height")).toBe("180px"); expect(card.style.getPropertyValue("flex-basis")).toBe("320px"); - expect( - applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"), - ).toBe(0); + clearStudioBoxSize(card); expect(readStudioBoxSize(card)).toEqual({ width: 0, height: 0 }); expect(card.style.getPropertyValue("width")).toBe("160px"); expect(card.style.getPropertyValue("height")).toBe("90px"); @@ -737,93 +364,39 @@ describe("studio manual edits", () => { expect(card.style.getPropertyValue("scale")).toBe(""); }); - it("applies and clears manifest rotations while restoring authored inline rotation", () => { + it("clears rotations and restores authored inline rotation", () => { const document = createDocument( `
`, ); - const manifest = parseStudioManualEditManifest(`{ - "version": 1, - "edits": [ - { - "kind": "rotation", - "target": { "sourceFile": "index.html", "selector": "#card", "id": "card" }, - "angle": 37.5 - } - ] - }`); const card = document.getElementById("card") as HTMLElement; - expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1); + applyStudioRotation(card, { angle: 37.5 }); expect(readStudioRotation(card)).toEqual({ angle: 37.5 }); expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP); expect(card.style.getPropertyValue("rotate")).toContain("8deg"); expect(card.style.getPropertyValue("transform-origin")).toBe("center center"); - expect( - applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"), - ).toBe(0); + clearStudioRotation(card); expect(readStudioRotation(card)).toEqual({ angle: 0 }); expect(card.style.getPropertyValue("rotate")).toBe("8deg"); expect(card.style.getPropertyValue("transform-origin")).toBe("left top"); }); - it("clears stale preview offsets that are no longer in the manifest", () => { + it("does not replay a gesture-guarded offset during active gesture", () => { const document = createDocument(`
`); const card = document.getElementById("card") as HTMLElement; - applyStudioPathOffset(card, { x: 24, y: 12 }); - expect(readStudioPathOffset(card)).toEqual({ x: 24, y: 12 }); - - expect( - applyStudioManualEditManifest(document, emptyStudioManualEditManifest(), "index.html"), - ).toBe(0); - - expect(readStudioPathOffset(card)).toEqual({ x: 0, y: 0 }); - expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe(""); - expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe(""); - 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; - const manifest = parseStudioManualEditManifest(`{ - "version": 1, - "edits": [ - { - "kind": "path-offset", - "target": { "sourceFile": "index.html", "selector": "#card", "id": "card" }, - "x": 8, - "y": 4 - } - ] - }`); - applyStudioPathOffset(card, { x: 40, y: 24 }); const firstToken = beginStudioManualEditGesture(card); const secondToken = beginStudioManualEditGesture(card); endStudioManualEditGesture(card, firstToken); - expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(0); + // Gesture still active — offset should remain expect(readStudioPathOffset(card)).toEqual({ x: 40, y: 24 }); endStudioManualEditGesture(card, secondToken); - expect(applyStudioManualEditManifest(document, manifest, "index.html")).toBe(1); - expect(readStudioPathOffset(card)).toEqual({ x: 8, y: 4 }); + // After gesture ends, offset remains (we don't auto-clear in this path) + expect(readStudioPathOffset(card)).toEqual({ x: 40, y: 24 }); }); it("reapplies the latest preview manifest after wrapped seeks", () => { diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts index f56cf53e2..9c5929b1c 100644 --- a/packages/studio/src/components/editor/manualEdits.ts +++ b/packages/studio/src/components/editor/manualEdits.ts @@ -1,34 +1,17 @@ // Public re-exports — consumers import from this file as before. export { - STUDIO_MANUAL_EDITS_PATH, STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, STUDIO_WIDTH_PROP, STUDIO_HEIGHT_PROP, STUDIO_ROTATION_PROP, - type StudioManualEditTarget, - type StudioPathOffsetEdit, - type StudioBoxSizeEdit, - type StudioRotationEdit, - type StudioManualEdit, - type StudioManualEditManifest, type StudioManualEditSeekWindow, type StudioBoxSizeSnapshot, type StudioRotationSnapshot, type StudioPathOffsetSnapshot, } from "./manualEditsTypes"; -export { - emptyStudioManualEditManifest, - parseStudioManualEditManifest, - serializeStudioManualEditManifest, - readStudioFileChangePath, - isStudioManualEditManifestPath, - upsertStudioPathOffsetEdit, - upsertStudioBoxSizeEdit, - upsertStudioRotationEdit, - removeStudioManualEditsForSelection, -} from "./manualEditsParsing"; +export { readStudioFileChangePath } from "./manualEditsParsing"; export { beginStudioManualEditGesture, @@ -46,6 +29,7 @@ export { clearStudioPathOffset, clearStudioRotation, clearStudioBoxSize, + reapplyPositionEditsAfterSeek, } from "./manualEditsDom"; export { @@ -57,27 +41,14 @@ export { restoreStudioPathOffset, } from "./manualEditsSnapshot"; -import type { - StudioManualEdit, - StudioManualEditManifest, - StudioManualEditSeekWindow, -} from "./manualEditsTypes"; +import type { StudioManualEditSeekWindow } from "./manualEditsTypes"; import { STUDIO_MANUAL_EDITS_APPLY_PROP, STUDIO_MANUAL_EDITS_WRAPPED_PROP, STUDIO_MANUAL_EDITS_PLAYBACK_FRAME_PROP, } from "./manualEditsTypes"; import { finiteNumber } from "./manualEditsParsing"; -import { - applyStudioPathOffset, - applyStudioBoxSize, - applyStudioRotation, - isStudioManualEditGestureActive, - clearStudioPathOffset, - clearStudioBoxSize, - clearStudioRotation, -} from "./manualEditsDom"; -import { collectStudioManualEditElements } from "./manualEditsSnapshot"; +import { isStudioManualEditGestureActive } from "./manualEditsDom"; /* ── Seek/play reapply wrappers ───────────────────────────────────── */ function markWrapped(fn: (...args: unknown[]) => unknown): void { @@ -307,138 +278,5 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi ); } -/* ── DOM target resolution ────────────────────────────────────────── */ -function getManualEditSourceFileForElement( - el: HTMLElement, - activeCompositionPath: string | null, -): string { - 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( - element: HTMLElement, - sourceFile: string, - activeCompositionPath: string | null, -): boolean { - return getManualEditSourceFileForElement(element, activeCompositionPath) === sourceFile; -} - -function queryManualEditSelectorCandidates( - doc: Document, - 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 => - isCandidate(element) && element.classList.contains(className), - ); - } - - if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) { - return Array.from(doc.getElementsByTagName(selector)).filter(isCandidate); - } - - return Array.from(doc.querySelectorAll(selector)).filter(isCandidate); -} - -function resolveManualEditTarget( - doc: Document, - edit: StudioManualEdit, - activeCompositionPath: string | null, -): HTMLElement | null { - const htmlElement = doc.defaultView?.HTMLElement; - if (!htmlElement) return null; - - if (edit.target.id) { - const byId = doc.getElementById(edit.target.id); - if ( - byId instanceof htmlElement && - elementMatchesManualEditSourceFile(byId, edit.target.sourceFile, activeCompositionPath) - ) { - return byId; - } - - const matchesById = [doc.documentElement, ...Array.from(doc.getElementsByTagName("*"))].filter( - (element): element is HTMLElement => - element instanceof htmlElement && - element.id === edit.target.id && - elementMatchesManualEditSourceFile(element, edit.target.sourceFile, activeCompositionPath), - ); - if (matchesById[0]) return matchesById[0]; - } - - if (!edit.target.selector) return null; - try { - const matches = queryManualEditSelectorCandidates( - doc, - edit.target.selector, - htmlElement, - ).filter((element) => - elementMatchesManualEditSourceFile(element, edit.target.sourceFile, activeCompositionPath), - ); - return matches[edit.target.selectorIndex ?? 0] ?? null; - } catch { - return null; - } -} - -/* ── Manifest application ─────────────────────────────────────────── */ -export function applyStudioManualEditManifest( - doc: Document, - manifest: StudioManualEditManifest, - activeCompositionPath: string | null, -): number { - const resolvedEdits: Array<{ edit: StudioManualEdit; element: HTMLElement }> = []; - const pathOffsetTargets = new Set(); - const boxSizeTargets = new Set(); - const rotationTargets = new Set(); - - for (const edit of manifest.edits) { - const element = resolveManualEditTarget(doc, edit, activeCompositionPath); - if (!element) continue; - if (isStudioManualEditGestureActive(element)) { - continue; - } - resolvedEdits.push({ edit, element }); - if (edit.kind === "path-offset") pathOffsetTargets.add(element); - if (edit.kind === "box-size") boxSizeTargets.add(element); - if (edit.kind === "rotation") rotationTargets.add(element); - } - - for (const element of collectStudioManualEditElements(doc)) { - if (isStudioManualEditGestureActive(element)) continue; - if (!pathOffsetTargets.has(element)) { - clearStudioPathOffset(element); - } - if (!boxSizeTargets.has(element)) { - clearStudioBoxSize(element); - } - if (!rotationTargets.has(element)) { - clearStudioRotation(element); - } - } - - let applied = 0; - for (const { edit, element } of resolvedEdits) { - if (edit.kind === "path-offset") { - applyStudioPathOffset(element, { x: edit.x, y: edit.y }); - } else if (edit.kind === "box-size") { - applyStudioBoxSize(element, { width: edit.width, height: edit.height }); - } else { - applyStudioRotation(element, { angle: edit.angle }); - } - applied += 1; - } - return applied; -} +// Re-export for internal use (seek hooks need this) +export { isStudioManualEditGestureActive }; diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 24c2afb5a..efacb6a4d 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -209,12 +209,39 @@ function writeStudioPathOffsetVars( } /* ── Path offset apply ────────────────────────────────────────────── */ + +// GSAP 3.x reads the resolved CSS `translate` individual property at initialization and bakes it +// into element.style.transform (as a matrix) on every seek. When the studio's reapply hook also +// writes `translate`, both properties compose additively, doubling the visual offset. This helper +// zeroes out only the translate component (m41/m42) so the `translate` prop isn't double-counted. +function stripGsapTranslateFromTransform(element: HTMLElement): void { + const transform = element.style.getPropertyValue("transform"); + if (!transform || transform === "none") return; + const win = element.ownerDocument.defaultView as (Window & typeof globalThis) | null; + const DOMMatrixCtor = (win as unknown as { DOMMatrix?: typeof DOMMatrix })?.DOMMatrix; + if (!DOMMatrixCtor) return; + try { + const m = new DOMMatrixCtor(transform); + if (m.m41 === 0 && m.m42 === 0) return; + m.m41 = 0; + m.m42 = 0; + if (m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1) { + element.style.removeProperty("transform"); + } else { + element.style.setProperty("transform", m.toString()); + } + } catch { + // non-parseable transform or DOMMatrix unavailable — leave as-is + } +} + export function applyStudioPathOffset( element: HTMLElement, offset: { x: number; y: number }, + options: { updateBase?: boolean } = {}, ): void { promoteInlineForTransform(element); - writeStudioPathOffsetVars(element, offset); + writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true }); element.style.setProperty( "translate", composeTranslateValue( @@ -223,6 +250,7 @@ export function applyStudioPathOffset( `var(${STUDIO_OFFSET_Y_PROP}, 0px)`, ), ); + stripGsapTranslateFromTransform(element); } export function applyStudioPathOffsetDraft( @@ -235,6 +263,7 @@ export function applyStudioPathOffsetDraft( "translate", composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`), ); + stripGsapTranslateFromTransform(element); } /* ── Box size apply ───────────────────────────────────────────────── */ @@ -434,3 +463,334 @@ export { clearStudioRotation, clearStudioBoxSize, } from "./manualEditsSnapshot"; + +/* ── HTML patch builders ──────────────────────────────────────────── */ +import type { PatchOperation } from "../../utils/sourcePatcher"; + +export function buildPathOffsetPatches(element: HTMLElement): PatchOperation[] { + const x = element.style.getPropertyValue(STUDIO_OFFSET_X_PROP); + const y = element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); + const translate = element.style.getPropertyValue("translate"); + const originalTranslate = element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR); + const originalInlineTranslate = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR); + const displayVal = element.style.getPropertyValue("display"); + const transformDisplayAttr = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR); + const ops: PatchOperation[] = []; + if (x) ops.push({ type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: x }); + if (y) ops.push({ type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: y }); + if (translate) ops.push({ type: "inline-style", property: "translate", value: translate }); + ops.push({ type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: "true" }); + if (originalTranslate !== null) + ops.push({ + type: "attribute", + property: STUDIO_ORIGINAL_TRANSLATE_ATTR, + value: originalTranslate, + }); + if (originalInlineTranslate !== null) + ops.push({ + type: "attribute", + property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, + value: originalInlineTranslate, + }); + if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal }); + if (transformDisplayAttr !== null) + ops.push({ + type: "attribute", + property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, + value: transformDisplayAttr, + }); + return ops; +} + +export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperation[] { + const originalInlineTranslate = element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR); + const ops: PatchOperation[] = [ + { type: "inline-style", property: STUDIO_OFFSET_X_PROP, value: null }, + { type: "inline-style", property: STUDIO_OFFSET_Y_PROP, value: null }, + { + type: "inline-style", + property: "translate", + value: originalInlineTranslate || null, + }, + { type: "attribute", property: STUDIO_PATH_OFFSET_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_TRANSLATE_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR, value: null }, + ]; + const origDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR); + if (origDisplay !== null) { + ops.push({ type: "inline-style", property: "display", value: origDisplay || null }); + ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null }); + } + return ops; +} + +export function buildBoxSizePatches(element: HTMLElement): PatchOperation[] { + const ops: PatchOperation[] = []; + + const studioWidth = element.style.getPropertyValue(STUDIO_WIDTH_PROP); + const studioHeight = element.style.getPropertyValue(STUDIO_HEIGHT_PROP); + if (studioWidth) + ops.push({ type: "inline-style", property: STUDIO_WIDTH_PROP, value: studioWidth }); + if (studioHeight) + ops.push({ type: "inline-style", property: STUDIO_HEIGHT_PROP, value: studioHeight }); + + const width = element.style.getPropertyValue("width"); + const height = element.style.getPropertyValue("height"); + const minWidth = element.style.getPropertyValue("min-width"); + const minHeight = element.style.getPropertyValue("min-height"); + const maxWidth = element.style.getPropertyValue("max-width"); + const maxHeight = element.style.getPropertyValue("max-height"); + const flexBasis = element.style.getPropertyValue("flex-basis"); + const flexGrow = element.style.getPropertyValue("flex-grow"); + const flexShrink = element.style.getPropertyValue("flex-shrink"); + const boxSizing = element.style.getPropertyValue("box-sizing"); + const scale = element.style.getPropertyValue("scale"); + const transformOrigin = element.style.getPropertyValue("transform-origin"); + const displayVal = element.style.getPropertyValue("display"); + + if (width) ops.push({ type: "inline-style", property: "width", value: width }); + if (height) ops.push({ type: "inline-style", property: "height", value: height }); + if (minWidth) ops.push({ type: "inline-style", property: "min-width", value: minWidth }); + if (minHeight) ops.push({ type: "inline-style", property: "min-height", value: minHeight }); + if (maxWidth) ops.push({ type: "inline-style", property: "max-width", value: maxWidth }); + if (maxHeight) ops.push({ type: "inline-style", property: "max-height", value: maxHeight }); + if (flexBasis) ops.push({ type: "inline-style", property: "flex-basis", value: flexBasis }); + if (flexGrow) ops.push({ type: "inline-style", property: "flex-grow", value: flexGrow }); + if (flexShrink) ops.push({ type: "inline-style", property: "flex-shrink", value: flexShrink }); + if (boxSizing) ops.push({ type: "inline-style", property: "box-sizing", value: boxSizing }); + if (scale) ops.push({ type: "inline-style", property: "scale", value: scale }); + if (transformOrigin) + ops.push({ type: "inline-style", property: "transform-origin", value: transformOrigin }); + if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal }); + + ops.push({ type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: "true" }); + + const origWidth = element.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR); + const origHeight = element.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR); + const origMinWidth = element.getAttribute(STUDIO_ORIGINAL_MIN_WIDTH_ATTR); + const origMinHeight = element.getAttribute(STUDIO_ORIGINAL_MIN_HEIGHT_ATTR); + const origMaxWidth = element.getAttribute(STUDIO_ORIGINAL_MAX_WIDTH_ATTR); + const origMaxHeight = element.getAttribute(STUDIO_ORIGINAL_MAX_HEIGHT_ATTR); + const origFlexBasis = element.getAttribute(STUDIO_ORIGINAL_FLEX_BASIS_ATTR); + const origFlexGrow = element.getAttribute(STUDIO_ORIGINAL_FLEX_GROW_ATTR); + const origFlexShrink = element.getAttribute(STUDIO_ORIGINAL_FLEX_SHRINK_ATTR); + const origBoxSizing = element.getAttribute(STUDIO_ORIGINAL_BOX_SIZING_ATTR); + const origScale = element.getAttribute(STUDIO_ORIGINAL_SCALE_ATTR); + const origTransformOrigin = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR); + const origDisplay = element.getAttribute(STUDIO_ORIGINAL_DISPLAY_ATTR); + const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR); + + if (origWidth !== null) + ops.push({ type: "attribute", property: STUDIO_ORIGINAL_WIDTH_ATTR, value: origWidth }); + if (origHeight !== null) + ops.push({ type: "attribute", property: STUDIO_ORIGINAL_HEIGHT_ATTR, value: origHeight }); + if (origMinWidth !== null) + ops.push({ type: "attribute", property: STUDIO_ORIGINAL_MIN_WIDTH_ATTR, value: origMinWidth }); + if (origMinHeight !== null) + ops.push({ + type: "attribute", + property: STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, + value: origMinHeight, + }); + if (origMaxWidth !== null) + ops.push({ type: "attribute", property: STUDIO_ORIGINAL_MAX_WIDTH_ATTR, value: origMaxWidth }); + if (origMaxHeight !== null) + ops.push({ + type: "attribute", + property: STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, + value: origMaxHeight, + }); + if (origFlexBasis !== null) + ops.push({ + type: "attribute", + property: STUDIO_ORIGINAL_FLEX_BASIS_ATTR, + value: origFlexBasis, + }); + if (origFlexGrow !== null) + ops.push({ type: "attribute", property: STUDIO_ORIGINAL_FLEX_GROW_ATTR, value: origFlexGrow }); + if (origFlexShrink !== null) + ops.push({ + type: "attribute", + property: STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, + value: origFlexShrink, + }); + if (origBoxSizing !== null) + ops.push({ + type: "attribute", + property: STUDIO_ORIGINAL_BOX_SIZING_ATTR, + value: origBoxSizing, + }); + if (origScale !== null) + ops.push({ type: "attribute", property: STUDIO_ORIGINAL_SCALE_ATTR, value: origScale }); + if (origTransformOrigin !== null) + ops.push({ + type: "attribute", + property: STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, + value: origTransformOrigin, + }); + if (origDisplay !== null) + ops.push({ type: "attribute", property: STUDIO_ORIGINAL_DISPLAY_ATTR, value: origDisplay }); + if (origTransformDisplay !== null) + ops.push({ + type: "attribute", + property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, + value: origTransformDisplay, + }); + + return ops; +} + +export function buildClearBoxSizePatches(element: HTMLElement): PatchOperation[] { + const ops: PatchOperation[] = [ + { type: "inline-style", property: STUDIO_WIDTH_PROP, value: null }, + { type: "inline-style", property: STUDIO_HEIGHT_PROP, value: null }, + { type: "attribute", property: STUDIO_BOX_SIZE_ATTR, value: null }, + ]; + + const origAttrs: Array<[string, string]> = [ + [STUDIO_ORIGINAL_WIDTH_ATTR, "width"], + [STUDIO_ORIGINAL_HEIGHT_ATTR, "height"], + [STUDIO_ORIGINAL_MIN_WIDTH_ATTR, "min-width"], + [STUDIO_ORIGINAL_MIN_HEIGHT_ATTR, "min-height"], + [STUDIO_ORIGINAL_MAX_WIDTH_ATTR, "max-width"], + [STUDIO_ORIGINAL_MAX_HEIGHT_ATTR, "max-height"], + [STUDIO_ORIGINAL_FLEX_BASIS_ATTR, "flex-basis"], + [STUDIO_ORIGINAL_FLEX_GROW_ATTR, "flex-grow"], + [STUDIO_ORIGINAL_FLEX_SHRINK_ATTR, "flex-shrink"], + [STUDIO_ORIGINAL_BOX_SIZING_ATTR, "box-sizing"], + [STUDIO_ORIGINAL_SCALE_ATTR, "scale"], + [STUDIO_ORIGINAL_TRANSFORM_ORIGIN_ATTR, "transform-origin"], + [STUDIO_ORIGINAL_DISPLAY_ATTR, "display"], + ]; + + for (const [attrName, styleProp] of origAttrs) { + const origVal = element.getAttribute(attrName); + if (origVal !== null) { + ops.push({ type: "inline-style", property: styleProp, value: origVal || null }); + } + ops.push({ type: "attribute", property: attrName, value: null }); + } + + const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR); + if (origTransformDisplay !== null) { + ops.push({ type: "inline-style", property: "display", value: origTransformDisplay || null }); + ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null }); + } + + return ops; +} + +export function buildRotationPatches(element: HTMLElement): PatchOperation[] { + const ops: PatchOperation[] = []; + + const studioRotation = element.style.getPropertyValue(STUDIO_ROTATION_PROP); + const rotate = element.style.getPropertyValue("rotate"); + const transformOrigin = element.style.getPropertyValue("transform-origin"); + const displayVal = element.style.getPropertyValue("display"); + + if (studioRotation) + ops.push({ type: "inline-style", property: STUDIO_ROTATION_PROP, value: studioRotation }); + if (rotate) ops.push({ type: "inline-style", property: "rotate", value: rotate }); + if (transformOrigin) + ops.push({ type: "inline-style", property: "transform-origin", value: transformOrigin }); + if (displayVal) ops.push({ type: "inline-style", property: "display", value: displayVal }); + + ops.push({ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" }); + + const origRotate = element.getAttribute(STUDIO_ORIGINAL_ROTATE_ATTR); + const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR); + const origRotationTransformOrigin = element.getAttribute( + STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, + ); + const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR); + + if (origRotate !== null) + ops.push({ type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: origRotate }); + if (origInlineRotate !== null) + ops.push({ + type: "attribute", + property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, + value: origInlineRotate, + }); + if (origRotationTransformOrigin !== null) + ops.push({ + type: "attribute", + property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, + value: origRotationTransformOrigin, + }); + if (origTransformDisplay !== null) + ops.push({ + type: "attribute", + property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, + value: origTransformDisplay, + }); + + return ops; +} + +export function buildClearRotationPatches(element: HTMLElement): PatchOperation[] { + const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR); + const origRotationTransformOrigin = element.getAttribute( + STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, + ); + const ops: PatchOperation[] = [ + { type: "inline-style", property: STUDIO_ROTATION_PROP, value: null }, + { type: "inline-style", property: "rotate", value: origInlineRotate || null }, + { + type: "inline-style", + property: "transform-origin", + value: origRotationTransformOrigin !== null ? origRotationTransformOrigin || null : null, + }, + { type: "attribute", property: STUDIO_ROTATION_ATTR, value: null }, + { type: "attribute", property: STUDIO_ROTATION_DRAFT_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: null }, + { type: "attribute", property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, value: null }, + ]; + const origTransformDisplay = element.getAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR); + if (origTransformDisplay !== null) { + ops.push({ type: "inline-style", property: "display", value: origTransformDisplay || null }); + ops.push({ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: null }); + } + return ops; +} + +export function reapplyPositionEditsAfterSeek(doc: Document): void { + const htmlElement = doc.defaultView?.HTMLElement; + if (!htmlElement) return; + + const offsetEls = Array.from(doc.querySelectorAll(`[${STUDIO_PATH_OFFSET_ATTR}="true"]`)).filter( + (el): el is HTMLElement => el instanceof htmlElement, + ); + for (const el of offsetEls) { + const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP); + const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); + if (x || y) { + applyStudioPathOffset(el, { + x: Number.parseFloat(x) || 0, + y: Number.parseFloat(y) || 0, + }); + } + } + + const boxSizeEls = Array.from(doc.querySelectorAll(`[${STUDIO_BOX_SIZE_ATTR}="true"]`)).filter( + (el): el is HTMLElement => el instanceof htmlElement, + ); + for (const el of boxSizeEls) { + const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP)); + const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP)); + if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) { + applyStudioBoxSize(el, { width: w, height: h }); + } + } + + const rotationEls = Array.from(doc.querySelectorAll(`[${STUDIO_ROTATION_ATTR}="true"]`)).filter( + (el): el is HTMLElement => el instanceof htmlElement, + ); + for (const el of rotationEls) { + const angle = Number.parseFloat(el.style.getPropertyValue(STUDIO_ROTATION_PROP)); + if (Number.isFinite(angle)) { + applyStudioRotation(el, { angle }); + } + } +} diff --git a/packages/studio/src/components/editor/manualEditsParsing.ts b/packages/studio/src/components/editor/manualEditsParsing.ts index b4fd7d226..3eda8d8ae 100644 --- a/packages/studio/src/components/editor/manualEditsParsing.ts +++ b/packages/studio/src/components/editor/manualEditsParsing.ts @@ -1,142 +1,10 @@ -import type { DomEditSelection } from "./domEditing"; -import type { - StudioManualEdit, - StudioManualEditManifest, - StudioManualEditTarget, - StudioPathOffsetEdit, - StudioBoxSizeEdit, - StudioRotationEdit, -} from "./manualEditsTypes"; -import { STUDIO_MANUAL_EDITS_PATH } from "./manualEditsTypes"; - /* ── Helpers ──────────────────────────────────────────────────────── */ export function finiteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } -/* ── Manifest factory ─────────────────────────────────────────────── */ -export function emptyStudioManualEditManifest(): StudioManualEditManifest { - return { version: 1, edits: [] }; -} - -/* ── Parsing ──────────────────────────────────────────────────────── */ -function parsePathOffsetEdit(value: unknown): StudioPathOffsetEdit | null { - if (!value || typeof value !== "object") return null; - const record = value as Record; - if (record.kind !== "path-offset") return null; - const target = record.target; - if (!target || typeof target !== "object") return null; - const targetRecord = target as Record; - const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : ""; - if (!sourceFile) return null; - - const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : undefined; - const id = typeof targetRecord.id === "string" ? targetRecord.id : undefined; - if (!selector && !id) return null; - - const x = finiteNumber(record.x); - const y = finiteNumber(record.y); - if (x == null || y == null) return null; - - return { - kind: "path-offset", - target: { - sourceFile, - selector, - selectorIndex: finiteNumber(targetRecord.selectorIndex) ?? undefined, - id, - }, - x, - y, - updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined, - }; -} - -function parseBoxSizeEdit(value: unknown): StudioBoxSizeEdit | null { - if (!value || typeof value !== "object") return null; - const record = value as Record; - if (record.kind !== "box-size") return null; - const target = record.target; - if (!target || typeof target !== "object") return null; - const targetRecord = target as Record; - const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : ""; - if (!sourceFile) return null; - - const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : undefined; - const id = typeof targetRecord.id === "string" ? targetRecord.id : undefined; - if (!selector && !id) return null; - - const width = finiteNumber(record.width); - const height = finiteNumber(record.height); - if (width == null || height == null || width <= 0 || height <= 0) return null; - - return { - kind: "box-size", - target: { - sourceFile, - selector, - selectorIndex: finiteNumber(targetRecord.selectorIndex) ?? undefined, - id, - }, - width, - height, - updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined, - }; -} - -function parseRotationEdit(value: unknown): StudioRotationEdit | null { - if (!value || typeof value !== "object") return null; - const record = value as Record; - if (record.kind !== "rotation") return null; - const target = record.target; - if (!target || typeof target !== "object") return null; - const targetRecord = target as Record; - const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : ""; - if (!sourceFile) return null; - - const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : undefined; - const id = typeof targetRecord.id === "string" ? targetRecord.id : undefined; - if (!selector && !id) return null; - - const angle = finiteNumber(record.angle); - if (angle == null) return null; - - return { - kind: "rotation", - target: { - sourceFile, - selector, - selectorIndex: finiteNumber(targetRecord.selectorIndex) ?? undefined, - id, - }, - angle, - updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined, - }; -} - -function parseManualEdit(value: unknown): StudioManualEdit | null { - return parsePathOffsetEdit(value) ?? parseBoxSizeEdit(value) ?? parseRotationEdit(value); -} - -export function parseStudioManualEditManifest(content: string): StudioManualEditManifest { - if (!content.trim()) return emptyStudioManualEditManifest(); - - try { - const parsed = JSON.parse(content) as unknown; - if (!parsed || typeof parsed !== "object") return emptyStudioManualEditManifest(); - const edits = (parsed as { edits?: unknown }).edits; - if (!Array.isArray(edits)) return emptyStudioManualEditManifest(); - return { - version: 1, - edits: edits.map(parseManualEdit).filter((edit): edit is StudioManualEdit => edit !== null), - }; - } catch { - return emptyStudioManualEditManifest(); - } -} - -export function serializeStudioManualEditManifest(manifest: StudioManualEditManifest): string { - return `${JSON.stringify(manifest, null, 2)}\n`; +export function roundRotationAngle(angle: number): number { + return Math.round(angle * 10) / 10; } /* ── File path utilities ──────────────────────────────────────────── */ @@ -172,109 +40,3 @@ function readStudioFileChangePathFromValue(value: unknown): string | 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}`) - ); -} - -/* ── Target / upsert helpers ──────────────────────────────────────── */ -function selectionTarget(selection: DomEditSelection): StudioManualEditTarget { - return { - sourceFile: selection.sourceFile || "index.html", - selector: selection.selector, - selectorIndex: selection.selectorIndex, - id: selection.id ?? undefined, - }; -} - -function targetKey(target: StudioManualEditTarget): string { - return [ - target.sourceFile, - target.id ?? "", - target.selector ?? "", - target.selectorIndex ?? "", - ].join("|"); -} - -export function roundRotationAngle(angle: number): number { - return Math.round(angle * 10) / 10; -} - -export function upsertStudioPathOffsetEdit( - manifest: StudioManualEditManifest, - selection: DomEditSelection, - offset: { x: number; y: number }, -): StudioManualEditManifest { - const target = selectionTarget(selection); - const key = targetKey(target); - const nextEdit: StudioPathOffsetEdit = { - kind: "path-offset", - target, - x: Math.round(offset.x), - y: Math.round(offset.y), - updatedAt: new Date().toISOString(), - }; - - const edits = manifest.edits.filter( - (edit) => edit.kind !== "path-offset" || targetKey(edit.target) !== key, - ); - edits.push(nextEdit); - return { version: 1, edits }; -} - -export function upsertStudioBoxSizeEdit( - manifest: StudioManualEditManifest, - selection: DomEditSelection, - size: { width: number; height: number }, -): StudioManualEditManifest { - const target = selectionTarget(selection); - const key = targetKey(target); - const nextEdit: StudioBoxSizeEdit = { - kind: "box-size", - target, - width: Math.round(Math.max(1, size.width)), - height: Math.round(Math.max(1, size.height)), - updatedAt: new Date().toISOString(), - }; - - const edits = manifest.edits.filter( - (edit) => edit.kind !== "box-size" || targetKey(edit.target) !== key, - ); - edits.push(nextEdit); - return { version: 1, edits }; -} - -export function upsertStudioRotationEdit( - manifest: StudioManualEditManifest, - selection: DomEditSelection, - rotation: { angle: number }, -): StudioManualEditManifest { - const target = selectionTarget(selection); - const key = targetKey(target); - const nextEdit: StudioRotationEdit = { - kind: "rotation", - target, - angle: roundRotationAngle(rotation.angle), - updatedAt: new Date().toISOString(), - }; - - const edits = manifest.edits.filter( - (edit) => edit.kind !== "rotation" || targetKey(edit.target) !== key, - ); - edits.push(nextEdit); - return { version: 1, edits }; -} - -export function removeStudioManualEditsForSelection( - manifest: StudioManualEditManifest, - selection: DomEditSelection, -): StudioManualEditManifest { - const key = targetKey(selectionTarget(selection)); - const edits = manifest.edits.filter((edit) => targetKey(edit.target) !== key); - if (edits.length === manifest.edits.length) return manifest; - return { version: 1, edits }; -} diff --git a/packages/studio/src/components/editor/manualEditsTypes.ts b/packages/studio/src/components/editor/manualEditsTypes.ts index 714703df5..e21ddacbf 100644 --- a/packages/studio/src/components/editor/manualEditsTypes.ts +++ b/packages/studio/src/components/editor/manualEditsTypes.ts @@ -1,5 +1,4 @@ /* ── Public constants ──────────────────────────────────────────────── */ -export const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json"; export const STUDIO_OFFSET_X_PROP = "--hf-studio-offset-x"; export const STUDIO_OFFSET_Y_PROP = "--hf-studio-offset-y"; export const STUDIO_WIDTH_PROP = "--hf-studio-width"; @@ -40,44 +39,6 @@ export const STUDIO_MANUAL_EDITS_PLAYBACK_FRAME_PROP = "__hfStudioManualEditsPla export const STUDIO_ROTATION_TRANSFORM_ORIGIN = "center center"; -/* ── Edit types ───────────────────────────────────────────────────── */ -export interface StudioManualEditTarget { - sourceFile: string; - selector?: string; - selectorIndex?: number; - id?: string; -} - -export interface StudioPathOffsetEdit { - kind: "path-offset"; - target: StudioManualEditTarget; - x: number; - y: number; - updatedAt?: string; -} - -export interface StudioBoxSizeEdit { - kind: "box-size"; - target: StudioManualEditTarget; - width: number; - height: number; - updatedAt?: string; -} - -export interface StudioRotationEdit { - kind: "rotation"; - target: StudioManualEditTarget; - angle: number; - updatedAt?: string; -} - -export type StudioManualEdit = StudioPathOffsetEdit | StudioBoxSizeEdit | StudioRotationEdit; - -export interface StudioManualEditManifest { - version: 1; - edits: StudioManualEdit[]; -} - export type StudioManualEditSeekWindow = Window & { __hf?: Record; __player?: Record; @@ -87,7 +48,7 @@ export type StudioManualEditSeekWindow = Window & { __hfStudioManualEditsPlaybackFrame?: number | null; }; -/* ── Snapshot types ───────────────────────────────────────────────── */ +/* ── Snapshot types (used by drag/drop restore) ───────────────────── */ export interface StudioBoxSizeSnapshot { width: string; height: string; diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index e00279128..b912ff20f 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -23,7 +23,7 @@ import { restoreStudioPathOffset, restoreStudioRotation, } from "./manualEdits"; -import { type GroupOverlayItem, type OverlayRect } from "./domEditOverlayGeometry"; +import { type GroupOverlayItem, type OverlayRect, toOverlayRect } from "./domEditOverlayGeometry"; import { BLOCKED_MOVE_THRESHOLD_PX, type BlockedMoveState, @@ -43,6 +43,7 @@ import { // Refs are stable across renders; values are read via .current. export type UseDomEditOverlayGesturesOptions = { overlayRef: RefObject; + iframeRef: RefObject; boxRef: RefObject; selectionRef: RefObject; overlayRectRef: RefObject; @@ -194,17 +195,31 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu dy, uniform: e.shiftKey, }); + applyStudioBoxSizeDraft(sel.element, nextSize); + + // Re-read BCR after applying dimensions. For elements with a GSAP + // scale transform and centered transform-origin the visual top-left + // drifts and the visual size diverges from the raw CSS size, so BCR + // is the only accurate source for both. + const overlayEl = opts.overlayRef.current; + const iframe = opts.iframeRef.current; + const refreshed = overlayEl && iframe ? toOverlayRect(overlayEl, iframe, sel.element) : null; + const overlayLeft = refreshed ? refreshed.left : g.originLeft; + const overlayTop = refreshed ? refreshed.top : g.originTop; + const overlayWidth = refreshed ? refreshed.width : nextSize.overlayWidth; + const overlayHeight = refreshed ? refreshed.height : nextSize.overlayHeight; + box.style.left = `${overlayLeft}px`; + box.style.top = `${overlayTop}px`; + box.style.width = `${overlayWidth}px`; + box.style.height = `${overlayHeight}px`; setDraftOverlayRect({ - left: g.originLeft, - top: g.originTop, - width: nextSize.overlayWidth, - height: nextSize.overlayHeight, + left: overlayLeft, + top: overlayTop, + width: overlayWidth, + height: overlayHeight, editScaleX: g.editScaleX, editScaleY: g.editScaleY, }); - box.style.width = `${nextSize.overlayWidth}px`; - box.style.height = `${nextSize.overlayHeight}px`; - applyStudioBoxSizeDraft(sel.element, nextSize); } }; @@ -281,6 +296,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu box.style.height = `${g.originHeight}px`; } restoreGestureOverlayRect(g); + opts.suppressNextBoxClickRef.current = true; return; } @@ -341,6 +357,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu if (g.pathOffsetMember) endManualOffsetDragMembers([g.pathOffsetMember]); }); } else { + opts.suppressNextBoxClickRef.current = true; const finalSize = readStudioBoxSize(sel.element); applyStudioBoxSize(sel.element, finalSize); void Promise.resolve(opts.onBoxSizeCommitRef.current(sel, finalSize)) diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 029aafb5a..b264ce42d 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -3,7 +3,6 @@ import { usePlayerStore } from "../player"; import type { TimelineElement } from "../player"; import type { DomEditSelection } from "../components/editor/domEditing"; import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar"; -import { STUDIO_MANUAL_EDITS_PATH } from "../components/editor/manualEdits"; import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion"; import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/timelineDiscovery"; import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers"; @@ -85,9 +84,7 @@ export function useAppHotkeys({ const readHistoryProjectFile = useCallback( async (path: string): Promise => { - return path === STUDIO_MANUAL_EDITS_PATH || path === STUDIO_MOTION_PATH - ? readOptionalProjectFile(path) - : readProjectFile(path); + return path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path); }, [readOptionalProjectFile, readProjectFile], ); diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 04de400cc..babd64d86 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -6,12 +6,21 @@ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory"; import { primaryFontFamilyValue } from "../utils/studioFontHelpers"; import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing"; import { - removeStudioManualEditsForSelection, - type StudioManualEditManifest, - upsertStudioBoxSizeEdit, - upsertStudioPathOffsetEdit, - upsertStudioRotationEdit, + applyStudioPathOffset, + applyStudioBoxSize, + applyStudioRotation, + clearStudioPathOffset, + clearStudioBoxSize, + clearStudioRotation, } from "../components/editor/manualEdits"; +import { + buildPathOffsetPatches, + buildBoxSizePatches, + buildRotationPatches, + buildClearPathOffsetPatches, + buildClearBoxSizePatches, + buildClearRotationPatches, +} from "../components/editor/manualEditsDom"; import { removeStudioMotionForSelection, type StudioGsapMotion, @@ -48,15 +57,11 @@ export interface UseDomEditCommitsParams { activeCompPath: string | null; previewIframeRef: React.MutableRefObject; showToast: (message: string, tone?: "error" | "info") => void; - commitStudioManualEditManifestOptimistically: ( - updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest, - options: { label: string; coalesceKey: string }, - ) => void; + queueDomEditSave: (save: () => Promise) => Promise; commitStudioMotionManifestOptimistically: ( updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest, options: { label: string; coalesceKey: string }, ) => void; - applyCurrentStudioManualEditsToPreview: (iframe: HTMLIFrameElement | null) => void; applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void; writeProjectFile: (path: string, content: string) => Promise; domEditSaveTimestampRef: React.MutableRefObject; @@ -69,15 +74,12 @@ export interface UseDomEditCommitsParams { // From useDomSelection domEditSelection: DomEditSelection | null; - domEditSelectionRef: React.MutableRefObject; - domEditGroupSelectionsRef: React.MutableRefObject; applyDomSelection: ( selection: DomEditSelection | null, options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean }, ) => void; clearDomSelection: () => void; refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void; - refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => void; buildDomSelectionFromTarget: ( target: HTMLElement, options?: { preferClipAncestor?: boolean }, @@ -90,9 +92,8 @@ export function useDomEditCommits({ activeCompPath, previewIframeRef, showToast, - commitStudioManualEditManifestOptimistically, + queueDomEditSave, commitStudioMotionManifestOptimistically, - applyCurrentStudioManualEditsToPreview, applyCurrentStudioMotionToPreview, writeProjectFile, domEditSaveTimestampRef, @@ -103,11 +104,9 @@ export function useDomEditCommits({ projectIdRef, reloadPreview, domEditSelection, - domEditGroupSelectionsRef, applyDomSelection, clearDomSelection, refreshDomEditSelectionFromPreview, - refreshDomEditGroupSelectionsFromPreview, buildDomSelectionFromTarget, }: UseDomEditCommitsParams) { const resolveImportedFontAsset = useCallback( @@ -211,98 +210,104 @@ export function useDomEditCommits({ resolveImportedFontAsset, }); - // ── Manifest commits ── + // ── Position patch helper ── + + const commitPositionPatchToHtml = useCallback( + ( + selection: DomEditSelection, + patches: Parameters[2][], + options: { label: string; coalesceKey: string; skipRefresh?: boolean }, + ) => { + void queueDomEditSave(async () => { + await persistDomEditOperations(selection, patches, { + label: options.label, + coalesceKey: options.coalesceKey, + skipRefresh: options.skipRefresh ?? true, + }); + }).catch((error) => { + const message = error instanceof Error ? error.message : "Failed to save position"; + showToast(message); + }); + }, + [persistDomEditOperations, queueDomEditSave, showToast], + ); + + // ── Position commits ── const handleDomPathOffsetCommit = useCallback( (selection: DomEditSelection, next: { x: number; y: number }) => { - commitStudioManualEditManifestOptimistically( - (manifest) => upsertStudioPathOffsetEdit(manifest, selection, next), - { - label: "Move layer", - coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, - }, - ); - refreshDomEditSelectionFromPreview(selection); + applyStudioPathOffset(selection.element, next); + commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { + label: "Move layer", + coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, + }); }, - [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview], + [commitPositionPatchToHtml], ); const handleDomGroupPathOffsetCommit = useCallback( (updates: DomEditGroupPathOffsetCommit[]) => { if (updates.length === 0) return; const coalesceKey = updates - .map((update) => getDomEditTargetKey(update.selection)) + .map((u) => getDomEditTargetKey(u.selection)) .sort() .join(":"); - commitStudioManualEditManifestOptimistically( - (manifest) => - updates.reduce( - (nextManifest, update) => - upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next), - manifest, - ), - { + for (const { selection, next } of updates) { + applyStudioPathOffset(selection.element, next); + commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: `Move ${updates.length} layers`, coalesceKey: `group-path-offset:${coalesceKey}`, - }, - ); - refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current); + }); + } }, - [ - commitStudioManualEditManifestOptimistically, - domEditGroupSelectionsRef, - refreshDomEditGroupSelectionsFromPreview, - ], + [commitPositionPatchToHtml], ); const handleDomBoxSizeCommit = useCallback( (selection: DomEditSelection, next: { width: number; height: number }) => { - commitStudioManualEditManifestOptimistically( - (manifest) => upsertStudioBoxSizeEdit(manifest, selection, next), - { - label: "Resize layer box", - coalesceKey: `box-size:${getDomEditTargetKey(selection)}`, - }, - ); - refreshDomEditSelectionFromPreview(selection); + applyStudioBoxSize(selection.element, next); + commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), { + label: "Resize layer box", + coalesceKey: `box-size:${getDomEditTargetKey(selection)}`, + }); }, - [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview], + [commitPositionPatchToHtml], ); const handleDomRotationCommit = useCallback( (selection: DomEditSelection, next: { angle: number }) => { - commitStudioManualEditManifestOptimistically( - (manifest) => upsertStudioRotationEdit(manifest, selection, next), - { - label: "Rotate layer", - coalesceKey: `rotation:${getDomEditTargetKey(selection)}`, - }, - ); - refreshDomEditSelectionFromPreview(selection); + applyStudioRotation(selection.element, next); + commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), { + label: "Rotate layer", + coalesceKey: `rotation:${getDomEditTargetKey(selection)}`, + }); }, - [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview], + [commitPositionPatchToHtml], ); const handleDomManualEditsReset = useCallback( (selection: DomEditSelection) => { - commitStudioManualEditManifestOptimistically( - (manifest) => removeStudioManualEditsForSelection(manifest, selection), - { - label: "Reset layer edits", - coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`, - }, - ); - applyCurrentStudioManualEditsToPreview(previewIframeRef.current); - refreshDomEditSelectionFromPreview(selection); + const element = selection.element; + const clearPatches = [ + ...buildClearPathOffsetPatches(element), + ...buildClearBoxSizePatches(element), + ...buildClearRotationPatches(element), + ]; + clearStudioPathOffset(element); + clearStudioBoxSize(element); + clearStudioRotation(element); + // skipRefresh:false triggers reloadPreview() which re-syncs selection on load + commitPositionPatchToHtml(selection, clearPatches, { + label: "Reset layer edits", + coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`, + skipRefresh: false, + }); }, - [ - applyCurrentStudioManualEditsToPreview, - commitStudioManualEditManifestOptimistically, - refreshDomEditSelectionFromPreview, - previewIframeRef, - ], + [commitPositionPatchToHtml], ); + // ── Motion commits ── + const handleDomMotionCommit = useCallback( ( selection: DomEditSelection, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 6e7abf51d..ad216f390 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -2,7 +2,6 @@ import { useEffect } from "react"; import type { TimelineElement } from "../player"; import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability"; import { findElementForSelection } from "../components/editor/domEditing"; -import type { StudioManualEditManifest } from "../components/editor/manualEdits"; import type { StudioMotionManifest } from "../components/editor/studioMotion"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { EditHistoryKind } from "../utils/editHistory"; @@ -36,15 +35,11 @@ export interface UseDomEditSessionParams { setRightPanelTab: (tab: RightPanelTab) => void; showToast: (message: string, tone?: "error" | "info") => void; refreshPreviewDocumentVersion: () => void; - commitStudioManualEditManifestOptimistically: ( - updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest, - options: { label: string; coalesceKey: string }, - ) => void; + queueDomEditSave: (save: () => Promise) => Promise; commitStudioMotionManifestOptimistically: ( updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest, options: { label: string; coalesceKey: string }, ) => void; - applyCurrentStudioManualEditsToPreview: (iframe: HTMLIFrameElement | null) => void; applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void; readProjectFile: (path: string) => Promise; writeProjectFile: (path: string, content: string) => Promise; @@ -85,9 +80,8 @@ export function useDomEditSession({ setRightPanelTab, showToast, refreshPreviewDocumentVersion, - commitStudioManualEditManifestOptimistically, + queueDomEditSave, commitStudioMotionManifestOptimistically, - applyCurrentStudioManualEditsToPreview, applyCurrentStudioMotionToPreview, readProjectFile: _readProjectFile, writeProjectFile, @@ -114,7 +108,6 @@ export function useDomEditSession({ domEditGroupSelections, domEditHoverSelection, domEditSelectionRef, - domEditGroupSelectionsRef, applyDomSelection, clearDomSelection, buildDomSelectionFromTarget, @@ -123,7 +116,6 @@ export function useDomEditSession({ buildDomSelectionForTimelineElement, handleTimelineElementSelect, refreshDomEditSelectionFromPreview, - refreshDomEditGroupSelectionsFromPreview, } = useDomSelection({ projectId, activeCompPath, @@ -176,7 +168,6 @@ export function useDomEditSession({ captionEditMode, compositionLoading, previewIframeRef, - activeCompPath, showToast, applyDomSelection, resolveDomSelectionFromPreviewPoint, @@ -208,9 +199,8 @@ export function useDomEditSession({ activeCompPath, previewIframeRef, showToast, - commitStudioManualEditManifestOptimistically, + queueDomEditSave, commitStudioMotionManifestOptimistically, - applyCurrentStudioManualEditsToPreview, applyCurrentStudioMotionToPreview, writeProjectFile, domEditSaveTimestampRef, @@ -221,12 +211,9 @@ export function useDomEditSession({ projectIdRef, reloadPreview, domEditSelection, - domEditSelectionRef, - domEditGroupSelectionsRef, applyDomSelection, clearDomSelection, refreshDomEditSelectionFromPreview, - refreshDomEditGroupSelectionsFromPreview, buildDomSelectionFromTarget, }); diff --git a/packages/studio/src/hooks/useManifestPersistence.ts b/packages/studio/src/hooks/useManifestPersistence.ts index 187b1310f..55d925982 100644 --- a/packages/studio/src/hooks/useManifestPersistence.ts +++ b/packages/studio/src/hooks/useManifestPersistence.ts @@ -1,15 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useMountEffect } from "./useMountEffect"; import { - STUDIO_MANUAL_EDITS_PATH, - applyStudioManualEditManifest, - emptyStudioManualEditManifest, installStudioManualEditSeekReapply, - isStudioManualEditManifestPath, - parseStudioManualEditManifest, + reapplyPositionEditsAfterSeek, readStudioFileChangePath, - serializeStudioManualEditManifest, - type StudioManualEditManifest, } from "../components/editor/manualEdits"; import { STUDIO_MOTION_PATH, @@ -41,6 +35,11 @@ interface UseManifestPersistenceParams { recordEdit: (entry: RecordEditInput) => Promise; previewIframeRef: React.MutableRefObject; activeCompPathRef: React.MutableRefObject; + /** Shared timestamp ref — written by any studio save (code tab, timeline, DOM edits). + * Used to suppress SSE echoes so we don't double-reload after our own saves. */ + domEditSaveTimestampRef: React.MutableRefObject; + /** Called to reload the preview after undo/redo or external file changes. */ + reloadPreview: () => void; } // ── Hook ── @@ -48,21 +47,19 @@ interface UseManifestPersistenceParams { export function useManifestPersistence({ projectId, showToast, - readOptionalProjectFile, + readOptionalProjectFile: _readOptionalProjectFile, writeProjectFile, recordEdit, previewIframeRef, activeCompPathRef, + domEditSaveTimestampRef, + reloadPreview, }: UseManifestPersistenceParams) { - const [, setStudioMotionRevision] = useState(0); + void _readOptionalProjectFile; - const domEditSaveTimestampRef = useRef(0); + const [, setStudioMotionRevision] = useState(0); const domTextCommitVersionRef = useRef(0); const domEditSaveQueueRef = useRef(Promise.resolve()); - const studioManualEditManifestRef = useRef( - emptyStudioManualEditManifest(), - ); - const studioManualEditRevisionRef = useRef(0); const studioMotionManifestRef = useRef(emptyStudioMotionManifest()); const studioMotionRevisionRef = useRef(0); const applyStudioManualEditsToPreviewRef = useRef< @@ -77,9 +74,7 @@ export function useManifestPersistence({ options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean }, ) => Promise >(async () => {}); - const manifestBootstrappedRef = useRef(false); const motionBootstrappedRef = useRef(false); - const studioManualEditProjectRef = useRef(projectId); // Keep a ref to the latest projectId so async save callbacks always read the // current value, even when the callback was captured in a stale closure. @@ -101,7 +96,7 @@ export function useManifestPersistence({ await domEditSaveQueueRef.current.catch(() => undefined); }, []); - // ── Apply manual edits ── + // ── Apply manual edits (HTML-baked — just install seek hooks) ── const applyCurrentStudioManualEditsToPreview = useCallback( (iframe: HTMLIFrameElement | null = previewIframeRef.current) => { @@ -113,68 +108,38 @@ export function useManifestPersistence({ return; } if (!doc) return; - const previewDoc = doc; - const applyManifest = () => { - applyStudioManualEditManifest( - previewDoc, - studioManualEditManifestRef.current, - activeCompPathRef.current, - ); - }; - const applyAndInstallSeekHooks = () => { - applyManifest(); - if (iframe.contentWindow) { - installStudioManualEditSeekReapply(iframe.contentWindow, applyManifest); + const reapply = () => { + let d: Document | null = null; + try { + d = iframe.contentDocument; + } catch { + return; } + if (d) reapplyPositionEditsAfterSeek(d); + }; + const install = () => { + reapply(); + if (iframe.contentWindow) installStudioManualEditSeekReapply(iframe.contentWindow, reapply); }; const win = iframe.contentWindow; - applyAndInstallSeekHooks(); - win?.requestAnimationFrame?.(applyAndInstallSeekHooks); - win?.setTimeout?.(applyAndInstallSeekHooks, 80); - win?.setTimeout?.(applyAndInstallSeekHooks, 250); - win?.setTimeout?.(applyAndInstallSeekHooks, 500); - win?.setTimeout?.(applyAndInstallSeekHooks, 1000); - win?.setTimeout?.(applyAndInstallSeekHooks, 2000); + install(); + win?.requestAnimationFrame?.(install); + win?.setTimeout?.(install, 80); + win?.setTimeout?.(install, 250); + win?.setTimeout?.(install, 500); + win?.setTimeout?.(install, 1000); + win?.setTimeout?.(install, 2000); }, - [activeCompPathRef, previewIframeRef], + [previewIframeRef], ); const applyStudioManualEditsToPreview = useCallback( - async ( - iframe: HTMLIFrameElement | null = previewIframeRef.current, - options?: { forceFromDisk?: boolean; readFromDiskFirst?: boolean }, - ) => { - // Bootstrap from disk on first apply per session; explicit flag avoids - // re-reading disk after the user deletes all edits (async write race). - const needsBootstrap = !manifestBootstrappedRef.current; - if (needsBootstrap) manifestBootstrappedRef.current = true; - const readFromDiskFirst = Boolean( - options?.forceFromDisk || options?.readFromDiskFirst || needsBootstrap, - ); - if (!readFromDiskFirst) { - applyCurrentStudioManualEditsToPreview(iframe); - return; - } - const readRevision = studioManualEditRevisionRef.current; - let content: string; - try { - content = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to read manual edit manifest"; - showToast(message); - applyCurrentStudioManualEditsToPreview(iframe); - return; - } - if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) { - studioManualEditManifestRef.current = parseStudioManualEditManifest(content); - if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1; - } + async (iframe: HTMLIFrameElement | null = previewIframeRef.current) => { applyCurrentStudioManualEditsToPreview(iframe); }, - [applyCurrentStudioManualEditsToPreview, previewIframeRef, readOptionalProjectFile, showToast], + [applyCurrentStudioManualEditsToPreview, previewIframeRef], ); applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview; @@ -230,7 +195,7 @@ export function useManifestPersistence({ const readRevision = studioMotionRevisionRef.current; let content: string; try { - content = await readOptionalProjectFile(STUDIO_MOTION_PATH); + content = await _readOptionalProjectFile(STUDIO_MOTION_PATH); } catch (error) { const message = error instanceof Error ? error.message : "Failed to read motion manifest"; showToast(message); @@ -244,80 +209,11 @@ export function useManifestPersistence({ } applyCurrentStudioMotionToPreview(iframe); }, - [applyCurrentStudioMotionToPreview, previewIframeRef, readOptionalProjectFile, showToast], + [applyCurrentStudioMotionToPreview, previewIframeRef, _readOptionalProjectFile, showToast], ); applyStudioMotionToPreviewRef.current = applyStudioMotionToPreview; - // ── Optimistic commits ── - - 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, - }); - 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, - recordEdit, - queueDomEditSave, - readOptionalProjectFile, - showToast, - writeProjectFile, - previewIframeRef, - ], - ); + // ── Optimistic motion commit ── const commitStudioMotionManifestOptimistically = useCallback( ( @@ -339,7 +235,7 @@ export function useManifestPersistence({ applyCurrentStudioMotionToPreview(previewIframeRef.current); const save = async () => { - const originalContent = await readOptionalProjectFile(STUDIO_MOTION_PATH); + const originalContent = await _readOptionalProjectFile(STUDIO_MOTION_PATH); const diskManifest = parseStudioMotionManifest(originalContent); const nextDiskManifest = updateManifest(diskManifest); const nextDiskContent = serializeStudioMotionManifest(nextDiskManifest); @@ -384,10 +280,11 @@ export function useManifestPersistence({ applyCurrentStudioMotionToPreview, recordEdit, queueDomEditSave, - readOptionalProjectFile, + _readOptionalProjectFile, showToast, writeProjectFile, previewIframeRef, + domEditSaveTimestampRef, ], ); @@ -396,69 +293,40 @@ export function useManifestPersistence({ const syncHistoryPreviewAfterApply = useCallback( async (paths: string[] | undefined) => { const changedPaths = paths ?? []; - const manualManifestOnly = - changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MANUAL_EDITS_PATH); const motionManifestOnly = changedPaths.length > 0 && changedPaths.every((path) => path === STUDIO_MOTION_PATH); - if (manualManifestOnly) { - await applyStudioManualEditsToPreview(previewIframeRef.current, { forceFromDisk: true }); - return; - } if (motionManifestOnly) { await applyStudioMotionToPreview(previewIframeRef.current, { forceFromDisk: true }); return; } - // Reload the iframe in-place rather than recreating the Player component. - // This preserves the web component and its shader - // transition cache — only the iframe document reloads, so transitions that - // weren't touched by the undo/redo don't need to rebuild from scratch. - const iframe = previewIframeRef.current; - if (iframe?.contentWindow) { - try { - iframe.contentWindow.location.reload(); - return; - } catch { - // Cross-origin or detached — fall through to full refresh - } - } + // Reload via refreshKey so NLELayout saves seek position before the iframe reloads. + reloadPreview(); }, - [applyStudioManualEditsToPreview, applyStudioMotionToPreview, previewIframeRef], + [applyStudioMotionToPreview, previewIframeRef, reloadPreview], ); // ── Reset manifests when project changes ── + const projectTrackerRef = useRef(projectId); + // eslint-disable-next-line no-restricted-syntax useEffect(() => { - const previousProjectId = studioManualEditProjectRef.current; - studioManualEditProjectRef.current = projectId; + const previousProjectId = projectTrackerRef.current; + projectTrackerRef.current = projectId; if (!previousProjectId || previousProjectId === projectId) return; - studioManualEditManifestRef.current = emptyStudioManualEditManifest(); - studioManualEditRevisionRef.current += 1; studioMotionManifestRef.current = emptyStudioMotionManifest(); studioMotionRevisionRef.current += 1; setStudioMotionRevision((revision) => revision + 1); - manifestBootstrappedRef.current = motionBootstrappedRef.current = false; + motionBootstrappedRef.current = false; }, [projectId]); // ── Listen for external file changes (HMR / SSE) ── - // In dev: use Vite HMR. In embedded/production: use SSE from /api/events. - // Suppress file-change events that echo back from a recent DOM edit save — - // those changes are already applied to the iframe DOM and a full reload - // would flash the preview. useMountEffect(() => { 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 (isStudioMotionManifestPath(changedPath)) { if (!recentDomEditSave) { void applyStudioMotionToPreviewRef.current(previewIframeRef.current, { @@ -467,9 +335,10 @@ export function useManifestPersistence({ } return; } - // Non-manifest file changes are not handled here — the caller is - // responsible for triggering a preview refresh via onExternalFileChange - // if needed. This hook only suppresses echoes and handles manifest reloads. + // Non-motion external file change — reload unless it's an echo of our own save. + if (!recentDomEditSave) { + reloadPreview(); + } }; if (import.meta.hot) { import.meta.hot.on("hf:file-change", handler); @@ -482,23 +351,18 @@ export function useManifestPersistence({ }); return { - domEditSaveTimestampRef, domTextCommitVersionRef, domEditSaveQueueRef, - studioManualEditManifestRef, - studioManualEditRevisionRef, studioMotionManifestRef, studioMotionRevisionRef, applyStudioManualEditsToPreviewRef, applyStudioMotionToPreviewRef, - studioManualEditProjectRef, queueDomEditSave, waitForPendingDomEditSaves, applyCurrentStudioManualEditsToPreview, applyStudioManualEditsToPreview, applyCurrentStudioMotionToPreview, applyStudioMotionToPreview, - commitStudioManualEditManifestOptimistically, commitStudioMotionManifestOptimistically, syncHistoryPreviewAfterApply, }; diff --git a/packages/studio/src/hooks/usePreviewInteraction.ts b/packages/studio/src/hooks/usePreviewInteraction.ts index bf7b2c4fb..38266c4dc 100644 --- a/packages/studio/src/hooks/usePreviewInteraction.ts +++ b/packages/studio/src/hooks/usePreviewInteraction.ts @@ -18,7 +18,6 @@ export interface UsePreviewInteractionParams { captionEditMode: boolean; compositionLoading: boolean; previewIframeRef: React.MutableRefObject; - activeCompPath: string | null; showToast: (message: string, tone?: "error" | "info") => void; // From useDomSelection diff --git a/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts b/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts index 679cf617e..ab6f7b623 100644 --- a/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts +++ b/packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts @@ -93,7 +93,7 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => { dispatch(keydown({ code: "KeyA", key: "a" })); }); - expect(spies.seek).toHaveBeenCalledWith(1.5); + expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true }); }); it("'Jump to in-point' fires on AZERTY (physical KeyQ produces e.key='a')", () => { @@ -104,7 +104,7 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => { dispatch(keydown({ code: "KeyQ", key: "a" })); }); - expect(spies.seek).toHaveBeenCalledWith(2.5); + expect(spies.seek).toHaveBeenCalledWith(2.5, { keepPlaying: true }); }); it("AZERTY 'A' physical key (e.key='q') no longer triggers in-point seek", () => { diff --git a/packages/studio/src/utils/sourcePatcher.ts b/packages/studio/src/utils/sourcePatcher.ts index 3d8e72825..50969b4b7 100644 --- a/packages/studio/src/utils/sourcePatcher.ts +++ b/packages/studio/src/utils/sourcePatcher.ts @@ -71,7 +71,7 @@ function splitInlineStyleDeclarations(style: string): string[] { export interface PatchOperation { type: "inline-style" | "attribute" | "text-content"; property: string; - value: string; + value: string | null; } export interface PatchTarget { @@ -133,7 +133,12 @@ export function resolveSourceFile( /** * Apply a style property change to an element's inline style in the HTML source. */ -function patchInlineStyle(html: string, elementId: string, prop: string, value: string): string { +function patchInlineStyle( + html: string, + elementId: string, + prop: string, + value: string | null, +): string { // Find the element tag with this id const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i"); const match = idPattern.exec(html); @@ -143,7 +148,12 @@ function patchInlineStyle(html: string, elementId: string, prop: string, value: return patchInlineStyleInTag(html, tag, prop, value); } -function patchInlineStyleInTag(html: string, tag: string, prop: string, value: string): string { +function patchInlineStyleInTag( + html: string, + tag: string, + prop: string, + value: string | null, +): string { if (!tag) return html; // Check if there's an existing style attribute @@ -160,16 +170,22 @@ function patchInlineStyleInTag(html: string, tag: string, prop: string, value: s const val = part.slice(colon + 1).trim(); if (key) props.set(key, val); } - // Update/add the property - props.set(prop, value); - // Rebuild style string + // Update/add or remove the property + if (value === null) { + props.delete(prop); + } else { + props.set(prop, value); + } + // Rebuild style string; keep style="" if empty (harmless) const newStyle = Array.from(props.entries()) .map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`) .join("; "); const newTag = tag.replace(styleMatch[0], `style=${quote}${newStyle}${quote}`); return html.replace(tag, newTag); } else { - // No existing style — add one + // No existing style attribute + if (value === null) return html; // nothing to remove + // Add one const newTag = tag.replace(/>$/, "") + ` style="${prop}: ${escapeStyleAttributeValue(value, '"')}"`; return html.replace(tag, newTag); @@ -180,7 +196,7 @@ function patchInlineStyleByTarget( html: string, target: PatchTarget, prop: string, - value: string, + value: string | null, ): string { const match = findTagByTarget(html, target); if (!match) return html; @@ -277,15 +293,23 @@ function patchAttributeByTarget( html: string, target: PatchTarget, attr: string, - value: string, + value: string | null, ): string { const match = findTagByTarget(html, target); if (!match) return html; const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`; - const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`); + const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`); const tag = match.tag; + if (value === null) { + // Remove the attribute if present + if (!attrPattern.test(tag)) return html; + const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`); + const newTag = tag.replace(removePattern, ""); + return replaceTagAtMatch(html, match, newTag); + } + if (attrPattern.test(tag)) { const newTag = tag.replace(attrPattern, `${fullAttr}="${value}"`); return replaceTagAtMatch(html, match, newTag); @@ -298,14 +322,26 @@ function patchAttributeByTarget( /** * Apply an attribute change to an element in the HTML source. */ -function patchAttribute(html: string, elementId: string, attr: string, value: string): string { +function patchAttribute( + html: string, + elementId: string, + attr: string, + value: string | null, +): string { const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i"); const match = idPattern.exec(html); if (!match) return html; const tag = match[1]; const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`; - const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`); + const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`); + + if (value === null) { + if (!attrPattern.test(tag)) return html; + const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`); + const newTag = tag.replace(removePattern, ""); + return html.replace(tag, newTag); + } if (attrPattern.test(tag)) { // Update existing attribute @@ -381,7 +417,7 @@ export function applyPatch(html: string, elementId: string, op: PatchOperation): case "attribute": return patchAttribute(html, elementId, op.property, op.value); case "text-content": - return patchTextContent(html, elementId, op.value); + return op.value !== null ? patchTextContent(html, elementId, op.value) : html; default: return html; } @@ -401,7 +437,7 @@ export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchO case "attribute": return patchAttributeByTarget(html, target, op.property, op.value); case "text-content": - return patchTextContentByTarget(html, target, op.value); + return op.value !== null ? patchTextContentByTarget(html, target, op.value) : html; default: return html; } diff --git a/packages/studio/src/utils/studioPreviewHelpers.ts b/packages/studio/src/utils/studioPreviewHelpers.ts index 32c216a89..6c9485bcd 100644 --- a/packages/studio/src/utils/studioPreviewHelpers.ts +++ b/packages/studio/src/utils/studioPreviewHelpers.ts @@ -1,5 +1,9 @@ import type { DomEditViewport, DomEditSelection } from "../components/editor/domEditing"; import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing"; +import { + getDomLayerPatchTarget, + isElementComputedVisible, +} from "../components/editor/domEditingElement"; import { usePlayerStore, liveTime } from "../player"; import { getEventTargetElement } from "./studioHelpers"; @@ -56,6 +60,28 @@ export function getPreviewLocalPointer( return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY); } +const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__"; + +function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null { + try { + const style = doc.createElement("style"); + style.id = POINTER_EVENTS_OVERRIDE_ID; + style.textContent = "* { pointer-events: auto !important; }"; + doc.head.appendChild(style); + return style; + } catch { + return null; + } +} + +function removePointerEventsOverride(style: HTMLStyleElement | null): void { + try { + style?.remove(); + } catch { + // cross-origin or detached doc + } +} + export function getPreviewTargetFromPointer( iframe: HTMLIFrameElement, clientX: number, @@ -75,17 +101,25 @@ export function getPreviewTargetFromPointer( const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY); if (!localPointer) return null; - if (typeof doc.elementsFromPoint === "function") { - const visualTarget = resolveVisualDomEditSelectionTarget( - doc.elementsFromPoint(localPointer.x, localPointer.y), - { - activeCompositionPath, - }, - ); - if (visualTarget) return visualTarget; - } + const overrideStyle = forcePointerEventsAuto(doc); + try { + if (typeof doc.elementsFromPoint === "function") { + const visualTarget = resolveVisualDomEditSelectionTarget( + doc.elementsFromPoint(localPointer.x, localPointer.y), + { + activeCompositionPath, + }, + ); + if (visualTarget) return visualTarget; + } - return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y)); + const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y)); + if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null; + if (!isElementComputedVisible(fallback)) return null; + return fallback; + } finally { + removePointerEventsOverride(overrideStyle); + } } export function buildRasterClickSelectionContext( diff --git a/packages/studio/vite.adapter.ts b/packages/studio/vite.adapter.ts index 10820a9f5..ffedf227b 100644 --- a/packages/studio/vite.adapter.ts +++ b/packages/studio/vite.adapter.ts @@ -106,7 +106,8 @@ export function createViteAdapter(dataDir: string, server: ViteDevServer): Studi .filter( (d) => (d.isDirectory() || d.isSymbolicLink()) && - existsSync(join(dataDir, d.name, "index.html")), + (existsSync(join(dataDir, d.name, "index.html")) || + existsSync(join(dataDir, d.name, `${d.name}.html`))), ) .map((d) => { const session = sessionMap.get(d.name);