@hyperframes/studio 0.8.138 → 0.8.140

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 (135) hide show
  1. package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-BLuyxnK7.js} +1 -1
  2. package/dist/assets/{index-DXVvWfQG.js → index-CWfuDCoA.js} +1 -1
  3. package/dist/assets/{index-DIJuIBwI.js → index-Dr3Bo9uH.js} +1 -1
  4. package/dist/assets/{index-BdjA0CLD.js → index-VAHDM_zL.js} +210 -210
  5. package/dist/{chunk-4S3AYCOG.js → chunk-H2DUOXTF.js} +3 -2
  6. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  7. package/dist/chunk-TFLU2MGI.js.map +1 -0
  8. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  9. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  10. package/dist/index.d.ts +20 -3
  11. package/dist/index.html +1 -1
  12. package/dist/index.js +7327 -6265
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/package.json +8 -8
  16. package/src/App.tsx +9 -4
  17. package/src/components/TimelineToolbar.test.tsx +79 -1
  18. package/src/components/TimelineToolbar.tsx +14 -29
  19. package/src/components/editor/DomEditOverlay.tsx +11 -7
  20. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  21. package/src/components/editor/PropertyPanel.tsx +1 -0
  22. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  23. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
  24. package/src/components/editor/domEditOverlayGeometry.ts +7 -7
  25. package/src/components/editor/domEditOverlayGestures.ts +18 -0
  26. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
  27. package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
  28. package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
  29. package/src/components/editor/dragUndoPaint.test.ts +2 -0
  30. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
  31. package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
  32. package/src/components/editor/manualEdits.ts +1 -1
  33. package/src/components/editor/manualEditsDom.ts +7 -7
  34. package/src/components/editor/manualEditsParsing.ts +14 -2
  35. package/src/components/editor/manualEditsTypes.ts +5 -0
  36. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  37. package/src/components/editor/manualOffsetDrag.ts +5 -0
  38. package/src/components/editor/previewGestureStarts.ts +284 -0
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  40. package/src/components/editor/propertyPanelFill.tsx +36 -13
  41. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  42. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  43. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  44. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  45. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  46. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  47. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  48. package/src/components/nle/NLEContext.test.ts +26 -0
  49. package/src/components/nle/NLEContext.tsx +8 -3
  50. package/src/components/nle/NLEPreview.test.ts +44 -1
  51. package/src/components/nle/NLEPreview.tsx +7 -2
  52. package/src/hooks/draggedGsapPosition.ts +8 -0
  53. package/src/hooks/editMoment.ts +13 -0
  54. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  55. package/src/hooks/gsapDragCommit.ts +2 -1
  56. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  57. package/src/hooks/gsapPositionDetection.ts +5 -3
  58. package/src/hooks/gsapResizeIntercept.ts +11 -4
  59. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  60. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  61. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  62. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  63. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  64. package/src/hooks/timelineEditingHelpers.ts +18 -2
  65. package/src/hooks/useDomSelection.test.ts +21 -2
  66. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  67. package/src/hooks/useEditHistoryActions.ts +2 -0
  68. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  69. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  70. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  71. package/src/hooks/useGsapAwareEditing.ts +148 -136
  72. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  73. package/src/hooks/useGsapScriptCommits.ts +5 -4
  74. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  75. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  76. package/src/hooks/usePreviewPersistence.ts +1 -4
  77. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  78. package/src/hooks/useSdkSession.ts +4 -1
  79. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  80. package/src/hooks/useThumbnailStripSize.ts +23 -1
  81. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  82. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  83. package/src/index.ts +2 -0
  84. package/src/player/components/AudioWaveform.test.tsx +153 -1
  85. package/src/player/components/AudioWaveform.tsx +93 -37
  86. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  87. package/src/player/components/Timeline.test.ts +3 -1
  88. package/src/player/components/TimelineCanvas.tsx +4 -0
  89. package/src/player/components/TimelineClip.tsx +20 -6
  90. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  91. package/src/player/components/TimelineClipFades.tsx +4 -4
  92. package/src/player/components/TimelineClipJoins.tsx +23 -18
  93. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  94. package/src/player/components/TimelineGroupRow.tsx +1 -0
  95. package/src/player/components/TimelineLaneClip.tsx +127 -0
  96. package/src/player/components/TimelineLanes.test.tsx +51 -6
  97. package/src/player/components/TimelineLanes.tsx +166 -178
  98. package/src/player/components/TimelineRuler.tsx +2 -0
  99. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  100. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  101. package/src/player/components/VideoThumbnail.tsx +37 -9
  102. package/src/player/components/thumbnailUtils.test.ts +17 -3
  103. package/src/player/components/thumbnailUtils.ts +17 -1
  104. package/src/player/components/timelineLayout.test.ts +3 -3
  105. package/src/player/components/timelineMotion.test.ts +27 -0
  106. package/src/player/components/timelineMotion.ts +62 -0
  107. package/src/player/components/timelineZoomInput.test.ts +438 -0
  108. package/src/player/components/timelineZoomInput.ts +456 -0
  109. package/src/player/components/useTimelineGeometry.ts +0 -3
  110. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  111. package/src/player/components/useTimelinePlayhead.ts +53 -59
  112. package/src/player/components/useTimelineProviderState.tsx +16 -21
  113. package/src/player/components/useTimelineZoom.ts +7 -12
  114. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  115. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  116. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  117. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  118. package/src/player/lib/thumbnailScheduler.ts +1 -1
  119. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  120. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  121. package/src/player/lib/timelineElementHelpers.ts +0 -11
  122. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  123. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  124. package/src/player/previewReloading.test.ts +48 -0
  125. package/src/player/previewReloading.ts +48 -0
  126. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  127. package/src/utils/rootDuration.ts +5 -4
  128. package/src/utils/studioPendingEdits.test.ts +27 -0
  129. package/src/utils/studioPendingEdits.ts +9 -0
  130. package/src/utils/timelineAssetDrop.test.ts +13 -4
  131. package/src/utils/timelineAssetDrop.ts +6 -2
  132. package/dist/chunk-TDWGXBUT.js.map +0 -1
  133. /package/dist/{chunk-4S3AYCOG.js.map → chunk-H2DUOXTF.js.map} +0 -0
  134. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  135. /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
