@hyperframes/studio 0.7.54 → 0.7.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/assets/{index-pRhCpGPz.js → index-BRwkMj0w.js} +108 -108
  2. package/dist/assets/{index-uBY329wb.js → index-BXaqaVKt.js} +1 -1
  3. package/dist/assets/{index-CMHYjEZ5.js → index-CPetwHFV.js} +1 -1
  4. package/dist/index.html +1 -1
  5. package/dist/index.js +48 -3
  6. package/dist/index.js.map +1 -1
  7. package/package.json +7 -7
  8. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  9. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  10. package/src/components/editor/anchoredResizeReleaseShift.test.ts +112 -0
  11. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  12. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  13. package/src/hooks/useRazorSplit.history.test.tsx +173 -0
  14. package/src/player/components/timelineCollision.test.ts +437 -0
  15. package/src/player/components/timelineCollision.ts +263 -0
  16. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  17. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  18. package/src/player/components/timelineSnapping.test.ts +134 -0
  19. package/src/player/components/timelineSnapping.ts +110 -0
  20. package/src/player/components/timelineStackingSync.test.ts +244 -0
  21. package/src/player/components/timelineStackingSync.ts +331 -0
  22. package/src/player/components/timelineZones.test.ts +425 -0
  23. package/src/player/components/timelineZones.ts +198 -0
  24. package/src/player/components/timelineZoom.test.ts +67 -0
  25. package/src/player/components/timelineZoom.ts +51 -0
  26. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  27. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  28. package/src/utils/assetClickBehavior.test.ts +104 -0
  29. package/src/utils/assetClickBehavior.ts +75 -0
  30. package/src/utils/canvasNudgeGate.test.ts +31 -0
  31. package/src/utils/canvasNudgeGate.ts +32 -0
  32. package/src/utils/studioUiPreferences.test.ts +38 -0
  33. package/src/utils/studioUiPreferences.ts +27 -0
  34. package/src/utils/timelineInspector.test.ts +121 -0
  35. package/src/utils/timelineInspector.ts +32 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.54",
