@hyperframes/studio 0.8.101 → 0.8.103
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-CFJLrHRF.js → hyperframes-player-DG795Hwu.js} +1 -1
- package/dist/assets/{index-BBNVnnyH.js → index-BPJRf2X9.js} +119 -119
- package/dist/assets/{index-CJOzbslX.js → index-BiEtDkae.js} +1 -1
- package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
- package/dist/assets/{index-C33EDBL2.js → index-DuCZ3pQw.js} +1 -1
- package/dist/{chunk-MTAVNAGW.js → chunk-46XP7J3B.js} +2 -2
- package/dist/{chunk-S65AQH6M.js → chunk-DI4LCJHL.js} +3 -2
- package/dist/chunk-DI4LCJHL.js.map +1 -0
- package/dist/{chunk-7X4XRP3L.js → chunk-HIGHVWYD.js} +2 -2
- package/dist/{domEditingLayers-NU6Z5QWA.js → domEditingLayers-AQBDFNUC.js} +3 -3
- package/dist/index.d.ts +13 -3
- package/dist/index.html +2 -2
- package/dist/index.js +932 -850
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/components/editor/CanvasContextMenu.test.tsx +23 -2
- package/src/components/editor/CanvasContextMenu.tsx +7 -10
- package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -128
- package/src/components/editor/DomEditOverlay.tsx +2 -1
- package/src/components/editor/PropertyPanel.tsx +2 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +13 -0
- package/src/components/editor/domEditOverlayGestures.ts +1 -1
- package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
- package/src/components/editor/gsapAnimatesProperty.ts +0 -29
- package/src/components/editor/gsapLivePreview.test.ts +46 -1
- package/src/components/editor/manualOffsetDrag.test.ts +16 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -5
- package/src/components/editor/plainTranslate.test.ts +38 -22
- package/src/components/editor/plainTranslate.ts +66 -36
- package/src/components/editor/previewReadOnly.overlay.test.tsx +30 -0
- package/src/components/editor/previewReadOnlyContext.tsx +3 -1
- package/src/components/editor/propertyPanelHelpers.ts +3 -0
- package/src/components/editor/propertyPanelTransformCommit.test.ts +51 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -1
- package/src/components/editor/useColorGradingController.test.ts +36 -1
- package/src/components/editor/useColorGradingController.ts +2 -2
- package/src/components/editor/useDomEditNudge.test.tsx +1 -0
- package/src/components/editor/useDomEditNudge.ts +6 -3
- package/src/components/editor/useDomEditZOrder.ts +185 -0
- package/src/components/nle/PreviewOverlaysTestMocks.ts +4 -0
- package/src/domEditExports.test.tsx +92 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +6 -0
- package/src/hooks/useDomEditCommits.test.tsx +94 -90
- package/src/hooks/usePreviewInteraction.test.ts +32 -0
- package/src/hooks/usePreviewInteraction.ts +3 -2
- package/src/index.ts +3 -0
- package/src/player/components/TimelineClipFades.test.tsx +103 -3
- package/src/player/components/TimelineClipFades.tsx +53 -50
- package/src/styles/theme.css +1 -1
- package/src/utils/projectRouting.test.ts +9 -0
- package/src/utils/projectRouting.ts +3 -1
- package/dist/chunk-S65AQH6M.js.map +0 -1
- /package/dist/{chunk-MTAVNAGW.js.map → chunk-46XP7J3B.js.map} +0 -0
- /package/dist/{chunk-7X4XRP3L.js.map → chunk-HIGHVWYD.js.map} +0 -0
- /package/dist/{domEditingLayers-NU6Z5QWA.js.map → domEditingLayers-AQBDFNUC.js.map} +0 -0
|
@@ -9,13 +9,17 @@ import {
|
|
|
9
9
|
PreviewReadOnlyProvider,
|
|
10
10
|
useDomEditSelectionContext,
|
|
11
11
|
useDomEditSession,
|
|
12
|
+
useDomEditZOrder,
|
|
12
13
|
usePreviewPersistence,
|
|
13
14
|
type ConnectedDomEditOverlayProps,
|
|
14
15
|
type DomEditCapabilities,
|
|
15
16
|
type DomEditSelection,
|
|
17
|
+
type DomEditZOrder,
|
|
16
18
|
type UseDomEditSessionParams,
|
|
17
19
|
type UsePreviewPersistenceParams,
|
|
20
|
+
type ZOrderAction,
|
|
18
21
|
} from "@hyperframes/studio";
|
|
22
|
+
import { makeSelection } from "./hooks/domSelectionTestHarness";
|
|
19
23
|
|
|
20
24
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
21
25
|
|
|
@@ -66,4 +70,92 @@ describe("DOM editing package exports", () => {
|
|
|
66
70
|
expect(canvas?.className).toContain("pointer-events-none");
|
|
67
71
|
await act(async () => root.unmount());
|
|
68
72
|
});
|
|
73
|
+
|
|
74
|
+
it("gives a host the canvas menu's z-order: enabled, then one step through the session's commit", async () => {
|
|
75
|
+
const parent = document.createElement("div");
|
|
76
|
+
const back = document.createElement("div");
|
|
77
|
+
back.id = "back";
|
|
78
|
+
const front = document.createElement("div");
|
|
79
|
+
front.id = "front";
|
|
80
|
+
parent.append(back, front);
|
|
81
|
+
document.body.append(parent);
|
|
82
|
+
const commitZ = vi.fn(() => Promise.resolve());
|
|
83
|
+
const session = {
|
|
84
|
+
handleDomZIndexReorderCommit: commitZ,
|
|
85
|
+
} as unknown as Parameters<typeof DomEditProvider>[0]["value"];
|
|
86
|
+
let zOrder: DomEditZOrder | undefined;
|
|
87
|
+
function Probe() {
|
|
88
|
+
zOrder = useDomEditZOrder();
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const el = document.createElement("div");
|
|
92
|
+
document.body.append(el);
|
|
93
|
+
const root = createRoot(el);
|
|
94
|
+
await act(async () =>
|
|
95
|
+
root.render(
|
|
96
|
+
<DomEditProvider value={session}>
|
|
97
|
+
<Probe />
|
|
98
|
+
</DomEditProvider>,
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
const sel = makeSelection("Back", back);
|
|
102
|
+
const toFront: ZOrderAction = "bring-to-front";
|
|
103
|
+
expect(zOrder?.enabled(sel, "send-to-back")).toBe(false);
|
|
104
|
+
expect(zOrder?.enabled(sel, toFront)).toBe(true);
|
|
105
|
+
expect(zOrder?.apply(sel, "send-to-back")).toBe(false);
|
|
106
|
+
expect(commitZ).not.toHaveBeenCalled();
|
|
107
|
+
await act(async () => {
|
|
108
|
+
expect(zOrder?.apply(sel, toFront)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
expect(commitZ).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(commitZ).toHaveBeenCalledWith(
|
|
112
|
+
expect.arrayContaining([expect.objectContaining({ element: back, id: "back" })]),
|
|
113
|
+
expect.stringContaining(toFront),
|
|
114
|
+
toFront,
|
|
115
|
+
);
|
|
116
|
+
await act(async () => root.unmount());
|
|
117
|
+
parent.remove();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("gives a read-only preview or a detached element no z-order, and never asks the session", async () => {
|
|
121
|
+
const parent = document.createElement("div");
|
|
122
|
+
const back = document.createElement("div");
|
|
123
|
+
back.id = "back";
|
|
124
|
+
parent.append(back, document.createElement("div"));
|
|
125
|
+
document.body.append(parent);
|
|
126
|
+
const commitZ = vi.fn(() => Promise.resolve());
|
|
127
|
+
const session = {
|
|
128
|
+
handleDomZIndexReorderCommit: commitZ,
|
|
129
|
+
} as unknown as Parameters<typeof DomEditProvider>[0]["value"];
|
|
130
|
+
let zOrder: DomEditZOrder | undefined;
|
|
131
|
+
function Probe() {
|
|
132
|
+
zOrder = useDomEditZOrder();
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const el = document.body.appendChild(document.createElement("div"));
|
|
136
|
+
const root = createRoot(el);
|
|
137
|
+
const mount = (readOnly: boolean) =>
|
|
138
|
+
act(async () =>
|
|
139
|
+
root.render(
|
|
140
|
+
<PreviewReadOnlyProvider readOnly={readOnly}>
|
|
141
|
+
<DomEditProvider value={session}>
|
|
142
|
+
<Probe />
|
|
143
|
+
</DomEditProvider>
|
|
144
|
+
</PreviewReadOnlyProvider>,
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
const sel = makeSelection("Back", back);
|
|
148
|
+
await mount(true);
|
|
149
|
+
expect(zOrder?.enabled(sel, "bring-to-front")).toBe(false);
|
|
150
|
+
expect(zOrder?.apply(sel, "bring-to-front")).toBe(false);
|
|
151
|
+
expect(zOrder?.commit(sel, [{ element: back, zIndex: 1 }], "bring-to-front", null)).toBe(false);
|
|
152
|
+
await mount(false);
|
|
153
|
+
// A reload leaves the old nodes connected to their old document, one no window shows.
|
|
154
|
+
document.implementation.createHTMLDocument().body.append(parent);
|
|
155
|
+
expect(back.isConnected).toBe(true);
|
|
156
|
+
expect(zOrder?.enabled(sel, "bring-to-front"), "a selection from before a reload").toBe(false);
|
|
157
|
+
expect(zOrder?.apply(sel, "bring-to-front")).toBe(false);
|
|
158
|
+
expect(commitZ).not.toHaveBeenCalled();
|
|
159
|
+
await act(async () => root.unmount());
|
|
160
|
+
});
|
|
69
161
|
});
|
|
@@ -419,6 +419,12 @@ export const POSITION_CHANNELS: string[] = [
|
|
|
419
419
|
];
|
|
420
420
|
const MOVE_CHANNELS = [...POSITION_CHANNELS, "motionPath"];
|
|
421
421
|
|
|
422
|
+
export const GSAP_TRANSFORM_KEYS = new Set(
|
|
423
|
+
"x,y,z,scale,scaleX,scaleY,xPercent,yPercent,rotation,rotationX,rotationY,skewX,skewY,transformOrigin,svgOrigin,force3D,smoothOrigin,transformPerspective,translateX,translateY,translateZ,rotate,rotationZ,rotateZ,rotateX,rotateY".split(
|
|
424
|
+
",",
|
|
425
|
+
),
|
|
426
|
+
);
|
|
427
|
+
|
|
422
428
|
/** Whether a live timeline tween or hold writes any of `channels` on `el`. Sync, no fetch. */
|
|
423
429
|
function gsapWritesChannels(el: Element, channels: string[]): boolean {
|
|
424
430
|
const win = el.ownerDocument.defaultView as { __timelines?: Record<string, RuntimeTimeline> };
|
|
@@ -42,6 +42,7 @@ interface RenderDomEditCommitsOptions {
|
|
|
42
42
|
onTrySdkPersist?: () => Promise<CutoverResult>;
|
|
43
43
|
queueDomEditSave?: <T>(save: () => Promise<T>) => Promise<T>;
|
|
44
44
|
projectIdRef?: MutableRefObject<string | null>;
|
|
45
|
+
readOnlyPreview?: boolean;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
type FetchHandler = (
|
|
@@ -232,7 +233,7 @@ function renderDomEditCommits(
|
|
|
232
233
|
target === selection.element ? selection : null,
|
|
233
234
|
),
|
|
234
235
|
onTrySdkPersist: options.onTrySdkPersist,
|
|
235
|
-
readOnlyPreview: false,
|
|
236
|
+
readOnlyPreview: options.readOnlyPreview ?? false,
|
|
236
237
|
});
|
|
237
238
|
return null;
|
|
238
239
|
}
|
|
@@ -266,97 +267,100 @@ describe("useDomEditCommits z-index reorder persistence", () => {
|
|
|
266
267
|
document.body.replaceChildren();
|
|
267
268
|
});
|
|
268
269
|
|
|
269
|
-
it(
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const { iframe, element } = createPreviewElement(
|
|
299
|
-
'<div data-hf-id="hf-card"></div><div id="b"></div><div id="c"></div>',
|
|
300
|
-
);
|
|
301
|
-
element.id = "a";
|
|
302
|
-
const elements = [
|
|
303
|
-
element,
|
|
304
|
-
iframe.contentDocument!.getElementById("b")!,
|
|
305
|
-
iframe.contentDocument!.getElementById("c")!,
|
|
306
|
-
];
|
|
307
|
-
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
308
|
-
|
|
309
|
-
try {
|
|
310
|
-
await act(async () => {
|
|
311
|
-
await rendered.hook.handleDomZIndexReorderCommit(
|
|
312
|
-
elements.map((item, index) => ({
|
|
313
|
-
element: item,
|
|
314
|
-
zIndex: 3 - index,
|
|
315
|
-
id: item.id,
|
|
316
|
-
sourceFile: "index.html",
|
|
317
|
-
})),
|
|
318
|
-
"z-reorder:test",
|
|
319
|
-
);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
const batchPosts = fetchMock.mock.calls.filter(([input]) =>
|
|
323
|
-
requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
|
|
270
|
+
it.each([false, true])(
|
|
271
|
+
"persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload (read-only preview %s: the timeline's z sync stays editable)",
|
|
272
|
+
async (readOnlyPreview) => {
|
|
273
|
+
const original =
|
|
274
|
+
'<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
|
|
275
|
+
const after =
|
|
276
|
+
'<div id="a" style="z-index: 3"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 1"></div>';
|
|
277
|
+
const fetchMock = vi.fn(
|
|
278
|
+
async (
|
|
279
|
+
input: Parameters<typeof fetch>[0],
|
|
280
|
+
_init?: Parameters<typeof fetch>[1],
|
|
281
|
+
): Promise<Response> => {
|
|
282
|
+
const url = requestUrl(input);
|
|
283
|
+
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
284
|
+
return jsonResponse({
|
|
285
|
+
durable: true,
|
|
286
|
+
files: [
|
|
287
|
+
{
|
|
288
|
+
sourceFile: "index.html",
|
|
289
|
+
changed: true,
|
|
290
|
+
matched: [true, true, true],
|
|
291
|
+
before: original,
|
|
292
|
+
after,
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
298
|
+
},
|
|
324
299
|
);
|
|
325
|
-
|
|
326
|
-
|
|
300
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
301
|
+
const { iframe, element } = createPreviewElement(
|
|
302
|
+
'<div data-hf-id="hf-card"></div><div id="b"></div><div id="c"></div>',
|
|
327
303
|
);
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
304
|
+
element.id = "a";
|
|
305
|
+
const elements = [
|
|
306
|
+
element,
|
|
307
|
+
iframe.contentDocument!.getElementById("b")!,
|
|
308
|
+
iframe.contentDocument!.getElementById("c")!,
|
|
309
|
+
];
|
|
310
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe, { readOnlyPreview });
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
await act(async () => {
|
|
314
|
+
await rendered.hook.handleDomZIndexReorderCommit(
|
|
315
|
+
elements.map((item, index) => ({
|
|
316
|
+
element: item,
|
|
317
|
+
zIndex: 3 - index,
|
|
318
|
+
id: item.id,
|
|
319
|
+
sourceFile: "index.html",
|
|
320
|
+
})),
|
|
321
|
+
"z-reorder:test",
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const batchPosts = fetchMock.mock.calls.filter(([input]) =>
|
|
326
|
+
requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
|
|
327
|
+
);
|
|
328
|
+
const singlePosts = fetchMock.mock.calls.filter(([input]) =>
|
|
329
|
+
requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
|
|
330
|
+
);
|
|
331
|
+
expect(batchPosts).toHaveLength(1);
|
|
332
|
+
expect(singlePosts).toHaveLength(0);
|
|
333
|
+
expect(JSON.parse(String(batchPosts[0]?.[1]?.body))).toEqual({
|
|
334
|
+
batches: [
|
|
335
|
+
{
|
|
336
|
+
sourceFile: "index.html",
|
|
337
|
+
patches: expect.arrayContaining([
|
|
338
|
+
expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
|
|
339
|
+
expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
|
|
340
|
+
expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
|
|
341
|
+
]),
|
|
342
|
+
},
|
|
343
|
+
],
|
|
344
|
+
});
|
|
345
|
+
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
346
|
+
expect(rendered.recordEdit).toHaveBeenCalledWith({
|
|
347
|
+
label: "Reorder layers",
|
|
348
|
+
coalesceKey: "z-reorder:test",
|
|
349
|
+
// Unbounded per-gesture fold window (keys are unique per gesture):
|
|
350
|
+
// the z entry and its mirror/lane counterpart fold across the server
|
|
351
|
+
// round-trip that separates them.
|
|
352
|
+
coalesceMs: Number.POSITIVE_INFINITY,
|
|
353
|
+
files: { "index.html": { before: original, after } },
|
|
354
|
+
});
|
|
355
|
+
// FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
|
|
356
|
+
// The live DOM + store already hold the final state and the server matched
|
|
357
|
+
// every style-only patch, so the reload is provably redundant.
|
|
358
|
+
expect(rendered.reloadPreview).not.toHaveBeenCalled();
|
|
359
|
+
} finally {
|
|
360
|
+
rendered.cleanup();
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
);
|
|
360
364
|
|
|
361
365
|
it("falls back to reloading when the server response omits matched[]", async () => {
|
|
362
366
|
// Without a matched[] confirmation the persist can't be proven in sync with
|
|
@@ -267,3 +267,35 @@ describe("usePreviewInteraction", () => {
|
|
|
267
267
|
cleanup();
|
|
268
268
|
});
|
|
269
269
|
});
|
|
270
|
+
|
|
271
|
+
describe("a refused move's toast", () => {
|
|
272
|
+
it("gives the refusal's own reason first, else the selection's", () => {
|
|
273
|
+
const showToast = vi.fn();
|
|
274
|
+
let blocked!: (selection: DomEditSelection, reason?: string) => void;
|
|
275
|
+
function Harness() {
|
|
276
|
+
blocked = usePreviewInteraction({
|
|
277
|
+
captionEditMode: false,
|
|
278
|
+
compositionLoading: false,
|
|
279
|
+
previewIframeRef: { current: null },
|
|
280
|
+
showToast,
|
|
281
|
+
applyDomSelection: vi.fn(),
|
|
282
|
+
resolveDomSelectionFromPreviewPoint: vi.fn(async () => null),
|
|
283
|
+
resolveAllDomSelectionsFromPreviewPoint: vi.fn(async () => []),
|
|
284
|
+
updateDomEditHoverSelection: vi.fn(),
|
|
285
|
+
setActiveGroupElement: vi.fn(),
|
|
286
|
+
}).handleBlockedDomMove;
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
const root = createRoot(document.createElement("div"));
|
|
290
|
+
act(() => root.render(React.createElement(Harness)));
|
|
291
|
+
const selection = makeSelection("Box", document.createElement("div"));
|
|
292
|
+
selection.capabilities.reasonIfDisabled = "Locked layer.";
|
|
293
|
+
blocked(selection, "Studio can't read it.");
|
|
294
|
+
blocked(selection);
|
|
295
|
+
act(() => root.unmount());
|
|
296
|
+
expect(showToast.mock.calls).toEqual([
|
|
297
|
+
["Studio can't read it.", "info"],
|
|
298
|
+
["Locked layer.", "info"],
|
|
299
|
+
]);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -260,9 +260,10 @@ export function usePreviewInteraction({
|
|
|
260
260
|
}, [updateDomEditHoverSelection]);
|
|
261
261
|
|
|
262
262
|
const handleBlockedDomMove = useCallback(
|
|
263
|
-
(selection: DomEditSelection) => {
|
|
263
|
+
(selection: DomEditSelection, reason?: string) => {
|
|
264
264
|
showToast(
|
|
265
|
-
|
|
265
|
+
reason ??
|
|
266
|
+
selection.capabilities.reasonIfDisabled ??
|
|
266
267
|
"This element can't be adjusted directly from the preview.",
|
|
267
268
|
"info",
|
|
268
269
|
);
|
package/src/index.ts
CHANGED
|
@@ -187,6 +187,9 @@ export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditC
|
|
|
187
187
|
export { PreviewReadOnlyProvider } from "./components/editor/previewReadOnlyContext";
|
|
188
188
|
export { ConnectedDomEditOverlay } from "./components/editor/ConnectedDomEditOverlay";
|
|
189
189
|
export type { ConnectedDomEditOverlayProps } from "./components/editor/ConnectedDomEditOverlay";
|
|
190
|
+
export { useDomEditZOrder } from "./components/editor/useDomEditZOrder";
|
|
191
|
+
export type { DomEditZOrder } from "./components/editor/useDomEditZOrder";
|
|
192
|
+
export type { ZOrderAction } from "./components/editor/canvasContextMenuZOrder";
|
|
190
193
|
export type { DomEditCapabilities, DomEditSelection } from "./components/editor/domEditingTypes";
|
|
191
194
|
export { useDomStyleCommit } from "./hooks/useDomStyleCommit";
|
|
192
195
|
export type { UseDomStyleCommitOptions } from "./hooks/useDomStyleCommit";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
|
-
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { TimelineElement } from "../store/playerStore";
|
|
7
7
|
import { TimelineClipFades } from "./TimelineClipFades";
|
|
@@ -9,7 +9,10 @@ import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
|
9
9
|
|
|
10
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
11
|
|
|
12
|
+
const mounted: Root[] = [];
|
|
13
|
+
|
|
12
14
|
afterEach(() => {
|
|
15
|
+
act(() => mounted.splice(0).forEach((root) => root.unmount()));
|
|
13
16
|
document.body.innerHTML = "";
|
|
14
17
|
});
|
|
15
18
|
|
|
@@ -31,6 +34,7 @@ function render(
|
|
|
31
34
|
const host = document.createElement("div");
|
|
32
35
|
document.body.append(host);
|
|
33
36
|
const root = createRoot(host);
|
|
37
|
+
mounted.push(root);
|
|
34
38
|
const onSetElementAttributeLive = vi.fn();
|
|
35
39
|
const onSetElementAttributeQuiet = vi.fn().mockResolvedValue(undefined);
|
|
36
40
|
const onRevertElementAttributeLive = vi.fn();
|
|
@@ -97,6 +101,14 @@ describe("TimelineClipFades", () => {
|
|
|
97
101
|
act(() => root.unmount());
|
|
98
102
|
});
|
|
99
103
|
|
|
104
|
+
it("clips the ramps to the clip's rounded corners", () => {
|
|
105
|
+
const { host, root } = render(clip, { showHandles: false, provide: false });
|
|
106
|
+
const ramps = host.querySelector<SVGElement>('[data-testid="clip-fade-ramps"]');
|
|
107
|
+
expect(ramps?.style.overflow).toBe("hidden");
|
|
108
|
+
expect(ramps?.style.borderRadius).toBe("inherit");
|
|
109
|
+
act(() => root.unmount());
|
|
110
|
+
});
|
|
111
|
+
|
|
100
112
|
it("renders nothing for a clip without fades when the handles are hidden", () => {
|
|
101
113
|
const { host, root } = render(
|
|
102
114
|
{ ...clip, fadeIn: undefined, fadeOut: undefined },
|
|
@@ -113,7 +125,7 @@ describe("TimelineClipFades", () => {
|
|
|
113
125
|
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
114
126
|
if (!handle) throw new Error("expected a fade-in handle");
|
|
115
127
|
armCapture(handle);
|
|
116
|
-
//
|
|
128
|
+
// 150 px of rightward travel adds 1.5 s to the 1 s fade-in, wherever the press lands.
|
|
117
129
|
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
118
130
|
act(() => handle.dispatchEvent(pointer("pointermove", 200)));
|
|
119
131
|
act(() => handle.dispatchEvent(pointer("pointermove", 250)));
|
|
@@ -134,7 +146,7 @@ describe("TimelineClipFades", () => {
|
|
|
134
146
|
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
135
147
|
if (!handle) throw new Error("expected a fade-out handle");
|
|
136
148
|
armCapture(handle);
|
|
137
|
-
//
|
|
149
|
+
// 300 px of leftward travel adds 3 s to the 2 s fade-out, wherever the press lands.
|
|
138
150
|
act(() => handle.dispatchEvent(pointer("pointerdown", 1000)));
|
|
139
151
|
act(() => handle.dispatchEvent(pointer("pointermove", 700)));
|
|
140
152
|
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "5");
|
|
@@ -179,6 +191,30 @@ describe("TimelineClipFades", () => {
|
|
|
179
191
|
act(() => root.unmount());
|
|
180
192
|
});
|
|
181
193
|
|
|
194
|
+
it("cancels a drag on Escape pressed while focus is elsewhere, and saves nothing", () => {
|
|
195
|
+
const { host, onSetElementAttributeQuiet, onRevertElementAttributeLive } = render(clip);
|
|
196
|
+
const elsewhere = document.createElement("button");
|
|
197
|
+
document.body.append(elsewhere);
|
|
198
|
+
elsewhere.focus();
|
|
199
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
200
|
+
if (!handle) throw new Error("expected a fade-in handle");
|
|
201
|
+
armCapture(handle);
|
|
202
|
+
act(() => handle.dispatchEvent(pointer("pointerdown", 100)));
|
|
203
|
+
act(() => handle.dispatchEvent(pointer("pointermove", 300)));
|
|
204
|
+
expect(document.activeElement).toBe(elsewhere);
|
|
205
|
+
act(() => {
|
|
206
|
+
document.activeElement?.dispatchEvent(
|
|
207
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
act(() => handle.dispatchEvent(pointer("pointerup", 300)));
|
|
211
|
+
expect(onSetElementAttributeQuiet).not.toHaveBeenCalled();
|
|
212
|
+
expect(onRevertElementAttributeLive).toHaveBeenCalledWith(clip, "data-fade-in");
|
|
213
|
+
expect(host.querySelector('[data-testid="clip-fade-in"]')?.getAttribute("points")).toBe(
|
|
214
|
+
"0,0 100,0 0,100",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
182
218
|
it.each([
|
|
183
219
|
["above the window", 300, -40],
|
|
184
220
|
["on the window's right edge", window.innerWidth, 10],
|
|
@@ -200,6 +236,70 @@ describe("TimelineClipFades", () => {
|
|
|
200
236
|
act(() => root.unmount());
|
|
201
237
|
});
|
|
202
238
|
|
|
239
|
+
it.each([
|
|
240
|
+
["in", "Fade in 0.25 s"],
|
|
241
|
+
["out", "Fade out 0.4 s"],
|
|
242
|
+
])(
|
|
243
|
+
"gives the fade-%s handle a 24 px target and a tooltip naming its length",
|
|
244
|
+
async (edge, text) => {
|
|
245
|
+
const { host, root } = render({ ...clip, fadeIn: 0.25, fadeOut: 0.4 });
|
|
246
|
+
const handle = host.querySelector<HTMLElement>(`[data-testid="clip-fade-handle-${edge}"]`);
|
|
247
|
+
if (!handle) throw new Error(`expected a fade-${edge} handle`);
|
|
248
|
+
expect(parseFloat(handle.style.width)).toBeGreaterThanOrEqual(24);
|
|
249
|
+
expect(parseFloat(handle.style.height)).toBeGreaterThanOrEqual(24);
|
|
250
|
+
expect(handle.getAttribute("title")).toBe("");
|
|
251
|
+
act(() => handle.focus());
|
|
252
|
+
await act(async () => {
|
|
253
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
254
|
+
});
|
|
255
|
+
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(text);
|
|
256
|
+
act(() => root.unmount());
|
|
257
|
+
},
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
it.each([0.3, 3])("pins the handles to the top corners for a %s s fade-in", (fadeIn) => {
|
|
261
|
+
const { host } = render({ ...clip, fadeIn, fadeOut: 0.4 });
|
|
262
|
+
const inHandle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
263
|
+
const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
264
|
+
expect(inHandle?.style.left).toBe("0px");
|
|
265
|
+
expect(fadeOut?.style.left).toBe(`${1000 - 24}px`);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("lengthens a fade by the pointer's inward travel from wherever the press lands", () => {
|
|
269
|
+
const { host, onSetElementAttributeLive } = render(clip);
|
|
270
|
+
const fadeIn = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
271
|
+
const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
272
|
+
if (!fadeIn || !fadeOut) throw new Error("expected both handles");
|
|
273
|
+
armCapture(fadeIn);
|
|
274
|
+
armCapture(fadeOut);
|
|
275
|
+
act(() => fadeIn.dispatchEvent(pointer("pointerdown", 12)));
|
|
276
|
+
act(() => fadeIn.dispatchEvent(pointer("pointermove", 162)));
|
|
277
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-in", "2.5");
|
|
278
|
+
act(() => fadeIn.dispatchEvent(pointer("pointerup", 162)));
|
|
279
|
+
act(() => fadeOut.dispatchEvent(pointer("pointerdown", 988, 2)));
|
|
280
|
+
act(() => fadeOut.dispatchEvent(pointer("pointermove", 888, 2)));
|
|
281
|
+
expect(onSetElementAttributeLive).toHaveBeenLastCalledWith(clip, "data-fade-out", "3");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("paints the handles above the clip's waveform and thumbnail layers", () => {
|
|
285
|
+
const { host } = render(clip);
|
|
286
|
+
const handle = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
287
|
+
expect(Number(handle?.style.zIndex)).toBeGreaterThan(10);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("splits a clip narrower than two targets between the handles", () => {
|
|
291
|
+
const { host } = render({ ...clip, duration: 0.3, fadeIn: 0.1, fadeOut: 0.1 });
|
|
292
|
+
const fadeIn = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-in"]');
|
|
293
|
+
const fadeOut = host.querySelector<HTMLElement>('[data-testid="clip-fade-handle-out"]');
|
|
294
|
+
expect([fadeIn?.style.left, fadeIn?.style.width]).toEqual(["0px", "15px"]);
|
|
295
|
+
expect([fadeOut?.style.left, fadeOut?.style.width]).toEqual(["15px", "15px"]);
|
|
296
|
+
const dot = fadeIn?.firstElementChild as HTMLElement;
|
|
297
|
+
expect(getComputedStyle(dot).width).toBe("10px");
|
|
298
|
+
// Layout is not real here; tests/e2e/fade-handles.mjs measures the box in Chrome.
|
|
299
|
+
expect(getComputedStyle(dot).flexShrink).toBe("0");
|
|
300
|
+
expect(getComputedStyle(dot).pointerEvents).toBe("none");
|
|
301
|
+
});
|
|
302
|
+
|
|
203
303
|
it("does not let a press on the dot start the clip's own move gesture", () => {
|
|
204
304
|
const outer = vi.fn();
|
|
205
305
|
const { host, root } = render(clip, { onClipPointerDown: outer });
|