package/dist/player.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-4S3AYCOG.js";
3
+ } from "./chunk-H2DUOXTF.js";
4
4
  import "./chunk-LOVBLSMR.js";
5
- import "./chunk-TDWGXBUT.js";
5
+ import "./chunk-TFLU2MGI.js";
6
6
  import "./chunk-PZ5AY32C.js";
7
7
  export {
8
8
  Player
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.138",
3
+ "version": "0.8.140",
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/parsers": "0.8.138",
64
- "@hyperframes/core": "0.8.138",
65
- "@hyperframes/player": "0.8.138",
66
- "@hyperframes/sdk": "0.8.138",
67
- "@hyperframes/shader-transitions": "0.8.138",
68
- "@hyperframes/studio-server": "0.8.138"
63
+ "@hyperframes/core": "0.8.140",
64
+ "@hyperframes/parsers": "0.8.140",
65
+ "@hyperframes/sdk": "0.8.140",
66
+ "@hyperframes/player": "0.8.140",
67
+ "@hyperframes/studio-server": "0.8.140",
68
+ "@hyperframes/shader-transitions": "0.8.140"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -82,7 +82,7 @@
82
82
  "vite": "^6.4.2",
83
83
  "vitest": "^4.1.11",
84
84
  "zustand": "^5.0.0",
85
- "@hyperframes/producer": "0.8.138"
85
+ "@hyperframes/producer": "0.8.140"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "react": "19",
package/src/App.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useOwnPreviewIframe, usePreviewIframeStore } from "./player/store/previewIframeStore";
2
2
  import { buildProjectApiPath } from "./utils/projectRouting";
3
- import { useState, useCallback, useRef, useMemo } from "react";
3
+ import { useState, useCallback, useRef, useMemo, type SetStateAction } from "react";
4
4
  import { useStableHandlers } from "./hooks/useStableHandlers";
