@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.
Files changed (57) hide show
  1. package/dist/assets/{hyperframes-player-CFJLrHRF.js → hyperframes-player-DG795Hwu.js} +1 -1
  2. package/dist/assets/{index-BBNVnnyH.js → index-BPJRf2X9.js} +119 -119
  3. package/dist/assets/{index-CJOzbslX.js → index-BiEtDkae.js} +1 -1
  4. package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
  5. package/dist/assets/{index-C33EDBL2.js → index-DuCZ3pQw.js} +1 -1
  6. package/dist/{chunk-MTAVNAGW.js → chunk-46XP7J3B.js} +2 -2
  7. package/dist/{chunk-S65AQH6M.js → chunk-DI4LCJHL.js} +3 -2
  8. package/dist/chunk-DI4LCJHL.js.map +1 -0
  9. package/dist/{chunk-7X4XRP3L.js → chunk-HIGHVWYD.js} +2 -2
  10. package/dist/{domEditingLayers-NU6Z5QWA.js → domEditingLayers-AQBDFNUC.js} +3 -3
  11. package/dist/index.d.ts +13 -3
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +932 -850
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +8 -8
  18. package/src/components/editor/CanvasContextMenu.test.tsx +23 -2
  19. package/src/components/editor/CanvasContextMenu.tsx +7 -10
  20. package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -128
  21. package/src/components/editor/DomEditOverlay.tsx +2 -1
  22. package/src/components/editor/PropertyPanel.tsx +2 -2
  23. package/src/components/editor/canvasContextMenuZOrder.ts +13 -0
  24. package/src/components/editor/domEditOverlayGestures.ts +1 -1
  25. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  26. package/src/components/editor/gsapAnimatesProperty.ts +0 -29
  27. package/src/components/editor/gsapLivePreview.test.ts +46 -1
  28. package/src/components/editor/manualOffsetDrag.test.ts +16 -0
  29. package/src/components/editor/manualOffsetDrag.ts +5 -5
  30. package/src/components/editor/plainTranslate.test.ts +38 -22
  31. package/src/components/editor/plainTranslate.ts +66 -36
  32. package/src/components/editor/previewReadOnly.overlay.test.tsx +30 -0
  33. package/src/components/editor/previewReadOnlyContext.tsx +3 -1
  34. package/src/components/editor/propertyPanelHelpers.ts +3 -0
  35. package/src/components/editor/propertyPanelTransformCommit.test.ts +51 -0
  36. package/src/components/editor/propertyPanelTransformCommit.ts +3 -1
  37. package/src/components/editor/useColorGradingController.test.ts +36 -1
  38. package/src/components/editor/useColorGradingController.ts +2 -2
  39. package/src/components/editor/useDomEditNudge.test.tsx +1 -0
  40. package/src/components/editor/useDomEditNudge.ts +6 -3
  41. package/src/components/editor/useDomEditZOrder.ts +185 -0
  42. package/src/components/nle/PreviewOverlaysTestMocks.ts +4 -0
  43. package/src/domEditExports.test.tsx +92 -0
  44. package/src/hooks/gsapRuntimeKeyframes.ts +6 -0
  45. package/src/hooks/useDomEditCommits.test.tsx +94 -90
  46. package/src/hooks/usePreviewInteraction.test.ts +32 -0
  47. package/src/hooks/usePreviewInteraction.ts +3 -2
  48. package/src/index.ts +3 -0
  49. package/src/player/components/TimelineClipFades.test.tsx +103 -3
  50. package/src/player/components/TimelineClipFades.tsx +53 -50
  51. package/src/styles/theme.css +1 -1
  52. package/src/utils/projectRouting.test.ts +9 -0
  53. package/src/utils/projectRouting.ts +3 -1
  54. package/dist/chunk-S65AQH6M.js.map +0 -1
  55. /package/dist/{chunk-MTAVNAGW.js.map → chunk-46XP7J3B.js.map} +0 -0
  56. /package/dist/{chunk-7X4XRP3L.js.map → chunk-HIGHVWYD.js.map} +0 -0
  57. /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("persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload", async () => {
270
- const original =
271
- '<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
272
- const after =
273
- '<div id="a" style="z-index: 3"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 1"></div>';
274
- const fetchMock = vi.fn(
275
- async (
276
- input: Parameters<typeof fetch>[0],
277
- _init?: Parameters<typeof fetch>[1],
278
- ): Promise<Response> => {
279
- const url = requestUrl(input);
280
- if (url.endsWith("/file-mutations/patch-element-batches")) {
281
- return jsonResponse({
282
- durable: true,
283
- files: [
284
- {
285
- sourceFile: "index.html",
286
- changed: true,
287
- matched: [true, true, true],
288
- before: original,
289
- after,
290
- },
291
- ],
292
- });
293
- }
294
- throw new Error(`Unexpected fetch: ${url}`);
295
- },
296
- );
297
- vi.stubGlobal("fetch", fetchMock);
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
- const singlePosts = fetchMock.mock.calls.filter(([input]) =>
326
- requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
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
- expect(batchPosts).toHaveLength(1);
329
- expect(singlePosts).toHaveLength(0);
330
- expect(JSON.parse(String(batchPosts[0]?.[1]?.body))).toEqual({
331
- batches: [
332
- {
333
- sourceFile: "index.html",
334
- patches: expect.arrayContaining([
335
- expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
336
- expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
337
- expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
338
- ]),
339
- },
340
- ],
341
- });
342
- expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
343
- expect(rendered.recordEdit).toHaveBeenCalledWith({
344
- label: "Reorder layers",
345
- coalesceKey: "z-reorder:test",
346
- // Unbounded per-gesture fold window (keys are unique per gesture):
347
- // the z entry and its mirror/lane counterpart fold across the server
348
- // round-trip that separates them.
349
- coalesceMs: Number.POSITIVE_INFINITY,
350
- files: { "index.html": { before: original, after } },
351
- });
352
- // FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
353
- // The live DOM + store already hold the final state and the server matched
354
- // every style-only patch, so the reload is provably redundant.
355
- expect(rendered.reloadPreview).not.toHaveBeenCalled();
356
- } finally {
357
- rendered.cleanup();
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
- selection.capabilities.reasonIfDisabled ??
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
- // The handle sits at the 1 s mark (x=100). Drag 150 px right → 2.5 s.
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
- // Pressed at x=1000 with a 2 s fade-out. 300 px left → 5 s.
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 });