@hyperframes/studio 0.8.53 → 0.8.54

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 (35) hide show
  1. package/dist/assets/{hyperframes-player-DKWM-Ons.js → hyperframes-player-Cy_AwUa8.js} +1 -1
  2. package/dist/assets/{index-UvW6BEwf.js → index-CTa-6Kso.js} +1 -1
  3. package/dist/assets/{index-HfP3sap2.js → index-DPUJoO8n.js} +191 -191
  4. package/dist/assets/{index-Y4OnpTEM.js → index-DkAzKuPZ.js} +1 -1
  5. package/dist/index.d.ts +440 -10
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1850 -1532
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/nle/TimelinePane.tsx +11 -1
  11. package/src/hooks/timelineAudioGroupCreate.ts +17 -1
  12. package/src/hooks/timelineAudioGroupVolume.ts +35 -2
  13. package/src/hooks/timelineEditPermission.ts +31 -0
  14. package/src/hooks/timelineElementFxAttribute.ts +34 -2
  15. package/src/hooks/useFileManager.ts +21 -123
  16. package/src/hooks/usePersistentEditHistory.ts +1 -1
  17. package/src/hooks/useProjectFileWriter.ts +150 -0
  18. package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
  19. package/src/hooks/useTimelineAssetDropOps.ts +40 -3
  20. package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
  21. package/src/hooks/useTimelineEditing.test.tsx +481 -4
  22. package/src/hooks/useTimelineEditing.ts +151 -27
  23. package/src/hooks/useTimelineEditingTypes.ts +3 -0
  24. package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
  25. package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
  26. package/src/hostMount.test.tsx +222 -0
  27. package/src/index.ts +40 -2
  28. package/src/player/components/Timeline.tsx +6 -6
  29. package/src/player/components/TimelineTypes.ts +3 -0
  30. package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
  31. package/src/player/components/usePublishRangeSelection.ts +42 -0
  32. package/src/player/index.ts +1 -0
  33. package/src/player/store/playerStore.ts +8 -51
  34. package/src/player/store/rangeSelectionSlice.ts +13 -0
  35. package/src/player/store/timelineResetState.ts +51 -0
@@ -1,7 +1,9 @@
1
1
  // fallow-ignore-file complexity
2
2
  import { useCallback, useRef } from "react";
3
3
  import type { TimelineElement } from "../player";
4
+ import { usePlayerStore } from "../player";
4
5
  import { useRazorSplit } from "./useRazorSplit";
6
+ import { selectSplittableElements } from "../utils/timelineElementSplit";
5
7
  import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
