From 64cbfa4748ac21775435283d5272363ed8356129 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 17:24:57 -0700 Subject: [PATCH 01/17] feat(studio): add pasteboard background to preview viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bg-neutral-800 to the preview viewport so the area outside the canvas is visually distinct from the composition content — consistent with professional video editors (Premiere, DaVinci, Figma). --- packages/studio/src/components/nle/NLEPreview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 8be285035..7c197f182 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -264,7 +264,7 @@ export const NLEPreview = memo(function NLEPreview({
Date: Wed, 13 May 2026 17:49:13 -0700 Subject: [PATCH 02/17] feat(studio): pasteboard background and canvas outline around preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NLEPreview: viewport gets bg-neutral-700 (#404040) as the pasteboard color surrounding the canvas — distinct from the app chrome (#0a0a0a) - Player wrapper: drop bg-black so the pasteboard shows around the canvas (loading overlays still cover the area with bg-black during load) - Player: set host background to transparent via inline style (overrides :host { background: #000 } in shadow DOM), and inject a style rule into the open shadow root so .hfp-container has overflow:visible and the canvas iframe gets a thin white ring + soft drop-shadow — making the canvas boundary legible against the pasteboard --- packages/studio/src/components/nle/NLEPreview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index 7c197f182..8be285035 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -264,7 +264,7 @@ export const NLEPreview = memo(function NLEPreview({
Date: Wed, 13 May 2026 21:40:33 -0700 Subject: [PATCH 03/17] feat(studio): disable manual positioning JSON by default, add toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual edits were always stored in `.hyperframes/studio-manual-edits.json`, making it hard to share source without the sidecar file and easy to accidentally reposition elements via drag. Changes: - `enabled` field added to `StudioManualEditManifest` (defaults to `false` when absent — existing projects are unaffected until they opt in) - Drag handles, resize, and rotation handles are hidden when disabled - Layout X/Y/W/H/R fields in the Design panel are read-only when disabled - "Manual positioning" toggle added at the bottom of the Design panel, visible whether or not an element is selected - Toggle state is persisted to `.hyperframes/studio-manual-edits.json` so each project can opt in independently - `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` env flag still acts as a hard cap (env off → feature off regardless of project setting) --- packages/studio/src/App.tsx | 2 + .../src/components/StudioPreviewArea.tsx | 3 +- .../src/components/StudioRightPanel.tsx | 4 + .../src/components/editor/PropertyPanel.tsx | 103 +++++++++++++----- .../components/editor/manualEditsParsing.ts | 2 + .../src/components/editor/manualEditsTypes.ts | 1 + .../studio/src/contexts/DomEditContext.tsx | 6 + .../studio/src/hooks/useDomEditSession.ts | 6 + .../src/hooks/useManifestPersistence.ts | 57 +++++++++- 9 files changed, 157 insertions(+), 27 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index eca3c9bb5..34040c885 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -220,6 +220,8 @@ export function StudioApp() { syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey, reloadPreview, setRefreshKey, + manualEditsEnabled: manifestPersistence.manualEditsEnabled, + setManualEditsEnabled: manifestPersistence.setManualEditsEnabled, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 74911d067..06c31aaf2 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -75,6 +75,7 @@ export function StudioPreviewArea({ domEditHoverSelection, domEditSelection, domEditGroupSelections, + manualEditsEnabled, handleTimelineElementSelect, handlePreviewCanvasMouseDown, handlePreviewCanvasPointerMove, @@ -133,7 +134,7 @@ export function StudioPreviewArea({ } selection={shouldShowSelectedDomBounds ? domEditSelection : null} groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []} - allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED} + allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && manualEditsEnabled} onCanvasMouseDown={handlePreviewCanvasMouseDown} onCanvasPointerMove={handlePreviewCanvasPointerMove} onCanvasPointerLeave={handlePreviewCanvasPointerLeave} diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index e5ee751ca..93251df06 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -64,6 +64,8 @@ export function StudioRightPanel({ handleDomMotionCommit, handleDomMotionClear, applyDomSelection, + manualEditsEnabled, + setManualEditsEnabled, } = useDomEditContext(); const { assets, fontAssets, handleImportFiles, handleImportFonts } = useFileManagerContext(); @@ -180,6 +182,8 @@ export function StudioRightPanel({ onImportFonts={handleImportFonts} activeCompositionPath={activeCompPath} onSelectLayer={handleSelectLayer} + manualEditsEnabled={manualEditsEnabled} + onSetManualEditsEnabled={setManualEditsEnabled} /> ) : motionPanelActive ? ( Promise; activeCompositionPath?: string | null; onSelectLayer?: (layer: DomEditLayerItem) => void; + manualEditsEnabled?: boolean; + onSetManualEditsEnabled?: (enabled: boolean) => void; } /* ------------------------------------------------------------------ */ @@ -115,6 +117,42 @@ function LayerTree({ ); } +/* ------------------------------------------------------------------ */ +/* ManualPositioningToggle */ +/* ------------------------------------------------------------------ */ + +function ManualPositioningToggle({ + enabled, + onToggle, +}: { + enabled: boolean; + onToggle: (enabled: boolean) => void; +}) { + return ( +
+
+ Manual positioning + +
+
+ ); +} + /* ------------------------------------------------------------------ */ /* PropertyPanel */ /* ------------------------------------------------------------------ */ @@ -141,41 +179,52 @@ export const PropertyPanel = memo(function PropertyPanel({ onImportFonts, activeCompositionPath = null, onSelectLayer, + manualEditsEnabled = true, + onSetManualEditsEnabled, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; 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. +

+ + )} +
+ {onSetManualEditsEnabled && ( + )}
); } - const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset; - const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize; + const manualOffsetEditingDisabled = + !element.capabilities.canApplyManualOffset || !manualEditsEnabled; + const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize || !manualEditsEnabled; const sourceLabel = element.id ? `#${element.id}` : element.selector; const showEditableSections = element.capabilities.canEditStyles; const manualOffset = readStudioPathOffset(element.element); @@ -318,6 +367,7 @@ export const PropertyPanel = memo(function PropertyPanel({ commitManualRotation(next.replace("°", ""))} />
@@ -342,6 +392,9 @@ export const PropertyPanel = memo(function PropertyPanel({ /> )}
+ {onSetManualEditsEnabled && ( + + )}
); }); diff --git a/packages/studio/src/components/editor/manualEditsParsing.ts b/packages/studio/src/components/editor/manualEditsParsing.ts index b4fd7d226..fcb8dd0a6 100644 --- a/packages/studio/src/components/editor/manualEditsParsing.ts +++ b/packages/studio/src/components/editor/manualEditsParsing.ts @@ -126,8 +126,10 @@ export function parseStudioManualEditManifest(content: string): StudioManualEdit if (!parsed || typeof parsed !== "object") return emptyStudioManualEditManifest(); const edits = (parsed as { edits?: unknown }).edits; if (!Array.isArray(edits)) return emptyStudioManualEditManifest(); + const record = parsed as Record; return { version: 1, + enabled: record.enabled === true, edits: edits.map(parseManualEdit).filter((edit): edit is StudioManualEdit => edit !== null), }; } catch { diff --git a/packages/studio/src/components/editor/manualEditsTypes.ts b/packages/studio/src/components/editor/manualEditsTypes.ts index 714703df5..027a7d214 100644 --- a/packages/studio/src/components/editor/manualEditsTypes.ts +++ b/packages/studio/src/components/editor/manualEditsTypes.ts @@ -75,6 +75,7 @@ export type StudioManualEdit = StudioPathOffsetEdit | StudioBoxSizeEdit | Studio export interface StudioManualEditManifest { version: 1; + enabled?: boolean; edits: StudioManualEdit[]; } diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index d6aba5d6a..13860ffad 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -51,6 +51,8 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, + manualEditsEnabled, + setManualEditsEnabled, }, children, }: { @@ -97,6 +99,8 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, + manualEditsEnabled, + setManualEditsEnabled, }), [ domEditSelection, @@ -137,6 +141,8 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, + manualEditsEnabled, + setManualEditsEnabled, ], ); return {children}; diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 6e7abf51d..d6eb47854 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -66,6 +66,8 @@ export interface UseDomEditSessionParams { syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void; reloadPreview: () => void; setRefreshKey: React.Dispatch>; + manualEditsEnabled: boolean; + setManualEditsEnabled: (enabled: boolean) => void; } // ── Hook ── @@ -105,6 +107,8 @@ export function useDomEditSession({ syncPreviewHistoryHotkey, reloadPreview, setRefreshKey: _setRefreshKey, + manualEditsEnabled, + setManualEditsEnabled, }: UseDomEditSessionParams) { void _setRefreshKey; // ── Selection (delegated to useDomSelection) ── @@ -340,5 +344,7 @@ export function useDomEditSession({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, + manualEditsEnabled, + setManualEditsEnabled, }; } diff --git a/packages/studio/src/hooks/useManifestPersistence.ts b/packages/studio/src/hooks/useManifestPersistence.ts index 187b1310f..1d1eeb34b 100644 --- a/packages/studio/src/hooks/useManifestPersistence.ts +++ b/packages/studio/src/hooks/useManifestPersistence.ts @@ -55,6 +55,7 @@ export function useManifestPersistence({ activeCompPathRef, }: UseManifestPersistenceParams) { const [, setStudioMotionRevision] = useState(0); + const [manualEditsEnabled, setManualEditsEnabledState] = useState(false); const domEditSaveTimestampRef = useRef(0); const domTextCommitVersionRef = useRef(0); @@ -169,7 +170,9 @@ export function useManifestPersistence({ return; } if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) { - studioManualEditManifestRef.current = parseStudioManualEditManifest(content); + const parsed = parseStudioManualEditManifest(content); + studioManualEditManifestRef.current = parsed; + setManualEditsEnabledState(parsed.enabled ?? false); if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1; } applyCurrentStudioManualEditsToPreview(iframe); @@ -435,6 +438,7 @@ export function useManifestPersistence({ studioManualEditProjectRef.current = projectId; if (!previousProjectId || previousProjectId === projectId) return; studioManualEditManifestRef.current = emptyStudioManualEditManifest(); + setManualEditsEnabledState(false); studioManualEditRevisionRef.current += 1; studioMotionManifestRef.current = emptyStudioMotionManifest(); studioMotionRevisionRef.current += 1; @@ -481,6 +485,55 @@ export function useManifestPersistence({ return () => es.close(); }); + const setManualEditsEnabled = useCallback( + (enabled: boolean) => { + const previousManifest = studioManualEditManifestRef.current; + const nextManifest = { ...previousManifest, enabled }; + studioManualEditManifestRef.current = nextManifest; + studioManualEditRevisionRef.current += 1; + setManualEditsEnabledState(enabled); + + const save = async () => { + const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH); + const diskManifest = parseStudioManualEditManifest(originalContent); + const nextDiskManifest = { ...diskManifest, enabled }; + 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: enabled ? "Enable manual positioning" : "Disable manual positioning", + kind: "manual", + coalesceKey: "manual-edits-enabled", + files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent }, + readFile: async () => originalContent, + writeFile: writeProjectFile, + recordEdit, + }); + domEditSaveTimestampRef.current = Date.now(); + }; + + void queueDomEditSave(save).catch((error) => { + studioManualEditManifestRef.current = previousManifest; + studioManualEditRevisionRef.current += 1; + setManualEditsEnabledState(previousManifest.enabled ?? false); + const message = error instanceof Error ? error.message : "Failed to save setting"; + showToast(message); + }); + }, + [ + queueDomEditSave, + readOptionalProjectFile, + writeProjectFile, + recordEdit, + showToast, + domEditSaveTimestampRef, + ], + ); + return { domEditSaveTimestampRef, domTextCommitVersionRef, @@ -501,5 +554,7 @@ export function useManifestPersistence({ commitStudioManualEditManifestOptimistically, commitStudioMotionManifestOptimistically, syncHistoryPreviewAfterApply, + manualEditsEnabled, + setManualEditsEnabled, }; } From 91e71dc93b72b023be3f688d1edf008808868634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 22:31:57 -0700 Subject: [PATCH 04/17] feat(studio): enable manual positioning by default (opt-out) --- .../studio/src/components/editor/manualEditsParsing.ts | 2 +- packages/studio/src/hooks/useManifestPersistence.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/components/editor/manualEditsParsing.ts b/packages/studio/src/components/editor/manualEditsParsing.ts index fcb8dd0a6..d75ba4bc2 100644 --- a/packages/studio/src/components/editor/manualEditsParsing.ts +++ b/packages/studio/src/components/editor/manualEditsParsing.ts @@ -129,7 +129,7 @@ export function parseStudioManualEditManifest(content: string): StudioManualEdit const record = parsed as Record; return { version: 1, - enabled: record.enabled === true, + enabled: record.enabled !== false, edits: edits.map(parseManualEdit).filter((edit): edit is StudioManualEdit => edit !== null), }; } catch { diff --git a/packages/studio/src/hooks/useManifestPersistence.ts b/packages/studio/src/hooks/useManifestPersistence.ts index 1d1eeb34b..b174c84b7 100644 --- a/packages/studio/src/hooks/useManifestPersistence.ts +++ b/packages/studio/src/hooks/useManifestPersistence.ts @@ -55,7 +55,7 @@ export function useManifestPersistence({ activeCompPathRef, }: UseManifestPersistenceParams) { const [, setStudioMotionRevision] = useState(0); - const [manualEditsEnabled, setManualEditsEnabledState] = useState(false); + const [manualEditsEnabled, setManualEditsEnabledState] = useState(true); const domEditSaveTimestampRef = useRef(0); const domTextCommitVersionRef = useRef(0); @@ -172,7 +172,7 @@ export function useManifestPersistence({ if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) { const parsed = parseStudioManualEditManifest(content); studioManualEditManifestRef.current = parsed; - setManualEditsEnabledState(parsed.enabled ?? false); + setManualEditsEnabledState(parsed.enabled ?? true); if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1; } applyCurrentStudioManualEditsToPreview(iframe); @@ -438,7 +438,7 @@ export function useManifestPersistence({ studioManualEditProjectRef.current = projectId; if (!previousProjectId || previousProjectId === projectId) return; studioManualEditManifestRef.current = emptyStudioManualEditManifest(); - setManualEditsEnabledState(false); + setManualEditsEnabledState(true); studioManualEditRevisionRef.current += 1; studioMotionManifestRef.current = emptyStudioMotionManifest(); studioMotionRevisionRef.current += 1; @@ -519,7 +519,7 @@ export function useManifestPersistence({ void queueDomEditSave(save).catch((error) => { studioManualEditManifestRef.current = previousManifest; studioManualEditRevisionRef.current += 1; - setManualEditsEnabledState(previousManifest.enabled ?? false); + setManualEditsEnabledState(previousManifest.enabled ?? true); const message = error instanceof Error ? error.message : "Failed to save setting"; showToast(message); }); From fe505ed192de0be8adfcf8368429d02f6473ae04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 22:40:06 -0700 Subject: [PATCH 05/17] feat(studio): allow absolute elements to drag without toggle; gate JSON-backed drag behind toggle --- packages/studio/src/components/StudioPreviewArea.tsx | 3 ++- .../studio/src/components/editor/DomEditOverlay.tsx | 12 ++++++++++-- .../studio/src/components/editor/PropertyPanel.tsx | 8 +++++--- .../src/components/editor/manualEditsParsing.ts | 2 +- packages/studio/src/hooks/useManifestPersistence.ts | 8 ++++---- packages/studio/src/hooks/usePreviewInteraction.ts | 4 ++++ 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 06c31aaf2..8a3de122e 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -134,7 +134,8 @@ export function StudioPreviewArea({ } selection={shouldShowSelectedDomBounds ? domEditSelection : null} groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []} - allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && manualEditsEnabled} + allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED} + manualEditsEnabled={manualEditsEnabled} onCanvasMouseDown={handlePreviewCanvasMouseDown} onCanvasPointerMove={handlePreviewCanvasPointerMove} onCanvasPointerLeave={handlePreviewCanvasPointerLeave} diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index bb4b3d4d0..1acc1f942 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -36,6 +36,7 @@ interface DomEditOverlayProps { groupSelections?: DomEditSelection[]; hoverSelection: DomEditSelection | null; allowCanvasMovement?: boolean; + manualEditsEnabled?: boolean; onCanvasMouseDown: ( event: React.MouseEvent, options?: { preferClipAncestor?: boolean }, @@ -70,6 +71,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ groupSelections = [], hoverSelection, allowCanvasMovement = true, + manualEditsEnabled = false, onCanvasMouseDown, onCanvasPointerMove, onCanvasPointerLeave, @@ -214,6 +216,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ onCanvasPointerMoveRef.current(event, { preferClipAncestor: false }) ?? hoverSelectionRef.current; if (!candidate?.capabilities.canApplyManualOffset) return; + if (!candidate.capabilities.canMove && !manualEditsEnabled) return; const overlayEl = overlayRef.current; const iframe = iframeRef.current; @@ -353,13 +356,18 @@ export const DomEditOverlay = memo(function DomEditOverlay({ width: overlayRect.width, height: overlayRect.height, cursor: - allowCanvasMovement && selection.capabilities.canApplyManualOffset + allowCanvasMovement && + selection.capabilities.canApplyManualOffset && + (selection.capabilities.canMove || manualEditsEnabled) ? "move" : "default", }} onPointerDown={(e) => { if (!allowCanvasMovement || e.shiftKey) return; - if (selection.capabilities.canApplyManualOffset) { + if ( + selection.capabilities.canApplyManualOffset && + (selection.capabilities.canMove || manualEditsEnabled) + ) { gestures.startGesture("drag", e); return; } diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index d976dbb94..a38d964aa 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -222,9 +222,11 @@ export const PropertyPanel = memo(function PropertyPanel({ ); } + const needsToggleForElement = !element.capabilities.canMove && !manualEditsEnabled; const manualOffsetEditingDisabled = - !element.capabilities.canApplyManualOffset || !manualEditsEnabled; - const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize || !manualEditsEnabled; + !element.capabilities.canApplyManualOffset || needsToggleForElement; + const manualSizeEditingDisabled = + !element.capabilities.canApplyManualSize || needsToggleForElement; const sourceLabel = element.id ? `#${element.id}` : element.selector; const showEditableSections = element.capabilities.canEditStyles; const manualOffset = readStudioPathOffset(element.element); @@ -367,7 +369,7 @@ export const PropertyPanel = memo(function PropertyPanel({ commitManualRotation(next.replace("°", ""))} />
diff --git a/packages/studio/src/components/editor/manualEditsParsing.ts b/packages/studio/src/components/editor/manualEditsParsing.ts index d75ba4bc2..fcb8dd0a6 100644 --- a/packages/studio/src/components/editor/manualEditsParsing.ts +++ b/packages/studio/src/components/editor/manualEditsParsing.ts @@ -129,7 +129,7 @@ export function parseStudioManualEditManifest(content: string): StudioManualEdit const record = parsed as Record; return { version: 1, - enabled: record.enabled !== false, + enabled: record.enabled === true, edits: edits.map(parseManualEdit).filter((edit): edit is StudioManualEdit => edit !== null), }; } catch { diff --git a/packages/studio/src/hooks/useManifestPersistence.ts b/packages/studio/src/hooks/useManifestPersistence.ts index b174c84b7..1d1eeb34b 100644 --- a/packages/studio/src/hooks/useManifestPersistence.ts +++ b/packages/studio/src/hooks/useManifestPersistence.ts @@ -55,7 +55,7 @@ export function useManifestPersistence({ activeCompPathRef, }: UseManifestPersistenceParams) { const [, setStudioMotionRevision] = useState(0); - const [manualEditsEnabled, setManualEditsEnabledState] = useState(true); + const [manualEditsEnabled, setManualEditsEnabledState] = useState(false); const domEditSaveTimestampRef = useRef(0); const domTextCommitVersionRef = useRef(0); @@ -172,7 +172,7 @@ export function useManifestPersistence({ if (options?.forceFromDisk || readRevision === studioManualEditRevisionRef.current) { const parsed = parseStudioManualEditManifest(content); studioManualEditManifestRef.current = parsed; - setManualEditsEnabledState(parsed.enabled ?? true); + setManualEditsEnabledState(parsed.enabled ?? false); if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1; } applyCurrentStudioManualEditsToPreview(iframe); @@ -438,7 +438,7 @@ export function useManifestPersistence({ studioManualEditProjectRef.current = projectId; if (!previousProjectId || previousProjectId === projectId) return; studioManualEditManifestRef.current = emptyStudioManualEditManifest(); - setManualEditsEnabledState(true); + setManualEditsEnabledState(false); studioManualEditRevisionRef.current += 1; studioMotionManifestRef.current = emptyStudioMotionManifest(); studioMotionRevisionRef.current += 1; @@ -519,7 +519,7 @@ export function useManifestPersistence({ void queueDomEditSave(save).catch((error) => { studioManualEditManifestRef.current = previousManifest; studioManualEditRevisionRef.current += 1; - setManualEditsEnabledState(previousManifest.enabled ?? true); + setManualEditsEnabledState(previousManifest.enabled ?? false); const message = error instanceof Error ? error.message : "Failed to save setting"; showToast(message); }); diff --git a/packages/studio/src/hooks/usePreviewInteraction.ts b/packages/studio/src/hooks/usePreviewInteraction.ts index bf7b2c4fb..3273547ed 100644 --- a/packages/studio/src/hooks/usePreviewInteraction.ts +++ b/packages/studio/src/hooks/usePreviewInteraction.ts @@ -124,6 +124,10 @@ export function usePreviewInteraction({ const handleBlockedDomMove = useCallback( (selection: DomEditSelection) => { + if (selection.capabilities.canApplyManualOffset && !selection.capabilities.canMove) { + showToast("Enable 'Manual positioning' in the Design panel to move this element.", "info"); + return; + } showToast( selection.capabilities.reasonIfDisabled ?? "This element can't be adjusted directly from the preview.", From 40c5279f32c2a8c4ba9aec1d77c86389d0cf6b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 23:47:57 -0700 Subject: [PATCH 06/17] feat(studio): persist positions directly to HTML; remove JSON sidecar and manual positioning toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `.hyperframes/studio-manual-edits.json` sidecar with inline-style persistence baked directly into the HTML source. Drag/resize/rotation values are written as CSS custom properties (`--hf-studio-offset-x/y`, `--hf-studio-width/height`, `--hf-studio-rotation`) plus `translate`/`width`/`height`/`rotate` inline styles via `persistDomEditOperations` — no re-apply step needed on load. Key changes: - `sourcePatcher`: add `value: string | null` to `PatchOperation` — null removes the property/attribute from the HTML tag instead of setting it - `manualEditsDom`: add `build*Patches` / `buildClear*Patches` helpers that capture live element state into `PatchOperation[]` for HTML source writes; add `reapplyPositionEditsAfterSeek` (DOM-query-based seek hook, queries data-attribute markers) - `manualEdits.ts`: remove `applyStudioManualEditManifest` and all manifest target resolution; export `reapplyPositionEditsAfterSeek`; keep seek/play wrap infrastructure - `useManifestPersistence`: remove all JSON I/O — no disk read on load, no manifest state, no toggle state; `applyCurrentStudioManualEditsToPreview` now only installs seek hooks via `reapplyPositionEditsAfterSeek` - `useDomEditCommits`: replace `commitStudioManualEditManifestOptimistically` calls with direct DOM apply + `commitPositionPatchToHtml` (queued HTML patch write, skipRefresh) - `DomEditOverlay`: remove `manualEditsEnabled` prop; revert all `canMove || manualEditsEnabled` gates to just `canApplyManualOffset` — every draggable element is always draggable - `PropertyPanel`: remove `ManualPositioningToggle` component and all toggle props - `manualEditsParsing/manualEditsTypes`: remove manifest types, upsert functions, and `STUDIO_MANUAL_EDITS_PATH`; keep `finiteNumber`, `readStudioFileChangePath`, `roundRotationAngle`, and snapshot/CSS-property types --- .filesize-allowlist | 2 + packages/studio/src/App.tsx | 7 +- .../src/components/StudioPreviewArea.tsx | 2 - .../src/components/StudioRightPanel.tsx | 4 - .../components/editor/DomEditOverlay.test.ts | 1 + .../src/components/editor/DomEditOverlay.tsx | 14 +- .../src/components/editor/PropertyPanel.tsx | 57 +- .../src/components/editor/manualEdits.test.ts | 505 ++---------------- .../src/components/editor/manualEdits.ts | 174 +----- .../src/components/editor/manualEditsDom.ts | 331 ++++++++++++ .../components/editor/manualEditsParsing.ts | 244 +-------- .../src/components/editor/manualEditsTypes.ts | 42 +- .../studio/src/contexts/DomEditContext.tsx | 6 - packages/studio/src/hooks/useAppHotkeys.ts | 5 +- .../studio/src/hooks/useDomEditCommits.ts | 145 ++--- .../studio/src/hooks/useDomEditSession.ts | 21 +- .../src/hooks/useManifestPersistence.ts | 267 ++------- .../studio/src/hooks/usePreviewInteraction.ts | 5 - packages/studio/src/utils/sourcePatcher.ts | 64 ++- 19 files changed, 562 insertions(+), 1334 deletions(-) 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/studio/src/App.tsx b/packages/studio/src/App.tsx index 34040c885..900068818 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -197,12 +197,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, @@ -220,8 +217,6 @@ export function StudioApp() { syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey, reloadPreview, setRefreshKey, - manualEditsEnabled: manifestPersistence.manualEditsEnabled, - setManualEditsEnabled: manifestPersistence.setManualEditsEnabled, }); domEditSelectionBridgeRef.current = domEditSession.domEditSelection; diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 8a3de122e..74911d067 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -75,7 +75,6 @@ export function StudioPreviewArea({ domEditHoverSelection, domEditSelection, domEditGroupSelections, - manualEditsEnabled, handleTimelineElementSelect, handlePreviewCanvasMouseDown, handlePreviewCanvasPointerMove, @@ -135,7 +134,6 @@ export function StudioPreviewArea({ selection={shouldShowSelectedDomBounds ? domEditSelection : null} groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []} allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED} - manualEditsEnabled={manualEditsEnabled} onCanvasMouseDown={handlePreviewCanvasMouseDown} onCanvasPointerMove={handlePreviewCanvasPointerMove} onCanvasPointerLeave={handlePreviewCanvasPointerLeave} diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 93251df06..e5ee751ca 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -64,8 +64,6 @@ export function StudioRightPanel({ handleDomMotionCommit, handleDomMotionClear, applyDomSelection, - manualEditsEnabled, - setManualEditsEnabled, } = useDomEditContext(); const { assets, fontAssets, handleImportFiles, handleImportFonts } = useFileManagerContext(); @@ -182,8 +180,6 @@ export function StudioRightPanel({ onImportFonts={handleImportFonts} activeCompositionPath={activeCompPath} onSelectLayer={handleSelectLayer} - manualEditsEnabled={manualEditsEnabled} - onSetManualEditsEnabled={setManualEditsEnabled} /> ) : motionPanelActive ? ( { 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 1acc1f942..ae7da94c4 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -36,7 +36,6 @@ interface DomEditOverlayProps { groupSelections?: DomEditSelection[]; hoverSelection: DomEditSelection | null; allowCanvasMovement?: boolean; - manualEditsEnabled?: boolean; onCanvasMouseDown: ( event: React.MouseEvent, options?: { preferClipAncestor?: boolean }, @@ -71,7 +70,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({ groupSelections = [], hoverSelection, allowCanvasMovement = true, - manualEditsEnabled = false, onCanvasMouseDown, onCanvasPointerMove, onCanvasPointerLeave, @@ -216,7 +214,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({ onCanvasPointerMoveRef.current(event, { preferClipAncestor: false }) ?? hoverSelectionRef.current; if (!candidate?.capabilities.canApplyManualOffset) return; - if (!candidate.capabilities.canMove && !manualEditsEnabled) return; const overlayEl = overlayRef.current; const iframe = iframeRef.current; @@ -310,7 +307,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} @@ -356,18 +353,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({ width: overlayRect.width, height: overlayRect.height, cursor: - allowCanvasMovement && - selection.capabilities.canApplyManualOffset && - (selection.capabilities.canMove || manualEditsEnabled) + allowCanvasMovement && selection.capabilities.canApplyManualOffset ? "move" : "default", }} onPointerDown={(e) => { if (!allowCanvasMovement || e.shiftKey) return; - if ( - selection.capabilities.canApplyManualOffset && - (selection.capabilities.canMove || manualEditsEnabled) - ) { + if (selection.capabilities.canApplyManualOffset) { gestures.startGesture("drag", e); return; } diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index a38d964aa..42d4fcd2a 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -53,8 +53,6 @@ interface PropertyPanelProps { onImportFonts?: (files: FileList | File[]) => Promise; activeCompositionPath?: string | null; onSelectLayer?: (layer: DomEditLayerItem) => void; - manualEditsEnabled?: boolean; - onSetManualEditsEnabled?: (enabled: boolean) => void; } /* ------------------------------------------------------------------ */ @@ -117,42 +115,6 @@ function LayerTree({ ); } -/* ------------------------------------------------------------------ */ -/* ManualPositioningToggle */ -/* ------------------------------------------------------------------ */ - -function ManualPositioningToggle({ - enabled, - onToggle, -}: { - enabled: boolean; - onToggle: (enabled: boolean) => void; -}) { - return ( -
-
- Manual positioning - -
-
- ); -} - /* ------------------------------------------------------------------ */ /* PropertyPanel */ /* ------------------------------------------------------------------ */ @@ -179,8 +141,6 @@ export const PropertyPanel = memo(function PropertyPanel({ onImportFonts, activeCompositionPath = null, onSelectLayer, - manualEditsEnabled = true, - onSetManualEditsEnabled, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; @@ -212,21 +172,12 @@ export const PropertyPanel = memo(function PropertyPanel({ )}
- {onSetManualEditsEnabled && ( - - )} ); } - const needsToggleForElement = !element.capabilities.canMove && !manualEditsEnabled; - const manualOffsetEditingDisabled = - !element.capabilities.canApplyManualOffset || needsToggleForElement; - const manualSizeEditingDisabled = - !element.capabilities.canApplyManualSize || needsToggleForElement; + const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset; + const manualSizeEditingDisabled = !element.capabilities.canApplyManualSize; const sourceLabel = element.id ? `#${element.id}` : element.selector; const showEditableSections = element.capabilities.canEditStyles; const manualOffset = readStudioPathOffset(element.element); @@ -369,7 +320,6 @@ export const PropertyPanel = memo(function PropertyPanel({ commitManualRotation(next.replace("°", ""))} /> @@ -394,9 +344,6 @@ export const PropertyPanel = memo(function PropertyPanel({ /> )} - {onSetManualEditsEnabled && ( - - )} ); }); 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..7e5371221 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -434,3 +434,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 fcb8dd0a6..3eda8d8ae 100644 --- a/packages/studio/src/components/editor/manualEditsParsing.ts +++ b/packages/studio/src/components/editor/manualEditsParsing.ts @@ -1,144 +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(); - const record = parsed as Record; - return { - version: 1, - enabled: record.enabled === true, - 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 ──────────────────────────────────────────── */ @@ -174,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 027a7d214..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,45 +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; - enabled?: boolean; - edits: StudioManualEdit[]; -} - export type StudioManualEditSeekWindow = Window & { __hf?: Record; __player?: Record; @@ -88,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/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 13860ffad..d6aba5d6a 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -51,8 +51,6 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, - manualEditsEnabled, - setManualEditsEnabled, }, children, }: { @@ -99,8 +97,6 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, - manualEditsEnabled, - setManualEditsEnabled, }), [ domEditSelection, @@ -141,8 +137,6 @@ export function DomEditProvider({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, - manualEditsEnabled, - setManualEditsEnabled, ], ); return {children}; 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..14f9d166e 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,7 +74,6 @@ export interface UseDomEditCommitsParams { // From useDomSelection domEditSelection: DomEditSelection | null; - domEditSelectionRef: React.MutableRefObject; domEditGroupSelectionsRef: React.MutableRefObject; applyDomSelection: ( selection: DomEditSelection | null, @@ -90,9 +94,8 @@ export function useDomEditCommits({ activeCompPath, previewIframeRef, showToast, - commitStudioManualEditManifestOptimistically, + queueDomEditSave, commitStudioMotionManifestOptimistically, - applyCurrentStudioManualEditsToPreview, applyCurrentStudioMotionToPreview, writeProjectFile, domEditSaveTimestampRef, @@ -211,45 +214,60 @@ 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)}`, - }, - ); + applyStudioPathOffset(selection.element, next); + commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { + label: "Move layer", + coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, + }); refreshDomEditSelectionFromPreview(selection); }, - [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview], + [commitPositionPatchToHtml, refreshDomEditSelectionFromPreview], ); 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, + commitPositionPatchToHtml, domEditGroupSelectionsRef, refreshDomEditGroupSelectionsFromPreview, ], @@ -257,52 +275,51 @@ export function useDomEditCommits({ 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)}`, - }, - ); + applyStudioBoxSize(selection.element, next); + commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), { + label: "Resize layer box", + coalesceKey: `box-size:${getDomEditTargetKey(selection)}`, + }); refreshDomEditSelectionFromPreview(selection); }, - [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview], + [commitPositionPatchToHtml, refreshDomEditSelectionFromPreview], ); const handleDomRotationCommit = useCallback( (selection: DomEditSelection, next: { angle: number }) => { - commitStudioManualEditManifestOptimistically( - (manifest) => upsertStudioRotationEdit(manifest, selection, next), - { - label: "Rotate layer", - coalesceKey: `rotation:${getDomEditTargetKey(selection)}`, - }, - ); + applyStudioRotation(selection.element, next); + commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), { + label: "Rotate layer", + coalesceKey: `rotation:${getDomEditTargetKey(selection)}`, + }); refreshDomEditSelectionFromPreview(selection); }, - [commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview], + [commitPositionPatchToHtml, refreshDomEditSelectionFromPreview], ); const handleDomManualEditsReset = useCallback( (selection: DomEditSelection) => { - commitStudioManualEditManifestOptimistically( - (manifest) => removeStudioManualEditsForSelection(manifest, selection), - { - label: "Reset layer edits", - coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`, - }, - ); - applyCurrentStudioManualEditsToPreview(previewIframeRef.current); + const element = selection.element; + const clearPatches = [ + ...buildClearPathOffsetPatches(element), + ...buildClearBoxSizePatches(element), + ...buildClearRotationPatches(element), + ]; + clearStudioPathOffset(element); + clearStudioBoxSize(element); + clearStudioRotation(element); + commitPositionPatchToHtml(selection, clearPatches, { + label: "Reset layer edits", + coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`, + skipRefresh: false, + }); refreshDomEditSelectionFromPreview(selection); }, - [ - applyCurrentStudioManualEditsToPreview, - commitStudioManualEditManifestOptimistically, - refreshDomEditSelectionFromPreview, - previewIframeRef, - ], + [commitPositionPatchToHtml, refreshDomEditSelectionFromPreview], ); + // ── Motion commits ── + const handleDomMotionCommit = useCallback( ( selection: DomEditSelection, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index d6eb47854..db4a95a80 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; @@ -66,8 +61,6 @@ export interface UseDomEditSessionParams { syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void; reloadPreview: () => void; setRefreshKey: React.Dispatch>; - manualEditsEnabled: boolean; - setManualEditsEnabled: (enabled: boolean) => void; } // ── Hook ── @@ -87,9 +80,8 @@ export function useDomEditSession({ setRightPanelTab, showToast, refreshPreviewDocumentVersion, - commitStudioManualEditManifestOptimistically, + queueDomEditSave, commitStudioMotionManifestOptimistically, - applyCurrentStudioManualEditsToPreview, applyCurrentStudioMotionToPreview, readProjectFile: _readProjectFile, writeProjectFile, @@ -107,8 +99,6 @@ export function useDomEditSession({ syncPreviewHistoryHotkey, reloadPreview, setRefreshKey: _setRefreshKey, - manualEditsEnabled, - setManualEditsEnabled, }: UseDomEditSessionParams) { void _setRefreshKey; // ── Selection (delegated to useDomSelection) ── @@ -180,7 +170,6 @@ export function useDomEditSession({ captionEditMode, compositionLoading, previewIframeRef, - activeCompPath, showToast, applyDomSelection, resolveDomSelectionFromPreviewPoint, @@ -212,9 +201,8 @@ export function useDomEditSession({ activeCompPath, previewIframeRef, showToast, - commitStudioManualEditManifestOptimistically, + queueDomEditSave, commitStudioMotionManifestOptimistically, - applyCurrentStudioManualEditsToPreview, applyCurrentStudioMotionToPreview, writeProjectFile, domEditSaveTimestampRef, @@ -225,7 +213,6 @@ export function useDomEditSession({ projectIdRef, reloadPreview, domEditSelection, - domEditSelectionRef, domEditGroupSelectionsRef, applyDomSelection, clearDomSelection, @@ -344,7 +331,5 @@ export function useDomEditSession({ setAgentModalOpen, setAgentPromptSelectionContext, setAgentModalAnchorPoint, - manualEditsEnabled, - setManualEditsEnabled, }; } diff --git a/packages/studio/src/hooks/useManifestPersistence.ts b/packages/studio/src/hooks/useManifestPersistence.ts index 1d1eeb34b..4130ff4c2 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, @@ -48,22 +42,19 @@ interface UseManifestPersistenceParams { export function useManifestPersistence({ projectId, showToast, - readOptionalProjectFile, + readOptionalProjectFile: _readOptionalProjectFile, writeProjectFile, recordEdit, previewIframeRef, activeCompPathRef, }: UseManifestPersistenceParams) { + void _readOptionalProjectFile; + const [, setStudioMotionRevision] = useState(0); - const [manualEditsEnabled, setManualEditsEnabledState] = useState(false); const domEditSaveTimestampRef = useRef(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< @@ -78,9 +69,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. @@ -102,7 +91,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) => { @@ -114,70 +103,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) { - const parsed = parseStudioManualEditManifest(content); - studioManualEditManifestRef.current = parsed; - setManualEditsEnabledState(parsed.enabled ?? false); - if (options?.forceFromDisk) studioManualEditRevisionRef.current += 1; - } + async (iframe: HTMLIFrameElement | null = previewIframeRef.current) => { applyCurrentStudioManualEditsToPreview(iframe); }, - [applyCurrentStudioManualEditsToPreview, previewIframeRef, readOptionalProjectFile, showToast], + [applyCurrentStudioManualEditsToPreview, previewIframeRef], ); applyStudioManualEditsToPreviewRef.current = applyStudioManualEditsToPreview; @@ -233,7 +190,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); @@ -247,80 +204,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( ( @@ -342,7 +230,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); @@ -387,7 +275,7 @@ export function useManifestPersistence({ applyCurrentStudioMotionToPreview, recordEdit, queueDomEditSave, - readOptionalProjectFile, + _readOptionalProjectFile, showToast, writeProjectFile, previewIframeRef, @@ -399,70 +287,48 @@ 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 + // Cross-origin or detached — fall through } } }, - [applyStudioManualEditsToPreview, applyStudioMotionToPreview, previewIframeRef], + [applyStudioMotionToPreview, previewIframeRef], ); // ── 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(); - setManualEditsEnabledState(false); - 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, { @@ -471,7 +337,7 @@ export function useManifestPersistence({ } return; } - // Non-manifest file changes are not handled here — the caller is + // Non-motion 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. }; @@ -485,76 +351,21 @@ export function useManifestPersistence({ return () => es.close(); }); - const setManualEditsEnabled = useCallback( - (enabled: boolean) => { - const previousManifest = studioManualEditManifestRef.current; - const nextManifest = { ...previousManifest, enabled }; - studioManualEditManifestRef.current = nextManifest; - studioManualEditRevisionRef.current += 1; - setManualEditsEnabledState(enabled); - - const save = async () => { - const originalContent = await readOptionalProjectFile(STUDIO_MANUAL_EDITS_PATH); - const diskManifest = parseStudioManualEditManifest(originalContent); - const nextDiskManifest = { ...diskManifest, enabled }; - 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: enabled ? "Enable manual positioning" : "Disable manual positioning", - kind: "manual", - coalesceKey: "manual-edits-enabled", - files: { [STUDIO_MANUAL_EDITS_PATH]: nextDiskContent }, - readFile: async () => originalContent, - writeFile: writeProjectFile, - recordEdit, - }); - domEditSaveTimestampRef.current = Date.now(); - }; - - void queueDomEditSave(save).catch((error) => { - studioManualEditManifestRef.current = previousManifest; - studioManualEditRevisionRef.current += 1; - setManualEditsEnabledState(previousManifest.enabled ?? false); - const message = error instanceof Error ? error.message : "Failed to save setting"; - showToast(message); - }); - }, - [ - queueDomEditSave, - readOptionalProjectFile, - writeProjectFile, - recordEdit, - showToast, - domEditSaveTimestampRef, - ], - ); - return { domEditSaveTimestampRef, domTextCommitVersionRef, domEditSaveQueueRef, - studioManualEditManifestRef, - studioManualEditRevisionRef, studioMotionManifestRef, studioMotionRevisionRef, applyStudioManualEditsToPreviewRef, applyStudioMotionToPreviewRef, - studioManualEditProjectRef, queueDomEditSave, waitForPendingDomEditSaves, applyCurrentStudioManualEditsToPreview, applyStudioManualEditsToPreview, applyCurrentStudioMotionToPreview, applyStudioMotionToPreview, - commitStudioManualEditManifestOptimistically, commitStudioMotionManifestOptimistically, syncHistoryPreviewAfterApply, - manualEditsEnabled, - setManualEditsEnabled, }; } diff --git a/packages/studio/src/hooks/usePreviewInteraction.ts b/packages/studio/src/hooks/usePreviewInteraction.ts index 3273547ed..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 @@ -124,10 +123,6 @@ export function usePreviewInteraction({ const handleBlockedDomMove = useCallback( (selection: DomEditSelection) => { - if (selection.capabilities.canApplyManualOffset && !selection.capabilities.canMove) { - showToast("Enable 'Manual positioning' in the Design panel to move this element.", "info"); - return; - } showToast( selection.capabilities.reasonIfDisabled ?? "This element can't be adjusted directly from the preview.", 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; } From 6cfd1b4764635fe80794e3059410bde15344c8cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 14 May 2026 14:05:29 -0700 Subject: [PATCH 07/17] fix(studio): sync keyboard shortcut handler with main; fix keepPlaying seek assertions in test --- packages/studio/src/player/hooks/usePlaybackKeyboard.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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", () => { From 3e5fbb6bccde10856a39e44d043b05353bc8739b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 14 May 2026 14:33:37 -0700 Subject: [PATCH 08/17] fix(studio): strip GSAP-cached translate from transform on path offset apply --- .../src/components/editor/manualEditsDom.ts | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 7e5371221..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 ───────────────────────────────────────────────── */ From 1dba42151ed2880578580708cedfaf22230f6454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 14 May 2026 14:39:24 -0700 Subject: [PATCH 09/17] fix(studio): remove Reset edits button from design panel --- packages/studio/src/components/StudioRightPanel.tsx | 2 -- .../studio/src/components/editor/PropertyPanel.tsx | 13 +------------ 2 files changed, 1 insertion(+), 14 deletions(-) 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/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 42d4fcd2a..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 = [], @@ -255,15 +253,6 @@ export const PropertyPanel = memo(function PropertyPanel({ {copiedAgentPrompt ? "Prompt copied" : "Ask agent"} -
From 55805ef5863c4eb8592bd9cb384eccb60e4a34b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 14 May 2026 19:31:22 -0700 Subject: [PATCH 10/17] feat(studio): wire reloadPreview into manifest persistence; drop stale group-selection refresh - Pass `reloadPreview` into `useManifestPersistence` so undo/redo reloads via the refresh-key path instead of directly touching the iframe. - Remove `refreshDomEditGroupSelectionsFromPreview` from commit handlers; HTML is now the source of truth so no stale-ref refresh is needed. - Add `manualEditsRenderScript` helper; export via studio-api and apply it in `htmlCompiler` during HTML compilation. --- .../helpers/manualEditsRenderScript.ts | 168 ++++++++++++++++++ packages/core/src/studio-api/index.ts | 1 + .../producer/src/services/htmlCompiler.ts | 16 +- packages/studio/src/App.tsx | 1 + .../studio/src/hooks/useDomEditCommits.ts | 24 +-- .../studio/src/hooks/useDomEditSession.ts | 4 - .../src/hooks/useManifestPersistence.ts | 17 +- 7 files changed, 197 insertions(+), 34 deletions(-) diff --git a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts index 444bb9f0b..c84ea1a22 100644 --- a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts +++ b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts @@ -12,6 +12,174 @@ 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; + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => reapplyAll(), { once: true }); + } else { + reapplyAll(); + } + + wrapSeekFunctions(); + 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/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 900068818..37f990cfc 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -144,6 +144,7 @@ export function StudioApp() { recordEdit: editHistory.recordEdit, previewIframeRef, activeCompPathRef, + reloadPreview: () => setRefreshKey((k) => k + 1), }); const timelineEditing = useTimelineEditing({ diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 14f9d166e..babd64d86 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -74,14 +74,12 @@ export interface UseDomEditCommitsParams { // From useDomSelection domEditSelection: DomEditSelection | null; - 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 }, @@ -106,11 +104,9 @@ export function useDomEditCommits({ projectIdRef, reloadPreview, domEditSelection, - domEditGroupSelectionsRef, applyDomSelection, clearDomSelection, refreshDomEditSelectionFromPreview, - refreshDomEditGroupSelectionsFromPreview, buildDomSelectionFromTarget, }: UseDomEditCommitsParams) { const resolveImportedFontAsset = useCallback( @@ -245,9 +241,8 @@ export function useDomEditCommits({ label: "Move layer", coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, }); - refreshDomEditSelectionFromPreview(selection); }, - [commitPositionPatchToHtml, refreshDomEditSelectionFromPreview], + [commitPositionPatchToHtml], ); const handleDomGroupPathOffsetCommit = useCallback( @@ -264,13 +259,8 @@ export function useDomEditCommits({ coalesceKey: `group-path-offset:${coalesceKey}`, }); } - refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current); }, - [ - commitPositionPatchToHtml, - domEditGroupSelectionsRef, - refreshDomEditGroupSelectionsFromPreview, - ], + [commitPositionPatchToHtml], ); const handleDomBoxSizeCommit = useCallback( @@ -280,9 +270,8 @@ export function useDomEditCommits({ label: "Resize layer box", coalesceKey: `box-size:${getDomEditTargetKey(selection)}`, }); - refreshDomEditSelectionFromPreview(selection); }, - [commitPositionPatchToHtml, refreshDomEditSelectionFromPreview], + [commitPositionPatchToHtml], ); const handleDomRotationCommit = useCallback( @@ -292,9 +281,8 @@ export function useDomEditCommits({ label: "Rotate layer", coalesceKey: `rotation:${getDomEditTargetKey(selection)}`, }); - refreshDomEditSelectionFromPreview(selection); }, - [commitPositionPatchToHtml, refreshDomEditSelectionFromPreview], + [commitPositionPatchToHtml], ); const handleDomManualEditsReset = useCallback( @@ -308,14 +296,14 @@ export function useDomEditCommits({ 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, }); - refreshDomEditSelectionFromPreview(selection); }, - [commitPositionPatchToHtml, refreshDomEditSelectionFromPreview], + [commitPositionPatchToHtml], ); // ── Motion commits ── diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index db4a95a80..ad216f390 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -108,7 +108,6 @@ export function useDomEditSession({ domEditGroupSelections, domEditHoverSelection, domEditSelectionRef, - domEditGroupSelectionsRef, applyDomSelection, clearDomSelection, buildDomSelectionFromTarget, @@ -117,7 +116,6 @@ export function useDomEditSession({ buildDomSelectionForTimelineElement, handleTimelineElementSelect, refreshDomEditSelectionFromPreview, - refreshDomEditGroupSelectionsFromPreview, } = useDomSelection({ projectId, activeCompPath, @@ -213,11 +211,9 @@ export function useDomEditSession({ projectIdRef, reloadPreview, domEditSelection, - domEditGroupSelectionsRef, applyDomSelection, clearDomSelection, refreshDomEditSelectionFromPreview, - refreshDomEditGroupSelectionsFromPreview, buildDomSelectionFromTarget, }); diff --git a/packages/studio/src/hooks/useManifestPersistence.ts b/packages/studio/src/hooks/useManifestPersistence.ts index 4130ff4c2..c89f9345d 100644 --- a/packages/studio/src/hooks/useManifestPersistence.ts +++ b/packages/studio/src/hooks/useManifestPersistence.ts @@ -35,6 +35,8 @@ interface UseManifestPersistenceParams { recordEdit: (entry: RecordEditInput) => Promise; previewIframeRef: React.MutableRefObject; activeCompPathRef: React.MutableRefObject; + /** Called to reload the preview after undo/redo. Must go through refreshKey so seek position is preserved. */ + reloadPreview: () => void; } // ── Hook ── @@ -47,6 +49,7 @@ export function useManifestPersistence({ recordEdit, previewIframeRef, activeCompPathRef, + reloadPreview, }: UseManifestPersistenceParams) { void _readOptionalProjectFile; @@ -295,18 +298,10 @@ export function useManifestPersistence({ return; } - // Reload the iframe in-place rather than recreating the Player component. - const iframe = previewIframeRef.current; - if (iframe?.contentWindow) { - try { - iframe.contentWindow.location.reload(); - return; - } catch { - // Cross-origin or detached — fall through - } - } + // Reload via refreshKey so NLELayout saves seek position before the iframe reloads. + reloadPreview(); }, - [applyStudioMotionToPreview, previewIframeRef], + [applyStudioMotionToPreview, previewIframeRef, reloadPreview], ); // ── Reset manifests when project changes ── From 3f64adede828eb61ceeb3b76422fcaa5398c048f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 14 May 2026 19:31:43 -0700 Subject: [PATCH 11/17] fix(studio): prevent root composition from being selected; correct overlay drift on resize - Guard `getDomLayerPatchTarget` against elements with `data-composition-id` so the root composition div is never returned as a visual selection target. - Apply the same guard to the raw `elementFromPoint` fallback in `getPreviewTargetFromPointer`, which was the actual escape path. - Thread `iframeRef` into gesture handler opts; after applying draft dimensions during resize, re-read the element BCR via `toOverlayRect` and update the overlay box position to compensate for visual drift on elements with centered transform-origin (e.g. GSAP scale tweens). --- .../src/components/editor/DomEditOverlay.tsx | 1 + .../components/editor/domEditingElement.ts | 1 + .../editor/useDomEditOverlayGestures.ts | 24 ++++++++++++++++++- .../studio/src/utils/studioPreviewHelpers.ts | 5 +++- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index ae7da94c4..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, 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/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index e00279128..b03180260 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; @@ -205,6 +206,27 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu box.style.width = `${nextSize.overlayWidth}px`; box.style.height = `${nextSize.overlayHeight}px`; applyStudioBoxSizeDraft(sel.element, nextSize); + + // After applying dimensions, the element's visual top-left may drift when + // transform-origin is center (e.g. GSAP scale tween). Re-read BCR to + // correct the overlay position. + const overlayEl = opts.overlayRef.current; + const iframe = opts.iframeRef.current; + if (overlayEl && iframe) { + const refreshed = toOverlayRect(overlayEl, iframe, sel.element); + if (refreshed) { + box.style.left = `${refreshed.left}px`; + box.style.top = `${refreshed.top}px`; + setDraftOverlayRect({ + left: refreshed.left, + top: refreshed.top, + width: nextSize.overlayWidth, + height: nextSize.overlayHeight, + editScaleX: g.editScaleX, + editScaleY: g.editScaleY, + }); + } + } } }; diff --git a/packages/studio/src/utils/studioPreviewHelpers.ts b/packages/studio/src/utils/studioPreviewHelpers.ts index 32c216a89..4938da158 100644 --- a/packages/studio/src/utils/studioPreviewHelpers.ts +++ b/packages/studio/src/utils/studioPreviewHelpers.ts @@ -1,5 +1,6 @@ import type { DomEditViewport, DomEditSelection } from "../components/editor/domEditing"; import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing"; +import { getDomLayerPatchTarget } from "../components/editor/domEditingElement"; import { usePlayerStore, liveTime } from "../player"; import { getEventTargetElement } from "./studioHelpers"; @@ -85,7 +86,9 @@ export function getPreviewTargetFromPointer( 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; + return fallback; } export function buildRasterClickSelectionContext( From db2c508a2da568a99d9b9fa667bb5f8ac10d3692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 14 May 2026 19:37:07 -0700 Subject: [PATCH 12/17] fix(studio): correct resize overlay for scaled elements; block invisible element selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resize: use BCR from `toOverlayRect` for both position and size after applying draft dimensions — GSAP scale makes visual size diverge from raw CSS size, BCR is the only accurate source during a gesture. - Click selection: add `isElementComputedVisible` guard to the `elementFromPoint` fallback so opacity-0 / autoAlpha-hidden elements cannot be selected even though the browser hit-test returns them. --- .../editor/useDomEditOverlayGestures.ts | 49 ++++++++----------- .../studio/src/utils/studioPreviewHelpers.ts | 6 ++- 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index b03180260..316e3d90b 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -195,38 +195,31 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu dy, uniform: e.shiftKey, }); - setDraftOverlayRect({ - left: g.originLeft, - top: g.originTop, - width: nextSize.overlayWidth, - height: nextSize.overlayHeight, - editScaleX: g.editScaleX, - editScaleY: g.editScaleY, - }); - box.style.width = `${nextSize.overlayWidth}px`; - box.style.height = `${nextSize.overlayHeight}px`; applyStudioBoxSizeDraft(sel.element, nextSize); - // After applying dimensions, the element's visual top-left may drift when - // transform-origin is center (e.g. GSAP scale tween). Re-read BCR to - // correct the overlay position. + // 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; - if (overlayEl && iframe) { - const refreshed = toOverlayRect(overlayEl, iframe, sel.element); - if (refreshed) { - box.style.left = `${refreshed.left}px`; - box.style.top = `${refreshed.top}px`; - setDraftOverlayRect({ - left: refreshed.left, - top: refreshed.top, - width: nextSize.overlayWidth, - height: nextSize.overlayHeight, - editScaleX: g.editScaleX, - editScaleY: g.editScaleY, - }); - } - } + 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: overlayLeft, + top: overlayTop, + width: overlayWidth, + height: overlayHeight, + editScaleX: g.editScaleX, + editScaleY: g.editScaleY, + }); } }; diff --git a/packages/studio/src/utils/studioPreviewHelpers.ts b/packages/studio/src/utils/studioPreviewHelpers.ts index 4938da158..a4d2b9750 100644 --- a/packages/studio/src/utils/studioPreviewHelpers.ts +++ b/packages/studio/src/utils/studioPreviewHelpers.ts @@ -1,6 +1,9 @@ import type { DomEditViewport, DomEditSelection } from "../components/editor/domEditing"; import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing"; -import { getDomLayerPatchTarget } from "../components/editor/domEditingElement"; +import { + getDomLayerPatchTarget, + isElementComputedVisible, +} from "../components/editor/domEditingElement"; import { usePlayerStore, liveTime } from "../player"; import { getEventTargetElement } from "./studioHelpers"; @@ -88,6 +91,7 @@ export function getPreviewTargetFromPointer( const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y)); if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null; + if (!isElementComputedVisible(fallback)) return null; return fallback; } From 042e0b735594085a3d1d23bf20da7fdbf02cec04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 14 May 2026 20:07:26 -0700 Subject: [PATCH 13/17] fix(studio): reload preview on external file changes via SSE/HMR Share the app-level domEditSaveTimestampRef with useManifestPersistence so the SSE/HMR handler can suppress echoes from all studio saves (code tab, timeline, DOM edits), then call reloadPreview() for non-motion external changes that aren't echoes of our own saves. --- packages/studio/src/App.tsx | 1 + .../studio/src/hooks/useManifestPersistence.ts | 17 ++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 37f990cfc..6454ce02a 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -144,6 +144,7 @@ export function StudioApp() { recordEdit: editHistory.recordEdit, previewIframeRef, activeCompPathRef, + domEditSaveTimestampRef, reloadPreview: () => setRefreshKey((k) => k + 1), }); diff --git a/packages/studio/src/hooks/useManifestPersistence.ts b/packages/studio/src/hooks/useManifestPersistence.ts index c89f9345d..55d925982 100644 --- a/packages/studio/src/hooks/useManifestPersistence.ts +++ b/packages/studio/src/hooks/useManifestPersistence.ts @@ -35,7 +35,10 @@ interface UseManifestPersistenceParams { recordEdit: (entry: RecordEditInput) => Promise; previewIframeRef: React.MutableRefObject; activeCompPathRef: React.MutableRefObject; - /** Called to reload the preview after undo/redo. Must go through refreshKey so seek position is preserved. */ + /** 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; } @@ -49,13 +52,12 @@ export function useManifestPersistence({ recordEdit, previewIframeRef, activeCompPathRef, + domEditSaveTimestampRef, reloadPreview, }: UseManifestPersistenceParams) { void _readOptionalProjectFile; const [, setStudioMotionRevision] = useState(0); - - const domEditSaveTimestampRef = useRef(0); const domTextCommitVersionRef = useRef(0); const domEditSaveQueueRef = useRef(Promise.resolve()); const studioMotionManifestRef = useRef(emptyStudioMotionManifest()); @@ -282,6 +284,7 @@ export function useManifestPersistence({ showToast, writeProjectFile, previewIframeRef, + domEditSaveTimestampRef, ], ); @@ -332,9 +335,10 @@ export function useManifestPersistence({ } return; } - // Non-motion 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); @@ -347,7 +351,6 @@ export function useManifestPersistence({ }); return { - domEditSaveTimestampRef, domTextCommitVersionRef, domEditSaveQueueRef, studioMotionManifestRef, From 90cbe9f231e99674768d897240431827b51c4dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 14 May 2026 20:17:12 -0700 Subject: [PATCH 14/17] fix(studio): suppress post-resize click to keep selection on resized element --- .../studio/src/components/editor/useDomEditOverlayGestures.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index 316e3d90b..b912ff20f 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -296,6 +296,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu box.style.height = `${g.originHeight}px`; } restoreGestureOverlayRect(g); + opts.suppressNextBoxClickRef.current = true; return; } @@ -356,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)) From 18f79dc25d9ffb891fa9386ecc4ea5add2b90157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 14 May 2026 21:02:04 -0700 Subject: [PATCH 15/17] fix(studio): serve registry blocks without index.html in preview Blocks ship as {id}.html + assets/ with no index.html. The preview route hard-coded index.html so these projects returned 404 and their assets (e.g. korea-map.png, map-nyc-paris.png) were never served. Add resolveProjectMainHtml() that falls back to {id}.html, thread the resolved compositionPath through transformPreviewHtml and injectStudioPreviewAugmentations, and update listProjects() in the vite adapter to surface block directories in the project list. --- .../core/src/studio-api/routes/preview.ts | 35 ++++++++++++++----- packages/studio/vite.adapter.ts | 3 +- 2 files changed, 28 insertions(+), 10 deletions(-) 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/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); From 1b7293ab650c32749e88a4620e25b5315e79fb4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 14 May 2026 21:10:11 -0700 Subject: [PATCH 16/17] fix(render): preserve studio drag/resize/rotation offsets in rendered video MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues caused studio-edited positions to be lost during rendering: 1. The seek-reapply script used setInterval to wrap window.__hf.seek, but Puppeteer's page.evaluate() calls don't yield the event loop for macrotasks — the interval never fired, so reapplyAll() never ran after GSAP seeks. Fix: use Object.defineProperty to trap writes to the seek property, wrapping it synchronously the instant the bridge assigns it. 2. MEDIA_VISUAL_STYLE_PROPERTIES (copied from