Live demo
Code example
import { TimelineEngine, Timeline, TimelineProvider, useTimeline, useTimelineClipGroups, useTimelineEditCommands, useTimelinePlayheadTime, useTimelineSelection, CanvasRenderer, compareRational, fromSeconds,} from '@techsquidtv/canvas-timeline';import { Link2, Scissors, Unlink } from 'lucide-react';import { useMemo } from 'react';import { demoMarkers, demoTracks, linkedClipGroupLabel,} from '#www/demos/clip-grouping-import/timeline-demo-data';import '@techsquidtv/canvas-timeline/styles.css';import '#www/demos/clip-grouping-import/timeline-editor.css';
function TrackHeaderColumn() { const { state } = useTimeline();
return ( <Timeline.TrackHeaderList className="timeline-editor-track-headers"> {state.tracks.map((track) => ( <Timeline.TrackHeader key={track.id} trackId={track.id}> {(header) => ( <div className="timeline-editor-track-header-content clip-grouping-track-header-content"> <span className="timeline-editor-track-header-label">{header.label}</span> </div> )} </Timeline.TrackHeader> ))} </Timeline.TrackHeaderList> );}
function TimelineLayers() { const { state } = useTimeline();
return ( <> <Timeline.PlayheadArea /> <Timeline.PlayheadGrabber /> <Timeline.TrackList className="timeline-track-list-overlay"> {state.tracks.map((track) => ( <Timeline.Track key={track.id} trackId={track.id} /> ))} </Timeline.TrackList> <Timeline.ClipInteractionLayer /> <Timeline.RangeSelector /> </> );}
function GroupingToolbar() { const playheadTime = useTimelinePlayheadTime(); const { splitSelectedClipsAtTime } = useTimelineEditCommands(); const { selectedClipIds, selectedClips } = useTimelineSelection(); const { getClipGroupClips, groupClips, selectedGroup, selectedGroupId, ungroupSelectedClips } = useTimelineClipGroups(); const canGroup = selectedClipIds.length >= 2 && selectedGroupId === null; const canUngroup = selectedGroupId !== null; const selectedOverlappingClipCount = selectedClips.filter( (clip) => compareRational(playheadTime, clip.timelineStart) > 0 && compareRational(playheadTime, clip.timelineEnd) < 0 ).length; const selectedGroupMembers = selectedGroup === null ? [] : getClipGroupClips(selectedGroup.id).map((entry) => entry.clip.label ?? entry.clip.id);
return ( <div className="clip-grouping-toolbar"> <div className="clip-grouping-toolbar-actions"> <button type="button" className="timeline-control-button" onClick={() => groupClips(selectedClipIds, linkedClipGroupLabel)} disabled={!canGroup} title={canGroup ? 'Group selected clips' : 'Select both clips to group them'} > <Link2 aria-hidden="true" /> Group selected </button> <button type="button" className="timeline-control-button" onClick={() => ungroupSelectedClips()} disabled={!canUngroup} title={canUngroup ? 'Ungroup selected clips' : 'Select a grouped clip to ungroup it'} > <Unlink aria-hidden="true" /> Ungroup selected </button> <button type="button" className="timeline-control-button" onClick={() => splitSelectedClipsAtTime(playheadTime)} disabled={selectedOverlappingClipCount === 0} title={ selectedOverlappingClipCount > 0 ? 'Cut selected clips at playhead' : 'Select clips that overlap the playhead' } > <Scissors aria-hidden="true" /> Cut selected </button> </div>
<div className="clip-grouping-selection-readout" aria-live="polite"> <span className="clip-grouping-readout-item"> <span className="clip-grouping-readout-label">Selected</span> <strong>{selectedClipIds.length}</strong> </span> <span className="clip-grouping-readout-item"> <span className="clip-grouping-readout-label">Group</span> <strong>{selectedGroup?.label ?? 'None'}</strong> </span> <span className="clip-grouping-readout-item clip-grouping-readout-item-wide"> <span className="clip-grouping-readout-label">Members</span> <strong> {selectedGroupMembers.length > 0 ? selectedGroupMembers.join(' + ') : 'None'} </strong> </span> </div> </div> );}
export function ClipGroupingImportTimeline() { const engine = useMemo( () => new TimelineEngine({ duration: fromSeconds(15), playheadTime: fromSeconds(6.5), zoomScale: 76, tracks: demoTracks, markers: demoMarkers, }), [] );
return ( <TimelineProvider engine={engine}> <div className="timeline-shell timeline-controls-shell clip-grouping-shell"> <GroupingToolbar />
<div className="timeline-editor-body-with-headers clip-grouping-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"> <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> </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 ClipGroupingTrackKind = 'visual' | 'audio';
export const demoTracks: Track<ClipGroupingTrackKind>[] = [ { id: 'video-main', kind: 'visual', name: 'Video', locked: false, muted: false, visible: true, selected: false, height: 56, clips: [ { id: 'video-clip', sourceId: 'shared-source', timelineStart: fromSeconds(3), timelineEnd: fromSeconds(9), sourceStart: fromSeconds(0), selected: false, color: getDemoClipColor(1), label: 'Video clip', }, ], }, { id: 'audio-main', kind: 'audio', name: 'Audio', locked: false, muted: false, visible: true, selected: false, height: 56, clips: [ { id: 'audio-clip', sourceId: 'shared-source', timelineStart: fromSeconds(3), timelineEnd: fromSeconds(9), sourceStart: fromSeconds(0), selected: false, color: getDemoClipColor(3), label: 'Audio clip', }, ], },];
export const demoMarkers: Marker[] = [];
export const linkedClipGroupLabel = 'Linked pair';.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);}
.clip-grouping-shell { --demo-editor-shell-height: 22rem; width: 100%; max-width: 100%; height: var(--demo-editor-shell-height); min-height: 0; grid-template-rows: auto minmax(0, 1fr);}
.clip-grouping-toolbar { display: grid; min-width: 0; gap: 0.75rem; grid-template-columns: auto minmax(0, 1fr); align-items: center; padding: 0.75rem; border-bottom: 1px solid var(--border); background: color-mix(in oklch, var(--background) 96%, var(--muted));}
.clip-grouping-toolbar-actions { display: flex; min-width: 0; flex-wrap: wrap; gap: 0.5rem;}
.clip-grouping-toolbar .timeline-control-button { gap: 0.45rem; white-space: nowrap;}
.clip-grouping-toolbar .timeline-control-button svg { width: 0.9rem; height: 0.9rem;}
.clip-grouping-selection-readout { display: grid; min-width: 0; gap: 0.5rem; grid-template-columns: auto auto minmax(0, 1fr);}
.clip-grouping-readout-item { display: grid; min-width: 5.25rem; gap: 0.12rem; align-content: center; padding: 0.35rem 0.5rem; 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) 82%, transparent);}
.clip-grouping-readout-item-wide { min-width: 0;}
.clip-grouping-readout-label { color: var(--muted-foreground); font-size: 0.64rem; line-height: 1;}
.clip-grouping-readout-item strong { min-width: 0; overflow: hidden; color: var(--foreground); font-size: 0.72rem; font-weight: 600; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap;}
.clip-grouping-editor-grid { display: grid; grid-template-columns: 7.75rem minmax(0, 1fr);}
.clip-grouping-editor-grid .timeline-stage { min-height: 0;}
.clip-grouping-track-header-content { grid-template-columns: minmax(0, 1fr);}
@media (max-width: 720px) { .clip-grouping-shell { --demo-editor-shell-height: 26rem; }
.clip-grouping-toolbar { grid-template-columns: minmax(0, 1fr); }
.clip-grouping-selection-readout { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.clip-grouping-readout-item-wide { grid-column: 1 / -1; }
.clip-grouping-editor-grid { grid-template-columns: 6rem minmax(0, 1fr); }}