@hyperframes/studio 0.8.121 → 0.8.123

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 (63) hide show
  1. package/dist/assets/{hyperframes-player-GkN9yzzq.js → hyperframes-player-yABdb6yk.js} +1 -1
  2. package/dist/assets/{index-ByV72rWx.js → index-D77MIjSJ.js} +1 -1
  3. package/dist/assets/{index-BpKhiuzT.js → index-DWjF2Kxw.js} +1 -1
  4. package/dist/assets/{index-CyFqMHGS.js → index-Dt7lWtY7.js} +184 -184
  5. package/dist/assets/{index-t1LH6H5H.css → index-LrNlT5Tg.css} +1 -1
  6. package/dist/{chunk-J6Q572FA.js → chunk-246VB2G3.js} +3 -1
  7. package/dist/chunk-246VB2G3.js.map +1 -0
  8. package/dist/index.d.ts +10 -2
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +1611 -1310
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +8 -8
  15. package/src/components/FrameyGlyph.tsx +52 -0
  16. package/src/components/OpenInDesktopButton.dom.test.tsx +113 -0
  17. package/src/components/OpenInDesktopButton.tsx +153 -0
  18. package/src/components/StudioHeader.dom.test.tsx +2 -0
  19. package/src/components/StudioHeader.tsx +2 -0
  20. package/src/components/TimelineToolbar.test.tsx +25 -0
  21. package/src/components/TimelineToolbar.tsx +5 -1
  22. package/src/components/editor/MotionPathNode.tsx +2 -1
  23. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +99 -0
  24. package/src/components/editor/MotionPathOverlay.tsx +6 -0
  25. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +57 -0
  26. package/src/components/editor/domEditOverlayStartGesture.ts +1 -1
  27. package/src/components/editor/motionPathGeometry.test.ts +10 -0
  28. package/src/components/editor/motionPathGeometry.ts +8 -5
  29. package/src/components/editor/motionPathLayerNode.test.ts +82 -0
  30. package/src/components/editor/motionPathLayerNode.ts +28 -0
  31. package/src/components/editor/useDomEditNudge.test.tsx +53 -1
  32. package/src/components/editor/useDomEditNudge.ts +2 -2
  33. package/src/components/editor/useMotionPathData.test.tsx +30 -0
  34. package/src/components/editor/useMotionPathData.ts +3 -1
  35. package/src/hooks/gsapValueAtPlayhead.test.ts +46 -3
  36. package/src/hooks/gsapValueAtPlayhead.ts +12 -2
  37. package/src/hooks/linkShortcuts.test.ts +16 -0
  38. package/src/hooks/linkShortcuts.ts +2 -0
  39. package/src/hooks/useAppHotkeys.ts +1 -2
  40. package/src/index.ts +2 -0
  41. package/src/player/components/TimelineClipFades.tsx +1 -0
  42. package/src/player/components/timelineClipDragPreview.test.ts +68 -0
  43. package/src/player/components/timelineClipDragPreview.ts +39 -14
  44. package/src/player/components/timelineLayout.test.ts +24 -1
  45. package/src/player/components/timelineRulerGeometry.ts +24 -3
  46. package/src/player/components/timelineSnapping.test.ts +43 -12
  47. package/src/player/components/timelineSnapping.ts +33 -15
  48. package/src/player/components/useTimelineClipDrag.resize.test.tsx +54 -54
  49. package/src/player/components/useTimelineClipDrag.ts +17 -29
  50. package/src/player/components/useTimelineProviderState.tsx +0 -1
  51. package/src/player/components/useTimelineTicks.ts +6 -3
  52. package/src/player/hooks/timelinePlayerTestHarness.ts +2 -0
  53. package/src/player/hooks/useTimelinePlayer.seek.test.ts +66 -0
  54. package/src/player/hooks/useTimelinePlayer.ts +6 -2
  55. package/src/player/lib/time.ts +3 -0
  56. package/src/player/store/previewFrameStore.ts +48 -0
  57. package/src/player/store/timelineElement.ts +12 -0
  58. package/src/styles/components.css +51 -0
  59. package/src/styles/theme.css +3 -0
  60. package/src/timelineChromeExports.test.tsx +6 -0
  61. package/src/utils/gsapSoftReload.test.ts +50 -0
  62. package/src/utils/gsapSoftReload.ts +22 -2
  63. package/dist/chunk-J6Q572FA.js.map +0 -1
