@hyperframes/studio 0.8.53 → 0.8.55
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.
- package/dist/assets/{hyperframes-player-DKWM-Ons.js → hyperframes-player-BjAGc2ID.js} +1 -1
- package/dist/assets/{index-Y4OnpTEM.js → index-BEIRytvV.js} +1 -1
- package/dist/assets/{index-UvW6BEwf.js → index-DaNpNFJC.js} +1 -1
- package/dist/assets/{index-HfP3sap2.js → index-TcBZyzNA.js} +191 -191
- package/dist/index.d.ts +440 -10
- package/dist/index.html +1 -1
- package/dist/index.js +1891 -1544
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/hooks/gsapDragCommit.ts +1 -1
- package/src/hooks/gsapEditOutcome.test.ts +115 -0
- package/src/hooks/gsapEditOutcome.ts +39 -10
- package/src/hooks/gsapResizeIntercept.test.ts +42 -3
- package/src/hooks/gsapResizeIntercept.ts +12 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +10 -2
- package/src/hooks/gsapRuntimeBridge.ts +14 -3
- package/src/hooks/timelineAudioGroupCreate.ts +17 -1
- package/src/hooks/timelineAudioGroupVolume.ts +35 -2
- package/src/hooks/timelineEditPermission.ts +31 -0
- package/src/hooks/timelineElementFxAttribute.ts +34 -2
- package/src/hooks/useFileManager.ts +21 -123
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/useProjectFileWriter.ts +150 -0
- package/src/hooks/useTimelineAssetDropOps.test.tsx +62 -4
- package/src/hooks/useTimelineAssetDropOps.ts +40 -3
- package/src/hooks/useTimelineDeleteOps.test.tsx +111 -0
- package/src/hooks/useTimelineEditing.test.tsx +481 -4
- package/src/hooks/useTimelineEditing.ts +151 -27
- package/src/hooks/useTimelineEditingTypes.ts +3 -0
- package/src/hooks/useTrackPendingTimelineEdit.test.tsx +62 -0
- package/src/hooks/useTrackPendingTimelineEdit.ts +22 -0
- package/src/hostMount.test.tsx +222 -0
- package/src/index.ts +40 -2
- package/src/player/components/Timeline.tsx +6 -6
- package/src/player/components/TimelineTypes.ts +3 -0
- package/src/player/components/usePublishRangeSelection.test.tsx +60 -0
- package/src/player/components/usePublishRangeSelection.ts +42 -0
- package/src/player/index.ts +1 -0
- package/src/player/store/playerStore.ts +8 -51
- package/src/player/store/rangeSelectionSlice.ts +13 -0
- package/src/player/store/timelineResetState.ts +51 -0
- package/src/utils/studioSaveDiagnostics.ts +12 -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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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";
|