@r2digisolutions/components 0.2.0 → 0.3.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.
Files changed (65) hide show
  1. package/README.md +10 -0
  2. package/dist/components/atoms/Playhead/Playhead.svelte +44 -5
  3. package/dist/components/atoms/Playhead/Playhead.svelte.d.ts +8 -0
  4. package/dist/components/atoms/TimeRuler/TimeRuler.svelte +75 -16
  5. package/dist/components/atoms/TimeRuler/TimeRuler.svelte.d.ts +7 -0
  6. package/dist/components/atoms/TimelineGrid/TimelineGrid.stories.js +8 -0
  7. package/dist/components/atoms/TimelineGrid/TimelineGrid.svelte +39 -0
  8. package/dist/components/atoms/TimelineGrid/TimelineGrid.svelte.d.ts +11 -0
  9. package/dist/components/atoms/TimelineGrid/TimelineGridStory.svelte +7 -0
  10. package/dist/components/atoms/TimelineGrid/TimelineGridStory.svelte.d.ts +18 -0
  11. package/dist/components/molecules/ColorPicker/ColorPicker.svelte +45 -4
  12. package/dist/components/molecules/ColorPicker/ColorPicker.svelte.d.ts +2 -0
  13. package/dist/components/molecules/ContextMenu/ContextMenu.svelte +38 -11
  14. package/dist/components/molecules/MediaAssetBrowser/MediaAssetBrowser.svelte +17 -2
  15. package/dist/components/molecules/MediaAssetBrowser/MediaAssetBrowser.svelte.d.ts +5 -0
  16. package/dist/components/molecules/MediaTimeline/MediaTimeline.svelte +362 -52
  17. package/dist/components/molecules/MediaTimeline/MediaTimeline.svelte.d.ts +22 -2
  18. package/dist/components/molecules/PdfViewer/PdfViewer.svelte.d.ts +1 -1
  19. package/dist/components/molecules/ProgramMonitor/ProgramMonitor.svelte +751 -29
  20. package/dist/components/molecules/ProgramMonitor/ProgramMonitor.svelte.d.ts +14 -1
  21. package/dist/components/molecules/SplitPane/SplitPane.svelte +2 -2
  22. package/dist/components/molecules/TimelineClip/TimelineClip.svelte +95 -18
  23. package/dist/components/molecules/TimelineClip/TimelineClip.svelte.d.ts +3 -0
  24. package/dist/components/molecules/TimelineTrack/TimelineTrack.svelte +82 -12
  25. package/dist/components/molecules/TimelineTrack/TimelineTrack.svelte.d.ts +13 -2
  26. package/dist/components/molecules/TransportControls/TransportControls.svelte +84 -2
  27. package/dist/components/molecules/TransportControls/TransportControls.svelte.d.ts +13 -1
  28. package/dist/components/molecules/TransportControls/TransportControlsStory.svelte +33 -11
  29. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.stories.js +8 -0
  30. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.svelte +213 -0
  31. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanel.svelte.d.ts +22 -0
  32. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanelStory.svelte +7 -0
  33. package/dist/components/molecules/VideoAnimationsPanel/VideoAnimationsPanelStory.svelte.d.ts +18 -0
  34. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.stories.js +8 -0
  35. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.svelte +524 -0
  36. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanel.svelte.d.ts +32 -0
  37. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanelStory.svelte +7 -0
  38. package/dist/components/molecules/VideoComponentsPanel/VideoComponentsPanelStory.svelte.d.ts +18 -0
  39. package/dist/components/molecules/VideoTrackHeader/VideoTrackHeader.svelte +85 -32
  40. package/dist/components/molecules/VideoTrackHeader/VideoTrackHeader.svelte.d.ts +6 -0
  41. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.stories.js +8 -0
  42. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.svelte +113 -0
  43. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanel.svelte.d.ts +18 -0
  44. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanelStory.svelte +7 -0
  45. package/dist/components/molecules/VideoTransitionsPanel/VideoTransitionsPanelStory.svelte.d.ts +18 -0
  46. package/dist/components/organisms/AudioEditor/AudioEditor.svelte +52 -1
  47. package/dist/components/organisms/EditorShell/EditorShell.svelte +102 -92
  48. package/dist/components/organisms/FileUploader/FileUploader.stories.d.ts +10 -0
  49. package/dist/components/organisms/FileUploader/FileUploader.stories.js +21 -0
  50. package/dist/components/organisms/FileUploader/FileUploader.svelte +61 -21
  51. package/dist/components/organisms/FileUploader/FileUploader.svelte.d.ts +9 -0
  52. package/dist/components/organisms/FileUploader/FileUploaderStory.svelte +7 -1
  53. package/dist/components/organisms/FileUploader/FileUploaderStory.svelte.d.ts +3 -0
  54. package/dist/components/organisms/VideoEditor/VideoEditor.svelte +1457 -78
  55. package/dist/components/organisms/VideoEditor/VideoEditor.svelte.d.ts +2 -1
  56. package/dist/components/organisms/VideoEditor/VideoEditorStory.svelte +3 -2
  57. package/dist/index.d.ts +11 -3
  58. package/dist/index.js +6 -2
  59. package/dist/styles.css +237 -0
  60. package/dist/utils/mediaTime.d.ts +14 -4
  61. package/dist/utils/mediaTime.js +27 -8
  62. package/dist/utils/mediaTracks.d.ts +154 -0
  63. package/dist/utils/mediaTracks.js +697 -1
  64. package/dist/utils/videoSequence.js +35 -4
  65. package/package.json +4 -2
@@ -1,30 +1,88 @@
1
1
  <script lang="ts">
2
2
  import EditorShell from '../EditorShell/EditorShell.svelte';
3
3
  import MediaAssetBrowser from '../../molecules/MediaAssetBrowser/MediaAssetBrowser.svelte';
4
+ import VideoComponentsPanel, {
5
+ VIDEO_COMPONENT_MIME,
6
+ type VideoComponentDef
7
+ } from '../../molecules/VideoComponentsPanel/VideoComponentsPanel.svelte';
8
+ import VideoAnimationsPanel, {
9
+ VIDEO_ANIMATION_MIME,
10
+ type VideoAnimationDef
11
+ } from '../../molecules/VideoAnimationsPanel/VideoAnimationsPanel.svelte';
12
+ import VideoTransitionsPanel, {
13
+ VIDEO_TRANSITION_MIME,
14
+ type VideoTransitionDef
15
+ } from '../../molecules/VideoTransitionsPanel/VideoTransitionsPanel.svelte';
4
16
  import ProgramMonitor from '../../molecules/ProgramMonitor/ProgramMonitor.svelte';
5
17
  import TransportControls from '../../molecules/TransportControls/TransportControls.svelte';
6
18
  import MediaTimeline from '../../molecules/MediaTimeline/MediaTimeline.svelte';
19
+ import ContextMenu from '../../molecules/ContextMenu/ContextMenu.svelte';
20
+ import type { ContextMenuItem } from '../../molecules/ContextMenu/ContextMenu.svelte';
21
+ import BulkActionBar from '../../molecules/BulkActionBar/BulkActionBar.svelte';
22
+ import type { BulkAction } from '../../molecules/BulkActionBar/BulkActionBar.svelte';
23
+ import FileUploader from '../FileUploader/FileUploader.svelte';
7
24
  import PropertyGroup from '../../molecules/PropertyGroup/PropertyGroup.svelte';
8
25
  import PropertyField from '../../molecules/PropertyField/PropertyField.svelte';
9
26
  import Input from '../../atoms/Input/Input.svelte';
27
+ import NumberInput from '../../molecules/NumberInput/NumberInput.svelte';
28
+ import ColorPicker from '../../molecules/ColorPicker/ColorPicker.svelte';
10
29
  import Button from '../../atoms/Button/Button.svelte';
11
30
  import IconButton from '../../atoms/IconButton/IconButton.svelte';
12
31
  import {
13
32
  createMediaClip,
33
+ createMediaTrack,
14
34
  updateClipInTracks,
15
- removeClipFromTracks,
35
+ removeClipsFromTracks,
36
+ splitClipsAt,
37
+ mergeClipsInTracks,
38
+ canMergeMediaClips,
39
+ duplicateClipInTracks,
40
+ findClipById,
41
+ nextTrackName,
42
+ reorderTracks,
43
+ removeTrack,
44
+ defaultOverlayRect,
45
+ alignClipRect,
46
+ resolveClipRect,
47
+ moveClipsByDelta,
48
+ isMediaClipFieldModified,
49
+ resetMediaClipField,
50
+ kindFromFile,
51
+ probeMediaSrc,
52
+ createMediaAsset,
16
53
  type MediaAsset,
54
+ type MediaAssetKind,
17
55
  type MediaClip,
18
- type MediaTrack
56
+ type MediaClipAlign,
57
+ type MediaClipResettableField,
58
+ type MediaTrack,
59
+ type MediaTrackKind
19
60
  } from '../../../utils/mediaTracks.js';
20
- import {
21
- emptyVideoSequence,
22
- type VideoSequence
23
- } from '../../../utils/videoSequence.js';
61
+ import { emptyVideoSequence, type VideoSequence } from '../../../utils/videoSequence.js';
24
62
  import { clampMs } from '../../../utils/mediaTime.js';
63
+ import Tooltip from '../../atoms/Tooltip/Tooltip.svelte';
64
+ import Slider from '../../atoms/Slider/Slider.svelte';
25
65
  import Download from '@lucide/svelte/icons/download';