@@ -78,7 +78,10 @@ export function nearestPointOnPath(
78
78
  return best;
79
79
  }
80
80
 
81
- export function buildMotionPathGeometry(read: ReadTween | null): MotionPathGeometry | null {
81
+ export function buildMotionPathGeometry(
82
+ read: ReadTween | null,
83
+ base: { x: number; y: number } = { x: 0, y: 0 },
84
+ ): MotionPathGeometry | null {
82
85
  if (!read) return null;
83
86
  const isArc = Boolean(read.arcPath);
84
87
  const nodes: MotionPathNode[] = [];
@@ -87,8 +90,8 @@ export function buildMotionPathGeometry(read: ReadTween | null): MotionPathGeome
87
90
  // anchor. Arc waypoints always carry x/y (never filtered), so source index
88
91
  // and node order stay aligned.
89
92
  // Which axes does the tween animate at all? A single-axis tween (e.g.
90
- // `to({ x: -260 })`) only carries x; its y stays at the base (0, the GSAP
91
- // transform identity), so we default it and still draw a path. But if the tween
93
+ // `to({ x: -260 })`) only carries x; its y stays at `base`, GSAP's live y (a CSS
94
+ // translate it folded in, else 0), so we default it and still draw a path. But if the tween
92
95
  // DOES animate an axis and a given keyframe omits it, that value is interpolated
93
96
  // (not 0) and can't be placed here → skip that node (the prior behavior).
94
97
  const finite = (v: unknown): v is number => typeof v === "number" && isFinite(v);
@@ -100,8 +103,8 @@ export function buildMotionPathGeometry(read: ReadTween | null): MotionPathGeome
100
103
  if (tweenHasX && !finite(kf.properties.x)) return;
101
104
  if (tweenHasY && !finite(kf.properties.y)) return;
102
105
  nodes.push({
103
- x: tweenHasX ? (kf.properties.x as number) : 0,
104
- y: tweenHasY ? (kf.properties.y as number) : 0,
106
+ x: tweenHasX ? (kf.properties.x as number) : base.x,
107
+ y: tweenHasY ? (kf.properties.y as number) : base.y,
105
108
  ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage },
106
109
  });
107
110
  });
