@hyperframes/studio 0.6.48 → 0.6.49

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.
@@ -38,7 +38,7 @@ export interface UseDomEditTextCommitsParams {
38
38
  buildDomSelectionFromTarget: (
39
39
  target: HTMLElement,
40
40
  options?: { preferClipAncestor?: boolean },
41
- ) => DomEditSelection | null;
41
+ ) => Promise<DomEditSelection | null>;
42
42
  persistDomEditOperations: PersistDomEditOperations;
43
43
  resolveImportedFontAsset: (fontFamilyValue: string) => ImportedFontAsset | null;
44
44
  }
@@ -231,7 +231,7 @@ export function useDomEditTextCommits({
231
231
  if (doc) {
232
232
  const refreshed = findElementForSelection(doc, domEditSelection, activeCompPath);
233
233
  if (refreshed) {
234
- const nextSelection = buildDomSelectionFromTarget(refreshed);
234
+ const nextSelection = await buildDomSelectionFromTarget(refreshed);
235
235
  if (nextSelection) {
236
236
  applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
237
237
  }
@@ -287,7 +287,7 @@ export function useDomEditTextCommits({
287
287
  if (doc) {
288
288
  const refreshed = findElementForSelection(doc, selection, activeCompPath);
289
289
  if (refreshed) {
290
- const nextSelection = buildDomSelectionFromTarget(refreshed);
290
+ const nextSelection = await buildDomSelectionFromTarget(refreshed);
291
291
  if (nextSelection) {
292
292
  applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
293
293
  }
@@ -60,17 +60,19 @@ export interface UseDomSelectionReturn {
60
60
  buildDomSelectionFromTarget: (
61
61
  target: HTMLElement,
62
62
  options?: { preferClipAncestor?: boolean },
63
- ) => DomEditSelection | null;
63
+ ) => Promise<DomEditSelection | null>;
64
64
  resolveDomSelectionFromPreviewPoint: (
65
65
  clientX: number,
66
66
  clientY: number,
67
67
  options?: { preferClipAncestor?: boolean },
68
- ) => DomEditSelection | null;
68
+ ) => Promise<DomEditSelection | null>;
69
69
  updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
70
- buildDomSelectionForTimelineElement: (element: TimelineElement) => DomEditSelection | null;
71
- handleTimelineElementSelect: (element: TimelineElement | null) => void;
72
- refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => void;
73
- refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => void;
70
+ buildDomSelectionForTimelineElement: (
71
+ element: TimelineElement,
72
+ ) => Promise<DomEditSelection | null>;
73
+ handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
74
+ refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
75
+ refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
74
76
  }
75
77
 
76
78
  // ── Hook ──
@@ -193,24 +195,34 @@ export function useDomSelection({
193
195
  }, [applyDomSelection]);
194
196
 
195
197
  const buildDomSelectionFromTarget = useCallback(
196
- (target: HTMLElement, options?: { preferClipAncestor?: boolean }) => {
198
+ (
199
+ target: HTMLElement,
200
+ options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
201
+ ) => {
197
202
  return resolveDomEditSelection(target, {
198
203
  activeCompositionPath: activeCompPath,
199
204
  isMasterView,
200
205
  preferClipAncestor: options?.preferClipAncestor,
206
+ skipSourceProbe: options?.skipSourceProbe,
207
+ projectId,
201
208
  });
202
209
  },
203
- [activeCompPath, isMasterView],
210
+ [activeCompPath, isMasterView, projectId],
204
211
  );
205
212
 
206
213
  const resolveDomSelectionFromPreviewPoint = useCallback(
207
- (clientX: number, clientY: number, options?: { preferClipAncestor?: boolean }) => {
214
+ async (
215
+ clientX: number,
216
+ clientY: number,
217
+ options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
218
+ ) => {
208
219
  const iframe = previewIframeRef.current;
209
220
  if (!iframe || captionEditMode) return null;
210
221
  const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
211
222
  if (!target) return null;
212
223
  return buildDomSelectionFromTarget(target, {
213
224
  preferClipAncestor: options?.preferClipAncestor,
225
+ skipSourceProbe: options?.skipSourceProbe,
214
226
  });
215
227
  },
216
228
  [activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
@@ -223,7 +235,7 @@ export function useDomSelection({
223
235
  }, []);
224
236
 
225
237
  const buildDomSelectionForTimelineElement = useCallback(
226
- (element: TimelineElement): DomEditSelection | null => {
238
+ async (element: TimelineElement): Promise<DomEditSelection | null> => {
227
239
  const iframe = previewIframeRef.current;
228
240
  let doc: Document | null = null;
229
241
  try {
@@ -248,21 +260,21 @@ export function useDomSelection({
248
260
  );
249
261
 
250
262
  const handleTimelineElementSelect = useCallback(
251
- (element: TimelineElement | null) => {
263
+ async (element: TimelineElement | null) => {
252
264
  if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
253
265
  if (!element) {
254
266
  applyDomSelection(null, { revealPanel: false });
255
267
  return;
256
268
  }
257
269
 
258
- const selection = buildDomSelectionForTimelineElement(element);
270
+ const selection = await buildDomSelectionForTimelineElement(element);
259
271
  if (selection) applyDomSelection(selection);
260
272
  },
261
273
  [applyDomSelection, buildDomSelectionForTimelineElement],
262
274
  );
263
275
 
264
276
  const refreshDomEditSelectionFromPreview = useCallback(
265
- (selection: DomEditSelection) => {
277
+ async (selection: DomEditSelection) => {
266
278
  const iframe = previewIframeRef.current;
267
279
  let doc: Document | null = null;
268
280
  try {
@@ -275,7 +287,7 @@ export function useDomSelection({
275
287
  const element = findElementForSelection(doc, selection, activeCompPath);
276
288
  if (!element) return;
277
289
 
278
- const nextSelection = buildDomSelectionFromTarget(element);
290
+ const nextSelection = await buildDomSelectionFromTarget(element);
279
291
  if (nextSelection) {
280
292
  applyDomSelection(nextSelection, {
281
293
  revealPanel: false,
@@ -287,7 +299,7 @@ export function useDomSelection({
287
299
  );
288
300
 
289
301
  const refreshDomEditGroupSelectionsFromPreview = useCallback(
290
- (selections: DomEditSelection[]) => {
302
+ async (selections: DomEditSelection[]) => {
291
303
  const iframe = previewIframeRef.current;
292
304
  let doc: Document | null = null;
293
305
  try {
@@ -301,7 +313,7 @@ export function useDomSelection({
301
313
  for (const selection of selections) {
302
314
  const element = findElementForSelection(doc, selection, activeCompPath);
303
315
  if (!element) continue;
304
- const nextSelection = buildDomSelectionFromTarget(element);
316
+ const nextSelection = await buildDomSelectionFromTarget(element);
305
317
  if (nextSelection) nextGroup.push(nextSelection);
306
318
  }
307
319
  if (nextGroup.length === 0) return;
@@ -20,8 +20,8 @@ export interface UsePreviewInteractionParams {
20
20
  resolveDomSelectionFromPreviewPoint: (
21
21
  clientX: number,
22
22
  clientY: number,
23
- options?: { preferClipAncestor?: boolean },
24
- ) => DomEditSelection | null;
23
+ options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
24
+ ) => Promise<DomEditSelection | null>;
25
25
  updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
26
26
 
27
27
  onClickToSource?: (selection: DomEditSelection) => void;
@@ -40,9 +40,9 @@ export function usePreviewInteraction({
40
40
  onClickToSource,
41
41
  }: UsePreviewInteractionParams) {
42
42
  const handlePreviewCanvasMouseDown = useCallback(
43
- (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
43
+ async (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
44
44
  if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) return;
45
- const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
45
+ const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
46
46
  preferClipAncestor: options?.preferClipAncestor ?? false,
47
47
  });
48
48
  if (!nextSelection) {
@@ -66,14 +66,15 @@ export function usePreviewInteraction({
66
66
  );
67
67
 
68
68
  const handlePreviewCanvasPointerMove = useCallback(
69
- (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
69
+ async (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
70
70
  if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
71
71
  updateDomEditHoverSelection(null);
72
72
  return null;
73
73
  }
74
74
 
75
- const nextSelection = resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
75
+ const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
76
76
  preferClipAncestor: options?.preferClipAncestor ?? false,
77
+ skipSourceProbe: true,
77
78
  });
78
79
  updateDomEditHoverSelection(nextSelection);
79
80
  return nextSelection;
@@ -25,7 +25,7 @@ interface UseStudioUrlStateParams {
25
25
  buildDomSelectionFromTarget: (
26
26
  target: HTMLElement,
27
27
  options?: { preferClipAncestor?: boolean },
28
- ) => DomEditSelection | null;
28
+ ) => Promise<DomEditSelection | null>;
29
29
  applyDomSelection: (
30
30
  selection: DomEditSelection | null,
31
31
  options?: {
@@ -140,10 +140,11 @@ export function useStudioUrlState({
140
140
  return;
141
141
  }
142
142
 
143
- const selection = buildDomSelectionFromTarget(element, { preferClipAncestor: false });
144
- applyDomSelection(selection, { revealPanel: false });
145
143
  hydratedSelectionRef.current = true;
146
144
  pendingSelectionRef.current = null;
145
+ void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((selection) => {
146
+ applyDomSelection(selection, { revealPanel: false });
147
+ });
147
148
  }, [
148
149
  activeCompPath,
149
150
  applyDomSelection,
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { wrapTimeline } from "./playbackAdapter";
3
- import type { TimelineLike } from "./playbackTypes";
2
+ import { createStaticSeekPlaybackAdapter, wrapTimeline } from "./playbackAdapter";
3
+ import type {
4
+ RuntimePlaybackAdapter,
5
+ StaticSeekPlaybackClock,
6
+ TimelineLike,
7
+ } from "./playbackTypes";
4
8
 
5
9
  describe("wrapTimeline seek keepPlaying option (#834)", () => {
6
10
  function mockTimeline(): TimelineLike & {
@@ -48,3 +52,162 @@ describe("wrapTimeline seek keepPlaying option (#834)", () => {
48
52
  expect(tl.seek).toHaveBeenCalledWith(5);
49
53
  });
50
54
  });
55
+
56
+ describe("createStaticSeekPlaybackAdapter seek keepPlaying option", () => {
57
+ type StaticSeekPlayer = Pick<RuntimePlaybackAdapter, "getTime"> &
58
+ Partial<Pick<RuntimePlaybackAdapter, "renderSeek" | "seek">>;
59
+
60
+ function makeFakeClock(): StaticSeekPlaybackClock & {
61
+ runNextFrame: () => boolean;
62
+ cancelled: number[];
63
+ scheduled: number;
64
+ setNow: (ms: number) => void;
65
+ } {
66
+ let now = 0;
67
+ let nextHandle = 0;
68
+ const pending = new Map<number, FrameRequestCallback>();
69
+ const cancelled: number[] = [];
70
+ let scheduled = 0;
71
+ return {
72
+ now: () => now,
73
+ requestAnimationFrame: (cb) => {
74
+ nextHandle += 1;
75
+ pending.set(nextHandle, cb);
76
+ scheduled += 1;
77
+ return nextHandle;
78
+ },
79
+ cancelAnimationFrame: (handle) => {
80
+ if (pending.delete(handle)) cancelled.push(handle);
81
+ },
82
+ runNextFrame: () => {
83
+ const next = pending.entries().next();
84
+ if (next.done) return false;
85
+ const [handle, cb] = next.value;
86
+ pending.delete(handle);
87
+ cb(now);
88
+ return true;
89
+ },
90
+ cancelled,
91
+ get scheduled() {
92
+ return scheduled;
93
+ },
94
+ setNow: (ms) => {
95
+ now = ms;
96
+ },
97
+ };
98
+ }
99
+
100
+ function makePlayer(): StaticSeekPlayer & {
101
+ renderSeek: ReturnType<typeof vi.fn>;
102
+ } {
103
+ return {
104
+ getTime: () => 0,
105
+ renderSeek: vi.fn(),
106
+ };
107
+ }
108
+
109
+ it("default seek stops the RAF ticker so the adapter reports paused", () => {
110
+ const clock = makeFakeClock();
111
+ const player = makePlayer();
112
+ const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
113
+
114
+ adapter.play();
115
+ expect(adapter.isPlaying()).toBe(true);
116
+
117
+ adapter.seek(5);
118
+
119
+ expect(adapter.isPlaying()).toBe(false);
120
+ expect(adapter.getTime()).toBe(5);
121
+ expect(player.renderSeek).toHaveBeenLastCalledWith(5);
122
+ expect(clock.cancelled.length).toBeGreaterThan(0);
123
+ });
124
+
125
+ it("default seek prevents the ticker from advancing further", () => {
126
+ const clock = makeFakeClock();
127
+ const player = makePlayer();
128
+ const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
129
+
130
+ adapter.play();
131
+ player.renderSeek.mockClear();
132
+
133
+ adapter.seek(5);
134
+
135
+ // Any frame the RAF callback already had queued before cancel should be a no-op.
136
+ clock.setNow(1000);
137
+ clock.runNextFrame();
138
+ expect(player.renderSeek).toHaveBeenCalledTimes(1); // only the seek itself
139
+ expect(player.renderSeek).toHaveBeenLastCalledWith(5);
140
+ expect(adapter.getTime()).toBe(5);
141
+ });
142
+
143
+ it("seek with { keepPlaying: true } preserves playback and rebases the ticker", () => {
144
+ const clock = makeFakeClock();
145
+ const player = makePlayer();
146
+ const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
147
+
148
+ adapter.play();
149
+ clock.setNow(500);
150
+ expect(adapter.isPlaying()).toBe(true);
151
+
152
+ adapter.seek(3, { keepPlaying: true });
153
+
154
+ expect(adapter.isPlaying()).toBe(true);
155
+ expect(adapter.getTime()).toBe(3);
156
+
157
+ // Advance 1s of wall-clock time. With playStartTime rebased to 3 and
158
+ // playStartNow rebased to 500, the next tick should render around t=4.
159
+ clock.setNow(1500);
160
+ clock.runNextFrame();
161
+ expect(player.renderSeek).toHaveBeenLastCalledWith(4);
162
+ });
163
+
164
+ it("seek with { keepPlaying: false } pauses (matches default)", () => {
165
+ const clock = makeFakeClock();
166
+ const player = makePlayer();
167
+ const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
168
+
169
+ adapter.play();
170
+ adapter.seek(5, { keepPlaying: false });
171
+
172
+ expect(adapter.isPlaying()).toBe(false);
173
+ expect(player.renderSeek).toHaveBeenLastCalledWith(5);
174
+ });
175
+
176
+ it("seek with { keepPlaying: true } does not force playback when adapter is paused", () => {
177
+ const clock = makeFakeClock();
178
+ const player = makePlayer();
179
+ const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
180
+
181
+ adapter.seek(2, { keepPlaying: true });
182
+
183
+ expect(adapter.isPlaying()).toBe(false);
184
+ expect(adapter.getTime()).toBe(2);
185
+ expect(player.renderSeek).toHaveBeenLastCalledWith(2);
186
+ });
187
+
188
+ it("seek without options stays back-compatible with the previous signature", () => {
189
+ const clock = makeFakeClock();
190
+ const player = makePlayer();
191
+ const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
192
+
193
+ // Caller written before the options parameter existed.
194
+ adapter.seek(4);
195
+
196
+ expect(player.renderSeek).toHaveBeenLastCalledWith(4);
197
+ expect(adapter.getTime()).toBe(4);
198
+ expect(adapter.isPlaying()).toBe(false);
199
+ });
200
+
201
+ it("default seek clamps to duration and still pauses", () => {
202
+ const clock = makeFakeClock();
203
+ const player = makePlayer();
204
+ const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
205
+
206
+ adapter.play();
207
+ adapter.seek(99);
208
+
209
+ expect(adapter.getTime()).toBe(10);
210
+ expect(player.renderSeek).toHaveBeenLastCalledWith(10);
211
+ expect(adapter.isPlaying()).toBe(false);
212
+ });
213
+ });
@@ -113,12 +113,20 @@ export function createStaticSeekPlaybackAdapter(
113
113
  playing = false;
114
114
  stopTicker();
115
115
  },
116
- seek: (time) => {
116
+ seek: (time, options) => {
117
117
  renderSeek(time);
118
- if (playing) {
119
- playStartTime = currentTime;
120
- playStartNow = clock.now();
118
+ if (options?.keepPlaying) {
119
+ if (playing) {
120
+ playStartTime = currentTime;
121
+ playStartNow = clock.now();
122
+ }
123
+ return;
121
124
  }
125
+ // Default seek aligns with wrapTimeline: stop the RAF ticker so the
126
+ // adapter's `playing` flag matches the public seek contract instead of
127
+ // silently driving renderSeek in the background.
128
+ playing = false;
129
+ stopTicker();
122
130
  },
123
131
  getTime: () => currentTime,
124
132
  getDuration: () => safeDuration,
@@ -53,7 +53,7 @@ function renderStudioUrlStateHarness(
53
53
  timelineVisible: true,
54
54
  activeCompPathHydrated: true,
55
55
  domEditSelection: null,
56
- buildDomSelectionFromTarget: () => null,
56
+ buildDomSelectionFromTarget: () => Promise.resolve(null),
57
57
  applyDomSelection: () => {},
58
58
  initialState: {
59
59
  activeCompPath: null,
@@ -162,7 +162,7 @@ describe("studio url state", () => {
162
162
  expect(normalizeStudioUrlPanelTab("motion", { motionPanelEnabled: false })).toBe("design");
163
163
  });
164
164
 
165
- it("hydrates seek first, preserves the initial url state, then restores selection", () => {
165
+ it("hydrates seek first, preserves the initial url state, then restores selection", async () => {
166
166
  vi.useFakeTimers();
167
167
  window.history.replaceState(null, "", "#project/demo?t=4.2&tab=design&selId=hero");
168
168
  const requestSeek = vi.fn();
@@ -209,7 +209,7 @@ describe("studio url state", () => {
209
209
  rightPanelTab: "design",
210
210
  rightCollapsed: false,
211
211
  applyDomSelection,
212
- buildDomSelectionFromTarget: () => restoredSelection,
212
+ buildDomSelectionFromTarget: () => Promise.resolve(restoredSelection),
213
213
  initialState: {
214
214
  activeCompPath: null,
215
215
  currentTime: 4.2,
@@ -232,8 +232,10 @@ describe("studio url state", () => {
232
232
  expect(applyDomSelection).not.toHaveBeenCalled();
233
233
 
234
234
  harness.rerender({ currentTime: 4.2 });
235
- act(() => {
235
+ await act(async () => {
236
236
  vi.advanceTimersByTime(250);
237
+ // Flush microtasks so the async buildDomSelectionFromTarget Promise resolves
238
+ await Promise.resolve();
237
239
  });
238
240
  expect(applyDomSelection).toHaveBeenCalledWith(restoredSelection, { revealPanel: false });
239
241