Files
hyperframes/packages/studio/src/components/editor/useDomEditOverlayGestures.ts
T
Miguel Ángel 225010800a feat(studio): persist element positions in HTML, fix resize overlay drift and GSAP double-translation (#829)
* feat(studio): add pasteboard background to preview viewport

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).

* feat(studio): pasteboard background and canvas outline around preview

- 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

* feat(studio): disable manual positioning JSON by default, add toggle

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)

* feat(studio): enable manual positioning by default (opt-out)

* feat(studio): allow absolute elements to drag without toggle; gate JSON-backed drag behind toggle

* feat(studio): persist positions directly to HTML; remove JSON sidecar and manual positioning toggle

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

* fix(studio): sync keyboard shortcut handler with main; fix keepPlaying seek assertions in test

* fix(studio): strip GSAP-cached translate from transform on path offset apply

* fix(studio): remove Reset edits button from design panel

* 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.

* 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).

* fix(studio): correct resize overlay for scaled elements; block invisible element selection

- 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.

* 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.

* fix(studio): suppress post-resize click to keep selection on resized element

* 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.

* fix(render): preserve studio drag/resize/rotation offsets in rendered video

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 <video> to proxy <img>
   during render) included "transform" but not "translate", "rotate", or
   "scale" — the CSS Transforms Level 2 individual properties used by
   studio drag/resize/rotation. The proxy was positioned at offsetLeft/
   offsetTop without the translate offset.

3. getViewportMatrix (HDR compositor) only read cs.transform, missing
   individual transform properties entirely. Added composeIndividualTransforms
   to build the translate × rotate × scale matrix and compose it before
   the legacy transform matrix.

* fix(studio): select elements with pointer-events: none in preview

Compositions often set pointer-events: none on scenes, avatar wrappers,
and decorative layers. elementsFromPoint() skips these elements entirely,
making them unselectable in the Studio. Fix: temporarily inject a
* { pointer-events: auto !important } stylesheet during hit-testing, then
remove it immediately after.

Also adds a pointer_events_none lint rule (info severity, visible with
--verbose) so authors know which selectors may affect Studio selection.
2026-05-15 06:43:00 +02:00

402 lines
14 KiB
TypeScript

