mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
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.
This commit is contained in:
@@ -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<HTMLIFrameElement | null>;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
commitStudioManualEditManifestOptimistically: (
|
||||
updateManifest: (manifest: StudioManualEditManifest) => StudioManualEditManifest,
|
||||
options: { label: string; coalesceKey: string },
|
||||
) => void;
|
||||
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
|
||||
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<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
@@ -69,15 +74,12 @@ export interface UseDomEditCommitsParams {
|
||||
|
||||
// From useDomSelection
|
||||
domEditSelection: DomEditSelection | null;
|
||||
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
||||
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean },
|
||||
) => void;
|
||||
clearDomSelection: () => void;
|
||||
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
|
||||
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => void;
|
||||
buildDomSelectionFromTarget: (
|
||||
target: HTMLElement,
|
||||
options?: { preferClipAncestor?: boolean },
|
||||
@@ -90,9 +92,8 @@ export function useDomEditCommits({
|
||||
activeCompPath,
|
||||
previewIframeRef,
|
||||
showToast,
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
queueDomEditSave,
|
||||
commitStudioMotionManifestOptimistically,
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
applyCurrentStudioMotionToPreview,
|
||||
writeProjectFile,
|
||||
domEditSaveTimestampRef,
|
||||
@@ -103,11 +104,9 @@ export function useDomEditCommits({
|
||||
projectIdRef,
|
||||
reloadPreview,
|
||||
domEditSelection,
|
||||
domEditGroupSelectionsRef,
|
||||
applyDomSelection,
|
||||
clearDomSelection,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
buildDomSelectionFromTarget,
|
||||
}: UseDomEditCommitsParams) {
|
||||
const resolveImportedFontAsset = useCallback(
|
||||
@@ -211,98 +210,104 @@ export function useDomEditCommits({
|
||||
resolveImportedFontAsset,
|
||||
});
|
||||
|
||||
// ── Manifest commits ──
|
||||
// ── Position patch helper ──
|
||||
|
||||
const commitPositionPatchToHtml = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
patches: Parameters<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 }) => {
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) => upsertStudioPathOffsetEdit(manifest, selection, next),
|
||||
{
|
||||
label: "Move layer",
|
||||
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
|
||||
},
|
||||
);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
applyStudioPathOffset(selection.element, next);
|
||||
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
||||
label: "Move layer",
|
||||
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
|
||||
});
|
||||
},
|
||||
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
||||
[commitPositionPatchToHtml],
|
||||
);
|
||||
|
||||
const handleDomGroupPathOffsetCommit = useCallback(
|
||||
(updates: DomEditGroupPathOffsetCommit[]) => {
|
||||
if (updates.length === 0) return;
|
||||
const coalesceKey = updates
|
||||
.map((update) => getDomEditTargetKey(update.selection))
|
||||
.map((u) => getDomEditTargetKey(u.selection))
|
||||
.sort()
|
||||
.join(":");
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) =>
|
||||
updates.reduce(
|
||||
(nextManifest, update) =>
|
||||
upsertStudioPathOffsetEdit(nextManifest, update.selection, update.next),
|
||||
manifest,
|
||||
),
|
||||
{
|
||||
for (const { selection, next } of updates) {
|
||||
applyStudioPathOffset(selection.element, next);
|
||||
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
||||
label: `Move ${updates.length} layers`,
|
||||
coalesceKey: `group-path-offset:${coalesceKey}`,
|
||||
},
|
||||
);
|
||||
refreshDomEditGroupSelectionsFromPreview(domEditGroupSelectionsRef.current);
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
domEditGroupSelectionsRef,
|
||||
refreshDomEditGroupSelectionsFromPreview,
|
||||
],
|
||||
[commitPositionPatchToHtml],
|
||||
);
|
||||
|
||||
const handleDomBoxSizeCommit = useCallback(
|
||||
(selection: DomEditSelection, next: { width: number; height: number }) => {
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) => upsertStudioBoxSizeEdit(manifest, selection, next),
|
||||
{
|
||||
label: "Resize layer box",
|
||||
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
|
||||
},
|
||||
);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
applyStudioBoxSize(selection.element, next);
|
||||
commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
|
||||
label: "Resize layer box",
|
||||
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
|
||||
});
|
||||
},
|
||||
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
||||
[commitPositionPatchToHtml],
|
||||
);
|
||||
|
||||
const handleDomRotationCommit = useCallback(
|
||||
(selection: DomEditSelection, next: { angle: number }) => {
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) => upsertStudioRotationEdit(manifest, selection, next),
|
||||
{
|
||||
label: "Rotate layer",
|
||||
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
|
||||
},
|
||||
);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
applyStudioRotation(selection.element, next);
|
||||
commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
|
||||
label: "Rotate layer",
|
||||
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
|
||||
});
|
||||
},
|
||||
[commitStudioManualEditManifestOptimistically, refreshDomEditSelectionFromPreview],
|
||||
[commitPositionPatchToHtml],
|
||||
);
|
||||
|
||||
const handleDomManualEditsReset = useCallback(
|
||||
(selection: DomEditSelection) => {
|
||||
commitStudioManualEditManifestOptimistically(
|
||||
(manifest) => removeStudioManualEditsForSelection(manifest, selection),
|
||||
{
|
||||
label: "Reset layer edits",
|
||||
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
|
||||
},
|
||||
);
|
||||
applyCurrentStudioManualEditsToPreview(previewIframeRef.current);
|
||||
refreshDomEditSelectionFromPreview(selection);
|
||||
const element = selection.element;
|
||||
const clearPatches = [
|
||||
...buildClearPathOffsetPatches(element),
|
||||
...buildClearBoxSizePatches(element),
|
||||
...buildClearRotationPatches(element),
|
||||
];
|
||||
clearStudioPathOffset(element);
|
||||
clearStudioBoxSize(element);
|
||||
clearStudioRotation(element);
|
||||
// skipRefresh:false triggers reloadPreview() which re-syncs selection on load
|
||||
commitPositionPatchToHtml(selection, clearPatches, {
|
||||
label: "Reset layer edits",
|
||||
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
|
||||
skipRefresh: false,
|
||||
});
|
||||
},
|
||||
[
|
||||
applyCurrentStudioManualEditsToPreview,
|
||||
commitStudioManualEditManifestOptimistically,
|
||||
refreshDomEditSelectionFromPreview,
|
||||
previewIframeRef,
|
||||
],
|
||||
[commitPositionPatchToHtml],
|
||||
);
|
||||
|
||||
// ── Motion commits ──
|
||||
|
||||
const handleDomMotionCommit = useCallback(
|
||||
(
|
||||
selection: DomEditSelection,
|
||||
|
||||
Reference in New Issue
Block a user