mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-09 03:16:38 +00:00
* feat(core): add probeElementInSource for source-existence checks
* feat(core): add probe-element endpoint for source-existence checks
* feat(studio): gate editing capabilities on source existence
* fix(studio): enrich save_failure telemetry with target details
* feat(studio): async selection resolution with source probe
Make `resolveDomEditSelection` async and wire a `probeSourceElement` call
into the selection path so elements generated by scripts (not present in the
source HTML) are detected early and have all edit capabilities disabled with
a clear reason message ("This element is generated by a script and cannot be
edited visually.").
Part A – core probe logic:
- `domEditingLayers.ts`: `resolveDomEditSelection` is now async; calls
`probeSourceElement` (POST /api/projects/:id/file-mutations/probe-element/:file)
when `projectId` is supplied and the element has a stable id/selector.
`existsInSource: false` flows into `resolveDomEditCapabilities`, which
disables all write capabilities with the appropriate reason.
- `domEditingLayers.ts`: `refreshDomEditSelection` promoted to async.
- `files.ts`: new `probe-element` route; extracted `resolveProjectPath`,
`resolveFileMutationContext`, `writeIfChanged`, and `parseMutationBody`
helpers to eliminate repeated boilerplate across remove/patch/probe handlers.
Part B – caller propagation (all eight consumer sites):
- `useDomSelection.ts`: `buildDomSelectionFromTarget`,
`resolveDomSelectionFromPreviewPoint`,
`buildDomSelectionForTimelineElement`, `handleTimelineElementSelect`,
`refreshDomEditSelectionFromPreview`, and
`refreshDomEditGroupSelectionsFromPreview` all made async; `projectId`
forwarded into `resolveDomEditSelection`.
- `useDomEditCommits.ts`, `useDomEditTextCommits.ts`: updated
`buildDomSelectionFromTarget` parameter type; added `await` at call sites.
- `useDomEditSession.ts`: inner `syncSelectionFromDocument` made async; fire
with `void` to satisfy the surrounding effect.
- `usePreviewInteraction.ts`: `handlePreviewCanvasMouseDown` and
`handlePreviewCanvasPointerMove` made async (React ignores handler return
values, so this is safe).
- `useStudioUrlState.ts`: deferred `buildDomSelectionFromTarget` call
converted to `.then()` chain with `void` prefix so the effect stays sync.
- `LayersPanel.tsx`: `seekToLayer`, `handleSelectLayer`, and
`handleLayerHover` made async.
- `DomEditOverlay.tsx` / `useDomEditOverlayGestures.ts`: `onCanvasPointerMove`
return type widened to `Promise<DomEditSelection | null>`; pointer-down
handler falls back to `hoverSelectionRef.current` (always populated by a
prior hover) instead of awaiting the async move callback inline.
Part C – test and tooling fixes:
- `lefthook.yml`: filesize hook shell loop explicitly skips `*.test.ts/tsx`
files as a guard against a lefthook v2.1.6 bug where `exclude` patterns are
not applied to `{staged_files}` in shell scripts.
- `domEditing.test.ts`: all `it()` blocks calling `resolveDomEditSelection`
made async with `await`.
- `DomEditOverlay.test.ts`: mock updated to return `Promise.resolve(selection)`
and `hoverSelection` pre-seeded so pointer-down test works with the new
hover-first path.
- `studioUrlState.test.ts`: `buildDomSelectionFromTarget` mocks wrapped in
`Promise.resolve()`; seek/selection hydration test made async with
`await act(async () => { await Promise.resolve(); })` to flush microtasks.
* feat(cli): add global error handlers for crash telemetry
Register process-level uncaughtException and unhandledRejection handlers
that fire trackCliError so unhandled crashes are captured in telemetry.
Add the trackCliError function to events.ts and re-export it from the
telemetry barrel.
* feat(cli): track per-command success/failure and duration
* test(core): add integration test for JS-created element probe scenario
* fix: address PR review feedback
- uncaughtException handler now calls process.exit(1) after flushing
- cli_command_result uses real exit code from process "exit" event
- drop stack_trace from cli_error (contains filesystem paths)
- skip source probe during hover — only probe on click/selection
- format .fallowrc.jsonc
* fix(cli): restore stack_trace in cli_error telemetry
* fix(cli): use captured module refs in exit handlers instead of dead import()
408 lines
11 KiB
TypeScript
408 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,
|
|
// Simulate the element being hovered before pointer-down (real users always hover first)
|
|
hoverSelection: selection,
|
|
groupSelections: [],
|
|
onCanvasMouseDown: () => {},
|
|
onCanvasPointerMove: () => Promise.resolve(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);
|
|
});
|
|
});
|