@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.
Files changed (155) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
  2. package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
  3. package/dist/assets/index-Ba9zbpT9.css +1 -0
  4. package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
  5. package/dist/assets/index-ng4tq6YE.js +428 -0
  6. package/dist/index.d.ts +108 -18
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10335 -7201
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +3 -5
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +253 -1
  89. package/src/hooks/timelineTrackVisibility.ts +207 -8
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +8 -8
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +8 -1
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
  124. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  125. package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
  126. package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
  127. package/src/player/components/VideoThumbnail.tsx +6 -1
  128. package/src/player/components/menuKeyboardNav.ts +47 -0
  129. package/src/player/components/timelineCallbacks.ts +40 -1
  130. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  131. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  132. package/src/player/components/timelineLaneProps.ts +3 -0
  133. package/src/player/components/timelineLayout.ts +1 -1
  134. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  135. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  136. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  137. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  138. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  139. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  140. package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
  141. package/src/player/components/useTimelineTrackDerivations.ts +143 -6
  142. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  143. package/src/player/hooks/previewMessageRouter.ts +127 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  145. package/src/player/lib/timelineDOM.ts +51 -0
  146. package/src/player/store/audioSoloSlice.test.ts +113 -0
  147. package/src/player/store/audioSoloSlice.ts +43 -0
  148. package/src/player/store/editingModeSlice.ts +45 -0
  149. package/src/player/store/groupLevels.ts +33 -0
  150. package/src/player/store/keyframeSlice.ts +26 -0
  151. package/src/player/store/playerStore.ts +21 -22
  152. package/src/player/store/timelineElement.ts +10 -0
  153. package/src/telemetry/events.ts +12 -0
  154. package/dist/assets/index-Cy6fGN1u.js +0 -428
  155. 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
- className="px-1 py-0.5 text-[10px] text-neutral-400 hover:text-white disabled:opacity-30"
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
- className="px-1 py-0.5 text-[10px] text-neutral-400 hover:text-white disabled:opacity-30"
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 === " ") setEditing(true);
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={() => onDelete(seq.id)}
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 &ldquo;{seq.label}&rdquo;
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
- className="px-3 py-1.5 rounded bg-studio-accent/80 hover:bg-studio-accent text-white text-[11px] font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
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
- isCopied: false,
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
- isCopied: false,
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
- isCopied: boolean;
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
- isCopied,
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
- // Treat as click
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={(e) => {
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
- <span
246
- className={`text-[10px] leading-tight text-center block w-full ${
247
- used ? "text-panel-text-2" : "text-panel-text-4"
248
- }`}
249
- title={fullName}
250
- >
251
- {truncateMiddle(fullName, 22)}
252
- </span>
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
- isCopied: boolean;
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
- isCopied,
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
- className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[140px] text-xs"
31
- style={{ left: x, top: y }}
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
- {onAddAtPlayhead && (
34
- <button
35
- type="button"
36
- onClick={(e) => {
37
- e.stopPropagation();
38
- onAddAtPlayhead(asset);
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
- className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
42
- >
43
- Add at playhead
44
- </button>
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={(e) => {
48
- e.stopPropagation();
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
- Copy path
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
  );