/**
* Gesture handling for DomEditOverlay.
* Owns: onPointerMove, onPointerUp, clearPointerState.
* startGesture and startGroupDrag live in domEditOverlayStartGesture.ts.
*/
import type { RefObject } from "react";
import { type DomEditSelection } from "./domEditing";
import {
applyManualOffsetDragCommit,
applyManualOffsetDragDraft,
endManualOffsetDragMembers,
restoreManualOffsetDragMembers,
} from "./manualOffsetDrag";
import {
applyStudioBoxSize,
applyStudioBoxSizeDraft,
applyStudioRotation,
applyStudioRotationDraft,
endStudioManualEditGesture,
isStudioManualEditGestureCurrent,
readStudioBoxSize,
restoreStudioBoxSize,
restoreStudioPathOffset,
restoreStudioRotation,
} from "./manualEdits";
import { type GroupOverlayItem, type OverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
import {
BLOCKED_MOVE_THRESHOLD_PX,
type BlockedMoveState,
type GestureKind,
type GestureState,
type GroupGestureState,
hasDomEditRotationChanged,
resolveDomEditResizeGesture,
resolveDomEditRotationGesture,
} from "./domEditOverlayGestures";
import type { DomEditGroupPathOffsetCommit } from "./DomEditOverlay";
import {
startGesture as _startGesture,
startGroupDrag as _startGroupDrag,
} from "./domEditOverlayStartGesture";
// Refs are stable across renders; values are read via .current.
export type UseDomEditOverlayGesturesOptions = {
overlayRef: RefObject<HTMLDivElement | null>;
iframeRef: RefObject<HTMLIFrameElement | null>;
boxRef: RefObject<HTMLDivElement | null>;
selectionRef: RefObject<DomEditSelection | null>;
overlayRectRef: RefObject<OverlayRect | null>;
groupOverlayItemsRef: RefObject<GroupOverlayItem[]>;
gestureRef: RefObject<GestureState | null>;
groupGestureRef: RefObject<GroupGestureState | null>;
blockedMoveRef: RefObject<BlockedMoveState | null>;
rafPausedRef: RefObject<boolean>;
suppressNextBoxClickRef: RefObject<boolean>;
setOverlayRect: (next: OverlayRect | null) => void;
setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>;
onManualDragStartRef: RefObject<(() => void) | undefined>;
onPathOffsetCommitRef: RefObject<
(s: DomEditSelection, n: { x: number; y: number }) => Promise<void> | void
>;
onGroupPathOffsetCommitRef: RefObject<
(updates: DomEditGroupPathOffsetCommit[]) => Promise<void> | void
>;
onBoxSizeCommitRef: RefObject<
(s: DomEditSelection, n: { width: number; height: number }) => Promise<void> | void
>;
onRotationCommitRef: RefObject<
(s: DomEditSelection, n: { angle: number }) => Promise<void> | void
>;
onCanvasPointerMoveRef: RefObject<
(
e: React.PointerEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean },
) => DomEditSelection | null
>;
onCanvasMouseDown: (
e: React.MouseEvent<HTMLDivElement>,
o?: { preferClipAncestor?: boolean },
) => void;
};
export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
const setDraftOverlayRect = (next: OverlayRect) => {
opts.setOverlayRect(next);
};
const restoreGestureOverlayRect = (g: GestureState) => {
setDraftOverlayRect({
left: g.originLeft,
top: g.originTop,
width: g.originWidth,
height: g.originHeight,
editScaleX: g.editScaleX,
editScaleY: g.editScaleY,
});
};
const setDraftGroupOverlayItems = (next: GroupOverlayItem[]) => {
opts.setGroupOverlayItems(next);
};
const restoreGroupPathOffsets = (g: GroupGestureState) => {
restoreManualOffsetDragMembers(g.members);
setDraftGroupOverlayItems(g.originItems);
};
const startGroupDrag = (e: React.PointerEvent<HTMLElement>) => _startGroupDrag(e, opts);
const startGesture = (
kind: GestureKind,
e: React.PointerEvent<HTMLElement>,
options?: { selection?: DomEditSelection; rect?: OverlayRect | null },
) => _startGesture(kind, e, opts, options);
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const g = opts.gestureRef.current;
const groupG = opts.groupGestureRef.current;
const sel = g?.selection ?? opts.selectionRef.current;
const box = opts.boxRef.current;
const blockedMove = opts.blockedMoveRef.current;
if (!blockedMove && !g && !groupG) {
opts.onCanvasPointerMoveRef.current(e, { preferClipAncestor: false });
}
if (blockedMove && sel) {
const dx = e.clientX - blockedMove.startX;
const dy = e.clientY - blockedMove.startY;
if (!blockedMove.notified && Math.hypot(dx, dy) >= BLOCKED_MOVE_THRESHOLD_PX) {
blockedMove.notified = true;
opts.suppressNextBoxClickRef.current = true;
opts.onBlockedMoveRef.current(sel);
}
return;
}
if (groupG) {
const dx = e.clientX - groupG.startX;
const dy = e.clientY - groupG.startY;
setDraftGroupOverlayItems(
groupG.originItems.map((item) => ({
...item,
rect: { ...item.rect, left: item.rect.left + dx, top: item.rect.top + dy },
})),
);
for (const member of groupG.members) applyManualOffsetDragDraft(member, dx, dy);
return;
}
if (!g || !sel) return;
const dx = e.clientX - g.startX;
const dy = e.clientY - g.startY;
if (g.kind === "rotate") {
applyStudioRotationDraft(
sel.element,
resolveDomEditRotationGesture({
centerX: g.centerX,
centerY: g.centerY,
startX: g.startX,
startY: g.startY,
currentX: e.clientX,
currentY: e.clientY,
actualAngle: g.actualRotation,
snap: e.shiftKey,
}),
);
return;
}
if (g.kind === "drag") {
const nextBoxLeft = g.originLeft + dx;
const nextBoxTop = g.originTop + dy;
setDraftOverlayRect({
left: nextBoxLeft,
top: nextBoxTop,
width: g.originWidth,
height: g.originHeight,
editScaleX: g.editScaleX,
editScaleY: g.editScaleY,
});
if (box) {
box.style.left = `${nextBoxLeft}px`;
box.style.top = `${nextBoxTop}px`;
}
if (g.pathOffsetMember) applyManualOffsetDragDraft(g.pathOffsetMember, dx, dy);
} else {
if (!box) return;
const nextSize = resolveDomEditResizeGesture({
originWidth: g.originWidth,
originHeight: g.originHeight,
actualWidth: g.actualWidth,
actualHeight: g.actualHeight,
scaleX: g.editScaleX,
scaleY: g.editScaleY,
dx,
dy,
uniform: e.shiftKey,
});
applyStudioBoxSizeDraft(sel.element, nextSize);
// Re-read BCR after applying dimensions. For elements with a GSAP
// scale transform and centered transform-origin the visual top-left
// drifts and the visual size diverges from the raw CSS size, so BCR
// is the only accurate source for both.
const overlayEl = opts.overlayRef.current;
const iframe = opts.iframeRef.current;
const refreshed = overlayEl && iframe ? toOverlayRect(overlayEl, iframe, sel.element) : null;
const overlayLeft = refreshed ? refreshed.left : g.originLeft;
const overlayTop = refreshed ? refreshed.top : g.originTop;
const overlayWidth = refreshed ? refreshed.width : nextSize.overlayWidth;
const overlayHeight = refreshed ? refreshed.height : nextSize.overlayHeight;
box.style.left = `${overlayLeft}px`;
box.style.top = `${overlayTop}px`;
box.style.width = `${overlayWidth}px`;
box.style.height = `${overlayHeight}px`;
setDraftOverlayRect({
left: overlayLeft,
top: overlayTop,
width: overlayWidth,
height: overlayHeight,
editScaleX: g.editScaleX,
editScaleY: g.editScaleY,
});
}
};
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
const g = opts.gestureRef.current;
const groupG = opts.groupGestureRef.current;
const sel = g?.selection ?? opts.selectionRef.current;
const box = opts.boxRef.current;
opts.blockedMoveRef.current = null;
if (groupG) {
opts.groupGestureRef.current = null;
opts.rafPausedRef.current = false;
const dx = e.clientX - groupG.startX;
const dy = e.clientY - groupG.startY;
if (Math.hypot(dx, dy) < BLOCKED_MOVE_THRESHOLD_PX) {
restoreGroupPathOffsets(groupG);
opts.suppressNextBoxClickRef.current = true;
return;
}
setDraftGroupOverlayItems(
groupG.originItems.map((item) => ({
...item,
rect: { ...item.rect, left: item.rect.left + dx, top: item.rect.top + dy },
})),
);
const updates = groupG.members.map((member) => ({
selection: member.selection,
next: applyManualOffsetDragCommit(member, dx, dy),
}));
void Promise.resolve(opts.onGroupPathOffsetCommitRef.current(updates))
.catch(() => {
for (const member of groupG.members) {
if (
member.gestureToken &&
isStudioManualEditGestureCurrent(member.element, member.gestureToken)
)
restoreStudioPathOffset(member.element, member.initialPathOffset);
}
})
.finally(() => endManualOffsetDragMembers(groupG.members));
return;
}
if (!g || !sel) {
opts.gestureRef.current = null;
opts.rafPausedRef.current = false;
return;
}
opts.gestureRef.current = null;
opts.rafPausedRef.current = false;
const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY);
if (g.kind === "drag" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
restoreStudioPathOffset(sel.element, g.initialPathOffset);
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
if (box) {
box.style.left = `${g.originLeft}px`;
box.style.top = `${g.originTop}px`;
}
restoreGestureOverlayRect(g);
opts.suppressNextBoxClickRef.current = true;
opts.onCanvasMouseDown(e as unknown as React.MouseEvent<HTMLDivElement>, {
preferClipAncestor: false,
});
return;
}
if (g.kind === "resize" && movedDistance < BLOCKED_MOVE_THRESHOLD_PX) {
restoreStudioBoxSize(sel.element, g.initialBoxSize);
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
if (box) {
box.style.width = `${g.originWidth}px`;
box.style.height = `${g.originHeight}px`;
}
restoreGestureOverlayRect(g);
opts.suppressNextBoxClickRef.current = true;
return;
}
if (g.kind === "rotate") {
const finalRotation = resolveDomEditRotationGesture({
centerX: g.centerX,
centerY: g.centerY,
startX: g.startX,
startY: g.startY,
currentX: e.clientX,
currentY: e.clientY,
actualAngle: g.actualRotation,
snap: e.shiftKey,
});
if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
restoreStudioRotation(sel.element, g.initialRotation);
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
return;
}
applyStudioRotation(sel.element, finalRotation);
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
.catch(() => {
if (
g.manualEditDragToken &&
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
)
restoreStudioRotation(sel.element, g.initialRotation);
})
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
} else if (g.kind === "drag") {
const dx = e.clientX - g.startX;
const dy = e.clientY - g.startY;
if (!g.pathOffsetMember) return;
const finalOffset = applyManualOffsetDragCommit(g.pathOffsetMember, dx, dy);
const nextBoxLeft = g.originLeft + dx;
const nextBoxTop = g.originTop + dy;
setDraftOverlayRect({
left: nextBoxLeft,
top: nextBoxTop,
width: g.originWidth,
height: g.originHeight,
editScaleX: g.editScaleX,
editScaleY: g.editScaleY,
});
if (box) {
box.style.left = `${nextBoxLeft}px`;
box.style.top = `${nextBoxTop}px`;
}
void Promise.resolve(opts.onPathOffsetCommitRef.current(sel, finalOffset))
.catch(() => {
if (
g.pathOffsetMember?.gestureToken &&
isStudioManualEditGestureCurrent(sel.element, g.pathOffsetMember.gestureToken)
)
restoreStudioPathOffset(sel.element, g.initialPathOffset);
})
.finally(() => {
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))
.catch(() => {
if (
g.manualEditDragToken &&
isStudioManualEditGestureCurrent(sel.element, g.manualEditDragToken)
)
restoreStudioBoxSize(sel.element, g.initialBoxSize);
})
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
}
};
const clearPointerState = (selectionRef: RefObject<DomEditSelection | null>) => {
const groupG = opts.groupGestureRef.current;
if (groupG) restoreGroupPathOffsets(groupG);
const g = opts.gestureRef.current;
const sel = g?.selection ?? selectionRef.current;
if (g?.mode === "path-offset" && sel) {
restoreStudioPathOffset(sel.element, g.initialPathOffset);
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
restoreGestureOverlayRect(g);
}
if (g?.mode === "box-size" && sel) {
restoreStudioBoxSize(sel.element, g.initialBoxSize);
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
restoreGestureOverlayRect(g);
}
if (g?.mode === "rotation" && sel) {
restoreStudioRotation(sel.element, g.initialRotation);
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
}
opts.blockedMoveRef.current = null;
opts.groupGestureRef.current = null;
opts.gestureRef.current = null;
opts.rafPausedRef.current = false;
};
return { startGesture, startGroupDrag, onPointerMove, onPointerUp, clearPointerState };
}