@hyperframes/studio 0.7.15 → 0.7.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-C4cUbqql.js +375 -0
- package/dist/assets/{index-CTXExGxO.js → index-D2SMcOdY.js} +1 -1
- package/dist/assets/index-DmkOvZns.css +1 -0
- package/dist/assets/{index-Db9KG66z.js → index-sCOjSz40.js} +1 -1
- package/dist/{chunk-UUEDZ4UJ.js → chunk-SBGXX7WY.js} +52 -22
- package/dist/chunk-SBGXX7WY.js.map +1 -0
- package/dist/{domEditingLayers-NWCBED4R.js → domEditingLayers-VZMLL4AP.js} +2 -4
- package/dist/index.d.ts +18 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3157 -2808
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -0
- package/src/components/StudioPreviewArea.tsx +0 -41
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/TimelineToolbar.tsx +4 -4
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/GestureRecordControl.tsx +2 -2
- package/src/components/editor/GsapAnimationSection.tsx +3 -3
- package/src/components/editor/InspectorHeaderActions.tsx +62 -0
- package/src/components/editor/LayersPanel.tsx +35 -2
- package/src/components/editor/MotionPathOverlay.tsx +24 -8
- package/src/components/editor/PropertyPanel.tsx +48 -60
- package/src/components/editor/Transform3DCube.tsx +84 -85
- package/src/components/editor/domEditOverlayGeometry.ts +28 -0
- package/src/components/editor/domEditingDom.ts +36 -2
- package/src/components/editor/domEditingGroups.ts +41 -0
- package/src/components/editor/domEditingLayers.test.ts +97 -1
- package/src/components/editor/domEditingLayers.ts +13 -29
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualOffsetDrag.ts +23 -2
- package/src/components/editor/propertyPanel3dTransform.tsx +70 -30
- package/src/components/editor/propertyPanelHelpers.ts +2 -0
- package/src/components/editor/useDomEditOverlayRects.ts +8 -3
- package/src/components/editor/useMotionPathData.ts +47 -1
- package/src/contexts/DomEditContext.tsx +16 -0
- package/src/contexts/TimelineEditContext.tsx +0 -1
- package/src/hooks/gsapRuntimeBridge.ts +9 -2
- package/src/hooks/gsapTweenSynth.ts +64 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +126 -26
- package/src/hooks/useAppHotkeys.ts +34 -3
- package/src/hooks/useDomEditSession.ts +48 -0
- package/src/hooks/useDomSelection.ts +73 -9
- package/src/hooks/useEnableKeyframes.test.ts +35 -0
- package/src/hooks/useEnableKeyframes.ts +29 -1
- package/src/hooks/useGroupCommits.ts +188 -0
- package/src/hooks/useGsapSelectionHandlers.ts +4 -0
- package/src/hooks/useGsapTweenCache.ts +56 -72
- package/src/hooks/usePreviewInteraction.ts +60 -2
- package/src/player/components/ShortcutsPanel.tsx +2 -0
- package/src/player/components/Timeline.tsx +0 -15
- package/src/player/components/TimelineCanvas.tsx +0 -13
- package/src/player/components/TimelineClipDiamonds.tsx +4 -125
- package/src/player/components/timelineCallbacks.ts +0 -1
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +47 -4
- package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -4
- package/src/player/store/playerStore.ts +20 -0
- package/src/utils/studioPreviewHelpers.ts +37 -0
- package/dist/assets/index-B7VWFKy1.js +0 -375
- package/dist/assets/index-svYFaNuq.css +0 -1
- package/dist/chunk-UUEDZ4UJ.js.map +0 -1
- package/src/components/editor/keyframeMove.test.ts +0 -101
- package/src/components/editor/keyframeMove.ts +0 -151
- /package/dist/{domEditingLayers-NWCBED4R.js.map → domEditingLayers-VZMLL4AP.js.map} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, useState } from "react";
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { projectAxes, projectCubeFaces, wrapDeg } from "./transform3dProjection";
|
|
3
3
|
|
|
4
4
|
export interface CubePose {
|
|
@@ -22,80 +22,19 @@ const SENSITIVITY = 0.6; // degrees per pixel of drag
|
|
|
22
22
|
* Presentational only: emits a live draft pose while dragging and a final pose
|
|
23
23
|
* on release — the parent owns live-previewing and committing to GSAP props.
|
|
24
24
|
*/
|
|
25
|
-
// transformPerspective (px)
|
|
26
|
-
//
|
|
27
|
-
const STRONG_PX = 200;
|
|
28
|
-
const WEAK_PX = 1600;
|
|
29
|
-
const PX_RANGE = WEAK_PX - STRONG_PX;
|
|
30
|
-
const strengthToPx = (s: number) => (s <= 0.01 ? 0 : Math.round(WEAK_PX - s * PX_RANGE));
|
|
31
|
-
const pxToStrength = (px: number) =>
|
|
32
|
-
px <= 0
|
|
33
|
-
? 0
|
|
34
|
-
: Math.max(0, Math.min(1, (WEAK_PX - Math.max(STRONG_PX, Math.min(WEAK_PX, px))) / PX_RANGE));
|
|
25
|
+
// transformPerspective (px) drives the cube's weak-perspective projection;
|
|
26
|
+
// 0 = off → flattest (largest projection distance).
|
|
35
27
|
const pxToProjPersp = (px: number) => (px > 0 ? Math.max(2.2, Math.min(14, px / 130)) : 14);
|
|
36
28
|
|
|
37
|
-
/** Horizontal "perspective strength" slider — left = none, right = dramatic. */
|
|
38
|
-
function PerspectiveSlider({
|
|
39
|
-
value,
|
|
40
|
-
onDraft,
|
|
41
|
-
onCommit,
|
|
42
|
-
}: {
|
|
43
|
-
value: number;
|
|
44
|
-
onDraft?: (px: number) => void;
|
|
45
|
-
onCommit: (px: number) => void;
|
|
46
|
-
}) {
|
|
47
|
-
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
48
|
-
const draggingRef = useRef(false);
|
|
49
|
-
const strength = pxToStrength(value);
|
|
50
|
-
const fromEvent = (clientX: number) => {
|
|
51
|
-
const r = trackRef.current?.getBoundingClientRect();
|
|
52
|
-
if (!r || r.width === 0) return 0;
|
|
53
|
-
return strengthToPx(Math.max(0, Math.min(1, (clientX - r.left) / r.width)));
|
|
54
|
-
};
|
|
55
|
-
return (
|
|
56
|
-
<div className="flex items-center gap-1.5 px-2 pb-1.5 pt-1">
|
|
57
|
-
<span className="text-[8px] font-medium uppercase tracking-wide text-neutral-600">Persp</span>
|
|
58
|
-
<div
|
|
59
|
-
ref={trackRef}
|
|
60
|
-
onPointerDown={(e) => {
|
|
61
|
-
e.currentTarget.setPointerCapture(e.pointerId);
|
|
62
|
-
draggingRef.current = true;
|
|
63
|
-
onDraft?.(fromEvent(e.clientX));
|
|
64
|
-
}}
|
|
65
|
-
onPointerMove={(e) => {
|
|
66
|
-
if (draggingRef.current) onDraft?.(fromEvent(e.clientX));
|
|
67
|
-
}}
|
|
68
|
-
onPointerUp={(e) => {
|
|
69
|
-
if (!draggingRef.current) return;
|
|
70
|
-
draggingRef.current = false;
|
|
71
|
-
onCommit(fromEvent(e.clientX));
|
|
72
|
-
}}
|
|
73
|
-
onPointerCancel={() => {
|
|
74
|
-
draggingRef.current = false;
|
|
75
|
-
}}
|
|
76
|
-
className="relative h-3 flex-1 cursor-ew-resize touch-none"
|
|
77
|
-
>
|
|
78
|
-
<div className="absolute top-1/2 h-0.5 w-full -translate-y-1/2 rounded-full bg-neutral-700" />
|
|
79
|
-
<div
|
|
80
|
-
className="absolute top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-[#5ff0bf]"
|
|
81
|
-
style={{ width: `${strength * 100}%` }}
|
|
82
|
-
/>
|
|
83
|
-
<div
|
|
84
|
-
className="absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-neutral-900 bg-[#5ff0bf]"
|
|
85
|
-
style={{ left: `${strength * 100}%` }}
|
|
86
|
-
/>
|
|
87
|
-
</div>
|
|
88
|
-
</div>
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
29
|
export function Transform3DCube({
|
|
93
30
|
pose,
|
|
94
31
|
perspective = 0,
|
|
32
|
+
defaultPerspective = 0,
|
|
33
|
+
z = 0,
|
|
95
34
|
onPoseDraft,
|
|
96
35
|
onPoseCommit,
|
|
97
|
-
|
|
98
|
-
|
|
36
|
+
onDepthDraft,
|
|
37
|
+
onDepthCommit,
|
|
99
38
|
onRecenter,
|
|
100
39
|
onKeyframe,
|
|
101
40
|
keyframed,
|
|
@@ -103,13 +42,18 @@ export function Transform3DCube({
|
|
|
103
42
|
pose: CubePose;
|
|
104
43
|
/** Element's transformPerspective (px); drives the cube's foreshortening. */
|
|
105
44
|
perspective?: number;
|
|
45
|
+
/** Comp-derived lens used for depth feedback before a perspective is committed. */
|
|
46
|
+
defaultPerspective?: number;
|
|
47
|
+
/** Element's translateZ (px) — "depth", adjusted by scrolling over the cube. */
|
|
48
|
+
z?: number;
|
|
106
49
|
/** Fires on every drag move with the in-progress pose (parent live-previews). */
|
|
107
50
|
onPoseDraft?: (pose: CubePose) => void;
|
|
108
51
|
/** Fires once on pointer release with the final pose (commit). */
|
|
109
52
|
onPoseCommit: (pose: CubePose) => void;
|
|
110
|
-
/** Live
|
|
111
|
-
|
|
112
|
-
|
|
53
|
+
/** Live depth (translateZ px) during a scroll; parent live-previews it. */
|
|
54
|
+
onDepthDraft?: (z: number) => void;
|
|
55
|
+
/** Committed depth (translateZ px) once a scroll burst settles. */
|
|
56
|
+
onDepthCommit?: (z: number) => void;
|
|
113
57
|
/** Reset to identity orientation. */
|
|
114
58
|
onRecenter?: () => void;
|
|
115
59
|
/** Toggle keyframing the 3D transform (convert the static set → keyframes). */
|
|
@@ -118,16 +62,74 @@ export function Transform3DCube({
|
|
|
118
62
|
keyframed?: boolean;
|
|
119
63
|
}) {
|
|
120
64
|
const [draft, setDraft] = useState<CubePose | null>(null);
|
|
65
|
+
const [depthDraft, setDepthDraft] = useState<number | null>(null);
|
|
121
66
|
const dragRef = useRef<{ x: number; y: number; pose: CubePose } | null>(null);
|
|
122
67
|
const shown = draft ?? pose;
|
|
68
|
+
const shownZ = depthDraft ?? z;
|
|
69
|
+
|
|
70
|
+
// Scroll over the cube to push the element along Z (depth) — matches the
|
|
71
|
+
// studio's "scroll = z depth" gesture-recording convention. A non-passive
|
|
72
|
+
// listener is required so preventDefault can stop the panel from scrolling.
|
|
73
|
+
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
74
|
+
// Perspective lens (committed, else the comp-derived default the panel will
|
|
75
|
+
// apply). Drives the cube's depth-scale feedback AND clamps the scroll so depth
|
|
76
|
+
// can't cross the lens. Defined here so the wheel handler can read it via the ref.
|
|
77
|
+
const lens = perspective > 0 ? perspective : defaultPerspective;
|
|
78
|
+
const depthRef = useRef({ z, onDepthDraft, onDepthCommit, lens });
|
|
79
|
+
depthRef.current = { z, onDepthDraft, onDepthCommit, lens };
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const el = svgRef.current;
|
|
82
|
+
if (!el) return;
|
|
83
|
+
let pending: number | null = null;
|
|
84
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
+
const onWheel = (e: WheelEvent) => {
|
|
86
|
+
const { onDepthCommit: commit, onDepthDraft: draft } = depthRef.current;
|
|
87
|
+
if (!commit) return;
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
// ponytail: 0.25 px of Z per wheel-delta unit (~25px per notch); tune if
|
|
90
|
+
// it feels too fast/slow. Scroll up (deltaY < 0) pushes toward the viewer.
|
|
91
|
+
let next = Math.round((pending ?? depthRef.current.z) - e.deltaY * 0.25);
|
|
92
|
+
// Clamp depth in front of the perspective lens. At z ≥ lens the element sits
|
|
93
|
+
// at/behind the virtual camera and the projection lens/(lens−z) blows up or
|
|
94
|
+
// inverts — that's the runaway "Z = 3195px past a 1080 lens". Cap just short
|
|
95
|
+
// of the lens; allow pushing well back (smaller) but not absurdly far.
|
|
96
|
+
const L = depthRef.current.lens;
|
|
97
|
+
if (L > 0) next = Math.max(Math.min(next, Math.round(L * 0.85)), Math.round(-L * 4));
|
|
98
|
+
pending = next;
|
|
99
|
+
draft?.(pending);
|
|
100
|
+
setDepthDraft(pending); // live-scale the cube while scrolling
|
|
101
|
+
if (timer) clearTimeout(timer);
|
|
102
|
+
timer = setTimeout(() => {
|
|
103
|
+
if (pending != null) commit(pending);
|
|
104
|
+
pending = null;
|
|
105
|
+
setDepthDraft(null); // fall back to the committed z prop
|
|
106
|
+
}, 160);
|
|
107
|
+
};
|
|
108
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
109
|
+
return () => {
|
|
110
|
+
el.removeEventListener("wheel", onWheel);
|
|
111
|
+
if (timer) clearTimeout(timer);
|
|
112
|
+
};
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
// Depth feedback: the cube scales like the element would — translateZ(z) under
|
|
116
|
+
// a perspective lens P appears scaled by P/(P-z). Closer (z>0) reads bigger,
|
|
117
|
+
// farther (z<0) smaller. Use the committed perspective, else the comp-derived
|
|
118
|
+
// lens the panel is about to apply — same value in both, so the cube doesn't
|
|
119
|
+
// jump when the commit lands. If neither is known, skip the scale (no lens).
|
|
120
|
+
const depthScale = lens > 0 ? Math.max(0.4, Math.min(2.2, lens / (lens - shownZ))) : 1;
|
|
123
121
|
const projOpts = {
|
|
124
122
|
cx: CX,
|
|
125
123
|
cy: CY,
|
|
126
|
-
r: RADIUS,
|
|
127
|
-
persp: pxToProjPersp(
|
|
124
|
+
r: RADIUS * depthScale,
|
|
125
|
+
persp: pxToProjPersp(lens),
|
|
128
126
|
};
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
// The element lives in CSS's screen-Y-down space; the cube projects Y-up. RotateX
|
|
128
|
+
// and RotateZ act in planes that contain Y, so they read inverted in the gizmo
|
|
129
|
+
// unless their sign is flipped — RotateY (X-Z plane) matches as-is. This keeps the
|
|
130
|
+
// cube's orientation a true mirror of the element.
|
|
131
|
+
const faces = projectCubeFaces(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts);
|
|
132
|
+
const axes = projectAxes(-shown.rotationX, shown.rotationY, -shown.rotationZ, projOpts);
|
|
131
133
|
|
|
132
134
|
const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
|
|
133
135
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
@@ -140,10 +142,13 @@ export function Transform3DCube({
|
|
|
140
142
|
if (!d) return;
|
|
141
143
|
const dx = e.clientX - d.x;
|
|
142
144
|
const dy = e.clientY - d.y;
|
|
145
|
+
// dy→rotationX and shift dx→rotationZ are negated to match the projection's
|
|
146
|
+
// sign flip (above), so the cube's response to a drag is unchanged while the
|
|
147
|
+
// element now rotates in lock-step with it.
|
|
143
148
|
const next: CubePose = e.shiftKey
|
|
144
|
-
? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ
|
|
149
|
+
? { ...d.pose, rotationZ: wrapDeg(d.pose.rotationZ - dx * SENSITIVITY) }
|
|
145
150
|
: {
|
|
146
|
-
rotationX: wrapDeg(d.pose.rotationX
|
|
151
|
+
rotationX: wrapDeg(d.pose.rotationX + dy * SENSITIVITY),
|
|
147
152
|
rotationY: wrapDeg(d.pose.rotationY + dx * SENSITIVITY),
|
|
148
153
|
rotationZ: d.pose.rotationZ,
|
|
149
154
|
};
|
|
@@ -161,6 +166,7 @@ export function Transform3DCube({
|
|
|
161
166
|
return (
|
|
162
167
|
<div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
|
|
163
168
|
<svg
|
|
169
|
+
ref={svgRef}
|
|
164
170
|
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
|
165
171
|
className="block w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
|
166
172
|
style={{ aspectRatio: `${VIEW_W} / ${VIEW_H}` }}
|
|
@@ -169,7 +175,7 @@ export function Transform3DCube({
|
|
|
169
175
|
onPointerUp={onPointerUp}
|
|
170
176
|
onPointerCancel={onPointerUp}
|
|
171
177
|
role="slider"
|
|
172
|
-
aria-label="Drag to rotate in 3D; hold Shift to roll"
|
|
178
|
+
aria-label="Drag to rotate in 3D; hold Shift to roll; scroll to change depth"
|
|
173
179
|
aria-valuetext={`X ${Math.round(shown.rotationX)}°, Y ${Math.round(
|
|
174
180
|
shown.rotationY,
|
|
175
181
|
)}°, Z ${Math.round(shown.rotationZ)}°`}
|
|
@@ -301,13 +307,6 @@ export function Transform3DCube({
|
|
|
301
307
|
</svg>
|
|
302
308
|
</button>
|
|
303
309
|
)}
|
|
304
|
-
{onPerspectiveCommit && (
|
|
305
|
-
<PerspectiveSlider
|
|
306
|
-
value={perspective}
|
|
307
|
-
onDraft={onPerspectiveDraft}
|
|
308
|
-
onCommit={onPerspectiveCommit}
|
|
309
|
-
/>
|
|
310
|
-
)}
|
|
311
310
|
</div>
|
|
312
311
|
);
|
|
313
312
|
}
|
|
@@ -186,6 +186,34 @@ export function resolveDomEditGroupOverlayRect(rects: OverlayRect[]): OverlayRec
|
|
|
186
186
|
};
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
// A group's overlay box encompasses its members' actual rendered bounds, not just
|
|
190
|
+
// the wrapper's own box — so members moved or transformed out of the wrapper still
|
|
191
|
+
// sit inside the box. Used by the selection, hover, and off-canvas overlays so they
|
|
192
|
+
// all agree on where a group is.
|
|
193
|
+
export function groupAwareOverlayRect(
|
|
194
|
+
overlayEl: HTMLDivElement,
|
|
195
|
+
iframe: HTMLIFrameElement,
|
|
196
|
+
el: HTMLElement,
|
|
197
|
+
): OverlayRect | null {
|
|
198
|
+
const rect = toOverlayRect(overlayEl, iframe, el);
|
|
199
|
+
if (!rect || !el.hasAttribute("data-hf-group")) return rect;
|
|
200
|
+
// Union the MEMBERS' rendered rects — where the content actually is — not the
|
|
201
|
+
// wrapper's own box. The wrapper is invisible and its box can sit apart from the
|
|
202
|
+
// members once they've been moved/transformed, which would otherwise drag the
|
|
203
|
+
// group's bounds (and its off-canvas marker) off to a stale position.
|
|
204
|
+
const rects: OverlayRect[] = [];
|
|
205
|
+
for (const child of Array.from(el.children)) {
|
|
206
|
+
const childRect = toOverlayRect(overlayEl, iframe, child as HTMLElement);
|
|
207
|
+
if (childRect) rects.push(childRect);
|
|
208
|
+
}
|
|
209
|
+
const union = rects.length > 0 ? resolveDomEditGroupOverlayRect(rects) : null;
|
|
210
|
+
if (!union) return rect; // empty group → fall back to the wrapper box
|
|
211
|
+
// resolveDomEditGroupOverlayRect hardcodes editScaleX/Y to 1; keep the wrapper's
|
|
212
|
+
// real edit (display) scale, which the drag uses to convert pointer→offset — a
|
|
213
|
+
// reset-to-1 makes the group move at ~display-scale speed and lag the cursor.
|
|
214
|
+
return { ...union, editScaleX: rect.editScaleX, editScaleY: rect.editScaleY };
|
|
215
|
+
}
|
|
216
|
+
|
|
189
217
|
export function filterNestedDomEditGroupItems<T extends { element: HTMLElement }>(items: T[]): T[] {
|
|
190
218
|
return items.filter(
|
|
191
219
|
(item) => !items.some((other) => other !== item && other.element.contains(item.element)),
|
|
@@ -250,7 +250,7 @@ export function querySelectorAllSafely(doc: Document, selector: string): Element
|
|
|
250
250
|
}
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
|
|
253
|
+
function humanizeIdentifier(value: string): string {
|
|
254
254
|
return (
|
|
255
255
|
value
|
|
256
256
|
.replace(/\.html$/i, "")
|
|
@@ -270,10 +270,16 @@ export function buildStableSelector(el: HTMLElement): string | undefined {
|
|
|
270
270
|
const compositionId = el.getAttribute("data-composition-id");
|
|
271
271
|
if (compositionId) return `[data-composition-id="${escapeCssString(compositionId)}"]`;
|
|
272
272
|
|
|
273
|
+
// Group wrappers carry no id/class; their data-hf-group value is the unique,
|
|
274
|
+
// stable handle the source mutations write — use it so the wrapper is
|
|
275
|
+
// selectable, patchable (move/scale), and addressable for ungroup.
|
|
276
|
+
const group = el.getAttribute("data-hf-group");
|
|
277
|
+
if (group) return `[data-hf-group="${escapeCssString(group)}"]`;
|
|
278
|
+
|
|
273
279
|
return getPreferredClassSelector(el);
|
|
274
280
|
}
|
|
275
281
|
|
|
276
|
-
|
|
282
|
+
function getPreferredClassSelector(el: HTMLElement): string | undefined {
|
|
277
283
|
const classes = Array.from(el.classList)
|
|
278
284
|
.map((value) => value.trim())
|
|
279
285
|
.filter(Boolean);
|
|
@@ -283,6 +289,34 @@ export function getPreferredClassSelector(el: HTMLElement): string | undefined {
|
|
|
283
289
|
return preferred ? `.${escapeCssIdentifier(preferred)}` : undefined;
|
|
284
290
|
}
|
|
285
291
|
|
|
292
|
+
// fallow-ignore-next-line complexity
|
|
293
|
+
export function buildElementLabel(el: HTMLElement): string {
|
|
294
|
+
const compositionId = el.getAttribute("data-composition-id");
|
|
295
|
+
if (compositionId && compositionId !== "main") {
|
|
296
|
+
return humanizeIdentifier(compositionId);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const compositionSrc =
|
|
300
|
+
el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file");
|
|
301
|
+
if (compositionSrc) {
|
|
302
|
+
return humanizeIdentifier(compositionSrc);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const group = el.getAttribute("data-hf-group");
|
|
306
|
+
if (group) return group;
|
|
307
|
+
|
|
308
|
+
if (el.id) return humanizeIdentifier(el.id);
|
|
309
|
+
|
|
310
|
+
const preferredClass = getPreferredClassSelector(el);
|
|
311
|
+
if (preferredClass) {
|
|
312
|
+
return humanizeIdentifier(preferredClass.replace(/^\./, ""));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
316
|
+
if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text;
|
|
317
|
+
return el.tagName.toLowerCase();
|
|
318
|
+
}
|
|
319
|
+
|
|
286
320
|
export function getSelectorIndex(
|
|
287
321
|
doc: Document,
|
|
288
322
|
el: HTMLElement,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { isHtmlElement } from "./domEditingDom";
|
|
2
|
+
|
|
3
|
+
// `data-hf-group` selection semantics: a group wrapper is selected as one unit
|
|
4
|
+
// until the user drills into it; once drilled in, clicks resolve to its children
|
|
5
|
+
// (or to the next nested group inside it). One level of drill-in at a time keeps
|
|
6
|
+
// nested groups navigable.
|
|
7
|
+
|
|
8
|
+
export type GroupCapture =
|
|
9
|
+
| { kind: "unit"; element: HTMLElement } // select this group wrapper as one unit
|
|
10
|
+
| { kind: "child" } // resolve the clicked element normally
|
|
11
|
+
| { kind: "out-of-scope" }; // clicked outside the drilled-into group → select nothing
|
|
12
|
+
|
|
13
|
+
// Layer-tree roots: the drilled-into group's element children, else the doc root.
|
|
14
|
+
export function groupScopedLayerRoots(
|
|
15
|
+
root: HTMLElement,
|
|
16
|
+
activeGroupElement: HTMLElement | null,
|
|
17
|
+
): HTMLElement[] {
|
|
18
|
+
const els = activeGroupElement?.isConnected ? Array.from(activeGroupElement.children) : [root];
|
|
19
|
+
return els.filter(isHtmlElement);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveGroupCapture(
|
|
23
|
+
startEl: HTMLElement,
|
|
24
|
+
activeGroupElement: HTMLElement | null,
|
|
25
|
+
): GroupCapture {
|
|
26
|
+
const groups: HTMLElement[] = [];
|
|
27
|
+
for (let n: HTMLElement | null = startEl; n; n = n.parentElement) {
|
|
28
|
+
if (n.hasAttribute("data-hf-group")) groups.push(n);
|
|
29
|
+
}
|
|
30
|
+
const result = ((): GroupCapture => {
|
|
31
|
+
if (!activeGroupElement) {
|
|
32
|
+
const outermost = groups[groups.length - 1];
|
|
33
|
+
return outermost ? { kind: "unit", element: outermost } : { kind: "child" };
|
|
34
|
+
}
|
|
35
|
+
const idx = groups.indexOf(activeGroupElement);
|
|
36
|
+
if (idx === -1) return { kind: "out-of-scope" };
|
|
37
|
+
const nestedInside = groups[idx - 1];
|
|
38
|
+
return nestedInside ? { kind: "unit", element: nestedInside } : { kind: "child" };
|
|
39
|
+
})();
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
collectDomEditLayerItems,
|
|
5
|
+
resolveDomEditSelection,
|
|
6
|
+
buildDomEditPatchTarget,
|
|
7
|
+
readHfId,
|
|
8
|
+
} from "./domEditingLayers";
|
|
4
9
|
|
|
5
10
|
const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true };
|
|
6
11
|
|
|
@@ -76,3 +81,94 @@ describe("resolveDomEditSelection — hfId from data-hf-id", () => {
|
|
|
76
81
|
expect(selection?.hfId).toBeUndefined();
|
|
77
82
|
});
|
|
78
83
|
});
|
|
84
|
+
|
|
85
|
+
describe("resolveDomEditSelection — data-hf-group capture", () => {
|
|
86
|
+
// <div id="parent"><div data-hf-group="Group 1"><div data-hf-group="Group 2">
|
|
87
|
+
// <span id="child"/></div></div></div>
|
|
88
|
+
function buildNestedGroups() {
|
|
89
|
+
const parent = document.createElement("div");
|
|
90
|
+
parent.id = "parent";
|
|
91
|
+
const outer = document.createElement("div");
|
|
92
|
+
outer.setAttribute("data-hf-group", "Group 1");
|
|
93
|
+
const inner = document.createElement("div");
|
|
94
|
+
inner.setAttribute("data-hf-group", "Group 2");
|
|
95
|
+
const child = document.createElement("span");
|
|
96
|
+
child.id = "child";
|
|
97
|
+
inner.appendChild(child);
|
|
98
|
+
outer.appendChild(inner);
|
|
99
|
+
parent.appendChild(outer);
|
|
100
|
+
document.body.appendChild(parent);
|
|
101
|
+
return { parent, outer, inner, child };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
it("selects the outermost group as a unit when clicking a child (not drilled in)", async () => {
|
|
105
|
+
const { parent, outer, child } = buildNestedGroups();
|
|
106
|
+
const selection = await resolveDomEditSelection(child, opts);
|
|
107
|
+
document.body.removeChild(parent);
|
|
108
|
+
|
|
109
|
+
expect(selection?.element).toBe(outer);
|
|
110
|
+
expect(selection?.selector).toBe('[data-hf-group="Group 1"]');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("selects the next nested group when drilled into the outer group", async () => {
|
|
114
|
+
const { parent, outer, inner, child } = buildNestedGroups();
|
|
115
|
+
const selection = await resolveDomEditSelection(child, { ...opts, activeGroupElement: outer });
|
|
116
|
+
document.body.removeChild(parent);
|
|
117
|
+
|
|
118
|
+
expect(selection?.element).toBe(inner);
|
|
119
|
+
expect(selection?.selector).toBe('[data-hf-group="Group 2"]');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("selects the child when drilled all the way into the innermost group", async () => {
|
|
123
|
+
const { parent, inner, child } = buildNestedGroups();
|
|
124
|
+
const selection = await resolveDomEditSelection(child, { ...opts, activeGroupElement: inner });
|
|
125
|
+
document.body.removeChild(parent);
|
|
126
|
+
|
|
127
|
+
expect(selection?.element).toBe(child);
|
|
128
|
+
expect(selection?.id).toBe("child");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("layer tree is scoped to the group's members when drilled in", () => {
|
|
132
|
+
const root = document.createElement("div");
|
|
133
|
+
root.setAttribute("data-composition-id", "main");
|
|
134
|
+
const group = document.createElement("div");
|
|
135
|
+
group.setAttribute("data-hf-group", "Group 1");
|
|
136
|
+
const inside = document.createElement("div");
|
|
137
|
+
inside.id = "inside";
|
|
138
|
+
const outside = document.createElement("div");
|
|
139
|
+
outside.id = "outside";
|
|
140
|
+
group.appendChild(inside);
|
|
141
|
+
root.appendChild(group);
|
|
142
|
+
root.appendChild(outside);
|
|
143
|
+
document.body.appendChild(root);
|
|
144
|
+
|
|
145
|
+
const opts2 = { activeCompositionPath: "index.html", isMasterView: true };
|
|
146
|
+
const full = collectDomEditLayerItems(root, opts2).map((i) => i.id);
|
|
147
|
+
const scoped = collectDomEditLayerItems(root, { ...opts2, activeGroupElement: group }).map(
|
|
148
|
+
(i) => i.id,
|
|
149
|
+
);
|
|
150
|
+
document.body.removeChild(root);
|
|
151
|
+
|
|
152
|
+
expect(full).toContain("outside");
|
|
153
|
+
expect(scoped).toContain("inside");
|
|
154
|
+
expect(scoped).not.toContain("outside");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("exits the drilled-into group and selects the outside element (non-sticky drill)", async () => {
|
|
158
|
+
const { parent, inner } = buildNestedGroups();
|
|
159
|
+
const outside = document.createElement("div");
|
|
160
|
+
outside.id = "outside";
|
|
161
|
+
document.body.appendChild(outside);
|
|
162
|
+
|
|
163
|
+
const selection = await resolveDomEditSelection(outside, {
|
|
164
|
+
...opts,
|
|
165
|
+
activeGroupElement: inner,
|
|
166
|
+
});
|
|
167
|
+
document.body.removeChild(parent);
|
|
168
|
+
document.body.removeChild(outside);
|
|
169
|
+
|
|
170
|
+
// Drill-in is non-sticky: clicking outside the active group exits it and
|
|
171
|
+
// resolves the clicked element normally (rather than selecting nothing).
|
|
172
|
+
expect(selection?.id).toBe("outside");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* for dom editing.
|
|
4
4
|
*/
|
|
5
5
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
6
|
+
import { groupScopedLayerRoots, resolveGroupCapture } from "./domEditingGroups";
|
|
6
7
|
import type {
|
|
7
8
|
DomEditCapabilities,
|
|
8
9
|
DomEditContextOptions,
|
|
@@ -11,15 +12,14 @@ import type {
|
|
|
11
12
|
DomEditTextField,
|
|
12
13
|
} from "./domEditingTypes";
|
|
13
14
|
import {
|
|
15
|
+
buildElementLabel,
|
|
14
16
|
buildStableSelector,
|
|
15
17
|
findClosestByAttribute,
|
|
16
18
|
getCuratedComputedStyles,
|
|
17
19
|
getDataAttributes,
|
|
18
20
|
getInlineStyles,
|
|
19
|
-
getPreferredClassSelector,
|
|
20
21
|
getSelectorIndex,
|
|
21
22
|
getSourceFileForElement,
|
|
22
|
-
humanizeIdentifier,
|
|
23
23
|
isHtmlElement,
|
|
24
24
|
isIdentityTransform,
|
|
25
25
|
isTextBearingTag,
|
|
@@ -275,31 +275,6 @@ export function resolveDomEditCapabilities(args: {
|
|
|
275
275
|
|
|
276
276
|
// ─── Element label ────────────────────────────────────────────────────────────
|
|
277
277
|
|
|
278
|
-
// fallow-ignore-next-line complexity
|
|
279
|
-
export function buildElementLabel(el: HTMLElement): string {
|
|
280
|
-
const compositionId = el.getAttribute("data-composition-id");
|
|
281
|
-
if (compositionId && compositionId !== "main") {
|
|
282
|
-
return humanizeIdentifier(compositionId);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const compositionSrc =
|
|
286
|
-
el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file");
|
|
287
|
-
if (compositionSrc) {
|
|
288
|
-
return humanizeIdentifier(compositionSrc);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (el.id) return humanizeIdentifier(el.id);
|
|
292
|
-
|
|
293
|
-
const preferredClass = getPreferredClassSelector(el);
|
|
294
|
-
if (preferredClass) {
|
|
295
|
-
return humanizeIdentifier(preferredClass.replace(/^\./, ""));
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
299
|
-
if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text;
|
|
300
|
-
return el.tagName.toLowerCase();
|
|
301
|
-
}
|
|
302
|
-
|
|
303
278
|
// ─── Source probe ────────────────────────────────────────────────────────────
|
|
304
279
|
|
|
305
280
|
async function probeSourceElement(
|
|
@@ -334,7 +309,15 @@ export async function resolveDomEditSelection(
|
|
|
334
309
|
if (!startEl) return null;
|
|
335
310
|
const doc = startEl.ownerDocument;
|
|
336
311
|
|
|
337
|
-
let
|
|
312
|
+
let capture = resolveGroupCapture(startEl, options.activeGroupElement ?? null);
|
|
313
|
+
if (capture.kind === "out-of-scope") {
|
|
314
|
+
// Drill-in is non-sticky: clicking/hovering OUTSIDE the drilled-into group
|
|
315
|
+
// exits it and resolves the target normally, rather than selecting nothing
|
|
316
|
+
// (which felt like "can't select anything" once you'd drilled in).
|
|
317
|
+
capture = resolveGroupCapture(startEl, null);
|
|
318
|
+
}
|
|
319
|
+
let current: HTMLElement | null =
|
|
320
|
+
capture.kind === "unit" ? capture.element : getSelectionCandidate(startEl, options);
|
|
338
321
|
while (current && current !== doc.body && current !== doc.documentElement) {
|
|
339
322
|
const selector = buildStableSelector(current);
|
|
340
323
|
const hfId = readHfId(current);
|
|
@@ -501,7 +484,8 @@ export function collectDomEditLayerItems(
|
|
|
501
484
|
}
|
|
502
485
|
};
|
|
503
486
|
|
|
504
|
-
|
|
487
|
+
// Drilled into a group → show only its members; otherwise the whole tree.
|
|
488
|
+
for (const el of groupScopedLayerRoots(root, options.activeGroupElement ?? null)) visit(el, 0);
|
|
505
489
|
return items;
|
|
506
490
|
}
|
|
507
491
|
|
|
@@ -108,6 +108,9 @@ export interface DomEditContextOptions {
|
|
|
108
108
|
activeCompositionPath: string | null;
|
|
109
109
|
isMasterView: boolean;
|
|
110
110
|
preferClipAncestor?: boolean;
|
|
111
|
+
/** The group wrapper the user has drilled into (null = top level). Selection
|
|
112
|
+
* resolution treats groups as a unit unless drilled into one. */
|
|
113
|
+
activeGroupElement?: HTMLElement | null;
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
export interface DomEditViewport {
|
|
@@ -209,6 +209,22 @@ export function applyManualOffsetDragMatrix(matrix: ManualOffsetDragMatrix, poin
|
|
|
209
209
|
};
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The perspective w-divisor (matrix3d m44) of the element's current transform.
|
|
214
|
+
* For a plain `translateZ(z)` under `perspective(p)`, m44 = (p - z) / p, so the
|
|
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). Used
|
|
217
|
+
* to keep the drag offset → screen-movement mapping correct for depth elements,
|
|
218
|
+
* which the flat-scale fast path below would otherwise get wrong by 1/m44.
|
|
219
|
+
*/
|
|
220
|
+
function readTransformWDivisor(element: HTMLElement): number {
|
|
221
|
+
const t = element.ownerDocument.defaultView?.getComputedStyle(element).transform;
|
|
222
|
+
if (!t || !t.startsWith("matrix3d(")) return 1;
|
|
223
|
+
const parts = t.slice("matrix3d(".length, -1).split(",");
|
|
224
|
+
const w = Number.parseFloat(parts[15] ?? "");
|
|
225
|
+
return Number.isFinite(w) && w > 0 ? w : 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
212
228
|
export function measureManualOffsetDragScreenToOffsetMatrix(
|
|
213
229
|
element: HTMLElement,
|
|
214
230
|
initialOffset: { x: number; y: number },
|
|
@@ -221,7 +237,11 @@ export function measureManualOffsetDragScreenToOffsetMatrix(
|
|
|
221
237
|
) {
|
|
222
238
|
const sx = options.scaleX || 1;
|
|
223
239
|
const sy = options.scaleY || 1;
|
|
224
|
-
|
|
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 } };
|
|
225
245
|
}
|
|
226
246
|
|
|
227
247
|
const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX;
|
|
@@ -360,6 +380,7 @@ export function createManualOffsetDragMember(input: {
|
|
|
360
380
|
// drag is acceptable — the final committed position is always exact.
|
|
361
381
|
const scaleX = input.rect.editScaleX || 1;
|
|
362
382
|
const scaleY = input.rect.editScaleY || 1;
|
|
383
|
+
const w = readTransformWDivisor(input.element);
|
|
363
384
|
return {
|
|
364
385
|
ok: true,
|
|
365
386
|
member: {
|
|
@@ -370,7 +391,7 @@ export function createManualOffsetDragMember(input: {
|
|
|
370
391
|
baseGsap,
|
|
371
392
|
initialPathOffset,
|
|
372
393
|
gestureToken,
|
|
373
|
-
screenToOffset: { a:
|
|
394
|
+
screenToOffset: { a: w / scaleX, b: 0, c: 0, d: w / scaleY },
|
|
374
395
|
originRect: input.rect,
|
|
375
396
|
},
|
|
376
397
|
};
|