@hyperframes/studio 0.8.6 → 0.8.8
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-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
- package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
- package/dist/assets/index-Ba9zbpT9.css +1 -0
- package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
- package/dist/assets/index-ng4tq6YE.js +428 -0
- package/dist/index.d.ts +108 -18
- package/dist/index.html +2 -2
- package/dist/index.js +10335 -7201
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +22 -23
- package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
- package/src/captions/components/CaptionOverlay.tsx +172 -29
- package/src/captions/components/CaptionOverlayUtils.ts +54 -0
- package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
- package/src/captions/components/CaptionTimeline.tsx +36 -79
- package/src/captions/components/shared.tsx +76 -1
- package/src/captions/hooks/useCaptionSync.ts +72 -13
- package/src/captions/keyboard.test.ts +12 -0
- package/src/captions/keyboard.ts +12 -1
- package/src/captions/store.ts +120 -14
- package/src/components/EditorShell.selectionSync.test.tsx +1 -1
- package/src/components/EditorShell.tsx +13 -1
- package/src/components/StudioRightPanel.tsx +2 -0
- package/src/components/StudioRightPanel.types.ts +1 -0
- package/src/components/editor/AnimationCard.tsx +2 -1
- package/src/components/editor/AnimationCardParts.tsx +6 -3
- package/src/components/editor/ArcPathControls.tsx +10 -4
- package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
- package/src/components/editor/BlockParamsPanel.tsx +153 -11
- package/src/components/editor/BorderRadiusEditor.tsx +3 -22
- package/src/components/editor/FileTree.tsx +41 -12
- package/src/components/editor/FileTreeNodes.tsx +90 -20
- package/src/components/editor/GsapAnimationList.tsx +5 -0
- package/src/components/editor/KeyframeDiamond.tsx +3 -1
- package/src/components/editor/KeyframeEaseList.tsx +3 -2
- package/src/components/editor/KeyframeNavigation.tsx +6 -2
- package/src/components/editor/LayersPanel.tsx +11 -2
- package/src/components/editor/MotionPathNode.tsx +1 -0
- package/src/components/editor/PropertyPanel.test.tsx +7 -6
- package/src/components/editor/PropertyPanel.tsx +13 -5
- package/src/components/editor/PropertyPanelFlat.tsx +3 -5
- package/src/components/editor/SnapToolbar.tsx +20 -4
- package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
- package/src/components/editor/TimelineFxPopover.tsx +138 -0
- package/src/components/editor/Transform3DCube.tsx +22 -1
- package/src/components/editor/manualOffsetDrag.test.ts +49 -0
- package/src/components/editor/manualOffsetDrag.ts +13 -1
- package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
- package/src/components/editor/propertyPanelColor.tsx +9 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
- package/src/components/editor/propertyPanelCommitField.tsx +9 -1
- package/src/components/editor/propertyPanelFill.tsx +50 -2
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFont.tsx +45 -9
- package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
- package/src/components/editor/propertyPanelFxSection.tsx +8 -23
- package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
- package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxCarveGrouping.ts +132 -0
- package/src/components/nle/PreviewOverlays.tsx +69 -2
- package/src/components/nle/PreviewPane.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +81 -12
- package/src/components/panels/SlideshowSubPanels.tsx +61 -7
- package/src/components/sidebar/AssetCard.test.tsx +2 -2
- package/src/components/sidebar/AssetCard.tsx +94 -46
- package/src/components/sidebar/AssetContextMenu.tsx +196 -42
- package/src/components/sidebar/AssetsTab.tsx +156 -96
- package/src/components/sidebar/AudioRow.tsx +95 -40
- package/src/components/sidebar/BlocksTab.tsx +40 -128
- package/src/components/sidebar/CompositionsTab.tsx +46 -34
- package/src/components/sidebar/LeftSidebar.tsx +87 -75
- package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
- package/src/components/sidebar/assetHelpers.ts +8 -0
- package/src/contexts/TimelineEditContext.tsx +5 -0
- package/src/hooks/timelineAudioGroupVolume.ts +148 -0
- package/src/hooks/timelineEditingHelpers.ts +78 -1
- package/src/hooks/timelineElementFxAttribute.ts +150 -0
- package/src/hooks/timelineTrackVisibility.test.ts +253 -1
- package/src/hooks/timelineTrackVisibility.ts +207 -8
- package/src/hooks/useAppHotkeys.ts +22 -0
- package/src/hooks/useAudioSoloBridge.ts +61 -0
- package/src/hooks/useCaptionDetection.ts +22 -2
- package/src/hooks/useDomEditCommits.test.tsx +24 -0
- package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
- package/src/hooks/useGroupLevel.ts +36 -0
- package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
- package/src/hooks/useMusicBeatAnalysis.ts +13 -0
- package/src/hooks/useTimelineDeleteOps.ts +170 -0
- package/src/hooks/useTimelineEditing.ts +53 -129
- package/src/player/components/AudioWaveform.tsx +29 -26
- package/src/player/components/BeatStrip.test.tsx +5 -9
- package/src/player/components/BeatStrip.tsx +12 -5
- package/src/player/components/ClipContextMenu.tsx +8 -2
- package/src/player/components/EditModal.tsx +48 -7
- package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
- package/src/player/components/LayerDisclosureRow.tsx +12 -13
- package/src/player/components/Player.tsx +43 -8
- package/src/player/components/PlayerControls.tsx +107 -63
- package/src/player/components/ShortcutsPanel.tsx +23 -4
- package/src/player/components/SpeedMenu.tsx +34 -42
- package/src/player/components/Timeline.test.ts +8 -8
- package/src/player/components/Timeline.tsx +7 -10
- package/src/player/components/TimelineFxButton.test.tsx +81 -0
- package/src/player/components/TimelineFxButton.tsx +143 -0
- package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
- package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
- package/src/player/components/TimelineGroupHeader.tsx +176 -0
- package/src/player/components/TimelineGroupRow.tsx +146 -0
- package/src/player/components/TimelineLanes.test.tsx +8 -1
- package/src/player/components/TimelineLanes.tsx +39 -33
- package/src/player/components/TimelineOverlays.tsx +34 -0
- package/src/player/components/TimelineSoloButton.tsx +32 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
- package/src/player/components/TimelineTrackHeader.tsx +74 -98
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
- package/src/player/components/VideoThumbnail.tsx +6 -1
- package/src/player/components/menuKeyboardNav.ts +47 -0
- package/src/player/components/timelineCallbacks.ts +40 -1
- package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
- package/src/player/components/timelineKeyboardNavigation.ts +147 -30
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineLayout.ts +1 -1
- package/src/player/components/timelineNavigationIdentity.ts +4 -0
- package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
- package/src/player/components/useTimelineLogicalFocus.ts +9 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
- package/src/player/components/useTimelineLogicalRows.ts +17 -12
- package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
- package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
- package/src/player/components/useTimelineTrackDerivations.ts +143 -6
- package/src/player/components/useTimelineTrackLayout.ts +61 -4
- package/src/player/hooks/previewMessageRouter.ts +127 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -47
- package/src/player/lib/timelineDOM.ts +51 -0
- package/src/player/store/audioSoloSlice.test.ts +113 -0
- package/src/player/store/audioSoloSlice.ts +43 -0
- package/src/player/store/editingModeSlice.ts +45 -0
- package/src/player/store/groupLevels.ts +33 -0
- package/src/player/store/keyframeSlice.ts +26 -0
- package/src/player/store/playerStore.ts +21 -22
- package/src/player/store/timelineElement.ts +10 -0
- package/src/telemetry/events.ts +12 -0
- package/dist/assets/index-Cy6fGN1u.js +0 -428
- package/dist/assets/index-DcF4s1PG.css +0 -1
|
@@ -23,7 +23,7 @@ export function SectionHeader({
|
|
|
23
23
|
return (
|
|
24
24
|
<button
|
|
25
25
|
type="button"
|
|
26
|
-
className="flex w-full items-center justify-between px-3 py-2 text-[11px] font-medium text-neutral-400 hover:text-neutral-200 border-b border-neutral-800 transition-colors"
|
|
26
|
+
className="flex w-full items-center justify-between px-3 py-2 text-[11px] font-medium text-neutral-400 hover:text-neutral-200 active:bg-neutral-800/60 border-b border-neutral-800 transition-colors"
|
|
27
27
|
onClick={onToggle}
|
|
28
28
|
aria-expanded={expanded}
|
|
29
29
|
>
|
|
@@ -64,6 +64,7 @@ export function SlideList({
|
|
|
64
64
|
{rows.map((scene) => {
|
|
65
65
|
const isSlide = slideIds.has(scene.id);
|
|
66
66
|
const isSelected = selectedSceneId === scene.id;
|
|
67
|
+
const slideIndex = slides.findIndex((s) => s.sceneId === scene.id);
|
|
67
68
|
return (
|
|
68
69
|
<div
|
|
69
70
|
key={scene.id}
|
|
@@ -98,7 +99,8 @@ export function SlideList({
|
|
|
98
99
|
type="button"
|
|
99
100
|
aria-label="Move slide up"
|
|
100
101
|
title="Move up"
|
|
101
|
-
|
|
102
|
+
disabled={slideIndex <= 0}
|
|
103
|
+
className="px-1 py-0.5 text-[10px] text-neutral-400 enabled:hover:text-white enabled:active:scale-[0.95] disabled:opacity-30 disabled:cursor-not-allowed"
|
|
102
104
|
onClick={(e) => {
|
|
103
105
|
e.stopPropagation();
|
|
104
106
|
onReorder(scene.id, "up");
|
|
@@ -110,7 +112,8 @@ export function SlideList({
|
|
|
110
112
|
type="button"
|
|
111
113
|
aria-label="Move slide down"
|
|
112
114
|
title="Move down"
|
|
113
|
-
|
|
115
|
+
disabled={slideIndex === slides.length - 1}
|
|
116
|
+
className="px-1 py-0.5 text-[10px] text-neutral-400 enabled:hover:text-white enabled:active:scale-[0.95] disabled:opacity-30 disabled:cursor-not-allowed"
|
|
114
117
|
onClick={(e) => {
|
|
115
118
|
e.stopPropagation();
|
|
116
119
|
onReorder(scene.id, "down");
|
|
@@ -310,6 +313,7 @@ function BranchItem({
|
|
|
310
313
|
}: BranchItemProps) {
|
|
311
314
|
const [editing, setEditing] = useState(false);
|
|
312
315
|
const [draft, setDraft] = useState(seq.label);
|
|
316
|
+
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
313
317
|
|
|
314
318
|
const commitRename = useCallback(() => {
|
|
315
319
|
const label = draft.trim();
|
|
@@ -341,7 +345,10 @@ function BranchItem({
|
|
|
341
345
|
title="Click to rename"
|
|
342
346
|
onClick={() => setEditing(true)}
|
|
343
347
|
onKeyDown={(e) => {
|
|
344
|
-
if (e.key === "Enter" || e.key === " ")
|
|
348
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
349
|
+
e.preventDefault();
|
|
350
|
+
setEditing(true);
|
|
351
|
+
}
|
|
345
352
|
}}
|
|
346
353
|
>
|
|
347
354
|
{seq.label}
|
|
@@ -350,12 +357,42 @@ function BranchItem({
|
|
|
350
357
|
<button
|
|
351
358
|
type="button"
|
|
352
359
|
aria-label={`Delete branch ${seq.label}`}
|
|
353
|
-
className="text-[10px] text-neutral-500 hover:text-red-400 transition-colors px-1"
|
|
354
|
-
onClick={() =>
|
|
360
|
+
className="text-[10px] text-neutral-500 hover:text-red-400 active:scale-[0.95] transition-colors px-1"
|
|
361
|
+
onClick={() => setConfirmingDelete(true)}
|
|
355
362
|
>
|
|
356
363
|
✕
|
|
357
364
|
</button>
|
|
358
365
|
</div>
|
|
366
|
+
{confirmingDelete && (
|
|
367
|
+
<div className="px-2 py-1.5 bg-red-950/30 border-l-2 border-red-500 flex flex-col gap-1 rounded-sm">
|
|
368
|
+
<span className="text-[10px] text-red-400">
|
|
369
|
+
Delete branch “{seq.label}”
|
|
370
|
+
{seq.slides.length > 0
|
|
371
|
+
? ` and its ${seq.slides.length} slide${seq.slides.length === 1 ? "" : "s"}`
|
|
372
|
+
: ""}
|
|
373
|
+
? Hotspots pointing to it will no longer resolve.
|
|
374
|
+
</span>
|
|
375
|
+
<div className="flex items-center justify-end gap-1">
|
|
376
|
+
<button
|
|
377
|
+
type="button"
|
|
378
|
+
onClick={() => {
|
|
379
|
+
setConfirmingDelete(false);
|
|
380
|
+
onDelete(seq.id);
|
|
381
|
+
}}
|
|
382
|
+
className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white hover:bg-red-500 active:bg-red-700 transition-colors"
|
|
383
|
+
>
|
|
384
|
+
Delete
|
|
385
|
+
</button>
|
|
386
|
+
<button
|
|
387
|
+
type="button"
|
|
388
|
+
onClick={() => setConfirmingDelete(false)}
|
|
389
|
+
className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 transition-colors"
|
|
390
|
+
>
|
|
391
|
+
Cancel
|
|
392
|
+
</button>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
)}
|
|
359
396
|
<div className="flex flex-col gap-px pl-2">
|
|
360
397
|
{scenes.map((scene) => {
|
|
361
398
|
const assigned = seq.slides.some((s) => s.sceneId === scene.id);
|
|
@@ -447,6 +484,16 @@ export function HotspotTool({
|
|
|
447
484
|
Selected element:{" "}
|
|
448
485
|
<span className="text-neutral-200 font-mono">{elementKey ?? "none"}</span>
|
|
449
486
|
</p>
|
|
487
|
+
{!elementKey && (
|
|
488
|
+
<p className="text-[10px] text-neutral-500 italic">
|
|
489
|
+
Click an element on the canvas to choose the hotspot target.
|
|
490
|
+
</p>
|
|
491
|
+
)}
|
|
492
|
+
{sequences.length === 0 && (
|
|
493
|
+
<p className="text-[10px] text-neutral-500 italic">
|
|
494
|
+
Create a branch in the Branches section first — hotspots jump to a branch.
|
|
495
|
+
</p>
|
|
496
|
+
)}
|
|
450
497
|
<label className="text-[11px] text-neutral-400">Hotspot label</label>
|
|
451
498
|
<input
|
|
452
499
|
type="text"
|
|
@@ -473,7 +520,14 @@ export function HotspotTool({
|
|
|
473
520
|
<button
|
|
474
521
|
type="button"
|
|
475
522
|
disabled={!elementKey || !targetSequenceId}
|
|
476
|
-
|
|
523
|
+
title={
|
|
524
|
+
!elementKey
|
|
525
|
+
? "Select an element on the canvas first"
|
|
526
|
+
: !targetSequenceId
|
|
527
|
+
? "Choose a target branch first"
|
|
528
|
+
: undefined
|
|
529
|
+
}
|
|
530
|
+
className="px-3 py-1.5 rounded bg-studio-accent/80 enabled:hover:bg-studio-accent enabled:active:scale-[0.98] text-white text-[11px] font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
477
531
|
onClick={handleMakeHotspot}
|
|
478
532
|
>
|
|
479
533
|
Make hotspot
|
|
@@ -52,7 +52,7 @@ describe("AssetCard click behavior", () => {
|
|
|
52
52
|
const cardProps = {
|
|
53
53
|
projectId: "p1",
|
|
54
54
|
onCopy: vi.fn(),
|
|
55
|
-
|
|
55
|
+
copyFeedback: null,
|
|
56
56
|
};
|
|
57
57
|
|
|
58
58
|
it("clears an open preview overlay when clicking an already-added asset (reveal branch)", () => {
|
|
@@ -81,7 +81,7 @@ describe("AudioRow click behavior", () => {
|
|
|
81
81
|
const rowProps = {
|
|
82
82
|
projectId: "p1",
|
|
83
83
|
onCopy: vi.fn(),
|
|
84
|
-
|
|
84
|
+
copyFeedback: null,
|
|
85
85
|
};
|
|
86
86
|
|
|
87
87
|
it("clears an open preview overlay when clicking an already-added audio asset (reveal branch)", () => {
|
|
@@ -11,7 +11,7 @@ import { usePlayerStore } from "../../player/store/playerStore";
|
|
|
11
11
|
import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
|
|
12
12
|
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
|
13
13
|
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
|
|
14
|
-
import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers";
|
|
14
|
+
import { basename, ext, truncateMiddle, formatDuration, type CopyFeedback } from "./assetHelpers";
|
|
15
15
|
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
|
|
16
16
|
|
|
17
17
|
/** Drag payload writer shared by the asset tile and the font row: copy effect
|
|
@@ -22,6 +22,23 @@ function writeAssetDragData(e: React.DragEvent, asset: string): void {
|
|
|
22
22
|
e.dataTransfer.setData("text/plain", asset);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Copy-path outcome chip. Copying is a context-menu action, so this is pure
|
|
26
|
+
* feedback — it renders only once a copy has succeeded or failed, and never
|
|
27
|
+
* as an idle affordance for something the tile itself does not do. */
|
|
28
|
+
function CopyChip({ feedback, asset }: { feedback: CopyFeedback; asset: string }) {
|
|
29
|
+
if (feedback?.path !== asset) return null;
|
|
30
|
+
return (
|
|
31
|
+
<span
|
|
32
|
+
role="status"
|
|
33
|
+
className={`flex-shrink-0 text-[9px] font-medium px-1.5 py-px rounded ${
|
|
34
|
+
feedback.ok ? "text-panel-accent bg-panel-accent/10" : "text-red-400 bg-red-500/10"
|
|
35
|
+
}`}
|
|
36
|
+
>
|
|
37
|
+
{feedback.ok ? "Copied" : "Copy failed"}
|
|
38
|
+
</span>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
25
42
|
/** Open the row/tile context menu at the pointer, shared by asset tile + font row. */
|
|
26
43
|
function openAssetContextMenu(
|
|
27
44
|
e: React.MouseEvent,
|
|
@@ -90,7 +107,7 @@ export interface AssetCardProps {
|
|
|
90
107
|
used: boolean;
|
|
91
108
|
duration?: number;
|
|
92
109
|
onCopy: (path: string) => void;
|
|
93
|
-
|
|
110
|
+
copyFeedback: CopyFeedback;
|
|
94
111
|
onDelete?: (path: string) => void;
|
|
95
112
|
onRename?: (oldPath: string, newPath: string) => void;
|
|
96
113
|
onAddAssetToTimeline?: (path: string) => void;
|
|
@@ -112,13 +129,15 @@ export function AssetCard({
|
|
|
112
129
|
used,
|
|
113
130
|
duration,
|
|
114
131
|
onCopy,
|
|
115
|
-
|
|
132
|
+
copyFeedback,
|
|
116
133
|
onDelete,
|
|
117
134
|
onRename,
|
|
118
135
|
onAddAssetToTimeline,
|
|
119
136
|
}: AssetCardProps) {
|
|
120
137
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
121
138
|
const [hovered, setHovered] = useState(false);
|
|
139
|
+
const [imgError, setImgError] = useState(false);
|
|
140
|
+
const isCopied = copyFeedback?.path === asset && copyFeedback.ok;
|
|
122
141
|
const fullName = asset.split("/").pop() ?? asset;
|
|
123
142
|
const name = basename(asset);
|
|
124
143
|
const extension = ext(asset);
|
|
@@ -143,66 +162,80 @@ export function AssetCard({
|
|
|
143
162
|
pointerDownRef.current = { x: e.clientX, y: e.clientY };
|
|
144
163
|
}, []);
|
|
145
164
|
|
|
165
|
+
// Reveal the clip when the asset is already on the timeline, otherwise open
|
|
166
|
+
// the preview overlay. Shared by pointer-up and keyboard activation so the
|
|
167
|
+
// tile does the same thing however it is operated.
|
|
168
|
+
const activateCard = useCallback(() => {
|
|
169
|
+
if (used) {
|
|
170
|
+
const clip = findClipForAsset(elements, asset);
|
|
171
|
+
if (clip) {
|
|
172
|
+
// Dismiss any open preview overlay (from another asset) — the reveal
|
|
173
|
+
// must not leave a stale preview card floating over the canvas.
|
|
174
|
+
clearPreviewAsset();
|
|
175
|
+
const clipKey = clip.key ?? clip.id;
|
|
176
|
+
setSelectedElementId(clipKey);
|
|
177
|
+
// Scroll the timeline so the selected clip is actually visible.
|
|
178
|
+
requestTimelineFocus(timelineClipFocusId(clipKey));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Not added (or no matching clip found) → preview overlay
|
|
183
|
+
setPreviewAsset(asset, projectId);
|
|
184
|
+
}, [
|
|
185
|
+
used,
|
|
186
|
+
elements,
|
|
187
|
+
asset,
|
|
188
|
+
projectId,
|
|
189
|
+
setSelectedElementId,
|
|
190
|
+
requestTimelineFocus,
|
|
191
|
+
setPreviewAsset,
|
|
192
|
+
clearPreviewAsset,
|
|
193
|
+
]);
|
|
194
|
+
|
|
146
195
|
const handlePointerUp = useCallback(
|
|
147
196
|
(e: React.PointerEvent) => {
|
|
148
197
|
const origin = pointerDownRef.current;
|
|
149
198
|
pointerDownRef.current = null;
|
|
150
199
|
if (!origin) return;
|
|
151
200
|
if (!isPointerClick(e.clientX - origin.x, e.clientY - origin.y)) return;
|
|
152
|
-
|
|
153
|
-
if (used) {
|
|
154
|
-
const clip = findClipForAsset(elements, asset);
|
|
155
|
-
if (clip) {
|
|
156
|
-
// Dismiss any open preview overlay (from another asset) — the reveal
|
|
157
|
-
// must not leave a stale preview card floating over the canvas.
|
|
158
|
-
clearPreviewAsset();
|
|
159
|
-
const clipKey = clip.key ?? clip.id;
|
|
160
|
-
setSelectedElementId(clipKey);
|
|
161
|
-
// Scroll the timeline so the selected clip is actually visible.
|
|
162
|
-
requestTimelineFocus(timelineClipFocusId(clipKey));
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
// Not added (or no matching clip found) → preview overlay
|
|
167
|
-
setPreviewAsset(asset, projectId);
|
|
201
|
+
activateCard();
|
|
168
202
|
},
|
|
169
|
-
[
|
|
170
|
-
used,
|
|
171
|
-
elements,
|
|
172
|
-
asset,
|
|
173
|
-
projectId,
|
|
174
|
-
setSelectedElementId,
|
|
175
|
-
requestTimelineFocus,
|
|
176
|
-
setPreviewAsset,
|
|
177
|
-
clearPreviewAsset,
|
|
178
|
-
],
|
|
203
|
+
[activateCard],
|
|
179
204
|
);
|
|
180
205
|
|
|
181
206
|
return (
|
|
182
207
|
<>
|
|
183
208
|
<div
|
|
184
209
|
draggable
|
|
210
|
+
role="button"
|
|
211
|
+
tabIndex={0}
|
|
212
|
+
aria-label={`${name} — open, drag to timeline, right-click for actions`}
|
|
185
213
|
onPointerDown={handlePointerDown}
|
|
186
214
|
onPointerUp={handlePointerUp}
|
|
215
|
+
onKeyDown={(e) => {
|
|
216
|
+
if (e.target !== e.currentTarget) return;
|
|
217
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
activateCard();
|
|
220
|
+
}
|
|
221
|
+
}}
|
|
187
222
|
onDragStart={(e) => writeAssetDragData(e, asset)}
|
|
188
223
|
onContextMenu={(e) => openAssetContextMenu(e, setContextMenu)}
|
|
189
224
|
onPointerEnter={() => setHovered(true)}
|
|
190
225
|
onPointerLeave={() => setHovered(false)}
|
|
191
|
-
className={`flex flex-col gap-1 cursor-pointer rounded-md p-1 transition-colors ${
|
|
226
|
+
className={`flex flex-col gap-1 cursor-pointer rounded-md p-1 transition-colors outline-none focus-visible:bg-neutral-800/60 ${
|
|
192
227
|
isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/40"
|
|
193
228
|
}`}
|
|
194
229
|
>
|
|
195
230
|
{/* Thumbnail */}
|
|
196
231
|
<div className="w-full aspect-video rounded overflow-hidden bg-neutral-900 relative">
|
|
197
|
-
{isImage && (
|
|
232
|
+
{isImage && !imgError && (
|
|
198
233
|
<img
|
|
199
234
|
src={serveUrl}
|
|
200
235
|
alt={name}
|
|
201
236
|
loading="lazy"
|
|
202
237
|
className="w-full h-full object-cover"
|
|
203
|
-
onError={(
|
|
204
|
-
(e.target as HTMLImageElement).style.display = "none";
|
|
205
|
-
}}
|
|
238
|
+
onError={() => setImgError(true)}
|
|
206
239
|
/>
|
|
207
240
|
)}
|
|
208
241
|
{isVideo && (
|
|
@@ -220,7 +253,7 @@ export function AssetCard({
|
|
|
220
253
|
)}
|
|
221
254
|
</>
|
|
222
255
|
)}
|
|
223
|
-
{!isImage && !isVideo && (
|
|
256
|
+
{((!isImage && !isVideo) || (isImage && imgError)) && (
|
|
224
257
|
<div className="w-full h-full flex items-center justify-center">
|
|
225
258
|
<span className="text-[10px] font-medium text-neutral-600">{extension}</span>
|
|
226
259
|
</div>
|
|
@@ -242,14 +275,17 @@ export function AssetCard({
|
|
|
242
275
|
</div>
|
|
243
276
|
|
|
244
277
|
{/* Filename caption */}
|
|
245
|
-
<
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
278
|
+
<div className="flex items-center justify-center gap-1">
|
|
279
|
+
<span
|
|
280
|
+
className={`text-[10px] leading-tight text-center ${
|
|
281
|
+
used ? "text-panel-text-2" : "text-panel-text-4"
|
|
282
|
+
}`}
|
|
283
|
+
title={fullName}
|
|
284
|
+
>
|
|
285
|
+
{truncateMiddle(fullName, 22)}
|
|
286
|
+
</span>
|
|
287
|
+
<CopyChip feedback={copyFeedback} asset={asset} />
|
|
288
|
+
</div>
|
|
253
289
|
</div>
|
|
254
290
|
|
|
255
291
|
{contextMenu && (
|
|
@@ -272,7 +308,7 @@ export interface FontRowProps {
|
|
|
272
308
|
asset: string;
|
|
273
309
|
used: boolean;
|
|
274
310
|
onCopy: (path: string) => void;
|
|
275
|
-
|
|
311
|
+
copyFeedback: CopyFeedback;
|
|
276
312
|
onDelete?: (path: string) => void;
|
|
277
313
|
onRename?: (oldPath: string, newPath: string) => void;
|
|
278
314
|
onAddAssetToTimeline?: (path: string) => void;
|
|
@@ -285,7 +321,7 @@ export function FontRow({
|
|
|
285
321
|
asset,
|
|
286
322
|
used,
|
|
287
323
|
onCopy,
|
|
288
|
-
|
|
324
|
+
copyFeedback,
|
|
289
325
|
onDelete,
|
|
290
326
|
onRename,
|
|
291
327
|
onAddAssetToTimeline,
|
|
@@ -293,15 +329,26 @@ export function FontRow({
|
|
|
293
329
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
294
330
|
const name = basename(asset);
|
|
295
331
|
const extension = ext(asset);
|
|
332
|
+
const isCopied = copyFeedback?.path === asset && copyFeedback.ok;
|
|
296
333
|
|
|
297
334
|
return (
|
|
298
335
|
<>
|
|
299
336
|
<div
|
|
300
337
|
draggable
|
|
338
|
+
role="button"
|
|
339
|
+
tabIndex={0}
|
|
340
|
+
aria-label={`${name} — copy path, drag to timeline, right-click for actions`}
|
|
301
341
|
onClick={() => onCopy(asset)}
|
|
342
|
+
onKeyDown={(e) => {
|
|
343
|
+
if (e.target !== e.currentTarget) return;
|
|
344
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
onCopy(asset);
|
|
347
|
+
}
|
|
348
|
+
}}
|
|
302
349
|
onDragStart={(e) => writeAssetDragData(e, asset)}
|
|
303
350
|
onContextMenu={(e) => openAssetContextMenu(e, setContextMenu)}
|
|
304
|
-
className={`px-2.5 py-1.5 flex items-center gap-2.5 cursor-pointer transition-colors ${
|
|
351
|
+
className={`px-2.5 py-1.5 flex items-center gap-2.5 cursor-pointer transition-colors outline-none focus-visible:bg-neutral-800/60 ${
|
|
305
352
|
isCopied
|
|
306
353
|
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
|
307
354
|
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
|
@@ -323,6 +370,7 @@ export function FontRow({
|
|
|
323
370
|
in use
|
|
324
371
|
</span>
|
|
325
372
|
)}
|
|
373
|
+
<CopyChip feedback={copyFeedback} asset={asset} />
|
|
326
374
|
</div>
|
|
327
375
|
</div>
|
|
328
376
|
</div>
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { filename } from "./assetHelpers";
|
|
3
|
+
|
|
4
|
+
/** Reject names that would escape the asset directory or break paths. */
|
|
5
|
+
function isValidAssetName(name: string): boolean {
|
|
6
|
+
return name.length > 0 && !/[/\\]/.test(name) && !name.includes("..");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// fallow-ignore-next-line complexity
|
|
1
10
|
export function ContextMenu({
|
|
2
11
|
x,
|
|
3
12
|
y,
|
|
@@ -17,6 +26,77 @@ export function ContextMenu({
|
|
|
17
26
|
onRename?: (oldPath: string, newPath: string) => void;
|
|
18
27
|
onAddAtPlayhead?: (path: string) => void;
|
|
19
28
|
}) {
|
|
29
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
30
|
+
const [pos, setPos] = useState({ x, y });
|
|
31
|
+
const [mode, setMode] = useState<"menu" | "confirm-delete" | "rename">("menu");
|
|
32
|
+
const [renameDraft, setRenameDraft] = useState(() => filename(asset));
|
|
33
|
+
const [renameError, setRenameError] = useState<string | null>(null);
|
|
34
|
+
|
|
35
|
+
// Clamp the menu inside the viewport once it has a size.
|
|
36
|
+
useLayoutEffect(() => {
|
|
37
|
+
const el = menuRef.current;
|
|
38
|
+
if (!el) return;
|
|
39
|
+
const rect = el.getBoundingClientRect();
|
|
40
|
+
const margin = 8;
|
|
41
|
+
setPos({
|
|
42
|
+
x: Math.min(x, window.innerWidth - rect.width - margin),
|
|
43
|
+
y: Math.min(y, window.innerHeight - rect.height - margin),
|
|
44
|
+
});
|
|
45
|
+
}, [x, y, mode]);
|
|
46
|
+
|
|
47
|
+
// Keyboard contract: Escape backs out one level (rename/delete-confirm →
|
|
48
|
+
// menu → closed), arrows move between menu items.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
51
|
+
if (e.key === "Escape") {
|
|
52
|
+
e.stopPropagation();
|
|
53
|
+
if (mode !== "menu") {
|
|
54
|
+
setMode("menu");
|
|
55
|
+
} else {
|
|
56
|
+
onClose();
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (mode !== "menu" || (e.key !== "ArrowDown" && e.key !== "ArrowUp")) return;
|
|
61
|
+
const items = Array.from(
|
|
62
|
+
menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [],
|
|
63
|
+
);
|
|
64
|
+
if (items.length === 0) return;
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
const idx = items.findIndex((el) => el === document.activeElement);
|
|
67
|
+
const delta = e.key === "ArrowDown" ? 1 : -1;
|
|
68
|
+
const next = items[(idx + delta + items.length) % items.length];
|
|
69
|
+
next.focus();
|
|
70
|
+
};
|
|
71
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
72
|
+
return () => document.removeEventListener("keydown", onKeyDown, true);
|
|
73
|
+
}, [mode, onClose]);
|
|
74
|
+
|
|
75
|
+
// Move focus into the menu on open so arrow keys work immediately.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (mode === "menu") {
|
|
78
|
+
menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitem"]')?.focus();
|
|
79
|
+
}
|
|
80
|
+
}, [mode]);
|
|
81
|
+
|
|
82
|
+
const commitRename = useCallback(() => {
|
|
83
|
+
const trimmed = renameDraft.trim();
|
|
84
|
+
if (trimmed === filename(asset)) {
|
|
85
|
+
onClose();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!isValidAssetName(trimmed)) {
|
|
89
|
+
setRenameError("Name can't contain / or ..");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const dir = asset.includes("/") ? asset.slice(0, asset.lastIndexOf("/") + 1) : "";
|
|
93
|
+
onRename?.(asset, `${dir}${trimmed}`);
|
|
94
|
+
onClose();
|
|
95
|
+
}, [renameDraft, asset, onRename, onClose]);
|
|
96
|
+
|
|
97
|
+
const itemCls =
|
|
98
|
+
"w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none active:bg-neutral-700/70 transition-colors";
|
|
99
|
+
|
|
20
100
|
return (
|
|
21
101
|
<div
|
|
22
102
|
className="fixed inset-0 z-[200]"
|
|
@@ -27,55 +107,129 @@ export function ContextMenu({
|
|
|
27
107
|
}}
|
|
28
108
|
>
|
|
29
109
|
<div
|
|
30
|
-
|
|
31
|
-
|
|
110
|
+
ref={menuRef}
|
|
111
|
+
role="menu"
|
|
112
|
+
aria-label={`Actions for ${filename(asset)}`}
|
|
113
|
+
className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[160px] text-xs"
|
|
114
|
+
style={{ left: pos.x, top: pos.y }}
|
|
115
|
+
onClick={(e) => e.stopPropagation()}
|
|
32
116
|
>
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
117
|
+
{mode === "menu" && (
|
|
118
|
+
<>
|
|
119
|
+
{onAddAtPlayhead && (
|
|
120
|
+
<button
|
|
121
|
+
role="menuitem"
|
|
122
|
+
onClick={() => {
|
|
123
|
+
onAddAtPlayhead(asset);
|
|
124
|
+
onClose();
|
|
125
|
+
}}
|
|
126
|
+
className={itemCls}
|
|
127
|
+
>
|
|
128
|
+
Add at playhead
|
|
129
|
+
</button>
|
|
130
|
+
)}
|
|
131
|
+
<button
|
|
132
|
+
role="menuitem"
|
|
133
|
+
onClick={() => {
|
|
134
|
+
onCopy(asset);
|
|
135
|
+
onClose();
|
|
136
|
+
}}
|
|
137
|
+
className={itemCls}
|
|
138
|
+
>
|
|
139
|
+
Copy path
|
|
140
|
+
</button>
|
|
141
|
+
{onRename && (
|
|
142
|
+
<button role="menuitem" onClick={() => setMode("rename")} className={itemCls}>
|
|
143
|
+
Rename
|
|
144
|
+
</button>
|
|
145
|
+
)}
|
|
146
|
+
{onDelete && (
|
|
147
|
+
<button
|
|
148
|
+
role="menuitem"
|
|
149
|
+
onClick={() => setMode("confirm-delete")}
|
|
150
|
+
className={`${itemCls} text-red-400`}
|
|
151
|
+
>
|
|
152
|
+
Delete
|
|
153
|
+
</button>
|
|
154
|
+
)}
|
|
155
|
+
</>
|
|
156
|
+
)}
|
|
157
|
+
{mode === "confirm-delete" && (
|
|
158
|
+
<DeleteConfirm
|
|
159
|
+
name={filename(asset)}
|
|
160
|
+
onConfirm={() => {
|
|
161
|
+
onDelete?.(asset);
|
|
39
162
|
onClose();
|
|
40
163
|
}}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
164
|
+
onCancel={() => setMode("menu")}
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
167
|
+
{mode === "rename" && (
|
|
168
|
+
<div className="px-2 py-1.5 flex flex-col gap-1">
|
|
169
|
+
<input
|
|
170
|
+
autoFocus
|
|
171
|
+
value={renameDraft}
|
|
172
|
+
onChange={(e) => {
|
|
173
|
+
setRenameDraft(e.target.value);
|
|
174
|
+
setRenameError(null);
|
|
175
|
+
}}
|
|
176
|
+
onKeyDown={(e) => {
|
|
177
|
+
if (e.key === "Enter") commitRename();
|
|
178
|
+
if (e.key === "Escape") {
|
|
179
|
+
e.stopPropagation();
|
|
180
|
+
setMode("menu");
|
|
181
|
+
}
|
|
182
|
+
}}
|
|
183
|
+
aria-label={`Rename ${filename(asset)}`}
|
|
184
|
+
className="w-full bg-neutral-800 border border-neutral-600 rounded px-1.5 py-1 text-[11px] text-white focus:border-studio-accent/60 focus:outline-none"
|
|
185
|
+
/>
|
|
186
|
+
{renameError && <span className="text-[10px] text-red-400">{renameError}</span>}
|
|
187
|
+
<div className="flex items-center justify-end gap-1">
|
|
188
|
+
<button
|
|
189
|
+
onClick={() => setMode("menu")}
|
|
190
|
+
className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 transition-colors"
|
|
191
|
+
>
|
|
192
|
+
Cancel
|
|
193
|
+
</button>
|
|
194
|
+
<button
|
|
195
|
+
onClick={commitRename}
|
|
196
|
+
className="px-2 py-0.5 text-[10px] rounded bg-studio-accent/80 hover:bg-studio-accent text-white transition-colors"
|
|
197
|
+
>
|
|
198
|
+
Rename
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
45
202
|
)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function DeleteConfirm({
|
|
209
|
+
name,
|
|
210
|
+
onConfirm,
|
|
211
|
+
onCancel,
|
|
212
|
+
}: {
|
|
213
|
+
name: string;
|
|
214
|
+
onConfirm: () => void;
|
|
215
|
+
onCancel: () => void;
|
|
216
|
+
}) {
|
|
217
|
+
return (
|
|
218
|
+
<div className="px-2 py-1.5 bg-red-950/30 border-l-2 border-red-500 flex items-center justify-between gap-2">
|
|
219
|
+
<span className="text-[10px] text-red-400 truncate">Delete {name}?</span>
|
|
220
|
+
<div className="flex items-center gap-1 flex-shrink-0">
|
|
46
221
|
<button
|
|
47
|
-
onClick={
|
|
48
|
-
|
|
49
|
-
onCopy(asset);
|
|
50
|
-
onClose();
|
|
51
|
-
}}
|
|
52
|
-
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
|
222
|
+
onClick={onConfirm}
|
|
223
|
+
className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white hover:bg-red-500 active:bg-red-700 transition-colors"
|
|
53
224
|
>
|
|
54
|
-
|
|
225
|
+
Delete
|
|
226
|
+
</button>
|
|
227
|
+
<button
|
|
228
|
+
onClick={onCancel}
|
|
229
|
+
className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 transition-colors"
|
|
230
|
+
>
|
|
231
|
+
Cancel
|
|
55
232
|
</button>
|
|
56
|
-
{onRename && (
|
|
57
|
-
<button
|
|
58
|
-
onClick={(e) => {
|
|
59
|
-
e.stopPropagation();
|
|
60
|
-
onClose();
|
|
61
|
-
}}
|
|
62
|
-
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
|
63
|
-
>
|
|
64
|
-
Rename
|
|
65
|
-
</button>
|
|
66
|
-
)}
|
|
67
|
-
{onDelete && (
|
|
68
|
-
<button
|
|
69
|
-
onClick={(e) => {
|
|
70
|
-
e.stopPropagation();
|
|
71
|
-
onDelete(asset);
|
|
72
|
-
onClose();
|
|
73
|
-
}}
|
|
74
|
-
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-neutral-800 transition-colors"
|
|
75
|
-
>
|
|
76
|
-
Delete
|
|
77
|
-
</button>
|
|
78
|
-
)}
|
|
79
233
|
</div>
|
|
80
234
|
</div>
|
|
81
235
|
);
|