@@ -0,0 +1,82 @@
1
+ // @vitest-environment happy-dom
2
+ import { expect, it } from "vitest";
3
+ import { pressBelongsToLayer, pressSelectedLayer } from "./motionPathLayerNode";
4
+
5
+ // Keyframe nodes at x 60 and 120 (y 30), drawn from home (500, 400); dots of radius 6.
6
+ const atLayer = { x: 60, y: 30, ax: 560, ay: 430 };
7
+ const other = { x: 120, y: 30, ax: 620, ay: 430 };
8
+ const live = { x: 60, y: 30 };
9
+
10
+ const box = document.createElement("div");
11
+ box.setAttribute("data-dom-edit-selection-box", "true");
12
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
13
+ document.body.append(box, circle);
14
+
15
+ /** A press on `circle` at (x, y), with `under` what the document hit-tests there. */
16
+ function press(x: number, y: number, under: Element[]) {
17
+ document.elementsFromPoint = () => [circle, ...under];
18
+ return { currentTarget: circle, clientX: x, clientY: y } as unknown as React.PointerEvent;
19
+ }
20
+
21
+ it("the node GSAP renders the layer at is the layer, inside the box or out", () => {
22
+ expect(pressBelongsToLayer(press(562, 431, [box]), { x: 562, y: 431 }, atLayer, 6, live)).toBe(
23
+ true,
24
+ );
25
+ expect(pressBelongsToLayer(press(570, 440, []), { x: 570, y: 440 }, atLayer, 6, live)).toBe(true);
26
+ });
27
+
28
+ it("inside the layer's box another node's grab ring is the layer's; its dot stays the node's", () => {
29
+ expect(pressBelongsToLayer(press(610, 430, [box]), { x: 610, y: 430 }, other, 6, live)).toBe(
30
+ true,
31
+ );
32
+ expect(pressBelongsToLayer(press(617, 430, [box]), { x: 617, y: 430 }, other, 6, live)).toBe(
33
+ false,
34
+ );
35
+ });
36
+
37
+ it("outside the layer's box a node's whole grab ring is the node's", () => {
38
+ expect(pressBelongsToLayer(press(610, 430, []), { x: 610, y: 430 }, other, 6, live)).toBe(false);
39
+ expect(pressBelongsToLayer(press(610, 430, [box]), { x: 610, y: 430 }, other, 6, null)).toBe(
40
+ true,
41
+ );
42
+ });
43
+
44
+ it("hands a press to the selected layer's box with its pointer and position", () => {
45
+ const got: number[][] = [];
46
+ const listen = (e: Event) => {
47
+ const p = e as PointerEvent;
48
+ got.push([p.pointerId, p.clientX, p.clientY, p.button]);
49
+ e.preventDefault();
50
+ };
51
+ box.addEventListener("pointerdown", listen);
52
+ const nativeEvent = new PointerEvent("pointerdown", {
53
+ pointerId: 7,
54
+ clientX: 12,
55
+ clientY: 34,
56
+ button: 0,
57
+ bubbles: true,
58
+ cancelable: true,
59
+ });
60
+ const down = { currentTarget: circle, nativeEvent } as unknown as React.PointerEvent;
61
+ expect(pressSelectedLayer(down)).toBe(true);
62
+ expect(got).toEqual([[7, 12, 34, 0]]);
63
+ box.removeEventListener("pointerdown", listen);
64
+ box.remove();
65
+ expect(pressSelectedLayer(down)).toBe(false);
66
+ document.body.append(box);
67
+ });
68
+
69
+ it("keeps the press when the box does not start a gesture with it, or takes no pointer", () => {
70
+ let got = 0;
71
+ const listen = () => void got++;
72
+ box.addEventListener("pointerdown", listen);
73
+ const nativeEvent = new PointerEvent("pointerdown", { bubbles: true, cancelable: true });
74
+ const down = { currentTarget: circle, nativeEvent } as unknown as React.PointerEvent;
75
+ expect(pressSelectedLayer(down)).toBe(false);
76
+ expect(got).toBe(1);
77
+ box.style.pointerEvents = "none";
78
+ expect(pressSelectedLayer(down)).toBe(false);
79
+ expect(got).toBe(1);
80
+ box.style.pointerEvents = "";
81
+ box.removeEventListener("pointerdown", listen);
82
+ });
@@ -0,0 +1,28 @@
1
+ type Point = { x: number; y: number };
2
+ type DrawnNode = Point & { ax: number; ay: number };
3
+
4
+ const LAYER_BOX = '[data-dom-edit-selection-box="true"]';
5
+
6
+ /** How far a node's dot reaches; a selected node draws it larger. */
7
+ export const dotRadius = (r: number, selected: boolean) => (selected ? r * 1.5 : r);
8
+
9
+ /** A press on the layer's own node, or in its box but off a node's dot, belongs to the layer. */
10
+ export function pressBelongsToLayer(
11
+ e: React.PointerEvent,
12
+ point: Point,
13
+ pressed: DrawnNode,
14
+ dotR: number,
15
+ live: Point | null,
16
+ ): boolean {
17
+ if (live && Math.abs(pressed.x - live.x) < 0.5 && Math.abs(pressed.y - live.y) < 0.5) return true;
18
+ const hits = e.currentTarget.ownerDocument.elementsFromPoint(e.clientX, e.clientY);
19
+ const inBox = hits.some((el) => el.closest(LAYER_BOX));
20
+ return inBox && Math.hypot(point.x - pressed.ax, point.y - pressed.ay) > dotR;
21
+ }
22
+
23
+ /** Hands a press to the selected layer's box; true when the box started a gesture with it. */
24
+ export function pressSelectedLayer(e: React.PointerEvent): boolean {
25
+ const box = e.currentTarget.ownerDocument.querySelector(LAYER_BOX);
26
+ if (!box || getComputedStyle(box).pointerEvents === "none") return false;
27
+ return !box.dispatchEvent(new PointerEvent("pointerdown", e.nativeEvent));
28
+ }
@@ -34,9 +34,11 @@ let flushNudge = () => {};
34
34
  function Harness({
35
35
  selection,
36
36
  onPathOffsetCommit,
37
+ onManualDragStart = () => {},
37
38
  }: {
38
39
  selection: DomEditSelection | null;
39
40
  onPathOffsetCommit: UseDomEditNudgeParams["onPathOffsetCommitRef"]["current"];
41
+ onManualDragStart?: () => void;
40
42
  }) {
41
43
  flushNudge = useDomEditNudge({
42
44
  selection,
@@ -48,7 +50,7 @@ function Harness({
48
50
  gestureRef: makeRef(null),
49
51
  groupGestureRef: makeRef(null),
50
52
  blockedMoveRef: makeRef(null),
51
- onManualDragStartRef: makeRef(() => {}),
53
+ onManualDragStartRef: makeRef(onManualDragStart),
52
54
  onBlockedMoveRef: makeRef(() => {}),
53
55
  onPathOffsetCommitRef: makeRef(onPathOffsetCommit),
54
56
  onGroupPathOffsetCommitRef: makeRef(async () => {}),
@@ -340,6 +342,56 @@ describe("useDomEditNudge carries the route its press chose", () => {
340
342
  });
341
343
  });
342
344
 
345
+ describe("useDomEditNudge pauses playback before it snapshots the timelines", () => {
346
+ let reactRoot: ReturnType<typeof createRoot> | null = null;
347
+ const pause = vi.fn();
348
+ const mount = (selection: DomEditSelection | null) => {
349
+ reactRoot = createRoot(document.createElement("div"));
350
+ act(() => {
351
+ reactRoot!.render(
352
+ React.createElement(Harness, {
353
+ selection,
354
+ onPathOffsetCommit: vi.fn(),
355
+ onManualDragStart: pause,
356
+ }),
357
+ );
358
+ });
359
+ };
360
+ beforeEach(() => {
361
+ vi.useFakeTimers();
362
+ __resetForTests();
363
+ pause.mockReset();
364
+ });
365
+ afterEach(() => {
366
+ act(() => reactRoot?.unmount());
367
+ reactRoot = null;
368
+ document.body.innerHTML = "";
369
+ vi.useRealTimers();
370
+ delete (window as { __timelines?: unknown }).__timelines;
371
+ });
372
+
373
+ it("does not resume a timeline the playback pause stopped", () => {
374
+ let playing = true;
375
+ const root = { pause: () => void (playing = false), paused: () => !playing };
376
+ (window as { __timelines?: unknown }).__timelines = { root };
377
+ pause.mockImplementation(root.pause);
378
+ const element = document.body.appendChild(document.createElement("div"));
379
+ mount(makeSelection("Dot", element));
380
+
381
+ act(() => dispatchArrowRight());
382
+
383
+ expect(element.hasAttribute("data-hf-drag-paused-timelines")).toBe(false);
384
+ });
385
+
386
+ it("leaves playback alone when no layer can move", () => {
387
+ mount(null);
388
+
389
+ act(() => dispatchArrowRight());
390
+
391
+ expect(pause).not.toHaveBeenCalled();
392
+ });
393
+ });
394
+
343
395
  describe("useDomEditNudge — undo right after a burst", () => {
344
396
  it("undo's drain commits a burst still inside its debounce and waits for its save", async () => {
345
397
  __resetForTests();
@@ -212,6 +212,8 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
212
212
  ? resolveGroupNudgeTargets(groupItems)
213
213
  : resolveSingleNudgeTarget(p.selectionRef.current, p.overlayRectRef.current);
214
214
  if (!targets) return null;
215
+ // Same side effect a drag start has (pauses preview playback).
216
+ p.onManualDragStartRef.current?.();
215
217
  const members: ManualOffsetDragMember[] = [];
216
218
  for (const target of targets) {
217
219
  const result = createManualOffsetDragMember({ ...target, gesture: "nudge" });
@@ -223,8 +225,6 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
223
225
  members.push(result.member);
224
226
  }
225
227
  if (members.length === 0) return null;
226
- // Same side effect a drag start has (pauses preview playback).
227
- p.onManualDragStartRef.current?.();
228
228
  const edit = beginStudioPendingEdit(manualOffsetMoveRevert(members));
229
229
  return { members, isGroup, accum: { x: 0, y: 0 }, timer: null, edit };
230
230
  };
@@ -8,6 +8,12 @@ import { usePlayerStore } from "../../player/store/playerStore";
8
8
 
9
9
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10
10
 
11
+ const runtime = vi.hoisted(() => ({ read: null as unknown, gsapPosition: null as unknown }));
12
+ vi.mock("../../hooks/gsapRuntimeKeyframes", () => ({ readRuntimeKeyframes: () => runtime.read }));
13
+ vi.mock("../../hooks/gsapPositionDetection", () => ({
14
+ readGsapPositionFromIframe: () => runtime.gsapPosition,
15
+ }));
16
+
11
17
  const originalRaf = window.requestAnimationFrame;
12
18
  let frames: Array<() => void> = [];
13
19
  const runFrames = (count: number) => {
@@ -36,6 +42,30 @@ afterEach(() => {
36
42
  vi.useRealTimers();
37
43
  usePlayerStore.setState({ previewBooted: false, motionPathArmed: false });
38
44
  document.body.innerHTML = "";
45
+ runtime.read = runtime.gsapPosition = null;
46
+ });
47
+
48
+ it("draws an axis the tween leaves alone where GSAP renders it, so the playhead node sits on the layer", () => {
49
+ runtime.read = {
50
+ keyframes: [
51
+ { percentage: 66.667, properties: { x: 60 } },
52
+ { percentage: 100, properties: { x: 120 } },
53
+ ],
54
+ };
55
+ runtime.gsapPosition = { x: 60, y: 30 };
56
+ let points: string | undefined;
57
+ function Probe() {
58
+ const ref = useRef(document.createElement("iframe"));
59
+ points = useMotionPathData(ref, "#box").geometry?.points;
60
+ return null;
61
+ }
62
+ const root = createRoot(document.body.appendChild(document.createElement("div")));
63
+ try {
64
+ act(() => root.render(<Probe />));
65
+ expect(points).toBe("60,30 120,30");
66
+ } finally {
67
+ act(() => root.unmount());
68
+ }
39
69
  });
40
70
 
41
71
  it("reads no layout per frame until there is a path or a create ring to draw", () => {
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, useState, type RefObject } from "react";
2
2
  import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
3
+ import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
3
4
  import { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
4
5
  import { buildMotionPathGeometry, type MotionPathGeometry } from "./motionPathGeometry";
5
6
  import { subscribeOverlayFrame } from "./overlayFrameLoop";
@@ -169,7 +170,8 @@ export function useMotionPathData(
169
170
  const recompute = () => {
170
171
  // Position-only: never let a co-located size/scale tween shadow the path.
171
172
  const read = readRuntimeKeyframes(iframeRef.current, selector, undefined, ["x", "y"]);
172
- const next = buildMotionPathGeometry(read);
173
+ const base = read ? readGsapPositionFromIframe(iframeRef.current, selector) : null;
174
+ const next = buildMotionPathGeometry(read, base ?? undefined);
173
175
  setGeometry((prev) =>
174
176
  prev?.points === next?.points && prev?.kind === next?.kind ? prev : next,
175
177
  );
@@ -527,9 +527,52 @@ describe("planValueAtPlayhead", () => {
527
527
  ]);
528
528
  });
529
529
 
530
- it("refuses keyframes eased as a whole, whose segment curves it cannot keep", () => {
531
- const anim = { ...kf([{ percentage: 100, properties: { x: 20 } }]), ease: "power2.out" };
532
- expect(plan({ anim, values: { x: 5 } })).toEqual({ ok: false, reason: "eased-keyframes" });
530
+ describe("keyframes eased as a whole", () => {
531
+ const eased = {
532
+ ...kf([
533
+ { percentage: 0, properties: { x: 0 } },
534
+ { percentage: 100, properties: { x: 20 } },
535
+ ]),
536
+ ease: "power2.out",
537
+ };
538
+
539
+ it("changes the keyframe under the playhead and writes the ease back", () => {
540
+ const result = plan({ anim: eased, at: { time: 4 }, values: { x: 5 } });
541
+ expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([0, 5]);
542
+ expect(result.ok && result.mutation.ease).toBe("power2.out");
543
+ });
544
+
545
+ it("writes the keyframes' own ease as the tween's, as GSAP prefers it", () => {
546
+ const anim = { ...eased, keyframes: { ...eased.keyframes!, ease: "expo.in" } };
547
+ const result = plan({ anim, at: { time: 0 }, values: { x: 5 } });
548
+ expect(result.ok && result.mutation.ease).toBe("expo.in");
549
+ });
550
+
551
+ it("refuses an inner keyframe, which the ease shows at another time", () => {
552
+ const anim = {
553
+ ...kf([
554
+ { percentage: 0, properties: { x: 0 } },
555
+ { percentage: 50, properties: { x: 100 } },
556
+ { percentage: 100, properties: { x: 300 } },
557
+ ]),
558
+ ease: "power2.out",
559
+ };
560
+ expect(plan({ anim, at: { time: 2 }, values: { x: 5 } })).toEqual({
561
+ ok: false,
562
+ reason: "eased-keyframes",
563
+ });
564
+ });
565
+
566
+ it("refuses to add a keyframe, whose time the ease would move", () => {
567
+ expect(plan({ anim: eased, at: { time: 2 }, values: { x: 5 } })).toEqual({
568
+ ok: false,
569
+ reason: "eased-keyframes",
570
+ });
571
+ expect(plan({ anim: eased, at: { time: 6 }, values: { x: 5 } })).toEqual({
572
+ ok: false,
573
+ reason: "eased-keyframes",
574
+ });
575
+ });
533
576
  });
534
577
  });
535
578
 
@@ -55,6 +55,7 @@ export type PlayheadEditPlan =
55
55
  position: number;
56
56
  duration: number;
57
57
  keyframes: Keyframe[];
58
+ ease?: string;
58
59
  easeEach?: string;
59
60
  };
60
61
  /** A keyframe was added, not only changed: keyframe usage counts it as an add. */
@@ -70,6 +71,7 @@ const isLinear = (ease: string | undefined) => !ease || ease === "none" || ease
70
71
 
71
72
  interface Normalized {
72
73
  keyframes: Keyframe[];
74
+ runEase?: string;
73
75
  easeEach?: string;
74
76
  }
75
77
 
@@ -82,10 +84,10 @@ function normalize(edit: PlayheadEdit): Normalized | { reason: PlayheadEditRefus
82
84
  const { anim } = edit;
83
85
  const data = anim.keyframes;
84
86
  if (data) {
85
- if (!isLinear(anim.ease) || !isLinear(data.ease)) return { reason: "eased-keyframes" };
86
87
  if (data.format === "simple-array") return { reason: "simple-array-keyframes" };
87
88
  const arrayStep = data.format === "object-array";
88
89
  return {
90
+ runEase: data.ease ?? anim.ease,
89
91
  keyframes: data.keyframes.map((kf) => ({
90
92
  ...kf,
91
93
  properties: { ...kf.properties },
@@ -126,8 +128,11 @@ function valueAt(keyframes: Keyframe[], prop: string, side: "first" | "last") {
126
128
  return kf ? { percentage: kf.percentage, value: kf.properties[prop]! } : null;
127
129
  }
128
130
 
131
+ const keyframeAt = (keyframes: Keyframe[], percentage: number) =>
132
+ keyframes.find((kf) => Math.abs(kf.percentage - percentage) <= KEYFRAME_PCT_MATCH);
133
+
129
134
  function upsert(keyframes: Keyframe[], percentage: number, properties: Props, ease?: string) {
130
- const hit = keyframes.find((kf) => Math.abs(kf.percentage - percentage) <= KEYFRAME_PCT_MATCH);
135
+ const hit = keyframeAt(keyframes, percentage);
131
136
  if (hit) {
132
137
  Object.assign(hit.properties, properties);
133
138
  return hit;
@@ -195,14 +200,18 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
195
200
  "percentage" in edit.at
196
201
  ? edit.at.percentage
197
202
  : roundPct(((edit.at.time - start) / duration) * 100);
203
+ const eased = !isLinear(norm.runEase);
198
204
  if (pct >= -KEYFRAME_PCT_MATCH && pct <= 100 + KEYFRAME_PCT_MATCH) {
199
205
  const at = Math.min(100, Math.max(0, pct));
206
+ const hit = keyframeAt(keyframes, at);
207
+ if (eased && hit?.percentage !== 0 && hit?.percentage !== 100) return refuse("eased-keyframes");
200
208
  const next = [...keyframes]
201
209
  .sort((a, b) => a.percentage - b.percentage)
202
210
  .find((kf) => kf.percentage > at + KEYFRAME_PCT_MATCH);
203
211
  // The new keyframe splits next's segment, so it takes next's ease.
204
212
  upsert(keyframes, at, { ...values }, next?.ease);
205
213
  } else {
214
+ if (eased) return refuse("eased-keyframes");
206
215
  const time = (edit.at as { time: number }).time;
207
216
  const before = time < start;
208
217
  const held = heldEnds(norm, before ? "start" : "end", edit.implicitEndValue, backfilled);
@@ -232,6 +241,7 @@ export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan {
232
241
  position: roundTo3(position),
233
242
  duration: roundTo3(span),
234
243
  keyframes,
244
+ ...(norm.runEase ? { ease: norm.runEase } : {}),
235
245
  ...(norm.easeEach ? { easeEach: norm.easeEach } : {}),
236
246
  },
237
247
  added: keyframes.length > authored,
@@ -61,6 +61,22 @@ describe("dispatchLinkShortcut", () => {
61
61
  expect(handleTimelineElementDeleteOnly).toHaveBeenCalledWith(video);
62
62
  });
63
63
 
64
+ it("leaves ⌥⌫ to a text field the user is typing in", () => {
65
+ const video = clip("talk", "video", { muted: true, link: "lk-1" });
66
+ select([video, clip("talk-audio", "audio", { link: "lk-1" })], "talk");
67
+ const input = document.createElement("input");
68
+ document.body.append(input);
69
+ const event = key({ altKey: true, key: "Backspace", bubbles: true });
70
+ const handleTimelineElementDeleteOnly = vi.fn();
71
+ input.addEventListener("keydown", (e) =>
72
+ dispatchLinkShortcut(e, { handleLinkEdit: vi.fn(), handleTimelineElementDeleteOnly }),
73
+ );
74
+ input.dispatchEvent(event);
75
+ input.remove();
76
+ expect(handleTimelineElementDeleteOnly).not.toHaveBeenCalled();
77
+ expect(event.preventDefault).not.toHaveBeenCalled();
78
+ });
79
+
64
80
  it("leaves the key alone when no link item applies", () => {
65
81
  select([clip("title", "div")], "title");
66
82
  const event = key({ metaKey: true, key: "l" });
@@ -1,6 +1,7 @@
1
1
  import { usePlayerStore } from "../player";
2
2
  import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
3
3
  import { resolveLinkMenuItems } from "../player/components/clipMenuLinkItems";
4
+ import { isTypingTarget } from "../utils/typingTarget";
4
5
 
5
6
  export interface LinkShortcutCallbacks {
6
7
  handleLinkEdit?: TimelineEditCallbacks["onLinkEdit"];
@@ -22,6 +23,7 @@ function shortcutOf(event: KeyboardEvent): string | null {
22
23
 
23
24
  /** ⌘L unlink/link, ⌥⇧D detach audio, ⌥⌫ delete one linked clip — the clip menu's shortcuts. */
24
25
  export function dispatchLinkShortcut(event: KeyboardEvent, cb: LinkShortcutCallbacks): boolean {
26
+ if (isTypingTarget(event.target)) return false;
25
27
  const shortcut = shortcutOf(event);
26
28
  if (!shortcut) return false;
27
29
  const { elements, selectedElementId, selectedElementIds } = usePlayerStore.getState();
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from "react";
2
2
  import { usePlayerStore } from "../player";
3
3
  import type { TimelineElement } from "../player";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
- import { isTypingTarget } from "../utils/typingTarget";
6
5
  import { dispatchLinkShortcut, type LinkShortcutCallbacks } from "./linkShortcuts";
7
6
  import { useCaptionStore } from "../captions/store";
8
7
  import {
@@ -226,7 +225,7 @@ export function useAppHotkeys({
226
225
  const handleAppKeyDown = useCallback((event: KeyboardEvent) => {
227
226
  const cb = cbRef.current;
228
227
  const key = event.key.toLowerCase();
229
- if (!isTypingTarget(event.target) && dispatchLinkShortcut(event, cb)) return;
228
+ if (dispatchLinkShortcut(event, cb)) return;
230
229
  if (event.metaKey || event.ctrlKey) {
231
230
  dispatchModifierKey(event, key, cb);
232
231
  return;
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ export type { EditorShellProps } from "./components/EditorShell";
7
7
  export { NLEPreview } from "./components/nle/NLEPreview";
8
8
  export { DEFAULT_SHORTCUT_SECTIONS } from "./player/components/studioShortcuts";
9
9
  export { ShortcutsButton } from "./player/components/ShortcutsPanel";
10
+ export { dispatchLinkShortcut } from "./hooks/linkShortcuts";
11
+ export type { LinkShortcutCallbacks } from "./hooks/linkShortcuts";
10
12
  export type { ShortcutsButtonProps } from "./player/components/ShortcutsPanel";
11
13
  export type { ShortcutHint, ShortcutSection } from "./player/components/studioShortcuts";
12
14
  export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
@@ -48,6 +48,7 @@ const SNAP_LABEL: Record<TimelineSnapType, string> = {
48
48
  playhead: "playhead",
49
49
  "clip-edge": "clip edge",
50
50
  beat: "beat",
51
+ grid: "grid line",
51
52
  };
52
53
 
53
54
  export type ClipFadeShape = AudioFades & { duration: number };
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
3
3
  import {
4
4
  computeDragPreview,
5
5
  computeResizePreview,
6
+ guideIfSaved,
6
7
  getTimelineDragOverlayPosition,
7
8
  type DragPreviewContext,
8
9
  } from "./timelineClipDragPreview";
@@ -293,6 +294,52 @@ describe("computeDragPreview — a clip landing on an empty main track keeps its
293
294
  });
294
295
  });
295
296
 
297
+ describe("guideIfSaved — a guide only where the clip saves", () => {
298
+ const grid = { time: 3.25, type: "grid" as const };
299
+
300
+ it("drops the guide when start and duration round apart from the target", () => {
301
+ // 1440 px/s: 1.125s + 2.125s saves as 1.13s + 2.13s, 14px past the 3.25s guide.
302
+ const el = clip("a", 0, 1.125, 2, 0, "div");
303
+ expect(guideIfSaved(el, { start: 1.125, duration: 2.125 }, grid, 1440)).toBeNull();
304
+ });
305
+
306
+ it("drops the guide when a nested clip's local start rounds off it", () => {
307
+ const el = { ...clip("a", 0, 2, 1, 0, "div"), parentCompositionStart: 1 / 30 };
308
+ const target = { time: 2.1, type: "playhead" as const };
309
+ expect(guideIfSaved(el, { start: 2.1, duration: 1 }, target, 1440)).toBeNull();
310
+ });
311
+
312
+ it("keeps the guide when the saved edge is on it", () => {
313
+ const el = clip("a", 0, 1, 2, 0, "div");
314
+ expect(guideIfSaved(el, { start: 1, duration: 2.25 }, grid, 1440)).toBe(grid);
315
+ });
316
+
317
+ it("judges a move by the duration it keeps, not a rounded one", () => {
318
+ // A 31/30 s clip moved so its end meets 3.25s saves its start at 2.22s and ends 4.8px past.
319
+ const el = clip("a", 0, 1, 31 / 30, 0, "div");
320
+ const edge = { time: 3.25, type: "clip-edge" as const };
321
+ expect(guideIfSaved(el, { start: 2.217 }, edge, 1440)).toBeNull();
322
+ });
323
+
324
+ it("draws no guide for a move whose saved start misses the target", () => {
325
+ const nested = { ...moodboard, parentCompositionStart: 1 / 30 };
326
+ const { drag } = horizontalDrag(nested, 0.5, 0);
327
+ const context = {
328
+ ...ctx(),
329
+ pps: 1440,
330
+ buildSnapTargets: () => [{ time: 21, type: "playhead" as const }],
331
+ };
332
+ const next = computeDragPreview(
333
+ drag,
334
+ drag.originClientX + 2 * 1440,
335
+ drag.originClientY,
336
+ context,
337
+ );
338
+ expect(next.previewStart).toBe(21);
339
+ expect(next.snapTime).toBeNull();
340
+ });
341
+ });
342
+
296
343
  describe("computeResizePreview — composition source continuity", () => {
297
344
  it("seeds a legacy composition offset and advances it at playback rate", () => {
298
345
  const element = {
@@ -320,6 +367,27 @@ describe("computeResizePreview — composition source continuity", () => {
320
367
  });
321
368
  });
322
369
 
370
+ it("draws no guide when the trimmed start cannot save onto the target", () => {
371
+ // 1440 px/s: a playhead on frame 31 (1.033s) saves a start of 1.03s, 4px off the guide.
372
+ const result = computeResizePreview(
373
+ {
374
+ element: clip("a", 0, 1, 2, 0, "div"),
375
+ edge: "start",
376
+ originClientX: 0,
377
+ previewStart: 1,
378
+ previewDuration: 2,
379
+ started: true,
380
+ },
381
+ 48,
382
+ {
383
+ scroll: fakeScroll(),
384
+ pps: 1440,
385
+ buildSnapTargets: () => [{ time: 1.033, type: "playhead" }],
386
+ },
387
+ );
388
+ expect(result).toMatchObject({ previewStart: 1.03, snapTime: null, snapType: null });
389
+ });
390
+
323
391
  it("does not let a tail snap shrink a clip below the drag's minimum duration", () => {
324
392
  const result = computeResizePreview(
325
393
  {