@hyperframes/studio 0.8.93 → 0.8.94
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-CkV9KK6A.js → hyperframes-player-Bkm-3G56.js} +1 -1
- package/dist/assets/{index-BlLFipOk.js → index-BUn5H_np.js} +1 -1
- package/dist/assets/{index-CYk0NwSv.js → index-BupvpoiM.js} +175 -175
- package/dist/assets/{index-V3fzEfOD.js → index-n2ZRiO44.js} +1 -1
- package/dist/index.d.ts +62 -46
- package/dist/index.html +1 -1
- package/dist/index.js +142 -98
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +17 -0
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/ConnectedDomEditOverlay.test.tsx +2 -0
- package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -1
- package/src/components/editor/DomEditCropHandles.test.tsx +87 -1
- package/src/components/editor/DomEditCropHandles.tsx +64 -62
- package/src/components/editor/DomEditOverlay.test.ts +39 -0
- package/src/components/editor/DomEditOverlay.tsx +6 -1
- package/src/components/editor/DomEditSelectionChrome.test.tsx +114 -1
- package/src/components/editor/DomEditSelectionChrome.tsx +17 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +19 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +1 -1
- package/src/components/editor/SnapToolbar.tsx +1 -1
- package/src/components/editor/domEditOverlayGeometry.ts +2 -0
- package/src/components/nle/PreviewOverlays.tsx +3 -3
- package/src/hooks/gsapResizeIntercept.test.ts +40 -0
- package/src/hooks/gsapResizeIntercept.ts +8 -4
- package/src/index.ts +4 -0
- package/src/player/components/PlayerControls.tsx +2 -15
- package/src/player/components/ShortcutsPanel.test.tsx +33 -1
- package/src/player/components/ShortcutsPanel.tsx +26 -0
- package/src/player/components/Timeline.trackPadding.test.tsx +157 -0
- package/src/player/components/TimelineCanvas.tsx +16 -19
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/timelineLayout.test.ts +47 -0
- package/src/player/components/timelineLayout.ts +22 -15
- package/src/player/components/useTimelineProviderState.tsx +2 -0
- package/src/player/components/useTimelineTrackLayout.ts +11 -2
- package/src/player/components/useTimelineVirtualRows.test.tsx +32 -0
- package/src/player/components/useTimelineVirtualRows.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.94",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/core": "0.8.
|
|
64
|
-
"@hyperframes/shader-transitions": "0.8.
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/sdk": "0.8.
|
|
63
|
+
"@hyperframes/core": "0.8.94",
|
|
64
|
+
"@hyperframes/shader-transitions": "0.8.94",
|
|
65
|
+
"@hyperframes/player": "0.8.94",
|
|
66
|
+
"@hyperframes/studio-server": "0.8.94",
|
|
67
|
+
"@hyperframes/parsers": "0.8.94",
|
|
68
|
+
"@hyperframes/sdk": "0.8.94"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.94"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -278,6 +278,23 @@ describe("TimelineToolbar audio meters", () => {
|
|
|
278
278
|
});
|
|
279
279
|
});
|
|
280
280
|
|
|
281
|
+
describe("TimelineToolbar history", () => {
|
|
282
|
+
const historyButtons = (host: HTMLElement) =>
|
|
283
|
+
host.querySelectorAll('button[aria-label="Undo"], button[aria-label="Redo"]').length;
|
|
284
|
+
|
|
285
|
+
it("shows Undo and Redo by default", () => {
|
|
286
|
+
const { host, root } = renderToolbar();
|
|
287
|
+
expect(historyButtons(host)).toBe(2);
|
|
288
|
+
act(() => root.unmount());
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("renders neither when the host hides history", () => {
|
|
292
|
+
const { host, root } = renderToolbar(undefined, { showHistory: false });
|
|
293
|
+
expect(historyButtons(host)).toBe(0);
|
|
294
|
+
act(() => root.unmount());
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
281
298
|
describe("TimelineToolbar add beat", () => {
|
|
282
299
|
it("shows Add beat by default, as Studio does", () => {
|
|
283
300
|
const { host, root } = renderToolbar();
|
|
@@ -43,6 +43,7 @@ export interface TimelineToolbarProps {
|
|
|
43
43
|
domEditSession?: DomEditSessionSlice;
|
|
44
44
|
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
|
45
45
|
history?: TimelineHistoryButtonsProps;
|
|
46
|
+
showHistory?: boolean;
|
|
46
47
|
showAddBeat?: boolean;
|
|
47
48
|
/** Hides Add keyframe and auto-record, and turns off auto-record and the K shortcut with them. */
|
|
48
49
|
showKeyframes?: boolean;
|
|
@@ -141,6 +142,7 @@ export function TimelineToolbar({
|
|
|
141
142
|
domEditSession,
|
|
142
143
|
onSplitElement,
|
|
143
144
|
history,
|
|
145
|
+
showHistory = true,
|
|
144
146
|
showAddBeat = true,
|
|
145
147
|
showKeyframes = true,
|
|
146
148
|
}: TimelineToolbarProps) {
|
|
@@ -213,7 +215,7 @@ export function TimelineToolbar({
|
|
|
213
215
|
<div className="border-b border-neutral-800/60">
|
|
214
216
|
<div className="flex items-center justify-between px-2 py-0.5">
|
|
215
217
|
<div className="flex items-center gap-0.5">
|
|
216
|
-
<TimelineHistoryButtons {...history} />
|
|
218
|
+
{showHistory && <TimelineHistoryButtons {...history} />}
|
|
217
219
|
<TimelineToolPicker />
|
|
218
220
|
{/* Divider: tool-mode | editing-actions */}
|
|
219
221
|
<div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
|
|
@@ -149,11 +149,13 @@ describe("ConnectedDomEditOverlay", () => {
|
|
|
149
149
|
showHoverSelection={false}
|
|
150
150
|
shouldShowSelectedDomBounds={true}
|
|
151
151
|
canvasInput="host"
|
|
152
|
+
allowBodyDrag={false}
|
|
152
153
|
onTextEditingChange={onTextEditingChange}
|
|
153
154
|
/>,
|
|
154
155
|
);
|
|
155
156
|
expect(props).toMatchObject({
|
|
156
157
|
canvasInput: "host",
|
|
158
|
+
allowBodyDrag: false,
|
|
157
159
|
onTextEditingChange,
|
|
158
160
|
hoverSelection: null,
|
|
159
161
|
selection: ctx.selection.domEditSelection,
|
|
@@ -13,7 +13,7 @@ import { runZLaneGesture } from "../nle/zLaneGesture";
|
|
|
13
13
|
|
|
14
14
|
type HostInputProps = Pick<
|
|
15
15
|
ComponentProps<typeof DomEditOverlay>,
|
|
16
|
-
"canvasInput" | "onSelectionBoxClick"
|
|
16
|
+
"canvasInput" | "onSelectionBoxClick" | "allowBodyDrag"
|
|
17
17
|
>;
|
|
18
18
|
|
|
19
19
|
export interface ConnectedDomEditOverlayProps extends HostInputProps {
|
|
@@ -121,6 +121,7 @@ export function ConnectedDomEditOverlay({
|
|
|
121
121
|
shouldShowSelectedDomBounds,
|
|
122
122
|
isGestureRecording,
|
|
123
123
|
canvasInput,
|
|
124
|
+
allowBodyDrag,
|
|
124
125
|
onTextEditingChange,
|
|
125
126
|
onSelectionBoxClick,
|
|
126
127
|
}: ConnectedDomEditOverlayProps) {
|
|
@@ -154,6 +155,7 @@ export function ConnectedDomEditOverlay({
|
|
|
154
155
|
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
|
|
155
156
|
allowCanvasMovement={!isGestureRecording}
|
|
156
157
|
canvasInput={canvasInput}
|
|
158
|
+
allowBodyDrag={allowBodyDrag}
|
|
157
159
|
onTextEditingChange={onTextEditingChange}
|
|
158
160
|
onSelectionBoxClick={onSelectionBoxClick}
|
|
159
161
|
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
@@ -36,6 +36,7 @@ function makeEl(id: string, clip: string): HTMLElement {
|
|
|
36
36
|
function render(
|
|
37
37
|
el: HTMLElement,
|
|
38
38
|
onStyleCommit: (property: string, value: string) => Promise<unknown> | void = () => undefined,
|
|
39
|
+
rect: OverlayRect = overlayRect,
|
|
39
40
|
): { root: Root; rerender: (next: HTMLElement) => void } {
|
|
40
41
|
const host = document.createElement("div");
|
|
41
42
|
document.body.append(host);
|
|
@@ -45,7 +46,7 @@ function render(
|
|
|
45
46
|
root.render(
|
|
46
47
|
<DomEditCropHandles
|
|
47
48
|
selection={selectionFor(target)}
|
|
48
|
-
overlayRect={
|
|
49
|
+
overlayRect={rect}
|
|
49
50
|
onStyleCommit={onStyleCommit}
|
|
50
51
|
/>,
|
|
51
52
|
);
|
|
@@ -155,3 +156,88 @@ describe("DomEditCropHandles clip lift/restore", () => {
|
|
|
155
156
|
act(() => root.unmount());
|
|
156
157
|
});
|
|
157
158
|
});
|
|
159
|
+
|
|
160
|
+
describe("DomEditCropHandles leaves the corner resize dots free", () => {
|
|
161
|
+
type Box = { left: number; top: number; width: number; height: number };
|
|
162
|
+
const HIT = 16;
|
|
163
|
+
const cornerSquares = (r: Box): Box[] =>
|
|
164
|
+
[
|
|
165
|
+
[r.left, r.top],
|
|
166
|
+
[r.left + r.width, r.top],
|
|
167
|
+
[r.left, r.top + r.height],
|
|
168
|
+
[r.left + r.width, r.top + r.height],
|
|
169
|
+
].map(([x, y]) => ({ left: x - HIT / 2, top: y - HIT / 2, width: HIT, height: HIT }));
|
|
170
|
+
const overlaps = (a: Box, b: Box) =>
|
|
171
|
+
a.left < b.left + b.width &&
|
|
172
|
+
b.left < a.left + a.width &&
|
|
173
|
+
a.top < b.top + b.height &&
|
|
174
|
+
b.top < a.top + a.height;
|
|
175
|
+
const handles = () =>
|
|
176
|
+
[...document.querySelectorAll<HTMLElement>("[data-dom-edit-crop-handle]")].map((el) => ({
|
|
177
|
+
label: el.getAttribute("aria-label"),
|
|
178
|
+
box: {
|
|
179
|
+
left: parseFloat(el.style.left),
|
|
180
|
+
top: parseFloat(el.style.top),
|
|
181
|
+
width: parseFloat(el.style.width),
|
|
182
|
+
height: parseFloat(el.style.height),
|
|
183
|
+
},
|
|
184
|
+
}));
|
|
185
|
+
const rectOf = (width: number, height: number): OverlayRect => ({
|
|
186
|
+
left: 0,
|
|
187
|
+
top: 0,
|
|
188
|
+
width,
|
|
189
|
+
height,
|
|
190
|
+
editScaleX: 1,
|
|
191
|
+
editScaleY: 1,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it.each([
|
|
195
|
+
{
|
|
196
|
+
name: "29x14",
|
|
197
|
+
size: [29, 14],
|
|
198
|
+
clip: "",
|
|
199
|
+
cropped: { left: 0, top: 0, width: 29, height: 14 },
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: "40x40",
|
|
203
|
+
size: [40, 40],
|
|
204
|
+
clip: "",
|
|
205
|
+
cropped: { left: 0, top: 0, width: 40, height: 40 },
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: "29x14",
|
|
209
|
+
size: [29, 14],
|
|
210
|
+
clip: "inset(2px)",
|
|
211
|
+
cropped: { left: 2, top: 2, width: 25, height: 10 },
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "200x100",
|
|
215
|
+
size: [200, 100],
|
|
216
|
+
clip: "inset(10px)",
|
|
217
|
+
cropped: { left: 10, top: 10, width: 180, height: 80 },
|
|
218
|
+
},
|
|
219
|
+
])("no crop handle covers a corner of a $name box clipped '$clip'", (c) => {
|
|
220
|
+
render(makeEl("a", c.clip), undefined, rectOf(c.size[0], c.size[1]));
|
|
221
|
+
const found = handles();
|
|
222
|
+
expect(found.length).toBeGreaterThan(0);
|
|
223
|
+
for (const handle of found) {
|
|
224
|
+
for (const corner of cornerSquares(c.cropped)) {
|
|
225
|
+
expect(overlaps(handle.box, corner), `${handle.label} covers a corner`).toBe(false);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("keeps every handle where the edges have room, and drops only those with none", () => {
|
|
231
|
+
render(makeEl("a", "inset(10px)"), undefined, rectOf(200, 100));
|
|
232
|
+
expect(handles().map((h) => h.label)).toEqual([
|
|
233
|
+
"Reposition crop",
|
|
234
|
+
"Crop top",
|
|
235
|
+
"Crop right",
|
|
236
|
+
"Crop bottom",
|
|
237
|
+
"Crop left",
|
|
238
|
+
]);
|
|
239
|
+
document.body.innerHTML = "";
|
|
240
|
+
render(makeEl("b", ""), undefined, rectOf(29, 14));
|
|
241
|
+
expect(handles().map((h) => h.label)).toEqual(["Crop top", "Crop bottom"]);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
|
2
2
|
import type { DomEditSelection } from "./domEditing";
|
|
3
|
-
import type
|
|
3
|
+
import { type OverlayRect, RESIZE_HANDLE_HIT_PX } from "./domEditOverlayGeometry";
|
|
4
4
|
import {
|
|
5
5
|
type CropEdge,
|
|
6
6
|
cropRectFromInsets,
|
|
@@ -37,47 +37,53 @@ interface DomEditCropHandlesProps {
|
|
|
37
37
|
// and centered inside the strip.
|
|
38
38
|
const EDGE_HIT_THICKNESS = 12;
|
|
39
39
|
const EDGE_HIT_LENGTH = 32;
|
|
40
|
+
const EDGE_PILL_LENGTH = 24;
|
|
41
|
+
const REPOSITION_HANDLE_PX = 22;
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
* (translate pushes it fully past the boundary). Keeps the element body free
|
|
43
|
-
* for moving. Corners stay free for the selection's own resize handles. */
|
|
44
|
-
function edgeHandlePlacement(
|
|
45
|
-
edge: CropEdge,
|
|
46
|
-
rect: { left: number; top: number; width: number; height: number },
|
|
47
|
-
) {
|
|
48
|
-
const cx = rect.left + rect.width / 2;
|
|
49
|
-
const cy = rect.top + rect.height / 2;
|
|
50
|
-
if (edge === "top") {
|
|
51
|
-
return { left: cx, top: rect.top, transform: "translate(-50%, -100%)" };
|
|
52
|
-
}
|
|
53
|
-
if (edge === "bottom") {
|
|
54
|
-
return { left: cx, top: rect.top + rect.height, transform: "translate(-50%, 0)" };
|
|
55
|
-
}
|
|
56
|
-
if (edge === "left") {
|
|
57
|
-
return { left: rect.left, top: cy, transform: "translate(-100%, -50%)" };
|
|
58
|
-
}
|
|
59
|
-
return { left: rect.left + rect.width, top: cy, transform: "translate(0, -50%)" };
|
|
60
|
-
}
|
|
43
|
+
type Rect = { left: number; top: number; width: number; height: number };
|
|
61
44
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
45
|
+
/** An edge handle's hit strip, just OUTSIDE the crop edge so the body stays free
|
|
46
|
+
* for moving, and short of the corner squares the resize dots own, with its
|
|
47
|
+
* cursor and pill. Null when the edge is too short to leave any room. */
|
|
48
|
+
function edgeHandleLayout(edge: CropEdge, rect: Rect) {
|
|
49
|
+
const vertical = edge === "left" || edge === "right";
|
|
50
|
+
const length = Math.min(
|
|
51
|
+
EDGE_HIT_LENGTH,
|
|
52
|
+
(vertical ? rect.height : rect.width) - RESIZE_HANDLE_HIT_PX,
|
|
53
|
+
);
|
|
54
|
+
if (length <= 0) return null;
|
|
55
|
+
const pill = Math.min(EDGE_PILL_LENGTH, length);
|
|
56
|
+
if (vertical) {
|
|
57
|
+
return {
|
|
58
|
+
hit: {
|
|
59
|
+
left: edge === "left" ? rect.left - EDGE_HIT_THICKNESS : rect.left + rect.width,
|
|
60
|
+
top: rect.top + (rect.height - length) / 2,
|
|
61
|
+
width: EDGE_HIT_THICKNESS,
|
|
62
|
+
height: length,
|
|
63
|
+
},
|
|
64
|
+
cursor: "ew-resize",
|
|
65
|
+
pill: { width: 4, height: pill },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
72
68
|
return {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
69
|
+
hit: {
|
|
70
|
+
left: rect.left + (rect.width - length) / 2,
|
|
71
|
+
top: edge === "top" ? rect.top - EDGE_HIT_THICKNESS : rect.top + rect.height,
|
|
72
|
+
width: length,
|
|
73
|
+
height: EDGE_HIT_THICKNESS,
|
|
74
|
+
},
|
|
75
|
+
cursor: "ns-resize",
|
|
76
|
+
pill: { width: pill, height: 4 },
|
|
78
77
|
};
|
|
79
78
|
}
|
|
80
79
|
|
|
80
|
+
/** The reposition handle's size: shrunk until its box clears the corner squares. */
|
|
81
|
+
function repositionHandleSize(rect: Rect): number {
|
|
82
|
+
return Math.min(REPOSITION_HANDLE_PX, Math.max(rect.width, rect.height) - RESIZE_HANDLE_HIT_PX);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];
|
|
86
|
+
|
|
81
87
|
/**
|
|
82
88
|
* Always-on crop, integrated with the selection (no crop "mode"): while a
|
|
83
89
|
* croppable element is selected its clip is lifted so the FULL content shows and
|
|
@@ -167,6 +173,7 @@ export function DomEditCropHandles({
|
|
|
167
173
|
frame.scaleX,
|
|
168
174
|
frame.scaleY,
|
|
169
175
|
);
|
|
176
|
+
const repositionSize = repositionHandleSize(cropRect);
|
|
170
177
|
|
|
171
178
|
const startCropGesture = (edge: CropEdge | "move", event: ReactPointerEvent<HTMLElement>) => {
|
|
172
179
|
if (!onStyleCommit) return;
|
|
@@ -214,13 +221,19 @@ export function DomEditCropHandles({
|
|
|
214
221
|
setState((prev) => ({ ...prev, insets: nextInsets }));
|
|
215
222
|
};
|
|
216
223
|
|
|
217
|
-
const
|
|
224
|
+
const endCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
|
218
225
|
const gesture = gestureRef.current;
|
|
219
|
-
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
|
226
|
+
if (!gesture || gesture.pointerId !== event.pointerId) return null;
|
|
220
227
|
event.preventDefault();
|
|
221
228
|
event.stopPropagation();
|
|
222
229
|
gestureRef.current = null;
|
|
223
230
|
setDragging(false);
|
|
231
|
+
return gesture;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const finishCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
|
235
|
+
const gesture = endCropGesture(event);
|
|
236
|
+
if (!gesture) return;
|
|
224
237
|
if (!gesture.didMove) return;
|
|
225
238
|
// Commit to the file. The commit path re-applies the value to the live
|
|
226
239
|
// element synchronously, so re-lift in the same turn to keep showing the full
|
|
@@ -250,12 +263,8 @@ export function DomEditCropHandles({
|
|
|
250
263
|
};
|
|
251
264
|
|
|
252
265
|
const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
|
253
|
-
const gesture =
|
|
254
|
-
if (!gesture
|
|
255
|
-
event.preventDefault();
|
|
256
|
-
event.stopPropagation();
|
|
257
|
-
gestureRef.current = null;
|
|
258
|
-
setDragging(false);
|
|
266
|
+
const gesture = endCropGesture(event);
|
|
267
|
+
if (!gesture) return;
|
|
259
268
|
// Clip stays lifted; the dim follows the reset insets.
|
|
260
269
|
setState((prev) => ({ ...prev, insets: gesture.startInsets }));
|
|
261
270
|
};
|
|
@@ -317,7 +326,7 @@ export function DomEditCropHandles({
|
|
|
317
326
|
{/* Reposition handle — a center circle shown only once cropped. Drag it to
|
|
318
327
|
pan the crop window (which part of the element shows) without resizing
|
|
319
328
|
the crop. It's a small, discrete target, so a body drag still MOVES. */}
|
|
320
|
-
{hasCrop && (
|
|
329
|
+
{hasCrop && repositionSize > 0 && (
|
|
321
330
|
<button
|
|
322
331
|
type="button"
|
|
323
332
|
aria-label="Reposition crop"
|
|
@@ -325,11 +334,10 @@ export function DomEditCropHandles({
|
|
|
325
334
|
data-dom-edit-crop-handle="true"
|
|
326
335
|
className="pointer-events-auto absolute rounded-full border-2 border-studio-accent bg-studio-accent/30 shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
|
|
327
336
|
style={{
|
|
328
|
-
left: cropRect.left + cropRect.width / 2,
|
|
329
|
-
top: cropRect.top + cropRect.height / 2,
|
|
330
|
-
width:
|
|
331
|
-
height:
|
|
332
|
-
transform: "translate(-50%, -50%)",
|
|
337
|
+
left: cropRect.left + (cropRect.width - repositionSize) / 2,
|
|
338
|
+
top: cropRect.top + (cropRect.height - repositionSize) / 2,
|
|
339
|
+
width: repositionSize,
|
|
340
|
+
height: repositionSize,
|
|
333
341
|
cursor: "move",
|
|
334
342
|
touchAction: "none",
|
|
335
343
|
}}
|
|
@@ -340,16 +348,15 @@ export function DomEditCropHandles({
|
|
|
340
348
|
/>
|
|
341
349
|
)}
|
|
342
350
|
{/* Edge handles — drag a side to crop it. Positioned just OUTSIDE the crop
|
|
343
|
-
edge (via
|
|
351
|
+
edge (via edgeHandleLayout) so they never overlap the element body:
|
|
344
352
|
dragging the body always MOVES, only a handle crops. The pill is
|
|
345
353
|
hover-revealed (or shown while dragging / once a crop exists) so the
|
|
346
354
|
resting selection chrome stays uncluttered; the hit strip is always
|
|
347
355
|
live, and the title names the affordance. */}
|
|
348
356
|
{EDGES.map((edge) => {
|
|
349
|
-
const
|
|
350
|
-
|
|
357
|
+
const layout = edgeHandleLayout(edge, cropRect);
|
|
358
|
+
if (!layout) return null;
|
|
351
359
|
const revealed = dragging || hasCrop || hotEdge === edge;
|
|
352
|
-
const m = edgeHandleMetrics(vertical);
|
|
353
360
|
return (
|
|
354
361
|
<button
|
|
355
362
|
key={edge}
|
|
@@ -359,12 +366,8 @@ export function DomEditCropHandles({
|
|
|
359
366
|
data-dom-edit-crop-handle="true"
|
|
360
367
|
className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
|
|
361
368
|
style={{
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
width: m.hitWidth,
|
|
365
|
-
height: m.hitHeight,
|
|
366
|
-
transform: place.transform,
|
|
367
|
-
cursor: m.cursor,
|
|
369
|
+
...layout.hit,
|
|
370
|
+
cursor: layout.cursor,
|
|
368
371
|
touchAction: "none",
|
|
369
372
|
}}
|
|
370
373
|
onPointerEnter={() => setHotEdge(edge)}
|
|
@@ -377,8 +380,7 @@ export function DomEditCropHandles({
|
|
|
377
380
|
<span
|
|
378
381
|
className="pointer-events-none rounded-full bg-studio-accent/90 shadow-[0_0_0_1px_rgba(0,0,0,0.4)] transition-opacity duration-100"
|
|
379
382
|
style={{
|
|
380
|
-
|
|
381
|
-
height: m.pillHeight,
|
|
383
|
+
...layout.pill,
|
|
382
384
|
opacity: revealed ? 1 : 0,
|
|
383
385
|
}}
|
|
384
386
|
/>
|
|
@@ -485,6 +485,45 @@ describe("DomEditOverlay", () => {
|
|
|
485
485
|
host.remove();
|
|
486
486
|
});
|
|
487
487
|
|
|
488
|
+
it.each([
|
|
489
|
+
{ canvasInput: "overlay" as const, drags: true },
|
|
490
|
+
{ canvasInput: "host" as const, drags: false },
|
|
491
|
+
])("with body drag off, a $canvasInput canvas drags the body: $drags", async (c) => {
|
|
492
|
+
const restoreRect = stubViewportRect();
|
|
493
|
+
const host = document.createElement("div");
|
|
494
|
+
document.body.append(host);
|
|
495
|
+
const root = createRoot(host);
|
|
496
|
+
const selection = makeDomEditSelection();
|
|
497
|
+
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
|
|
498
|
+
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
|
|
499
|
+
HTMLDivElement.prototype.setPointerCapture = () => {};
|
|
500
|
+
gestureSpies.startGesture.mockClear();
|
|
501
|
+
|
|
502
|
+
act(() => {
|
|
503
|
+
root.render(
|
|
504
|
+
React.createElement(DomEditOverlay, {
|
|
505
|
+
...createOverlayProps({ iframeRef, selection, hoverSelection: null }),
|
|
506
|
+
allowBodyDrag: false,
|
|
507
|
+
canvasInput: c.canvasInput,
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
});
|
|
511
|
+
await flushOverlayRaf();
|
|
512
|
+
const selectionBox = host.querySelector(
|
|
513
|
+
'[data-dom-edit-selection-box="true"]',
|
|
514
|
+
) as HTMLDivElement;
|
|
515
|
+
dispatchOverlayPointerDown(selectionBox);
|
|
516
|
+
|
|
517
|
+
expect(gestureSpies.startGesture.mock.calls.some((call) => call[0] === "drag")).toBe(c.drags);
|
|
518
|
+
|
|
519
|
+
act(() => {
|
|
520
|
+
root.unmount();
|
|
521
|
+
});
|
|
522
|
+
HTMLDivElement.prototype.setPointerCapture = originalPointerCapture;
|
|
523
|
+
restoreRect();
|
|
524
|
+
host.remove();
|
|
525
|
+
});
|
|
526
|
+
|
|
488
527
|
it("passes the tracked hover selection when clicking the existing selection box", async () => {
|
|
489
528
|
const restoreRect = stubViewportRect();
|
|
490
529
|
|
|
@@ -54,7 +54,8 @@ export interface DomEditOverlayProps {
|
|
|
54
54
|
groupSelections?: DomEditSelection[];
|
|
55
55
|
hoverSelection: DomEditSelection | null;
|
|
56
56
|
allowCanvasMovement?: boolean;
|
|
57
|
-
|
|
57
|
+
allowBodyDrag?: boolean;
|
|
58
|
+
/** "host": no hover, marquee, box re-select, or body drag if allowBodyDrag is false; Enter still opens text. */
|
|
58
59
|
canvasInput?: "overlay" | "host";
|
|
59
60
|
onTextEditingChange?: (editing: boolean) => void;
|
|
60
61
|
/** A click on a single selection's box, in either mode; the event may be the pointerup. */
|
|
@@ -128,6 +129,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
128
129
|
groupSelections = [],
|
|
129
130
|
hoverSelection,
|
|
130
131
|
allowCanvasMovement = true,
|
|
132
|
+
allowBodyDrag = true,
|
|
131
133
|
canvasInput = "overlay",
|
|
132
134
|
onTextEditingChange,
|
|
133
135
|
onSelectionBoxClick,
|
|
@@ -148,6 +150,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
148
150
|
}: DomEditOverlayProps) {
|
|
149
151
|
const readOnly = usePreviewReadOnly();
|
|
150
152
|
const hostInput = canvasInput === "host";
|
|
153
|
+
const bodyDrag = allowBodyDrag || !hostInput;
|
|
151
154
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
152
155
|
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
153
156
|
const onMarqueeSelectRef = useRef(onMarqueeSelect);
|
|
@@ -503,6 +506,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
503
506
|
groupOverlayItems={groupOverlayItems}
|
|
504
507
|
groupBounds={groupBounds}
|
|
505
508
|
allowCanvasMovement={allowCanvasMovement}
|
|
509
|
+
allowBodyDrag={bodyDrag}
|
|
506
510
|
groupCanMove={groupCanMove}
|
|
507
511
|
gestures={gestures}
|
|
508
512
|
onBoxMouseDown={suppressBoxMouseDown}
|
|
@@ -515,6 +519,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
515
519
|
selection={selection}
|
|
516
520
|
overlayRect={overlayRect}
|
|
517
521
|
allowCanvasMovement={allowCanvasMovement}
|
|
522
|
+
allowBodyDrag={bodyDrag}
|
|
518
523
|
cropOutlineInsetPx={cropOutlineInsetPx ?? undefined}
|
|
519
524
|
boxRef={boxRef}
|
|
520
525
|
boxChromeClass={boxChromeClass}
|
|
@@ -4,7 +4,7 @@ import React, { act, createRef } from "react";
|
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { DomEditSelection } from "./domEditing";
|
|
7
|
-
import { DomEditSelectionChrome } from "./DomEditSelectionChrome";
|
|
7
|
+
import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome";
|
|
8
8
|
|
|
9
9
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
10
|
|
|
@@ -48,6 +48,7 @@ describe("DomEditSelectionChrome crop composition", () => {
|
|
|
48
48
|
selection={selection}
|
|
49
49
|
overlayRect={{ left: 44, top: 52, width: 220, height: 48, editScaleX: 1, editScaleY: 1 }}
|
|
50
50
|
allowCanvasMovement={false}
|
|
51
|
+
allowBodyDrag
|
|
51
52
|
boxRef={createRef()}
|
|
52
53
|
boxChromeClass="border border-studio-accent/80"
|
|
53
54
|
boxClipPath={undefined}
|
|
@@ -103,6 +104,7 @@ describe("DomEditSelectionChrome crop composition", () => {
|
|
|
103
104
|
angle: 30,
|
|
104
105
|
}}
|
|
105
106
|
allowCanvasMovement={true}
|
|
107
|
+
allowBodyDrag
|
|
106
108
|
boxRef={createRef()}
|
|
107
109
|
boxChromeClass=""
|
|
108
110
|
boxClipPath={undefined}
|
|
@@ -163,6 +165,7 @@ describe("DomEditSelectionChrome while editing text", () => {
|
|
|
163
165
|
selection={selection}
|
|
164
166
|
overlayRect={{ left: 10, top: 20, width: 200, height: 60, editScaleX: 1, editScaleY: 1 }}
|
|
165
167
|
allowCanvasMovement={true}
|
|
168
|
+
allowBodyDrag
|
|
166
169
|
boxRef={createRef()}
|
|
167
170
|
boxChromeClass="border border-studio-accent/80"
|
|
168
171
|
boxClipPath={undefined}
|
|
@@ -214,3 +217,113 @@ describe("DomEditSelectionChrome while editing text", () => {
|
|
|
214
217
|
unmount();
|
|
215
218
|
});
|
|
216
219
|
});
|
|
220
|
+
|
|
221
|
+
describe("DomEditSelectionChrome with body drag off", () => {
|
|
222
|
+
const rect = { left: 10, top: 10, width: 200, height: 100, editScaleX: 1, editScaleY: 1 };
|
|
223
|
+
const gestureSpies = () => ({
|
|
224
|
+
startGesture: vi.fn(),
|
|
225
|
+
startGroupDrag: vi.fn(),
|
|
226
|
+
startBlockedMove: vi.fn(),
|
|
227
|
+
});
|
|
228
|
+
const press = (el: Element) => {
|
|
229
|
+
const event = new PointerEvent("pointerdown", {
|
|
230
|
+
bubbles: true,
|
|
231
|
+
cancelable: true,
|
|
232
|
+
button: 0,
|
|
233
|
+
pointerId: 1,
|
|
234
|
+
});
|
|
235
|
+
act(() => {
|
|
236
|
+
el.dispatchEvent(event);
|
|
237
|
+
});
|
|
238
|
+
return event;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
function renderChrome(allowBodyDrag: boolean) {
|
|
242
|
+
const element = document.createElement("div");
|
|
243
|
+
document.body.append(element);
|
|
244
|
+
const { selection, host, root } = selectionFixture(element, "#box", true);
|
|
245
|
+
const hostPress = vi.fn();
|
|
246
|
+
host.addEventListener("pointerdown", hostPress);
|
|
247
|
+
const gestures = gestureSpies();
|
|
248
|
+
act(() => {
|
|
249
|
+
root.render(
|
|
250
|
+
<DomEditSelectionChrome
|
|
251
|
+
selection={selection}
|
|
252
|
+
overlayRect={rect}
|
|
253
|
+
allowCanvasMovement
|
|
254
|
+
allowBodyDrag={allowBodyDrag}
|
|
255
|
+
boxRef={createRef()}
|
|
256
|
+
boxChromeClass=""
|
|
257
|
+
boxClipPath={undefined}
|
|
258
|
+
selectionKey="box"
|
|
259
|
+
groupSelectionCount={0}
|
|
260
|
+
gestures={gestures as never}
|
|
261
|
+
onBoxMouseDown={vi.fn()}
|
|
262
|
+
onBoxClick={vi.fn()}
|
|
263
|
+
/>,
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
const box = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
|
|
267
|
+
return { host, box, gestures, hostPress, cleanup: () => act(() => root.unmount()) };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
it("leaves a body press untouched for the host: no drag, no capture, no cursor", () => {
|
|
271
|
+
const { box, gestures, hostPress, cleanup } = renderChrome(false);
|
|
272
|
+
const event = press(box);
|
|
273
|
+
expect(hostPress).toHaveBeenCalledTimes(1);
|
|
274
|
+
expect(event.defaultPrevented).toBe(false);
|
|
275
|
+
expect(box.hasPointerCapture(1)).toBe(false);
|
|
276
|
+
expect(gestures.startGesture).not.toHaveBeenCalled();
|
|
277
|
+
expect(gestures.startBlockedMove).not.toHaveBeenCalled();
|
|
278
|
+
expect(box.style.cursor).toBe("");
|
|
279
|
+
cleanup();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("keeps the resize and rotate handles working", () => {
|
|
283
|
+
const { host, gestures, cleanup } = renderChrome(false);
|
|
284
|
+
const corner = host.querySelector<HTMLElement>('[style*="nwse-resize"]')!;
|
|
285
|
+
press(corner);
|
|
286
|
+
expect(gestures.startGesture).toHaveBeenCalledWith("resize", expect.anything(), {
|
|
287
|
+
resizeHandle: "nw",
|
|
288
|
+
});
|
|
289
|
+
press(host.querySelector('[aria-label="Rotate selection"]')!);
|
|
290
|
+
expect(gestures.startGesture).toHaveBeenCalledWith("rotate", expect.anything());
|
|
291
|
+
cleanup();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("still drags the body by default", () => {
|
|
295
|
+
const { box, gestures, cleanup } = renderChrome(true);
|
|
296
|
+
press(box);
|
|
297
|
+
expect(gestures.startGesture).toHaveBeenCalledWith("drag", expect.anything());
|
|
298
|
+
expect(box.style.cursor).toBe("move");
|
|
299
|
+
cleanup();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("leaves a group body press untouched too", () => {
|
|
303
|
+
const { host, root } = selectionFixture(document.createElement("div"), "#g", true);
|
|
304
|
+
const hostPress = vi.fn();
|
|
305
|
+
host.addEventListener("pointerdown", hostPress);
|
|
306
|
+
const gestures = gestureSpies();
|
|
307
|
+
act(() => {
|
|
308
|
+
root.render(
|
|
309
|
+
<DomEditGroupChrome
|
|
310
|
+
groupOverlayItems={[]}
|
|
311
|
+
groupBounds={rect}
|
|
312
|
+
allowCanvasMovement
|
|
313
|
+
allowBodyDrag={false}
|
|
314
|
+
groupCanMove
|
|
315
|
+
gestures={gestures as never}
|
|
316
|
+
onBoxMouseDown={vi.fn()}
|
|
317
|
+
onBoxClick={vi.fn()}
|
|
318
|
+
/>,
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
const groupBox = host.querySelector<HTMLElement>('[data-dom-edit-selection-box="true"]')!;
|
|
322
|
+
const event = press(groupBox);
|
|
323
|
+
expect(groupBox.style.cursor).toBe("");
|
|
324
|
+
expect(hostPress).toHaveBeenCalledTimes(1);
|
|
325
|
+
expect(event.defaultPrevented).toBe(false);
|
|
326
|
+
expect(gestures.startGroupDrag).not.toHaveBeenCalled();
|
|
327
|
+
act(() => root.unmount());
|
|
328
|
+
});
|
|
329
|
+
});
|