Files
hyperframes/packages/studio/src/hooks/useDomEditCommits.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

443 lines
15 KiB
TypeScript

import { useCallback } from "react";
import { usePlayerStore } from "../player";
import { FONT_EXT } from "../utils/mediaTypes";
import { applyPatchByTarget } from "../utils/sourcePatcher";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
import {
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,
type StudioMotionManifest,
upsertStudioGsapMotion,
} from "../components/editor/studioMotion";
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
import type { EditHistoryKind } from "../utils/editHistory";
import { useDomEditTextCommits } from "./useDomEditTextCommits";
// ── Types ──
interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}
export type PersistDomEditOperations = (
selection: DomEditSelection,
operations: Parameters<typeof applyPatchByTarget>[2][],
options?: {
label?: string;
coalesceKey?: string;
skipRefresh?: boolean;
prepareContent?: (html: string, sourceFile: string) => string;
shouldSave?: () => boolean;
},
) => Promise<void>;
export interface UseDomEditCommitsParams {
activeCompPath: string | null;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
showToast: (message: string, tone?: "error" | "info") => void;
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
commitStudioMotionManifestOptimistically: (
updateManifest: (manifest: StudioMotionManifest) => StudioMotionManifest,
options: { label: string; coalesceKey: string },
) => void;
applyCurrentStudioMotionToPreview: (iframe: HTMLIFrameElement | null) => void;
writeProjectFile: (path: string, content: string) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
fileTree: string[];
importedFontAssetsRef: React.MutableRefObject<ImportedFontAsset[]>;
projectId: string | null;
projectIdRef: React.MutableRefObject<string | null>;
reloadPreview: () => void;
// From useDomSelection
domEditSelection: DomEditSelection | null;
applyDomSelection: (
selection: DomEditSelection | null,
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
) => void;
clearDomSelection: () => void;
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
buildDomSelectionFromTarget: (
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
) => DomEditSelection | null;
}
// ── Hook ──
export function useDomEditCommits({
activeCompPath,
previewIframeRef,
showToast,
queueDomEditSave,
commitStudioMotionManifestOptimistically,
applyCurrentStudioMotionToPreview,
writeProjectFile,
domEditSaveTimestampRef,
editHistory,
fileTree,
importedFontAssetsRef,
projectId,
projectIdRef,
reloadPreview,
domEditSelection,
applyDomSelection,
clearDomSelection,
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
}: UseDomEditCommitsParams) {
const resolveImportedFontAsset = useCallback(
(fontFamilyValue: string): ImportedFontAsset | null => {
const family = primaryFontFamilyValue(fontFamilyValue);
if (!family) return null;
const imported = importedFontAssetsRef.current.find(
(font) => font.family.toLowerCase() === family.toLowerCase(),
);
if (imported) return imported;
const asset = fileTree.find(
(path) =>
FONT_EXT.test(path) &&
fontFamilyFromAssetPath(path).toLowerCase() === family.toLowerCase(),
);
if (!asset) return null;
return {
family: fontFamilyFromAssetPath(asset),
path: asset,
url: `/api/projects/${projectId}/preview/${asset}`,
};
},
[fileTree, projectId, importedFontAssetsRef],
);
const persistDomEditOperations: PersistDomEditOperations = useCallback(
async (selection, operations, options) => {
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
if (options?.shouldSave && !options.shouldSave()) return;
const targetPath = selection.sourceFile || activeCompPath || "index.html";
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
if (!response.ok) {
throw new Error(`Failed to read ${targetPath}`);
}
const data = (await response.json()) as { content?: string };
const originalContent = data.content;
if (typeof originalContent !== "string") {
throw new Error(`Missing file contents for ${targetPath}`);
}
let patchedContent = originalContent;
for (const operation of operations) {
patchedContent = applyPatchByTarget(patchedContent, selection, operation);
}
if (options?.prepareContent) {
patchedContent = options.prepareContent(patchedContent, targetPath);
}
if (options?.shouldSave && !options.shouldSave()) return;
if (patchedContent === originalContent) {
throw new Error(`Unable to patch ${selection.selector ?? selection.id ?? "selection"}`);
}
await saveProjectFilesWithHistory({
projectId: pid,
label: options?.label ?? "Edit layer",
kind: "manual",
coalesceKey: options?.coalesceKey,
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit: editHistory.recordEdit,
});
if (options?.skipRefresh) {
domEditSaveTimestampRef.current = Date.now();
} else {
reloadPreview();
}
},
[
activeCompPath,
editHistory.recordEdit,
writeProjectFile,
projectIdRef,
domEditSaveTimestampRef,
reloadPreview,
],
);
// ── Text & style commits (delegated to useDomEditTextCommits) ──
const {
handleDomStyleCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
} = useDomEditTextCommits({
activeCompPath,
previewIframeRef,
domEditSelection,
applyDomSelection,
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
persistDomEditOperations,
resolveImportedFontAsset,
});
// ── Position patch helper ──
const commitPositionPatchToHtml = useCallback(
(
selection: DomEditSelection,
patches: Parameters<typeof applyPatchByTarget>[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 }) => {
applyStudioPathOffset(selection.element, next);
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
label: "Move layer",
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
});
},
[commitPositionPatchToHtml],
);
const handleDomGroupPathOffsetCommit = useCallback(
(updates: DomEditGroupPathOffsetCommit[]) => {
if (updates.length === 0) return;
const coalesceKey = updates
.map((u) => getDomEditTargetKey(u.selection))
.sort()
.join(":");
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}`,
});
}
},
[commitPositionPatchToHtml],
);
const handleDomBoxSizeCommit = useCallback(
(selection: DomEditSelection, next: { width: number; height: number }) => {
applyStudioBoxSize(selection.element, next);
commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
label: "Resize layer box",
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
});
},
[commitPositionPatchToHtml],
);
const handleDomRotationCommit = useCallback(
(selection: DomEditSelection, next: { angle: number }) => {
applyStudioRotation(selection.element, next);
commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
label: "Rotate layer",
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
});
},
[commitPositionPatchToHtml],
);
const handleDomManualEditsReset = useCallback(
(selection: DomEditSelection) => {
const element = selection.element;
const clearPatches = [
...buildClearPathOffsetPatches(element),
...buildClearBoxSizePatches(element),
...buildClearRotationPatches(element),
];
clearStudioPathOffset(element);
clearStudioBoxSize(element);
clearStudioRotation(element);
// skipRefresh:false triggers reloadPreview() which re-syncs selection on load
commitPositionPatchToHtml(selection, clearPatches, {
label: "Reset layer edits",
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
skipRefresh: false,
});
},
[commitPositionPatchToHtml],
);
// ── Motion commits ──
const handleDomMotionCommit = useCallback(
(
selection: DomEditSelection,
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
) => {
commitStudioMotionManifestOptimistically(
(manifest) => upsertStudioGsapMotion(manifest, selection, motion),
{
label: "Set GSAP motion",
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
},
);
refreshDomEditSelectionFromPreview(selection);
},
[commitStudioMotionManifestOptimistically, refreshDomEditSelectionFromPreview],
);
const handleDomMotionClear = useCallback(
(selection: DomEditSelection) => {
commitStudioMotionManifestOptimistically(
(manifest) => removeStudioMotionForSelection(manifest, selection),
{
label: "Clear GSAP motion",
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
},
);
applyCurrentStudioMotionToPreview(previewIframeRef.current);
refreshDomEditSelectionFromPreview(selection);
},
[
applyCurrentStudioMotionToPreview,
commitStudioMotionManifestOptimistically,
refreshDomEditSelectionFromPreview,
previewIframeRef,
],
);
const handleDomEditElementDelete = useCallback(
async (selection: DomEditSelection) => {
const pid = projectIdRef.current;
if (!pid) return;
const label = selection.label || selection.id || selection.selector || selection.tagName;
const targetPath = selection.sourceFile || activeCompPath || "index.html";
try {
const response = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
);
if (!response.ok) throw new Error(`Failed to read ${targetPath}`);
const data = (await response.json()) as { content?: string };
const originalContent = data.content;
if (typeof originalContent !== "string")
throw new Error(`Missing file contents for ${targetPath}`);
const patchTarget: { id?: string; selector?: string; selectorIndex?: number } = selection.id
? {
id: selection.id,
selector: selection.selector,
selectorIndex: selection.selectorIndex,
}
: selection.selector
? { selector: selection.selector, selectorIndex: selection.selectorIndex }
: ({} as never);
if (!patchTarget.id && !patchTarget.selector) {
throw new Error("Selected element has no patchable target");
}
const removeResponse = await fetch(
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: patchTarget }),
},
);
if (!removeResponse.ok) throw new Error(`Failed to delete element from ${targetPath}`);
const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
const patchedContent =
typeof removeData.content === "string" ? removeData.content : originalContent;
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: "Delete element",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit: editHistory.recordEdit,
});
clearDomSelection();
usePlayerStore.getState().setSelectedElementId(null);
reloadPreview();
showToast(`Deleted ${label}. Use Undo to restore it.`, "info");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to delete element";
showToast(message);
}
},
[
activeCompPath,
clearDomSelection,
domEditSaveTimestampRef,
editHistory.recordEdit,
projectIdRef,
reloadPreview,
showToast,
writeProjectFile,
],
);
return {
resolveImportedFontAsset,
handleDomStyleCommit,
handleDomTextCommit,
commitDomTextFields,
handleDomTextFieldStyleCommit,
handleDomAddTextField,
handleDomRemoveTextField,
handleDomPathOffsetCommit,
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
handleDomManualEditsReset,
handleDomMotionCommit,
handleDomMotionClear,
handleDomEditElementDelete,
};
}