mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +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:
@@ -525,30 +525,66 @@ export async function queryElementStacking(
|
||||
if (!htmlEl) continue;
|
||||
mat = mat.translate(htmlEl.offsetLeft, htmlEl.offsetTop);
|
||||
const cs = window.getComputedStyle(htmlEl);
|
||||
if (cs.transform && cs.transform !== "none") {
|
||||
const origin = cs.transformOrigin.split(" ");
|
||||
const ox = resolveLength(origin[0] ?? "0", htmlEl.offsetWidth);
|
||||
const oy = resolveLength(origin[1] ?? "0", htmlEl.offsetHeight);
|
||||
try {
|
||||
const t = new DOMMatrix(cs.transform);
|
||||
if (
|
||||
Number.isFinite(t.a) &&
|
||||
Number.isFinite(t.b) &&
|
||||
Number.isFinite(t.c) &&
|
||||
Number.isFinite(t.d) &&
|
||||
Number.isFinite(t.e) &&
|
||||
Number.isFinite(t.f)
|
||||
) {
|
||||
mat = mat.translate(ox, oy).multiply(t).translate(-ox, -oy);
|
||||
const origin = cs.transformOrigin.split(" ");
|
||||
const ox = resolveLength(origin[0] ?? "0", htmlEl.offsetWidth);
|
||||
const oy = resolveLength(origin[1] ?? "0", htmlEl.offsetHeight);
|
||||
const individualTransform = composeIndividualTransforms(cs);
|
||||
const hasIndividual = individualTransform !== null;
|
||||
const hasTransform = cs.transform && cs.transform !== "none";
|
||||
if (hasIndividual || hasTransform) {
|
||||
mat = mat.translate(ox, oy);
|
||||
if (hasIndividual) mat = mat.multiply(individualTransform);
|
||||
if (hasTransform) {
|
||||
try {
|
||||
const t = new DOMMatrix(cs.transform);
|
||||
if (
|
||||
Number.isFinite(t.a) &&
|
||||
Number.isFinite(t.b) &&
|
||||
Number.isFinite(t.c) &&
|
||||
Number.isFinite(t.d) &&
|
||||
Number.isFinite(t.e) &&
|
||||
Number.isFinite(t.f)
|
||||
) {
|
||||
mat = mat.multiply(t);
|
||||
}
|
||||
} catch {
|
||||
// DOMMatrix constructor throws on malformed input — skip.
|
||||
}
|
||||
} catch {
|
||||
// DOMMatrix constructor throws on malformed input — skip ancestor.
|
||||
}
|
||||
mat = mat.translate(-ox, -oy);
|
||||
}
|
||||
}
|
||||
return mat.toString();
|
||||
}
|
||||
|
||||
function composeIndividualTransforms(cs: CSSStyleDeclaration): DOMMatrix | null {
|
||||
const translate = cs.getPropertyValue("translate").trim();
|
||||
const rotate = cs.getPropertyValue("rotate").trim();
|
||||
const scale = cs.getPropertyValue("scale").trim();
|
||||
const hasTranslate = translate && translate !== "none";
|
||||
const hasRotate = rotate && rotate !== "none";
|
||||
const hasScale = scale && scale !== "none";
|
||||
if (!hasTranslate && !hasRotate && !hasScale) return null;
|
||||
let m = new DOMMatrix();
|
||||
if (hasTranslate) {
|
||||
const parts = translate.split(/\s+/);
|
||||
const tx = parseFloat(parts[0] ?? "0") || 0;
|
||||
const ty = parseFloat(parts[1] ?? "0") || 0;
|
||||
if (tx !== 0 || ty !== 0) m = m.translate(tx, ty);
|
||||
}
|
||||
if (hasRotate) {
|
||||
const deg = parseFloat(rotate) || 0;
|
||||
if (deg !== 0) m = m.rotate(deg);
|
||||
}
|
||||
if (hasScale) {
|
||||
const parts = scale.split(/\s+/);
|
||||
const sx = parseFloat(parts[0] ?? "1") || 1;
|
||||
const sy = parseFloat(parts[1] ?? String(sx)) || sx;
|
||||
if (sx !== 1 || sy !== 1) m = m.scale(sx, sy);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function resolveLength(value: string, basis: number): number {
|
||||
if (value.endsWith("%")) {
|
||||
const pct = parseFloat(value) / 100;
|
||||
|
||||
Reference in New Issue
Block a user