26
66
  import PanelLeft from '@lucide/svelte/icons/panel-left';
27
67
  import PanelRight from '@lucide/svelte/icons/panel-right';
68
+ import Scissors from '@lucide/svelte/icons/scissors';
69
+ import Copy from '@lucide/svelte/icons/copy';
70
+ import Combine from '@lucide/svelte/icons/combine';
71
+ import Trash2 from '@lucide/svelte/icons/trash-2';
72
+ import AlignStartVertical from '@lucide/svelte/icons/align-start-vertical';
73
+ import AlignCenterVertical from '@lucide/svelte/icons/align-center-vertical';
74
+ import AlignEndVertical from '@lucide/svelte/icons/align-end-vertical';
75
+ import AlignStartHorizontal from '@lucide/svelte/icons/align-start-horizontal';
76
+ import AlignCenterHorizontal from '@lucide/svelte/icons/align-center-horizontal';
77
+ import AlignEndHorizontal from '@lucide/svelte/icons/align-end-horizontal';
78
+ import AlignCenter from '@lucide/svelte/icons/align-center';
79
+ import AlignLeft from '@lucide/svelte/icons/align-left';
80
+ import AlignRight from '@lucide/svelte/icons/align-right';
81
+ import Maximize2 from '@lucide/svelte/icons/maximize-2';
82
+ import type { Component } from 'svelte';
83
+ import { onDestroy } from 'svelte';
84
+
85
+ type IconComp = Component<{ class?: string }>;
28
86
 
29
87
  interface VideoEditorProps {
30
88
  value?: VideoSequence;
@@ -40,23 +98,150 @@
40
98
  onexport
41
99
  }: VideoEditorProps = $props();
42
100
 
43
- let selectedClipId = $state<string | null>(null);
101
+ let selectedClipIds = $state<string[]>([]);
44
102
  let currentTimeMs = $state(0);
45
103
  let playing = $state(false);
104
+ let playbackRate = $state(1);
105
+ let muted = $state(false);
106
+ let volume = $state(1);
46
107
  let zoom = $state(1);
47
108
  let showSidebar = $state(true);
48
109
  let showInspector = $state(true);
110
+ let sidebarTab = $state<'components' | 'animations' | 'transitions' | 'assets'>('components');
111
+ let ctxOpen = $state(false);
112
+ let ctxAnchor = $state<{ x: number; y: number } | null>(null);
113
+ let ctxTrackId = $state<string | null>(null);
49
114
  let raf = 0;
50
115
  let lastTs = 0;
116
+ let transitionPreviewTimer = 0;
117
+ const SKIP_MS = 5000;
118
+
119
+ onDestroy(() => {
120
+ window.clearTimeout(transitionPreviewTimer);
121
+ });
51
122
 
52
- const selectedClip = $derived.by(() => {
53
- for (const t of value.tracks) {
54
- const c = t.clips.find((x) => x.id === selectedClipId);
55
- if (c) return c;
123
+ const primaryClipId = $derived(selectedClipIds[selectedClipIds.length - 1] ?? null);
124
+ const selectedClip = $derived(primaryClipId ? findClipById(value.tracks, primaryClipId) : null);
125
+ const canMerge = $derived(canMergeMediaClips(value.tracks, selectedClipIds));
126
+ const canSplit = $derived(
127
+ !!selectedClip && (selectedClip.kind === 'video' || selectedClip.kind === 'audio')
128
+ );
129
+ const canBulkSplit = $derived(
130
+ selectedClipIds.some((id) => {
131
+ const c = findClipById(value.tracks, id);
132
+ return !!c && (c.kind === 'video' || c.kind === 'audio');
133
+ })
134
+ );
135
+
136
+ const bulkActions = $derived.by((): BulkAction[] => [
137
+ {
138
+ id: 'split',
139
+ label: 'Split at playhead',
140
+ icon: Scissors,
141
+ variant: 'ghost',
142
+ disabled: !canBulkSplit
143
+ },
144
+ { id: 'merge', label: 'Merge', icon: Combine, variant: 'ghost', disabled: !canMerge },
145
+ { id: 'duplicate', label: 'Duplicate', icon: Copy, variant: 'ghost' },
146
+ {
147
+ id: 'delete',
148
+ label: 'Delete',
149
+ icon: Trash2,
150
+ variant: 'destructive',
151
+ confirm: true,
152
+ confirmLabel: `Delete ${selectedClipIds.length}`
56
153
  }
57
- return null;
154
+ ]);
155
+
156
+ function handleBulk(id: string) {
157
+ if (id === 'split') splitSelected();
158
+ else if (id === 'merge') mergeSelected();
159
+ else if (id === 'duplicate') duplicateSelected();
160
+ else if (id === 'delete') deleteSelected();
161
+ }
162
+
163
+ const ctxItems = $derived.by((): ContextMenuItem[] => {
164
+ const hasSel = selectedClipIds.length > 0;
165
+ const canDeleteTrack = !!ctxTrackId && value.tracks.length > 1;
166
+ return [
167
+ {
168
+ id: 'split',
169
+ label: 'Split at playhead',
170
+ shortcut: 'S',
171
+ disabled: !canSplit,
172
+ category: 'Edit'
173
+ },
174
+ {
175
+ id: 'merge',
176
+ label: 'Merge selected',
177
+ disabled: !canMerge,
178
+ category: 'Edit'
179
+ },
180
+ {
181
+ id: 'duplicate',
182
+ label: 'Duplicate',
183
+ shortcut: '⌘D',
184
+ disabled: !hasSel,
185
+ category: 'Edit'
186
+ },
187
+ { id: 'sep-1', label: '', separator: true },
188
+ {
189
+ id: 'add-clip',
190
+ label: 'Add empty clip',
191
+ category: 'Insert',
192
+ disabled: !ctxTrackId && !hasSel
193
+ },
194
+ { id: 'add-video-track', label: 'Add video track', category: 'Tracks' },
195
+ { id: 'add-audio-track', label: 'Add audio track', category: 'Tracks' },
196
+ {
197
+ id: 'delete-track',
198
+ label: 'Delete track',
199
+ destructive: true,
200
+ disabled: !canDeleteTrack,
201
+ category: 'Tracks'
202
+ },
203
+ { id: 'sep-2', label: '', separator: true },
204
+ {
205
+ id: 'delete',
206
+ label: selectedClipIds.length > 1 ? `Delete ${selectedClipIds.length} clips` : 'Delete',
207
+ destructive: true,
208
+ shortcut: '⌫',
209
+ disabled: !hasSel,
210
+ category: 'Edit'
211
+ }
212
+ ];
58
213
  });
59
214
 
215
+ const alignDock: { id: MediaClipAlign; label: string; icon: IconComp }[] = [
216
+ { id: 'left', label: 'Align left', icon: AlignStartVertical },
217
+ { id: 'center-h', label: 'Align center', icon: AlignCenterVertical },
218
+ { id: 'right', label: 'Align right', icon: AlignEndVertical },
219
+ { id: 'top', label: 'Align top', icon: AlignStartHorizontal },
220
+ { id: 'center-v', label: 'Align middle', icon: AlignCenterHorizontal },
221
+ { id: 'bottom', label: 'Align bottom', icon: AlignEndHorizontal },
222
+ { id: 'center', label: 'Center on frame', icon: AlignCenter },
223
+ { id: 'full', label: 'Fill frame', icon: Maximize2 }
224
+ ];
225
+
226
+ const textAlignDock: { id: 'left' | 'center' | 'right'; label: string; icon: IconComp }[] = [
227
+ { id: 'left', label: 'Align left', icon: AlignLeft },
228
+ { id: 'center', label: 'Align center', icon: AlignCenter },
229
+ { id: 'right', label: 'Align right', icon: AlignRight }
230
+ ];
231
+
232
+ const selectedRect = $derived(
233
+ selectedClip
234
+ ? resolveClipRect(selectedClip, { width: value.width, height: value.height })
235
+ : null
236
+ );
237
+ const showTransform = $derived(
238
+ !!selectedClip &&
239
+ selectedClip.kind !== 'audio' &&
240
+ (selectedClip.kind === 'text' ||
241
+ !!selectedClip.rect ||
242
+ (selectedClip.kind === 'image' && !selectedClip.src))
243
+ );
244
+
60
245
  function emit(next: VideoSequence) {
61
246
  value = next;
62
247
  onchange?.(next);
@@ -66,8 +251,52 @@
66
251
  emit({ ...value, tracks });
67
252
  }
68
253
 
254
+ function patchSequence(partial: Partial<VideoSequence>) {
255
+ emit({ ...value, ...partial });
256
+ }
257
+
258
+ function selectClip(id: string, e?: MouseEvent) {
259
+ const multi = e && (e.metaKey || e.ctrlKey || e.shiftKey);
260
+ if (multi) {
261
+ if (selectedClipIds.includes(id)) {
262
+ selectedClipIds = selectedClipIds.filter((x) => x !== id);
263
+ } else {
264
+ selectedClipIds = [...selectedClipIds, id];
265
+ }
266
+ } else if (selectedClipIds.includes(id)) {
267
+ // Keep multi-selection when dragging / clicking an already-selected clip
268
+ return;
269
+ } else {
270
+ selectedClipIds = [id];
271
+ }
272
+ }
273
+
274
+ function setSelection(ids: string[]) {
275
+ selectedClipIds = ids;
276
+ }
277
+
278
+ function openClipMenu(clipId: string, e: MouseEvent) {
279
+ e.preventDefault();
280
+ e.stopPropagation();
281
+ if (!selectedClipIds.includes(clipId)) {
282
+ selectedClipIds = [clipId];
283
+ }
284
+ ctxTrackId = findClipById(value.tracks, clipId)?.trackId ?? null;
285
+ // Update anchor in place so an already-open menu retargets without flicker
286
+ ctxAnchor = { x: e.clientX, y: e.clientY };
287
+ ctxOpen = true;
288
+ }
289
+
290
+ function openTrackMenu(trackId: string, e: MouseEvent) {
291
+ e.preventDefault();
292
+ e.stopPropagation();
293
+ ctxTrackId = trackId;
294
+ ctxAnchor = { x: e.clientX, y: e.clientY };
295
+ ctxOpen = true;
296
+ }
297
+
69
298
  function addAssetAsClip(asset: MediaAsset) {
70
- const trackKind = asset.kind === 'audio' ? 'audio' : 'video';
299
+ const trackKind: MediaTrackKind = asset.kind === 'audio' ? 'audio' : 'video';
71
300
  const track =
72
301
  value.tracks.find((t) => t.kind === trackKind) ??
73
302
  value.tracks.find((t) => t.kind === 'video') ??
@@ -83,7 +312,8 @@
83
312
  src: asset.src,
84
313
  startMs,
85
314
  endMs: startMs + dur,
86
- color: track.color ?? (trackKind === 'audio' ? '#10b981' : '#3b82f6')
315
+ color: track.color ?? (trackKind === 'audio' ? '#10b981' : '#3b82f6'),
316
+ text: asset.kind === 'text' ? asset.name : undefined
87
317
  });
88
318
  const assets = value.assets.some((a) => a.id === asset.id)
89
319
  ? value.assets
@@ -95,21 +325,467 @@
95
325
  t.id === track.id ? { ...t, clips: [...t.clips, clip] } : t
96
326
  )
97
327
  });