5
5
  import { useHistoryFlags, useToolbarSession } from "./hooks/useShellSlices";
6
6
  import { useDismissingTabSetter, useRightPanelIntent } from "./hooks/useRightPanelIntents";
@@ -62,6 +62,7 @@ import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
62
62
  import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
63
63
  import { readStudioUrlStateFromWindow, resolveMasterCompositionPath } from "./utils/studioUrlState";
64
64
  import { useActiveComposition } from "./hooks/useActiveComposition";
65
+ import { requestPreviewReload } from "./player/previewReloading";
65
66
  const getTimelineSelectionSet = () => usePlayerStore.getState().selectedElementIds;
66
67
 
67
68
  export interface StudioAppProps {
@@ -79,7 +80,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
79
80
  const [compIdToSrc, setCompIdToSrc] = useState<Map<string, string>>(new Map());
80
81
  const previewIframe = useOwnPreviewIframe();
81
82
  const [compositionLoading, setCompositionLoading] = useState(true);
82
- const [refreshKey, setRefreshKey] = useState(0);
83
+ const [refreshKey, setRefreshKeyState] = useState(0);
83
84
  const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
84
85
  const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
85
86
  const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -104,7 +105,11 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
104
105
  const handleDomZIndexReorderCommitRef = useRef<TimelineZIndexReorderCommit | null>(null);
105
106
  const pendingTimelineEditPathRef = useRef(new Set<string>());
106
107
  const isGestureRecordingRef = useRef(false);
107
- const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), []);
108
+ const setRefreshKey = useCallback((next: SetStateAction<number>) => {
109
+ requestPreviewReload();
110
+ setRefreshKeyState(next);
111
+ }, []);
112
+ const reloadPreview = useCallback(() => setRefreshKey((k) => k + 1), [setRefreshKey]);
108
113
  const fileManagerResult = useFileManager({
109
114
  projectId,
110
115
  showToast,
@@ -144,7 +149,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
144
149
  recordEdit: editHistory.recordEdit,
145
150
  previewIframeRef,
146
151
  activeCompPathRef,
147
- reloadPreview: () => setRefreshKey((k) => k + 1),
152
+ reloadPreview,
148
153
  });
149
154
  const previewPersistence = useStableHandlers(previewPersistenceResult, projectId);
