Live demo
Code example
import { TimelineEngine, type Clip, type TimelineClipGroupPlacement, Timeline, TimelineProvider, useTimelineClipGroups, useTimelineExternalClipDrop, CanvasRenderer, fromSeconds, type RationalTime,} from '@techsquidtv/canvas-timeline';import { Clapperboard, Film, Rows3, Unlink2 } from 'lucide-react';import { useMemo, useRef, useState, type CSSProperties, type DragEvent } from 'react';import { demoMarkers, demoTracks, externalClipAssets, type ExternalClipAsset, type ExternalClipDropTrackKind,} from '#www/demos/external-clip-drop/timeline-demo-data';import '@techsquidtv/canvas-timeline/styles.css';import '#www/demos/external-clip-drop/timeline-editor.css';
const externalAssetMimeType = 'application/x-canvas-timeline-external-asset';const demoAudioTrackId = 'audio-main';
function TrackHeaderColumn() { return ( <Timeline.TrackHeaderList className="timeline-editor-track-headers"> {demoTracks.map((track) => ( <Timeline.TrackHeader key={track.id} trackId={track.id}> {(header) => ( <div className="timeline-editor-track-header-content external-drop-track-header-content"> <span className="timeline-editor-track-header-label">{header.label}</span> </div> )} </Timeline.TrackHeader> ))} </Timeline.TrackHeaderList> );}
function TimelineLayers() { return ( <> <Timeline.PlayheadArea /> <Timeline.PlayheadGrabber /> <Timeline.TrackList className="timeline-track-list-overlay"> {demoTracks.map((track) => ( <Timeline.Track key={track.id} trackId={track.id} /> ))} </Timeline.TrackList> <Timeline.ClipInteractionLayer /> <Timeline.RangeSelector /> </> );}
function createExternalClip(asset: ExternalClipAsset, id: string, label: string): Clip { return { id, sourceId: asset.sourceId, timelineStart: fromSeconds(0), timelineEnd: fromSeconds(asset.durationSeconds), sourceStart: fromSeconds(0), selected: false, color: asset.color, label, };}
function createPlacement( asset: ExternalClipAsset, input: { id: string; label: string; targetTrackId: string; startTime: RationalTime; }): TimelineClipGroupPlacement { return { clip: createExternalClip(asset, input.id, input.label), targetTrackId: input.targetTrackId, startTime: input.startTime, };}
function resolveExternalAsset(event: DragEvent<HTMLElement>, fallbackAssetId: string | null) { const assetId = event.dataTransfer.getData(externalAssetMimeType) || fallbackAssetId; return externalClipAssets.find((asset) => asset.id === assetId) ?? null;}
function ExternalDropWorkspace() { const [editMode, setEditMode] = useState<'insert' | 'overwrite'>('overwrite'); const { selectedGroupId, ungroupSelectedClips } = useTimelineClipGroups(); const activeAssetIdRef = useRef<string | null>(null); const clipCounterRef = useRef(1); const canUngroup = selectedGroupId !== null;
const drop = useTimelineExternalClipDrop<ExternalClipAsset, ExternalClipDropTrackKind>({ editMode, resolveDragData: (event) => resolveExternalAsset(event, activeAssetIdRef.current), createPlacements: (context) => { const instance = clipCounterRef.current; clipCounterRef.current += 1;
if (context.data.kind === 'visual') { return [ createPlacement(context.data, { id: `${context.data.id}-${instance}`, label: context.data.label, targetTrackId: context.targetTrack.id, startTime: context.dropTime, }), ]; }
return [ createPlacement(context.data, { id: `${context.data.id}-video-${instance}`, label: `${context.data.label} video`, targetTrackId: context.targetTrack.id, startTime: context.dropTime, }), createPlacement(context.data, { id: `${context.data.id}-audio-${instance}`, label: `${context.data.label} audio`, targetTrackId: demoAudioTrackId, startTime: context.dropTime, }), ]; }, canDropOnTrack: (context) => context.targetTrack.kind === 'visual' ? true : { canDrop: false, reason: 'incompatible-track-kind' }, group: (context) => context.data.kind === 'linked-av' ? { label: `${context.data.label} group` } : null, snap: false, rulerHeight: 32, trackHeight: 56, });
const lastResultLabel = drop.lastResult === null ? 'Ready' : drop.lastResult.ok ? 'Dropped' : drop.lastResult.reason; const targetLabel = drop.targetTrack?.name ?? drop.targetTrackId ?? (drop.dragging ? 'No target' : 'Idle');
return ( <div className="external-drop-workspace"> <div className="external-drop-toolbar"> <div className="external-drop-assets" aria-label="External clip assets"> {externalClipAssets.map((asset) => ( <div key={asset.id} className="external-drop-asset" draggable onDragStart={(event) => { activeAssetIdRef.current = asset.id; event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.setData(externalAssetMimeType, asset.id); }} onDragEnd={() => { activeAssetIdRef.current = null; }} style={{ '--asset-color': asset.color } as CSSProperties} aria-label={`Drag ${asset.label} onto the timeline`} > {asset.kind === 'linked-av' ? ( <Clapperboard aria-hidden="true" /> ) : ( <Film aria-hidden="true" /> )} <span>{asset.label}</span> </div> ))} </div>
<div className="external-drop-controls" aria-label="External drop edit mode"> <button type="button" className={editMode === 'overwrite' ? 'external-drop-mode-active' : undefined} onClick={() => setEditMode('overwrite')} > <Rows3 aria-hidden="true" /> Overwrite </button> <button type="button" className={editMode === 'insert' ? 'external-drop-mode-active' : undefined} onClick={() => setEditMode('insert')} > <Rows3 aria-hidden="true" /> Insert </button> </div>
<div className="external-drop-actions" aria-label="Selected group actions"> <button type="button" disabled={!canUngroup} onClick={() => ungroupSelectedClips()}> <Unlink2 aria-hidden="true" /> Ungroup </button> </div>
<div className="external-drop-readout" aria-live="polite"> <span>{targetLabel}</span> <strong>{lastResultLabel}</strong> </div> </div>
<div className="timeline-editor-body-with-headers external-drop-editor-grid"> <div className="timeline-editor-header-panel"> <div className="timeline-stage timeline-editor-header-stage"> <TrackHeaderColumn /> </div> </div>
<div className="timeline-editor-timeline-panel"> <div className="timeline-editor-stage-row"> <div className="timeline-stage timeline-editor-timeline-stage"> <Timeline.Root className={`timeline-fill timeline-editor-root-with-headers ${ drop.dragging ? 'external-drop-target-active' : '' } ${drop.valid ? 'external-drop-target-valid' : ''}`} {...drop.rootProps} > <CanvasRenderer /> <TimelineLayers /> </Timeline.Root> </div> <div className="timeline-editor-vertical-scrollbar-column"> <Timeline.VerticalScrollbar className="timeline-editor-vertical-scrollbar"> <Timeline.VerticalScrollbarThumb className="timeline-editor-vertical-scrollbar-thumb"> <Timeline.VerticalScrollbarHandle side="start" /> <Timeline.VerticalScrollbarHandle side="end" /> </Timeline.VerticalScrollbarThumb> </Timeline.VerticalScrollbar> </div> </div> <div className="timeline-scrollbar-row timeline-editor-scrollbar-row"> <Timeline.ViewportScrollbar> <Timeline.ViewportScrollbarThumb> <Timeline.ViewportScrollbarHandle side="start" /> <Timeline.ViewportScrollbarHandle side="end" /> </Timeline.ViewportScrollbarThumb> </Timeline.ViewportScrollbar> </div> </div> </div> </div> );}
export function ExternalClipDropTimeline() { const engine = useMemo( () => new TimelineEngine({ duration: fromSeconds(14), playheadTime: fromSeconds(4), zoomScale: 76, tracks: demoTracks, markers: demoMarkers, }), [] );
return ( <TimelineProvider engine={engine}> <div className="timeline-shell timeline-controls-shell external-drop-shell"> <ExternalDropWorkspace /> </div> </TimelineProvider> );}const demoClipColors = [ 'oklch(0.62 0.16 250)', 'oklch(0.68 0.14 145)', 'oklch(0.72 0.16 70)', 'oklch(0.65 0.17 25)', 'oklch(0.58 0.18 305)', 'oklch(0.64 0.12 195)',] as const;
export function getDemoClipColor(index: number) { return demoClipColors[index % demoClipColors.length];}import { type Marker, type Track, fromSeconds } from '@techsquidtv/canvas-timeline';import { getDemoClipColor } from '#www/demos/demo-clip-colors';
export type ExternalClipDropTrackKind = 'visual' | 'audio';
export interface ExternalClipAsset { id: string; kind: 'visual' | 'linked-av'; label: string; sourceId: string; durationSeconds: number; color: string;}
export const externalClipAssets: ExternalClipAsset[] = [ { id: 'broll', kind: 'visual', label: 'B-roll shot', sourceId: 'external-broll', durationSeconds: 2.5, color: getDemoClipColor(5), }, { id: 'interview', kind: 'linked-av', label: 'Interview take', sourceId: 'external-interview', durationSeconds: 3, color: getDemoClipColor(2), },];
export const demoTracks: Track<ExternalClipDropTrackKind>[] = [ { id: 'video-main', kind: 'visual', name: 'Video', locked: false, muted: false, visible: true, selected: false, height: 56, clips: [ { id: 'existing-video', sourceId: 'existing-video-source', timelineStart: fromSeconds(5), timelineEnd: fromSeconds(8), sourceStart: fromSeconds(0), selected: false, color: getDemoClipColor(1), label: 'Existing video', }, ], }, { id: 'audio-main', kind: 'audio', name: 'Audio', locked: false, muted: false, visible: true, selected: false, height: 56, clips: [ { id: 'existing-audio', sourceId: 'existing-audio-source', timelineStart: fromSeconds(5), timelineEnd: fromSeconds(8), sourceStart: fromSeconds(0), selected: false, color: getDemoClipColor(3), label: 'Existing audio', }, ], },];
export const demoMarkers: Marker[] = [];.timeline-editor-body-with-headers { --demo-editor-scrollbar-gutter-size: 1.5rem; --demo-editor-scrollbar-track-size: 0.625rem; --demo-editor-scrollbar-gutter-padding: calc( (var(--demo-editor-scrollbar-gutter-size) - var(--demo-editor-scrollbar-track-size)) / 2 ); --demo-editor-scrollbar-gutter-padding-inline: calc( (var(--demo-editor-scrollbar-gutter-size) - var(--demo-editor-scrollbar-track-size) - 2px) / 2 ); --demo-editor-scrollbar-surface: var(--background);
width: 100%; min-width: 0; min-height: 0; overflow: hidden;}
.timeline-editor-header-panel,.timeline-editor-timeline-panel { display: grid; min-width: 0; min-height: 0; height: 100%; grid-template-rows: minmax(0, 1fr) auto; overflow: hidden;}
.timeline-editor-header-stage { min-height: 0; overflow: hidden;}
.timeline-editor-timeline-stage { min-height: 0;}
.timeline-editor-stage-row { display: grid; min-width: 0; min-height: 0; grid-template-columns: minmax(0, 1fr) auto;}
.timeline-editor-vertical-scrollbar-column { box-sizing: border-box; display: flex; width: var(--demo-editor-scrollbar-gutter-size); min-height: 0; align-items: stretch; justify-content: center; background: var(--demo-editor-scrollbar-surface); padding-block: var(--demo-editor-scrollbar-gutter-padding); padding-inline: var(--demo-editor-scrollbar-gutter-padding-inline);}
.timeline-editor-vertical-scrollbar { width: var(--demo-editor-scrollbar-track-size); min-height: 0;}
.timeline-editor-track-headers { width: 100%; height: 100%;}
.timeline-editor-root-with-headers .timeline-track-list-overlay { inset: 0;}
.timeline-editor-track-header-content { display: grid; width: 100%; min-width: 0; align-items: center; gap: 0.3rem;}
.timeline-editor-track-header-button { display: inline-grid; width: 1.45rem; height: 1.45rem; place-items: center; padding: 0; border: 1px solid color-mix(in oklch, var(--foreground) 10%, transparent); border-radius: calc(var(--radius) - 0.125rem); background: color-mix(in oklch, var(--background) 80%, transparent); color: var(--muted-foreground); cursor: pointer; transition: border-color 150ms ease, color 150ms ease, background-color 150ms ease, opacity 150ms ease;}
.timeline-editor-track-header-button svg { width: 0.85rem; height: 0.85rem;}
.timeline-editor-track-header-button:hover,.timeline-editor-track-header-button[aria-pressed='true'] { border-color: color-mix(in oklch, var(--primary) 55%, transparent); background: color-mix(in oklch, var(--primary) 12%, var(--background)); color: var(--foreground);}
.timeline-editor-track-header-button:disabled,.timeline-editor-track-header-button:disabled:hover { cursor: not-allowed; opacity: 0.52;}
.timeline-editor-track-header-label { min-width: 0; justify-self: start; overflow: hidden; color: var(--foreground); font-size: 0.7rem; text-align: left; text-overflow: ellipsis; white-space: nowrap;}
.timeline-editor-scrollbar-row { display: grid; min-height: var(--demo-editor-scrollbar-gutter-size); grid-template-columns: minmax(0, 1fr) var(--demo-editor-scrollbar-gutter-size); padding: 0; background: var(--demo-editor-scrollbar-surface);}
.timeline-editor-scrollbar-row .timeline-viewport-scrollbar { width: auto; min-width: 0; height: var(--demo-editor-scrollbar-track-size); align-self: center; margin-inline: var(--demo-editor-scrollbar-gutter-padding);}
.timeline-editor-scrollbar-row .timeline-viewport-scrollbar-handle { width: var(--demo-editor-scrollbar-track-size);}
.timeline-editor-column-resize-handle { position: relative; width: 0.8rem; flex: 0 0 0.8rem; background: var(--muted); cursor: col-resize; outline: none;}
.timeline-editor-column-resize-handle::before { position: absolute; inset-block: 0; left: 50%; width: 2px; background: var(--border); content: ''; transform: translateX(-50%); transition: background-color 150ms ease, box-shadow 150ms ease;}
.timeline-editor-column-resize-handle:hover::before,.timeline-editor-column-resize-handle:focus-visible::before,.timeline-editor-column-resize-handle[data-resize-handle-active]::before { background: var(--primary); box-shadow: 0 0 0 2px color-mix(in oklch, var(--primary) 28%, transparent);}
.timeline-shell.external-drop-shell { --demo-editor-shell-height: 23rem; width: 100%; max-width: 100%; height: var(--demo-editor-shell-height); min-height: 0; grid-template-rows: minmax(0, 1fr);}
.external-drop-workspace { display: grid; width: 100%; height: 100%; min-width: 0; min-height: 0; grid-template-rows: auto minmax(0, 1fr); overflow: hidden;}
.external-drop-toolbar { display: grid; min-width: 0; gap: 0.75rem; grid-template-columns: minmax(13rem, 1fr) auto auto minmax(8rem, auto); align-items: center; padding: 0.75rem; border-bottom: 1px solid var(--border); background: color-mix(in oklch, var(--background) 96%, var(--muted));}
.external-drop-assets { display: flex; min-width: 0; flex-wrap: wrap; gap: 0.5rem;}
.external-drop-asset { --asset-color: var(--primary); display: inline-flex; max-width: 12rem; min-height: 2.25rem; align-items: center; gap: 0.45rem; padding: 0.4rem 0.6rem; border: 1px solid color-mix(in oklch, var(--asset-color) 42%, var(--border)); border-radius: calc(var(--radius) - 0.125rem); background: color-mix(in oklch, var(--asset-color) 18%, var(--background)); color: var(--foreground); cursor: grab; font-size: 0.76rem; font-weight: 600; line-height: 1.1; user-select: none;}
.external-drop-asset:active { cursor: grabbing;}
.external-drop-asset svg,.external-drop-controls svg,.external-drop-actions svg { width: 0.9rem; height: 0.9rem; flex: 0 0 auto;}
.external-drop-asset span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
.external-drop-controls { display: inline-flex; overflow: hidden; border: 1px solid var(--border); border-radius: calc(var(--radius) - 0.125rem);}
.external-drop-controls button { display: inline-flex; min-height: 2.25rem; align-items: center; gap: 0.35rem; padding: 0 0.65rem; border: 0; border-right: 1px solid var(--border); background: color-mix(in oklch, var(--background) 88%, var(--muted)); color: var(--muted-foreground); font-size: 0.72rem; font-weight: 600;}
.external-drop-controls button:last-child { border-right: 0;}
.external-drop-controls button.external-drop-mode-active { background: color-mix(in oklch, var(--primary) 22%, var(--background)); color: var(--foreground);}
.external-drop-actions { display: inline-flex;}
.external-drop-actions button { display: inline-flex; min-height: 2.25rem; align-items: center; gap: 0.35rem; padding: 0 0.65rem; border: 1px solid var(--border); border-radius: calc(var(--radius) - 0.125rem); background: color-mix(in oklch, var(--background) 88%, var(--muted)); color: var(--foreground); font-size: 0.72rem; font-weight: 600;}
.external-drop-actions button:disabled { color: var(--muted-foreground); cursor: not-allowed; opacity: 0.55;}
.external-drop-readout { display: grid; min-width: 0; gap: 0.1rem; justify-items: end; font-size: 0.68rem; line-height: 1.15;}
.external-drop-readout span { max-width: 8rem; overflow: hidden; color: var(--muted-foreground); text-overflow: ellipsis; white-space: nowrap;}
.external-drop-readout strong { max-width: 8rem; overflow: hidden; color: var(--foreground); text-overflow: ellipsis; white-space: nowrap;}
.external-drop-editor-grid { display: grid; height: 100%; min-height: 0; grid-template-columns: 7.75rem minmax(0, 1fr);}
.external-drop-editor-grid .timeline-stage { min-height: 0;}
.external-drop-track-header-content { grid-template-columns: minmax(0, 1fr);}
.external-drop-target-active { outline: 1px solid color-mix(in oklch, var(--foreground) 22%, transparent); outline-offset: -1px;}
.external-drop-target-valid { outline-color: color-mix(in oklch, var(--primary) 70%, var(--foreground));}
@media (max-width: 760px) { .timeline-shell.external-drop-shell { --demo-editor-shell-height: 29rem; }
.external-drop-toolbar { grid-template-columns: minmax(0, 1fr); }
.external-drop-controls, .external-drop-actions, .external-drop-readout { justify-self: start; }
.external-drop-editor-grid { grid-template-columns: 6rem minmax(0, 1fr); }}