98
- selectedClipId = clip.id;
328
+ selectedClipIds = [clip.id];
329
+ }
330
+
331
+ function addComponent(def: VideoComponentDef, at?: { trackId?: string; startMs?: number }) {
332
+ const kind: MediaAssetKind =
333
+ def.kind === 'solid' ? 'image' : def.kind === 'image' ? 'image' : 'text';
334
+ const startMs = at?.startMs ?? currentTimeMs;
335
+ const frame = { width: value.width, height: value.height };
336
+ const rect = defaultOverlayRect(frame, def.rectPreset ?? (kind === 'text' ? 'center' : 'full'));
337
+
338
+ if (at?.trackId) {
339
+ const track = value.tracks.find((t) => t.id === at.trackId);
340
+ if (!track || track.kind === 'audio') {
341
+ // Fall through to new track
342
+ } else {
343
+ const clip = createMediaClip({
344
+ trackId: track.id,
345
+ name: def.label,
346
+ kind,
347
+ text: def.kind === 'text' ? def.text : undefined,
348
+ src: def.src,
349
+ startMs,
350
+ endMs: startMs + def.durationMs,
351
+ color: def.color ?? '#6366f1',
352
+ textColor: def.textColor,
353
+ fontSize: def.fontSize,
354
+ fontWeight: def.fontWeight,
355
+ fontFamily: def.fontFamily,
356
+ textAlign: def.textAlign,
357
+ opacity: 1,
358
+ previewOpacity: def.previewOpacity ?? def.opacity,
359
+ rect
360
+ });
361
+ setTracks(
362
+ value.tracks.map((t) =>
363
+ t.id === track.id ? { ...t, clips: [...t.clips, clip] } : t
364
+ )
365
+ );
366
+ selectedClipIds = [clip.id];
367
+ return;
368
+ }
369
+ }
370
+
371
+ const trackKind: MediaTrackKind = 'video';
372
+ const track = createMediaTrack({
373
+ name: nextTrackName(value.tracks, trackKind),
374
+ kind: trackKind,
375
+ color: def.color ?? '#6366f1'
376
+ });
377
+ const clip = createMediaClip({
378
+ trackId: track.id,
379
+ name: def.label,
380
+ kind,
381
+ text: def.kind === 'text' ? def.text : undefined,
382
+ src: def.src,
383
+ startMs,
384
+ endMs: startMs + def.durationMs,
385
+ color: def.color ?? '#6366f1',
386
+ textColor: def.textColor,
387
+ fontSize: def.fontSize,
388
+ fontWeight: def.fontWeight,
389
+ fontFamily: def.fontFamily,
390
+ textAlign: def.textAlign,
391
+ opacity: 1,
392
+ previewOpacity: def.previewOpacity ?? def.opacity,
393
+ rect
394
+ });
395
+ const audioIdx = value.tracks.findIndex((t) => t.kind === 'audio');
396
+ const next = [...value.tracks];
397
+ const insertAt = audioIdx >= 0 ? audioIdx : next.length;
398
+ next.splice(insertAt, 0, { ...track, clips: [clip] });
399
+ setTracks(next);
400
+ selectedClipIds = [clip.id];
401
+ }
402
+
403
+ function addEmptyClip(trackId?: string | null) {
404
+ const track =
405
+ (trackId && value.tracks.find((t) => t.id === trackId)) ||
406
+ (selectedClip && value.tracks.find((t) => t.id === selectedClip.trackId)) ||
407
+ value.tracks.find((t) => t.kind === 'video') ||
408
+ value.tracks[0];
409
+ if (!track) return;
410
+ const startMs = currentTimeMs;
411
+ const kind: MediaAssetKind = track.kind === 'audio' ? 'audio' : 'video';
412
+ const clip = createMediaClip({
413
+ trackId: track.id,
414
+ name: 'New clip',
415
+ kind,
416
+ startMs,
417
+ endMs: startMs + 3000,
418
+ color: track.color ?? (kind === 'audio' ? '#10b981' : '#3b82f6')
419
+ });
420
+ setTracks(
421
+ value.tracks.map((t) =>
422
+ t.id === track.id ? { ...t, clips: [...t.clips, clip] } : t
423
+ )
424
+ );
425
+ selectedClipIds = [clip.id];
426
+ }
427
+
428
+ function addTrack(kind: MediaTrackKind) {
429
+ const name = nextTrackName(value.tracks, kind);
430
+ const track = createMediaTrack({
431
+ name,
432
+ kind,
433
+ color: kind === 'audio' ? '#10b981' : '#3b82f6'
434
+ });
435
+ setTracks([...value.tracks, track]);
436
+ }
437
+
438
+ function deleteTrack(trackId: string) {
439
+ if (value.tracks.length <= 1) return;
440
+ const track = value.tracks.find((t) => t.id === trackId);
441
+ if (!track) return;
442
+ const removedClipIds = new Set(track.clips.map((c) => c.id));
443
+ setTracks(removeTrack(value.tracks, trackId));
444
+ selectedClipIds = selectedClipIds.filter((id) => !removedClipIds.has(id));
445
+ if (ctxTrackId === trackId) ctxTrackId = null;
446
+ }
447
+
448
+ function reorderTrackIds(orderedIds: string[]) {
449
+ setTracks(reorderTracks(value.tracks, orderedIds));
450
+ }
451
+
452
+ function applyAlign(align: MediaClipAlign) {
453
+ if (!selectedClipIds.length) return;
454
+ const frame = { width: value.width, height: value.height };
455
+ let tracks = value.tracks;
456
+ for (const id of selectedClipIds) {
457
+ tracks = updateClipInTracks(tracks, id, (c) => {
458
+ if (c.kind === 'audio' || c.kind === 'video') {
459
+ // Allow transform on overlays / text / solids; skip pure A/V full-frame
460
+ if (!c.rect && c.kind === 'video') return c;
461
+ if (!c.rect && c.kind === 'audio') return c;
462
+ }
463
+ return alignClipRect(c, frame, align);
464
+ });
465
+ }
466
+ setTracks(tracks);
467
+ }
468
+
469
+ function patchSelectedRect(partial: Partial<{ x: number; y: number; w: number; h: number }>) {
470
+ if (!selectedClip || !selectedRect) return;
471
+ patchClip({
472
+ ...selectedClip,
473
+ rect: {
474
+ x: partial.x ?? selectedRect.x,
475
+ y: partial.y ?? selectedRect.y,
476
+ w: Math.max(1, partial.w ?? selectedRect.w),
477
+ h: Math.max(1, partial.h ?? selectedRect.h)
478
+ }
479
+ });
480
+ }
481
+
482
+ function clipFieldModified(field: MediaClipResettableField): boolean {
483
+ return (
484
+ !!selectedClip &&
485
+ isMediaClipFieldModified(selectedClip, field, {
486
+ width: value.width,
487
+ height: value.height
488
+ })
489
+ );
490
+ }
491
+
492
+ function resetClipField(field: MediaClipResettableField) {
493
+ if (!selectedClip) return;
494
+ patchClip(
495
+ resetMediaClipField(selectedClip, field, {
496
+ width: value.width,
497
+ height: value.height
498
+ })
499
+ );
500
+ }
501
+
502
+ function acceptForClipKind(kind: MediaAssetKind): string {
503
+ if (kind === 'audio') return 'audio/*';
504
+ if (kind === 'image') return 'image/*';
505
+ if (kind === 'video') return 'video/*';
506
+ return 'image/*,video/*,audio/*';
507
+ }
508
+
509
+ async function replaceSelectedMedia(files: File[]) {
510
+ const file = files[0];
511
+ if (!file || !selectedClip) return;
512
+ const kind = kindFromFile(file);
513
+ if (kind === 'text') return;
514
+ // Keep clip kind aligned with the track role when possible
515
+ const nextKind: MediaAssetKind =
516
+ selectedClip.kind === 'audio'
517
+ ? 'audio'
518
+ : kind === 'audio'
519
+ ? selectedClip.kind
520
+ : kind;
521
+ if (selectedClip.kind === 'audio' && kind !== 'audio') return;
522
+ if (selectedClip.kind !== 'audio' && kind === 'audio') return;
523
+
524
+ const src = URL.createObjectURL(file);
525
+ const meta = await probeMediaSrc(src, nextKind);
526
+ const asset = createMediaAsset({
527
+ kind: nextKind,
528
+ name: file.name,
529
+ src,
530
+ ...meta
531
+ });
532
+ const dur = meta.durationMs;
533
+ const startMs = selectedClip.startMs;
534
+ const endMs =
535
+ dur != null
536
+ ? startMs + dur
537
+ : selectedClip.endMs > startMs
538
+ ? selectedClip.endMs
539
+ : startMs + 5000;
540
+
541
+ emit({
542
+ ...value,
543
+ assets: value.assets.some((a) => a.id === asset.id)
544
+ ? value.assets
545
+ : [...value.assets, asset],
546
+ tracks: updateClipInTracks(value.tracks, selectedClip.id, (c) => ({
547
+ ...c,
548
+ assetId: asset.id,
549
+ name: file.name,
550
+ kind: nextKind,
551
+ src,
552
+ endMs
553
+ }))
554
+ });
555
+ }
556
+
557
+ function clearSelectedMedia() {
558
+ if (!selectedClip) return;
559
+ patchClip({
560
+ ...selectedClip,
561
+ src: undefined,
562
+ assetId: undefined
563
+ });
564
+ }
565
+
566
+ function applyAnimation(def: VideoAnimationDef, clipIds = selectedClipIds) {
567
+ if (!clipIds.length) return;
568
+ let tracks = value.tracks;
569
+ for (const id of clipIds) {
570
+ tracks = updateClipInTracks(tracks, id, (c) => {
571
+ if (def.slot === 'in') {
572
+ return { ...c, animationIn: { type: def.type, durationMs: def.durationMs } };
573
+ }
574
+ if (def.slot === 'out') {
575
+ return { ...c, animationOut: { type: def.type, durationMs: def.durationMs } };
576
+ }
577
+ // combo
578
+ return {
579
+ ...c,
580
+ animationIn: { type: def.type, durationMs: def.durationMs },
581
+ animationOut: {
582
+ type: def.outType ?? def.type,
583
+ durationMs: def.durationMs
584
+ }
585
+ };
586
+ });
587
+ }
588
+ setTracks(tracks);
589
+ }
590
+
591
+ function applyTransition(def: VideoTransitionDef, clipIds = selectedClipIds) {
592
+ if (!clipIds.length) return;
593
+ let tracks = value.tracks;
594
+ for (const id of clipIds) {
595
+ tracks = updateClipInTracks(tracks, id, (c) => ({
596
+ ...c,
597
+ transitionOut: { type: def.type, durationMs: def.durationMs }
598
+ }));
599
+ }
600
+ setTracks(tracks);
601
+ // Preview: play through the transition window so the effect is obvious
602
+ const primary = findClipById(tracks, clipIds[clipIds.length - 1] ?? '');
603
+ if (primary?.transitionOut) {
604
+ const dur = Math.min(
605
+ primary.transitionOut.durationMs,
606
+ Math.max(120, primary.endMs - primary.startMs)
607
+ );
608
+ const start = clampMs(primary.endMs - dur, 0, value.durationMs);
609
+ currentTimeMs = start;
610
+ playing = true;
611
+ window.clearTimeout(transitionPreviewTimer);
612
+ transitionPreviewTimer = window.setTimeout(
613
+ () => {
614
+ playing = false;
615
+ currentTimeMs = clampMs(primary.endMs - 1, 0, value.durationMs);
616
+ },
617
+ Math.round(dur / Math.max(0.25, playbackRate)) + 120
618
+ );
619
+ }
620
+ }
621
+
622
+ function handleClipDrop(clipId: string, e: DragEvent) {
623
+ const animRaw = e.dataTransfer?.getData(VIDEO_ANIMATION_MIME);
624
+ if (animRaw) {
625
+ try {
626
+ applyAnimation(JSON.parse(animRaw) as VideoAnimationDef, [clipId]);
627
+ selectedClipIds = [clipId];
628
+ } catch {
629
+ /* ignore */
630
+ }
631
+ return;
632
+ }
633
+ const transRaw = e.dataTransfer?.getData(VIDEO_TRANSITION_MIME);
634
+ if (transRaw) {
635
+ try {
636
+ applyTransition(JSON.parse(transRaw) as VideoTransitionDef, [clipId]);
637
+ selectedClipIds = [clipId];
638
+ } catch {
639
+ /* ignore */
640
+ }
641
+ return;
642
+ }
643
+ const compRaw = e.dataTransfer?.getData(VIDEO_COMPONENT_MIME);
644
+ if (compRaw) {
645
+ try {
646
+ const def = JSON.parse(compRaw) as VideoComponentDef;
647
+ const clip = findClipById(value.tracks, clipId);
648
+ addComponent(def, { trackId: clip?.trackId, startMs: clip?.endMs ?? currentTimeMs });
649
+ } catch {
650
+ /* ignore */
651
+ }
652
+ }
653
+ }
654
+
655
+ function handleTrackDrop(trackId: string, e: DragEvent, timeMs: number) {
656
+ const animRaw = e.dataTransfer?.getData(VIDEO_ANIMATION_MIME);
657
+ if (animRaw) {
658
+ const track = value.tracks.find((t) => t.id === trackId);
659
+ const hit = track?.clips.find((c) => timeMs >= c.startMs && timeMs < c.endMs);
660
+ if (hit) {
661
+ try {
662
+ applyAnimation(JSON.parse(animRaw) as VideoAnimationDef, [hit.id]);
663
+ selectedClipIds = [hit.id];
664
+ } catch {
665
+ /* ignore */
666
+ }
667
+ }
668
+ return;
669
+ }
670
+ const transRaw = e.dataTransfer?.getData(VIDEO_TRANSITION_MIME);
671
+ if (transRaw) {
672
+ const track = value.tracks.find((t) => t.id === trackId);
673
+ const hit = track?.clips.find((c) => timeMs >= c.startMs && timeMs < c.endMs);
674
+ if (hit) {
675
+ try {
676
+ applyTransition(JSON.parse(transRaw) as VideoTransitionDef, [hit.id]);
677
+ selectedClipIds = [hit.id];
678
+ } catch {
679
+ /* ignore */
680
+ }
681
+ }
682
+ return;
683
+ }
684
+ const compRaw = e.dataTransfer?.getData(VIDEO_COMPONENT_MIME);
685
+ if (!compRaw) return;
686
+ try {
687
+ const def = JSON.parse(compRaw) as VideoComponentDef;
688
+ addComponent(def, { trackId, startMs: timeMs });
689
+ } catch {
690
+ /* ignore */
691
+ }
99
692
  }
