@hyperframes/studio 0.7.53 → 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.
- package/dist/assets/{index-CJpl5RTK.js → index-BRwkMj0w.js} +108 -108
- package/dist/assets/{index-DHrXh-VF.js → index-BXaqaVKt.js} +1 -1
- package/dist/assets/{index-Bo8sRL2U.js → index-CPetwHFV.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +48 -3
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
- package/src/components/editor/CanvasContextMenu.tsx +198 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +112 -0
- package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
- package/src/hooks/useRazorSplit.history.test.tsx +173 -0
- package/src/player/components/timelineCollision.test.ts +437 -0
- package/src/player/components/timelineCollision.ts +263 -0
- package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
- package/src/player/components/timelineMultiDragPreview.ts +106 -0
- package/src/player/components/timelineSnapping.test.ts +134 -0
- package/src/player/components/timelineSnapping.ts +110 -0
- package/src/player/components/timelineStackingSync.test.ts +244 -0
- package/src/player/components/timelineStackingSync.ts +331 -0
- package/src/player/components/timelineZones.test.ts +425 -0
- package/src/player/components/timelineZones.ts +198 -0
- package/src/player/components/timelineZoom.test.ts +67 -0
- package/src/player/components/timelineZoom.ts +51 -0
- package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
- package/src/utils/assetClickBehavior.test.ts +104 -0
- package/src/utils/assetClickBehavior.ts +75 -0
- package/src/utils/canvasNudgeGate.test.ts +31 -0
- package/src/utils/canvasNudgeGate.ts +32 -0
- package/src/utils/studioUiPreferences.test.ts +38 -0
- package/src/utils/studioUiPreferences.ts +27 -0
- package/src/utils/timelineInspector.test.ts +121 -0
- 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.
|
|
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/
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
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.
|
|
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
|
+
});
|