6
8
  import {
7
9
  applyTimelineStackingReorder,
@@ -23,6 +25,8 @@ import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
23
25
  import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume";
24
26
  import { useSetElementAttribute } from "./timelineElementFxAttribute";
25
27
  import { useTimelineDeleteOps } from "./useTimelineDeleteOps";
28
+ import { useTimelineRowElements } from "../player/hooks/useTimelineRowElements";
29
+ import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
26
30
  import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate";
27
31
  import {
28
32
  useTimelineElementVisibilityEditing,
@@ -30,11 +34,22 @@ import {
30
34
  } from "./timelineTrackVisibility";
31
35
  import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
32
36
  import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast";
37
+ import { useTimelineEditGate } from "./timelineEditPermission";
33
38
  import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
34
39
  import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
35
40
  import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes";
36
41
  import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
37
42
 
43
+ type GuardedTimelineHandler = (...args: never[]) => Promise<void>;
44
+ type GuardedTimelineResolver = (...args: never[]) => readonly TimelineElement[];
45
+ type GuardedTimelineRefusal = (...args: never[]) => void;
46
+
47
+ interface GuardedTimelineEntry {
48
+ resolveTargets: GuardedTimelineResolver;
49
+ onRefused?: GuardedTimelineRefusal;
50
+ wrapped: GuardedTimelineHandler;
51
+ }
52
+
38
53
  export function useTimelineEditing({
39
54
  projectId,
40
55
  activeCompPath,
@@ -53,10 +68,51 @@ export function useTimelineEditing({
53
68
  forceReloadSdkSession,
54
69
  invalidateGsapCache,
55
70
  handleDomZIndexReorderCommitRef,
71
+ canEdit,
56
72
  }: UseTimelineEditingOptions) {
57
73
  const projectIdRef = useRef(projectId);
58
74
  projectIdRef.current = projectId;
59
75
  const editQueueRef = useRef(Promise.resolve());
76
+ const track = useTrackPendingTimelineEdit();
77
+ const checkEditable = useTimelineEditGate(canEdit, showToast);
78
+ const checkEditableRef = useRef(checkEditable);
79
+ checkEditableRef.current = checkEditable;
80
+ // Same expanded-row source the hide handlers themselves resolve against
81
+ // (timelineTrackVisibility.ts) — a virtual sub-comp child's track/key
82
+ // only exists here, not in the raw store list canEdit would otherwise miss.
83
+ const timelineRowElements = useTimelineRowElements();
84
+ const guardedRef = useRef(new WeakMap<GuardedTimelineHandler, GuardedTimelineEntry>());
85
+ // Refuses (no call, no write, no history entry) when any target is
86
+ // blocked; otherwise runs fn as before. Cached by fn identity — like
87
+ // track() — so a fresh closure here doesn't defeat track's own cache.
88
+ const guard = useCallback(
89
+ <H extends (...args: never[]) => Promise<void>>(
90
+ resolveTargets: (...args: Parameters<H>) => readonly TimelineElement[],
91
+ fn: H,
92
+ onRefused?: (...args: Parameters<H>) => void,
93
+ ): H => {
94
+ const key = fn as unknown as GuardedTimelineHandler;
95
+ const cached = guardedRef.current.get(key);
96
+ if (cached) {
97
+ cached.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
98
+ cached.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
99
+ return cached.wrapped as H;
100
+ }
101
+ const entry = {} as GuardedTimelineEntry;
102
+ entry.resolveTargets = resolveTargets as unknown as GuardedTimelineResolver;
103
+ entry.onRefused = onRefused as unknown as GuardedTimelineRefusal | undefined;
104
+ entry.wrapped = ((...args: Parameters<H>) => {
105
+ if (!checkEditableRef.current(entry.resolveTargets(...(args as never[])))) {
106
+ entry.onRefused?.(...(args as never[]));
107
+ return Promise.resolve();
108
+ }
109
+ return fn(...args);
110
+ }) as H as unknown as GuardedTimelineHandler;
111
+ guardedRef.current.set(key, entry);
112
+ return entry.wrapped as H;
113
+ },
114
+ [],
115
+ );
60
116
 
61
117
  const enqueueEdit = useCallback(
62
118
  (
@@ -378,9 +434,10 @@ export function useTimelineEditing({
378
434
  previewIframeRef,
379
435
  pendingTimelineEditPathRef,
380
436
  isRecordingRef,
437
+ checkEditable,
381
438
  });
382
439
 
383
- const setElementFxAttribute = useSetElementAttribute({
440
+ const { revertLive: revertElementFxLive, ...setElementFxAttribute } = useSetElementAttribute({
384
441
  projectIdRef,
385
442
  activeCompPath,
386
443
  showToast,
@@ -391,16 +448,18 @@ export function useTimelineEditing({
391
448
  isRecordingRef,
392
449
  });
393
450
 
394
- const setAudioGroupAttribute = useSetAudioGroupAttribute({
395
- projectIdRef,
396
- activeCompPath,
397
- showToast,
398
- writeProjectFile,
399
- recordEdit,
400
- previewIframeRef,
401
- pendingTimelineEditPathRef,
402
- isRecordingRef,
403
- });
451
+ const { revertLive: revertAudioGroupLive, ...setAudioGroupAttribute } = useSetAudioGroupAttribute(
452
+ {
453
+ projectIdRef,
454
+ activeCompPath,
455
+ showToast,
456
+ writeProjectFile,
457
+ recordEdit,
458
+ previewIframeRef,
459
+ pendingTimelineEditPathRef,
460
+ isRecordingRef,
461
+ },
462
+ );
404
463
 
405
464
  const { handleTimelineElementsDelete, handleTimelineElementDelete } = useTimelineDeleteOps({
406
465
  projectIdRef,
@@ -429,6 +488,7 @@ export function useTimelineEditing({
429
488
  isRecordingRef,
430
489
  forceReloadSdkSession,
431
490
  observeProjectFileVersion,
491
+ checkEditable,
432
492
  });
433
493
 
434
494
  const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast);
@@ -445,23 +505,87 @@ export function useTimelineEditing({
445
505
  forceReloadSdkSession,
446
506
  });
447
507
 
508
+ // Every write-handler is tracked here, the one place all hand edits
509
+ // converge, so undo never races a write; canEdit gates the same point.
510
+ // Coverage boundary: see the PR body, not every kind resolves an element.
511
+ const trackedRazorSplit = track(guard((element) => [element], handleRazorSplit));
448
512
  return {
449
- handleTimelineElementMove,
450
- handleTimelineElementResize,
451
- handleToggleTrackHidden,
452
- handleToggleElementHidden,
453
- handleAutoGroupCarveSources,
454
- setAudioGroupAttribute,
455
- setElementFxAttribute,
456
- handleTimelineElementDelete,
457
- handleTimelineElementsDelete,
458
- handleTimelineElementSplit: handleRazorSplit,
459
- handleRazorSplit,
460
- handleRazorSplitAll,
461
- handleTimelineAssetDrop,
462
- handleTimelineFileDrop,
463
- handleTimelineCompositionDrop,
513
+ handleTimelineElementMove: track(guard((element) => [element], handleTimelineElementMove)),
514
+ handleTimelineElementResize: track(guard((element) => [element], handleTimelineElementResize)),
515
+ handleToggleTrackHidden: track(
516
+ guard(
517
+ (trackIndex) => timelineRowElements.filter((el) => el.track === trackIndex),
518
+ handleToggleTrackHidden,
519
+ ),
520
+ ),
521
+ handleToggleElementHidden: track(
522
+ guard((elementKey) => {
523
+ const keys = new Set(Array.isArray(elementKey) ? elementKey : [elementKey]);
524
+ return timelineRowElements.filter((el) => keys.has(el.key ?? el.id));
525
+ }, handleToggleElementHidden),
526
+ ),
527
+ handleAutoGroupCarveSources: track(handleAutoGroupCarveSources),
528
+ setAudioGroupAttribute: {
529
+ ...setAudioGroupAttribute,
530
+ // Same two-array member lookup syncStoredGroupAttribute mirrors into
531
+ // (timelineAudioGroupVolume.ts): a sub-composition's group members have
532
+ // no flat twin, only a domClipChildren entry, so both are checked.
533
+ setQuiet: track(
534
+ guard(
535
+ (groupId) => {
536
+ const state = usePlayerStore.getState();
537
+ const flatMembers = state.elements.filter((el) => el.audioGroup === groupId);
538
+ const domMembers = state.domClipChildren
539
+ .filter((child) => child.audioGroup === groupId)
540
+ .map(
541
+ (child): TimelineElement => ({
542
+ id: child.id,
543
+ domId: child.id,
544
+ tag: "div",
545
+ start: 0,
546
+ duration: 0,
547
+ track: -1,
548
+ }),
549
+ );
550
+ return [...flatMembers, ...domMembers];
551
+ },
552
+ setAudioGroupAttribute.setQuiet,
553
+ (groupId, attr) => revertAudioGroupLive(groupId, attr),
554
+ ),
555
+ ),
556
+ },
557
+ setElementFxAttribute: {
558
+ ...setElementFxAttribute,
559
+ setQuiet: track(
560
+ guard(
561
+ (element) => [element],
562
+ setElementFxAttribute.setQuiet,
563
+ (element, attr) => revertElementFxLive(element, attr),
564
+ ),
565
+ ),
566
+ },
567
+ handleTimelineElementDelete: track(guard((element) => [element], handleTimelineElementDelete)),
568
+ handleTimelineElementsDelete: track(
569
+ guard((elements) => elements, handleTimelineElementsDelete),
570
+ ),
571
+ handleTimelineElementSplit: trackedRazorSplit,
572
+ handleRazorSplit: trackedRazorSplit,
573
+ // Same selection the handler itself splits (useRazorSplit.ts).
574
+ handleRazorSplitAll: track(
575
+ guard(
576
+ (splitTime) => selectSplittableElements(usePlayerStore.getState().elements, splitTime),
577
+ handleRazorSplitAll,
578
+ ),
579
+ ),
580
+ handleTimelineAssetDrop: track(handleTimelineAssetDrop),
581
+ handleTimelineFileDrop: track(handleTimelineFileDrop),
582
+ handleTimelineCompositionDrop: track(handleTimelineCompositionDrop),
464
583
  handleBlockedTimelineEdit,
465
- ...groupEditing,
584
+ handleTimelineGroupMove: track(
585
+ guard((changes) => changes.map((c) => c.element), groupEditing.handleTimelineGroupMove),
586
+ ),
587
+ handleTimelineGroupResize: track(
588
+ guard((changes) => changes.map((c) => c.element), groupEditing.handleTimelineGroupResize),
589
+ ),
466
590
  };
467
591
  }
@@ -4,6 +4,7 @@ import type { TimelineElement } from "../player";
4
4
  import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
5
5
  import type { EditHistoryKind } from "../utils/editHistory";
6
6
  import type { PublishSdkSession } from "../utils/sdkCutover";
7
+ import type { CanEditTimelineElement } from "./timelineEditPermission";
7
8
 
8
9
  interface RecordEditInput {
9
10
  label: string;
@@ -49,6 +50,8 @@ export interface UseTimelineEditingOptions {
49
50
  /** Reparse authored animations after a timing rewrite changes their positions. */
50
51
  invalidateGsapCache?: () => void;
51
52
  handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
53
+ /** Refuses a hand edit at the persist boundary when set; absent = Studio unchanged. */
54
+ canEdit?: CanEditTimelineElement;
52
55
  }
53
56
 
54
57
  export type TimelineFileDropHandler = (
@@ -0,0 +1,62 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act, createElement } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
7
+ import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ let root: Root | null = null;
12
+ afterEach(() => act(() => root?.unmount()));
13
+
14
+ function mount() {
15
+ let track!: ReturnType<typeof useTrackPendingTimelineEdit>;
16
+ function Probe() {
17
+ track = useTrackPendingTimelineEdit();
18
+ return null;
19
+ }
20
+ root = createRoot(document.createElement("div"));
21
+ act(() => root!.render(createElement(Probe)));
22
+ return track;
23
+ }
24
+
25
+ const deferred = <T,>() => {
26
+ let resolve!: (value: T) => void;
27
+ const promise = new Promise<T>((r) => (resolve = r));
28
+ return { promise, resolve };
29
+ };
30
+
31
+ describe("useTrackPendingTimelineEdit", () => {
32
+ it("feeds Studio's shared pending-edit registry, so the app-wide flush waits for it", async () => {
33
+ const track = mount();
34
+ const gate = deferred<void>();
35
+ const order: string[] = [];
36
+ const tracked = track(() => gate.promise.then(() => order.push("edit-settled")));
37
+ const call = tracked();
38
+ const flushPromise = flushStudioPendingEdits().then(() => order.push("flushed"));
39
+ // Give the flush's own microtasks a turn: if tracking did nothing, it
40
+ // would already show "flushed" here, before the edit ever settles.
41
+ await Promise.resolve();
42
+ await Promise.resolve();
43
+ expect(order).toEqual([]);
44
+ gate.resolve();
45
+ await call;
46
+ await flushPromise;
47
+ expect(order).toEqual(["edit-settled", "flushed"]);
48
+ });
49
+
50
+ it("returns the same wrapper for the same handler across calls", () => {
51
+ const track = mount();
52
+ const handler = () => Promise.resolve();
53
+ expect(track(handler)).toBe(track(handler));
54
+ });
55
+
56
+ it("returns a different wrapper for a different handler", () => {
57
+ const track = mount();
58
+ const a = () => Promise.resolve();
59
+ const b = () => Promise.resolve();
60
+ expect(track(a)).not.toBe(track(b));
61
+ });
62
+ });
@@ -0,0 +1,22 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { trackStudioPendingEdit } from "../utils/studioPendingEdits";
3
+
4
+ type AsyncFn = (...args: never[]) => Promise<unknown>;
5
+
6
+ // Tracks a hand-edit handler's write so undo/redo (already draining this
7
+ // registry) waits for it. Same `fn` in, same wrapper out, so memoized deps stay stable.
8
+ export function useTrackPendingTimelineEdit() {
9
+ const wrappedRef = useRef(new WeakMap<AsyncFn, AsyncFn>());
10
+ return useCallback(<Args extends unknown[], R>(fn: (...args: Args) => Promise<R>) => {
11
+ const key = fn as unknown as AsyncFn;
12
+ const cached = wrappedRef.current.get(key);
13
+ if (cached) return cached as unknown as (...args: Args) => Promise<R>;
14
+ const wrapped = (...args: Args): Promise<R> => {
15
+ const result = fn(...args);
16
+ trackStudioPendingEdit(result);
17
+ return result;
18
+ };
19
+ wrappedRef.current.set(key, wrapped as unknown as AsyncFn);
20
+ return wrapped;
21
+ }, []);
22
+ }
@@ -0,0 +1,222 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // Proves the package's public exports, not internal hook paths, are enough
4
+ // for a host to mount hand editing outside EditorShell: an edit writes
5
+ // through the history path, Undo restores it, and a 409 shows the banner.
6
+ import { act, createElement, useRef } from "react";
7
+ import { createRoot, type Root } from "react-dom/client";
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ // Backend/storage mocking only, standing in for a host's own server and
10
+ // persistence — not part of the host's integration code under test.
11
+ import { studioFileContentVersion } from "./utils/studioFileVersion";
12
+ import { createMemoryEditHistoryStorage } from "./utils/editHistoryStorage";
13
+ import {
14
+ flushStudioPendingEdits,
15
+ useTimelineEditing,
16
+ useProjectFileWriter,
17
+ usePersistentEditHistory,
18
+ useEditHistoryActions,
19
+ ExternalFileConflictBanner,
20
+ StudioFileConflictError,
21
+ type ExternalFileChangeCoordinatorHandle,
22
+ type TimelineElement,
23
+ } from "./index";
24
+
25
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
26
+
27
+ const ORIGINAL_HTML = '<div id="clip" data-start="0" data-track-index="0"></div>';
28
+
29
+ let root: Root | null = null;
30
+ afterEach(() => {
31
+ act(() => root?.unmount());
32
+ document.body.innerHTML = "";
33
+ vi.unstubAllGlobals();
34
+ });
35
+
36
+ /** A minimal disk with etag-guarded PUT, faithful enough to exercise a real
37
+ * conflict: a stale If-Match gets a 409 with the current version + content. */
38
+ function stubHostProjectDisk(
39
+ initial: Record<string, string>,
40
+ options?: { editElsewhereAfterFirstRead?: boolean },
41
+ ) {
42
+ const content = { ...initial };
43
+ const readsSeen = new Set<string>();
44
+ // The real backend's etag is a content hash (useProjectFileWriter derives
45
+ // If-Match the same way) — an arbitrary counter here would make every
46
+ // write from a host that hasn't just read the file look stale.
47
+ const versionOf = (path: string) => studioFileContentVersion(content[path]);
48
+
49
+ async function handlePut(path: string, init: RequestInit | undefined) {
50
+ const ifMatch = new Headers(init?.headers).get("If-Match");
51
+ const currentVersion = await versionOf(path);
52
+ if (ifMatch && ifMatch !== currentVersion) {
53
+ return new Response(JSON.stringify({ currentVersion, currentContent: content[path] }), {
54
+ status: 409,
55
+ });
56
+ }
57
+ content[path] = String(init?.body ?? "");
58
+ return new Response(JSON.stringify({ version: await versionOf(path) }), { status: 200 });
59
+ }
60
+
61
+ async function handleGet(path: string) {
62
+ const served = content[path];
63
+ // Simulates another writer landing between this read and the caller's
64
+ // own write: content moves on right after being served once, so the
65
+ // caller's hash of what it just read no longer matches disk.
66
+ if (options?.editElsewhereAfterFirstRead && !readsSeen.has(path)) {
67
+ readsSeen.add(path);
68
+ content[path] = `${served} <!--edited elsewhere-->`;
69
+ }
70
+ return new Response(
71
+ JSON.stringify({ content: served, version: await studioFileContentVersion(served) }),
72
+ { status: 200 },
73
+ );
74
+ }
75
+
76
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
77
+ const match = String(input).match(/\/files\/([^?]+)/);
78
+ const path = match ? decodeURIComponent(match[1]) : "";
79
+ return (init?.method ?? "GET") === "PUT" ? handlePut(path, init) : handleGet(path);
80
+ });
81
+ vi.stubGlobal("fetch", fetchMock);
82
+ return { content, fetchMock };
83
+ }
84
+
85
+ type TimelineEditingHandle = {
86
+ handleTimelineElementMove: (
87
+ element: TimelineElement,
88
+ updates: { start: number; track: number },
89
+ ) => Promise<void>;
90
+ };
91
+
92
+ function mountHost(
93
+ clip: TimelineElement,
94
+ iframe: HTMLIFrameElement,
95
+ ): {
96
+ timelineEditing: TimelineEditingHandle;
97
+ undo: () => Promise<void>;
98
+ bannerHost: HTMLDivElement;
99
+ } {
100
+ let timelineEditing: TimelineEditingHandle | null = null;
101
+ let undo: (() => Promise<void>) | null = null;
102
+ let conflict: ExternalFileChangeCoordinatorHandle = {
103
+ blocked: null,
104
+ retry: async () => {},
105
+ useExternalFile: async () => {},
106
+ keepStudioFile: async () => {},
107
+ };
108
+ const bannerHost = document.createElement("div");
109
+ document.body.append(bannerHost);
110
+ const bannerRoot = createRoot(bannerHost);
111
+ // Created once: a fresh storage object on every render would change
112
+ // usePersistentEditHistory's effect deps each time and loop forever.
113
+ const storage = createMemoryEditHistoryStorage();
114
+
115
+ function Harness() {
116
+ const writer = useProjectFileWriter({ projectId: "p1" });
117
+ const editHistory = usePersistentEditHistory({ projectId: "p1", storage });
118
+ const historyActions = useEditHistoryActions({
119
+ editHistory,
120
+ readOptionalProjectFile: writer.readOptionalProjectFile,
121
+ readProjectFile: writer.readProjectFile,
122
+ writeProjectFile: writer.writeProjectFile,
123
+ showToast: () => {},
124
+ syncHistoryPreviewAfterApply: async () => {},
125
+ // The pattern a host's own dom-edit queue would also drain through —
126
+ // this is the one gate that closes the undo/delete race (#4194).
127
+ waitForPendingDomEditSaves: async () => {
128
+ const result = await flushStudioPendingEdits();
129
+ if (result.status !== "clean") throw result.error;
130
+ },
131
+ });
132
+ undo = historyActions.undo;
133
+ const commitRef = useRef(null);
134
+ timelineEditing = useTimelineEditing({
135
+ projectId: "p1",
136
+ activeCompPath: "index.html",
137
+ timelineElements: [clip],
138
+ showToast: () => {},
139
+ writeProjectFile: async (path, content, expectedContent) => {
140
+ try {
141
+ await writer.writeProjectFile(path, content, expectedContent);
142
+ } catch (error) {
143
+ if (error instanceof StudioFileConflictError) {
144
+ conflict = {
145
+ ...conflict,
146
+ blocked: { status: "conflict", generation: 0, error, payload: null },
147
+ };
148
+ bannerRoot.render(createElement(ExternalFileConflictBanner, { coordinator: conflict }));
149
+ }
150
+ throw error;
151
+ }
152
+ },
153
+ observeProjectFileVersion: writer.observeProjectFileVersion,
154
+ recordEdit: editHistory.recordEdit,
155
+ reloadPreview: () => {},
156
+ previewIframeRef: { current: iframe },
157
+ pendingTimelineEditPathRef: { current: new Set<string>() },
158
+ uploadProjectFiles: async () => [],
159
+ handleDomZIndexReorderCommitRef: commitRef,
160
+ });
161
+ return null;
162
+ }
163
+
164
+ root = createRoot(document.createElement("div"));
165
+ act(() => root!.render(createElement(Harness)));
166
+ if (!timelineEditing) throw new Error("Expected hook to mount");
167
+ if (!undo) throw new Error("Expected undo to be exposed");
168
+ return { timelineEditing, undo, bannerHost };
169
+ }
170
+
171
+ describe("public export surface: a host mounting hand editing outside EditorShell", () => {
172
+ it("writes a move through the history path and undo restores it", async () => {
173
+ const disk = stubHostProjectDisk({ "index.html": ORIGINAL_HTML });
174
+ const iframe = document.createElement("iframe");
175
+ document.body.append(iframe);
176
+ iframe.contentDocument!.body.innerHTML = ORIGINAL_HTML;
177
+ const clip: TimelineElement = {
178
+ id: "clip",
179
+ domId: "clip",
180
+ tag: "div",
181
+ start: 0,
182
+ duration: 2,
183
+ track: 0,
184
+ };
185
+
186
+ const { timelineEditing, undo } = mountHost(clip, iframe);
187
+ await act(async () => {
188
+ await timelineEditing.handleTimelineElementMove(clip, { start: 5, track: 0 });
189
+ });
190
+ expect(disk.content["index.html"]).toContain('data-start="5"');
191
+
192
+ await act(async () => {
193
+ await undo();
194
+ });
195
+ expect(disk.content["index.html"]).toContain('data-start="0"');
196
+ });
197
+
198
+ it("surfaces the conflict banner on a 409", async () => {
199
+ stubHostProjectDisk({ "index.html": ORIGINAL_HTML }, { editElsewhereAfterFirstRead: true });
200
+ const iframe = document.createElement("iframe");
201
+ document.body.append(iframe);
202
+ iframe.contentDocument!.body.innerHTML = ORIGINAL_HTML;
203
+ const clip: TimelineElement = {
204
+ id: "clip",
205
+ domId: "clip",
206
+ tag: "div",
207
+ start: 0,
208
+ duration: 2,
209
+ track: 0,
210
+ };
211
+
212
+ const { timelineEditing, bannerHost } = mountHost(clip, iframe);
213
+
214
+ await act(async () => {
215
+ await expect(
216
+ timelineEditing.handleTimelineElementMove(clip, { start: 5, track: 0 }),
217
+ ).rejects.toBeInstanceOf(StudioFileConflictError);
218
+ });
219
+
220
+ expect(bannerHost.textContent).toContain("changed outside Studio");
221
+ });
222
+ });
package/src/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  // UI primitives
2
2
  export { Button, buttonBase, buttonSizes, buttonVariants } from "./components/ui/Button";
3
3
  export type { ButtonSize, ButtonVariant, PreviewState } from "./components/ui/Button";
4
+ export { HyperframesLogo } from "./components/StudioHeader";
4
5
  export { IconButton } from "./components/ui/IconButton";
5
6
  export { Tab, TabPanel, Tabs, TabsList } from "./components/ui/Tabs";
7
+ export { HyperframesLoader } from "./components/ui/HyperframesLoader";
8
+ export type { HyperframesLoaderProps } from "./components/ui/HyperframesLoader";
6
9
  export { Tooltip } from "./components/ui/Tooltip";
7
- export { HyperframesLogo } from "./components/StudioHeader";
8
10
  export { cn } from "./components/ui/cn";
9
11
  export {
10
12
  ContextMenu,
@@ -49,7 +51,7 @@ export {
49
51
  liveTime,
50
52
  formatTime,
51
53
  } from "./player";
52
- export type { TimelineElement } from "./player";
54
+ export type { TimelineElement, TimelineTimeRange } from "./player";
53
55
 
54
56
  // Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
55
57
  export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
@@ -97,3 +99,39 @@ export type { PickedElement } from "./hooks/useElementPicker";
97
99
  export { resolveSourceFile, applyPatch } from "./utils/sourcePatcher";
98
100
  export type { PatchOperation } from "./utils/sourcePatcher";
99
101
  export { parseStyleString, mergeStyleIntoTag, findElementBlock } from "./utils/htmlEditor";
102
+
103
+ // Timeline editing: Studio's own hand-edit path, undo/redo, the
104
+ // etag-guarded writer and the conflict banner, for a host mounting the
105
+ // timeline outside EditorShell.
106
+ export { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
107
+ export type { UsePersistentEditHistoryOptions } from "./hooks/usePersistentEditHistory";
108
+ export { useTimelineEditing } from "./hooks/useTimelineEditing";
109
+ export type { UseTimelineEditingOptions } from "./hooks/useTimelineEditingTypes";
110
+ // A host's own waitForPendingDomEditSaves must also call this, or undo/redo
111
+ // can race a write still in flight (see useTrackPendingTimelineEdit.ts).
112
+ export { flushStudioPendingEdits } from "./utils/studioPendingEdits";
113
+ export type { StudioPendingEditsDrainResult } from "./utils/studioPendingEdits";
114
+ export type {
115
+ CanEditTimelineElement,
116
+ TimelineEditPermission,
117
+ } from "./hooks/timelineEditPermission";
118
+ export { useEditHistoryActions } from "./hooks/useEditHistoryActions";
119
+ export type {
120
+ EditHistoryHandle,
121
+ UseEditHistoryActionsOptions,
122
+ } from "./hooks/useEditHistoryActions";
123
+ export { useProjectFileWriter } from "./hooks/useProjectFileWriter";
124
+ export type { UseProjectFileWriterOptions } from "./hooks/useProjectFileWriter";
125
+ // A host's writeProjectFile throws this on a 409; catch it to know when
126
+ // to show ExternalFileConflictBanner.
127
+ export { StudioFileConflictError } from "./utils/studioSaveDiagnostics";
128
+ export { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
129
+ export type {
130
+ ExternalFileChangeCoordinatorHandle,
131
+ ExternalFileChangeBlockedState,
132
+ } from "./hooks/useExternalFileChangeCoordinator";
133
+ export { TimelinePane } from "./components/nle/TimelinePane";
134
+ export type { TimelinePaneProps } from "./components/nle/TimelinePane";
135
+ export { TimelineEditProvider } from "./contexts/TimelineEditContext";
136
+ export type { TimelineEditCallbacks } from "./player/components/timelineCallbacks";
137
+ export type { BlockedTimelineEditIntent } from "./player/components/timelineEditing";