100
693
 
101
694
  function patchClip(clip: MediaClip) {
102
695
  setTracks(updateClipInTracks(value.tracks, clip.id, () => clip));
103
696
  }
104
697
 
698
+ function patchClips(clips: MediaClip[]) {
699
+ if (!clips.length) return;
700
+ let tracks = value.tracks;
701
+ for (const clip of clips) {
702
+ tracks = updateClipInTracks(tracks, clip.id, () => clip);
703
+ }
704
+ setTracks(tracks);
705
+ }
706
+
707
+ function moveSelectedClips(clipIds: string[], deltaMs: number) {
708
+ setTracks(moveClipsByDelta(value.tracks, clipIds, deltaMs));
709
+ }
710
+
105
711
  function patchTrack(track: MediaTrack) {
106
712
  setTracks(value.tracks.map((t) => (t.id === track.id ? track : t)));
107
713
  }
108
714
 
715
+ function splitSelected() {
716
+ if (!selectedClipIds.length) return;
717
+ setTracks(splitClipsAt(value.tracks, selectedClipIds, currentTimeMs));
718
+ }
719
+
720
+ function mergeSelected() {
721
+ const idsBefore = new Set(value.tracks.flatMap((t) => t.clips.map((c) => c.id)));
722
+ const next = mergeClipsInTracks(value.tracks, selectedClipIds);
723
+ if (!next) return;
724
+ setTracks(next);
725
+ const created = next.flatMap((t) => t.clips).find((c) => !idsBefore.has(c.id));
726
+ selectedClipIds = created ? [created.id] : [];
727
+ }
728
+
729
+ function duplicateSelected() {
730
+ let tracks = value.tracks;
731
+ const created: string[] = [];
732
+ for (const id of selectedClipIds) {
733
+ const before = new Set(tracks.flatMap((t) => t.clips.map((c) => c.id)));
734
+ tracks = duplicateClipInTracks(tracks, id);
735
+ const neu = tracks.flatMap((t) => t.clips).find((c) => !before.has(c.id));
736
+ if (neu) created.push(neu.id);
737
+ }
738
+ setTracks(tracks);
739
+ if (created.length) selectedClipIds = created;
740
+ }
741
+
742
+ function deleteSelected() {
743
+ if (!selectedClipIds.length) return;
744
+ setTracks(removeClipsFromTracks(value.tracks, selectedClipIds));
745
+ selectedClipIds = [];
746
+ }
747
+
748
+ function handleCtx(id: string) {
749
+ if (id === 'split') splitSelected();
750
+ else if (id === 'merge') mergeSelected();
751
+ else if (id === 'duplicate') duplicateSelected();
752
+ else if (id === 'delete') deleteSelected();
753
+ else if (id === 'add-clip') addEmptyClip(ctxTrackId);
754
+ else if (id === 'add-video-track') addTrack('video');
755
+ else if (id === 'add-audio-track') addTrack('audio');
756
+ else if (id === 'delete-track' && ctxTrackId) deleteTrack(ctxTrackId);
757
+ ctxOpen = false;
758
+ }
759
+
760
+ function secValue(ms: number) {
761
+ const s = Math.round(ms / 100) / 10; // 1 decimal second
762
+ return Number.isInteger(s) ? String(s) : s.toFixed(1);
763
+ }
764
+
765
+ function msToSec(ms: number) {
766
+ return Math.round(ms / 100) / 10;
767
+ }
768
+
769
+ function secToMs(s: number) {
770
+ return Math.max(0, s) * 1000;
771
+ }
772
+
773
+ function clearSelection() {
774
+ selectedClipIds = [];
775
+ }
776
+
777
+ function seekBy(deltaMs: number) {
778
+ currentTimeMs = clampMs(currentTimeMs + deltaMs, 0, value.durationMs);
779
+ }
780
+
781
+ function seekTo(ms: number) {
782
+ currentTimeMs = clampMs(ms, 0, value.durationMs);
783
+ }
784
+
109
785
  function tick(ts: number) {
110
786
  if (!playing) return;
111
787
  if (lastTs) {
112
- const delta = ts - lastTs;
788
+ const delta = (ts - lastTs) * playbackRate;
113
789
  currentTimeMs = clampMs(currentTimeMs + delta, 0, value.durationMs);
114
790
  if (currentTimeMs >= value.durationMs) {
115
791
  playing = false;
@@ -137,10 +813,49 @@
137
813
  playing = !playing;
138
814
  return;
139
815
  }
140
- if ((e.key === 'Delete' || e.key === 'Backspace') && selectedClipId) {
816
+ if (e.key === 'Escape') {
141
817
  e.preventDefault();
142
- setTracks(removeClipFromTracks(value.tracks, selectedClipId));
143
- selectedClipId = null;
818
+ clearSelection();
819
+ return;
820
+ }
821
+ if (e.key === 'ArrowLeft') {
822
+ e.preventDefault();
823
+ seekBy(e.shiftKey ? -SKIP_MS : -(1000 / Math.max(1, value.fps)));
824
+ return;
825
+ }
826
+ if (e.key === 'ArrowRight') {
827
+ e.preventDefault();
828
+ seekBy(e.shiftKey ? SKIP_MS : 1000 / Math.max(1, value.fps));
829
+ return;
830
+ }
831
+ if (e.key === 'Home') {
832
+ e.preventDefault();
833
+ seekTo(0);
834
+ return;
835
+ }
836
+ if (e.key === 'End') {
837
+ e.preventDefault();
838
+ seekTo(value.durationMs);
839
+ return;
840
+ }
841
+ if (e.key === 'm' || e.key === 'M') {
842
+ e.preventDefault();
843
+ muted = !muted;
844
+ return;
845
+ }
846
+ if ((e.key === 's' || e.key === 'S') && canSplit) {
847
+ e.preventDefault();
848
+ splitSelected();
849
+ return;
850
+ }
851
+ if ((e.key === 'd' || e.key === 'D') && (e.metaKey || e.ctrlKey) && selectedClipIds.length) {
852
+ e.preventDefault();
853
+ duplicateSelected();
854
+ return;
855
+ }
856
+ if ((e.key === 'Delete' || e.key === 'Backspace') && selectedClipIds.length) {
857
+ e.preventDefault();
858
+ deleteSelected();
144
859
  }
145
860
  }
146
861
 
@@ -154,12 +869,18 @@
154
869
  a.click();
155
870
  URL.revokeObjectURL(url);
156
871
  }
872
+
873
+ const tabBtn = (active: boolean) =>
874
+ [
875
+ 'flex-1 rounded-md px-1.5 py-1.5 text-[10px] font-medium transition-colors',
876
+ active ? 'bg-brand-500 text-white' : 'text-secondary hover:bg-surface-overlay'
877
+ ].join(' ');
157
878
  </script>
158
879
 
159
880
  <svelte:window onkeydown={handleKeydown} />
160
881
 
161
- <div class={['flex h-full min-h-[560px] flex-col', className]}>
162
- <div class="flex items-center gap-2 border-b border-border bg-surface-elevated px-3 py-2">
882
+ <div class={['relative flex h-full min-h-0 flex-col', className]}>
883
+ <div class="flex shrink-0 items-center gap-2 border-b border-border bg-surface-elevated px-3 py-2">
163
884
  <span class="text-sm font-semibold text-primary">Video Editor</span>
164
885
  <div class="ml-auto flex items-center gap-1">
165
886
  <IconButton label="Toggle sidebar" size="sm" onclick={() => (showSidebar = !showSidebar)}>
@@ -181,10 +902,14 @@
181
902
  </div>
182
903
  </div>
183
904
 
184
- <div class="min-h-0 flex-1">
905
+ <div class="min-h-0 flex-1 overflow-hidden">
185
906
  <EditorShell
186
- sidebarTitle="Media"
187
- inspectorTitle="Clip"
907
+ sidebarTitle="Library"
908
+ inspectorTitle={selectedClip
909
+ ? selectedClipIds.length > 1
910
+ ? `${selectedClipIds.length} clips`
911
+ : 'Clip'
912
+ : 'Sequence'}
188
913
  workspaceTitle="Program"
189
914
  bottomTitle="Timeline"
190
915
  showBottom={true}
@@ -195,47 +920,103 @@
195
920
  wrapWorkspace={false}
196
921
  wrapBottom={false}
197
922
  sidebarSize={280}
198
- inspectorSize={260}
199
- bottomSize={220}
923
+ inspectorSize={280}
924
+ bottomSize={300}
925
+ sidebarMin={180}
926
+ sidebarMax={420}
927
+ inspectorMin={200}
928
+ inspectorMax={420}
929
+ bottomMin={180}
930
+ bottomMax={560}
200
931
  sizeUnit="px"
201
932
  fullHeight
933
+ class="h-full"
202
934
  >
203
935
  {#snippet sidebar()}
204
- <MediaAssetBrowser
205
- assets={value.assets}
206
- accept="image/*,video/*,audio/*"
207
- helperText="Image, video or audio"
208
- onassetschange={(assets) => emit({ ...value, assets })}
209
- onselect={(id) => {
210
- const asset = value.assets.find((a) => a.id === id);
211
- if (asset) addAssetAsClip(asset);
212
- }}
213
- onadd={(asset) => {
214
- emit({ ...value, assets: [...value.assets, asset] });
215
- }}
216
- />
936
+ <div class="flex h-full min-h-0 flex-col">
937
+ <div class="flex shrink-0 gap-1 border-b border-border p-2">
938
+ <button
939
+ type="button"
940
+ class={tabBtn(sidebarTab === 'components')}
941
+ onclick={() => (sidebarTab = 'components')}
942
+ >
943
+ Comp
944
+ </button>
945
+ <button
946
+ type="button"
947
+ class={tabBtn(sidebarTab === 'animations')}
948
+ onclick={() => (sidebarTab = 'animations')}
949
+ >
950
+ Anim
951
+ </button>
952
+ <button
953
+ type="button"
954
+ class={tabBtn(sidebarTab === 'transitions')}
955
+ onclick={() => (sidebarTab = 'transitions')}
956
+ >
957
+ Trans
958
+ </button>
959
+ <button
960
+ type="button"
961
+ class={tabBtn(sidebarTab === 'assets')}
962
+ onclick={() => (sidebarTab = 'assets')}
963
+ >
964
+ Assets
965
+ </button>
966
+ </div>
967
+ <div class="min-h-0 flex-1 overflow-auto">
968
+ {#if sidebarTab === 'components'}
969
+ <VideoComponentsPanel onadd={addComponent} />
970
+ {:else if sidebarTab === 'animations'}
971
+ <VideoAnimationsPanel
972
+ disabled={!selectedClip}
973
+ onapply={applyAnimation}
974
+ />
975
+ {:else if sidebarTab === 'transitions'}
976
+ <VideoTransitionsPanel
977
+ disabled={!selectedClip}
978
+ onapply={applyTransition}
979
+ />
980
+ {:else}
981
+ <MediaAssetBrowser
982
+ assets={value.assets}
983
+ accept="image/*,video/*,audio/*"
984
+ helperText="Image, video or audio"
985
+ uploadLayout="vertical"
986
+ onassetschange={(assets) => emit({ ...value, assets })}
987
+ onselect={(id) => {
988
+ const asset = value.assets.find((a) => a.id === id);
989
+ if (asset) addAssetAsClip(asset);
990
+ }}
991
+ onadd={(asset) => {
992
+ emit({ ...value, assets: [...value.assets, asset] });
993
+ }}
994
+ />
995
+ {/if}
996
+ </div>
997
+ </div>
217
998
  {/snippet}
218
999
 
219
1000
  {#snippet workspace()}
220
- <div class="flex h-full flex-col gap-2 bg-surface p-3">
221
- <TransportControls
222
- {playing}
223
- {currentTimeMs}
224
- durationMs={value.durationMs}
225
- onplay={() => (playing = true)}
226
- onpause={() => (playing = false)}
227
- onstop={() => {
228
- playing = false;
229
- currentTimeMs = 0;
230
- }}
231
- />
1001
+ <div class="flex h-full min-h-0 flex-col bg-surface p-3">
232
1002
  <div class="min-h-0 flex-1">
233
1003
  <ProgramMonitor
234
1004
  width={value.width}
235
1005
  height={value.height}
236
1006
  background={value.background}
237
1007
  {currentTimeMs}
1008
+ {playing}
1009
+ {playbackRate}
1010
+ {muted}
1011
+ {volume}
238
1012
  tracks={value.tracks}
1013
+ {selectedClipIds}
1014
+ editable={!playing}
1015
+ onselectclip={selectClip}
1016
+ onchangeclip={patchClip}
1017
+ onchangeclips={patchClips}
1018
+ onselectionchange={setSelection}
1019
+ onemptyclick={clearSelection}
239
1020
  class="mx-auto max-h-full"
240
1021
  />
241
1022
  </div>
@@ -244,7 +1025,51 @@
244
1025
 
245
1026
  {#snippet inspector()}
246
1027
  {#if !selectedClip}
247
- <p class="px-3 py-6 text-center text-xs text-muted">Select a clip</p>
1028
+ <PropertyGroup title="Sequence">
1029
+ <PropertyField label="Width">
1030
+ <NumberInput
1031
+ size="sm"
1032
+ min={1}
1033
+ value={value.width}
1034
+ onchange={(v) => patchSequence({ width: v || value.width })}
1035
+ />
1036
+ </PropertyField>
1037
+ <PropertyField label="Height">
1038
+ <NumberInput
1039
+ size="sm"
1040
+ min={1}
1041
+ value={value.height}
1042
+ onchange={(v) => patchSequence({ height: v || value.height })}
1043
+ />
1044
+ </PropertyField>
1045
+ <PropertyField label="FPS">
1046
+ <NumberInput
1047
+ size="sm"
1048
+ min={1}
1049
+ value={value.fps}
1050
+ onchange={(v) => patchSequence({ fps: v || value.fps })}
1051
+ />
1052
+ </PropertyField>
1053
+ <PropertyField label="Duration (s)">
1054
+ <NumberInput
1055
+ size="sm"
1056
+ step={0.1}
1057
+ min={0.1}
1058
+ value={msToSec(value.durationMs)}
1059
+ onchange={(v) =>
1060
+ patchSequence({ durationMs: secToMs(v) || value.durationMs })}
1061
+ />
1062
+ </PropertyField>
1063
+ <PropertyField label="Background">
1064
+ <ColorPicker
1065
+ size="sm"
1066
+ value={value.background}
1067
+ showSwatches={false}
1068
+ onchange={(v) => patchSequence({ background: v })}
1069
+ />
1070
+ </PropertyField>
1071
+ </PropertyGroup>
1072
+ <p class="px-3 py-4 text-center text-xs text-muted">Select a clip to edit</p>
248
1073
  {:else}
249
1074
  <PropertyGroup title="Clip">
250
1075
  <PropertyField label="Name">
@@ -258,51 +1083,605 @@
258
1083
  })}
259
1084
  />
260
1085
  </PropertyField>
261
- <PropertyField label="Start">
262
- <Input
263
- type="number"
1086
+ <PropertyField label="Kind">
1087
+ <span class="text-xs capitalize text-secondary">{selectedClip.kind}</span>
1088
+ </PropertyField>
1089
+ <PropertyField label="Start (s)">
1090
+ <NumberInput
264
1091
  size="sm"
265
- value={String(Math.round(selectedClip.startMs))}
266
- oninput={(e) => {
267
- const startMs = Number((e.currentTarget as HTMLInputElement).value);
1092
+ step={0.1}
1093
+ min={0}
1094
+ value={msToSec(selectedClip.startMs)}
1095
+ onchange={(v) => {
1096
+ const startMs = secToMs(v);
268
1097
  const dur = selectedClip.endMs - selectedClip.startMs;
269
1098
  patchClip({ ...selectedClip, startMs, endMs: startMs + dur });
270
1099
  }}
271
1100
  />
272
1101
  </PropertyField>
273
- <PropertyField label="End">
274
- <Input
275
- type="number"
1102
+ <PropertyField label="End (s)">
1103
+ <NumberInput
276
1104
  size="sm"
277
- value={String(Math.round(selectedClip.endMs))}
278
- oninput={(e) =>
1105
+ step={0.1}
1106
+ min={0}
1107
+ value={msToSec(selectedClip.endMs)}
1108
+ onchange={(v) => patchClip({ ...selectedClip, endMs: secToMs(v) })}
1109
+ />
1110
+ </PropertyField>
1111
+ <PropertyField label="Duration (s)">
1112
+ <NumberInput
1113
+ size="sm"
1114
+ step={0.1}
1115
+ min={0.1}
1116
+ value={msToSec(selectedClip.endMs - selectedClip.startMs)}
1117
+ onchange={(v) =>
279
1118
  patchClip({
280
1119
  ...selectedClip,
281
- endMs: Number((e.currentTarget as HTMLInputElement).value)
1120
+ endMs: selectedClip.startMs + Math.max(50, secToMs(v))
282
1121
  })}
283
1122
  />
284
1123
  </PropertyField>
1124
+ <PropertyField
1125
+ label="Clip opacity"
1126
+ modified={clipFieldModified('opacity')}
1127
+ onreset={() => resetClipField('opacity')}
1128
+ >
1129
+ <Slider
1130
+ size="sm"
1131
+ min={0}
1132
+ max={1}
1133
+ step={0.01}
1134
+ value={selectedClip.opacity ?? 1}
1135
+ showValue
1136
+ valuePosition="header"
1137
+ oninput={(v) => patchClip({ ...selectedClip, opacity: v })}
1138
+ />
1139
+ </PropertyField>
1140
+ {#if selectedClip.kind !== 'audio'}
1141
+ <PropertyField
1142
+ label="Preview opacity"
1143
+ modified={clipFieldModified('previewOpacity')}
1144
+ onreset={() => resetClipField('previewOpacity')}
1145
+ >
1146
+ <Slider
1147
+ size="sm"
1148
+ min={0}
1149
+ max={1}
1150
+ step={0.01}
1151
+ value={selectedClip.previewOpacity ?? 1}
1152
+ showValue
1153
+ valuePosition="header"
1154
+ oninput={(v) => patchClip({ ...selectedClip, previewOpacity: v })}
1155
+ />
1156
+ </PropertyField>
1157
+ {/if}
1158
+ <PropertyField
1159
+ label="Color"
1160
+ modified={clipFieldModified('color')}
1161
+ onreset={() => resetClipField('color')}
1162
+ >
1163
+ <ColorPicker
1164
+ size="sm"
1165
+ value={selectedClip.color ?? '#3b82f6'}
1166
+ showSwatches={false}
1167
+ onchange={(v) => patchClip({ ...selectedClip, color: v })}
1168
+ />
1169
+ </PropertyField>
285
1170
  </PropertyGroup>
1171
+
1172
+ {#if showTransform && selectedRect}
1173
+ <PropertyGroup title="Transform">
1174
+ <div class="mx-1 mb-1.5 flex w-full min-w-0 flex-col gap-1.5">
1175
+ <div
1176
+ class="flex w-full flex-nowrap items-center justify-between gap-0 overflow-x-auto rounded-xl border border-border bg-surface-elevated p-0.5 shadow-sm"
1177
+ role="toolbar"
1178
+ aria-label="Position"
1179
+ >
1180
+ {#each alignDock as action (action.id)}
1181
+ {@const Icon = action.icon}
1182
+ <Tooltip content={action.label} side="top" class="shrink-0">
1183
+ <IconButton
1184
+ label={action.label}
1185
+ size="xs"
1186
+ variant="ghost"
1187
+ onclick={() => applyAlign(action.id)}
1188
+ >
1189
+ <Icon class="h-3.5 w-3.5" />
1190
+ </IconButton>
1191
+ </Tooltip>
1192
+ {/each}
1193
+ </div>
1194
+ </div>
1195
+ <PropertyField
1196
+ label="X"
1197
+ modified={clipFieldModified('rect.x')}
1198
+ onreset={() => resetClipField('rect.x')}
1199
+ >
1200
+ <NumberInput
1201
+ size="sm"
1202
+ value={Math.round(selectedRect.x)}
1203
+ onchange={(v) => patchSelectedRect({ x: v })}
1204
+ />
1205
+ </PropertyField>
1206
+ <PropertyField
1207
+ label="Y"
1208
+ modified={clipFieldModified('rect.y')}
1209
+ onreset={() => resetClipField('rect.y')}
1210
+ >
1211
+ <NumberInput
1212
+ size="sm"
1213
+ value={Math.round(selectedRect.y)}
1214
+ onchange={(v) => patchSelectedRect({ y: v })}
1215
+ />
1216
+ </PropertyField>
1217
+ <PropertyField
1218
+ label="W"
1219
+ modified={clipFieldModified('rect.w')}
1220
+ onreset={() => resetClipField('rect.w')}
1221
+ >
1222
+ <NumberInput
1223
+ size="sm"
1224
+ min={1}
1225
+ value={Math.round(selectedRect.w)}
1226
+ onchange={(v) => patchSelectedRect({ w: v })}
1227
+ />
1228
+ </PropertyField>
1229
+ <PropertyField
1230
+ label="H"
1231
+ modified={clipFieldModified('rect.h')}
1232
+ onreset={() => resetClipField('rect.h')}
1233
+ >
1234
+ <NumberInput
1235
+ size="sm"
1236
+ min={1}
1237
+ value={Math.round(selectedRect.h)}
1238
+ onchange={(v) => patchSelectedRect({ h: v })}
1239
+ />
1240
+ </PropertyField>
1241
+ </PropertyGroup>
1242
+ {/if}
1243
+
1244
+ {#if selectedClip.kind === 'text'}
1245
+ <PropertyGroup title="Text">
1246
+ <PropertyField label="Content">
1247
+ <Input
1248
+ size="sm"
1249
+ value={selectedClip.text ?? ''}
1250
+ oninput={(e) =>
1251
+ patchClip({
1252
+ ...selectedClip,
1253
+ text: (e.currentTarget as HTMLInputElement).value
1254
+ })}
1255
+ />
1256
+ </PropertyField>
1257
+ <PropertyField
1258
+ label="Color"
1259
+ modified={clipFieldModified('textColor')}
1260
+ onreset={() => resetClipField('textColor')}
1261
+ >
1262
+ <ColorPicker
1263
+ size="sm"
1264
+ value={selectedClip.textColor ?? '#ffffff'}
1265
+ showSwatches={false}
1266
+ onchange={(v) => patchClip({ ...selectedClip, textColor: v })}
1267
+ />
1268
+ </PropertyField>
1269
+ <PropertyField
1270
+ label="Size"
1271
+ modified={clipFieldModified('fontSize')}
1272
+ onreset={() => resetClipField('fontSize')}
1273
+ >
1274
+ <Slider
1275
+ size="sm"
1276
+ min={8}
1277
+ max={120}
1278
+ step={1}
1279
+ value={selectedClip.fontSize ?? 18}
1280
+ showValue
1281
+ valuePosition="header"
1282
+ oninput={(v) => patchClip({ ...selectedClip, fontSize: v })}
1283
+ />
1284
+ </PropertyField>
1285
+ <PropertyField
1286
+ label="Weight"
1287
+ modified={clipFieldModified('fontWeight')}
1288
+ onreset={() => resetClipField('fontWeight')}
1289
+ >
1290
+ <Slider
1291
+ size="sm"
1292
+ min={100}
1293
+ max={900}
1294
+ step={100}
1295
+ value={Number(selectedClip.fontWeight ?? 600)}
1296
+ showValue
1297
+ valuePosition="header"
1298
+ oninput={(v) => patchClip({ ...selectedClip, fontWeight: v })}
1299
+ />
1300
+ </PropertyField>
1301
+ <PropertyField
1302
+ label="Align"
1303
+ modified={clipFieldModified('textAlign')}
1304
+ onreset={() => resetClipField('textAlign')}
1305
+ >
1306
+ <div
1307
+ class="flex w-full items-center justify-between gap-0 rounded-xl border border-border bg-surface-elevated p-0.5 shadow-sm"
1308
+ role="toolbar"
1309
+ aria-label="Text align"
1310
+ >
1311
+ {#each textAlignDock as action (action.id)}
1312
+ {@const Icon = action.icon}
1313
+ {@const active = (selectedClip.textAlign ?? 'center') === action.id}
1314
+ <Tooltip content={action.label} side="top" class="min-w-0 flex-1">
1315
+ <IconButton
1316
+ label={action.label}
1317
+ size="xs"
1318
+ variant={active ? 'secondary' : 'ghost'}
1319
+ class="w-full"
1320
+ onclick={() =>
1321
+ patchClip({ ...selectedClip, textAlign: action.id })}
1322
+ >
1323
+ <Icon class="h-3.5 w-3.5" />
1324
+ </IconButton>
1325
+ </Tooltip>
1326
+ {/each}
1327
+ </div>
1328
+ </PropertyField>
1329
+ </PropertyGroup>
1330
+ {/if}
1331
+
1332
+ {#if selectedClip.kind === 'video' || selectedClip.kind === 'image'}
1333
+ <PropertyGroup title={selectedClip.kind === 'video' ? 'Video' : 'Image'}>
1334
+ <div class="px-1 pb-1">
1335
+ {#key selectedClip.id}
1336
+ <FileUploader
1337
+ label="Source"
1338
+ variant="single"
1339
+ layout="vertical"
1340
+ accept={acceptForClipKind(selectedClip.kind)}
1341
+ helperText={selectedClip.kind === 'video' ? 'MP4, WebM…' : 'PNG, JPG, WebP…'}
1342
+ src={selectedClip.src ?? ''}
1343
+ srcName={selectedClip.name}
1344
+ maxSizeMb={200}
1345
+ showFileList={false}
1346
+ onchange={(files) => void replaceSelectedMedia(files)}
1347
+ onclear={clearSelectedMedia}
1348
+ />
1349
+ {/key}
1350
+ </div>
1351
+ {#if selectedClip.kind === 'video'}
1352
+ <PropertyField
1353
+ label="Volume"
1354
+ modified={clipFieldModified('volume')}
1355
+ onreset={() => resetClipField('volume')}
1356
+ >
1357
+ <Slider
1358
+ size="sm"
1359
+ min={0}
1360
+ max={1}
1361
+ step={0.01}
1362
+ value={selectedClip.volume ?? 1}
1363
+ showValue
1364
+ valuePosition="header"
1365
+ oninput={(v) => patchClip({ ...selectedClip, volume: v })}
1366
+ />
1367
+ </PropertyField>
1368
+ {/if}
1369
+ <PropertyField
1370
+ label="Trim in (s)"
1371
+ modified={clipFieldModified('trimInMs')}
1372
+ onreset={() => resetClipField('trimInMs')}
1373
+ >
1374
+ <NumberInput
1375
+ size="sm"
1376
+ step={0.1}
1377
+ min={0}
1378
+ value={msToSec(selectedClip.trimInMs ?? 0)}
1379
+ onchange={(v) => patchClip({ ...selectedClip, trimInMs: secToMs(v) })}
1380
+ />
1381
+ </PropertyField>
1382
+ <PropertyField
1383
+ label="Trim out (s)"
1384
+ modified={clipFieldModified('trimOutMs')}
1385
+ onreset={() => resetClipField('trimOutMs')}
1386
+ >
1387
+ <NumberInput
1388
+ size="sm"
1389
+ step={0.1}
1390
+ min={0}
1391
+ value={msToSec(selectedClip.trimOutMs ?? 0)}
1392
+ onchange={(v) =>
1393
+ patchClip({
1394
+ ...selectedClip,
1395
+ trimOutMs: secToMs(v) || undefined
1396
+ })}
1397
+ />
1398
+ </PropertyField>
1399
+ </PropertyGroup>
1400
+ {/if}
1401
+
1402
+ {#if selectedClip.kind === 'audio'}
1403
+ <PropertyGroup title="Audio">
1404
+ <div class="px-1 pb-1">
1405
+ {#key selectedClip.id}
1406
+ <FileUploader
1407
+ label="Source"
1408
+ variant="single"
1409
+ layout="vertical"
1410
+ accept={acceptForClipKind('audio')}
1411
+ helperText="MP3, WAV, OGG…"
1412
+ src={selectedClip.src ?? ''}
1413
+ srcName={selectedClip.name}
1414
+ maxSizeMb={200}
1415
+ showFileList={false}
1416
+ onchange={(files) => void replaceSelectedMedia(files)}
1417
+ onclear={clearSelectedMedia}
1418
+ />
1419
+ {/key}
1420
+ </div>
1421
+ <PropertyField
1422
+ label="Volume"
1423
+ modified={clipFieldModified('volume')}
1424
+ onreset={() => resetClipField('volume')}
1425
+ >
1426
+ <Slider
1427
+ size="sm"
1428
+ min={0}
1429
+ max={1}
1430
+ step={0.01}
1431
+ value={selectedClip.volume ?? 1}
1432
+ showValue
1433
+ valuePosition="header"
1434
+ oninput={(v) => patchClip({ ...selectedClip, volume: v })}
1435
+ />
1436
+ </PropertyField>
1437
+ <PropertyField
1438
+ label="Trim in (s)"
1439
+ modified={clipFieldModified('trimInMs')}
1440
+ onreset={() => resetClipField('trimInMs')}
1441
+ >
1442
+ <NumberInput
1443
+ size="sm"
1444
+ step={0.1}
1445
+ min={0}
1446
+ value={msToSec(selectedClip.trimInMs ?? 0)}
1447
+ onchange={(v) => patchClip({ ...selectedClip, trimInMs: secToMs(v) })}
1448
+ />
1449
+ </PropertyField>
1450
+ <PropertyField
1451
+ label="Trim out (s)"
1452
+ modified={clipFieldModified('trimOutMs')}
1453
+ onreset={() => resetClipField('trimOutMs')}
1454
+ >
1455
+ <NumberInput
1456
+ size="sm"
1457
+ step={0.1}
1458
+ min={0}
1459
+ value={msToSec(selectedClip.trimOutMs ?? 0)}
1460
+ onchange={(v) =>
1461
+ patchClip({
1462
+ ...selectedClip,
1463
+ trimOutMs: secToMs(v) || undefined
1464
+ })}
1465
+ />
1466
+ </PropertyField>
1467
+ </PropertyGroup>
1468
+ {/if}
1469
+
1470
+ {#if selectedClip.animationIn || selectedClip.animationOut || selectedClip.transitionOut}
1471
+ <PropertyGroup title="Effects">
1472
+ {#if selectedClip.animationIn}
1473
+ <PropertyField label="Anim in">
1474
+ <div class="flex min-w-0 items-center gap-1">
1475
+ <span class="min-w-0 flex-1 truncate text-xs capitalize text-secondary">
1476
+ {selectedClip.animationIn.type}
1477
+ </span>
1478
+ <IconButton
1479
+ label="Remove animation in"
1480
+ size="xs"
1481
+ onclick={() => {
1482
+ const { animationIn: _a, ...rest } = selectedClip;
1483
+ patchClip(rest);
1484
+ }}
1485
+ >
1486
+ <Trash2 class="h-3 w-3" />
1487
+ </IconButton>
1488
+ </div>
1489
+ </PropertyField>
1490
+ <PropertyField label="In duration (s)">
1491
+ <NumberInput
1492
+ size="sm"
1493
+ step={0.05}
1494
+ min={0.05}
1495
+ value={msToSec(selectedClip.animationIn.durationMs)}
1496
+ onchange={(v) =>
1497
+ patchClip({
1498
+ ...selectedClip,
1499
+ animationIn: {
1500
+ ...selectedClip.animationIn!,
1501
+ durationMs: Math.max(50, secToMs(v))
1502
+ }
1503
+ })}
1504
+ />
1505
+ </PropertyField>
1506
+ {/if}
1507
+ {#if selectedClip.animationOut}
1508
+ <PropertyField label="Anim out">
1509
+ <div class="flex min-w-0 items-center gap-1">
1510
+ <span class="min-w-0 flex-1 truncate text-xs capitalize text-secondary">
1511
+ {selectedClip.animationOut.type}
1512
+ </span>
1513
+ <IconButton
1514
+ label="Remove animation out"
1515
+ size="xs"
1516
+ onclick={() => {
1517
+ const { animationOut: _a, ...rest } = selectedClip;
1518
+ patchClip(rest);
1519
+ }}
1520
+ >
1521
+ <Trash2 class="h-3 w-3" />
1522
+ </IconButton>
1523
+ </div>
1524
+ </PropertyField>
1525
+ <PropertyField label="Out duration (s)">
1526
+ <NumberInput
1527
+ size="sm"
1528
+ step={0.05}
1529
+ min={0.05}
1530
+ value={msToSec(selectedClip.animationOut.durationMs)}
1531
+ onchange={(v) =>
1532
+ patchClip({
1533
+ ...selectedClip,
1534
+ animationOut: {
1535
+ ...selectedClip.animationOut!,
1536
+ durationMs: Math.max(50, secToMs(v))
1537
+ }
1538
+ })}
1539
+ />
1540
+ </PropertyField>
1541
+ {/if}
1542
+ {#if selectedClip.transitionOut}
1543
+ <PropertyField label="Transition">
1544
+ <div class="flex min-w-0 items-center gap-1">
1545
+ <span class="min-w-0 flex-1 truncate text-xs capitalize text-secondary">
1546
+ {selectedClip.transitionOut.type}
1547
+ </span>
1548
+ <IconButton
1549
+ label="Remove transition"
1550
+ size="xs"
1551
+ onclick={() => {
1552
+ const { transitionOut: _t, ...rest } = selectedClip;
1553
+ patchClip(rest);
1554
+ }}
1555
+ >
1556
+ <Trash2 class="h-3 w-3" />
1557
+ </IconButton>
1558
+ </div>
1559
+ </PropertyField>
1560
+ <PropertyField label="Trans duration (s)">
1561
+ <NumberInput
1562
+ size="sm"
1563
+ step={0.05}
1564
+ min={0.05}
1565
+ value={msToSec(selectedClip.transitionOut.durationMs)}
1566
+ onchange={(v) =>
1567
+ patchClip({
1568
+ ...selectedClip,
1569
+ transitionOut: {
1570
+ ...selectedClip.transitionOut!,
1571
+ durationMs: Math.max(50, secToMs(v))
1572
+ }
1573
+ })}
1574
+ />
1575
+ </PropertyField>
1576
+ {/if}
1577
+ </PropertyGroup>
1578
+ {:else}
1579
+ <PropertyGroup title="Effects">
1580
+ <p class="px-2 py-2 text-[10px] leading-relaxed text-muted">
1581
+ Select Animations / Transitions in the library, then click or drag onto this clip.
1582
+ CapCut-style: one <span class="text-secondary">In</span> + one
1583
+ <span class="text-secondary"> Out</span> + optional transition at the end.
1584
+ </p>
1585
+ </PropertyGroup>
1586
+ {/if}
1587
+
1588
+ {#if canSplit || canMerge}
1589
+ <div class="mt-3 flex flex-col gap-2 px-3 pb-3">
1590
+ {#if canSplit}
1591
+ <Button size="sm" variant="secondary" onclick={splitSelected}>
1592
+ <span class="inline-flex items-center gap-1.5">
1593
+ <Scissors class="h-3.5 w-3.5" />
1594
+ Split at playhead
1595
+ </span>
1596
+ </Button>
1597
+ {/if}
1598
+ {#if canMerge}
1599
+ <Button size="sm" variant="secondary" onclick={mergeSelected}>Merge selected</Button>
1600
+ {/if}
1601
+ </div>
1602
+ {/if}
286
1603
  {/if}
287
1604
  {/snippet}
288
1605
 
289
1606
  {#snippet bottom()}
290
- <div class="h-full p-1">
291
- <MediaTimeline
292
- tracks={value.tracks}
293
- durationMs={value.durationMs}
294
- bind:currentTimeMs
295
- bind:zoom
296
- {selectedClipId}
297
- onselectclip={(id) => (selectedClipId = id)}
298
- onchangeclip={patchClip}
299
- onchangetrack={patchTrack}
300
- headerVariant="video"
301
- onseek={(ms) => (currentTimeMs = ms)}
302
- class="h-full"
303
- />
1607
+ <div class="flex h-full min-h-0 flex-col gap-1 p-1">
1608
+ <div class="shrink-0 rounded-md border border-border bg-surface-elevated px-2 py-1">
1609
+ <TransportControls
1610
+ {playing}
1611
+ {currentTimeMs}
1612
+ durationMs={value.durationMs}
1613
+ bind:playbackRate
1614
+ bind:muted
1615
+ bind:volume
1616
+ skipMs={SKIP_MS}
1617
+ onplay={() => (playing = true)}
1618
+ onpause={() => (playing = false)}
1619
+ onstop={() => {
1620
+ playing = false;
1621
+ seekTo(0);
1622
+ }}
1623
+ onseekstart={() => seekTo(0)}
1624
+ onseekend={() => seekTo(value.durationMs)}
1625
+ onseekback={() => seekBy(-SKIP_MS)}
1626
+ onseekforward={() => seekBy(SKIP_MS)}
1627
+ />
1628
+ </div>
1629
+ <div class="min-h-0 flex-1">
1630
+ <MediaTimeline
1631
+ tracks={value.tracks}
1632
+ durationMs={value.durationMs}
1633
+ bind:currentTimeMs
1634
+ bind:zoom
1635
+ {selectedClipIds}
1636
+ showTrackActions
1637
+ reorderTracks
1638
+ allowRemoveTracks
1639
+ onselectclip={selectClip}
1640
+ oncontextmenuclip={openClipMenu}
1641
+ oncontextmenutrack={openTrackMenu}
1642
+ onchangeclip={patchClip}
1643
+ onmoveclips={moveSelectedClips}
1644
+ ondropclip={handleClipDrop}
1645
+ ondroptrack={handleTrackDrop}
1646
+ onselectionchange={setSelection}
1647
+ onchangetrack={patchTrack}
1648
+ onaddtrack={addTrack}
1649
+ onremovetrack={deleteTrack}
1650
+ onreordertracks={reorderTrackIds}
1651
+ onemptyclick={() => clearSelection()}
1652
+ headerVariant="video"
1653
+ onseek={(ms) => (currentTimeMs = ms)}
1654
+ class="h-full min-h-0"
1655
+ />
1656
+ </div>
304
1657
  </div>
305
1658
  {/snippet}
306
1659
  </EditorShell>
307
1660
  </div>
1661
+
1662
+ {#if selectedClipIds.length > 1}
1663
+ <div class="pointer-events-none absolute inset-x-0 bottom-4 z-40 flex justify-center px-4">
1664
+ <div class="pointer-events-auto">
1665
+ <BulkActionBar
1666
+ count={selectedClipIds.length}
1667
+ itemLabel="clip"
1668
+ itemLabelPlural="clips"
1669
+ placement="dock"
1670
+ compact
1671
+ showSelectAll={false}
1672
+ maxPrimary={4}
1673
+ actions={bulkActions}
1674
+ onaction={handleBulk}
1675
+ onclear={clearSelection}
1676
+ />
1677
+ </div>
1678
+ </div>
1679
+ {/if}
308
1680
  </div>
1681
+
1682
+ <ContextMenu
1683
+ items={ctxItems}
1684
+ bind:open={ctxOpen}
1685
+ bind:anchor={ctxAnchor}
1686
+ onselect={handleCtx}
1687
+ />