@hyperframes/studio 0.5.4 → 0.6.0-alpha.1
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-Cd8vYWxP.js +198 -0
- package/dist/assets/index-D04_ZoMm.js +107 -0
- package/dist/assets/index-UWFaHilT.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2621 -170
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.test.ts +241 -0
- package/src/components/editor/DomEditOverlay.tsx +1300 -0
- package/src/components/editor/MotionPanel.tsx +651 -0
- package/src/components/editor/PropertyPanel.test.ts +67 -0
- package/src/components/editor/PropertyPanel.tsx +2891 -207
- package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
- package/src/components/editor/TimelineLayerPanel.tsx +113 -0
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +872 -0
- package/src/components/editor/domEditing.ts +993 -0
- package/src/components/editor/floatingPanel.test.ts +34 -0
- package/src/components/editor/floatingPanel.ts +54 -0
- package/src/components/editor/fontAssets.ts +32 -0
- package/src/components/editor/fontCatalog.ts +126 -0
- package/src/components/editor/gradientValue.test.ts +89 -0
- package/src/components/editor/gradientValue.ts +445 -0
- package/src/components/editor/manualEditingAvailability.test.ts +120 -0
- package/src/components/editor/manualEditingAvailability.ts +60 -0
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1397 -0
- package/src/components/editor/manualOffsetDrag.test.ts +140 -0
- package/src/components/editor/manualOffsetDrag.ts +307 -0
- package/src/components/editor/studioMotion.test.ts +355 -0
- package/src/components/editor/studioMotion.ts +632 -0
- package/src/components/nle/NLELayout.tsx +27 -4
- package/src/components/nle/NLEPreview.tsx +50 -5
- package/src/components/renders/RenderQueue.tsx +13 -62
- package/src/components/renders/useRenderQueue.ts +6 -30
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/CompositionsTab.test.ts +16 -1
- package/src/components/sidebar/CompositionsTab.tsx +117 -45
- package/src/components/sidebar/LeftSidebar.tsx +140 -125
- package/src/hooks/usePersistentEditHistory.test.ts +256 -0
- package/src/hooks/usePersistentEditHistory.ts +337 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +50 -13
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.tsx +18 -2
- package/src/player/components/Timeline.test.ts +20 -0
- package/src/player/components/Timeline.tsx +103 -21
- package/src/player/components/TimelineClip.test.ts +92 -0
- package/src/player/components/TimelineClip.tsx +241 -7
- package/src/player/components/timelineEditing.test.ts +16 -3
- package/src/player/components/timelineEditing.ts +10 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
- package/src/player/hooks/useTimelinePlayer.ts +287 -16
- package/src/player/store/playerStore.ts +2 -0
- package/src/utils/clipboard.test.ts +89 -0
- package/src/utils/clipboard.ts +57 -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/mediaTypes.ts +1 -1
- package/src/utils/sourcePatcher.test.ts +128 -1
- package/src/utils/sourcePatcher.ts +130 -18
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/src/utils/timelineInspector.test.ts +79 -0
- package/src/utils/timelineInspector.ts +116 -0
- package/dist/assets/hyperframes-player-CEnWY28J.js +0 -417
- package/dist/assets/index-04Mp2wOn.css +0 -1
- package/dist/assets/index-960mgQMI.js +0 -93
|
@@ -28,6 +28,11 @@ import {
|
|
|
28
28
|
} from "./timelineTheme";
|
|
29
29
|
import { getPinchTimelineZoomPercent, getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
30
30
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
31
|
+
import {
|
|
32
|
+
canInspectTimelineElement,
|
|
33
|
+
getTimelineElementKey,
|
|
34
|
+
isAudioTimelineElement,
|
|
35
|
+
} from "../../utils/timelineInspector";
|
|
31
36
|
|
|
32
37
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
|
33
38
|
const GUTTER = 32;
|
|
@@ -35,7 +40,7 @@ const TRACK_H = 72;
|
|
|
35
40
|
const RULER_H = 24;
|
|
36
41
|
const CLIP_Y = 3; // vertical inset inside track
|
|
37
42
|
const CLIP_HANDLE_W = 18;
|
|
38
|
-
const TIMELINE_SCROLL_BUFFER =
|
|
43
|
+
const TIMELINE_SCROLL_BUFFER = 20;
|
|
39
44
|
|
|
40
45
|
interface TrackVisualStyle extends TimelineTrackStyle {
|
|
41
46
|
icon: ReactNode;
|
|
@@ -216,6 +221,14 @@ export function getTimelineCanvasHeight(trackCount: number): number {
|
|
|
216
221
|
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
|
|
217
222
|
}
|
|
218
223
|
|
|
224
|
+
export function shouldShowTimelineShortcutHint(
|
|
225
|
+
scrollHeight: number,
|
|
226
|
+
clientHeight: number,
|
|
227
|
+
): boolean {
|
|
228
|
+
if (!Number.isFinite(scrollHeight) || !Number.isFinite(clientHeight)) return true;
|
|
229
|
+
return scrollHeight - clientHeight <= 1;
|
|
230
|
+
}
|
|
231
|
+
|
|
219
232
|
export function shouldHandleTimelineDeleteKey(input: {
|
|
220
233
|
key: string;
|
|
221
234
|
metaKey?: boolean;
|
|
@@ -279,7 +292,6 @@ export function resolveTimelineAssetDrop(
|
|
|
279
292
|
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
|
280
293
|
};
|
|
281
294
|
}
|
|
282
|
-
|
|
283
295
|
/* ── Component ──────────────────────────────────────────────────── */
|
|
284
296
|
interface TimelineProps {
|
|
285
297
|
/** Called when user seeks via ruler/track click or playhead drag */
|
|
@@ -322,6 +334,12 @@ interface TimelineProps {
|
|
|
322
334
|
element: import("../store/playerStore").TimelineElement,
|
|
323
335
|
intent: BlockedTimelineEditIntent,
|
|
324
336
|
) => void;
|
|
337
|
+
onSelectElement?: (element: import("../store/playerStore").TimelineElement | null) => void;
|
|
338
|
+
onInspectElement?: (element: import("../store/playerStore").TimelineElement) => void;
|
|
339
|
+
inspectedElementId?: string | null;
|
|
340
|
+
layerChildCounts?: ReadonlyMap<string, number>;
|
|
341
|
+
thumbnailedElementIds?: ReadonlySet<string>;
|
|
342
|
+
onToggleElementThumbnail?: (element: import("../store/playerStore").TimelineElement) => void;
|
|
325
343
|
theme?: Partial<TimelineTheme>;
|
|
326
344
|
}
|
|
327
345
|
|
|
@@ -369,6 +387,12 @@ export const Timeline = memo(function Timeline({
|
|
|
369
387
|
onMoveElement,
|
|
370
388
|
onResizeElement,
|
|
371
389
|
onBlockedEditAttempt,
|
|
390
|
+
onSelectElement,
|
|
391
|
+
onInspectElement,
|
|
392
|
+
inspectedElementId,
|
|
393
|
+
layerChildCounts,
|
|
394
|
+
thumbnailedElementIds,
|
|
395
|
+
onToggleElementThumbnail,
|
|
372
396
|
theme: themeOverrides,
|
|
373
397
|
}: TimelineProps = {}) {
|
|
374
398
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
|
@@ -427,30 +451,51 @@ export const Timeline = memo(function Timeline({
|
|
|
427
451
|
onDeleteElementRef.current = onDeleteElement;
|
|
428
452
|
const suppressClickRef = useRef(false);
|
|
429
453
|
const [showPopover, setShowPopover] = useState(false);
|
|
454
|
+
const [showShortcutHint, setShowShortcutHint] = useState(true);
|
|
430
455
|
const [viewportWidth, setViewportWidth] = useState(0);
|
|
431
456
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
457
|
+
const shortcutHintRafRef = useRef(0);
|
|
458
|
+
const syncShortcutHintVisibility = useCallback(() => {
|
|
459
|
+
const scroll = scrollRef.current;
|
|
460
|
+
setShowShortcutHint(
|
|
461
|
+
scroll ? shouldShowTimelineShortcutHint(scroll.scrollHeight, scroll.clientHeight) : true,
|
|
462
|
+
);
|
|
463
|
+
}, []);
|
|
464
|
+
const scheduleShortcutHintVisibilitySync = useCallback(() => {
|
|
465
|
+
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
466
|
+
shortcutHintRafRef.current = requestAnimationFrame(() => {
|
|
467
|
+
shortcutHintRafRef.current = 0;
|
|
468
|
+
syncShortcutHintVisibility();
|
|
469
|
+
});
|
|
470
|
+
}, [syncShortcutHintVisibility]);
|
|
432
471
|
|
|
433
472
|
// Callback ref: sets up ResizeObserver when the DOM element actually mounts.
|
|
434
473
|
// useMountEffect can't work here because the component returns null on first
|
|
435
474
|
// render (timelineReady=false), so containerRef.current is null when the
|
|
436
475
|
// effect fires and the ResizeObserver is never created.
|
|
437
|
-
const setContainerRef = useCallback(
|
|
438
|
-
|
|
439
|
-
roRef.current
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
476
|
+
const setContainerRef = useCallback(
|
|
477
|
+
(el: HTMLDivElement | null) => {
|
|
478
|
+
if (roRef.current) {
|
|
479
|
+
roRef.current.disconnect();
|
|
480
|
+
roRef.current = null;
|
|
481
|
+
}
|
|
482
|
+
containerRef.current = el;
|
|
483
|
+
if (!el) return;
|
|
484
|
+
setViewportWidth(el.clientWidth);
|
|
485
|
+
scheduleShortcutHintVisibilitySync();
|
|
486
|
+
roRef.current = new ResizeObserver(([entry]) => {
|
|
487
|
+
setViewportWidth(entry.contentRect.width);
|
|
488
|
+
scheduleShortcutHintVisibilitySync();
|
|
489
|
+
});
|
|
490
|
+
roRef.current.observe(el);
|
|
491
|
+
},
|
|
492
|
+
[scheduleShortcutHintVisibilitySync],
|
|
493
|
+
);
|
|
450
494
|
|
|
451
495
|
// Clean up ResizeObserver on unmount
|
|
452
496
|
useMountEffect(() => () => {
|
|
453
497
|
roRef.current?.disconnect();
|
|
498
|
+
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
|
454
499
|
});
|
|
455
500
|
|
|
456
501
|
// Effective duration: max of store duration and the furthest element end.
|
|
@@ -495,6 +540,7 @@ export const Timeline = memo(function Timeline({
|
|
|
495
540
|
}
|
|
496
541
|
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
|
497
542
|
}, [draggedClip, trackOrder]);
|
|
543
|
+
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
498
544
|
const selectedElement = useMemo(
|
|
499
545
|
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
|
500
546
|
[elements, selectedElementId],
|
|
@@ -544,7 +590,6 @@ export const Timeline = memo(function Timeline({
|
|
|
544
590
|
);
|
|
545
591
|
previousZoomModeRef.current = zoomMode;
|
|
546
592
|
}, [zoomMode]);
|
|
547
|
-
|
|
548
593
|
useMountEffect(() => {
|
|
549
594
|
const unsub = liveTime.subscribe((t) => {
|
|
550
595
|
const dur = durationRef.current;
|
|
@@ -1012,6 +1057,10 @@ export const Timeline = memo(function Timeline({
|
|
|
1012
1057
|
);
|
|
1013
1058
|
const majorTickInterval =
|
|
1014
1059
|
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
|
|
1060
|
+
useEffect(() => {
|
|
1061
|
+
syncShortcutHintVisibility();
|
|
1062
|
+
}, [syncShortcutHintVisibility, timelineReady, elements.length, totalH]);
|
|
1063
|
+
|
|
1015
1064
|
const getPreviewElement = useCallback(
|
|
1016
1065
|
(element: TimelineElement): TimelineElement => {
|
|
1017
1066
|
if (
|
|
@@ -1239,7 +1288,6 @@ export const Timeline = memo(function Timeline({
|
|
|
1239
1288
|
);
|
|
1240
1289
|
}
|
|
1241
1290
|
|
|
1242
|
-
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
|
|
1243
1291
|
const draggedElement = draggedClip?.element ?? null;
|
|
1244
1292
|
const activeDraggedElement =
|
|
1245
1293
|
draggedClip?.started === true && draggedElement
|
|
@@ -1313,7 +1361,7 @@ export const Timeline = memo(function Timeline({
|
|
|
1313
1361
|
<div
|
|
1314
1362
|
ref={setContainerRef}
|
|
1315
1363
|
aria-label="Timeline"
|
|
1316
|
-
className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
1364
|
+
className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
|
1317
1365
|
style={{
|
|
1318
1366
|
touchAction: "pan-x pan-y",
|
|
1319
1367
|
background: theme.shellBackground,
|
|
@@ -1451,6 +1499,14 @@ export const Timeline = memo(function Timeline({
|
|
|
1451
1499
|
const elementKey = el.key ?? el.id;
|
|
1452
1500
|
const capabilities = getTimelineEditCapabilities(el);
|
|
1453
1501
|
const isSelected = selectedElementId === elementKey;
|
|
1502
|
+
const canInspectClip = canInspectTimelineElement(el);
|
|
1503
|
+
const isInspectorActive =
|
|
1504
|
+
canInspectClip && inspectedElementId === getTimelineElementKey(el);
|
|
1505
|
+
const childCount = canInspectClip
|
|
1506
|
+
? (layerChildCounts?.get(elementKey) ?? 0)
|
|
1507
|
+
: 0;
|
|
1508
|
+
const isThumbnailActive = thumbnailedElementIds?.has(elementKey) ?? false;
|
|
1509
|
+
const thumbnailLabel = isAudioTimelineElement(el) ? "waveform" : "thumbnail";
|
|
1454
1510
|
const isComposition = !!el.compositionSrc;
|
|
1455
1511
|
const clipKey = `${elementKey}-${i}`;
|
|
1456
1512
|
const isHovered = hoveredClip === clipKey;
|
|
@@ -1474,8 +1530,32 @@ export const Timeline = memo(function Timeline({
|
|
|
1474
1530
|
theme={theme}
|
|
1475
1531
|
trackStyle={clipStyle}
|
|
1476
1532
|
isComposition={isComposition}
|
|
1533
|
+
isInspectorActive={isInspectorActive}
|
|
1534
|
+
isThumbnailActive={isThumbnailActive}
|
|
1535
|
+
thumbnailLabel={thumbnailLabel}
|
|
1536
|
+
childCount={childCount}
|
|
1477
1537
|
onHoverStart={() => setHoveredClip(clipKey)}
|
|
1478
1538
|
onHoverEnd={() => setHoveredClip(null)}
|
|
1539
|
+
onInspectorClick={
|
|
1540
|
+
canInspectClip && onInspectElement
|
|
1541
|
+
? (e) => {
|
|
1542
|
+
e.stopPropagation();
|
|
1543
|
+
if (suppressClickRef.current) return;
|
|
1544
|
+
setSelectedElementId(elementKey);
|
|
1545
|
+
onSelectElement?.(el);
|
|
1546
|
+
onInspectElement(el);
|
|
1547
|
+
}
|
|
1548
|
+
: undefined
|
|
1549
|
+
}
|
|
1550
|
+
onThumbnailClick={
|
|
1551
|
+
onToggleElementThumbnail && canInspectClip
|
|
1552
|
+
? (e) => {
|
|
1553
|
+
e.stopPropagation();
|
|
1554
|
+
if (suppressClickRef.current) return;
|
|
1555
|
+
onToggleElementThumbnail(el);
|
|
1556
|
+
}
|
|
1557
|
+
: undefined
|
|
1558
|
+
}
|
|
1479
1559
|
onResizeStart={(edge, e) => {
|
|
1480
1560
|
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
|
|
1481
1561
|
if (edge === "start" && !capabilities.canTrimStart) return;
|
|
@@ -1548,7 +1628,9 @@ export const Timeline = memo(function Timeline({
|
|
|
1548
1628
|
onClick={(e) => {
|
|
1549
1629
|
e.stopPropagation();
|
|
1550
1630
|
if (suppressClickRef.current) return;
|
|
1551
|
-
|
|
1631
|
+
const nextElement = isSelected ? null : el;
|
|
1632
|
+
setSelectedElementId(nextElement ? elementKey : null);
|
|
1633
|
+
onSelectElement?.(nextElement);
|
|
1552
1634
|
}}
|
|
1553
1635
|
onDoubleClick={(e) => {
|
|
1554
1636
|
e.stopPropagation();
|
|
@@ -1652,8 +1734,8 @@ export const Timeline = memo(function Timeline({
|
|
|
1652
1734
|
</div>
|
|
1653
1735
|
</div>
|
|
1654
1736
|
|
|
1655
|
-
{/* Keyboard shortcut hint
|
|
1656
|
-
{!showPopover && !rangeSelection && (
|
|
1737
|
+
{/* Keyboard shortcut hint */}
|
|
1738
|
+
{showShortcutHint && !showPopover && !rangeSelection && (
|
|
1657
1739
|
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
|
1658
1740
|
<div
|
|
1659
1741
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getTimelineClipControlPresentation } from "./TimelineClip";
|
|
3
|
+
|
|
4
|
+
describe("getTimelineClipControlPresentation", () => {
|
|
5
|
+
it("collapses persistent controls for compact clips until the clip is interactive", () => {
|
|
6
|
+
expect(
|
|
7
|
+
getTimelineClipControlPresentation({
|
|
8
|
+
widthPx: 42,
|
|
9
|
+
isHovered: false,
|
|
10
|
+
isSelected: false,
|
|
11
|
+
isInspectorActive: false,
|
|
12
|
+
isThumbnailActive: false,
|
|
13
|
+
isDragging: false,
|
|
14
|
+
}),
|
|
15
|
+
).toMatchObject({
|
|
16
|
+
compact: true,
|
|
17
|
+
showControls: false,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("shows compact controls when the clip is hovered, selected, or active", () => {
|
|
22
|
+
expect(
|
|
23
|
+
getTimelineClipControlPresentation({
|
|
24
|
+
widthPx: 42,
|
|
25
|
+
isHovered: true,
|
|
26
|
+
isSelected: false,
|
|
27
|
+
isInspectorActive: false,
|
|
28
|
+
isThumbnailActive: false,
|
|
29
|
+
isDragging: false,
|
|
30
|
+
}),
|
|
31
|
+
).toMatchObject({
|
|
32
|
+
compact: true,
|
|
33
|
+
showControls: true,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(
|
|
37
|
+
getTimelineClipControlPresentation({
|
|
38
|
+
widthPx: 42,
|
|
39
|
+
isHovered: false,
|
|
40
|
+
isSelected: false,
|
|
41
|
+
isInspectorActive: true,
|
|
42
|
+
isThumbnailActive: false,
|
|
43
|
+
isDragging: false,
|
|
44
|
+
}).showControls,
|
|
45
|
+
).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("keeps controls visible on wide clips", () => {
|
|
49
|
+
expect(
|
|
50
|
+
getTimelineClipControlPresentation({
|
|
51
|
+
widthPx: 120,
|
|
52
|
+
isHovered: false,
|
|
53
|
+
isSelected: false,
|
|
54
|
+
isInspectorActive: false,
|
|
55
|
+
isThumbnailActive: false,
|
|
56
|
+
isDragging: false,
|
|
57
|
+
}),
|
|
58
|
+
).toMatchObject({
|
|
59
|
+
compact: false,
|
|
60
|
+
showControls: true,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("treats medium-width clips as compact so dense tracks do not turn into icon grids", () => {
|
|
65
|
+
expect(
|
|
66
|
+
getTimelineClipControlPresentation({
|
|
67
|
+
widthPx: 96,
|
|
68
|
+
isHovered: false,
|
|
69
|
+
isSelected: false,
|
|
70
|
+
isInspectorActive: false,
|
|
71
|
+
isThumbnailActive: false,
|
|
72
|
+
isDragging: false,
|
|
73
|
+
}),
|
|
74
|
+
).toMatchObject({
|
|
75
|
+
compact: true,
|
|
76
|
+
showControls: false,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("hides controls while dragging", () => {
|
|
81
|
+
expect(
|
|
82
|
+
getTimelineClipControlPresentation({
|
|
83
|
+
widthPx: 120,
|
|
84
|
+
isHovered: true,
|
|
85
|
+
isSelected: true,
|
|
86
|
+
isInspectorActive: true,
|
|
87
|
+
isThumbnailActive: true,
|
|
88
|
+
isDragging: true,
|
|
89
|
+
}).showControls,
|
|
90
|
+
).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -17,15 +17,67 @@ interface TimelineClipProps {
|
|
|
17
17
|
theme?: TimelineTheme;
|
|
18
18
|
trackStyle: TimelineTrackStyle;
|
|
19
19
|
isComposition: boolean;
|
|
20
|
+
isInspectorActive?: boolean;
|
|
21
|
+
isThumbnailActive?: boolean;
|
|
22
|
+
thumbnailLabel?: string;
|
|
23
|
+
childCount?: number;
|
|
20
24
|
onHoverStart: () => void;
|
|
21
25
|
onHoverEnd: () => void;
|
|
22
26
|
onPointerDown?: (e: React.PointerEvent) => void;
|
|
23
27
|
onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void;
|
|
28
|
+
onInspectorClick?: (e: React.MouseEvent) => void;
|
|
29
|
+
onThumbnailClick?: (e: React.MouseEvent) => void;
|
|
24
30
|
onClick: (e: React.MouseEvent) => void;
|
|
25
31
|
onDoubleClick: (e: React.MouseEvent) => void;
|
|
26
32
|
children?: ReactNode;
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
export const TIMELINE_CLIP_CONTROL_Z_INDEX = 20;
|
|
36
|
+
|
|
37
|
+
const COMPACT_CLIP_CONTROL_WIDTH = 112;
|
|
38
|
+
|
|
39
|
+
interface TimelineClipControlPresentationInput {
|
|
40
|
+
widthPx: number;
|
|
41
|
+
isSelected: boolean;
|
|
42
|
+
isHovered: boolean;
|
|
43
|
+
isInspectorActive: boolean;
|
|
44
|
+
isThumbnailActive: boolean;
|
|
45
|
+
isDragging: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TimelineClipControlPresentation {
|
|
49
|
+
compact: boolean;
|
|
50
|
+
showControls: boolean;
|
|
51
|
+
containerClassName: string;
|
|
52
|
+
buttonClassName: string;
|
|
53
|
+
iconSize: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getTimelineClipControlPresentation({
|
|
57
|
+
widthPx,
|
|
58
|
+
isSelected,
|
|
59
|
+
isHovered,
|
|
60
|
+
isInspectorActive,
|
|
61
|
+
isThumbnailActive,
|
|
62
|
+
isDragging,
|
|
63
|
+
}: TimelineClipControlPresentationInput): TimelineClipControlPresentation {
|
|
64
|
+
const compact = widthPx < COMPACT_CLIP_CONTROL_WIDTH;
|
|
65
|
+
const isInteractive = isHovered || isSelected || isInspectorActive || isThumbnailActive;
|
|
66
|
+
const showControls = !isDragging && (!compact || isInteractive);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
compact,
|
|
70
|
+
showControls,
|
|
71
|
+
containerClassName: compact
|
|
72
|
+
? "absolute right-1 top-1 flex items-center gap-1"
|
|
73
|
+
: "absolute right-2 top-2 flex items-center gap-1",
|
|
74
|
+
buttonClassName: compact
|
|
75
|
+
? "flex h-5 w-5 items-center justify-center rounded-[7px]"
|
|
76
|
+
: "flex h-6 w-6 items-center justify-center rounded-md",
|
|
77
|
+
iconSize: compact ? 12 : 14,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
29
81
|
export const TimelineClip = memo(function TimelineClip({
|
|
30
82
|
el,
|
|
31
83
|
pps,
|
|
@@ -37,10 +89,16 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
37
89
|
theme = defaultTimelineTheme,
|
|
38
90
|
trackStyle,
|
|
39
91
|
isComposition,
|
|
92
|
+
isInspectorActive = false,
|
|
93
|
+
isThumbnailActive = false,
|
|
94
|
+
thumbnailLabel = "thumbnail",
|
|
95
|
+
childCount = 0,
|
|
40
96
|
onHoverStart,
|
|
41
97
|
onHoverEnd,
|
|
42
98
|
onPointerDown,
|
|
43
99
|
onResizeStart,
|
|
100
|
+
onInspectorClick,
|
|
101
|
+
onThumbnailClick,
|
|
44
102
|
onClick,
|
|
45
103
|
onDoubleClick,
|
|
46
104
|
children,
|
|
@@ -62,7 +120,38 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
62
120
|
: theme.clipShadow;
|
|
63
121
|
const capabilities = getTimelineEditCapabilities(el);
|
|
64
122
|
const displayLabel = el.label || el.id || el.tag;
|
|
123
|
+
const inspectorLabel =
|
|
124
|
+
childCount > 0
|
|
125
|
+
? `${childCount} nested selectable layer${childCount === 1 ? "" : "s"}`
|
|
126
|
+
: "Inspect clip layer";
|
|
65
127
|
const showHandles = handleOpacity > 0.01;
|
|
128
|
+
const baseBackgroundImage = isSelected ? theme.clipBackgroundActive : theme.clipBackground;
|
|
129
|
+
const controlPresentation = getTimelineClipControlPresentation({
|
|
130
|
+
widthPx,
|
|
131
|
+
isSelected,
|
|
132
|
+
isHovered,
|
|
133
|
+
isInspectorActive,
|
|
134
|
+
isThumbnailActive,
|
|
135
|
+
isDragging,
|
|
136
|
+
});
|
|
137
|
+
const glossBackgroundImage = isSelected
|
|
138
|
+
? "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0))"
|
|
139
|
+
: "linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0))";
|
|
140
|
+
const accentBackgroundImage = `linear-gradient(120deg, ${trackStyle.accent}${
|
|
141
|
+
isSelected ? "22" : "1e"
|
|
142
|
+
}, transparent 28%)`;
|
|
143
|
+
const compositionStripeBackgroundImage =
|
|
144
|
+
isComposition && !hasCustomContent
|
|
145
|
+
? "repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)"
|
|
146
|
+
: undefined;
|
|
147
|
+
const clipBackgroundImage = [
|
|
148
|
+
compositionStripeBackgroundImage,
|
|
149
|
+
glossBackgroundImage,
|
|
150
|
+
accentBackgroundImage,
|
|
151
|
+
baseBackgroundImage,
|
|
152
|
+
]
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
.join(", ");
|
|
66
155
|
|
|
67
156
|
return (
|
|
68
157
|
<div
|
|
@@ -76,13 +165,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
76
165
|
top: clipY,
|
|
77
166
|
bottom: clipY,
|
|
78
167
|
borderRadius: theme.clipRadius,
|
|
79
|
-
|
|
80
|
-
? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
|
|
81
|
-
: `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
|
|
82
|
-
backgroundImage:
|
|
83
|
-
isComposition && !hasCustomContent
|
|
84
|
-
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
|
|
85
|
-
: undefined,
|
|
168
|
+
backgroundImage: clipBackgroundImage,
|
|
86
169
|
border: `1px solid ${borderColor}`,
|
|
87
170
|
boxShadow,
|
|
88
171
|
transition:
|
|
@@ -102,6 +185,157 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
102
185
|
onClick={onClick}
|
|
103
186
|
onDoubleClick={onDoubleClick}
|
|
104
187
|
>
|
|
188
|
+
{childCount > 0 && controlPresentation.showControls && (
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
className={`absolute flex items-center gap-1 rounded-md border border-studio-accent/30 bg-neutral-950/75 text-[10px] font-semibold tabular-nums text-studio-accent shadow-lg shadow-black/25 backdrop-blur transition-colors hover:border-studio-accent/60 hover:bg-studio-accent/15 ${
|
|
192
|
+
controlPresentation.compact ? "left-1 top-1 h-5 px-1" : "left-2 top-2 h-6 px-1.5"
|
|
193
|
+
}`}
|
|
194
|
+
style={{ zIndex: TIMELINE_CLIP_CONTROL_Z_INDEX }}
|
|
195
|
+
title={inspectorLabel}
|
|
196
|
+
aria-label={inspectorLabel}
|
|
197
|
+
onPointerDown={(event) => {
|
|
198
|
+
event.stopPropagation();
|
|
199
|
+
}}
|
|
200
|
+
onClick={(event) => {
|
|
201
|
+
event.stopPropagation();
|
|
202
|
+
onInspectorClick?.(event);
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
<svg
|
|
206
|
+
width={controlPresentation.compact ? "11" : "13"}
|
|
207
|
+
height={controlPresentation.compact ? "11" : "13"}
|
|
208
|
+
viewBox="0 0 24 24"
|
|
209
|
+
fill="none"
|
|
210
|
+
stroke="currentColor"
|
|
211
|
+
strokeWidth="1.8"
|
|
212
|
+
strokeLinecap="round"
|
|
213
|
+
strokeLinejoin="round"
|
|
214
|
+
aria-hidden="true"
|
|
215
|
+
>
|
|
216
|
+
<rect x="4" y="4" width="6" height="6" rx="1" />
|
|
217
|
+
<rect x="14" y="4" width="6" height="6" rx="1" />
|
|
218
|
+
<rect x="4" y="14" width="6" height="6" rx="1" />
|
|
219
|
+
<path d="M14 17h6" />
|
|
220
|
+
</svg>
|
|
221
|
+
{childCount}
|
|
222
|
+
</button>
|
|
223
|
+
)}
|
|
224
|
+
{onInspectorClick &&
|
|
225
|
+
controlPresentation.compact &&
|
|
226
|
+
!controlPresentation.showControls &&
|
|
227
|
+
!isDragging && (
|
|
228
|
+
<button
|
|
229
|
+
type="button"
|
|
230
|
+
className="group/clip-inspect absolute right-1 top-1/2 flex h-7 w-2 -translate-y-1/2 items-center justify-center rounded-full border border-white/15 bg-neutral-950/70 text-neutral-300 shadow-lg shadow-black/25 backdrop-blur transition-all hover:w-5 hover:border-white/30 hover:bg-neutral-950/90 focus:w-5 focus:border-studio-accent/60 focus:bg-studio-accent/15 focus:outline-none"
|
|
231
|
+
style={{ zIndex: TIMELINE_CLIP_CONTROL_Z_INDEX }}
|
|
232
|
+
title={inspectorLabel}
|
|
233
|
+
aria-label={inspectorLabel}
|
|
234
|
+
onPointerDown={(event) => {
|
|
235
|
+
event.stopPropagation();
|
|
236
|
+
}}
|
|
237
|
+
onClick={(event) => {
|
|
238
|
+
event.stopPropagation();
|
|
239
|
+
onInspectorClick(event);
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
<svg
|
|
243
|
+
className="opacity-0 transition-opacity group-hover/clip-inspect:opacity-100 group-focus/clip-inspect:opacity-100"
|
|
244
|
+
width="12"
|
|
245
|
+
height="12"
|
|
246
|
+
viewBox="0 0 24 24"
|
|
247
|
+
fill="none"
|
|
248
|
+
stroke="currentColor"
|
|
249
|
+
strokeWidth="1.8"
|
|
250
|
+
strokeLinecap="round"
|
|
251
|
+
strokeLinejoin="round"
|
|
252
|
+
aria-hidden="true"
|
|
253
|
+
>
|
|
254
|
+
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
|
|
255
|
+
<circle cx="12" cy="12" r="3" />
|
|
256
|
+
</svg>
|
|
257
|
+
</button>
|
|
258
|
+
)}
|
|
259
|
+
{(onThumbnailClick || onInspectorClick) && controlPresentation.showControls && (
|
|
260
|
+
<div
|
|
261
|
+
className={controlPresentation.containerClassName}
|
|
262
|
+
style={{ zIndex: TIMELINE_CLIP_CONTROL_Z_INDEX }}
|
|
263
|
+
>
|
|
264
|
+
{onThumbnailClick && (
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
className={`${controlPresentation.buttonClassName} border shadow-lg shadow-black/25 backdrop-blur transition-colors ${
|
|
268
|
+
isThumbnailActive
|
|
269
|
+
? "border-studio-accent/60 bg-studio-accent/18 text-studio-accent"
|
|
270
|
+
: "border-white/12 bg-neutral-950/70 text-neutral-400 hover:border-white/24 hover:text-neutral-100"
|
|
271
|
+
}`}
|
|
272
|
+
title={
|
|
273
|
+
isThumbnailActive ? `Hide clip ${thumbnailLabel}` : `Show clip ${thumbnailLabel}`
|
|
274
|
+
}
|
|
275
|
+
aria-label={
|
|
276
|
+
isThumbnailActive ? `Hide clip ${thumbnailLabel}` : `Show clip ${thumbnailLabel}`
|
|
277
|
+
}
|
|
278
|
+
onPointerDown={(event) => {
|
|
279
|
+
event.stopPropagation();
|
|
280
|
+
}}
|
|
281
|
+
onClick={(event) => {
|
|
282
|
+
event.stopPropagation();
|
|
283
|
+
onThumbnailClick(event);
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
<svg
|
|
287
|
+
width={controlPresentation.iconSize}
|
|
288
|
+
height={controlPresentation.iconSize}
|
|
289
|
+
viewBox="0 0 24 24"
|
|
290
|
+
fill="none"
|
|
291
|
+
stroke="currentColor"
|
|
292
|
+
strokeWidth="1.8"
|
|
293
|
+
strokeLinecap="round"
|
|
294
|
+
strokeLinejoin="round"
|
|
295
|
+
aria-hidden="true"
|
|
296
|
+
>
|
|
297
|
+
<rect x="3" y="5" width="18" height="14" rx="2" />
|
|
298
|
+
<circle cx="8" cy="10" r="1.5" />
|
|
299
|
+
<path d="m4 17 5-5 4 4 2-2 5 5" />
|
|
300
|
+
</svg>
|
|
301
|
+
</button>
|
|
302
|
+
)}
|
|
303
|
+
{onInspectorClick && (
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
className={`${controlPresentation.buttonClassName} border shadow-lg shadow-black/25 backdrop-blur transition-colors ${
|
|
307
|
+
isInspectorActive
|
|
308
|
+
? "border-studio-accent/60 bg-studio-accent/18 text-studio-accent"
|
|
309
|
+
: "border-white/12 bg-neutral-950/70 text-neutral-400 hover:border-white/24 hover:text-neutral-100"
|
|
310
|
+
}`}
|
|
311
|
+
title={inspectorLabel}
|
|
312
|
+
aria-label={inspectorLabel}
|
|
313
|
+
onPointerDown={(event) => {
|
|
314
|
+
event.stopPropagation();
|
|
315
|
+
}}
|
|
316
|
+
onClick={(event) => {
|
|
317
|
+
event.stopPropagation();
|
|
318
|
+
onInspectorClick(event);
|
|
319
|
+
}}
|
|
320
|
+
>
|
|
321
|
+
<svg
|
|
322
|
+
width={controlPresentation.iconSize}
|
|
323
|
+
height={controlPresentation.iconSize}
|
|
324
|
+
viewBox="0 0 24 24"
|
|
325
|
+
fill="none"
|
|
326
|
+
stroke="currentColor"
|
|
327
|
+
strokeWidth="1.8"
|
|
328
|
+
strokeLinecap="round"
|
|
329
|
+
strokeLinejoin="round"
|
|
330
|
+
aria-hidden="true"
|
|
331
|
+
>
|
|
332
|
+
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
|
|
333
|
+
<circle cx="12" cy="12" r="3" />
|
|
334
|
+
</svg>
|
|
335
|
+
</button>
|
|
336
|
+
)}
|
|
337
|
+
</div>
|
|
338
|
+
)}
|
|
105
339
|
<div
|
|
106
340
|
aria-hidden="true"
|
|
107
341
|
role="presentation"
|
|
@@ -248,13 +248,28 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
248
248
|
});
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
it("
|
|
251
|
+
it("allows moving generic motion clips while keeping trims blocked", () => {
|
|
252
252
|
expect(
|
|
253
253
|
getTimelineEditCapabilities({
|
|
254
254
|
tag: "section",
|
|
255
255
|
duration: 2,
|
|
256
256
|
selector: ".feature-card",
|
|
257
257
|
}),
|
|
258
|
+
).toEqual({
|
|
259
|
+
canMove: true,
|
|
260
|
+
canTrimStart: false,
|
|
261
|
+
canTrimEnd: false,
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("keeps implicit layout layers selectable but not timeline-editable", () => {
|
|
266
|
+
expect(
|
|
267
|
+
getTimelineEditCapabilities({
|
|
268
|
+
duration: 8,
|
|
269
|
+
selector: ".scene-shell",
|
|
270
|
+
tag: "div",
|
|
271
|
+
timingSource: "implicit",
|
|
272
|
+
}),
|
|
258
273
|
).toEqual({
|
|
259
274
|
canMove: false,
|
|
260
275
|
canTrimStart: false,
|
|
@@ -428,7 +443,6 @@ describe("buildClipRangeSelection", () => {
|
|
|
428
443
|
});
|
|
429
444
|
});
|
|
430
445
|
});
|
|
431
|
-
|
|
432
446
|
describe("resolveTimelineAutoScroll", () => {
|
|
433
447
|
it("does not scroll when the pointer stays away from the edges", () => {
|
|
434
448
|
expect(
|
|
@@ -512,7 +526,6 @@ describe("buildTimelineElementAgentPrompt", () => {
|
|
|
512
526
|
).toContain("If this clip is animated with GSAP");
|
|
513
527
|
});
|
|
514
528
|
});
|
|
515
|
-
|
|
516
529
|
describe("resolveTimelineResize", () => {
|
|
517
530
|
it("shrinks clip duration from the right edge", () => {
|
|
518
531
|
expect(
|