@hyperframes/studio 0.7.92 → 0.7.93

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 (108) hide show
  1. package/dist/assets/{hyperframes-player-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CDTN2ZVa.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +20 -20
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7055 -5321
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  16. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  17. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  18. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  19. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  20. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  21. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  22. package/src/components/editor/propertyPanelTypes.ts +9 -3
  23. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  24. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  25. package/src/components/sidebar/AssetCard.tsx +4 -3
  26. package/src/components/sidebar/AudioRow.tsx +4 -3
  27. package/src/contexts/FileManagerContext.tsx +12 -0
  28. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  29. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  30. package/src/hooks/externalFileReloadBus.ts +18 -0
  31. package/src/hooks/gsapDragCommit.test.ts +39 -1
  32. package/src/hooks/gsapDragCommit.ts +12 -40
  33. package/src/hooks/gsapEditOutcome.ts +72 -0
  34. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  35. package/src/hooks/gsapResizeIntercept.ts +44 -16
  36. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  37. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  38. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  39. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  40. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  41. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  42. package/src/hooks/useDomGeometryCommits.ts +20 -0
  43. package/src/hooks/useEditorSave.test.tsx +131 -0
  44. package/src/hooks/useEditorSave.ts +113 -39
  45. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  47. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  48. package/src/hooks/useFileManager.ts +32 -3
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  50. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  51. package/src/hooks/useGsapAwareEditing.ts +88 -27
  52. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  54. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  55. package/src/hooks/usePreviewPersistence.ts +22 -58
  56. package/src/hooks/useSdkSession.ts +8 -52
  57. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  58. package/src/hooks/useStudioSdkSessions.ts +2 -7
  59. package/src/hooks/useStudioSessionStart.ts +15 -0
  60. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  61. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  62. package/src/player/components/Timeline.test.ts +24 -5
  63. package/src/player/components/Timeline.tsx +40 -40
  64. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  65. package/src/player/components/TimelineClip.test.tsx +17 -2
  66. package/src/player/components/TimelineClip.tsx +15 -3
  67. package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
  68. package/src/player/components/TimelineClipDiamonds.tsx +53 -13
  69. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  70. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  71. package/src/player/components/TimelineLanes.test.tsx +64 -6
  72. package/src/player/components/TimelineLanes.tsx +81 -64
  73. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  74. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  75. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  76. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  77. package/src/player/components/TimelineTrackRow.tsx +58 -7
  78. package/src/player/components/timelineDiamondTypes.ts +14 -7
  79. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  80. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  81. package/src/player/components/timelineLaneProps.ts +3 -0
  82. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  83. package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
  84. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  85. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  86. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  87. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  88. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  89. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  90. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  91. package/src/player/components/useTimelineRowVirtualization.ts +16 -26
  92. package/src/player/store/liveTime.ts +11 -0
  93. package/src/player/store/playerStore.test.ts +29 -22
  94. package/src/player/store/playerStore.ts +24 -29
  95. package/src/player/store/timelineFocusState.ts +15 -0
  96. package/src/utils/domEditSaveQueue.test.ts +37 -1
  97. package/src/utils/domEditSaveQueue.ts +22 -4
  98. package/src/utils/externalConflictStorage.test.ts +95 -0
  99. package/src/utils/externalConflictStorage.ts +172 -0
  100. package/src/utils/studioFileVersion.test.ts +28 -1
  101. package/src/utils/studioFileVersion.ts +30 -0
  102. package/src/utils/studioPendingEdits.test.ts +108 -1
  103. package/src/utils/studioPendingEdits.ts +39 -2
  104. package/src/utils/studioSaveDiagnostics.ts +5 -0
  105. package/dist/assets/index-BXkzp_OU.css +0 -1
  106. package/dist/assets/index-DIntkrQl.js +0 -428
  107. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  108. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -90,6 +90,75 @@ describe("FlatRow", () => {
90
90
  act(() => root.unmount());
91
91
  });
92
92
 
93
+ it("restores its durable value when an async commit rejects", async () => {
94
+ let rejectCommit: ((error: Error) => void) | null = null;
95
+ const onCommit = vi.fn(
96
+ () =>
97
+ new Promise<void>((_resolve, reject) => {
98
+ rejectCommit = reject;
99
+ }),
100
+ );
101
+ const row = (value: string) => (
102
+ <FlatRow label="X" value={value} tier="explicitDefault" onCommit={onCommit} />
103
+ );
104
+ const { host, root } = renderInto(row("22px"));
105
+ const input = host.querySelector<HTMLInputElement>("input");
106
+ if (!input) throw new Error("expected an input");
107
+ act(() => {
108
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
109
+ window.HTMLInputElement.prototype,
110
+ "value",
111
+ )?.set;
112
+ nativeInputValueSetter?.call(input, "99px");
113
+ input.dispatchEvent(new Event("input", { bubbles: true }));
114
+ input.dispatchEvent(new Event("focusout", { bubbles: true }));
115
+ });
116
+ // The parent can echo the preview before persistence settles. That is not a
117
+ // durable acknowledgement and must not invalidate the pending rollback.
118
+ act(() => root.render(row("99px")));
119
+ await act(async () => {
120
+ rejectCommit?.(new Error("save failed"));
121
+ await Promise.resolve();
122
+ });
123
+
124
+ expect(onCommit).toHaveBeenCalledWith("99px");
125
+ expect(input.value).toBe("22px");
126
+ act(() => root.unmount());
127
+ });
128
+
129
+ it("does not let an older rejected commit overwrite a newer draft", async () => {
130
+ let rejectCommit: ((error: Error) => void) | null = null;
131
+ const onCommit = vi.fn(
132
+ () =>
133
+ new Promise<void>((_resolve, reject) => {
134
+ rejectCommit = reject;
135
+ }),
136
+ );
137
+ const { host, root } = renderInto(
138
+ <FlatRow label="X" value="22px" tier="explicitDefault" onCommit={onCommit} />,
139
+ );
140
+ const input = host.querySelector<HTMLInputElement>("input");
141
+ if (!input) throw new Error("expected an input");
142
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
143
+ window.HTMLInputElement.prototype,
144
+ "value",
145
+ )?.set;
146
+ act(() => {
147
+ nativeInputValueSetter?.call(input, "99px");
148
+ input.dispatchEvent(new Event("input", { bubbles: true }));
149
+ input.dispatchEvent(new Event("focusout", { bubbles: true }));
150
+ nativeInputValueSetter?.call(input, "100px");
151
+ input.dispatchEvent(new Event("input", { bubbles: true }));
152
+ });
153
+ await act(async () => {
154
+ rejectCommit?.(new Error("old save failed"));
155
+ await Promise.resolve();
156
+ });
157
+
158
+ expect(input.value).toBe("100px");
159
+ act(() => root.unmount());
160
+ });
161
+
93
162
  it("persists a rapid numeric arrow-key burst as one commit", () => {
94
163
  vi.useFakeTimers();
95
164
  const onCommit = vi.fn();
@@ -35,7 +35,7 @@ export function FlatRow({
35
35
  /** Renders a trailing 10px caret-down, for select-backed rows. */
36
36
  dropdown?: boolean;
37
37
  onPreview?: (nextValue: string) => void;
38
- onCommit: (nextValue: string) => void;
38
+ onCommit: (nextValue: string) => void | Promise<void>;
39
39
  onReset?: () => void;
40
40
  }) {
41
41
  const track = useTrackDesignInput();
@@ -59,7 +59,7 @@ export function FlatRow({
59
59
  onPreview={onPreview}
60
60
  onCommit={(nextValue) => {
61
61
  track("metric", label);
62
- onCommit(nextValue);
62
+ return onCommit(nextValue);
63
63
  }}
64
64
  />
65
65
  </span>
@@ -29,14 +29,14 @@ export function MetricField({
29
29
  scrub?: boolean;
30
30
  suffix?: string;
31
31
  tooltip?: string;
32
- onCommit: (nextValue: string) => void;
32
+ onCommit: (nextValue: string) => void | Promise<void>;
33
33
  }) {
34
34
  const track = useTrackDesignInput();
35
35
  const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
36
36
  const commit = useCallback(
37
37
  (nextValue: string) => {
38
38
  if (nextValue !== value) track("metric", label);
39
- onCommit(nextValue);
39
+ return onCommit(nextValue);
40
40
  },
41
41
  [label, onCommit, track, value],
42
42
  );
@@ -0,0 +1,57 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection } from "./domEditingTypes";
5
+ import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
6
+ import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
7
+
8
+ describe("createTransformCommitHandlers", () => {
9
+ it.each([
10
+ [
11
+ "position",
12
+ (handlers: ReturnType<typeof createTransformCommitHandlers>) =>
13
+ handlers.commitManualOffset("x", "20px"),
14
+ ],
15
+ [
16
+ "size",
17
+ (handlers: ReturnType<typeof createTransformCommitHandlers>) =>
18
+ handlers.commitManualSize("width", "200px"),
19
+ ],
20
+ [
21
+ "rotation",
22
+ (handlers: ReturnType<typeof createTransformCommitHandlers>) =>
23
+ handlers.commitManualRotation("45"),
24
+ ],
25
+ ])("propagates blocked %s edits so the field can roll back", async (_name, commit) => {
26
+ const blocked = new GsapEditBlockedError("unroll-required");
27
+ const onCommitAnimatedProperty = vi.fn().mockRejectedValue(blocked);
28
+ const onSetManualOffset = vi.fn();
29
+ const onSetManualSize = vi.fn();
30
+ const onSetManualRotation = vi.fn();
31
+ const element = {
32
+ id: "box",
33
+ selector: "#box",
34
+ element: document.createElement("div"),
35
+ boundingBox: { width: 100, height: 100 },
36
+ } as unknown as DomEditSelection;
37
+ const handlers = createTransformCommitHandlers({
38
+ element,
39
+ styles: {},
40
+ hasGsapAnimation: true,
41
+ gsapAnimId: "#box-to-position",
42
+ gsapKeyframes: null,
43
+ currentPct: 0,
44
+ onCommitAnimatedProperty,
45
+ onAddKeyframe: undefined,
46
+ onSetManualOffset,
47
+ onSetManualSize,
48
+ onSetManualRotation,
49
+ showToast: vi.fn(),
50
+ });
51
+
52
+ await expect(commit(handlers)).rejects.toBe(blocked);
53
+ expect(onSetManualOffset).not.toHaveBeenCalled();
54
+ expect(onSetManualSize).not.toHaveBeenCalled();
55
+ expect(onSetManualRotation).not.toHaveBeenCalled();
56
+ });
57
+ });
@@ -41,13 +41,13 @@ export function createTransformCommitHandlers({
41
41
  // Route a transform value into the GSAP animation (or a new keyframe) when the
42
42
  // element is animated. Returns true when handled, so callers fall through to
43
43
  // the manual-transform path only for non-animated elements.
44
- const commitAnimatedTransformValue = (
44
+ const commitAnimatedTransformValue = async (
45
45
  property: string,
46
46
  value: number,
47
47
  noCallbacksMessage: string,
48
- ): boolean => {
48
+ ): Promise<boolean> => {
49
49
  if (onCommitAnimatedProperty && hasGsapAnimation) {
50
- void onCommitAnimatedProperty(element, property, value);
50
+ await onCommitAnimatedProperty(element, property, value);
51
51
  return true;
52
52
  }
53
53
  if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
@@ -62,11 +62,11 @@ export function createTransformCommitHandlers({
62
62
  return false;
63
63
  };
64
64
 
65
- const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
65
+ const commitManualOffset = async (axis: "x" | "y", nextValue: string) => {
66
66
  const parsed = parsePxMetricValue(nextValue);
67
67
  if (parsed == null) return;
68
68
  if (
69
- commitAnimatedTransformValue(
69
+ await commitAnimatedTransformValue(
70
70
  axis,
71
71
  parsed,
72
72
  "Cannot edit position — animation callbacks not available",
@@ -74,20 +74,20 @@ export function createTransformCommitHandlers({
74
74
  )
75
75
  return;
76
76
  const current = readStudioPathOffset(element.element);
77
- void Promise.resolve(
77
+ await Promise.resolve(
78
78
  onSetManualOffset(element, {
79
79
  x: axis === "x" ? parsed : current.x,
80
80
  y: axis === "y" ? parsed : current.y,
81
81
  }),
82
- ).catch(() => undefined);
82
+ );
83
83
  };
84
84
 
85
85
  // fallow-ignore-next-line complexity
86
- const commitManualSize = (axis: "width" | "height", nextValue: string) => {
86
+ const commitManualSize = async (axis: "width" | "height", nextValue: string) => {
87
87
  const parsed = parsePxMetricValue(nextValue);
88
88
  if (parsed == null || parsed <= 0) return;
89
89
  if (onCommitAnimatedProperty && hasGsapAnimation) {
90
- void onCommitAnimatedProperty(element, axis, parsed);
90
+ await onCommitAnimatedProperty(element, axis, parsed);
91
91
  return;
92
92
  }
93
93
  if (hasGsapAnimation) {
@@ -103,26 +103,26 @@ export function createTransformCommitHandlers({
103
103
  current.height > 0
104
104
  ? current.height
105
105
  : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
106
- void Promise.resolve(
106
+ await Promise.resolve(
107
107
  onSetManualSize(element, {
108
108
  width: axis === "width" ? parsed : width,
109
109
  height: axis === "height" ? parsed : height,
110
110
  }),
111
- ).catch(() => undefined);
111
+ );
112
112
  };
113
113
 
114
- const commitManualRotation = (nextValue: string) => {
114
+ const commitManualRotation = async (nextValue: string) => {
115
115
  const parsed = Number.parseFloat(nextValue);
116
116
  if (!Number.isFinite(parsed)) return;
117
117
  if (
118
- commitAnimatedTransformValue(
118
+ await commitAnimatedTransformValue(
119
119
  "rotation",
120
120
  parsed,
121
121
  "Cannot edit rotation — animation callbacks not available",
122
122
  )
123
123
  )
124
124
  return;
125
- void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => undefined);
125
+ await Promise.resolve(onSetManualRotation(element, { angle: parsed }));
126
126
  };
127
127
 
128
128
  return { commitManualOffset, commitManualSize, commitManualRotation };
@@ -71,9 +71,15 @@ export interface PropertyPanelProps {
71
71
  onProgress?: (progress: BackgroundRemovalProgress) => void;
72
72
  },
73
73
  ) => Promise<BackgroundRemovalResult>;
74
- onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
75
- onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
76
- onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
74
+ onSetManualOffset: (
75
+ element: DomEditSelection,
76
+ next: { x: number; y: number },
77
+ ) => void | Promise<void>;
78
+ onSetManualSize: (
79
+ element: DomEditSelection,
80
+ next: { width: number; height: number },
81
+ ) => void | Promise<void>;
82
+ onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void | Promise<void>;
77
83
  onSetText: (value: string, fieldKey?: string) => void;
78
84
  onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
79
85
  onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
@@ -8,6 +8,30 @@ import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction
8
8
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
9
 
10
10
  describe("useInspectorGestureTransaction", () => {
11
+ it("restores the durable baseline when an inspector commit rejects", async () => {
12
+ const host = document.createElement("div");
13
+ const root = createRoot(host);
14
+ const onPreview = vi.fn();
15
+ const onCommit = vi.fn().mockRejectedValue(new Error("save failed"));
16
+ let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
17
+
18
+ function Probe() {
19
+ gesture = useInspectorGestureTransaction({ sourceValue: 10, onPreview, onCommit });
20
+ return null;
21
+ }
22
+
23
+ act(() => root.render(<Probe />));
24
+ act(() => {
25
+ gesture?.preview(25);
26
+ gesture?.settle();
27
+ });
28
+ expect(onPreview.mock.calls.map(([value]) => value)).toEqual([25]);
29
+ await act(async () => Promise.resolve());
30
+
31
+ expect(onPreview.mock.calls.map(([value]) => value)).toEqual([25, 10]);
32
+ act(() => root.unmount());
33
+ });
34
+
11
35
  it("keeps a new gesture active when the prior async commit is acknowledged", () => {
12
36
  const host = document.createElement("div");
13
37
  const root = createRoot(host);
@@ -34,4 +58,45 @@ describe("useInspectorGestureTransaction", () => {
34
58
 
35
59
  act(() => root.unmount());
36
60
  });
61
+
62
+ it("does not let an older rejected commit roll back a newer successful gesture", async () => {
63
+ const host = document.createElement("div");
64
+ const root = createRoot(host);
65
+ const onPreview = vi.fn();
66
+ let rejectFirst: ((error: Error) => void) | null = null;
67
+ const onCommit = vi
68
+ .fn()
69
+ .mockImplementationOnce((value: number) => {
70
+ onPreview(value);
71
+ return new Promise<void>((_resolve, reject) => {
72
+ rejectFirst = reject;
73
+ });
74
+ })
75
+ .mockImplementationOnce((value: number) => {
76
+ onPreview(value);
77
+ return Promise.resolve();
78
+ });
79
+ let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
80
+
81
+ function Probe() {
82
+ gesture = useInspectorGestureTransaction({ sourceValue: 10, onPreview, onCommit });
83
+ return null;
84
+ }
85
+
86
+ act(() => root.render(<Probe />));
87
+ act(() => {
88
+ gesture?.preview(20);
89
+ gesture?.settle();
90
+ gesture?.preview(30);
91
+ gesture?.settle();
92
+ });
93
+ await act(async () => {
94
+ rejectFirst?.(new Error("old save failed"));
95
+ await Promise.resolve();
96
+ });
97
+
98
+ expect(onCommit.mock.calls.map(([value]) => value)).toEqual([20, 30]);
99
+ expect(onPreview).toHaveBeenLastCalledWith(30);
100
+ act(() => root.unmount());
101
+ });
37
102
  });
@@ -1,5 +1,9 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
+ function isPromiseCommit(result: void | Promise<void>): result is Promise<void> {
4
+ return Boolean(result && typeof result.then === "function");
5
+ }
6
+
3
7
  /** One owner for continuous inspector edits: preview freely, persist once. */
4
8
  export function useInspectorGestureTransaction<T>({
5
9
  sourceValue,
@@ -8,44 +12,96 @@ export function useInspectorGestureTransaction<T>({
8
12
  }: {
9
13
  sourceValue: T;
10
14
  onPreview: (value: T) => void;
11
- onCommit: (value: T) => void;
15
+ onCommit: (value: T) => void | Promise<void>;
12
16
  }) {
13
17
  const sourceRef = useRef(sourceValue);
14
18
  const activeRef = useRef<{ before: T; latest: T } | null>(null);
15
19
  const previewRef = useRef(onPreview);
16
20
  const commitRef = useRef(onCommit);
17
- if (!activeRef.current) sourceRef.current = sourceValue;
21
+ const generationRef = useRef(0);
22
+ const pendingRef = useRef<{ before: T; latest: T } | null>(null);
23
+ const awaitingSourceAckRef = useRef<{ generation: number; value: T } | null>(null);
24
+ const lastSourceValueRef = useRef(sourceValue);
25
+ if (!Object.is(lastSourceValueRef.current, sourceValue)) {
26
+ lastSourceValueRef.current = sourceValue;
27
+ const matchesSourceAck = Object.is(awaitingSourceAckRef.current?.value, sourceValue);
28
+ const matchesOptimisticValue =
29
+ (activeRef.current && Object.is(activeRef.current.latest, sourceValue)) ||
30
+ (pendingRef.current && Object.is(pendingRef.current.latest, sourceValue)) ||
31
+ matchesSourceAck;
32
+ if (matchesSourceAck) awaitingSourceAckRef.current = null;
33
+ if (!matchesOptimisticValue) {
34
+ generationRef.current += 1;
35
+ activeRef.current = null;
36
+ pendingRef.current = null;
37
+ awaitingSourceAckRef.current = null;
38
+ sourceRef.current = sourceValue;
39
+ } else {
40
+ sourceRef.current = sourceValue;
41
+ }
42
+ }
18
43
  previewRef.current = onPreview;
19
44
  commitRef.current = onCommit;
20
45
 
21
46
  const begin = useCallback(() => {
22
47
  if (!activeRef.current) {
48
+ generationRef.current += 1;
23
49
  activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
24
50
  }
25
51
  }, []);
26
52
 
27
53
  const preview = useCallback((value: T) => {
28
54
  if (!activeRef.current) {
55
+ generationRef.current += 1;
29
56
  activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
30
57
  }
31
58
  activeRef.current.latest = value;
32
59
  previewRef.current(value);
33
60
  }, []);
34
61
 
62
+ const rollbackCommit = useCallback((active: { before: T; latest: T }, generation: number) => {
63
+ if (generation !== generationRef.current) return;
64
+ pendingRef.current = null;
65
+ if (awaitingSourceAckRef.current?.generation === generation) {
66
+ awaitingSourceAckRef.current = null;
67
+ }
68
+ sourceRef.current = active.before;
69
+ previewRef.current(active.before);
70
+ }, []);
71
+
35
72
  const settle = useCallback(() => {
36
73
  const active = activeRef.current;
37
74
  activeRef.current = null;
38
75
  if (active && !Object.is(active.before, active.latest)) {
76
+ const generation = ++generationRef.current;
39
77
  sourceRef.current = active.latest;
40
- // Restore the captured baseline before the persistent commit captures
41
- // rollback state. The commit reapplies `latest` synchronously, so this
42
- // is not visible but a failed save can now correctly restore `before`.
43
- previewRef.current(active.before);
44
- commitRef.current(active.latest);
78
+ pendingRef.current = active;
79
+ awaitingSourceAckRef.current = { generation, value: active.latest };
80
+ try {
81
+ const result = commitRef.current(active.latest);
82
+ if (isPromiseCommit(result)) {
83
+ void result.then(
84
+ () => {
85
+ if (generation === generationRef.current) pendingRef.current = null;
86
+ },
87
+ () => rollbackCommit(active, generation),
88
+ );
89
+ } else if (generation === generationRef.current) {
90
+ pendingRef.current = null;
91
+ // Synchronous inspector consumers historically restore their preview
92
+ // after persisting (color pickers close, curves release the pointer).
93
+ // Async source mutations keep the optimistic preview until the write
94
+ // resolves so they do not flash back to the baseline while pending.
95
+ previewRef.current(active.before);
96
+ }
97
+ } catch {
98
+ rollbackCommit(active, generation);
99
+ }
45
100
  }
46
- }, []);
101
+ }, [rollbackCommit]);
47
102
 
48
103
  const cancel = useCallback(() => {
104
+ generationRef.current += 1;
49
105
  const active = activeRef.current;
50
106
  activeRef.current = null;
51
107
  if (active && !Object.is(active.before, active.latest)) {
@@ -66,7 +122,7 @@ export function useInspectorGestureDraft<T>({
66
122
  }: {
67
123
  sourceValue: T;
68
124
  onPreview: (value: T) => void;
69
- onCommit: (value: T) => void;
125
+ onCommit: (value: T) => void | Promise<void>;
70
126
  }) {
71
127
  const [draft, setDraft] = useState(sourceValue);
72
128
  const transaction = useInspectorGestureTransaction({
@@ -77,7 +133,7 @@ export function useInspectorGestureDraft<T>({
77
133
  },
78
134
  onCommit: (next) => {
79
135
  setDraft(next);
80
- onCommit(next);
136
+ return onCommit(next);
81
137
  },
82
138
  });
83
139
 
@@ -8,6 +8,7 @@ import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
8
8
  import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
9
9
  import { ContextMenu } from "./AssetContextMenu";
10
10
  import { usePlayerStore } from "../../player/store/playerStore";
11
+ import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
11
12
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
12
13
  import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
13
14
  import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers";
@@ -133,7 +134,7 @@ export function AssetCard({
133
134
  const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
134
135
 
135
136
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
136
- const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
137
+ const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
137
138
  const elements = usePlayerStore((s) => s.elements);
138
139
  const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
139
140
  const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
@@ -158,7 +159,7 @@ export function AssetCard({
158
159
  const clipKey = clip.key ?? clip.id;
159
160
  setSelectedElementId(clipKey);
160
161
  // Scroll the timeline so the selected clip is actually visible.
161
- requestClipReveal(clipKey);
162
+ requestTimelineFocus(timelineClipFocusId(clipKey));
162
163
  return;
163
164
  }
164
165
  }
@@ -171,7 +172,7 @@ export function AssetCard({
171
172
  asset,
172
173
  projectId,
173
174
  setSelectedElementId,
174
- requestClipReveal,
175
+ requestTimelineFocus,
175
176
  setPreviewAsset,
176
177
  clearPreviewAsset,
177
178
  ],
@@ -6,6 +6,7 @@ import { usePlayerStore } from "../../player/store/playerStore";
6
6
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
7
7
  import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
8
8
  import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
9
+ import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
9
10
 
10
11
  export function AudioRow({
11
12
  projectId,
@@ -43,7 +44,7 @@ export function AudioRow({
43
44
  // CapCut-style click behavior: drag-threshold gate.
44
45
  const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
45
46
  const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
46
- const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
47
+ const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
47
48
  const elements = usePlayerStore((s) => s.elements);
48
49
  const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
49
50
  const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
@@ -67,7 +68,7 @@ export function AudioRow({
67
68
  const clipKey = clip.key ?? clip.id;
68
69
  setSelectedElementId(clipKey);
69
70
  // Scroll the timeline so the selected clip is actually visible.
70
- requestClipReveal(clipKey);
71
+ requestTimelineFocus(timelineClipFocusId(clipKey));
71
72
  return;
72
73
  }
73
74
  }
@@ -80,7 +81,7 @@ export function AudioRow({
80
81
  asset,
81
82
  projectId,
82
83
  setSelectedElementId,
83
- requestClipReveal,
84
+ requestTimelineFocus,
84
85
  setPreviewAsset,
85
86
  clearPreviewAsset,
86
87
  ],
@@ -27,9 +27,13 @@ export function FileManagerProvider({
27
27
  editingPathRef,
28
28
  projectIdRef,
29
29
  saveRafRef,
30
+ flushPendingSourceSave,
31
+ discardPendingSourceSave,
32
+ getPendingSourceCandidate,
30
33
  importedFontAssetsRef,
31
34
  readProjectFile,
32
35
  writeProjectFile,
36
+ overwriteExternalConflict,
33
37
  readOptionalProjectFile,
34
38
  observeProjectFileVersion,
35
39
  updateEditingFileContent,
@@ -67,9 +71,13 @@ export function FileManagerProvider({
67
71
  editingPathRef,
68
72
  projectIdRef,
69
73
  saveRafRef,
74
+ flushPendingSourceSave,
75
+ discardPendingSourceSave,
76
+ getPendingSourceCandidate,
70
77
  importedFontAssetsRef,
71
78
  readProjectFile,
72
79
  writeProjectFile,
80
+ overwriteExternalConflict,
73
81
  readOptionalProjectFile,
74
82
  observeProjectFileVersion,
75
83
  updateEditingFileContent,
@@ -101,9 +109,13 @@ export function FileManagerProvider({
101
109
  editingPathRef,
102
110
  projectIdRef,
103
111
  saveRafRef,
112
+ flushPendingSourceSave,
113
+ discardPendingSourceSave,
114
+ getPendingSourceCandidate,
104
115
  importedFontAssetsRef,
105
116
  readProjectFile,
106
117
  writeProjectFile,
118
+ overwriteExternalConflict,
107
119
  readOptionalProjectFile,
108
120
  observeProjectFileVersion,
109
121
  updateEditingFileContent,
@@ -0,0 +1,17 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ describe("external file-change subscription ownership", () => {
5
+ it("has one subscriber instead of independent Preview and SDK listeners", () => {
6
+ const preview = readFileSync(new URL("./usePreviewPersistence.ts", import.meta.url), "utf8");
7
+ const sdk = readFileSync(new URL("./useSdkSession.ts", import.meta.url), "utf8");
8
+ const coordinator = readFileSync(
9
+ new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
10
+ "utf8",
11
+ );
12
+
13
+ expect(preview).not.toContain('hot.on("hf:file-change"');
14
+ expect(sdk).not.toContain('hot.on("hf:file-change"');
15
+ expect(coordinator.match(/hot\.on\("hf:file-change"/g)).toHaveLength(1);
16
+ });
17
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { addExternalFileReloadListener, notifyExternalFileReload } from "./externalFileReloadBus";
3
+
4
+ describe("external file reload bus", () => {
5
+ it("lets the sole watcher reload every SDK owner of the changed path", () => {
6
+ const first = vi.fn();
7
+ const second = vi.fn();
8
+ const removeFirst = addExternalFileReloadListener(first);
9
+ const removeSecond = addExternalFileReloadListener(second);
10
+
11
+ notifyExternalFileReload("scenes/card.html");
12
+ expect(first).toHaveBeenCalledWith("scenes/card.html");
13
+ expect(second).toHaveBeenCalledWith("scenes/card.html");
14
+
15
+ removeFirst();
16
+ removeSecond();
17
+ });
18
+
19
+ it("isolates a broken listener so later SDK owners still reload", () => {
20
+ const broken = vi.fn(() => {
21
+ throw new Error("stale owner");
22
+ });
23
+ const healthy = vi.fn();
24
+ const removeBroken = addExternalFileReloadListener(broken);
25
+ const removeHealthy = addExternalFileReloadListener(healthy);
26
+
27
+ expect(() => notifyExternalFileReload("scenes/card.html")).not.toThrow();
28
+ expect(broken).toHaveBeenCalledWith("scenes/card.html");
29
+ expect(healthy).toHaveBeenCalledWith("scenes/card.html");
30
+
31
+ removeBroken();
32
+ removeHealthy();
33
+ });
34
+ });
@@ -0,0 +1,18 @@
1
+ type ExternalFileReloadListener = (path: string) => void;
2
+
3
+ const listeners = new Set<ExternalFileReloadListener>();
4
+
5
+ export function addExternalFileReloadListener(listener: ExternalFileReloadListener): () => void {
6
+ listeners.add(listener);
7
+ return () => listeners.delete(listener);
8
+ }
9
+
10
+ export function notifyExternalFileReload(path: string): void {
11
+ for (const listener of listeners) {
12
+ try {
13
+ listener(path);
14
+ } catch {
15
+ // A stale SDK owner must not prevent sibling owners from reloading.
16
+ }
17
+ }
18
+ }