3
+ "version": "0.7.55",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/player": "0.7.54",
50
- "@hyperframes/sdk": "0.7.54",
51
- "@hyperframes/parsers": "0.7.54",
52
- "@hyperframes/studio-server": "0.7.54",
53
- "@hyperframes/core": "0.7.54"
49
+ "@hyperframes/core": "0.7.55",
50
+ "@hyperframes/player": "0.7.55",
51
+ "@hyperframes/studio-server": "0.7.55",
52
+ "@hyperframes/parsers": "0.7.55",
53
+ "@hyperframes/sdk": "0.7.55"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.54"
68
+ "@hyperframes/producer": "0.7.55"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
@@ -0,0 +1,115 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
6
+ import { CanvasContextMenu } from "./CanvasContextMenu";
7
+ import type { DomEditSelection } from "./domEditing";
8
+
9
+ installReactActEnvironment();
10
+
11
+ let host: HTMLDivElement;
12
+ let root: Root | null = null;
13
+
14
+ beforeEach(() => {
15
+ host = document.createElement("div");
16
+ document.body.append(host);
17
+ });
18
+
19
+ afterEach(() => {
20
+ act(() => root?.unmount());
21
+ root = null;
22
+ document.body.innerHTML = "";
23
+ });
24
+
25
+ function renderMenu(props: {
26
+ selection: DomEditSelection;
27
+ onApplyZIndex?: () => void;
28
+ onDelete?: (selection: DomEditSelection) => void;
29
+ }) {
30
+ root = createRoot(host);
31
+ act(() => {
32
+ root!.render(
33
+ React.createElement(CanvasContextMenu, {
34
+ x: 10,
35
+ y: 10,
36
+ selection: props.selection,
37
+ onClose: () => {},
38
+ onApplyZIndex: props.onApplyZIndex,
39
+ onDelete: props.onDelete,
40
+ }),
41
+ );
42
+ });
43
+ }
44
+
45
+ /** All menu buttons live in the portal under document.body. */
46
+ function menuButtons(): HTMLButtonElement[] {
47
+ return [...document.body.querySelectorAll("button")];
48
+ }
49
+
50
+ function hasDeleteItem(): boolean {
51
+ return menuButtons().some((b) => b.textContent?.includes("Delete"));
52
+ }
53
+
54
+ function zOrderButtons(): HTMLButtonElement[] {
55
+ return menuButtons().filter((b) => !b.textContent?.includes("Delete"));
56
+ }
57
+
58
+ describe("CanvasContextMenu — handler gating", () => {
59
+ it("renders all four z-order items, a divider, and Delete when both handlers are present", () => {
60
+ const el = document.createElement("div");
61
+ el.id = "target";
62
+ document.body.append(el);
63
+
64
+ renderMenu({
65
+ selection: makeSelection("Target", el),
66
+ onApplyZIndex: vi.fn(),
67
+ onDelete: vi.fn(),
68
+ });
69
+
70
+ expect(zOrderButtons()).toHaveLength(4);
71
+ expect(hasDeleteItem()).toBe(true);
72
+ // The divider only appears between the two groups.
73
+ expect(document.body.querySelector(".border-t")).not.toBeNull();
74
+ });
75
+
76
+ it("hides every item and does NOT render the menu when no handlers are present", () => {
77
+ const el = document.createElement("div");
78
+ el.id = "target";
79
+ // A z-index that a stray optimistic write would clobber — assert it is
80
+ // untouched, since the menu must not mutate the DOM without a persist path.
81
+ el.style.zIndex = "3";
82
+ document.body.append(el);
83
+
84
+ renderMenu({ selection: makeSelection("Target", el) });
85
+
86
+ // No menu opened at all — no buttons, no dead-end items, no DOM mutation.
87
+ expect(menuButtons()).toHaveLength(0);
88
+ expect(document.body.querySelector(".fixed.z-50")).toBeNull();
89
+ expect(el.style.zIndex).toBe("3");
90
+ });
91
+
92
+ it("shows only the z-order items (no Delete, no divider) when onDelete is absent", () => {
93
+ const el = document.createElement("div");
94
+ el.id = "target";
95
+ document.body.append(el);
96
+
97
+ renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() });
98
+
99
+ expect(zOrderButtons()).toHaveLength(4);
100
+ expect(hasDeleteItem()).toBe(false);
101
+ expect(document.body.querySelector(".border-t")).toBeNull();
102
+ });
103
+
104
+ it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => {
105
+ const el = document.createElement("div");
106
+ el.id = "target";
107
+ document.body.append(el);
108
+
109
+ renderMenu({ selection: makeSelection("Target", el), onDelete: vi.fn() });
110
+
111
+ expect(zOrderButtons()).toHaveLength(0);
112
+ expect(hasDeleteItem()).toBe(true);
113
+ expect(document.body.querySelector(".border-t")).toBeNull();
114
+ });
115
+ });
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Right-click context menu for a selected canvas element.
3
+ *
4
+ * Mirrors the look, positioning, and dismiss behavior of
5
+ * player/components/ClipContextMenu.tsx — portaled to document.body,
6
+ * overflow-adjusted, dismissed on outside-click or Escape via
7
+ * useContextMenuDismiss.
8
+ *
9
+ * ── Wiring (z-order persistence) ─────────────────────────────────────────────
10
+ * Z-index changes are applied optimistically to the live iframe element(s) via
11
+ * `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware:
12
+ * moving a target past an equal-z sibling can require renumbering the affected
13
+ * set). The patches are surfaced through the `onApplyZIndex` prop.
14
+ *
15
+ * The prop MUST be wired at the call site to route through the full persist
16
+ * path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
17
+ * element carries its full selection identity; sibling elements are iframe DOM
18
+ * nodes, so their id / selector are derived from the node and they share the
19
+ * selection's sourceFile) and forwards them to handleDomZIndexReorderCommit.
20
+ * ─────────────────────────────────────────────────────────────────────────────
21
+ */
22
+
23
+ import { memo } from "react";
24
+ import { createPortal } from "react-dom";
25
+ import type { DomEditSelection } from "./domEditing";
26
+ import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
27
+ import {
28
+ isZOrderActionEnabled,
29
+ resolveZOrderChange,
30
+ type ZOrderPatch,
31
+ } from "./canvasContextMenuZOrder";
32
+
33
+ interface CanvasContextMenuProps {
34
+ /** Viewport x of the right-click event. */
35
+ x: number;
36
+ /** Viewport y of the right-click event. */
37
+ y: number;
38
+ selection: DomEditSelection;
39
+ onClose: () => void;
40
+ /**
41
+ * Called with the resolved z-order patch list after an optimistic DOM update.
42
+ * Each patch is an { element, zIndex } pair (the target and, when a renumber
43
+ * is needed, affected siblings). Wire to handleDomZIndexReorderCommit (see
44
+ * module-level wiring comment).
45
+ */
46
+ onApplyZIndex?: (patches: ZOrderPatch[]) => void;
47
+ /**
48
+ * Delete the selected element. Wire to handleDomEditElementDelete from
49
+ * useDomEditActionsContext — same path as the Delete/Backspace hotkey.
50
+ * Absent when the caller wires no delete persist path (e.g. a legacy mount):
51
+ * the Delete item is then hidden rather than shown as a silent no-op.
52
+ */
53
+ onDelete?: (selection: DomEditSelection) => void;
54
+ }
55
+
56
+ type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
57
+
58
+ const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [
59
+ { action: "bring-forward", label: "Bring forward" },
60
+ { action: "send-backward", label: "Send backward" },
61
+ { action: "bring-to-front", label: "Bring to front" },
62
+ { action: "send-to-back", label: "Send to back" },
63
+ ];
64
+
65
+ export const CanvasContextMenu = memo(function CanvasContextMenu({
66
+ x,
67
+ y,
68
+ selection,
69
+ onClose,
70
+ onApplyZIndex,
71
+ onDelete,
72
+ }: CanvasContextMenuProps) {
73
+ const menuRef = useContextMenuDismiss(onClose);
74
+
75
+ // Gate each item group on the presence of its persist handler. Without the
76
+ // handler the action can't be persisted, so showing it would be a dead-end:
77
+ // a z-write reverts on reload and Delete silently no-ops. Hide the group
78
+ // instead. If nothing is actionable (a legacy mount with no handlers at all),
79
+ // don't render the menu — an empty menu is itself a dead-end.
80
+ const hasZActions = Boolean(onApplyZIndex);
81
+ const hasDelete = Boolean(onDelete);
82
+ const hasDivider = hasZActions && hasDelete;
83
+
84
+ // Overflow correction — match ClipContextMenu approach. Only the rendered
85
+ // groups contribute height (keeps positioning correct when a group is hidden).
86
+ const menuWidth = 200;
87
+ const menuHeight =
88
+ 8 + (hasZActions ? Z_ACTIONS.length * 28 : 0) + (hasDivider ? 1 : 0) + (hasDelete ? 28 : 0) + 8; // padding + items + divider + delete + padding
89
+ const overflowY = y + menuHeight - window.innerHeight;
90
+ const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
91
+ const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
92
+
93
+ const el = selection.element;
94
+
95
+ function handleZAction(action: ZAction) {
96
+ // No persist handler → do NOT touch the live iframe DOM. An optimistic
97
+ // write with nothing to persist just reverts on the next reload.
98
+ if (!onApplyZIndex) return;
99
+ const patches = resolveZOrderChange(el, action);
100
+ if (patches === null) return;
101
+ // Optimistic update — visible immediately even before persist completes.
102
+ for (const patch of patches) {
103
+ patch.element.style.zIndex = String(patch.zIndex);
104
+ const view = patch.element.ownerDocument?.defaultView;
105
+ if (view && view.getComputedStyle(patch.element).position === "static") {
106
+ patch.element.style.position = "relative";
107
+ }
108
+ }
109
+ onApplyZIndex(patches);
110
+ onClose();
111
+ }
112
+
113
+ function handleDelete() {
114
+ if (!onDelete) return;
115
+ onDelete(selection);
116
+ onClose();
117
+ }
118
+
119
+ if (!hasZActions && !hasDelete) return null;
120
+
121
+ // The menu is portaled to document.body, but in the React tree it is still a
122
+ // child of the DomEditOverlay <div>. React synthetic events bubble through the
123
+ // REACT tree (not the DOM tree), so a click on any menu control would otherwise
124
+ // bubble into the overlay's onPointerDown / onMouseDown handlers — which
125
+ // preventDefault() to start a marquee and re-resolve the selection. That
126
+ // preventDefault cancels the button's own click and the item action never runs.
127
+ //
128
+ // Stop pointer/mouse propagation at the menu root so overlay gesture handlers
129
+ // never see these events, and drive the item actions on pointerDown (which
130
+ // fires before any outside-click / dismiss logic can unmount the menu).
131
+ const stopBubble = (e: React.SyntheticEvent) => {
132
+ e.stopPropagation();
133
+ };
134
+
135
+ return createPortal(
136
+ <div
137
+ ref={menuRef}
138
+ className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
139
+ style={{ left: adjustedX, top: adjustedY }}
140
+ onPointerDown={stopBubble}
141
+ onMouseDown={stopBubble}
142
+ onClick={stopBubble}
143
+ onContextMenu={(e) => {
144
+ // Keep a right-click on the menu itself from re-opening / bubbling.
145
+ e.preventDefault();
146
+ e.stopPropagation();
147
+ }}
148
+ >
149
+ {hasZActions &&
150
+ Z_ACTIONS.map(({ action, label }) => {
151
+ const enabled = isZOrderActionEnabled(el, action);
152
+ return (
153
+ <button
154
+ key={action}
155
+ type="button"
156
+ className={`w-full flex items-center px-3 py-1.5 text-xs text-left ${
157
+ enabled
158
+ ? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
159
+ : "text-neutral-600 cursor-not-allowed"
160
+ }`}
161
+ disabled={!enabled}
162
+ // Act on pointerDown, not click: a pointerDown that reaches the
163
+ // overlay/document would otherwise re-select or dismiss the menu
164
+ // before the trailing click fires. Running here guarantees the
165
+ // action lands. Guard `button === 0` so a right-press is ignored.
166
+ onPointerDown={(e) => {
167
+ if (e.button !== 0) return;
168
+ e.preventDefault();
169
+ e.stopPropagation();
170
+ if (enabled) handleZAction(action);
171
+ }}
172
+ >
173
+ {label}
174
+ </button>
175
+ );
176
+ })}
177
+
178
+ {hasDivider && <div className="my-1 border-t border-neutral-700/60" />}
179
+
180
+ {hasDelete && (
181
+ <button
182
+ type="button"
183
+ className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
184
+ onPointerDown={(e) => {
185
+ if (e.button !== 0) return;
186
+ e.preventDefault();
187
+ e.stopPropagation();
188
+ handleDelete();
189
+ }}
190
+ >
191
+ <span>Delete</span>
192
+ <span className="text-neutral-500 text-[10px] ml-3">⌫</span>
193
+ </button>
194
+ )}
195
+ </div>,
196
+ document.body,
197
+ );
198
+ });
@@ -0,0 +1,112 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import {
4
+ applyStudioBoxSize,
5
+ applyStudioPathOffset,
6
+ readStudioBoxSize,
7
+ reapplyPositionEditsAfterSeek,
8
+ } from "./manualEditsDom";
9
+ import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches";
10
+ import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag";
11
+ import type { PatchOperation } from "../../utils/sourcePatcher";
12
+ import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
13
+
14
+ /**
15
+ * Center-anchored corner resize (CapCut model): the element scales about its
16
+ * CENTER, which must stay planted across the whole gesture — including after
17
+ * release, on every corner and at any rotation.
18
+ *
19
+ * This file lands here with ONLY the persist round-trip test, which exercises the
20
+ * real apply → persist → reload symbols that already exist at this point in the
21
+ * stack. The two center-anchor CONVERGENCE tests (the release-shift root cause)
22
+ * drive the exported `computeNextResizeAnchor` accumulator, which is extracted from
23
+ * the resize pointermove branch of `useDomEditOverlayGestures.ts`. That gesture code
24
+ * lands later in the stack (with the canvas glue swap), so those two tests are added
25
+ * to this file at that point — importing the real production helper rather than a
26
+ * test-local copy, so they can never pass against a stand-in that drifts from the
27
+ * shipped math.
28
+ */
29
+
30
+ afterEach(() => {
31
+ document.body.innerHTML = "";
32
+ });
33
+
34
+ /** Apply a built PatchOperation[] to a live element, mirroring sourcePatcher's
35
+ * inline-style / attribute application — i.e. what the persisted source carries
36
+ * when it is re-parsed into the DOM on the next preview load. */
37
+ function applyPatchesToElement(el: HTMLElement, ops: PatchOperation[]): void {
38
+ for (const op of ops) {
39
+ if (op.type === "inline-style") {
40
+ if (op.value === null) el.style.removeProperty(op.property);
41
+ else el.style.setProperty(op.property, op.value);
42
+ } else if (op.type === "attribute") {
43
+ if (op.value === null) el.removeAttribute(op.property);
44
+ else el.setAttribute(op.property, op.value);
45
+ }
46
+ }
47
+ }
48
+
49
+ /** Net translate applied to an element, resolving the studio offset var()
50
+ * expression to its px value so we compare the actually-rendered translation. */
51
+ function resolvedTranslatePx(el: HTMLElement): { x: number; y: number } {
52
+ const raw = el.style.getPropertyValue("translate").trim();
53
+ if (!raw || raw === "none") return { x: 0, y: 0 };
54
+ const vx = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0;
55
+ const vy = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0;
56
+ const parts = splitTopLevelWhitespace(raw);
57
+ const parseAxis = (part: string, varVal: number): number => {
58
+ if (part && part.includes("--hf-studio-offset")) return varVal;
59
+ const n = Number.parseFloat(part);
60
+ return Number.isFinite(n) ? n : 0;
61
+ };
62
+ return {
63
+ x: parseAxis(parts[0] ?? "", vx),
64
+ y: parseAxis(parts[1] ?? "", vy),
65
+ };
66
+ }
67
+
68
+ describe("center-anchored corner resize — no shift after release", () => {
69
+ it("net translate after persist+reload equals the committed anchor offset (non-GSAP)", () => {
70
+ // The committed offset flows through the real apply → persist → reload chain
71
+ // unchanged (this hop was proved clean; the shift is upstream in the anchor
72
+ // loop, tested with the gesture code, not in persistence).
73
+ const el = document.createElement("div");
74
+ el.style.setProperty("width", "200px");
75
+ el.style.setProperty("height", "100px");
76
+ document.body.appendChild(el);
77
+
78
+ const anchorDx = -30;
79
+ const anchorDy = -18;
80
+ const finalSize = { width: 240, height: 130 };
81
+
82
+ applyStudioBoxSize(el, finalSize);
83
+ const memberResult = createManualOffsetDragMember({
84
+ key: "k",
85
+ selection: { element: el } as never,
86
+ element: el,
87
+ rect: { left: 0, top: 0, width: 240, height: 130, editScaleX: 1, editScaleY: 1 },
88
+ });
89
+ expect(memberResult.ok).toBe(true);
90
+ if (!memberResult.ok) return;
91
+
92
+ const finalOffset = applyManualOffsetDragCommit(memberResult.member, anchorDx, anchorDy);
93
+
94
+ applyStudioBoxSize(el, finalSize);
95
+ const patches = buildBoxSizePatches(el);
96
+ applyStudioPathOffset(el, finalOffset);
97
+ patches.push(...buildPathOffsetPatches(el));
98
+
99
+ expect(resolvedTranslatePx(el)).toEqual({ x: anchorDx, y: anchorDy });
100
+
101
+ // Persist → fresh element re-parsed from source → reload re-stamp.
102
+ const reloaded = document.createElement("div");
103
+ reloaded.style.setProperty("width", "200px");
104
+ reloaded.style.setProperty("height", "100px");
105
+ document.body.appendChild(reloaded);
106
+ applyPatchesToElement(reloaded, patches);
107
+ reapplyPositionEditsAfterSeek(reloaded.ownerDocument);
108
+
109
+ expect(resolvedTranslatePx(reloaded)).toEqual({ x: anchorDx, y: anchorDy });
110
+ expect(readStudioBoxSize(reloaded)).toEqual(finalSize);
111
+ });
112
+ });