@hyperframes/studio 0.7.102 → 0.7.104
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-BTnkvhf3.js → hyperframes-player-Bj2HAymq.js} +1 -1
- package/dist/assets/{index-CnrSn9Ua.js → index-8UzO6PkK.js} +1 -1
- package/dist/assets/{index-DN614u1l.js → index-DzfDa1ci.js} +212 -212
- package/dist/assets/{index-B4MocVKM.js → index-TDfRaO1Y.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +779 -294
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +6 -2
- package/src/captions/hooks/useCaptionSync.ts +2 -1
- package/src/components/editor/DomEditOverlay.test.ts +89 -2
- package/src/components/editor/DomEditOverlay.tsx +40 -23
- package/src/components/editor/domEditOverlayGeometry.test.ts +94 -0
- package/src/components/editor/domEditOverlayGeometry.ts +59 -2
- package/src/components/editor/domEditOverlayGestures.ts +42 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +17 -0
- package/src/components/editor/groupDragMove.ts +110 -0
- package/src/components/editor/groupDropKeepsSelection.test.ts +70 -0
- package/src/components/editor/manualEditsDom.ts +15 -13
- package/src/components/editor/manualOffsetDrag.test.ts +92 -5
- package/src/components/editor/manualOffsetDrag.ts +22 -17
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +64 -0
- package/src/components/editor/snapEngageTravel.test.ts +65 -0
- package/src/components/editor/snapEngine.ts +21 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +40 -53
- package/src/components/editor/useDomEditOverlayRects.ts +4 -2
- package/src/hooks/domSelectionTimelineMirror.ts +73 -0
- package/src/hooks/gestureTransaction.test.ts +7 -12
- package/src/hooks/gestureTransaction.ts +7 -20
- package/src/hooks/gsapRuntimePatch.test.ts +43 -0
- package/src/hooks/gsapRuntimePatch.ts +11 -1
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +20 -0
- package/src/hooks/keyframeCacheAstLoad.test.ts +108 -0
- package/src/hooks/keyframeCacheAstLoad.ts +29 -1
- package/src/hooks/timelineTimingSync.ts +4 -1
- package/src/hooks/useDomEditCommits.ts +2 -1
- package/src/hooks/useDomEditCommitsHelpers.ts +2 -1
- package/src/hooks/useDomEditPreviewSync.ts +23 -0
- package/src/hooks/useDomEditSession.test.tsx +43 -62
- package/src/hooks/useDomEditSession.ts +10 -2
- package/src/hooks/useDomEditWiring.ts +6 -0
- package/src/hooks/useDomSelection.test.ts +133 -3
- package/src/hooks/useDomSelection.ts +67 -40
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +6 -2
- package/src/hooks/useElementLifecycleOps.ts +2 -1
- package/src/hooks/useExternalFileChangeCoordinator.ts +16 -3
- package/src/hooks/useFileManager.ts +2 -8
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAnimationFetchFallback.ts +6 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +36 -0
- package/src/hooks/useGsapAwareEditing.ts +88 -15
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -5
- package/src/hooks/useGsapScriptCommits.test.tsx +193 -7
- package/src/hooks/useGsapScriptCommits.ts +52 -15
- package/src/hooks/useStudioUrlState.ts +130 -7
- package/src/hooks/useTimelineEditing.ts +2 -1
- package/src/hooks/useTimelineSelectionPreviewSync.ts +13 -0
- package/src/player/hooks/useTimelinePlayer.ts +2 -0
- package/src/utils/dragDebug.ts +98 -0
- package/src/utils/razorSplitTransaction.test.ts +15 -3
- package/src/utils/razorSplitTransaction.ts +2 -0
- package/src/utils/reloadDebug.ts +10 -0
- package/src/utils/resizeDebug.ts +19 -29
- package/src/utils/selectDebug.ts +9 -0
- package/src/utils/studioDebug.test.ts +32 -0
- package/src/utils/studioDebug.ts +30 -0
- package/src/utils/studioFileVersion.ts +16 -1
- package/src/utils/studioUrlState.test.ts +163 -1
- package/src/utils/studioUrlState.ts +60 -3
- package/src/utils/timelineCompositionInsert.ts +2 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
|
|
2
|
+
import {
|
|
3
|
+
resolveEquidistanceGuides,
|
|
4
|
+
resolveSnapAdjustment,
|
|
5
|
+
snapEngagedForTravel,
|
|
6
|
+
SNAP_THRESHOLD_PX,
|
|
7
|
+
} from "./snapEngine";
|
|
8
|
+
import { applyManualOffsetDragDraft } from "./manualOffsetDrag";
|
|
9
|
+
import type { GroupGestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
|
|
10
|
+
import type { GroupOverlayItem } from "./domEditOverlayGeometry";
|
|
11
|
+
import {
|
|
12
|
+
findNonRigidMembers,
|
|
13
|
+
logDrag,
|
|
14
|
+
logDragMove,
|
|
15
|
+
readDragPositions,
|
|
16
|
+
} from "../../utils/dragDebug";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One frame of a group drag, kept out of onPointerMove — which already handles
|
|
20
|
+
* four gesture kinds and reads better without this one's snapping arithmetic.
|
|
21
|
+
* The previous frame's positions live in the closure so the rigidity check below
|
|
22
|
+
* compares against the frame before, not against whatever was last sampled.
|
|
23
|
+
*/
|
|
24
|
+
export function createGroupDragMover(
|
|
25
|
+
opts: UseDomEditOverlayGesturesOptions,
|
|
26
|
+
setDraftGroupOverlayItems: (items: GroupOverlayItem[]) => void,
|
|
27
|
+
) {
|
|
28
|
+
let lastGroupPositions: Record<string, string> = {};
|
|
29
|
+
let lastGesture: GroupGestureState | null = null;
|
|
30
|
+
|
|
31
|
+
/** Snap the group's delta to nearby edges, publishing the guides drawn for it. */
|
|
32
|
+
// fallow-ignore-next-line complexity
|
|
33
|
+
const snapGroupDelta = (
|
|
34
|
+
groupG: GroupGestureState,
|
|
35
|
+
e: React.PointerEvent<HTMLDivElement>,
|
|
36
|
+
proposed: { dx: number; dy: number },
|
|
37
|
+
) => {
|
|
38
|
+
const sc = groupG.snapContext;
|
|
39
|
+
if (!sc?.snapEnabled || sc.targets.length === 0) return proposed;
|
|
40
|
+
if (!snapEngagedForTravel(proposed.dx, proposed.dy)) {
|
|
41
|
+
opts.snapGuidesRef.current = null;
|
|
42
|
+
return proposed;
|
|
43
|
+
}
|
|
44
|
+
const groupBounds = resolveDomEditGroupOverlayRect(groupG.originItems.map((i) => i.rect));
|
|
45
|
+
if (!groupBounds) return proposed;
|
|
46
|
+
const allTargets = sc.compositionTarget ? [...sc.targets, sc.compositionTarget] : sc.targets;
|
|
47
|
+
const snap = resolveSnapAdjustment({
|
|
48
|
+
movingRect: groupBounds,
|
|
49
|
+
proposedDx: proposed.dx,
|
|
50
|
+
proposedDy: proposed.dy,
|
|
51
|
+
targets: allTargets,
|
|
52
|
+
gridEdges: sc.gridEdges ?? undefined,
|
|
53
|
+
threshold: SNAP_THRESHOLD_PX,
|
|
54
|
+
disabled: e.altKey,
|
|
55
|
+
});
|
|
56
|
+
const movingRect = {
|
|
57
|
+
...groupBounds,
|
|
58
|
+
left: groupBounds.left + snap.dx,
|
|
59
|
+
top: groupBounds.top + snap.dy,
|
|
60
|
+
};
|
|
61
|
+
const spacingGuides = e.altKey
|
|
62
|
+
? []
|
|
63
|
+
: resolveEquidistanceGuides({
|
|
64
|
+
movingRect,
|
|
65
|
+
targets: allTargets,
|
|
66
|
+
threshold: SNAP_THRESHOLD_PX,
|
|
67
|
+
});
|
|
68
|
+
opts.snapGuidesRef.current = { guides: snap.guides, spacingGuides };
|
|
69
|
+
return { dx: snap.dx, dy: snap.dy };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** One frame of a group drag: snap the delta, redraw the boxes, move every member. */
|
|
73
|
+
const moveGroupDrag = (groupG: GroupGestureState, e: React.PointerEvent<HTMLDivElement>) => {
|
|
74
|
+
if (groupG !== lastGesture) {
|
|
75
|
+
lastGesture = groupG;
|
|
76
|
+
lastGroupPositions = {};
|
|
77
|
+
}
|
|
78
|
+
const { dx, dy } = snapGroupDelta(groupG, e, {
|
|
79
|
+
dx: e.clientX - groupG.startX,
|
|
80
|
+
dy: e.clientY - groupG.startY,
|
|
81
|
+
});
|
|
82
|
+
groupG.lastSnappedDx = dx;
|
|
83
|
+
groupG.lastSnappedDy = dy;
|
|
84
|
+
|
|
85
|
+
setDraftGroupOverlayItems(
|
|
86
|
+
groupG.originItems.map((i) => ({
|
|
87
|
+
...i,
|
|
88
|
+
rect: { ...i.rect, left: i.rect.left + dx, top: i.rect.top + dy },
|
|
89
|
+
})),
|
|
90
|
+
);
|
|
91
|
+
const offsets: Record<string, string> = {};
|
|
92
|
+
for (const m of groupG.members) {
|
|
93
|
+
const n = applyManualOffsetDragDraft(m, dx, dy);
|
|
94
|
+
offsets[m.key] = `${Math.round(n.x)},${Math.round(n.y)}`;
|
|
95
|
+
}
|
|
96
|
+
const at = readDragPositions(groupG.members);
|
|
97
|
+
const px = Math.round(e.clientX - groupG.startX);
|
|
98
|
+
const py = Math.round(e.clientY - groupG.startY);
|
|
99
|
+
// A member breaking away IS the fault, so it reports on the frame it happens;
|
|
100
|
+
// the throttled line below would step over it. A gap between pointer and
|
|
101
|
+
// applied there is snapping pulling the group off the cursor.
|
|
102
|
+
const trace = { pointer: `${px},${py}`, applied: `${Math.round(dx)},${Math.round(dy)}`, at };
|
|
103
|
+
const drift = findNonRigidMembers(lastGroupPositions, at);
|
|
104
|
+
if (drift.length > 0) logDrag("drift", { ...trace, drift });
|
|
105
|
+
lastGroupPositions = at;
|
|
106
|
+
logDragMove({ ...trace, offsets });
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return moveGroupDrag;
|
|
110
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
4
|
+
import type { GroupGestureState } from "./domEditOverlayGestures";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A group drag ended by deselecting the group it had just moved.
|
|
8
|
+
*
|
|
9
|
+
* Every pointerup trails a click. The gesture ref is cleared before the commit,
|
|
10
|
+
* so by the time that click arrives the box no longer looks busy and it reaches
|
|
11
|
+
* the canvas as an ordinary click — landing in the gap between the members,
|
|
12
|
+
* resolving to nothing, and clearing the selection. Captured live as a
|
|
13
|
+
* `[hf-select] clear` with `hadGroup: 3` two milliseconds after the drop.
|
|
14
|
+
*
|
|
15
|
+
* The under-threshold path already ate that click; the committed path has to as
|
|
16
|
+
* well, and the flag is set before the two diverge so neither can forget.
|
|
17
|
+
*/
|
|
18
|
+
describe("dropping a dragged group eats the click that follows", () => {
|
|
19
|
+
function harness(travel: { dx: number; dy: number }) {
|
|
20
|
+
const suppressNextBoxClickRef = { current: false };
|
|
21
|
+
const groupGestureRef = {
|
|
22
|
+
current: {
|
|
23
|
+
startX: 0,
|
|
24
|
+
startY: 0,
|
|
25
|
+
originItems: [],
|
|
26
|
+
members: [],
|
|
27
|
+
} as unknown as GroupGestureState,
|
|
28
|
+
};
|
|
29
|
+
const handlers = createDomEditOverlayGestureHandlers({
|
|
30
|
+
overlayRef: { current: null },
|
|
31
|
+
iframeRef: { current: null },
|
|
32
|
+
boxRef: { current: null },
|
|
33
|
+
selectionRef: { current: null },
|
|
34
|
+
hoverSelectionRef: { current: null },
|
|
35
|
+
overlayRectRef: { current: null },
|
|
36
|
+
groupOverlayItemsRef: { current: [] },
|
|
37
|
+
gestureRef: { current: null },
|
|
38
|
+
groupGestureRef,
|
|
39
|
+
blockedMoveRef: { current: null },
|
|
40
|
+
rafPausedRef: { current: false },
|
|
41
|
+
suppressNextBoxClickRef,
|
|
42
|
+
setOverlayRect: vi.fn(),
|
|
43
|
+
setGroupOverlayItems: vi.fn(),
|
|
44
|
+
onBlockedMoveRef: { current: vi.fn() },
|
|
45
|
+
onManualDragStartRef: { current: vi.fn() },
|
|
46
|
+
onPathOffsetCommitRef: { current: vi.fn() },
|
|
47
|
+
onGroupPathOffsetCommitRef: { current: vi.fn() },
|
|
48
|
+
onBoxSizeCommitRef: { current: vi.fn() },
|
|
49
|
+
onRotationCommitRef: { current: vi.fn() },
|
|
50
|
+
onCanvasPointerMoveRef: { current: vi.fn() },
|
|
51
|
+
onCanvasMouseDown: vi.fn(),
|
|
52
|
+
snapGuidesRef: { current: null },
|
|
53
|
+
} as never);
|
|
54
|
+
|
|
55
|
+
handlers.onPointerUp({
|
|
56
|
+
clientX: travel.dx,
|
|
57
|
+
clientY: travel.dy,
|
|
58
|
+
currentTarget: { releasePointerCapture: vi.fn() },
|
|
59
|
+
} as never);
|
|
60
|
+
return suppressNextBoxClickRef;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
it("eats the click after a drag that moved", () => {
|
|
64
|
+
expect(harness({ dx: 120, dy: 60 }).current).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("still eats it after a press that never travelled", () => {
|
|
68
|
+
expect(harness({ dx: 1, dy: 0 }).current).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -221,6 +221,7 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean {
|
|
|
221
221
|
return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// fallow-ignore-next-line complexity
|
|
224
225
|
function stripGsapTranslateFromTransform(element: HTMLElement): void {
|
|
225
226
|
if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
|
|
226
227
|
const transform = element.style.getPropertyValue("transform");
|
|
@@ -256,6 +257,7 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
|
|
|
256
257
|
// and push the offset straight into GSAP's x/y via gsap.set; the var() offset is
|
|
257
258
|
// still persisted (buildPathOffsetPatches), and GSAP re-reads it at init on
|
|
258
259
|
// reload. Returns true when handled as GSAP (caller must skip the CSS path).
|
|
260
|
+
// fallow-ignore-next-line complexity
|
|
259
261
|
function applyStudioPathOffsetViaGsap(
|
|
260
262
|
element: HTMLElement,
|
|
261
263
|
offset: { x: number; y: number },
|
|
@@ -553,26 +555,26 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
|
|
|
553
555
|
|
|
554
556
|
function reapplyPathOffsets(doc: Document): void {
|
|
555
557
|
for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) {
|
|
556
|
-
|
|
558
|
+
// Unlike size below, the offset channels COMPOSE — applying both doubles the move.
|
|
559
|
+
if (gsapAnimatesProperty(el, "x", "y")) continue;
|
|
557
560
|
const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP);
|
|
558
561
|
const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP);
|
|
559
|
-
if (
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
el,
|
|
563
|
-
{
|
|
564
|
-
x: Number.parseFloat(x) || 0,
|
|
565
|
-
y: Number.parseFloat(y) || 0,
|
|
566
|
-
},
|
|
567
|
-
{ updateBase: false },
|
|
568
|
-
);
|
|
569
|
-
}
|
|
562
|
+
if (!x && !y) continue;
|
|
563
|
+
const offset = { x: Number.parseFloat(x) || 0, y: Number.parseFloat(y) || 0 };
|
|
564
|
+
applyStudioPathOffset(el, offset, { updateBase: false });
|
|
570
565
|
}
|
|
571
566
|
}
|
|
572
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Put the studio's committed size back after a seek, GSAP-sized elements included.
|
|
570
|
+
* Size does not compose the way the offset above does: both channels write width
|
|
571
|
+
* and height, so the later write wins on the same number. Standing aside meant
|
|
572
|
+
* nothing held the size while a soft reload reverted the old timeline (GSAP hands
|
|
573
|
+
* back each tween's recorded starting width), so the element sat at its stylesheet
|
|
574
|
+
* size until the new one rendered — the jump after a resize.
|
|
575
|
+
*/
|
|
573
576
|
function reapplyBoxSizes(doc: Document): void {
|
|
574
577
|
for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) {
|
|
575
|
-
if (gsapAnimatesProperty(el, "width", "height")) continue;
|
|
576
578
|
const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP));
|
|
577
579
|
const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP));
|
|
578
580
|
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
|
|
@@ -88,6 +88,41 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
|
|
|
88
88
|
expect(element.style.getPropertyValue("translate")).toBe("");
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The element that has never been offset is the common case, and it used to skip
|
|
93
|
+
* the measurement and assume the canvas zoom was the whole story. Any transform
|
|
94
|
+
* above the element makes that assumption wrong: the mirrored parent here sends a
|
|
95
|
+
* rightward drag left, so the overlay followed the pointer while the element went
|
|
96
|
+
* the other way, and only on drop did the overlay jump to where the element really
|
|
97
|
+
* was. The fixture mirrors x and scales both axes by 1.2, as a `rotationY: 180`
|
|
98
|
+
* card at `scale: 1.2` does.
|
|
99
|
+
*/
|
|
100
|
+
it("measures a mirrored parent even when the element carries no offset yet", () => {
|
|
101
|
+
const window = new Window();
|
|
102
|
+
const element = window.document.createElement("div");
|
|
103
|
+
window.document.body.append(element);
|
|
104
|
+
|
|
105
|
+
element.getBoundingClientRect = () => {
|
|
106
|
+
const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
|
|
107
|
+
const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
|
|
108
|
+
return new window.DOMRect(100 - 1.2 * offsetX, 200 + 1.2 * offsetY, 40, 20);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
|
|
112
|
+
if (!measured.ok) throw new Error(measured.reason);
|
|
113
|
+
|
|
114
|
+
// Dragging one screen px right must move the element one screen px right, which
|
|
115
|
+
// on a mirrored parent means writing a NEGATIVE offset.
|
|
116
|
+
const offset = resolveManualOffsetForPointerDelta({
|
|
117
|
+
initialOffset: { x: 0, y: 0 },
|
|
118
|
+
screenToOffset: measured.matrix,
|
|
119
|
+
dx: 60,
|
|
120
|
+
dy: 60,
|
|
121
|
+
});
|
|
122
|
+
expect(offset.x).toBeCloseTo(-50, 6);
|
|
123
|
+
expect(offset.y).toBeCloseTo(50, 6);
|
|
124
|
+
});
|
|
125
|
+
|
|
91
126
|
it("measures movement in parent viewport pixels when the element is inside a scaled iframe", () => {
|
|
92
127
|
const window = new Window();
|
|
93
128
|
const iframe = window.document.createElement("iframe");
|
|
@@ -133,7 +168,12 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
|
|
|
133
168
|
expect(nextOffset).toEqual({ x: 100, y: 50 });
|
|
134
169
|
});
|
|
135
170
|
|
|
136
|
-
|
|
171
|
+
// Carrying no path offset used to be taken as permission to assume the response
|
|
172
|
+
// instead of measuring it. It is not a signal about the transforms above the
|
|
173
|
+
// element, so it no longer changes the answer: an element that does not move is
|
|
174
|
+
// unmeasurable either way, and the caller falls back rather than being handed a
|
|
175
|
+
// matrix that was never checked.
|
|
176
|
+
it("does not treat a missing path offset as a measurable response", () => {
|
|
137
177
|
const window = new Window();
|
|
138
178
|
const element = window.document.createElement("div");
|
|
139
179
|
window.document.body.append(element);
|
|
@@ -141,10 +181,7 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
|
|
|
141
181
|
|
|
142
182
|
const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
|
|
143
183
|
|
|
144
|
-
expect(measured.ok).toBe(
|
|
145
|
-
if (measured.ok) {
|
|
146
|
-
expectMatrixClose(measured.matrix, { a: 1, b: 0, c: 0, d: 1 });
|
|
147
|
-
}
|
|
184
|
+
expect(measured.ok).toBe(false);
|
|
148
185
|
});
|
|
149
186
|
|
|
150
187
|
it("rejects path-offset elements whose movement response cannot be measured", () => {
|
|
@@ -160,6 +197,56 @@ describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
|
|
|
160
197
|
});
|
|
161
198
|
});
|
|
162
199
|
|
|
200
|
+
/**
|
|
201
|
+
* A group drag is rigid: every member is handed the SAME pointer delta and must
|
|
202
|
+
* travel the same distance on screen, or the group visibly comes apart mid-drag.
|
|
203
|
+
* Members do not share a mapping though — each measures its own, because each can
|
|
204
|
+
* sit under different ancestor transforms. A member whose movement cannot be
|
|
205
|
+
* measured falls back to a guess, and this pins what that guess costs the group.
|
|
206
|
+
*/
|
|
207
|
+
describe("group drag stays rigid", () => {
|
|
208
|
+
function member(key: string, response: number, measurable: boolean) {
|
|
209
|
+
const window = new Window();
|
|
210
|
+
const element = window.document.createElement("div");
|
|
211
|
+
window.document.body.append(element);
|
|
212
|
+
element.getBoundingClientRect = () => {
|
|
213
|
+
const ox = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
|
|
214
|
+
const oy = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
|
|
215
|
+
const move = measurable ? response : 0;
|
|
216
|
+
return new window.DOMRect(100 + move * ox, 200 + move * oy, 40, 20);
|
|
217
|
+
};
|
|
218
|
+
const result = createManualOffsetDragMember({
|
|
219
|
+
key,
|
|
220
|
+
selection: { element } as never,
|
|
221
|
+
element,
|
|
222
|
+
rect: { left: 100, top: 200, width: 40, height: 20, editScaleX: 1, editScaleY: 1 },
|
|
223
|
+
});
|
|
224
|
+
if (!result.ok) throw new Error(result.reason);
|
|
225
|
+
return { member: result.member, response };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Screen distance this member travels for a pointer delta of `d`. */
|
|
229
|
+
function screenTravel(entry: ReturnType<typeof member>, d: number): number {
|
|
230
|
+
const offset = resolveManualOffsetForPointerDelta({
|
|
231
|
+
initialOffset: entry.member.initialOffset,
|
|
232
|
+
screenToOffset: entry.member.screenToOffset,
|
|
233
|
+
dx: d,
|
|
234
|
+
dy: 0,
|
|
235
|
+
});
|
|
236
|
+
return offset.x * entry.response;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
it("moves every measurable member the same distance for one pointer delta", () => {
|
|
240
|
+
// Two members under different ancestor scales: one 1:1, one inside a half-scale
|
|
241
|
+
// parent. Different offsets, identical screen travel — that is what rigid means.
|
|
242
|
+
const a = member("a", 1, true);
|
|
243
|
+
const b = member("b", 0.5, true);
|
|
244
|
+
|
|
245
|
+
expect(screenTravel(a, 60)).toBeCloseTo(60, 6);
|
|
246
|
+
expect(screenTravel(b, 60)).toBeCloseTo(60, 6);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
163
250
|
describe("createManualOffsetDragMember uses raw CSS var offset", () => {
|
|
164
251
|
it("ignores GSAP transform — initialOffset comes from CSS vars only", () => {
|
|
165
252
|
const window = new Window();
|
|
@@ -213,9 +213,9 @@ export function applyManualOffsetDragMatrix(matrix: ManualOffsetDragMatrix, poin
|
|
|
213
213
|
* The perspective w-divisor (matrix3d m44) of the element's current transform.
|
|
214
214
|
* For a plain `translateZ(z)` under `perspective(p)`, m44 = (p - z) / p, so the
|
|
215
215
|
* element renders 1/m44× larger and a translate of `d` composition px moves
|
|
216
|
-
* `d / m44` px on screen. Returns 1 for 2D transforms (no foreshortening).
|
|
217
|
-
*
|
|
218
|
-
*
|
|
216
|
+
* `d / m44` px on screen. Returns 1 for 2D transforms (no foreshortening). Only
|
|
217
|
+
* the unmeasurable-element fallback needs this — the measured path reads the
|
|
218
|
+
* foreshortening off the element's real movement along with everything else.
|
|
219
219
|
*/
|
|
220
220
|
function readTransformWDivisor(element: HTMLElement): number {
|
|
221
221
|
const t = element.ownerDocument.defaultView?.getComputedStyle(element).transform;
|
|
@@ -225,25 +225,25 @@ function readTransformWDivisor(element: HTMLElement): number {
|
|
|
225
225
|
return Number.isFinite(w) && w > 0 ? w : 1;
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/**
|
|
229
|
+
* How far the element actually moves on screen per unit of drag offset, measured
|
|
230
|
+
* rather than assumed.
|
|
231
|
+
*
|
|
232
|
+
* The offset is written on the element, but what reaches the screen is that offset
|
|
233
|
+
* put through every transform above it. A parent carrying a rotation, a mirror, a
|
|
234
|
+
* scale or a perspective changes both the direction and the distance — a card at
|
|
235
|
+
* `rotationY: 180` sends a rightward drag left. Guessing this from the canvas zoom
|
|
236
|
+
* alone was wrong for every such element: the overlay tracked the pointer while the
|
|
237
|
+
* element went somewhere else, and the overlay only jumped to the truth on drop,
|
|
238
|
+
* when it re-measured. Moving the element and watching where it lands costs three
|
|
239
|
+
* layout reads once per gesture and is right for any transform, including ones no
|
|
240
|
+
* closed-form fast path would cover.
|
|
241
|
+
*/
|
|
228
242
|
export function measureManualOffsetDragScreenToOffsetMatrix(
|
|
229
243
|
element: HTMLElement,
|
|
230
244
|
initialOffset: { x: number; y: number },
|
|
231
245
|
options: { probeSize?: number; scaleX?: number; scaleY?: number } = {},
|
|
232
246
|
): { ok: true; matrix: ManualOffsetDragMatrix } | { ok: false; reason: string } {
|
|
233
|
-
if (
|
|
234
|
-
!element.hasAttribute("data-hf-studio-path-offset") &&
|
|
235
|
-
initialOffset.x === 0 &&
|
|
236
|
-
initialOffset.y === 0
|
|
237
|
-
) {
|
|
238
|
-
const sx = options.scaleX || 1;
|
|
239
|
-
const sy = options.scaleY || 1;
|
|
240
|
-
// Fold in the perspective foreshortening: a depth element (z≠0) moves
|
|
241
|
-
// 1/m44× faster on screen than its flat scale implies, so the screen→offset
|
|
242
|
-
// matrix must scale by m44 or the element outruns the pointer/overlay.
|
|
243
|
-
const w = readTransformWDivisor(element);
|
|
244
|
-
return { ok: true, matrix: { a: w / sx, b: 0, c: 0, d: w / sy } };
|
|
245
|
-
}
|
|
246
|
-
|
|
247
247
|
const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX;
|
|
248
248
|
if (!Number.isFinite(probeSize) || probeSize <= 0) {
|
|
249
249
|
return { ok: false, reason: "Invalid movement probe size." };
|
|
@@ -325,6 +325,8 @@ export function resolveManualOffsetForPointerDelta(input: {
|
|
|
325
325
|
};
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
// Pre-existing complexity — surfaced by this branch touching the file, not by new logic.
|
|
329
|
+
// fallow-ignore-next-line complexity
|
|
328
330
|
export function createManualOffsetDragMember(input: {
|
|
329
331
|
key: string;
|
|
330
332
|
selection: DomEditSelection;
|
|
@@ -515,6 +517,7 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
|
|
|
515
517
|
endStudioManualEditGesture(member.element, member.gestureToken);
|
|
516
518
|
}
|
|
517
519
|
|
|
520
|
+
/** Roll back a FAILED drag to the exact gesture-start state. */
|
|
518
521
|
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
|
|
519
522
|
for (const member of members) {
|
|
520
523
|
restoreManualOffsetDragMember(member);
|
|
@@ -522,6 +525,7 @@ export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]
|
|
|
522
525
|
}
|
|
523
526
|
}
|
|
524
527
|
|
|
528
|
+
/** Teardown after a COMMITTED drag. */
|
|
525
529
|
export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
|
|
526
530
|
for (const member of members) {
|
|
527
531
|
endStudioManualEditGesture(member.element, member.gestureToken);
|
|
@@ -550,6 +554,7 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
|
|
|
550
554
|
}
|
|
551
555
|
}
|
|
552
556
|
|
|
557
|
+
/** Shared timeline teardown for either the committed or restored path. */
|
|
553
558
|
export function resumeGsapTimelines(element: HTMLElement): void {
|
|
554
559
|
const ids = element.getAttribute("data-hf-drag-paused-timelines");
|
|
555
560
|
element.removeAttribute("data-hf-drag-paused-timelines");
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { reapplyPositionEditsAfterSeek } from "./manualEditsDom";
|
|
4
|
+
import { STUDIO_BOX_SIZE_ATTR, STUDIO_HEIGHT_PROP, STUDIO_WIDTH_PROP } from "./manualEditsTypes";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A resize commit hands the size to a GSAP tween, and a soft reload reverts the
|
|
8
|
+
* old timeline before the new one renders — GSAP restores each tween's recorded
|
|
9
|
+
* starting width on the way out. Nothing else held the size across that window,
|
|
10
|
+
* so the element sat at its stylesheet size for a few hundred milliseconds: the
|
|
11
|
+
* jump after a resize. Worse, the next gesture then started from a box that
|
|
12
|
+
* disagreed with the studio's own vars and snapped on its first move.
|
|
13
|
+
*
|
|
14
|
+
* The seek reapply is what closes the window, and it used to stand aside for
|
|
15
|
+
* exactly the elements that need it — the ones GSAP sizes.
|
|
16
|
+
*/
|
|
17
|
+
describe("box size survives a seek while GSAP owns the size", () => {
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
document.body.innerHTML = "";
|
|
20
|
+
Reflect.deleteProperty(window, "__timelines");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function cardSizedByGsap(): HTMLElement {
|
|
24
|
+
const el = document.createElement("div");
|
|
25
|
+
el.id = "card";
|
|
26
|
+
el.setAttribute(STUDIO_BOX_SIZE_ATTR, "true");
|
|
27
|
+
el.style.setProperty(STUDIO_WIDTH_PROP, "305px");
|
|
28
|
+
el.style.setProperty(STUDIO_HEIGHT_PROP, "202px");
|
|
29
|
+
document.body.append(el);
|
|
30
|
+
// A timeline that animates this element's width/height, as the committed
|
|
31
|
+
// resize leaves behind.
|
|
32
|
+
Object.assign(window, {
|
|
33
|
+
__timelines: {
|
|
34
|
+
main: {
|
|
35
|
+
getChildren: () => [{ targets: () => [el], vars: { width: 305, height: 202 } }],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
return el;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
it("re-applies the committed size after the timeline gave it back", () => {
|
|
43
|
+
const el = cardSizedByGsap();
|
|
44
|
+
// The revert: GSAP puts the tween's recorded starting size back.
|
|
45
|
+
el.style.width = "395px";
|
|
46
|
+
el.style.height = "261px";
|
|
47
|
+
|
|
48
|
+
reapplyPositionEditsAfterSeek(document);
|
|
49
|
+
|
|
50
|
+
expect(el.style.width).toBe("305px");
|
|
51
|
+
expect(el.style.height).toBe("202px");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("leaves an element alone once its studio size is cleared", () => {
|
|
55
|
+
const el = cardSizedByGsap();
|
|
56
|
+
el.style.removeProperty(STUDIO_WIDTH_PROP);
|
|
57
|
+
el.style.removeProperty(STUDIO_HEIGHT_PROP);
|
|
58
|
+
el.style.width = "395px";
|
|
59
|
+
|
|
60
|
+
reapplyPositionEditsAfterSeek(document);
|
|
61
|
+
|
|
62
|
+
expect(el.style.width).toBe("395px");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
resolveSnapAdjustment,
|
|
4
|
+
snapEngagedForTravel,
|
|
5
|
+
SNAP_THRESHOLD_PX,
|
|
6
|
+
type SnapTarget,
|
|
7
|
+
} from "./snapEngine";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Picking a selection up used to move it. An element resting within the snap
|
|
11
|
+
* threshold of a guide is already snappable, so the snap computed on the first
|
|
12
|
+
* frame of a drag displaced it by up to the threshold while the pointer had not
|
|
13
|
+
* moved at all — captured live as `pointer "0,0"` against `applied "4,-3"`, with
|
|
14
|
+
* every member of the group jumping 12,-8 composition px before the drag had
|
|
15
|
+
* started. Snapping pulls toward a guide as you drag; it has nothing to say about
|
|
16
|
+
* a gesture that has not moved.
|
|
17
|
+
*/
|
|
18
|
+
describe("snapping waits for the drag to travel", () => {
|
|
19
|
+
// Moving box's right edge is at 150; the target's left edge is at 154, so the
|
|
20
|
+
// pair is 4px apart — inside the threshold, and snappable the moment it is asked.
|
|
21
|
+
const movingRect = { left: 100, top: 50, width: 50, height: 40 };
|
|
22
|
+
const target: SnapTarget = {
|
|
23
|
+
left: 154,
|
|
24
|
+
top: 50,
|
|
25
|
+
right: 254,
|
|
26
|
+
bottom: 90,
|
|
27
|
+
centerX: 204,
|
|
28
|
+
centerY: 70,
|
|
29
|
+
id: "neighbour",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const snapAt = (dx: number, dy: number) =>
|
|
33
|
+
resolveSnapAdjustment({
|
|
34
|
+
movingRect,
|
|
35
|
+
proposedDx: dx,
|
|
36
|
+
proposedDy: dy,
|
|
37
|
+
targets: [target],
|
|
38
|
+
threshold: SNAP_THRESHOLD_PX,
|
|
39
|
+
disabled: false,
|
|
40
|
+
disabledForTravel: !snapEngagedForTravel(dx, dy),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does not move a selection that has not been dragged yet", () => {
|
|
44
|
+
expect(snapAt(0, 0)).toMatchObject({ dx: 0, dy: 0 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("leaves a sub-threshold twitch alone", () => {
|
|
48
|
+
expect(snapAt(1, -1)).toMatchObject({ dx: 1, dy: -1 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("still snaps once the drag is a real one", () => {
|
|
52
|
+
expect(snapEngagedForTravel(0, 0)).toBe(false);
|
|
53
|
+
expect(snapEngagedForTravel(10, 0)).toBe(true);
|
|
54
|
+
// Without the travel gate the same delta snaps, which is the behaviour to keep.
|
|
55
|
+
const engaged = resolveSnapAdjustment({
|
|
56
|
+
movingRect,
|
|
57
|
+
proposedDx: 0,
|
|
58
|
+
proposedDy: 0,
|
|
59
|
+
targets: [target],
|
|
60
|
+
threshold: SNAP_THRESHOLD_PX,
|
|
61
|
+
disabled: false,
|
|
62
|
+
});
|
|
63
|
+
expect(engaged.dx).toBe(4);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -3,6 +3,24 @@
|
|
|
3
3
|
// All position values are in overlay-space (screen) pixels.
|
|
4
4
|
|
|
5
5
|
export const SNAP_THRESHOLD_PX = 6;
|
|
6
|
+
/**
|
|
7
|
+
* Pointer travel a MOVE must reach before snapping is allowed to touch it.
|
|
8
|
+
*
|
|
9
|
+
* An element resting within the threshold of a guide is already "snappable", so
|
|
10
|
+
* a snap computed on the very first frame displaces it by up to the threshold
|
|
11
|
+
* while the pointer has moved nothing — pick a selection up and the whole thing
|
|
12
|
+
* teleports before you have dragged at all. Snapping is meant to pull toward a
|
|
13
|
+
* guide as the user drags, so it does not participate until the drag is real.
|
|
14
|
+
* The value matches the distance a drag must cover to count as a drag rather
|
|
15
|
+
* than a click, so nothing below it moves anything.
|
|
16
|
+
*/
|
|
17
|
+
const SNAP_ENGAGE_TRAVEL_PX = 4;
|
|
18
|
+
|
|
19
|
+
/** Whether a move of this size has travelled far enough for snapping to apply. */
|
|
20
|
+
export function snapEngagedForTravel(dx: number, dy: number): boolean {
|
|
21
|
+
return Math.hypot(dx, dy) >= SNAP_ENGAGE_TRAVEL_PX;
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
const EQUIDISTANCE_TOLERANCE_PX = 1;
|
|
7
25
|
|
|
8
26
|
// ---------------------------------------------------------------------------
|
|
@@ -359,8 +377,10 @@ export function resolveSnapAdjustment(input: {
|
|
|
359
377
|
gridEdges?: { x: SnapEdge[]; y: SnapEdge[] };
|
|
360
378
|
threshold: number;
|
|
361
379
|
disabled: boolean;
|
|
380
|
+
/** Set when the gesture has not travelled far enough for snapping yet. */
|
|
381
|
+
disabledForTravel?: boolean;
|
|
362
382
|
}): SnapResult {
|
|
363
|
-
if (input.disabled || input.threshold <= 0) {
|
|
383
|
+
if (input.disabled || input.disabledForTravel || input.threshold <= 0) {
|
|
364
384
|
return DISABLED_RESULT(input.proposedDx, input.proposedDy);
|
|
365
385
|
}
|
|
366
386
|
|