150
155
  const externalFileChanges = useStudioExternalFileChanges({
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
8
  import { makeSelection } from "../hooks/domSelectionTestHarness";
@@ -11,6 +11,7 @@ import { readStudioUiPreferences } from "../utils/studioUiPreferences";
11
11
  import { dispatchPlainKey, type HotkeyCallbacks } from "../hooks/appHotkeysDispatch";
12
12
  import { AudioMeterStrip } from "./nle/AudioMeterStrip";
13
13
  import { TimelineToolbar } from "./TimelineToolbar";
14
+ import { registerTimelineZoomViewport } from "../player/components/timelineZoomInput";
14
15
 
15
16
  vi.mock("../contexts/StudioContext", () => ({
16
17
  useStudioShellContextOptional: () => ({
@@ -239,6 +240,81 @@ describe("TimelineToolbar snap key", () => {
239
240
  });
240
241
 
241
242
  describe("TimelineToolbar Fit", () => {
243
+ // A zoom lands on the next frame; these tests step frames instead of waiting for them.
244
+ beforeEach(() => {
245
+ vi.useFakeTimers({
246
+ toFake: [
247
+ "requestAnimationFrame",
248
+ "cancelAnimationFrame",
249
+ "setTimeout",
250
+ "clearTimeout",
251
+ "performance",
252
+ ],
253
+ });
254
+ });
255
+ afterEach(() => {
256
+ vi.useRealTimers();
257
+ });
258
+
259
+ /** A mounted timeline for the zoom to preview on: 1080px wide, 32px of headers. */
260
+ function mountTimelineViewport() {
261
+ const scroll = document.createElement("div");
262
+ Object.defineProperties(scroll, {
263
+ clientWidth: { value: 1080 },
264
+ // At Fit the content is as wide as the view.
265
+ scrollWidth: { value: 1080 },
266
+ scrollLeft: { value: 0, writable: true },
267
+ });
268
+ usePlayerStore.setState({
269
+ zoomMode: "fit",
270
+ manualZoomPercent: 100,
271
+ timelineFitPps: 10,
272
+ timelinePps: 10,
273
+ });
274
+ return registerTimelineZoomViewport({ scroll, contentOrigin: 32 });
275
+ }
276
+
277
+ it("moves the slider and its readout with a zoom while it is previewed", () => {
278
+ const unregister = mountTimelineViewport();
279
+ const { host, root } = renderToolbar();
280
+ const slider = host.querySelector<HTMLInputElement>('input[aria-label="Timeline zoom"]')!;
281
+ const before = slider.value;
282
+ act(() => {
283
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(
284
+ slider,
285
+ String(Number(before) + 5),
286
+ );
287
+ slider.dispatchEvent(new Event("input", { bubbles: true }));
288
+ });
289
+ act(() => vi.advanceTimersToNextFrame());
290
+ // Not laid out yet: the store still holds Fit, but the toolbar shows the zoom on screen.
291
+ expect(usePlayerStore.getState().zoomMode).toBe("fit");
292
+ expect(slider.value).not.toBe(before);
293
+ expect(host.querySelector('[aria-label="Timeline zoom level"]')?.textContent).not.toBe("Fit");
294
+ act(() => root.unmount());
295
+ act(() => unregister());
296
+ });
297
+
298
+ it("keeps Fit when it is picked while a zoom waits to be laid out", () => {
299
+ const unregister = mountTimelineViewport();
300
+ const { host, root } = renderToolbar();
301
+ act(() =>
302
+ host
303
+ .querySelector('button[aria-label="Zoom in"]')
304
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
305
+ );
306
+
307
+ act(() =>
308
+ host
309
+ .querySelector('button[aria-label="Fit timeline to width"]')
310
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
311
+ );
312
+ act(() => vi.advanceTimersByTime(1000));
313
+ expect(usePlayerStore.getState().zoomMode).toBe("fit");
314
+ act(() => root.unmount());
315
+ act(() => unregister());
316
+ });
317
+
242
318
  it("shows Fit as a named icon and says whether fit is on", () => {
243
319
  const { host, root } = renderToolbar();
244
320
  const fit = () => host.querySelector('button[aria-label="Fit timeline to width"]');
@@ -251,6 +327,7 @@ describe("TimelineToolbar Fit", () => {
251
327
  .querySelector('button[aria-label="Zoom in"]')
252
328
  ?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
253
329
  );
330
+ act(() => vi.advanceTimersToNextFrame());
254
331
  expect(fit()?.getAttribute("aria-pressed")).toBe("false");
255
332
  act(() => root.unmount());
256
333
  });
@@ -263,6 +340,7 @@ describe("TimelineToolbar Fit", () => {
263
340
  .querySelector('button[aria-label="Zoom in"]')
264
341
  ?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
265
342
  );
343
+ act(() => vi.advanceTimersToNextFrame());
266
344
  expect(usePlayerStore.getState().userZoomCount).toBe(before + 1);
267
345
  act(() => root.unmount());
268
346
  });
@@ -14,12 +14,14 @@ import {
14
14
  import { computeElementPercentage, playsNear } from "../hooks/gsapShared";
15
15
  import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
16
16
  import {
17
- getNextTimelineZoomPercent,
18
- getTimelineZoomPercent,
19
17
  timelineZoomPercentToSlider,
20
18
  timelineSliderToZoomPercent,
21
19
  } from "../player/components/timelineZoom";
22
20
  import { useTimelineZoom } from "../player/components/useTimelineZoom";
21
+ import {
22
+ useShownTimelineZoomPercent,
23
+ zoomTimelineStep,
24
+ } from "../player/components/timelineZoomInput";
23
25
  import { usePlayerStore, type TimelineElement } from "../player";
24
26
  import { Tooltip } from "./ui";
25
27
  import { AudioMetersIcon } from "./icons/AudioMetersIcon";
@@ -170,17 +172,13 @@ export const TimelineToolbar = memo(function TimelineToolbar({
170
172
  // Subscribe so the add-beat button reacts to playhead movement and analysis load.
171
173
  const currentTime = usePlayerStore((s) => s.currentTime);
172
174
  const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
173
- // Subscribe (not getState) so the split button enables/disables the moment
174
- // the selection changes, not only on the next playhead tick.
175
+ // Subscribed, so the split button follows the selection, not only playhead ticks.
175
176
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
176
177
  const elements = usePlayerStore((s) => s.elements);
177
178
  const timelineFitPps = usePlayerStore((s) => s.timelineFitPps);
178
- const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
179
- const displayedTimelineZoomPercent = getTimelineZoomPercent(
180
- zoomMode,
181
- manualZoomPercent,
182
- timelineFitPps,
183
- );
179
+ const { zoomMode, setZoomMode, setManualZoomPercent } = useTimelineZoom();
180
+ const displayedTimelineZoomPercent = useShownTimelineZoomPercent();
181
+ const showsFit = zoomMode === "fit" && displayedTimelineZoomPercent === 100;
184
182
  const {
185
183
  state: keyframeState,
186
184
  isMotionPath: keyframeIsMotionPath,
@@ -215,8 +213,6 @@ export const TimelineToolbar = memo(function TimelineToolbar({
215
213
  }, []);
216
214
 
217
215
  return (
218
- // The "TIMELINE" label is dropped for CapCut-like density — the pane's
219
- // position (tracks right below) makes it self-evident.
220
216
  <div className="border-b border-neutral-800/60">
221
217
  <div className="flex items-center justify-between px-2 py-0.5">
222
218
  <div className="flex items-center gap-0.5">
@@ -510,12 +506,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
510
506
  <button
511
507
  type="button"
512
508
  aria-label="Zoom out"
513
- onClick={() => {
514
- setZoomMode("manual");
515
- setManualZoomPercent(
516
- getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent, timelineFitPps),
517
- );
518
- }}
509
+ onClick={() => zoomTimelineStep("out")}
519
510
  className={flatIdle}
520
511
  >
521
512
  <MagnifyingGlassMinus size={16} aria-hidden="true" />
@@ -528,12 +519,11 @@ export const TimelineToolbar = memo(function TimelineToolbar({
528
519
  value={timelineZoomPercentToSlider(displayedTimelineZoomPercent, timelineFitPps)}
529
520
  title={`${displayedTimelineZoomPercent}%`}
530
521
  aria-label="Timeline zoom"
531
- onChange={(e) => {
532
- setZoomMode("manual");
522
+ onChange={(e) =>
533
523
  setManualZoomPercent(
534
524
  timelineSliderToZoomPercent(Number(e.target.value), timelineFitPps),
535
- );
536
- }}
525
+ )
526
+ }
537
527
  // h-6 on the input is the 24x24 WCAG 2.2 (2.5.8) target: the visible
538
528
  // track stays 2px and the thumb 10px, only the pointer box grows.
539
529
  className="mx-1 h-6 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-text-0 [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
@@ -542,12 +532,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
542
532
  <button
543
533
  type="button"
544
534
  aria-label="Zoom in"
545
- onClick={() => {
546
- setZoomMode("manual");
547
- setManualZoomPercent(
548
- getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent, timelineFitPps),
549
- );
550
- }}
535
+ onClick={() => zoomTimelineStep("in")}
551
536
  className={flatIdle}
552
537
  >
553
538
  <MagnifyingGlassPlus size={16} aria-hidden="true" />
@@ -558,7 +543,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
558
543
  className="ml-1 w-[38px] text-right font-mono text-[11px] tabular-nums text-neutral-500 select-none"
559
544
  aria-label="Timeline zoom level"
560
545
  >
561
- {zoomMode === "fit" ? "Fit" : `${displayedTimelineZoomPercent}%`}
546
+ {showsFit ? "Fit" : `${displayedTimelineZoomPercent}%`}
562
547
  </span>
563
548
  </div>
564
549
  </div>
@@ -10,6 +10,7 @@ import { useZOrderCrossedFlash, ZOrderCrossedFlash } from "./useZOrderCrossedFla
10
10
  import { useCanvasContextMenuState } from "./useCanvasContextMenuState";
11
11
  import {
12
12
  type BlockedMoveState,
13
+ type WaitingPressState,
13
14
  type DomEditGroupPathOffsetCommit,
14
15
  type FocusableDomEditOverlay,
15
16
  type MoveCommitOptions,
@@ -60,7 +61,7 @@ export interface DomEditOverlayProps {
60
61
  hoverSelection: DomEditSelection | null;
61
62
  allowCanvasMovement?: boolean;
62
63
  allowBodyDrag?: boolean;
63
- /** "host": no hover, marquee, box re-select, or body drag if allowBodyDrag is false; Enter still opens text. */
64
+ /** "host": no marquee, box re-select, or body drag if allowBodyDrag is false; Enter still opens text. */
64
65
  canvasInput?: "overlay" | "host";
65
66
  onTextEditingChange?: (editing: boolean) => void;
66
67
  /** A click on a single selection's box, in either mode; the event may be the pointerup. */
@@ -166,6 +167,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
166
167
  const gestureRef = useRef<GestureState | null>(null);
167
168
  const groupGestureRef = useRef<GroupGestureState | null>(null);
168
169
  const blockedMoveRef = useRef<BlockedMoveState | null>(null);
170
+ const waitingPressRef = useRef<WaitingPressState | null>(null);
169
171
  const suppressNextBoxClickRef = useRef(false);
170
172
  const snapGuidesRef = useRef<SnapGuidesState | null>(null);
171
173
  const rafPausedRef = useRef(false);
@@ -273,6 +275,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
273
275
  });
274
276
 
275
277
  const gestures = createDomEditOverlayGestureHandlers({
278
+ activeCompositionPathRef,
276
279
  overlayRef,
277
280
  iframeRef,
278
281
  boxRef,
@@ -283,6 +286,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
283
286
  gestureRef,
284
287
  groupGestureRef,
285
288
  blockedMoveRef,
289
+ waitingPressRef,
286
290
  rafPausedRef,
287
291
  suppressNextBoxClickRef,
288
292
  setOverlayRect,
@@ -299,17 +303,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
299
303
  });
300
304
 
301
305
  useEffect(() => {
302
- if (readOnly) gestures.clearPointerState(selectionRef);
306
+ if (readOnly) gestures.clearPointerState(selectionRef, true);
303
307
  }, [gestures, readOnly, selectionRef]);
304
308
  // A gesture that loses its pointer, the window or the overlay is cancelled, so its mark goes too.
305
- const cancelGestureRef = useRef(() => {});
306
- cancelGestureRef.current = () => gestures.clearPointerState(selectionRef);
309
+ const cancelGestureRef = useRef((_dropQueued?: boolean) => {});
310
+ cancelGestureRef.current = (dropQueued) => gestures.clearPointerState(selectionRef, dropQueued);
307
311
  useMountEffect(() => {
308
312
  const cancel = () => cancelGestureRef.current();
309
313
  window.addEventListener("blur", cancel);
310
314
  return () => {
311
315
  window.removeEventListener("blur", cancel);
312
- cancel();
316
+ cancelGestureRef.current(true);
313
317
  };
314
318
  });
315
319
 
@@ -493,10 +497,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
493
497
  onPointerLeave={() => onCanvasPointerLeaveRef.current()}
494
498
  onPointerUp={marquee.onPointerUp}
495
499
  onPointerCancel={marquee.onPointerCancel}
496
- onLostPointerCapture={() => cancelGestureRef.current()}
500
+ onLostPointerCapture={gestures.onLostPointerCapture}
497
501
  onContextMenu={hostInput ? undefined : handleContextMenu}
498
502
  >
499
- {!hostInput && hoverSelection && hoverRect && compRect.width > 0 && (
503
+ {hoverSelection && hoverRect && compRect.width > 0 && (
500
504
  <div
501
505
  aria-hidden="true"
502
506
  data-dom-edit-hover-box="true"