@hyperframes/studio 0.8.95 → 0.8.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-DJJKab4_.js → hyperframes-player-Biu5_W0l.js} +1 -1
- package/dist/assets/{index-DE1dn3QB.js → index-B5Z-1RW9.js} +1 -1
- package/dist/assets/{index-Cc4by8r7.js → index-BdV5T-QI.js} +200 -194
- package/dist/assets/{index-C1dXTZvs.js → index-CWuozokW.js} +1 -1
- package/dist/assets/{index-Dx79V1Se.css → index-xB5O0hPw.css} +1 -1
- package/dist/{chunk-QZ6HNNFG.js → chunk-6GAO75IU.js} +16 -4
- package/dist/chunk-6GAO75IU.js.map +1 -0
- package/dist/{chunk-PQFEGZWM.js → chunk-MTAVNAGW.js} +2 -2
- package/dist/chunk-MTAVNAGW.js.map +1 -0
- package/dist/{chunk-QPSOTK6M.js → chunk-U3EE4ACX.js} +5 -5
- package/dist/chunk-U3EE4ACX.js.map +1 -0
- package/dist/{domEditingLayers-3BAP6NNO.js → domEditingLayers-NU6Z5QWA.js} +2 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.html +2 -2
- package/dist/index.js +2036 -1809
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.d.ts +3 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolPicker.tsx +19 -15
- package/src/components/TimelineToolbar.test.tsx +62 -0
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/DomEditCropHandles.test.tsx +63 -0
- package/src/components/editor/DomEditCropHandles.tsx +7 -10
- package/src/components/editor/DomEditSelectionChrome.tsx +3 -3
- package/src/components/editor/domEditOverlayGestures.ts +16 -0
- package/src/components/editor/domEditing.test.ts +9 -1
- package/src/components/editor/domEditingDom.ts +2 -1
- package/src/components/editor/fontAssets.test.ts +69 -0
- package/src/components/editor/fontAssets.ts +11 -2
- package/src/components/editor/groupDragMove.ts +13 -6
- package/src/components/editor/manualOffsetDrag.test.ts +56 -0
- package/src/components/editor/manualOffsetDrag.ts +16 -9
- package/src/components/editor/shiftDragAxisLock.test.tsx +358 -0
- package/src/components/editor/snapEngine.ts +3 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +24 -9
- package/src/components/ui/Menu.test.tsx +21 -0
- package/src/components/ui/Popover.tsx +21 -1
- package/src/hooks/domSelectionTimelineMirror.ts +5 -4
- package/src/hooks/domStyleCommit.fonts.test.ts +55 -0
- package/src/hooks/useAppHotkeys.test.ts +7 -5
- package/src/hooks/useClipboard.ts +25 -7
- package/src/hooks/useDomSelection.test.ts +56 -32
- package/src/hooks/useDomSelection.ts +4 -3
- package/src/player/components/BeatStrip.tsx +6 -3
- package/src/player/components/Timeline.readOnly.test.tsx +670 -0
- package/src/player/components/Timeline.rulerSeek.test.tsx +1 -25
- package/src/player/components/TimelineAutomationLane.test.tsx +23 -0
- package/src/player/components/TimelineClipDiamonds.tsx +7 -2
- package/src/player/components/TimelineFxButton.tsx +3 -0
- package/src/player/components/TimelineLanes.tsx +3 -2
- package/src/player/components/TimelineProvider.tsx +23 -7
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/studioShortcuts.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.ts +3 -1
- package/src/player/components/timelineDragDrop.ts +11 -1
- package/src/player/components/timelineEditCapabilities.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +24 -0
- package/src/player/components/timelineReadOnly.ts +64 -0
- package/src/player/components/useAutomationLaneGestures.ts +13 -2
- package/src/player/components/useTimelineKeyframeHandlers.ts +7 -0
- package/src/player/components/useTimelineProviderState.tsx +42 -14
- package/src/player/hooks/useShadowPreviewReload.ts +2 -2
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +69 -0
- package/src/player/hooks/useTimelinePlayer.ts +6 -0
- package/src/player/lib/timelineDOM.test.ts +25 -0
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineIframeHelpers.test.ts +15 -0
- package/src/player/store/playerStore.ts +4 -4
- package/src/utils/clipboardLook.test.ts +130 -0
- package/src/utils/clipboardLook.ts +82 -0
- package/src/utils/clipboardPayload.ts +8 -2
- package/src/utils/studioFontHelpers.ts +6 -3
- package/dist/chunk-PQFEGZWM.js.map +0 -1
- package/dist/chunk-QPSOTK6M.js.map +0 -1
- package/dist/chunk-QZ6HNNFG.js.map +0 -1
- /package/dist/{domEditingLayers-3BAP6NNO.js.map → domEditingLayers-NU6Z5QWA.js.map} +0 -0
package/dist/ui.d.ts
CHANGED
|
@@ -181,6 +181,8 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof Popover$1.Ro
|
|
|
181
181
|
side?: "top" | "bottom" | "left" | "right";
|
|
182
182
|
align?: "start" | "center" | "end";
|
|
183
183
|
sideOffset?: number;
|
|
184
|
+
/** Points the popup at its trigger. Off by default; the default gap from the trigger grows by the arrow's height. */
|
|
185
|
+
arrow?: boolean;
|
|
184
186
|
/** Portal target. Pass the shadow root when the trigger lives in one. */
|
|
185
187
|
container?: PortalContainer;
|
|
186
188
|
/** Names the popup for assistive tech. */
|
|
@@ -193,7 +195,7 @@ interface PopoverProps extends Omit<ComponentPropsWithoutRef<typeof Popover$1.Ro
|
|
|
193
195
|
className?: string;
|
|
194
196
|
"data-preview-state"?: PopupPreviewState;
|
|
195
197
|
}
|
|
196
|
-
declare function Popover({ trigger, children, side, align, sideOffset, container, className, initialFocus, "aria-label": ariaLabel, "data-preview-state": previewState, ...root }: PopoverProps): react_jsx_runtime.JSX.Element;
|
|
198
|
+
declare function Popover({ trigger, children, side, align, arrow, sideOffset, container, className, initialFocus, "aria-label": ariaLabel, "data-preview-state": previewState, ...root }: PopoverProps): react_jsx_runtime.JSX.Element;
|
|
197
199
|
|
|
198
200
|
/**
|
|
199
201
|
* Shared field look (also used by NumberField and Select). Each interactive state is written
|
package/dist/ui.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.97",
|
|
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/parsers": "0.8.
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.97",
|
|
64
|
+
"@hyperframes/parsers": "0.8.97",
|
|
65
|
+
"@hyperframes/player": "0.8.97",
|
|
66
|
+
"@hyperframes/shader-transitions": "0.8.97",
|
|
67
|
+
"@hyperframes/sdk": "0.8.97",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.97"
|
|
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.97"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -23,7 +23,7 @@ interface ToolRow {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** One button for the active timeline tool; its menu switches tools or selects clips around the playhead. */
|
|
26
|
-
export function TimelineToolPicker() {
|
|
26
|
+
export function TimelineToolPicker({ showSelectAroundPlayhead = true }) {
|
|
27
27
|
const activeTool = usePlayerStore((s) => s.activeTool);
|
|
28
28
|
const store = () => usePlayerStore.getState();
|
|
29
29
|
const rows: ToolRow[] = [
|
|
@@ -41,21 +41,25 @@ export function TimelineToolPicker() {
|
|
|
41
41
|
active: activeTool === "razor",
|
|
42
42
|
run: () => store().setActiveTool("razor"),
|
|
43
43
|
},
|
|
44
|
-
{
|
|
45
|
-
label: "Select leftward",
|
|
46
|
-
shortcut: "[",
|
|
47
|
-
icon: <ArrowLineLeft size={16} aria-hidden="true" />,
|
|
48
|
-
active: false,
|
|
49
|
-
run: () => store().selectLeftward(),
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
label: "Select rightward",
|
|
53
|
-
shortcut: "]",
|
|
54
|
-
icon: <ArrowLineRight size={16} aria-hidden="true" />,
|
|
55
|
-
active: false,
|
|
56
|
-
run: () => store().selectRightward(),
|
|
57
|
-
},
|
|
58
44
|
];
|
|
45
|
+
if (showSelectAroundPlayhead) {
|
|
46
|
+
rows.push(
|
|
47
|
+
{
|
|
48
|
+
label: "Select leftward",
|
|
49
|
+
shortcut: "[",
|
|
50
|
+
icon: <ArrowLineLeft size={16} aria-hidden="true" />,
|
|
51
|
+
active: false,
|
|
52
|
+
run: () => store().selectLeftward(),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
label: "Select rightward",
|
|
56
|
+
shortcut: "]",
|
|
57
|
+
icon: <ArrowLineRight size={16} aria-hidden="true" />,
|
|
58
|
+
active: false,
|
|
59
|
+
run: () => store().selectRightward(),
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
}
|
|
59
63
|
const current = rows.find((row) => row.active) ?? rows[0];
|
|
60
64
|
return (
|
|
61
65
|
<Menu
|
|
@@ -8,6 +8,7 @@ import { usePlayerStore } from "../player/store/playerStore";
|
|
|
8
8
|
import { makeSelection } from "../hooks/domSelectionTestHarness";
|
|
9
9
|
import { useAudioMetersVisible } from "../utils/audioMeterVisibility";
|
|
10
10
|
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
11
|
+
import { dispatchPlainKey, type HotkeyCallbacks } from "../hooks/appHotkeysDispatch";
|
|
11
12
|
import { AudioMeterStrip } from "./nle/AudioMeterStrip";
|
|
12
13
|
import { TimelineToolbar } from "./TimelineToolbar";
|
|
13
14
|
|
|
@@ -295,6 +296,67 @@ describe("TimelineToolbar history", () => {
|
|
|
295
296
|
});
|
|
296
297
|
});
|
|
297
298
|
|
|
299
|
+
describe("TimelineToolbar tool menu", () => {
|
|
300
|
+
async function openMenuRows(props: Partial<React.ComponentProps<typeof TimelineToolbar>>) {
|
|
301
|
+
const { host, root } = renderToolbar(undefined, props);
|
|
302
|
+
const trigger = host.querySelector<HTMLButtonElement>('button[aria-label^="Timeline tool:"]')!;
|
|
303
|
+
await act(async () => {
|
|
304
|
+
trigger.click();
|
|
305
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
306
|
+
});
|
|
307
|
+
const rows = [...document.querySelectorAll('[role="menuitem"] > span:first-child')].map(
|
|
308
|
+
(label) => label.textContent,
|
|
309
|
+
);
|
|
310
|
+
act(() => root.unmount());
|
|
311
|
+
return rows;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
it("lists Select, Split, Select leftward and Select rightward by default", async () => {
|
|
315
|
+
expect(await openMenuRows({})).toEqual([
|
|
316
|
+
"Select",
|
|
317
|
+
"Split",
|
|
318
|
+
"Select leftward",
|
|
319
|
+
"Select rightward",
|
|
320
|
+
]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("lists only Select and Split when the host hides select around the playhead", async () => {
|
|
324
|
+
expect(await openMenuRows({ showSelectAroundPlayhead: false })).toEqual(["Select", "Split"]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it.each([true, false])("[ and ] select around the playhead with the rows shown: %s", (shown) => {
|
|
328
|
+
usePlayerStore.setState({
|
|
329
|
+
currentTime: 4,
|
|
330
|
+
elements: [0, 4, 7].map((start, track) => ({
|
|
331
|
+
id: `c${start}`,
|
|
332
|
+
key: `c${start}`,
|
|
333
|
+
tag: "div",
|
|
334
|
+
start,
|
|
335
|
+
duration: 2,
|
|
336
|
+
track,
|
|
337
|
+
})),
|
|
338
|
+
});
|
|
339
|
+
const { root } = renderToolbar(undefined, { showSelectAroundPlayhead: shown });
|
|
340
|
+
const press = (key: string) =>
|
|
341
|
+
act(() =>
|
|
342
|
+
dispatchPlainKey(new KeyboardEvent("keydown", { key }), key, {} as HotkeyCallbacks),
|
|
343
|
+
);
|
|
344
|
+
try {
|
|
345
|
+
press("[");
|
|
346
|
+
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["c0"]);
|
|
347
|
+
press("]");
|
|
348
|
+
expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual(["c4", "c7"]);
|
|
349
|
+
} finally {
|
|
350
|
+
act(() => root.unmount());
|
|
351
|
+
usePlayerStore.setState({
|
|
352
|
+
elements: [],
|
|
353
|
+
selectedElementId: null,
|
|
354
|
+
selectedElementIds: new Set(),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
298
360
|
describe("TimelineToolbar add beat", () => {
|
|
299
361
|
it("shows Add beat by default, as Studio does", () => {
|
|
300
362
|
const { host, root } = renderToolbar();
|
|
@@ -44,6 +44,7 @@ export interface TimelineToolbarProps {
|
|
|
44
44
|
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
|
|
45
45
|
history?: TimelineHistoryButtonsProps;
|
|
46
46
|
showHistory?: boolean;
|
|
47
|
+
showSelectAroundPlayhead?: boolean;
|
|
47
48
|
showAddBeat?: boolean;
|
|
48
49
|
/** Hides Add keyframe and auto-record, and turns off auto-record and the K shortcut with them. */
|
|
49
50
|
showKeyframes?: boolean;
|
|
@@ -143,6 +144,7 @@ export function TimelineToolbar({
|
|
|
143
144
|
onSplitElement,
|
|
144
145
|
history,
|
|
145
146
|
showHistory = true,
|
|
147
|
+
showSelectAroundPlayhead = true,
|
|
146
148
|
showAddBeat = true,
|
|
147
149
|
showKeyframes = true,
|
|
148
150
|
}: TimelineToolbarProps) {
|
|
@@ -216,7 +218,7 @@ export function TimelineToolbar({
|
|
|
216
218
|
<div className="flex items-center justify-between px-2 py-0.5">
|
|
217
219
|
<div className="flex items-center gap-0.5">
|
|
218
220
|
{showHistory && <TimelineHistoryButtons {...history} />}
|
|
219
|
-
<TimelineToolPicker />
|
|
221
|
+
<TimelineToolPicker showSelectAroundPlayhead={showSelectAroundPlayhead} />
|
|
220
222
|
{/* Divider: tool-mode | editing-actions */}
|
|
221
223
|
<div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
|
|
222
224
|
<Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
|
|
@@ -126,6 +126,69 @@ describe("DomEditCropHandles clip lift/restore", () => {
|
|
|
126
126
|
expect(a.style.getPropertyValue("clip-path")).toBe("inset(10px 30px 10px 10px)");
|
|
127
127
|
});
|
|
128
128
|
|
|
129
|
+
it.each([
|
|
130
|
+
{ name: "a crop edge", clip: "inset(10px)", handle: "Crop right", dx: -20, dy: 0 },
|
|
131
|
+
{
|
|
132
|
+
name: "the reposition handle on fractional insets",
|
|
133
|
+
clip: "inset(20px 0.42px 40px 0.03px)",
|
|
134
|
+
handle: "Reposition crop",
|
|
135
|
+
dx: 0,
|
|
136
|
+
dy: 10,
|
|
137
|
+
},
|
|
138
|
+
])("commits nothing when $name is dragged back to where it started", (c) => {
|
|
139
|
+
const onStyleCommit = vi.fn();
|
|
140
|
+
render(makeEl("a", c.clip), onStyleCommit);
|
|
141
|
+
const handle = document.querySelector<HTMLButtonElement>(`[aria-label="${c.handle}"]`)!;
|
|
142
|
+
for (const [type, d] of [
|
|
143
|
+
["pointerdown", 0],
|
|
144
|
+
["pointermove", 1],
|
|
145
|
+
["pointermove", 0],
|
|
146
|
+
["pointerup", 0],
|
|
147
|
+
] as const) {
|
|
148
|
+
const at = { clientX: 100 + d * c.dx, clientY: 50 + d * c.dy };
|
|
149
|
+
act(() =>
|
|
150
|
+
handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 3, ...at })),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
expect(onStyleCommit).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("commits where the pointer was released, even before its last move renders", () => {
|
|
157
|
+
const onStyleCommit = vi.fn();
|
|
158
|
+
render(makeEl("a", "inset(10px)"), onStyleCommit);
|
|
159
|
+
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
|
|
160
|
+
act(() => {
|
|
161
|
+
for (const [type, clientX] of [
|
|
162
|
+
["pointerdown", 100],
|
|
163
|
+
["pointermove", 90],
|
|
164
|
+
["pointermove", 80],
|
|
165
|
+
["pointerup", 80],
|
|
166
|
+
] as const) {
|
|
167
|
+
handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 4, clientX }));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("keeps a first crop on deselect when the release beats the last render", async () => {
|
|
174
|
+
const a = makeEl("a", "");
|
|
175
|
+
const { root } = render(a, (property, value) => void a.style.setProperty(property, value));
|
|
176
|
+
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]')!;
|
|
177
|
+
act(() => {
|
|
178
|
+
for (const [type, clientX] of [
|
|
179
|
+
["pointerdown", 100],
|
|
180
|
+
["pointermove", 90],
|
|
181
|
+
["pointermove", 80],
|
|
182
|
+
["pointerup", 80],
|
|
183
|
+
] as const) {
|
|
184
|
+
handle.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 5, clientX }));
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
await act(async () => undefined);
|
|
188
|
+
act(() => root.unmount());
|
|
189
|
+
expect(a.style.getPropertyValue("clip-path")).toBe("inset(0px 20px 0px 0px)");
|
|
190
|
+
});
|
|
191
|
+
|
|
129
192
|
it("re-lifts when the crop commit rejects", async () => {
|
|
130
193
|
const a = makeEl("a", "inset(10px)");
|
|
131
194
|
const onStyleCommit = vi.fn((property: string, value: string) => {
|
|
@@ -18,7 +18,7 @@ interface CropGestureState {
|
|
|
18
18
|
startX: number;
|
|
19
19
|
startY: number;
|
|
20
20
|
startInsets: ClipPathInsetSides;
|
|
21
|
-
|
|
21
|
+
insets: ClipPathInsetSides;
|
|
22
22
|
/** Element frame captured at gesture start: pointer deltas rotate into it. */
|
|
23
23
|
angleDeg: number;
|
|
24
24
|
scaleX: number;
|
|
@@ -186,7 +186,7 @@ export function DomEditCropHandles({
|
|
|
186
186
|
startX: event.clientX,
|
|
187
187
|
startY: event.clientY,
|
|
188
188
|
startInsets: state.insets,
|
|
189
|
-
|
|
189
|
+
insets: state.insets,
|
|
190
190
|
angleDeg: frame.angleDeg,
|
|
191
191
|
scaleX: frame.scaleX,
|
|
192
192
|
scaleY: frame.scaleY,
|
|
@@ -217,7 +217,7 @@ export function DomEditCropHandles({
|
|
|
217
217
|
gesture.edge === "move"
|
|
218
218
|
? resolveCropInsetFromMoveDrag(drag)
|
|
219
219
|
: resolveCropInsetFromEdgeDrag({ ...drag, edge: gesture.edge, width, height });
|
|
220
|
-
gesture.
|
|
220
|
+
gesture.insets = nextInsets;
|
|
221
221
|
setState((prev) => ({ ...prev, insets: nextInsets }));
|
|
222
222
|
};
|
|
223
223
|
|
|
@@ -234,7 +234,6 @@ export function DomEditCropHandles({
|
|
|
234
234
|
const finishCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
|
235
235
|
const gesture = endCropGesture(event);
|
|
236
236
|
if (!gesture) return;
|
|
237
|
-
if (!gesture.didMove) return;
|
|
238
237
|
// Commit to the file. The commit path re-applies the value to the live
|
|
239
238
|
// element synchronously, so re-lift in the same turn to keep showing the full
|
|
240
239
|
// content + dim while selected. Re-lift again on rejection so a failed commit
|
|
@@ -243,12 +242,10 @@ export function DomEditCropHandles({
|
|
|
243
242
|
const reLift = () => {
|
|
244
243
|
if (liftedRef.current) el.style.setProperty("clip-path", "none");
|
|
245
244
|
};
|
|
246
|
-
const
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
state.insets.bottom > 0 ||
|
|
251
|
-
state.insets.left > 0;
|
|
245
|
+
const { insets } = gesture;
|
|
246
|
+
const committedValue = buildInsetClipPathSides(insets, state.radius);
|
|
247
|
+
if (committedValue === buildInsetClipPathSides(gesture.startInsets, state.radius)) return;
|
|
248
|
+
const cropped = insets.top > 0 || insets.right > 0 || insets.bottom > 0 || insets.left > 0;
|
|
252
249
|
const commit = onStyleCommit?.("clip-path", committedValue);
|
|
253
250
|
// handleDomStyleCommit applies the persisted value to the live element
|
|
254
251
|
// synchronously before its first await. Restore the crop-mode lift in this
|
|
@@ -105,7 +105,7 @@ export function DomEditGroupChrome({
|
|
|
105
105
|
cursor: !allowBodyDrag ? undefined : canManipulate && groupCanMove ? "move" : "default",
|
|
106
106
|
}}
|
|
107
107
|
onPointerDown={(e) => {
|
|
108
|
-
if (!canManipulate || e.shiftKey
|
|
108
|
+
if (!canManipulate || !allowBodyDrag || (e.shiftKey && !groupCanMove)) return;
|
|
109
109
|
gestures.startGroupDrag(e);
|
|
110
110
|
}}
|
|
111
111
|
onMouseDown={onBoxMouseDown}
|
|
@@ -218,12 +218,12 @@ export function DomEditSelectionChrome({
|
|
|
218
218
|
e.stopPropagation();
|
|
219
219
|
return;
|
|
220
220
|
}
|
|
221
|
-
if (!allowBodyDrag || !canManipulate
|
|
221
|
+
if (!allowBodyDrag || !canManipulate) return;
|
|
222
222
|
if (selection.capabilities.canApplyManualOffset) {
|
|
223
223
|
gestures.startGesture("drag", e);
|
|
224
224
|
return;
|
|
225
225
|
}
|
|
226
|
-
gestures.startBlockedMove(e, selection);
|
|
226
|
+
if (!e.shiftKey) gestures.startBlockedMove(e, selection);
|
|
227
227
|
}}
|
|
228
228
|
onMouseDown={onBoxMouseDown}
|
|
229
229
|
onClick={onBoxClick}
|
|
@@ -18,6 +18,20 @@ export type GestureKind = "drag" | "resize" | "rotate";
|
|
|
18
18
|
export type ResizeHandle = "nw" | "ne" | "sw" | "se";
|
|
19
19
|
|
|
20
20
|
export const BLOCKED_MOVE_THRESHOLD_PX = 4;
|
|
21
|
+
|
|
22
|
+
export interface AxisLockedDelta {
|
|
23
|
+
dx: number;
|
|
24
|
+
dy: number;
|
|
25
|
+
lockedAxis?: "x" | "y";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function lockDragToDominantAxis(dx: number, dy: number, shiftKey: boolean): AxisLockedDelta {
|
|
29
|
+
if (!shiftKey) return { dx, dy };
|
|
30
|
+
return Math.abs(dx) >= Math.abs(dy)
|
|
31
|
+
? { dx, dy: 0, lockedAxis: "y" }
|
|
32
|
+
: { dx: 0, dy, lockedAxis: "x" };
|
|
33
|
+
}
|
|
34
|
+
|
|
21
35
|
const ROTATION_COMMIT_EPSILON_DEGREES = 0.05;
|
|
22
36
|
const ROTATION_SNAP_DEGREES = 15;
|
|
23
37
|
/**
|
|
@@ -73,6 +87,7 @@ export interface GestureState {
|
|
|
73
87
|
snapContext?: SnapContext;
|
|
74
88
|
lastSnappedDx?: number;
|
|
75
89
|
lastSnappedDy?: number;
|
|
90
|
+
travelled?: boolean;
|
|
76
91
|
/** Corner the resize gesture grabbed (resize gestures only). */
|
|
77
92
|
resizeHandle?: ResizeHandle;
|
|
78
93
|
/** Last anchoring translation applied during a corner resize (overlay px). */
|
|
@@ -96,6 +111,7 @@ export interface GroupGestureState {
|
|
|
96
111
|
snapContext?: SnapContext;
|
|
97
112
|
lastSnappedDx?: number;
|
|
98
113
|
lastSnappedDy?: number;
|
|
114
|
+
travelled?: boolean;
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
export interface BlockedMoveState {
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
resolveDomEditCapabilities,
|
|
18
18
|
resolveDomEditSelection,
|
|
19
19
|
} from "./domEditing";
|
|
20
|
-
import { normalizeTimelineCompositionSource } from "./domEditingDom";
|
|
20
|
+
import { buildElementLabel, normalizeTimelineCompositionSource } from "./domEditingDom";
|
|
21
21
|
|
|
22
22
|
function createDocument(markup: string): Document {
|
|
23
23
|
const window = new Window();
|
|
@@ -925,6 +925,14 @@ describe("resolveDomEditSelection", () => {
|
|
|
925
925
|
);
|
|
926
926
|
});
|
|
927
927
|
|
|
928
|
+
it("names a repeated section by its authored id, not its runtime instance id", () => {
|
|
929
|
+
const document = createDocument(`
|
|
930
|
+
<div data-composition-id="benefit-fresh__hf2" data-hf-original-composition-id="benefit-fresh"></div>
|
|
931
|
+
`);
|
|
932
|
+
|
|
933
|
+
expect(buildElementLabel(document.querySelector("div") as HTMLElement)).toBe("Benefit Fresh");
|
|
934
|
+
});
|
|
935
|
+
|
|
928
936
|
it("finds a composition host authored with a ./ path", () => {
|
|
929
937
|
const document = createDocument(`
|
|
930
938
|
<div data-composition-id="scene" data-composition-file="./compositions/scene.html"></div>
|
|
@@ -312,7 +312,8 @@ function getPreferredClassSelector(el: HTMLElement): string | undefined {
|
|
|
312
312
|
|
|
313
313
|
// fallow-ignore-next-line complexity
|
|
314
314
|
export function buildElementLabel(el: HTMLElement): string {
|
|
315
|
-
const compositionId =
|
|
315
|
+
const compositionId =
|
|
316
|
+
el.getAttribute("data-hf-original-composition-id") ?? el.getAttribute("data-composition-id");
|
|
316
317
|
if (compositionId && compositionId !== "main") {
|
|
317
318
|
return humanizeIdentifier(compositionId);
|
|
318
319
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { expect, it } from "vitest";
|
|
3
|
+
import { ensureImportedFontFace, injectPreviewImportedFont } from "../../utils/studioFontHelpers";
|
|
4
|
+
import { importedFontFaceCss, type ImportedFontAsset } from "./fontAssets";
|
|
5
|
+
|
|
6
|
+
const face = (file: string, extra: Partial<ImportedFontAsset> = {}): ImportedFontAsset => ({
|
|
7
|
+
family: "Poppins",
|
|
8
|
+
path: `assets/fonts/Poppins/${file}`,
|
|
9
|
+
url: `/preview/assets/fonts/Poppins/${file}`,
|
|
10
|
+
...extra,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("a face with no weight or style is written as before", () => {
|
|
14
|
+
expect(importedFontFaceCss(face("Poppins-Bold.ttf"))).toBe(
|
|
15
|
+
'@font-face { font-family: "Poppins"; src: url("/preview/assets/fonts/Poppins/Poppins-Bold.ttf"); font-display: swap; }',
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("a face says which weight and style it draws, a variable file its weight range", () => {
|
|
20
|
+
expect(
|
|
21
|
+
importedFontFaceCss(face("Poppins-BoldItalic.ttf", { weight: "700", style: "italic" })),
|
|
22
|
+
).toBe(
|
|
23
|
+
'@font-face { font-family: "Poppins"; src: url("/preview/assets/fonts/Poppins/Poppins-BoldItalic.ttf"); font-weight: 700; font-style: italic; font-display: swap; }',
|
|
24
|
+
);
|
|
25
|
+
expect(
|
|
26
|
+
importedFontFaceCss(face("Roboto-Variable.ttf", { weight: "100 900", style: "normal" })),
|
|
27
|
+
).toContain("font-weight: 100 900; font-style: normal;");
|
|
28
|
+
expect(importedFontFaceCss(face("x.ttf", { weight: "700; color: red" }))).not.toContain(
|
|
29
|
+
"font-weight",
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("two weights of one family are both kept in the file and both drawn in the preview", () => {
|
|
34
|
+
const bold = face("Poppins-Bold.ttf", { weight: "700", style: "normal" });
|
|
35
|
+
const regular = face("Poppins-Regular.ttf", { weight: "400", style: "normal" });
|
|
36
|
+
const html = "<html><head></head><body></body></html>";
|
|
37
|
+
const saved = ensureImportedFontFace(
|
|
38
|
+
ensureImportedFontFace(html, bold, "index.html"),
|
|
39
|
+
regular,
|
|
40
|
+
"index.html",
|
|
41
|
+
);
|
|
42
|
+
expect(saved).toContain("font-weight: 700;");
|
|
43
|
+
expect(saved).toContain("font-weight: 400;");
|
|
44
|
+
expect(ensureImportedFontFace(saved, bold, "index.html")).toBe(saved);
|
|
45
|
+
injectPreviewImportedFont(document, bold);
|
|
46
|
+
injectPreviewImportedFont(document, regular);
|
|
47
|
+
injectPreviewImportedFont(document, bold);
|
|
48
|
+
expect(document.head.querySelectorAll("style")).toHaveLength(2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("a weight the browser would drop is left out, and a family cannot close the style block", () => {
|
|
52
|
+
for (const weight of ["0", "5000", "bold", "700;}"])
|
|
53
|
+
expect(importedFontFaceCss(face("x.ttf", { weight }))).not.toContain("font-weight");
|
|
54
|
+
expect(importedFontFaceCss(face("x.ttf", { weight: "1000" }))).toContain("font-weight: 1000;");
|
|
55
|
+
expect(importedFontFaceCss({ ...face("x.ttf"), family: "</style><b>x" })).not.toContain(
|
|
56
|
+
"</style>",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("a file name with a replacement pattern is saved as written, not expanded into the page", () => {
|
|
61
|
+
const html = "<html><head></head><body>rest</body></html>";
|
|
62
|
+
const odd = { family: "Cash", path: "assets/Cash$'Font.ttf", url: "/Cash$'Font.ttf" };
|
|
63
|
+
const saved = ensureImportedFontFace(html, odd, "index.html");
|
|
64
|
+
expect(saved).toContain("Cash$'Font.ttf");
|
|
65
|
+
expect(saved.match(/<body>/g)).toHaveLength(1);
|
|
66
|
+
const again = ensureImportedFontFace(saved, { ...odd, path: "assets/Cash$&.ttf" }, "index.html");
|
|
67
|
+
expect(again).toContain("Cash$&.ttf");
|
|
68
|
+
expect(again.match(/<body>/g)).toHaveLength(1);
|
|
69
|
+
});
|
|
@@ -2,6 +2,8 @@ export interface ImportedFontAsset {
|
|
|
2
2
|
family: string;
|
|
3
3
|
path: string;
|
|
4
4
|
url: string;
|
|
5
|
+
weight?: string;
|
|
6
|
+
style?: "normal" | "italic";
|
|
5
7
|
}
|
|
6
8
|
|
|
7
9
|
const FONT_EXT_RE = /\.(eot|otf|ttc|ttf|woff2?)$/i;
|
|
@@ -9,7 +11,7 @@ const FONT_STYLE_SUFFIX_RE =
|
|
|
9
11
|
/\s+(thin|extralight|extra light|light|regular|roman|medium|semibold|semi bold|bold|extrabold|extra bold|black|italic|oblique|variable)$/i;
|
|
10
12
|
|
|
11
13
|
function cssString(value: string): string {
|
|
12
|
-
return JSON.stringify(value);
|
|
14
|
+
return JSON.stringify(value).replace(/</g, "\\3c ");
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export function fontFamilyFromAssetPath(path: string): string {
|
|
@@ -28,5 +30,12 @@ export function fontFamilyFromAssetPath(path: string): string {
|
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
export function importedFontFaceCss(asset: ImportedFontAsset, url: string = asset.url): string {
|
|
31
|
-
|
|
33
|
+
const weight =
|
|
34
|
+
asset.weight && /^(?:[1-9]\d{0,2}|1000)(?: (?:[1-9]\d{0,2}|1000))?$/.test(asset.weight)
|
|
35
|
+
? ` font-weight: ${asset.weight};`
|
|
36
|
+
: "";
|
|
37
|
+
const style = asset.style
|
|
38
|
+
? ` font-style: ${asset.style === "italic" ? "italic" : "normal"};`
|
|
39
|
+
: "";
|
|
40
|
+
return `@font-face { font-family: ${cssString(asset.family)}; src: url(${cssString(url)});${weight}${style} font-display: swap; }`;
|
|
32
41
|
}
|
|
@@ -6,7 +6,12 @@ import {
|
|
|
6
6
|
SNAP_THRESHOLD_PX,
|
|
7
7
|
} from "./snapEngine";
|
|
8
8
|
import { applyManualOffsetDragDraft } from "./manualOffsetDrag";
|
|
9
|
-
import
|
|
9
|
+
import {
|
|
10
|
+
lockDragToDominantAxis,
|
|
11
|
+
type AxisLockedDelta,
|
|
12
|
+
type GroupGestureState,
|
|
13
|
+
type UseDomEditOverlayGesturesOptions,
|
|
14
|
+
} from "./domEditOverlayGestures";
|
|
10
15
|
import type { GroupOverlayItem } from "./domEditOverlayGeometry";
|
|
11
16
|
import {
|
|
12
17
|
findNonRigidMembers,
|
|
@@ -33,7 +38,7 @@ export function createGroupDragMover(
|
|
|
33
38
|
const snapGroupDelta = (
|
|
34
39
|
groupG: GroupGestureState,
|
|
35
40
|
e: React.PointerEvent<HTMLDivElement>,
|
|
36
|
-
proposed:
|
|
41
|
+
proposed: AxisLockedDelta,
|
|
37
42
|
) => {
|
|
38
43
|
const sc = groupG.snapContext;
|
|
39
44
|
if (!sc?.snapEnabled || sc.targets.length === 0) return proposed;
|
|
@@ -52,6 +57,7 @@ export function createGroupDragMover(
|
|
|
52
57
|
gridEdges: sc.gridEdges ?? undefined,
|
|
53
58
|
threshold: SNAP_THRESHOLD_PX,
|
|
54
59
|
disabled: e.altKey,
|
|
60
|
+
lockedAxis: proposed.lockedAxis,
|
|
55
61
|
});
|
|
56
62
|
const movingRect = {
|
|
57
63
|
...groupBounds,
|
|
@@ -75,10 +81,11 @@ export function createGroupDragMover(
|
|
|
75
81
|
lastGesture = groupG;
|
|
76
82
|
lastGroupPositions = {};
|
|
77
83
|
}
|
|
78
|
-
const { dx, dy } = snapGroupDelta(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
const { dx, dy } = snapGroupDelta(
|
|
85
|
+
groupG,
|
|
86
|
+
e,
|
|
87
|
+
lockDragToDominantAxis(e.clientX - groupG.startX, e.clientY - groupG.startY, e.shiftKey),
|
|
88
|
+
);
|
|
82
89
|
groupG.lastSnappedDx = dx;
|
|
83
90
|
groupG.lastSnappedDy = dy;
|
|
84
91
|
|
|
@@ -449,6 +449,62 @@ describe("GSAP-element drag — dot-a flies regressions", () => {
|
|
|
449
449
|
});
|
|
450
450
|
});
|
|
451
451
|
|
|
452
|
+
describe("a stylesheet translate after a committed GSAP drag", () => {
|
|
453
|
+
function draggedTitle(css: string, oldOffsetChannel = false, withGsap = true) {
|
|
454
|
+
const window = new Window();
|
|
455
|
+
const style = window.document.createElement("style");
|
|
456
|
+
style.textContent = css;
|
|
457
|
+
window.document.head.append(style);
|
|
458
|
+
const element = window.document.createElement("h1");
|
|
459
|
+
element.id = "title";
|
|
460
|
+
if (oldOffsetChannel) {
|
|
461
|
+
element.setAttribute("data-hf-studio-path-offset", "true");
|
|
462
|
+
element.style.setProperty(STUDIO_OFFSET_X_PROP, "30px");
|
|
463
|
+
element.style.setProperty(STUDIO_OFFSET_Y_PROP, "0px");
|
|
464
|
+
element.style.translate = `var(${STUDIO_OFFSET_X_PROP}, 0px) var(${STUDIO_OFFSET_Y_PROP}, 0px)`;
|
|
465
|
+
}
|
|
466
|
+
window.document.body.append(element);
|
|
467
|
+
element.getBoundingClientRect = () => new window.DOMRect(10, 20, 100, 50);
|
|
468
|
+
(window as unknown as { gsap: unknown }).gsap = withGsap && {
|
|
469
|
+
set: (el: HTMLElement, vars: { x?: number; y?: number }) =>
|
|
470
|
+
el.style.setProperty("transform", `translate(${vars.x ?? 0}px, ${vars.y ?? 0}px)`),
|
|
471
|
+
getProperty: () => 0,
|
|
472
|
+
};
|
|
473
|
+
const result = createManualOffsetDragMember({
|
|
474
|
+
key: "title",
|
|
475
|
+
selection: { element } as never,
|
|
476
|
+
element,
|
|
477
|
+
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
|
|
478
|
+
});
|
|
479
|
+
if (!result.ok) throw new Error("member not created");
|
|
480
|
+
applyManualOffsetDragCommit(result.member, 40, 30);
|
|
481
|
+
if (withGsap) expect(element.style.getPropertyValue("translate")).toBe("none");
|
|
482
|
+
endManualOffsetDragMembers([result.member]);
|
|
483
|
+
return element;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
it("stays masked, so it is not applied on top of the committed transform", () => {
|
|
487
|
+
expect(
|
|
488
|
+
draggedTitle("#title { translate: 0 -200px; }").style.getPropertyValue("translate"),
|
|
489
|
+
).toBe("none");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("stays masked for an element still on the old offset channel", () => {
|
|
493
|
+
const element = draggedTitle("#title { translate: 0 -200px; }", true);
|
|
494
|
+
expect(element.hasAttribute("data-hf-studio-path-offset")).toBe(false);
|
|
495
|
+
expect(element.style.getPropertyValue("translate")).toBe("none");
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
it("is not masked when GSAP does not own the position", () => {
|
|
499
|
+
const element = draggedTitle("#title { translate: 0 -200px; }", true, false);
|
|
500
|
+
expect(element.style.getPropertyValue("translate")).toContain(STUDIO_OFFSET_X_PROP);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("is not left behind when nothing sits under the draft's mask", () => {
|
|
504
|
+
expect(draggedTitle("").style.getPropertyValue("translate")).toBe("");
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
452
508
|
describe("resumeGsapTimelines", () => {
|
|
453
509
|
it("unpauses exactly the timelines the drag start paused, then re-seeks the player", () => {
|
|
454
510
|
const window = new Window();
|