@hyperframes/studio 0.7.103 → 0.7.105
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-DUB-xtR5.js → hyperframes-player-Jx07OQgw.js} +1 -1
- package/dist/assets/{index-DHc-xHLb.js → index-CryZ0PGi.js} +1 -1
- package/dist/assets/{index-Cez_yvk5.js → index-Dqi2GmOp.js} +1 -1
- package/dist/assets/{index-CxC2vn2-.js → index-DuxKGsHZ.js} +214 -214
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.105",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"gsap": "^3.13.0",
|
|
48
48
|
"marked": "^14.1.4",
|
|
49
49
|
"mediabunny": "^1.45.3",
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
50
|
+
"@hyperframes/sdk": "0.7.105",
|
|
51
|
+
"@hyperframes/player": "0.7.105",
|
|
52
|
+
"@hyperframes/studio-server": "0.7.105",
|
|
53
|
+
"@hyperframes/parsers": "0.7.105",
|
|
54
|
+
"@hyperframes/core": "0.7.105"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/react": "19",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"vite": "^6.4.2",
|
|
68
68
|
"vitest": "^3.2.4",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
|
-
"@hyperframes/producer": "0.7.
|
|
70
|
+
"@hyperframes/producer": "0.7.105"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
readStudioUrlStateFromWindow,
|
|
66
66
|
resolveMasterCompositionPath,
|
|
67
67
|
} from "./utils/studioUrlState";
|
|
68
|
+
const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds;
|
|
68
69
|
// fallow-ignore-next-line complexity
|
|
69
70
|
export function StudioApp() {
|
|
70
71
|
const { projectId, resolving, waitingForServer } = useServerConnection();
|
|
@@ -91,9 +92,9 @@ export function StudioApp() {
|
|
|
91
92
|
const captionSync = useCaptionSync(projectId);
|
|
92
93
|
const timelineElements = usePlayerStore((s) => s.elements);
|
|
93
94
|
const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
95
|
+
const setTimelineSelectionSet = usePlayerStore((s) => s.setSelectedElementIds);
|
|
94
96
|
const timelineDuration = usePlayerStore((s) => s.duration);
|
|
95
97
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
96
|
-
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
97
98
|
const effectiveTimelineDuration = useMemo(() => {
|
|
98
99
|
const maxEnd =
|
|
99
100
|
timelineElements.length > 0
|
|
@@ -270,13 +271,14 @@ export function StudioApp() {
|
|
|
270
271
|
const domEditSession = useDomEditSession({
|
|
271
272
|
projectId,
|
|
272
273
|
activeCompPath,
|
|
273
|
-
isMasterView,
|
|
274
274
|
compIdToSrc,
|
|
275
275
|
captionEditMode,
|
|
276
276
|
compositionLoading,
|
|
277
277
|
previewIframeRef,
|
|
278
278
|
timelineElements,
|
|
279
|
+
getTimelineSelectionSet,
|
|
279
280
|
setSelectedTimelineElementId,
|
|
281
|
+
setTimelineSelectionSet,
|
|
280
282
|
setRightCollapsed: panelLayout.setRightCollapsed,
|
|
281
283
|
setRightPanelTab: panelLayout.setRightPanelTab,
|
|
282
284
|
showToast,
|
|
@@ -416,6 +418,8 @@ export function StudioApp() {
|
|
|
416
418
|
rightCollapsed: panelLayout.rightCollapsed,
|
|
417
419
|
activeCompPathHydrated,
|
|
418
420
|
domEditSelection: domEditSession.domEditSelection,
|
|
421
|
+
domEditGroupSelections: domEditSession.domEditGroupSelections,
|
|
422
|
+
applyMarqueeSelection: domEditSession.applyMarqueeSelection,
|
|
419
423
|
buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget,
|
|
420
424
|
applyDomSelection: domEditSession.applyDomSelection,
|
|
421
425
|
setRightPanelTab: panelLayout.setRightPanelTab,
|
|
@@ -3,6 +3,7 @@ import { useCaptionStore } from "../store";
|
|
|
3
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
4
|
import { trackEvent } from "../../telemetry/client";
|
|
5
5
|
import type { CaptionStyle } from "../types";
|
|
6
|
+
import { studioWriteHeaders } from "../../utils/studioFileVersion";
|
|
6
7
|
|
|
7
8
|
interface CaptionOverrideEntry {
|
|
8
9
|
wordId?: string;
|
|
@@ -77,7 +78,7 @@ export function useCaptionSync(projectId: string | null) {
|
|
|
77
78
|
|
|
78
79
|
fetch(`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`, {
|
|
79
80
|
method: "PUT",
|
|
80
|
-
headers: { "Content-Type": "text/plain" },
|
|
81
|
+
headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
|
|
81
82
|
body: JSON.stringify(overrides, null, 2),
|
|
82
83
|
}).catch((error: unknown) => {
|
|
83
84
|
// Caption auto-save is a data-loss path; surface failures via telemetry
|
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
resolveDomEditRotationGesture,
|
|
15
15
|
} from "./DomEditOverlay";
|
|
16
16
|
import type { DomEditSelection } from "./domEditing";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
hoverCacheDescribesPoint,
|
|
19
|
+
resolveResizeCenterAnchorOffset,
|
|
20
|
+
} from "./domEditOverlayGestures";
|
|
18
21
|
|
|
19
22
|
// React 19 warns unless the test environment opts into act().
|
|
20
23
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -278,7 +281,9 @@ describe("DomEditOverlay", () => {
|
|
|
278
281
|
const host = document.createElement("div");
|
|
279
282
|
document.body.append(host);
|
|
280
283
|
const root = createRoot(host);
|
|
281
|
-
const iframeRef
|
|
284
|
+
const iframeRef: { current: HTMLIFrameElement | null } = {
|
|
285
|
+
current: document.createElement("iframe"),
|
|
286
|
+
};
|
|
282
287
|
const onCanvasMouseDown = vi.fn();
|
|
283
288
|
const onMarqueeSelect = vi.fn();
|
|
284
289
|
|
|
@@ -323,6 +328,44 @@ describe("DomEditOverlay", () => {
|
|
|
323
328
|
host.remove();
|
|
324
329
|
});
|
|
325
330
|
|
|
331
|
+
it("starts a marquee from outside the composition frame", async () => {
|
|
332
|
+
const restoreRect = stubViewportRect();
|
|
333
|
+
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
|
|
334
|
+
const setPointerCapture = vi.fn();
|
|
335
|
+
HTMLDivElement.prototype.setPointerCapture = setPointerCapture;
|
|
336
|
+
const host = document.createElement("div");
|
|
337
|
+
document.body.append(host);
|
|
338
|
+
const root = createRoot(host);
|
|
339
|
+
const iframeRef: { current: HTMLIFrameElement | null } = {
|
|
340
|
+
current: document.createElement("iframe"),
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
act(() => {
|
|
344
|
+
root.render(
|
|
345
|
+
React.createElement(DomEditOverlay, {
|
|
346
|
+
...createOverlayProps({
|
|
347
|
+
iframeRef,
|
|
348
|
+
selection: null,
|
|
349
|
+
hoverSelection: null,
|
|
350
|
+
onSelectionChange: () => {},
|
|
351
|
+
}),
|
|
352
|
+
onMarqueeSelect: vi.fn(),
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
await flushOverlayRaf();
|
|
357
|
+
|
|
358
|
+
// Negative x is outside the 0..800 composition frame but still reaches the
|
|
359
|
+
// overlay in a real pointer event when the user starts in the grey margin.
|
|
360
|
+
dispatchOverlayPointerDown(getOverlay(host), -40, 100);
|
|
361
|
+
expect(setPointerCapture).toHaveBeenCalledTimes(1);
|
|
362
|
+
|
|
363
|
+
act(() => root.unmount());
|
|
364
|
+
HTMLDivElement.prototype.setPointerCapture = originalPointerCapture;
|
|
365
|
+
restoreRect();
|
|
366
|
+
host.remove();
|
|
367
|
+
});
|
|
368
|
+
|
|
326
369
|
it("does not start a drag from a stale hover target on canvas pointer-down", () => {
|
|
327
370
|
const host = document.createElement("div");
|
|
328
371
|
document.body.append(host);
|
|
@@ -628,6 +671,50 @@ describe("resolveDomEditRotationGesture", () => {
|
|
|
628
671
|
});
|
|
629
672
|
});
|
|
630
673
|
|
|
674
|
+
/**
|
|
675
|
+
* Shift-click reads the hover cache instead of hit-testing, and the cache is
|
|
676
|
+
* filled asynchronously as the pointer moves. Pass over one element on the way to
|
|
677
|
+
* another and the cache still names the one you left, so the shift-click added
|
|
678
|
+
* THAT element and the click looked like it selected something at random. The
|
|
679
|
+
* guard is what makes the cache usable only when it is about the point clicked.
|
|
680
|
+
*/
|
|
681
|
+
describe("hoverCacheDescribesPoint", () => {
|
|
682
|
+
const doc = new Window().document;
|
|
683
|
+
|
|
684
|
+
it("rejects a cache left behind by an element the pointer passed over", () => {
|
|
685
|
+
const passedOver = doc.createElement("div");
|
|
686
|
+
const clicked = doc.createElement("div");
|
|
687
|
+
doc.body.append(passedOver, clicked);
|
|
688
|
+
|
|
689
|
+
expect(hoverCacheDescribesPoint(passedOver, clicked)).toBe(false);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("accepts the cache when it names the element at the point", () => {
|
|
693
|
+
const clicked = doc.createElement("div");
|
|
694
|
+
doc.body.append(clicked);
|
|
695
|
+
|
|
696
|
+
expect(hoverCacheDescribesPoint(clicked, clicked)).toBe(true);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// The resolver is allowed to hand back a clip ancestor of the raw target, which
|
|
700
|
+
// still describes the same click — rejecting it would drop the fast path on
|
|
701
|
+
// every element that has children.
|
|
702
|
+
it("accepts an ancestor of the element at the point", () => {
|
|
703
|
+
const clip = doc.createElement("div");
|
|
704
|
+
const child = doc.createElement("span");
|
|
705
|
+
clip.append(child);
|
|
706
|
+
doc.body.append(clip);
|
|
707
|
+
|
|
708
|
+
expect(hoverCacheDescribesPoint(clip, child)).toBe(true);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("rejects a missing cache or an empty point", () => {
|
|
712
|
+
const el = doc.createElement("div");
|
|
713
|
+
expect(hoverCacheDescribesPoint(null, el)).toBe(false);
|
|
714
|
+
expect(hoverCacheDescribesPoint(el, null)).toBe(false);
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
|
|
631
718
|
// resolveResizeCenterAnchorOffset is the UNROTATED (AABB) fallback used only when
|
|
632
719
|
// the element's real transformed corners can't be measured. Center-anchored: a
|
|
633
720
|
// width/height change grows the box from its top-left, drifting the center by half
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type GestureState,
|
|
14
14
|
type GroupGestureState,
|
|
15
15
|
focusDomEditOverlayElement,
|
|
16
|
+
resolveShiftClickCandidate,
|
|
16
17
|
} from "./domEditOverlayGestures";
|
|
17
18
|
import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
|
|
18
19
|
import { OffCanvasIndicators, type OffCanvasRect } from "./OffCanvasIndicators";
|
|
@@ -31,6 +32,7 @@ import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
|
|
|
31
32
|
import { CanvasContextMenu } from "./CanvasContextMenu";
|
|
32
33
|
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
|
|
33
34
|
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
|
|
35
|
+
import { logSelect } from "../../utils/selectDebug";
|
|
34
36
|
|
|
35
37
|
// Re-exports for external consumers — preserving existing import paths.
|
|
36
38
|
export {
|
|
@@ -318,6 +320,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
318
320
|
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
319
321
|
if (!allowCanvasMovement) return;
|
|
320
322
|
if (suppressNextOverlayMouseDownRef.current) {
|
|
323
|
+
logSelect("mousedown-suppressed", { shift: event.shiftKey });
|
|
321
324
|
suppressNextOverlayMouseDownRef.current = false;
|
|
322
325
|
suppressNextBoxMouseDownRef.current = false;
|
|
323
326
|
suppressNextBoxClickRef.current = false;
|
|
@@ -326,7 +329,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
326
329
|
return;
|
|
327
330
|
}
|
|
328
331
|
const target = event.target as HTMLElement | null;
|
|
329
|
-
|
|
332
|
+
const onBox = Boolean(target?.closest('[data-dom-edit-selection-box="true"]'));
|
|
333
|
+
logSelect("mousedown", { shift: event.shiftKey, onBox });
|
|
334
|
+
if (onBox) return;
|
|
330
335
|
// Allow clicks anywhere on the overlay — GSAP-translated elements can
|
|
331
336
|
// extend beyond the composition rect into the gray zone, and users need
|
|
332
337
|
// to select/deselect them by clicking there.
|
|
@@ -341,8 +346,20 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
341
346
|
const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
342
347
|
if (!allowCanvasMovement || event.button !== 0) return;
|
|
343
348
|
if (event.shiftKey) {
|
|
344
|
-
|
|
345
|
-
const candidate =
|
|
349
|
+
const shiftIframe = iframeRef.current;
|
|
350
|
+
const candidate = resolveShiftClickCandidate({
|
|
351
|
+
cached: hoverSelectionRef.current,
|
|
352
|
+
elementAtPoint: shiftIframe
|
|
353
|
+
? getPreviewTargetFromPointer(
|
|
354
|
+
shiftIframe,
|
|
355
|
+
event.clientX,
|
|
356
|
+
event.clientY,
|
|
357
|
+
activeCompositionPathRef.current,
|
|
358
|
+
)
|
|
359
|
+
: null,
|
|
360
|
+
});
|
|
361
|
+
// Not confident: fall through untouched — no preventDefault, no suppression —
|
|
362
|
+
// so the mousedown path resolves this point instead of guessing here.
|
|
346
363
|
if (!candidate) return;
|
|
347
364
|
event.preventDefault();
|
|
348
365
|
event.stopPropagation();
|
|
@@ -376,28 +393,27 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
376
393
|
const overlayEl = overlayRef.current;
|
|
377
394
|
if (overlayEl) {
|
|
378
395
|
const oRect = overlayEl.getBoundingClientRect();
|
|
396
|
+
// Anywhere empty on the overlay starts one, not just inside the frame.
|
|
397
|
+
// An element dragged past the edge sits OUT there in the grey, and a
|
|
398
|
+
// rubber band that refuses to start there cannot reach it — which left
|
|
399
|
+
// the timeline as the only way to select something you can plainly see.
|
|
400
|
+
// The hit test collects in overlay space and never clipped to the frame,
|
|
401
|
+
// so those elements were always selectable once the band could begin.
|
|
402
|
+
event.preventDefault();
|
|
403
|
+
event.stopPropagation();
|
|
404
|
+
suppressNextOverlayMouseDownRef.current = true;
|
|
405
|
+
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
379
406
|
const cx = event.clientX - oRect.left;
|
|
380
407
|
const cy = event.clientY - oRect.top;
|
|
381
|
-
|
|
382
|
-
cx
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
cy
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
391
|
-
marquee.marqueeRef.current = {
|
|
392
|
-
startX: cx,
|
|
393
|
-
startY: cy,
|
|
394
|
-
currentX: cx,
|
|
395
|
-
currentY: cy,
|
|
396
|
-
pointerId: event.pointerId,
|
|
397
|
-
pastThreshold: false,
|
|
398
|
-
};
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
408
|
+
marquee.marqueeRef.current = {
|
|
409
|
+
startX: cx,
|
|
410
|
+
startY: cy,
|
|
411
|
+
currentX: cx,
|
|
412
|
+
currentY: cy,
|
|
413
|
+
pointerId: event.pointerId,
|
|
414
|
+
pastThreshold: false,
|
|
415
|
+
};
|
|
416
|
+
return;
|
|
401
417
|
}
|
|
402
418
|
}
|
|
403
419
|
};
|
|
@@ -503,6 +519,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
503
519
|
top: cr.top,
|
|
504
520
|
width: cr.width,
|
|
505
521
|
height: cr.height,
|
|
522
|
+
transform: cr.angle ? `rotate(${cr.angle}deg)` : undefined,
|
|
506
523
|
}}
|
|
507
524
|
/>
|
|
508
525
|
))}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
orientedGroupAwareOverlayRect,
|
|
6
6
|
overlayCornersCentroid,
|
|
7
7
|
selectionCacheKey,
|
|
8
|
+
orientedVisibleOverlayRect,
|
|
8
9
|
} from "./domEditOverlayGeometry";
|
|
9
10
|
|
|
10
11
|
describe("overlayCornersCentroid", () => {
|
|
@@ -67,6 +68,17 @@ describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () =>
|
|
|
67
68
|
number,
|
|
68
69
|
];
|
|
69
70
|
}
|
|
71
|
+
/** `this` applied outside `other`, the way an ancestor composes over a child. */
|
|
72
|
+
multiply(other: { a: number; b: number; c: number; d: number; e: number; f: number }) {
|
|
73
|
+
const out = new (this.constructor as new (init?: string) => this)();
|
|
74
|
+
out.a = this.a * other.a + this.c * other.b;
|
|
75
|
+
out.b = this.b * other.a + this.d * other.b;
|
|
76
|
+
out.c = this.a * other.c + this.c * other.d;
|
|
77
|
+
out.d = this.b * other.c + this.d * other.d;
|
|
78
|
+
out.e = this.a * other.e + this.c * other.f + this.e;
|
|
79
|
+
out.f = this.b * other.e + this.d * other.f + this.f;
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
70
82
|
transformPoint(pt: { x: number; y: number }) {
|
|
71
83
|
return {
|
|
72
84
|
x: this.a * pt.x + this.c * pt.y + this.e,
|
|
@@ -148,6 +160,30 @@ describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () =>
|
|
|
148
160
|
expect(rect!.angle ?? 0).toBe(0);
|
|
149
161
|
});
|
|
150
162
|
|
|
163
|
+
/**
|
|
164
|
+
* A child outline is drawn ON the child, not around it. Measured axis-aligned,
|
|
165
|
+
* a text layer inside a rotated card got an upright dashed box sitting across
|
|
166
|
+
* the rotated glyphs — the parent's chrome rotated and its children's did not.
|
|
167
|
+
*/
|
|
168
|
+
it("child outlines carry the element's angle, so they can co-rotate with it", () => {
|
|
169
|
+
const { overlayEl, iframe, el } = buildHarness();
|
|
170
|
+
el.style.transform = ROTATE_30DEG_MATRIX;
|
|
171
|
+
const rect = orientedVisibleOverlayRect(overlayEl, iframe, el);
|
|
172
|
+
expect(rect).not.toBeNull();
|
|
173
|
+
expect(rect!.angle).toBeCloseTo(30, 3);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("an unrotated child outline is unchanged — no angle, same box as before", () => {
|
|
177
|
+
const { overlayEl, iframe, el } = buildHarness();
|
|
178
|
+
const rect = orientedVisibleOverlayRect(overlayEl, iframe, el);
|
|
179
|
+
expect(rect).not.toBeNull();
|
|
180
|
+
expect(rect!.angle ?? 0).toBe(0);
|
|
181
|
+
expect(rect!.left).toBeCloseTo(400, 5);
|
|
182
|
+
expect(rect!.top).toBeCloseTo(450, 5);
|
|
183
|
+
expect(rect!.width).toBeCloseTo(200, 5);
|
|
184
|
+
expect(rect!.height).toBeCloseTo(100, 5);
|
|
185
|
+
});
|
|
186
|
+
|
|
151
187
|
it("rotated element takes the corner-geometry path — reports the live angle", () => {
|
|
152
188
|
const { overlayEl, iframe, el } = buildHarness();
|
|
153
189
|
el.style.transform = ROTATE_30DEG_MATRIX;
|
|
@@ -156,6 +192,64 @@ describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () =>
|
|
|
156
192
|
expect(rect!.angle).toBeCloseTo(30, 3);
|
|
157
193
|
});
|
|
158
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The selection box is drawn at the size the element PAINTS, which is the
|
|
197
|
+
* product of every transform between it and the composition root.
|
|
198
|
+
*
|
|
199
|
+
* A text layer inside a card carrying `scale(1.2)` was drawn at 1/1.2 of the
|
|
200
|
+
* text: the top-left was right, because the caller anchors that to the real
|
|
201
|
+
* bounding rect, and the right and bottom edges fell short. The same read
|
|
202
|
+
* decides whether to draw the box rotated, so an element inside a rotated
|
|
203
|
+
* parent got an upright box.
|
|
204
|
+
*/
|
|
205
|
+
const SCALE_1_2_MATRIX = "matrix(1.2, 0, 0, 1.2, 0, 0)";
|
|
206
|
+
const MIRROR_X_MATRIX = "matrix(-1, 0, 0, 1, 0, 0)";
|
|
207
|
+
|
|
208
|
+
it("sizes the box by the accumulated transform, not the element's own", () => {
|
|
209
|
+
const { overlayEl, iframe, el } = buildHarness();
|
|
210
|
+
// The element carries no transform; its parent scales it by 1.2, so it
|
|
211
|
+
// paints at 240x120 and its bounding rect says so.
|
|
212
|
+
el.parentElement!.style.transform = SCALE_1_2_MATRIX;
|
|
213
|
+
el.style.transform = ROTATE_30DEG_MATRIX;
|
|
214
|
+
stubRect(el, { left: 400, top: 450, width: 240, height: 120 });
|
|
215
|
+
|
|
216
|
+
const rect = orientedOverlayRect(overlayEl, iframe, el);
|
|
217
|
+
|
|
218
|
+
expect(rect).not.toBeNull();
|
|
219
|
+
// 200x100 local, scaled by the ancestor, then rotated: the oriented box is
|
|
220
|
+
// the scaled local box, and the AABB it is anchored to is wider again.
|
|
221
|
+
expect(rect!.width).toBeCloseTo(240, 3);
|
|
222
|
+
expect(rect!.height).toBeCloseTo(120, 3);
|
|
223
|
+
expect(rect!.angle).toBeCloseTo(30, 3);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("takes the rotated path when only an ANCESTOR is rotated", () => {
|
|
227
|
+
const { overlayEl, iframe, el } = buildHarness();
|
|
228
|
+
el.parentElement!.style.transform = ROTATE_30DEG_MATRIX;
|
|
229
|
+
|
|
230
|
+
const rect = orientedOverlayRect(overlayEl, iframe, el);
|
|
231
|
+
|
|
232
|
+
expect(rect!.angle).toBeCloseTo(30, 3);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("does not misread a mirrored ancestor as a 180-degree rotation", () => {
|
|
236
|
+
const { overlayEl, iframe, el } = buildHarness();
|
|
237
|
+
el.parentElement!.style.transform = MIRROR_X_MATRIX;
|
|
238
|
+
|
|
239
|
+
const rect = orientedOverlayRect(overlayEl, iframe, el);
|
|
240
|
+
|
|
241
|
+
expect(rect?.angle ?? 0).toBe(0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("stops transform composition at the composition root", () => {
|
|
245
|
+
const { overlayEl, iframe, el } = buildHarness();
|
|
246
|
+
iframe.contentDocument!.body.style.transform = ROTATE_30DEG_MATRIX;
|
|
247
|
+
|
|
248
|
+
const rect = orientedOverlayRect(overlayEl, iframe, el);
|
|
249
|
+
|
|
250
|
+
expect(rect?.angle ?? 0).toBe(0);
|
|
251
|
+
});
|
|
252
|
+
|
|
159
253
|
it("preserves an ordinary element's rotation through the group-aware entry point", () => {
|
|
160
254
|
const { overlayEl, iframe, el } = buildHarness();
|
|
161
255
|
el.style.transform = ROTATE_30DEG_MATRIX;
|
|
@@ -117,6 +117,28 @@ interface ElementTransformSnapshot {
|
|
|
117
117
|
cs: CSSStyleDeclaration;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The transform from the element's own box to the composition's, ACCUMULATED
|
|
122
|
+
* over its ancestors rather than read from the element alone.
|
|
123
|
+
*
|
|
124
|
+
* What the user sees is the product of every transform between the element and
|
|
125
|
+
* the composition root, and an element is routinely a child of something
|
|
126
|
+
* scaled or rotated. Reading only its own transform drew the selection box at
|
|
127
|
+
* the element's untransformed size: a text layer inside a card carrying
|
|
128
|
+
* `scale(1.2)` got a box at 1/1.2 of the text, with the top-left correct (the
|
|
129
|
+
* caller anchors that to the real bounding rect) and the right and bottom
|
|
130
|
+
* edges falling short. The same read decides whether to draw the box rotated,
|
|
131
|
+
* so an element inside a rotated parent got an upright box too.
|
|
132
|
+
*
|
|
133
|
+
* Only the linear part matters here. Each transform's origin contributes
|
|
134
|
+
* translation, and the caller discards translation by matching the corners'
|
|
135
|
+
* bounding box to the element's real one, so composing the matrices alone is
|
|
136
|
+
* enough and there is no per-ancestor origin to unpick.
|
|
137
|
+
*
|
|
138
|
+
* The walk stops at the composition document's root. The canvas zoom lives on
|
|
139
|
+
* the iframe element in Studio's own document and is applied separately by
|
|
140
|
+
* `computeOverlayRootScale`; including it here would count it twice.
|
|
141
|
+
*/
|
|
120
142
|
function readElementTransformSnapshot(
|
|
121
143
|
win: Window,
|
|
122
144
|
element: HTMLElement,
|
|
@@ -125,7 +147,15 @@ function readElementTransformSnapshot(
|
|
|
125
147
|
if (!DOMMatrixCtor) return null;
|
|
126
148
|
const cs = win.getComputedStyle(element);
|
|
127
149
|
try {
|
|
128
|
-
|
|
150
|
+
let matrix = new DOMMatrixCtor();
|
|
151
|
+
for (let node: HTMLElement | null = element; node; node = node.parentElement) {
|
|
152
|
+
const transform = node === element ? cs.transform : win.getComputedStyle(node).transform;
|
|
153
|
+
if (transform && transform !== "none") {
|
|
154
|
+
// An ancestor applies outside, so it multiplies on the left.
|
|
155
|
+
matrix = new DOMMatrixCtor(transform).multiply(matrix);
|
|
156
|
+
}
|
|
157
|
+
if (node.hasAttribute("data-composition-id")) break;
|
|
158
|
+
}
|
|
129
159
|
return { matrix, cs };
|
|
130
160
|
} catch {
|
|
131
161
|
return null;
|
|
@@ -141,7 +171,16 @@ function readElementTransformSnapshot(
|
|
|
141
171
|
function rotationDegreesFromMatrix(matrix: DOMMatrix): number {
|
|
142
172
|
const a = Number.isFinite(matrix.a) ? matrix.a : 1;
|
|
143
173
|
const b = Number.isFinite(matrix.b) ? matrix.b : 0;
|
|
144
|
-
const
|
|
174
|
+
const c = Number.isFinite(matrix.c) ? matrix.c : 0;
|
|
175
|
+
const d = Number.isFinite(matrix.d) ? matrix.d : 1;
|
|
176
|
+
const fromX = (Math.atan2(b, a) * 180) / Math.PI;
|
|
177
|
+
const determinant = a * d - b * c;
|
|
178
|
+
// A reflection makes one basis direction read 180° away from the authored
|
|
179
|
+
// rotation. For cursor/handle orientation those directions are equivalent;
|
|
180
|
+
// choose the representative nearest zero instead of drawing a pure mirror's
|
|
181
|
+
// rotate handle on the opposite side of the element.
|
|
182
|
+
const fromY = (Math.atan2(-c, d) * 180) / Math.PI;
|
|
183
|
+
const deg = determinant < 0 && Math.abs(fromY) < Math.abs(fromX) ? fromY : fromX;
|
|
145
184
|
return Number.isFinite(deg) ? deg : 0;
|
|
146
185
|
}
|
|
147
186
|
|
|
@@ -391,6 +430,24 @@ export function orientedOverlayRect(
|
|
|
391
430
|
};
|
|
392
431
|
}
|
|
393
432
|
|
|
433
|
+
/**
|
|
434
|
+
* `toVisibleOverlayRect`'s oriented twin: the element's crop-hugged box plus its
|
|
435
|
+
* live rotation, for chrome that has to sit on a rotated element rather than
|
|
436
|
+
* around it. Rendering the result with `transform: rotate(angle)` about its
|
|
437
|
+
* centre lands it on the element's real corners.
|
|
438
|
+
*
|
|
439
|
+
* At angle 0 `orientedOverlayRect` returns the plain AABB, so an unrotated
|
|
440
|
+
* element measures exactly as it did before.
|
|
441
|
+
*/
|
|
442
|
+
export function orientedVisibleOverlayRect(
|
|
443
|
+
overlayEl: HTMLDivElement,
|
|
444
|
+
iframe: HTMLIFrameElement,
|
|
445
|
+
element: HTMLElement,
|
|
446
|
+
): OverlayRect | null {
|
|
447
|
+
const rect = orientedOverlayRect(overlayEl, iframe, element);
|
|
448
|
+
return rect ? { ...rect, ...hugRectForElement(rect, element) } : null;
|
|
449
|
+
}
|
|
450
|
+
|
|
394
451
|
const OVERLAY_RECT_EPSILON_PX = 0.5;
|
|
395
452
|
const OVERLAY_RECT_ANGLE_EPSILON_DEG = 0.1;
|
|
396
453
|
|
|
@@ -10,6 +10,7 @@ import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
|
|
|
10
10
|
import type { SnapContext } from "./snapTargetCollection";
|
|
11
11
|
import type { SnapGuidesState } from "./SnapGuideOverlay";
|
|
12
12
|
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
|
|
13
|
+
import { logSelect } from "../../utils/selectDebug";
|
|
13
14
|
|
|
14
15
|
export type GestureKind = "drag" | "resize" | "rotate";
|
|
15
16
|
|
|
@@ -112,6 +113,47 @@ export function focusDomEditOverlayElement(element: FocusableDomEditOverlay | nu
|
|
|
112
113
|
element?.focus({ preventScroll: true });
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Whether the hover cache may stand in for a hit-test at this point.
|
|
118
|
+
*
|
|
119
|
+
* The cache is filled asynchronously as the pointer moves, so it can describe an
|
|
120
|
+
* element the pointer has already left. That is harmless for drawing a hover
|
|
121
|
+
* outline and wrong for a shift-click, which would add the stale element to the
|
|
122
|
+
* selection instead of the one under the pointer. True only when the cached
|
|
123
|
+
* element IS the element at the point, or contains it — the resolver is allowed
|
|
124
|
+
* to hand back a clip ancestor of the raw target, and that still describes the
|
|
125
|
+
* same click.
|
|
126
|
+
*/
|
|
127
|
+
export function hoverCacheDescribesPoint(
|
|
128
|
+
cachedElement: Element | null | undefined,
|
|
129
|
+
elementAtPoint: Element | null | undefined,
|
|
130
|
+
): boolean {
|
|
131
|
+
if (!cachedElement || !elementAtPoint) return false;
|
|
132
|
+
return cachedElement === elementAtPoint || cachedElement.contains(elementAtPoint);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The element a shift-click should add, or null to let the slower path resolve it.
|
|
137
|
+
*
|
|
138
|
+
* Reading the hover cache without checking is safe for a hover outline and wrong
|
|
139
|
+
* for a shift-click: the click silently adds whatever the pointer last passed
|
|
140
|
+
* over instead of the element under it, which reads as multi-select picking
|
|
141
|
+
* things at random. Returning null means "not confident", and the caller must
|
|
142
|
+
* then fall through untouched so the mousedown path resolves the point properly.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveShiftClickCandidate<T extends { element: Element }>(input: {
|
|
145
|
+
cached: T | null;
|
|
146
|
+
elementAtPoint: Element | null;
|
|
147
|
+
}): T | null {
|
|
148
|
+
const describes = hoverCacheDescribesPoint(input.cached?.element, input.elementAtPoint);
|
|
149
|
+
logSelect("shift-pointerdown", {
|
|
150
|
+
candidate: input.cached ? ((input.cached as { selector?: string }).selector ?? null) : null,
|
|
151
|
+
pointTarget: input.elementAtPoint?.id ?? input.elementAtPoint?.tagName ?? null,
|
|
152
|
+
cacheIsAboutThisPoint: describes,
|
|
153
|
+
});
|
|
154
|
+
return describes ? input.cached : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
115
157
|
/**
|
|
116
158
|
* Overlay-px translation that keeps the element's CENTER fixed while a corner
|
|
117
159
|
* resizes: a CSS width/height change grows the layout box from its top-left, so
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "./domEditOverlayGestures";
|
|
34
34
|
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
|
|
35
35
|
import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
|
|
36
|
+
import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";
|
|
36
37
|
|
|
37
38
|
export function startGroupDrag(
|
|
38
39
|
e: React.PointerEvent<HTMLElement>,
|
|
@@ -70,6 +71,22 @@ export function startGroupDrag(
|
|
|
70
71
|
}
|
|
71
72
|
members.push(result.member);
|
|
72
73
|
}
|
|
74
|
+
resetDragMoveLog();
|
|
75
|
+
logDrag("group-start", {
|
|
76
|
+
// A member whose mapping differs from its neighbours travels a different
|
|
77
|
+
// distance for the same pointer delta, which is the group coming apart.
|
|
78
|
+
members: Object.fromEntries(
|
|
79
|
+
members.map((member) => [
|
|
80
|
+
member.key,
|
|
81
|
+
{
|
|
82
|
+
map: `${member.screenToOffset.a.toFixed(3)},${member.screenToOffset.d.toFixed(3)}`,
|
|
83
|
+
base: `${Math.round(member.baseGsap.x)},${Math.round(member.baseGsap.y)}`,
|
|
84
|
+
offset: `${Math.round(member.initialOffset.x)},${Math.round(member.initialOffset.y)}`,
|
|
85
|
+
},
|
|
86
|
+
]),
|
|
87
|
+
),
|
|
88
|
+
at: readDragPositions(members),
|
|
89
|
+
});
|
|
73
90
|
|
|
74
91
|
const overlayEl = opts.overlayRef.current;
|
|
75
92
|
const iframe = opts.iframeRef.current;
|