mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
* 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.
407 lines
11 KiB
TypeScript
407 lines
11 KiB
TypeScript
// @vitest-environment happy-dom
|
|
|
|
import React, { act } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { Window } from "happy-dom";
|
|
import {
|
|
DomEditOverlay,
|
|
filterNestedDomEditGroupItems,
|
|
focusDomEditOverlayElement,
|
|
hasDomEditRotationChanged,
|
|
resolveDomEditCoordinateScale,
|
|
resolveDomEditGroupOverlayRect,
|
|
resolveDomEditResizeGesture,
|
|
resolveDomEditRotationGesture,
|
|
} from "./DomEditOverlay";
|
|
import type { DomEditSelection } from "./domEditing";
|
|
|
|
// React 19 warns unless the test environment opts into act().
|
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
vi.mock("./useDomEditOverlayGestures", () => ({
|
|
createDomEditOverlayGestureHandlers: () => ({
|
|
startGesture: () => true,
|
|
startGroupDrag: () => {},
|
|
onPointerMove: () => {},
|
|
onPointerUp: () => {},
|
|
clearPointerState: () => {},
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./useDomEditOverlayRects", async () => {
|
|
const React = await import("react");
|
|
const { rectsEqual } = await import("./domEditOverlayGeometry");
|
|
|
|
return {
|
|
useDomEditOverlayRects: () => {
|
|
const [overlayRect, setOverlayRectState] = React.useState(null);
|
|
const overlayRectRef = React.useRef(null);
|
|
const [groupOverlayItems, setGroupOverlayItemsState] = React.useState([]);
|
|
const groupOverlayItemsRef = React.useRef([]);
|
|
|
|
const setOverlayRect = (next: unknown) => {
|
|
if (rectsEqual(overlayRectRef.current, next)) return;
|
|
overlayRectRef.current = next;
|
|
setOverlayRectState(next);
|
|
};
|
|
|
|
const setGroupOverlayItems = (next: unknown[]) => {
|
|
groupOverlayItemsRef.current = next;
|
|
setGroupOverlayItemsState(next);
|
|
};
|
|
|
|
return {
|
|
overlayRect,
|
|
overlayRectRef,
|
|
setOverlayRect,
|
|
hoverRect: null,
|
|
hoverRectRef: { current: null },
|
|
setHoverRect: () => {},
|
|
groupOverlayItems,
|
|
groupOverlayItemsRef,
|
|
setGroupOverlayItems,
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("./domEditOverlayGeometry", async () => {
|
|
const actual = await vi.importActual<typeof import("./domEditOverlayGeometry")>(
|
|
"./domEditOverlayGeometry",
|
|
);
|
|
|
|
return {
|
|
...actual,
|
|
toOverlayRect: () => ({
|
|
left: 24,
|
|
top: 36,
|
|
width: 180,
|
|
height: 72,
|
|
editScaleX: 1,
|
|
editScaleY: 1,
|
|
}),
|
|
};
|
|
});
|
|
|
|
describe("focusDomEditOverlayElement", () => {
|
|
it("focuses the canvas overlay without scrolling", () => {
|
|
const calls: Array<FocusOptions | undefined> = [];
|
|
focusDomEditOverlayElement({
|
|
focus: (options?: FocusOptions) => calls.push(options),
|
|
});
|
|
|
|
expect(calls).toEqual([{ preventScroll: true }]);
|
|
});
|
|
});
|
|
|
|
describe("DomEditOverlay", () => {
|
|
it("renders selected bounds right after clicking a movable selection", () => {
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
const selection: DomEditSelection = {
|
|
element: document.createElement("div"),
|
|
id: "hero-title",
|
|
selector: ".hero-title",
|
|
selectorIndex: 0,
|
|
sourceFile: "index.html",
|
|
tagName: "div",
|
|
label: "Hero Title",
|
|
textContent: "Hello",
|
|
textFields: [],
|
|
capabilities: {
|
|
canEditText: true,
|
|
canEditLayout: true,
|
|
canMove: true,
|
|
canApplyManualOffset: true,
|
|
canApplyManualSize: false,
|
|
canApplyManualRotation: false,
|
|
canAdjustOpacity: true,
|
|
canAdjustFill: true,
|
|
canAdjustBorderRadius: true,
|
|
canAdjustStroke: true,
|
|
canAdjustShadow: true,
|
|
canAdjustZIndex: true,
|
|
},
|
|
computedStyle: {
|
|
display: "block",
|
|
position: "absolute",
|
|
},
|
|
};
|
|
|
|
let currentSelection: DomEditSelection | null = null;
|
|
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
|
|
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
|
|
HTMLDivElement.prototype.setPointerCapture = () => {};
|
|
|
|
function Harness() {
|
|
const [selected, setSelected] = React.useState<DomEditSelection | null>(null);
|
|
currentSelection = selected;
|
|
|
|
return React.createElement(DomEditOverlay, {
|
|
iframeRef,
|
|
activeCompositionPath: null,
|
|
selection: selected,
|
|
hoverSelection: null,
|
|
groupSelections: [],
|
|
onCanvasMouseDown: () => {},
|
|
onCanvasPointerMove: () => selection,
|
|
onCanvasPointerLeave: () => {},
|
|
onSelectionChange: (next: DomEditSelection) => setSelected(next),
|
|
onBlockedMove: () => {},
|
|
onPathOffsetCommit: () => {},
|
|
onGroupPathOffsetCommit: () => {},
|
|
onBoxSizeCommit: () => {},
|
|
onRotationCommit: () => {},
|
|
});
|
|
}
|
|
|
|
act(() => {
|
|
root.render(React.createElement(Harness));
|
|
});
|
|
|
|
const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement;
|
|
expect(overlay).toBeTruthy();
|
|
|
|
act(() => {
|
|
overlay.dispatchEvent(
|
|
new PointerEvent("pointerdown", {
|
|
bubbles: true,
|
|
button: 0,
|
|
clientX: 120,
|
|
clientY: 80,
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(currentSelection).toBe(selection);
|
|
expect(host.querySelector('[data-dom-edit-selection-box="true"]')).toBeTruthy();
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
HTMLDivElement.prototype.setPointerCapture = originalPointerCapture;
|
|
host.remove();
|
|
});
|
|
});
|
|
|
|
describe("resolveDomEditCoordinateScale", () => {
|
|
it("uses the top-level preview scale when no source boundary dimensions are available", () => {
|
|
expect(
|
|
resolveDomEditCoordinateScale({
|
|
rootScaleX: 0.5,
|
|
rootScaleY: 0.5,
|
|
}),
|
|
).toEqual({
|
|
scaleX: 0.5,
|
|
scaleY: 0.5,
|
|
});
|
|
});
|
|
|
|
it("converts source-local pixels through a scaled nested composition host", () => {
|
|
expect(
|
|
resolveDomEditCoordinateScale({
|
|
rootScaleX: 0.5,
|
|
rootScaleY: 0.5,
|
|
sourceRectWidth: 960,
|
|
sourceRectHeight: 540,
|
|
sourceWidth: 1920,
|
|
sourceHeight: 1080,
|
|
}),
|
|
).toEqual({
|
|
scaleX: 0.25,
|
|
scaleY: 0.25,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveDomEditGroupOverlayRect", () => {
|
|
it("returns a bounding box that contains every selected element", () => {
|
|
expect(
|
|
resolveDomEditGroupOverlayRect([
|
|
{ left: 40, top: 30, width: 80, height: 50, editScaleX: 1, editScaleY: 1 },
|
|
{ left: 150, top: 10, width: 30, height: 120, editScaleX: 0.5, editScaleY: 0.5 },
|
|
{ left: 20, top: 90, width: 50, height: 20, editScaleX: 2, editScaleY: 2 },
|
|
]),
|
|
).toEqual({
|
|
left: 20,
|
|
top: 10,
|
|
width: 160,
|
|
height: 120,
|
|
editScaleX: 1,
|
|
editScaleY: 1,
|
|
});
|
|
});
|
|
|
|
it("returns null for an empty group", () => {
|
|
expect(resolveDomEditGroupOverlayRect([])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("filterNestedDomEditGroupItems", () => {
|
|
it("keeps top-level selected elements so descendants are not moved twice", () => {
|
|
const window = new Window();
|
|
const parent = window.document.createElement("div");
|
|
const child = window.document.createElement("div");
|
|
const sibling = window.document.createElement("div");
|
|
parent.append(child);
|
|
|
|
expect(
|
|
filterNestedDomEditGroupItems([
|
|
{ key: "parent", element: parent },
|
|
{ key: "child", element: child },
|
|
{ key: "sibling", element: sibling },
|
|
]).map((item) => item.key),
|
|
).toEqual(["parent", "sibling"]);
|
|
});
|
|
});
|
|
|
|
describe("resolveDomEditResizeGesture", () => {
|
|
it("resizes width and height independently by default", () => {
|
|
expect(
|
|
resolveDomEditResizeGesture({
|
|
originWidth: 240,
|
|
originHeight: 120,
|
|
actualWidth: 240,
|
|
actualHeight: 120,
|
|
scaleX: 1,
|
|
scaleY: 1,
|
|
dx: 30,
|
|
dy: 12,
|
|
uniform: false,
|
|
}),
|
|
).toEqual({
|
|
overlayWidth: 270,
|
|
overlayHeight: 132,
|
|
width: 270,
|
|
height: 132,
|
|
});
|
|
});
|
|
|
|
it("snaps width and height to the same value when Shift is held", () => {
|
|
expect(
|
|
resolveDomEditResizeGesture({
|
|
originWidth: 240,
|
|
originHeight: 120,
|
|
actualWidth: 240,
|
|
actualHeight: 120,
|
|
scaleX: 1,
|
|
scaleY: 1,
|
|
dx: 30,
|
|
dy: 12,
|
|
uniform: true,
|
|
}),
|
|
).toEqual({
|
|
overlayWidth: 270,
|
|
overlayHeight: 270,
|
|
width: 270,
|
|
height: 270,
|
|
});
|
|
});
|
|
|
|
it("uses the dominant pointer delta for uniform shrink", () => {
|
|
expect(
|
|
resolveDomEditResizeGesture({
|
|
originWidth: 300,
|
|
originHeight: 180,
|
|
actualWidth: 300,
|
|
actualHeight: 180,
|
|
scaleX: 1,
|
|
scaleY: 1,
|
|
dx: 8,
|
|
dy: -40,
|
|
uniform: true,
|
|
}),
|
|
).toMatchObject({
|
|
width: 260,
|
|
height: 260,
|
|
});
|
|
});
|
|
|
|
it("writes source-local dimensions when the edited source is scaled down in master view", () => {
|
|
expect(
|
|
resolveDomEditResizeGesture({
|
|
originWidth: 100,
|
|
originHeight: 50,
|
|
actualWidth: 400,
|
|
actualHeight: 200,
|
|
scaleX: 0.25,
|
|
scaleY: 0.25,
|
|
dx: 25,
|
|
dy: 10,
|
|
uniform: false,
|
|
}),
|
|
).toEqual({
|
|
overlayWidth: 125,
|
|
overlayHeight: 60,
|
|
width: 500,
|
|
height: 240,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("resolveDomEditRotationGesture", () => {
|
|
it("rotates by the pointer angle around the element center", () => {
|
|
expect(
|
|
resolveDomEditRotationGesture({
|
|
centerX: 0,
|
|
centerY: 0,
|
|
startX: 0,
|
|
startY: -10,
|
|
currentX: 10,
|
|
currentY: 0,
|
|
actualAngle: 5,
|
|
snap: false,
|
|
}),
|
|
).toEqual({ angle: 95 });
|
|
});
|
|
|
|
it("uses the shortest delta across the 180 degree boundary", () => {
|
|
expect(
|
|
resolveDomEditRotationGesture({
|
|
centerX: 0,
|
|
centerY: 0,
|
|
startX: -10,
|
|
startY: 1.76,
|
|
currentX: -10,
|
|
currentY: -1.76,
|
|
actualAngle: 0,
|
|
snap: false,
|
|
}).angle,
|
|
).toBeCloseTo(20, 1);
|
|
});
|
|
|
|
it("snaps to 15 degree increments when requested", () => {
|
|
expect(
|
|
resolveDomEditRotationGesture({
|
|
centerX: 0,
|
|
centerY: 0,
|
|
startX: 10,
|
|
startY: 0,
|
|
currentX: 10,
|
|
currentY: 3.25,
|
|
actualAngle: 0,
|
|
snap: true,
|
|
}),
|
|
).toEqual({ angle: 15 });
|
|
});
|
|
|
|
it("allows small pointer movements when the rounded angle changes", () => {
|
|
const nextRotation = resolveDomEditRotationGesture({
|
|
centerX: 0,
|
|
centerY: 0,
|
|
startX: 0,
|
|
startY: -40,
|
|
currentX: 1,
|
|
currentY: -40,
|
|
actualAngle: 0,
|
|
snap: false,
|
|
});
|
|
|
|
expect(nextRotation.angle).toBe(1.4);
|
|
expect(hasDomEditRotationChanged(0, nextRotation.angle)).toBe(true);
|
|
expect(hasDomEditRotationChanged(0, 0)).toBe(false);
|
|
});
|
|
});
|