@hyperframes/studio 0.8.95 → 0.8.97
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/{hyperframes-player-DJJKab4_.js → hyperframes-player-Biu5_W0l.js} +1 -1
- package/dist/assets/{index-DE1dn3QB.js → index-B5Z-1RW9.js} +1 -1
- package/dist/assets/{index-Cc4by8r7.js → index-BdV5T-QI.js} +200 -194
- package/dist/assets/{index-C1dXTZvs.js → index-CWuozokW.js} +1 -1
- package/dist/assets/{index-Dx79V1Se.css → index-xB5O0hPw.css} +1 -1
- package/dist/{chunk-QZ6HNNFG.js → chunk-6GAO75IU.js} +16 -4
- package/dist/chunk-6GAO75IU.js.map +1 -0
- package/dist/{chunk-PQFEGZWM.js → chunk-MTAVNAGW.js} +2 -2
- package/dist/chunk-MTAVNAGW.js.map +1 -0
- package/dist/{chunk-QPSOTK6M.js → chunk-U3EE4ACX.js} +5 -5
- package/dist/chunk-U3EE4ACX.js.map +1 -0
- package/dist/{domEditingLayers-3BAP6NNO.js → domEditingLayers-NU6Z5QWA.js} +2 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.html +2 -2
- package/dist/index.js +2036 -1809
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.d.ts +3 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolPicker.tsx +19 -15
- package/src/components/TimelineToolbar.test.tsx +62 -0
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/DomEditCropHandles.test.tsx +63 -0
- package/src/components/editor/DomEditCropHandles.tsx +7 -10
- package/src/components/editor/DomEditSelectionChrome.tsx +3 -3
- package/src/components/editor/domEditOverlayGestures.ts +16 -0
- package/src/components/editor/domEditing.test.ts +9 -1
- package/src/components/editor/domEditingDom.ts +2 -1
- package/src/components/editor/fontAssets.test.ts +69 -0
- package/src/components/editor/fontAssets.ts +11 -2
- package/src/components/editor/groupDragMove.ts +13 -6
- package/src/components/editor/manualOffsetDrag.test.ts +56 -0
- package/src/components/editor/manualOffsetDrag.ts +16 -9
- package/src/components/editor/shiftDragAxisLock.test.tsx +358 -0
- package/src/components/editor/snapEngine.ts +3 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +24 -9
- package/src/components/ui/Menu.test.tsx +21 -0
- package/src/components/ui/Popover.tsx +21 -1
- package/src/hooks/domSelectionTimelineMirror.ts +5 -4
- package/src/hooks/domStyleCommit.fonts.test.ts +55 -0
- package/src/hooks/useAppHotkeys.test.ts +7 -5
- package/src/hooks/useClipboard.ts +25 -7
- package/src/hooks/useDomSelection.test.ts +56 -32
- package/src/hooks/useDomSelection.ts +4 -3
- package/src/player/components/BeatStrip.tsx +6 -3
- package/src/player/components/Timeline.readOnly.test.tsx +670 -0
- package/src/player/components/Timeline.rulerSeek.test.tsx +1 -25
- package/src/player/components/TimelineAutomationLane.test.tsx +23 -0
- package/src/player/components/TimelineClipDiamonds.tsx +7 -2
- package/src/player/components/TimelineFxButton.tsx +3 -0
- package/src/player/components/TimelineLanes.tsx +3 -2
- package/src/player/components/TimelineProvider.tsx +23 -7
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/studioShortcuts.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.ts +3 -1
- package/src/player/components/timelineDragDrop.ts +11 -1
- package/src/player/components/timelineEditCapabilities.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +24 -0
- package/src/player/components/timelineReadOnly.ts +64 -0
- package/src/player/components/useAutomationLaneGestures.ts +13 -2
- package/src/player/components/useTimelineKeyframeHandlers.ts +7 -0
- package/src/player/components/useTimelineProviderState.tsx +42 -14
- package/src/player/hooks/useShadowPreviewReload.ts +2 -2
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +69 -0
- package/src/player/hooks/useTimelinePlayer.ts +6 -0
- package/src/player/lib/timelineDOM.test.ts +25 -0
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineIframeHelpers.test.ts +15 -0
- package/src/player/store/playerStore.ts +4 -4
- package/src/utils/clipboardLook.test.ts +130 -0
- package/src/utils/clipboardLook.ts +82 -0
- package/src/utils/clipboardPayload.ts +8 -2
- package/src/utils/studioFontHelpers.ts +6 -3
- package/dist/chunk-PQFEGZWM.js.map +0 -1
- package/dist/chunk-QPSOTK6M.js.map +0 -1
- package/dist/chunk-QZ6HNNFG.js.map +0 -1
- /package/dist/{domEditingLayers-3BAP6NNO.js.map → domEditingLayers-NU6Z5QWA.js.map} +0 -0
|
@@ -514,6 +514,7 @@ export function applyManualOffsetNudgeCommit(
|
|
|
514
514
|
|
|
515
515
|
function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
|
|
516
516
|
restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
517
|
+
getOffsetDragGsap(member.element)?.set(member.element, { ...member.baseGsap });
|
|
517
518
|
endStudioManualEditGesture(member.element, member.gestureToken);
|
|
518
519
|
}
|
|
519
520
|
|
|
@@ -525,6 +526,18 @@ export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]
|
|
|
525
526
|
}
|
|
526
527
|
}
|
|
527
528
|
|
|
529
|
+
// Drop the draft's `translate: none` so the soft reload starts clean (#1673). When GSAP owns
|
|
530
|
+
// the position it folded any translate into its transform, so one still showing applies twice.
|
|
531
|
+
function settleDraftTranslate(element: HTMLElement, gsapOwnsPosition: boolean): void {
|
|
532
|
+
if (element.style.getPropertyValue("translate") === "none") {
|
|
533
|
+
element.style.removeProperty("translate");
|
|
534
|
+
}
|
|
535
|
+
if (!gsapOwnsPosition) return;
|
|
536
|
+
const view = element.ownerDocument.defaultView;
|
|
537
|
+
const underneath = view?.getComputedStyle(element).getPropertyValue("translate");
|
|
538
|
+
if (underneath && underneath !== "none") element.style.setProperty("translate", "none");
|
|
539
|
+
}
|
|
540
|
+
|
|
528
541
|
/** Teardown after a COMMITTED drag. */
|
|
529
542
|
export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
|
|
530
543
|
for (const member of members) {
|
|
@@ -533,23 +546,17 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
|
|
|
533
546
|
member.element.removeAttribute("data-hf-drag-initial-offset-y");
|
|
534
547
|
member.element.removeAttribute("data-hf-drag-gsap-base-x");
|
|
535
548
|
member.element.removeAttribute("data-hf-drag-gsap-base-y");
|
|
536
|
-
// Clear the draft's `translate: none` so the soft reload starts clean —
|
|
537
|
-
// otherwise button-less pointermoves after the reload compute deltas
|
|
538
|
-
// from a stale base and fling the element off-screen (#1673).
|
|
539
549
|
// Do NOT clearProps:"transform" — that nukes the committed GSAP position
|
|
540
550
|
// and causes a visual snap-back before the soft reload re-applies it.
|
|
541
|
-
|
|
542
|
-
member.element.style.removeProperty("translate");
|
|
543
|
-
}
|
|
551
|
+
const gsapOwnsPosition = Boolean(getOffsetDragGsap(member.element));
|
|
544
552
|
// Migration: when GSAP owns the position (the committed value lives in the
|
|
545
553
|
// GSAP transform), the legacy `--hf-studio-offset` CSS channel is obsolete.
|
|
546
554
|
// Clear it on the LIVE element — otherwise the leftover `translate:
|
|
547
555
|
// var(--hf-studio-offset)` composes with the GSAP transform and the element
|
|
548
556
|
// renders offset by the stale value until a full page reload (the source is
|
|
549
557
|
// already stripped). clearStudioPathOffset leaves `transform` untouched.
|
|
550
|
-
if (
|
|
551
|
-
|
|
552
|
-
}
|
|
558
|
+
if (gsapOwnsPosition) clearStudioPathOffset(member.element);
|
|
559
|
+
settleDraftTranslate(member.element, gsapOwnsPosition);
|
|
553
560
|
resumeGsapTimelines(member.element);
|
|
554
561
|
}
|
|
555
562
|
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React, { act, createRef } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { DomEditSelection } from "./domEditing";
|
|
6
|
+
import type { GestureState, GroupGestureState } from "./domEditOverlayGestures";
|
|
7
|
+
import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome";
|
|
8
|
+
import { createManualOffsetDragMember } from "./manualOffsetDrag";
|
|
9
|
+
import { resolveSnapAdjustment } from "./snapEngine";
|
|
10
|
+
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
11
|
+
|
|
12
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
|
+
|
|
14
|
+
function gestureHarness(gesture: Partial<GestureState> | null, group: GroupGestureState | null) {
|
|
15
|
+
const opts = {
|
|
16
|
+
gestureRef: { current: gesture },
|
|
17
|
+
groupGestureRef: { current: group },
|
|
18
|
+
selectionRef: { current: null },
|
|
19
|
+
hoverSelectionRef: { current: null },
|
|
20
|
+
boxRef: { current: null },
|
|
21
|
+
blockedMoveRef: { current: null },
|
|
22
|
+
rafPausedRef: { current: false },
|
|
23
|
+
suppressNextBoxClickRef: { current: false },
|
|
24
|
+
snapGuidesRef: { current: null },
|
|
25
|
+
setOverlayRect: vi.fn(),
|
|
26
|
+
setGroupOverlayItems: vi.fn(),
|
|
27
|
+
onCanvasMouseDown: vi.fn(),
|
|
28
|
+
onCanvasPointerMoveRef: { current: vi.fn() },
|
|
29
|
+
onPathOffsetCommitRef: { current: vi.fn() },
|
|
30
|
+
onGroupPathOffsetCommitRef: { current: vi.fn() },
|
|
31
|
+
};
|
|
32
|
+
const handlers = createDomEditOverlayGestureHandlers(opts as never);
|
|
33
|
+
const pointer = (clientX: number, clientY: number, shiftKey: boolean) =>
|
|
34
|
+
({ clientX, clientY, shiftKey, altKey: false }) as never;
|
|
35
|
+
return { opts, handlers, pointer };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function singleDrag() {
|
|
39
|
+
const element = document.createElement("div");
|
|
40
|
+
return gestureHarness(
|
|
41
|
+
{
|
|
42
|
+
kind: "drag",
|
|
43
|
+
mode: "path-offset",
|
|
44
|
+
selection: { element } as unknown as DomEditSelection,
|
|
45
|
+
startX: 100,
|
|
46
|
+
startY: 100,
|
|
47
|
+
originLeft: 10,
|
|
48
|
+
originTop: 20,
|
|
49
|
+
originWidth: 50,
|
|
50
|
+
originHeight: 40,
|
|
51
|
+
editScaleX: 1,
|
|
52
|
+
editScaleY: 1,
|
|
53
|
+
actualRotation: 0,
|
|
54
|
+
},
|
|
55
|
+
null,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const groupGesture = () =>
|
|
60
|
+
({ startX: 100, startY: 100, originItems: [], members: [] }) as unknown as GroupGestureState;
|
|
61
|
+
|
|
62
|
+
describe("shift+drag locks a move to the axis the pointer travels further on", () => {
|
|
63
|
+
it("keeps a mostly horizontal single drag on its row", () => {
|
|
64
|
+
const { opts, handlers, pointer } = singleDrag();
|
|
65
|
+
handlers.onPointerMove(pointer(130, 108, true));
|
|
66
|
+
expect(opts.setOverlayRect).toHaveBeenLastCalledWith(
|
|
67
|
+
expect.objectContaining({ left: 40, top: 20 }),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("keeps a mostly vertical single drag on its column", () => {
|
|
72
|
+
const { opts, handlers, pointer } = singleDrag();
|
|
73
|
+
handlers.onPointerMove(pointer(95, 160, true));
|
|
74
|
+
expect(opts.setOverlayRect).toHaveBeenLastCalledWith(
|
|
75
|
+
expect.objectContaining({ left: 10, top: 80 }),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("frees the drag on the next move once shift is released, and locks again when pressed", () => {
|
|
80
|
+
const { opts, handlers, pointer } = singleDrag();
|
|
81
|
+
handlers.onPointerMove(pointer(130, 108, false));
|
|
82
|
+
expect(opts.setOverlayRect).toHaveBeenLastCalledWith(
|
|
83
|
+
expect.objectContaining({ left: 40, top: 28 }),
|
|
84
|
+
);
|
|
85
|
+
handlers.onPointerMove(pointer(140, 110, true));
|
|
86
|
+
expect(opts.setOverlayRect).toHaveBeenLastCalledWith(
|
|
87
|
+
expect.objectContaining({ left: 50, top: 20 }),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("locks a group drag the same way", () => {
|
|
92
|
+
const group = groupGesture();
|
|
93
|
+
const { handlers, pointer } = gestureHarness(null, group);
|
|
94
|
+
handlers.onPointerMove(pointer(130, 108, true));
|
|
95
|
+
expect([group.lastSnappedDx, group.lastSnappedDy]).toEqual([30, 0]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("snaps a locked drag only along its free axis", () => {
|
|
99
|
+
const target = {
|
|
100
|
+
id: "t",
|
|
101
|
+
left: 0,
|
|
102
|
+
top: 203,
|
|
103
|
+
right: 400,
|
|
104
|
+
bottom: 303,
|
|
105
|
+
centerX: 200,
|
|
106
|
+
centerY: 253,
|
|
107
|
+
};
|
|
108
|
+
const snap = (lockedAxis?: "x" | "y") =>
|
|
109
|
+
resolveSnapAdjustment({
|
|
110
|
+
movingRect: { left: 0, top: 0, width: 50, height: 200 },
|
|
111
|
+
proposedDx: 97,
|
|
112
|
+
proposedDy: 0,
|
|
113
|
+
targets: [target],
|
|
114
|
+
threshold: 6,
|
|
115
|
+
disabled: false,
|
|
116
|
+
lockedAxis,
|
|
117
|
+
} as never);
|
|
118
|
+
expect(snap().dy).toBe(3);
|
|
119
|
+
const locked = snap("y");
|
|
120
|
+
expect(locked.dy).toBe(0);
|
|
121
|
+
expect(locked.guides.every((guide) => guide.axis === "x")).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("shift+click on a selected box still toggles the element under the pointer", () => {
|
|
126
|
+
it.each([
|
|
127
|
+
["group", () => gestureHarness(null, groupGesture())],
|
|
128
|
+
["single", () => singleMemberDrag()],
|
|
129
|
+
])(
|
|
130
|
+
"hands a %s shift press that never travelled to the canvas as an additive click",
|
|
131
|
+
(_, make) => {
|
|
132
|
+
const { opts, handlers, pointer } = make();
|
|
133
|
+
handlers.onPointerUp(pointer(101, 100, true));
|
|
134
|
+
expect(opts.onCanvasMouseDown).toHaveBeenCalledWith(
|
|
135
|
+
expect.objectContaining({ shiftKey: true }),
|
|
136
|
+
expect.objectContaining({ preferClipAncestor: false }),
|
|
137
|
+
);
|
|
138
|
+
expect(opts.suppressNextBoxClickRef.current).toBe(true);
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
it("leaves a plain press on the group box alone", () => {
|
|
143
|
+
const { opts, handlers, pointer } = gestureHarness(null, groupGesture());
|
|
144
|
+
handlers.onPointerUp(pointer(101, 100, false));
|
|
145
|
+
expect(opts.onCanvasMouseDown).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("a shift press on a selected box starts the drag", () => {
|
|
150
|
+
const rect = { left: 10, top: 10, width: 200, height: 100, editScaleX: 1, editScaleY: 1 };
|
|
151
|
+
const gestures = () => ({
|
|
152
|
+
startGesture: vi.fn(),
|
|
153
|
+
startGroupDrag: vi.fn(),
|
|
154
|
+
startBlockedMove: vi.fn(),
|
|
155
|
+
});
|
|
156
|
+
const shiftPress = (el: Element) =>
|
|
157
|
+
act(() => {
|
|
158
|
+
el.dispatchEvent(
|
|
159
|
+
new PointerEvent("pointerdown", {
|
|
160
|
+
bubbles: true,
|
|
161
|
+
cancelable: true,
|
|
162
|
+
shiftKey: true,
|
|
163
|
+
pointerId: 1,
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
function mount(node: (spies: ReturnType<typeof gestures>) => React.ReactNode) {
|
|
169
|
+
const host = document.createElement("div");
|
|
170
|
+
document.body.append(host);
|
|
171
|
+
const root = createRoot(host);
|
|
172
|
+
const spies = gestures();
|
|
173
|
+
act(() => root.render(node(spies)));
|
|
174
|
+
const box = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
|
|
175
|
+
return { spies, box, unmount: () => act(() => root.unmount()) };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const single = (canMove: boolean) =>
|
|
179
|
+
mount((spies) => (
|
|
180
|
+
<DomEditSelectionChrome
|
|
181
|
+
selection={
|
|
182
|
+
{
|
|
183
|
+
element: document.createElement("div"),
|
|
184
|
+
capabilities: { canApplyManualOffset: canMove },
|
|
185
|
+
} as unknown as DomEditSelection
|
|
186
|
+
}
|
|
187
|
+
overlayRect={rect}
|
|
188
|
+
allowCanvasMovement
|
|
189
|
+
allowBodyDrag
|
|
190
|
+
boxRef={createRef()}
|
|
191
|
+
boxChromeClass=""
|
|
192
|
+
boxClipPath={undefined}
|
|
193
|
+
selectionKey="box"
|
|
194
|
+
groupSelectionCount={0}
|
|
195
|
+
gestures={spies as never}
|
|
196
|
+
onBoxMouseDown={vi.fn()}
|
|
197
|
+
onBoxClick={vi.fn()}
|
|
198
|
+
/>
|
|
199
|
+
));
|
|
200
|
+
|
|
201
|
+
const group = (groupCanMove: boolean) =>
|
|
202
|
+
mount((spies) => (
|
|
203
|
+
<DomEditGroupChrome
|
|
204
|
+
groupOverlayItems={[]}
|
|
205
|
+
groupBounds={rect}
|
|
206
|
+
allowCanvasMovement
|
|
207
|
+
allowBodyDrag
|
|
208
|
+
groupCanMove={groupCanMove}
|
|
209
|
+
gestures={spies as never}
|
|
210
|
+
onBoxMouseDown={vi.fn()}
|
|
211
|
+
onBoxClick={vi.fn()}
|
|
212
|
+
/>
|
|
213
|
+
));
|
|
214
|
+
|
|
215
|
+
it("drags a single selection", () => {
|
|
216
|
+
const { spies, box, unmount } = single(true);
|
|
217
|
+
shiftPress(box);
|
|
218
|
+
expect(spies.startGesture).toHaveBeenCalledWith("drag", expect.anything());
|
|
219
|
+
unmount();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("drags a group selection", () => {
|
|
223
|
+
const { spies, box, unmount } = group(true);
|
|
224
|
+
shiftPress(box);
|
|
225
|
+
expect(spies.startGroupDrag).toHaveBeenCalledTimes(1);
|
|
226
|
+
unmount();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("leaves a shift press on a box that cannot move to the multi-select toggle", () => {
|
|
230
|
+
const one = single(false);
|
|
231
|
+
shiftPress(one.box);
|
|
232
|
+
expect(one.spies.startBlockedMove).not.toHaveBeenCalled();
|
|
233
|
+
one.unmount();
|
|
234
|
+
const many = group(false);
|
|
235
|
+
shiftPress(many.box);
|
|
236
|
+
expect(many.spies.startGroupDrag).not.toHaveBeenCalled();
|
|
237
|
+
many.unmount();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
type GsapStub = {
|
|
242
|
+
set: (el: Element, vars: object) => void;
|
|
243
|
+
getProperty: (el: Element, p: string) => number;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
function previewGsap(): GsapStub {
|
|
247
|
+
const win = window as unknown as { gsap?: GsapStub };
|
|
248
|
+
if (win.gsap) return win.gsap;
|
|
249
|
+
const positions = new WeakMap<Element, Record<string, number>>();
|
|
250
|
+
win.gsap = {
|
|
251
|
+
set: (el, vars) => positions.set(el, { ...positions.get(el), ...vars }),
|
|
252
|
+
getProperty: (el, prop) => positions.get(el)?.[prop] ?? 0,
|
|
253
|
+
};
|
|
254
|
+
return win.gsap;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function draggableMember(key: string) {
|
|
258
|
+
const gsap = previewGsap();
|
|
259
|
+
const element = document.createElement("div");
|
|
260
|
+
document.body.append(element);
|
|
261
|
+
gsap.set(element, { x: 10, y: -10 });
|
|
262
|
+
const selection = {
|
|
263
|
+
element,
|
|
264
|
+
capabilities: { canApplyManualOffset: true },
|
|
265
|
+
} as unknown as DomEditSelection;
|
|
266
|
+
const result = createManualOffsetDragMember({
|
|
267
|
+
key,
|
|
268
|
+
selection,
|
|
269
|
+
element,
|
|
270
|
+
rect: { left: 0, top: 0, width: 50, height: 40, editScaleX: 1, editScaleY: 1 },
|
|
271
|
+
} as never);
|
|
272
|
+
if (!result.ok) throw new Error(result.reason);
|
|
273
|
+
const at = () => [gsap.getProperty(element, "x"), gsap.getProperty(element, "y")];
|
|
274
|
+
return { member: result.member, selection, at };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function singleMemberDrag() {
|
|
278
|
+
const { member, selection, at } = draggableMember("single");
|
|
279
|
+
const harness = gestureHarness(
|
|
280
|
+
{
|
|
281
|
+
kind: "drag",
|
|
282
|
+
mode: "path-offset",
|
|
283
|
+
selection,
|
|
284
|
+
startX: 100,
|
|
285
|
+
startY: 100,
|
|
286
|
+
originLeft: 0,
|
|
287
|
+
originTop: 0,
|
|
288
|
+
originWidth: 50,
|
|
289
|
+
originHeight: 40,
|
|
290
|
+
editScaleX: 1,
|
|
291
|
+
editScaleY: 1,
|
|
292
|
+
actualRotation: 0,
|
|
293
|
+
pathOffsetMember: member,
|
|
294
|
+
initialPathOffset: member.initialPathOffset,
|
|
295
|
+
manualEditDragToken: member.gestureToken,
|
|
296
|
+
},
|
|
297
|
+
null,
|
|
298
|
+
);
|
|
299
|
+
return { ...harness, at };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function groupMemberDrag() {
|
|
303
|
+
const a = draggableMember("a");
|
|
304
|
+
const b = draggableMember("b");
|
|
305
|
+
const group = { startX: 100, startY: 100, originItems: [], members: [a.member, b.member] };
|
|
306
|
+
const harness = gestureHarness(null, group as unknown as GroupGestureState);
|
|
307
|
+
return { ...harness, at: () => [a.at(), b.at()] };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
afterEach(() => {
|
|
311
|
+
delete (window as unknown as { gsap?: GsapStub }).gsap;
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("a press that stays under the drag threshold puts the preview back where it started", () => {
|
|
315
|
+
it.each([true, false])("single, shift %s", (shift) => {
|
|
316
|
+
const { handlers, pointer, at } = singleMemberDrag();
|
|
317
|
+
handlers.onPointerMove(pointer(102, 100, shift));
|
|
318
|
+
expect(at()).toEqual([12, -10]);
|
|
319
|
+
handlers.onPointerUp(pointer(102, 100, shift));
|
|
320
|
+
expect(at()).toEqual([10, -10]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it.each([true, false])("group, shift %s", (shift) => {
|
|
324
|
+
const { handlers, pointer, at } = groupMemberDrag();
|
|
325
|
+
handlers.onPointerMove(pointer(102, 100, shift));
|
|
326
|
+
handlers.onPointerUp(pointer(102, 100, shift));
|
|
327
|
+
expect(at()).toEqual([
|
|
328
|
+
[10, -10],
|
|
329
|
+
[10, -10],
|
|
330
|
+
]);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("a drag that travelled is not a click, even when it ends near its start", () => {
|
|
335
|
+
it.each([
|
|
336
|
+
["single", singleMemberDrag, "onPathOffsetCommitRef"],
|
|
337
|
+
["group", groupMemberDrag, "onGroupPathOffsetCommitRef"],
|
|
338
|
+
] as const)("commits a %s shift drag instead of toggling", (_, make, commit) => {
|
|
339
|
+
const { opts, handlers, pointer } = make();
|
|
340
|
+
handlers.onPointerMove(pointer(180, 100, true));
|
|
341
|
+
handlers.onPointerMove(pointer(101, 100, true));
|
|
342
|
+
handlers.onPointerUp(pointer(101, 100, true));
|
|
343
|
+
expect(opts.onCanvasMouseDown).not.toHaveBeenCalled();
|
|
344
|
+
expect(opts[commit].current).toHaveBeenCalledTimes(1);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("a cancelled drag puts the preview back where it started", () => {
|
|
349
|
+
it("returns a single drag that travelled to its start and saves nothing", () => {
|
|
350
|
+
const { opts, handlers, pointer, at } = singleMemberDrag();
|
|
351
|
+
handlers.onPointerMove(pointer(140, 100, false));
|
|
352
|
+
expect(at()).toEqual([50, -10]);
|
|
353
|
+
handlers.clearPointerState({ current: null });
|
|
354
|
+
expect(at()).toEqual([10, -10]);
|
|
355
|
+
expect(opts.gestureRef.current).toBeNull();
|
|
356
|
+
expect(opts.onPathOffsetCommitRef.current).not.toHaveBeenCalled();
|
|
357
|
+
});
|
|
358
|
+
});
|
|
@@ -379,6 +379,7 @@ export function resolveSnapAdjustment(input: {
|
|
|
379
379
|
disabled: boolean;
|
|
380
380
|
/** Set when the gesture has not travelled far enough for snapping yet. */
|
|
381
381
|
disabledForTravel?: boolean;
|
|
382
|
+
lockedAxis?: "x" | "y";
|
|
382
383
|
}): SnapResult {
|
|
383
384
|
if (input.disabled || input.disabledForTravel || input.threshold <= 0) {
|
|
384
385
|
return DISABLED_RESULT(input.proposedDx, input.proposedDy);
|
|
@@ -407,8 +408,8 @@ export function resolveSnapAdjustment(input: {
|
|
|
407
408
|
input.threshold,
|
|
408
409
|
);
|
|
409
410
|
|
|
410
|
-
const bestX = pickBest(xCandidates);
|
|
411
|
-
const bestY = pickBest(yCandidates);
|
|
411
|
+
const bestX = input.lockedAxis === "x" ? null : pickBest(xCandidates);
|
|
412
|
+
const bestY = input.lockedAxis === "y" ? null : pickBest(yCandidates);
|
|
412
413
|
const adjustedDx = input.proposedDx + (bestX?.adjustment ?? 0);
|
|
413
414
|
const adjustedDy = input.proposedDy + (bestY?.adjustment ?? 0);
|
|
414
415
|
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
applyRotationDraftViaGsap,
|
|
13
13
|
endManualOffsetDragMembers,
|
|
14
14
|
restoreManualOffsetDragMembers,
|
|
15
|
-
resumeGsapTimelines,
|
|
16
15
|
} from "./manualOffsetDrag";
|
|
17
16
|
import {
|
|
18
17
|
applyStudioBoxSize,
|
|
@@ -40,6 +39,7 @@ import {
|
|
|
40
39
|
type UseDomEditOverlayGesturesOptions,
|
|
41
40
|
ROTATED_SNAP_BYPASS_DEGREES,
|
|
42
41
|
hasDomEditRotationChanged,
|
|
42
|
+
lockDragToDominantAxis,
|
|
43
43
|
resolveDomEditRotationGesture,
|
|
44
44
|
} from "./domEditOverlayGestures";
|
|
45
45
|
import { resolveCenterResizeSize } from "./domEditResizeLocal";
|
|
@@ -61,6 +61,13 @@ import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug
|
|
|
61
61
|
import { createGroupDragMover } from "./groupDragMove";
|
|
62
62
|
import { DomEditSaveQueueOpenError } from "../../utils/domEditSaveQueue";
|
|
63
63
|
|
|
64
|
+
function isTap(g: { startX: number; startY: number; travelled?: boolean }, e: React.PointerEvent) {
|
|
65
|
+
return (
|
|
66
|
+
!g.travelled &&
|
|
67
|
+
Math.hypot(e.clientX - g.startX, e.clientY - g.startY) < BLOCKED_MOVE_THRESHOLD_PX
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
64
71
|
function logGestureCommitFailure(message: string, error: unknown): void {
|
|
65
72
|
if (error instanceof DomEditSaveQueueOpenError) return;
|
|
66
73
|
console.error(message, error);
|
|
@@ -136,11 +143,13 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
136
143
|
}
|
|
137
144
|
|
|
138
145
|
if (groupG) {
|
|
146
|
+
if (!isTap(groupG, e)) groupG.travelled = true;
|
|
139
147
|
moveGroupDrag(groupG, e);
|
|
140
148
|
return;
|
|
141
149
|
}
|
|
142
150
|
|
|
143
151
|
if (!g || !sel) return;
|
|
152
|
+
if (!isTap(g, e)) g.travelled = true;
|
|
144
153
|
let dx = e.clientX - g.startX;
|
|
145
154
|
let dy = e.clientY - g.startY;
|
|
146
155
|
|
|
@@ -164,6 +173,9 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
164
173
|
}
|
|
165
174
|
|
|
166
175
|
if (g.kind === "drag") {
|
|
176
|
+
const lock = lockDragToDominantAxis(dx, dy, e.shiftKey);
|
|
177
|
+
dx = lock.dx;
|
|
178
|
+
dy = lock.dy;
|
|
167
179
|
const sc = g.snapContext;
|
|
168
180
|
// Bypass edge-snapping for rotated elements — the snap targets and the
|
|
169
181
|
// snapped rect are axis-aligned, so snapping a rotated box's AABB shifts it
|
|
@@ -196,6 +208,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
196
208
|
gridEdges: sc.gridEdges ?? undefined,
|
|
197
209
|
threshold: SNAP_THRESHOLD_PX,
|
|
198
210
|
disabled: e.altKey,
|
|
211
|
+
lockedAxis: lock.lockedAxis,
|
|
199
212
|
});
|
|
200
213
|
dx = snap.dx;
|
|
201
214
|
dy = snap.dy;
|
|
@@ -302,8 +315,14 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
302
315
|
// ordinary click — which lands between the members, resolves to nothing,
|
|
303
316
|
// and deselects the group the drag just moved.
|
|
304
317
|
opts.suppressNextBoxClickRef.current = true;
|
|
305
|
-
if (
|
|
318
|
+
if (isTap(groupG, e)) {
|
|
306
319
|
restoreGroupPathOffsets(groupG);
|
|
320
|
+
if (e.shiftKey) {
|
|
321
|
+
opts.onCanvasMouseDown(e as unknown as React.MouseEvent<HTMLDivElement>, {
|
|
322
|
+
preferClipAncestor: false,
|
|
323
|
+
hoverSelection: opts.hoverSelectionRef.current,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
307
326
|
return;
|
|
308
327
|
}
|
|
309
328
|
const dx = groupG.lastSnappedDx ?? rawDx;
|
|
@@ -360,10 +379,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
360
379
|
opts.rafPausedRef.current = false;
|
|
361
380
|
const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY);
|
|
362
381
|
|
|
363
|
-
if (g.kind === "drag" &&
|
|
364
|
-
|
|
365
|
-
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
|
366
|
-
resumeGsapTimelines(sel.element);
|
|
382
|
+
if (g.kind === "drag" && isTap(g, e)) {
|
|
383
|
+
if (g.pathOffsetMember) restoreManualOffsetDragMembers([g.pathOffsetMember]);
|
|
367
384
|
if (box) {
|
|
368
385
|
box.style.left = `${g.originLeft}px`;
|
|
369
386
|
box.style.top = `${g.originTop}px`;
|
|
@@ -525,9 +542,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
525
542
|
const g = opts.gestureRef.current;
|
|
526
543
|
const sel = g?.selection ?? selectionRef.current;
|
|
527
544
|
if (g?.mode === "path-offset" && sel) {
|
|
528
|
-
|
|
529
|
-
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
|
530
|
-
resumeGsapTimelines(sel.element);
|
|
545
|
+
if (g.pathOffsetMember) restoreManualOffsetDragMembers([g.pathOffsetMember]);
|
|
531
546
|
restoreGestureOverlayRect(g);
|
|
532
547
|
}
|
|
533
548
|
if (g?.mode === "box-size" && sel) {
|
|
@@ -298,6 +298,27 @@ describe("Popover", () => {
|
|
|
298
298
|
expect(document.activeElement).toBe(input);
|
|
299
299
|
expect(document.querySelector('[role="menu"]')).toBeNull();
|
|
300
300
|
});
|
|
301
|
+
|
|
302
|
+
it("points at its trigger only when asked", async () => {
|
|
303
|
+
const renderOpen = (arrow: boolean) => {
|
|
304
|
+
render(
|
|
305
|
+
<Popover trigger={<button data-testid="trigger">Speed</button>} arrow={arrow} defaultOpen>
|
|
306
|
+
<span>1x</span>
|
|
307
|
+
</Popover>,
|
|
308
|
+
);
|
|
309
|
+
};
|
|
310
|
+
renderOpen(false);
|
|
311
|
+
await settle();
|
|
312
|
+
expect(one("[role=dialog]", "popup").querySelector("svg")).toBeNull();
|
|
313
|
+
act(() => mounted?.root.unmount());
|
|
314
|
+
mounted?.host.remove();
|
|
315
|
+
mounted = null;
|
|
316
|
+
|
|
317
|
+
renderOpen(true);
|
|
318
|
+
await settle();
|
|
319
|
+
const arrow = one("[role=dialog] svg", "arrow").parentElement!;
|
|
320
|
+
expect(arrow.getAttribute("data-side")).toBe("bottom");
|
|
321
|
+
});
|
|
301
322
|
});
|
|
302
323
|
|
|
303
324
|
describe("hotkey classification", () => {
|
|
@@ -14,6 +14,15 @@ type PortalContainer = ComponentPropsWithoutRef<typeof BasePopover.Portal>["cont
|
|
|
14
14
|
const SIDE_OFFSET = 6;
|
|
15
15
|
const VIEWPORT_MARGIN = 8;
|
|
16
16
|
|
|
17
|
+
/** The arrow's 14x8 box overlaps the popup's border by 1px, so its fill hides the border where it meets the popup. */
|
|
18
|
+
const ARROW_PX = 7;
|
|
19
|
+
const ARROW_CORNER_CLEARANCE = 10;
|
|
20
|
+
const arrowPlace = cn(
|
|
21
|
+
"data-[side=bottom]:-top-[7px] data-[side=top]:-bottom-[7px] data-[side=top]:rotate-180",
|
|
22
|
+
"data-[side=left]:-right-[10px] data-[side=left]:rotate-90",
|
|
23
|
+
"data-[side=right]:-left-[10px] data-[side=right]:-rotate-90",
|
|
24
|
+
);
|
|
25
|
+
|
|
17
26
|
interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof BasePopover.Root>, "children"> {
|
|
18
27
|
/** A single element. It becomes the trigger; no wrapper is added around it. */
|
|
19
28
|
trigger: ReactElement;
|
|
@@ -22,6 +31,8 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof BasePopover.
|
|
|
22
31
|
side?: "top" | "bottom" | "left" | "right";
|
|
23
32
|
align?: "start" | "center" | "end";
|
|
24
33
|
sideOffset?: number;
|
|
34
|
+
/** Points the popup at its trigger. Off by default; the default gap from the trigger grows by the arrow's height. */
|
|
35
|
+
arrow?: boolean;
|
|
25
36
|
/** Portal target. Pass the shadow root when the trigger lives in one. */
|
|
26
37
|
container?: PortalContainer;
|
|
27
38
|
/** Names the popup for assistive tech. */
|
|
@@ -40,7 +51,8 @@ export function Popover({
|
|
|
40
51
|
children,
|
|
41
52
|
side = "bottom",
|
|
42
53
|
align = "center",
|
|
43
|
-
|
|
54
|
+
arrow = false,
|
|
55
|
+
sideOffset = arrow ? SIDE_OFFSET + ARROW_PX : SIDE_OFFSET,
|
|
44
56
|
container,
|
|
45
57
|
className,
|
|
46
58
|
initialFocus,
|
|
@@ -57,6 +69,7 @@ export function Popover({
|
|
|
57
69
|
align={align}
|
|
58
70
|
sideOffset={sideOffset}
|
|
59
71
|
collisionPadding={VIEWPORT_MARGIN}
|
|
72
|
+
arrowPadding={ARROW_CORNER_CLEARANCE}
|
|
60
73
|
className="z-200"
|
|
61
74
|
>
|
|
62
75
|
<BasePopover.Popup
|
|
@@ -65,6 +78,13 @@ export function Popover({
|
|
|
65
78
|
data-preview-state={previewState}
|
|
66
79
|
className={cn(popupSurface, "p-3 text-step-11 text-text-1 shadow-popover", className)}
|
|
67
80
|
>
|
|
81
|
+
{arrow && (
|
|
82
|
+
<BasePopover.Arrow className={arrowPlace}>
|
|
83
|
+
<svg width="14" height="8" viewBox="0 0 14 8" aria-hidden="true" className="block">
|
|
84
|
+
<path d="M0 8 L7 1 L14 8" className="fill-surface stroke-border-input" />
|
|
85
|
+
</svg>
|
|
86
|
+
</BasePopover.Arrow>
|
|
87
|
+
)}
|
|
68
88
|
{children}
|
|
69
89
|
</BasePopover.Popup>
|
|
70
90
|
</BasePopover.Positioner>
|
|
@@ -28,6 +28,7 @@ export function announceTimelineSelection(
|
|
|
28
28
|
deps: TimelineMirrorDeps,
|
|
29
29
|
group: DomEditSelection[],
|
|
30
30
|
primary: DomEditSelection | null,
|
|
31
|
+
replaceSet = false,
|
|
31
32
|
): void {
|
|
32
33
|
const {
|
|
33
34
|
timelineElements,
|
|
@@ -72,10 +73,10 @@ export function announceTimelineSelection(
|
|
|
72
73
|
}
|
|
73
74
|
return;
|
|
74
75
|
}
|
|
75
|
-
// A late async primary
|
|
76
|
-
// group
|
|
77
|
-
// first; otherwise `preserveSet` clears
|
|
78
|
-
if (group.length > 1 || !getTimelineSelectionSet().has(timelineAnchor)) {
|
|
76
|
+
// A late async primary inside the live set keeps the group. A click outside it, or a
|
|
77
|
+
// group the caller states in full (`replaceSet`: shift toggle, marquee, timeline sync), is published
|
|
78
|
+
// first; otherwise `preserveSet` clears or keeps a stale set and sync undoes the canvas.
|
|
79
|
+
if (replaceSet || group.length > 1 || !getTimelineSelectionSet().has(timelineAnchor)) {
|
|
79
80
|
setTimelineSelectionSet(publishedMembers);
|
|
80
81
|
}
|
|
81
82
|
setSelectedTimelineElementId(timelineAnchor, { preserveSet: true });
|