@hyperframes/studio 0.5.0-alpha.10 → 0.5.0-alpha.12
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/index-JhhmFie-.js +105 -0
- package/dist/assets/index-KioPDrX6.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +371 -149
- package/src/components/nle/NLELayout.tsx +43 -8
- package/src/components/sidebar/LeftSidebar.tsx +64 -36
- package/src/hooks/usePersistentEditHistory.test.ts +255 -0
- package/src/hooks/usePersistentEditHistory.ts +336 -0
- package/src/icons/SystemIcons.tsx +4 -0
- package/src/player/components/PlayerControls.tsx +3 -44
- package/src/player/components/Timeline.tsx +5 -2
- package/src/player/components/TimelineClip.tsx +2 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +139 -0
- package/src/player/hooks/useTimelinePlayer.ts +201 -89
- package/src/player/lib/time.test.ts +11 -1
- package/src/player/lib/time.ts +6 -0
- package/src/player/store/playerStore.ts +1 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +38 -0
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/dist/assets/index-DKaNgV2Z.css +0 -1
- package/dist/assets/index-peNJzL-4.js +0 -105
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
buildEditHistoryEntry,
|
|
4
|
+
createEmptyEditHistory,
|
|
5
|
+
hashEditHistoryContent,
|
|
6
|
+
pushEditHistoryEntry,
|
|
7
|
+
redoEditHistory,
|
|
8
|
+
undoEditHistory,
|
|
9
|
+
type BuildEditHistoryEntryInput,
|
|
10
|
+
type EditHistoryKind,
|
|
11
|
+
type EditHistoryState,
|
|
12
|
+
} from "../utils/editHistory";
|
|
13
|
+
import {
|
|
14
|
+
createIndexedDbEditHistoryStorage,
|
|
15
|
+
loadEditHistoryState,
|
|
16
|
+
saveEditHistoryState,
|
|
17
|
+
type EditHistoryStorageAdapter,
|
|
18
|
+
} from "../utils/editHistoryStorage";
|
|
19
|
+
|
|
20
|
+
interface RecordEditInput {
|
|
21
|
+
label: string;
|
|
22
|
+
kind: EditHistoryKind;
|
|
23
|
+
coalesceKey?: string;
|
|
24
|
+
files: BuildEditHistoryEntryInput["files"];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ApplyCallbacks {
|
|
28
|
+
readFile: (path: string) => Promise<string>;
|
|
29
|
+
writeFile: (path: string, content: string) => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface UsePersistentEditHistoryOptions {
|
|
33
|
+
projectId: string | null;
|
|
34
|
+
storage?: EditHistoryStorageAdapter;
|
|
35
|
+
now?: () => number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ApplyResult {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
reason?: "empty" | "content-mismatch";
|
|
41
|
+
label?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PersistentEditHistoryStoreOptions {
|
|
45
|
+
projectId: string;
|
|
46
|
+
storage: EditHistoryStorageAdapter;
|
|
47
|
+
initialState: EditHistoryState;
|
|
48
|
+
now?: () => number;
|
|
49
|
+
onChange: (state: EditHistoryState) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type EditHistoryMutation<T> = (state: EditHistoryState) => Promise<{
|
|
53
|
+
state: EditHistoryState;
|
|
54
|
+
result: T;
|
|
55
|
+
}>;
|
|
56
|
+
|
|
57
|
+
function createEntryId(now: number): string {
|
|
58
|
+
return `edit-${now.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function snapshotEditHistoryState(state: EditHistoryState) {
|
|
62
|
+
const undoEntry = state.undo[state.undo.length - 1] ?? null;
|
|
63
|
+
const redoEntry = state.redo[state.redo.length - 1] ?? null;
|
|
64
|
+
return {
|
|
65
|
+
canUndo: Boolean(undoEntry),
|
|
66
|
+
canRedo: Boolean(redoEntry),
|
|
67
|
+
undoLabel: undoEntry?.label ?? null,
|
|
68
|
+
redoLabel: redoEntry?.label ?? null,
|
|
69
|
+
undoPaths: undoEntry ? Object.keys(undoEntry.files) : [],
|
|
70
|
+
redoPaths: redoEntry ? Object.keys(redoEntry.files) : [],
|
|
71
|
+
state,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readCurrentFileHashes(
|
|
76
|
+
paths: string[],
|
|
77
|
+
readFile: (path: string) => Promise<string>,
|
|
78
|
+
): Promise<{
|
|
79
|
+
currentFiles: Record<string, string>;
|
|
80
|
+
currentHashes: Record<string, string>;
|
|
81
|
+
}> {
|
|
82
|
+
const currentFiles: Record<string, string> = {};
|
|
83
|
+
const currentHashes: Record<string, string> = {};
|
|
84
|
+
for (const path of paths) {
|
|
85
|
+
const content = await readFile(path);
|
|
86
|
+
currentFiles[path] = content;
|
|
87
|
+
currentHashes[path] = hashEditHistoryContent(content);
|
|
88
|
+
}
|
|
89
|
+
return { currentFiles, currentHashes };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function writeFilesWithRollback({
|
|
93
|
+
files,
|
|
94
|
+
rollbackFiles,
|
|
95
|
+
writeFile,
|
|
96
|
+
}: {
|
|
97
|
+
files: Record<string, string>;
|
|
98
|
+
rollbackFiles: Record<string, string>;
|
|
99
|
+
writeFile: (path: string, content: string) => Promise<void>;
|
|
100
|
+
}): Promise<void> {
|
|
101
|
+
const writtenPaths: string[] = [];
|
|
102
|
+
try {
|
|
103
|
+
for (const [path, content] of Object.entries(files)) {
|
|
104
|
+
await writeFile(path, content);
|
|
105
|
+
writtenPaths.push(path);
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
try {
|
|
109
|
+
for (const path of writtenPaths.reverse()) {
|
|
110
|
+
await writeFile(path, rollbackFiles[path]);
|
|
111
|
+
}
|
|
112
|
+
} catch (rollbackError) {
|
|
113
|
+
throw new AggregateError(
|
|
114
|
+
[error, rollbackError],
|
|
115
|
+
"Failed to apply edit history and rollback did not complete",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createPersistentEditHistoryStore({
|
|
123
|
+
projectId,
|
|
124
|
+
storage,
|
|
125
|
+
initialState,
|
|
126
|
+
now = Date.now,
|
|
127
|
+
onChange,
|
|
128
|
+
}: PersistentEditHistoryStoreOptions) {
|
|
129
|
+
let state = initialState;
|
|
130
|
+
let queue = Promise.resolve();
|
|
131
|
+
|
|
132
|
+
const save = async (nextState: EditHistoryState) => {
|
|
133
|
+
state = nextState;
|
|
134
|
+
onChange(nextState);
|
|
135
|
+
try {
|
|
136
|
+
await saveEditHistoryState(storage, projectId, nextState);
|
|
137
|
+
} catch {
|
|
138
|
+
// Keep in-memory history usable when IndexedDB is unavailable.
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const mutate = async <T>(mutation: EditHistoryMutation<T>): Promise<T> => {
|
|
143
|
+
const run = queue.then(async () => {
|
|
144
|
+
const { state: nextState, result } = await mutation(state);
|
|
145
|
+
if (nextState !== state) await save(nextState);
|
|
146
|
+
return result;
|
|
147
|
+
});
|
|
148
|
+
queue = run.then(
|
|
149
|
+
() => undefined,
|
|
150
|
+
() => undefined,
|
|
151
|
+
);
|
|
152
|
+
return run;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
snapshot: () => snapshotEditHistoryState(state),
|
|
157
|
+
async recordEdit(input: RecordEditInput) {
|
|
158
|
+
await mutate<void>(async (currentState) => {
|
|
159
|
+
const timestamp = now();
|
|
160
|
+
const entry = buildEditHistoryEntry({
|
|
161
|
+
...input,
|
|
162
|
+
id: createEntryId(timestamp),
|
|
163
|
+
projectId,
|
|
164
|
+
now: timestamp,
|
|
165
|
+
});
|
|
166
|
+
return {
|
|
167
|
+
state: pushEditHistoryEntry(currentState, entry),
|
|
168
|
+
result: undefined,
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
async undo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
|
|
173
|
+
return mutate<ApplyResult>(async (currentState) => {
|
|
174
|
+
const entry = currentState.undo[currentState.undo.length - 1];
|
|
175
|
+
if (!entry) {
|
|
176
|
+
return {
|
|
177
|
+
state: currentState,
|
|
178
|
+
result: { ok: false, reason: "empty" },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const { currentFiles, currentHashes } = await readCurrentFileHashes(
|
|
182
|
+
Object.keys(entry.files),
|
|
183
|
+
callbacks.readFile,
|
|
184
|
+
);
|
|
185
|
+
const result = undoEditHistory(currentState, currentHashes, now());
|
|
186
|
+
if (!result.ok) {
|
|
187
|
+
return {
|
|
188
|
+
state: currentState,
|
|
189
|
+
result: { ok: false, reason: result.reason },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
await writeFilesWithRollback({
|
|
193
|
+
files: result.filesToWrite,
|
|
194
|
+
rollbackFiles: currentFiles,
|
|
195
|
+
writeFile: callbacks.writeFile,
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
state: result.state,
|
|
199
|
+
result: { ok: true, label: result.entry.label },
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
async redo(callbacks: ApplyCallbacks): Promise<ApplyResult> {
|
|
204
|
+
return mutate<ApplyResult>(async (currentState) => {
|
|
205
|
+
const entry = currentState.redo[currentState.redo.length - 1];
|
|
206
|
+
if (!entry) {
|
|
207
|
+
return {
|
|
208
|
+
state: currentState,
|
|
209
|
+
result: { ok: false, reason: "empty" },
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const { currentFiles, currentHashes } = await readCurrentFileHashes(
|
|
213
|
+
Object.keys(entry.files),
|
|
214
|
+
callbacks.readFile,
|
|
215
|
+
);
|
|
216
|
+
const result = redoEditHistory(currentState, currentHashes, now());
|
|
217
|
+
if (!result.ok) {
|
|
218
|
+
return {
|
|
219
|
+
state: currentState,
|
|
220
|
+
result: { ok: false, reason: result.reason },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
await writeFilesWithRollback({
|
|
224
|
+
files: result.filesToWrite,
|
|
225
|
+
rollbackFiles: currentFiles,
|
|
226
|
+
writeFile: callbacks.writeFile,
|
|
227
|
+
});
|
|
228
|
+
return {
|
|
229
|
+
state: result.state,
|
|
230
|
+
result: { ok: true, label: result.entry.label },
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function createPersistentEditHistoryController({
|
|
238
|
+
projectId,
|
|
239
|
+
storage,
|
|
240
|
+
now = Date.now,
|
|
241
|
+
onChange,
|
|
242
|
+
}: {
|
|
243
|
+
projectId: string;
|
|
244
|
+
storage: EditHistoryStorageAdapter;
|
|
245
|
+
now?: () => number;
|
|
246
|
+
onChange: (state: EditHistoryState) => void;
|
|
247
|
+
}) {
|
|
248
|
+
let state = await loadEditHistoryState(storage, projectId);
|
|
249
|
+
const store = createPersistentEditHistoryStore({
|
|
250
|
+
projectId,
|
|
251
|
+
storage,
|
|
252
|
+
initialState: state,
|
|
253
|
+
now,
|
|
254
|
+
onChange: (nextState) => {
|
|
255
|
+
state = nextState;
|
|
256
|
+
onChange(nextState);
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
return store;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function usePersistentEditHistory(options: UsePersistentEditHistoryOptions) {
|
|
264
|
+
const storage = useMemo(
|
|
265
|
+
() => options.storage ?? createIndexedDbEditHistoryStorage(),
|
|
266
|
+
[options.storage],
|
|
267
|
+
);
|
|
268
|
+
const now = options.now ?? Date.now;
|
|
269
|
+
const [state, setState] = useState<EditHistoryState>(() => createEmptyEditHistory());
|
|
270
|
+
const [loaded, setLoaded] = useState(false);
|
|
271
|
+
const projectId = options.projectId;
|
|
272
|
+
const storeRef = useRef<ReturnType<typeof createPersistentEditHistoryStore> | null>(null);
|
|
273
|
+
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
let cancelled = false;
|
|
276
|
+
const emptyState = createEmptyEditHistory();
|
|
277
|
+
storeRef.current = null;
|
|
278
|
+
setState(emptyState);
|
|
279
|
+
setLoaded(false);
|
|
280
|
+
if (!projectId) {
|
|
281
|
+
setLoaded(true);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
loadEditHistoryState(storage, projectId)
|
|
286
|
+
.then((loadedState) => {
|
|
287
|
+
if (cancelled) return;
|
|
288
|
+
storeRef.current = createPersistentEditHistoryStore({
|
|
289
|
+
projectId,
|
|
290
|
+
storage,
|
|
291
|
+
initialState: loadedState,
|
|
292
|
+
now,
|
|
293
|
+
onChange: setState,
|
|
294
|
+
});
|
|
295
|
+
setState(loadedState);
|
|
296
|
+
})
|
|
297
|
+
.catch(() => {
|
|
298
|
+
if (cancelled) return;
|
|
299
|
+
storeRef.current = createPersistentEditHistoryStore({
|
|
300
|
+
projectId,
|
|
301
|
+
storage,
|
|
302
|
+
initialState: emptyState,
|
|
303
|
+
now,
|
|
304
|
+
onChange: setState,
|
|
305
|
+
});
|
|
306
|
+
setState(emptyState);
|
|
307
|
+
})
|
|
308
|
+
.finally(() => {
|
|
309
|
+
if (!cancelled) setLoaded(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
return () => {
|
|
313
|
+
cancelled = true;
|
|
314
|
+
};
|
|
315
|
+
}, [now, projectId, storage]);
|
|
316
|
+
|
|
317
|
+
const recordEdit = useCallback(async (input: RecordEditInput) => {
|
|
318
|
+
await storeRef.current?.recordEdit(input);
|
|
319
|
+
}, []);
|
|
320
|
+
|
|
321
|
+
const undo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
|
322
|
+
return storeRef.current?.undo(callbacks) ?? { ok: false, reason: "empty" };
|
|
323
|
+
}, []);
|
|
324
|
+
|
|
325
|
+
const redo = useCallback(async (callbacks: ApplyCallbacks): Promise<ApplyResult> => {
|
|
326
|
+
return storeRef.current?.redo(callbacks) ?? { ok: false, reason: "empty" };
|
|
327
|
+
}, []);
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
loaded,
|
|
331
|
+
...snapshotEditHistoryState(state),
|
|
332
|
+
recordEdit,
|
|
333
|
+
undo,
|
|
334
|
+
redo,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
@@ -53,6 +53,8 @@ import {
|
|
|
53
53
|
CaretRight,
|
|
54
54
|
ClipboardText,
|
|
55
55
|
ArrowCounterClockwise,
|
|
56
|
+
Camera as PhCamera,
|
|
57
|
+
ArrowClockwise,
|
|
56
58
|
Gear,
|
|
57
59
|
} from "@phosphor-icons/react";
|
|
58
60
|
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
|
|
@@ -127,4 +129,6 @@ export const ChevronDown = makeIcon(CaretDown);
|
|
|
127
129
|
export const ChevronRight = makeIcon(CaretRight);
|
|
128
130
|
export const ClipboardList = makeIcon(ClipboardText);
|
|
129
131
|
export const RotateCcw = makeIcon(ArrowCounterClockwise);
|
|
132
|
+
export const Camera = makeIcon(PhCamera);
|
|
133
|
+
export const RotateCw = makeIcon(ArrowClockwise);
|
|
130
134
|
export const Settings = makeIcon(Gear);
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { useRef, useState, useCallback, useEffect, memo } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
-
import {
|
|
4
|
-
TIMELINE_TOGGLE_SHORTCUT_LABEL,
|
|
5
|
-
getTimelineToggleTitle,
|
|
6
|
-
} from "../../utils/timelineDiscovery";
|
|
7
|
-
import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
|
|
3
|
+
import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
|
|
8
4
|
import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
9
5
|
|
|
10
6
|
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
|
@@ -30,15 +26,11 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
|
|
|
30
26
|
interface PlayerControlsProps {
|
|
31
27
|
onTogglePlay: () => void;
|
|
32
28
|
onSeek: (time: number) => void;
|
|
33
|
-
timelineVisible?: boolean;
|
|
34
|
-
onToggleTimeline?: () => void;
|
|
35
29
|
}
|
|
36
30
|
|
|
37
31
|
export const PlayerControls = memo(function PlayerControls({
|
|
38
32
|
onTogglePlay,
|
|
39
33
|
onSeek,
|
|
40
|
-
timelineVisible,
|
|
41
|
-
onToggleTimeline,
|
|
42
34
|
}: PlayerControlsProps) {
|
|
43
35
|
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
|
44
36
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
@@ -216,10 +208,10 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
216
208
|
const step = e.shiftKey ? 10 : 1;
|
|
217
209
|
if (e.key === "ArrowLeft") {
|
|
218
210
|
e.preventDefault();
|
|
219
|
-
onSeek(
|
|
211
|
+
onSeek(stepFrameTime(currentTimeRef.current, -step));
|
|
220
212
|
} else if (e.key === "ArrowRight") {
|
|
221
213
|
e.preventDefault();
|
|
222
|
-
onSeek(Math.min(duration, currentTimeRef.current
|
|
214
|
+
onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
|
|
223
215
|
}
|
|
224
216
|
},
|
|
225
217
|
[timelineReady, duration, onSeek],
|
|
@@ -437,39 +429,6 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
437
429
|
</span>
|
|
438
430
|
))}
|
|
439
431
|
</div>
|
|
440
|
-
|
|
441
|
-
{/* Timeline toggle */}
|
|
442
|
-
{onToggleTimeline !== undefined && (
|
|
443
|
-
<button
|
|
444
|
-
type="button"
|
|
445
|
-
onClick={onToggleTimeline}
|
|
446
|
-
className={`h-7 flex items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-medium transition-colors ${
|
|
447
|
-
timelineVisible
|
|
448
|
-
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
449
|
-
: "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
|
|
450
|
-
}`}
|
|
451
|
-
title={getTimelineToggleTitle(Boolean(timelineVisible))}
|
|
452
|
-
aria-label={timelineVisible ? "Hide timeline editor" : "Show timeline editor"}
|
|
453
|
-
>
|
|
454
|
-
<svg
|
|
455
|
-
width="13"
|
|
456
|
-
height="13"
|
|
457
|
-
viewBox="0 0 24 24"
|
|
458
|
-
fill="none"
|
|
459
|
-
stroke="currentColor"
|
|
460
|
-
strokeWidth="2"
|
|
461
|
-
strokeLinecap="round"
|
|
462
|
-
>
|
|
463
|
-
<rect x="3" y="13" width="18" height="8" rx="1" />
|
|
464
|
-
<line x1="3" y1="9" x2="21" y2="9" />
|
|
465
|
-
<line x1="3" y1="5" x2="21" y2="5" />
|
|
466
|
-
</svg>
|
|
467
|
-
<span>Timeline</span>
|
|
468
|
-
<span className="hidden md:inline rounded bg-black/20 px-1 py-0.5 text-[9px] font-mono opacity-70">
|
|
469
|
-
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
|
|
470
|
-
</span>
|
|
471
|
-
</button>
|
|
472
|
-
)}
|
|
473
432
|
</div>
|
|
474
433
|
);
|
|
475
434
|
});
|
|
@@ -1046,7 +1046,10 @@ export const Timeline = memo(function Timeline({
|
|
|
1046
1046
|
|
|
1047
1047
|
const getPreviewElement = useCallback(
|
|
1048
1048
|
(element: TimelineElement): TimelineElement => {
|
|
1049
|
-
if (
|
|
1049
|
+
if (
|
|
1050
|
+
resizingClip &&
|
|
1051
|
+
(resizingClip.element.key ?? resizingClip.element.id) === (element.key ?? element.id)
|
|
1052
|
+
) {
|
|
1050
1053
|
return {
|
|
1051
1054
|
...element,
|
|
1052
1055
|
start: resizingClip.previewStart,
|
|
@@ -1273,7 +1276,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1273
1276
|
draggedClip?.started === true && draggedElement
|
|
1274
1277
|
? getRenderedTimelineElement({
|
|
1275
1278
|
element: draggedElement,
|
|
1276
|
-
draggedElementId: draggedElement.id,
|
|
1279
|
+
draggedElementId: draggedElement.key ?? draggedElement.id,
|
|
1277
1280
|
previewStart: draggedClip.previewStart,
|
|
1278
1281
|
previewTrack: draggedClip.previewTrack,
|
|
1279
1282
|
})
|
|
@@ -61,6 +61,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
61
61
|
? theme.clipShadowHover
|
|
62
62
|
: theme.clipShadow;
|
|
63
63
|
const capabilities = getTimelineEditCapabilities(el);
|
|
64
|
+
const displayLabel = el.label || el.id || el.tag;
|
|
64
65
|
const showHandles = handleOpacity > 0.01;
|
|
65
66
|
const baseBackgroundImage = isSelected ? theme.clipBackgroundActive : theme.clipBackground;
|
|
66
67
|
const glossBackgroundImage = isSelected
|
|
@@ -106,7 +107,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
106
107
|
title={
|
|
107
108
|
isComposition
|
|
108
109
|
? `${el.compositionSrc} \u2022 Double-click to open`
|
|
109
|
-
: `${
|
|
110
|
+
: `${displayLabel} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
|
|
110
111
|
}
|
|
111
112
|
onPointerEnter={onHoverStart}
|
|
112
113
|
onPointerLeave={onHoverEnd}
|
|
@@ -53,4 +53,23 @@ describe("getRenderedTimelineElement", () => {
|
|
|
53
53
|
}),
|
|
54
54
|
).toEqual({ ...element, start: 2.4, track: 3 });
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
it("uses key before id when matching the dragged clip", () => {
|
|
58
|
+
const element = {
|
|
59
|
+
id: "Card",
|
|
60
|
+
key: "index.html:.card:1",
|
|
61
|
+
tag: "div",
|
|
62
|
+
start: 1,
|
|
63
|
+
duration: 2,
|
|
64
|
+
track: 0,
|
|
65
|
+
};
|
|
66
|
+
expect(
|
|
67
|
+
getRenderedTimelineElement({
|
|
68
|
+
element,
|
|
69
|
+
draggedElementId: "index.html:.card:1",
|
|
70
|
+
previewStart: 2.4,
|
|
71
|
+
previewTrack: 3,
|
|
72
|
+
}),
|
|
73
|
+
).toEqual({ ...element, start: 2.4, track: 3 });
|
|
74
|
+
});
|
|
56
75
|
});
|
|
@@ -63,12 +63,12 @@ const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
|
|
|
63
63
|
const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
|
|
64
64
|
|
|
65
65
|
export const defaultTimelineTheme: TimelineTheme = {
|
|
66
|
-
shellBackground: "#
|
|
66
|
+
shellBackground: "#0A0A0B",
|
|
67
67
|
shellBorder: "rgba(255,255,255,0.05)",
|
|
68
68
|
rulerBorder: "rgba(255,255,255,0.045)",
|
|
69
|
-
rowBackground: "#
|
|
69
|
+
rowBackground: "#0A0A0B",
|
|
70
70
|
rowBorder: "rgba(255,255,255,0.05)",
|
|
71
|
-
gutterBackground: "#
|
|
71
|
+
gutterBackground: "#0A0A0B",
|
|
72
72
|
gutterBorder: "rgba(255,255,255,0.05)",
|
|
73
73
|
textPrimary: "#E8EDF5",
|
|
74
74
|
textSecondary: "#8391A8",
|
|
@@ -130,7 +130,11 @@ export function getRenderedTimelineElement({
|
|
|
130
130
|
previewStart: number | null;
|
|
131
131
|
previewTrack: number | null;
|
|
132
132
|
}): TimelineElement {
|
|
133
|
-
if (
|
|
133
|
+
if (
|
|
134
|
+
(element.key ?? element.id) !== draggedElementId ||
|
|
135
|
+
previewStart === null ||
|
|
136
|
+
previewTrack === null
|
|
137
|
+
) {
|
|
134
138
|
return element;
|
|
135
139
|
}
|
|
136
140
|
return {
|
|
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import { Window } from "happy-dom";
|
|
3
3
|
import {
|
|
4
4
|
buildStandaloneRootTimelineElement,
|
|
5
|
+
createTimelineElementFromManifestClip,
|
|
5
6
|
findTimelineDomNodeForClip,
|
|
6
7
|
getTimelineElementSelector,
|
|
8
|
+
parseTimelineFromDOM,
|
|
7
9
|
type ClipManifestClip,
|
|
8
10
|
mergeTimelineElementsPreservingDowngrades,
|
|
9
11
|
resolveStandaloneRootCompositionSrc,
|
|
@@ -66,6 +68,7 @@ describe("buildStandaloneRootTimelineElement", () => {
|
|
|
66
68
|
}),
|
|
67
69
|
).toEqual({
|
|
68
70
|
id: "hero",
|
|
71
|
+
label: "hero",
|
|
69
72
|
key: 'scenes/hero.html:[data-composition-id="hero"]:0',
|
|
70
73
|
tag: "div",
|
|
71
74
|
start: 0,
|
|
@@ -146,6 +149,83 @@ describe("findTimelineDomNodeForClip", () => {
|
|
|
146
149
|
});
|
|
147
150
|
});
|
|
148
151
|
|
|
152
|
+
describe("anonymous timeline identity", () => {
|
|
153
|
+
it("keeps fallback-parsed anonymous clips distinct when labels match", () => {
|
|
154
|
+
const doc = createDocument(`
|
|
155
|
+
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
156
|
+
<div class="clip card" data-label="Card" data-start="0" data-duration="3" data-track-index="0"></div>
|
|
157
|
+
<div class="clip card" data-label="Card" data-start="3" data-duration="3" data-track-index="1"></div>
|
|
158
|
+
</div>
|
|
159
|
+
`);
|
|
160
|
+
|
|
161
|
+
const elements = parseTimelineFromDOM(doc, 8);
|
|
162
|
+
|
|
163
|
+
expect(elements).toHaveLength(2);
|
|
164
|
+
expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
|
|
165
|
+
expect(new Set(elements.map((element) => element.id)).size).toBe(2);
|
|
166
|
+
expect(new Set(elements.map((element) => element.key)).size).toBe(2);
|
|
167
|
+
expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("keeps runtime-manifest anonymous clips distinct when labels match", () => {
|
|
171
|
+
const doc = createDocument(`
|
|
172
|
+
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
173
|
+
<div class="clip card" data-start="0" data-duration="3" data-track-index="0"></div>
|
|
174
|
+
<div class="clip card" data-start="3" data-duration="3" data-track-index="1"></div>
|
|
175
|
+
</div>
|
|
176
|
+
`);
|
|
177
|
+
const clips = [
|
|
178
|
+
createClip({ id: null, label: "Card", start: 0, duration: 3, track: 0 }),
|
|
179
|
+
createClip({ id: null, label: "Card", start: 3, duration: 3, track: 1 }),
|
|
180
|
+
];
|
|
181
|
+
const used = new Set<Element>();
|
|
182
|
+
const elements = clips.map((clip, index) => {
|
|
183
|
+
const hostEl = findTimelineDomNodeForClip(doc, clip, index, used);
|
|
184
|
+
if (hostEl) used.add(hostEl);
|
|
185
|
+
return createTimelineElementFromManifestClip({
|
|
186
|
+
clip,
|
|
187
|
+
fallbackIndex: index,
|
|
188
|
+
doc,
|
|
189
|
+
hostEl,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(elements.map((element) => element.label)).toEqual(["Card", "Card"]);
|
|
194
|
+
expect(new Set(elements.map((element) => element.id)).size).toBe(2);
|
|
195
|
+
expect(new Set(elements.map((element) => element.key)).size).toBe(2);
|
|
196
|
+
expect(elements.map((element) => element.selectorIndex)).toEqual([0, 1]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("reads media metadata from owner-window media elements", () => {
|
|
200
|
+
const doc = createDocument(`
|
|
201
|
+
<div data-composition-id="main" data-start="0" data-duration="8">
|
|
202
|
+
<div class="clip video-card" data-start="0" data-duration="3" data-track-index="0">
|
|
203
|
+
<video src="/clip.mp4" data-source-duration="12"></video>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
`);
|
|
207
|
+
const hostEl = doc.querySelector(".video-card");
|
|
208
|
+
const video = hostEl?.querySelector("video");
|
|
209
|
+
if (!hostEl || !video) throw new Error("missing video test fixture");
|
|
210
|
+
Object.defineProperty(video, "defaultPlaybackRate", {
|
|
211
|
+
value: 1.5,
|
|
212
|
+
configurable: true,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const element = createTimelineElementFromManifestClip({
|
|
216
|
+
clip: createClip({ kind: "video", tagName: "div" }),
|
|
217
|
+
fallbackIndex: 0,
|
|
218
|
+
doc,
|
|
219
|
+
hostEl,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(element.tag).toBe("video");
|
|
223
|
+
expect(element.src).toBe("/clip.mp4");
|
|
224
|
+
expect(element.sourceDuration).toBe(12);
|
|
225
|
+
expect(element.playbackRate).toBe(1.5);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
149
229
|
describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
150
230
|
it("preserves missing current elements when a shorter manifest arrives", () => {
|
|
151
231
|
expect(
|
|
@@ -174,6 +254,65 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
|
|
|
174
254
|
),
|
|
175
255
|
).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
|
|
176
256
|
});
|
|
257
|
+
|
|
258
|
+
it("preserves distinct anonymous clips that share the same friendly id label", () => {
|
|
259
|
+
expect(
|
|
260
|
+
mergeTimelineElementsPreservingDowngrades(
|
|
261
|
+
[
|
|
262
|
+
{
|
|
263
|
+
id: "Card",
|
|
264
|
+
key: "index.html:.card:0",
|
|
265
|
+
label: "Card",
|
|
266
|
+
tag: "div",
|
|
267
|
+
start: 0,
|
|
268
|
+
duration: 3,
|
|
269
|
+
track: 0,
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
id: "Card",
|
|
273
|
+
key: "index.html:.card:1",
|
|
274
|
+
label: "Card",
|
|
275
|
+
tag: "div",
|
|
276
|
+
start: 3,
|
|
277
|
+
duration: 3,
|
|
278
|
+
track: 1,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
[
|
|
282
|
+
{
|
|
283
|
+
id: "Card",
|
|
284
|
+
key: "index.html:.card:0",
|
|
285
|
+
label: "Card",
|
|
286
|
+
tag: "div",
|
|
287
|
+
start: 0,
|
|
288
|
+
duration: 3,
|
|
289
|
+
track: 0,
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
8,
|
|
293
|
+
8,
|
|
294
|
+
),
|
|
295
|
+
).toEqual([
|
|
296
|
+
{
|
|
297
|
+
id: "Card",
|
|
298
|
+
key: "index.html:.card:0",
|
|
299
|
+
label: "Card",
|
|
300
|
+
tag: "div",
|
|
301
|
+
start: 0,
|
|
302
|
+
duration: 3,
|
|
303
|
+
track: 0,
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
id: "Card",
|
|
307
|
+
key: "index.html:.card:1",
|
|
308
|
+
label: "Card",
|
|
309
|
+
tag: "div",
|
|
310
|
+
start: 3,
|
|
311
|
+
duration: 3,
|
|
312
|
+
track: 1,
|
|
313
|
+
},
|
|
314
|
+
]);
|
|
315
|
+
});
|
|
177
316
|
});
|
|
178
317
|
|
|
179
318
|
describe("shouldIgnorePlaybackShortcutTarget", () => {
|