Download frontend/src/components/widgets/multitrack/MultiTrackRuler.tsx from turtle89431/Moxie-Multimedia: direct link, hf CLI and curl.
- Browser
- Download file 8.35 kB
-
https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/frontend/src/components/widgets/multitrack/MultiTrackRuler.tsx
- Command line
-
hf download hf://turtle89431/Moxie-Multimedia/frontend/src/components/widgets/multitrack/MultiTrackRuler.tsx
-
curl -L -o MultiTrackRuler.tsx https://huggingface.co/turtle89431/Moxie-Multimedia/resolve/main/frontend/src/components/widgets/multitrack/MultiTrackRuler.tsx
8.35 kB
| import { useRef, useState } from 'react' | |
| import { formatMultiTrackTime } from '@/lib/multitrack-utils' | |
| import type { MultiTrackTaskMarker } from '@/types/multitrack' | |
| import { MultiTrackTaskMarker as TaskMarker } from './MultiTrackTaskMarker' | |
| export const MULTITRACK_LEFT_GUTTER = 28 | |
| export const MULTITRACK_RIGHT_RESERVE = 48 | |
| const MIN_LABEL_SPACING_PX = 56 | |
| const SECOND_STEPS = [1, 2, 5, 10, 15, 30, 60, 120, 300] as const | |
| interface MultiTrackRulerProps { | |
| totalLength: number | |
| frameRate: number | |
| width: number | |
| canvasScale: number | |
| currentTime: number | |
| taskMarkers: MultiTrackTaskMarker[] | |
| selectedTaskMarkerId: string | null | |
| onSeek: (time: number) => void | |
| onSelectTaskMarker: (markerId: string) => void | |
| onMoveTaskMarker: (markerId: string, frame: number) => void | |
| onDeleteTaskMarker: (markerId: string) => void | |
| } | |
| export function buildTicks(totalLength: number, frameRate: number, timelineWidth: number) { | |
| const safeFrameRate = Math.max(1, frameRate) | |
| const safeLength = Math.max(totalLength, 1) | |
| const pixelsPerFrame = timelineWidth / safeLength | |
| const showFrames = pixelsPerFrame >= 6 | |
| const ticks: Array<{ time: number; label?: string; major: boolean }> = [] | |
| let lastLabelTime = Number.NEGATIVE_INFINITY | |
| function hasLabelSpacing(time: number): boolean { | |
| return (time - lastLabelTime) * pixelsPerFrame >= MIN_LABEL_SPACING_PX | |
| } | |
| function pushTick(time: number, label: string | undefined, major: boolean) { | |
| if (label) lastLabelTime = time | |
| ticks.push({ time, label, major }) | |
| } | |
| if (showFrames) { | |
| const minLabelFrameStep = Math.max(1, Math.ceil(MIN_LABEL_SPACING_PX / Math.max(pixelsPerFrame, 1))) | |
| const frameStepUnit = Math.max(1, Math.round(safeFrameRate / 8)) | |
| const frameLabelStep = Math.max(1, Math.ceil(minLabelFrameStep / frameStepUnit) * frameStepUnit) | |
| for (let frame = 0; frame <= totalLength; frame += 1) { | |
| const isStepLabel = frame % frameLabelStep === 0 | |
| const isEndLabel = frame === totalLength && hasLabelSpacing(frame) | |
| const major = isStepLabel || isEndLabel | |
| pushTick( | |
| frame, | |
| major ? formatMultiTrackTime(frame, { frameRate: safeFrameRate, showFrames: true }) : undefined, | |
| major, | |
| ) | |
| } | |
| return ticks | |
| } | |
| const minStepSeconds = MIN_LABEL_SPACING_PX / Math.max(pixelsPerFrame * safeFrameRate, 1) | |
| const stepSeconds = SECOND_STEPS.find((candidate) => candidate >= minStepSeconds) ?? SECOND_STEPS[SECOND_STEPS.length - 1] | |
| const stepFrames = Math.max(1, Math.round(stepSeconds * safeFrameRate)) | |
| for (let frame = 0; frame <= totalLength; frame += stepFrames) { | |
| pushTick(frame, formatMultiTrackTime(frame, { frameRate: safeFrameRate, showFrames: true }), true) | |
| } | |
| if (ticks.at(-1)?.time !== totalLength && hasLabelSpacing(totalLength)) { | |
| pushTick(totalLength, formatMultiTrackTime(totalLength, { frameRate: safeFrameRate, showFrames: true }), true) | |
| } | |
| return ticks | |
| } | |
| export function MultiTrackRuler({ | |
| totalLength, | |
| frameRate, | |
| width, | |
| canvasScale, | |
| currentTime, | |
| taskMarkers, | |
| selectedTaskMarkerId, | |
| onSeek, | |
| onSelectTaskMarker, | |
| onMoveTaskMarker, | |
| onDeleteTaskMarker, | |
| }: Readonly<MultiTrackRulerProps>) { | |
| const rulerRef = useRef<HTMLDivElement>(null) | |
| const [dragPreview, setDragPreview] = useState<{ markerId: string; frame: number } | null>(null) | |
| const timelineWidth = Math.max(1, width - MULTITRACK_LEFT_GUTTER) | |
| const playableWidth = Math.max(1, timelineWidth - MULTITRACK_RIGHT_RESERVE) | |
| const ticks = buildTicks(totalLength, frameRate, playableWidth) | |
| const safeLength = Math.max(totalLength, 1) | |
| const playheadLeft = MULTITRACK_LEFT_GUTTER + (currentTime / safeLength) * playableWidth | |
| const reserveLeft = MULTITRACK_LEFT_GUTTER + playableWidth | |
| function renderedCoordinateScale(rectWidth: number): number { | |
| const measuredScale = rectWidth / Math.max(width, 1) | |
| return Number.isFinite(measuredScale) && measuredScale > 0.01 | |
| ? measuredScale | |
| : Math.max(canvasScale, 0.01) | |
| } | |
| function timeFromClientX(clientX: number, rectLeft: number, coordinateScale: number): number { | |
| const x = Math.max(0, (clientX - rectLeft) / coordinateScale - MULTITRACK_LEFT_GUTTER) | |
| const ratio = x / playableWidth | |
| return Math.max(0, Math.min(totalLength, Math.round(ratio * totalLength))) | |
| } | |
| function handleMouseDown(event: React.MouseEvent<HTMLDivElement>) { | |
| event.preventDefault() | |
| const rect = event.currentTarget.getBoundingClientRect() | |
| const coordinateScale = renderedCoordinateScale(rect.width) | |
| onSeek(timeFromClientX(event.clientX, rect.left, coordinateScale)) | |
| function handleMouseMove(moveEvent: MouseEvent) { | |
| onSeek(timeFromClientX(moveEvent.clientX, rect.left, coordinateScale)) | |
| } | |
| function handleMouseUp() { | |
| globalThis.removeEventListener('mousemove', handleMouseMove) | |
| globalThis.removeEventListener('mouseup', handleMouseUp) | |
| } | |
| globalThis.addEventListener('mousemove', handleMouseMove) | |
| globalThis.addEventListener('mouseup', handleMouseUp) | |
| } | |
| function handleTaskMarkerDragStart(markerId: string, clientX: number) { | |
| const marker = taskMarkers.find((candidate) => candidate.id === markerId) | |
| if (!marker) return | |
| const rulerRect = rulerRef.current?.getBoundingClientRect() | |
| const rectLeft = rulerRect?.left ?? 0 | |
| const coordinateScale = renderedCoordinateScale(rulerRect?.width ?? 0) | |
| const originalFrame = marker.frame | |
| let lastValidFrame = originalFrame | |
| function updateDragPreview(nextClientX: number) { | |
| const nextFrame = Math.max(1, timeFromClientX(nextClientX, rectLeft, coordinateScale)) | |
| const occupied = taskMarkers.some((candidate) => ( | |
| candidate.id !== markerId && candidate.frame === nextFrame | |
| )) | |
| if (occupied) return | |
| lastValidFrame = nextFrame | |
| setDragPreview({ markerId, frame: nextFrame }) | |
| } | |
| function handleMouseMove(event: MouseEvent) { | |
| updateDragPreview(event.clientX) | |
| } | |
| function handleMouseUp(event: MouseEvent) { | |
| updateDragPreview(event.clientX) | |
| globalThis.removeEventListener('mousemove', handleMouseMove) | |
| globalThis.removeEventListener('mouseup', handleMouseUp) | |
| setDragPreview(null) | |
| if (lastValidFrame !== originalFrame) onMoveTaskMarker(markerId, lastValidFrame) | |
| } | |
| updateDragPreview(clientX) | |
| globalThis.addEventListener('mousemove', handleMouseMove) | |
| globalThis.addEventListener('mouseup', handleMouseUp) | |
| } | |
| return ( | |
| <div | |
| ref={rulerRef} | |
| className={`relative h-6 shrink-0 select-none border-b border-border ${dragPreview ? 'cursor-grabbing' : 'cursor-col-resize'}`} | |
| style={{ width }} | |
| onMouseDown={handleMouseDown} | |
| > | |
| <div className="absolute left-0 top-0 h-full border-r border-border" style={{ width: MULTITRACK_LEFT_GUTTER }} /> | |
| <div | |
| className="pointer-events-none absolute top-0 h-full bg-black/30" | |
| style={{ left: reserveLeft, width: MULTITRACK_RIGHT_RESERVE }} | |
| /> | |
| {ticks.map((tick) => ( | |
| <div | |
| key={`${tick.time}-${tick.major ? 'major' : 'minor'}`} | |
| className="absolute bottom-0 flex h-full flex-col justify-end" | |
| style={{ left: MULTITRACK_LEFT_GUTTER + (tick.time / safeLength) * playableWidth }} | |
| > | |
| <div className={tick.major ? 'h-3 w-px bg-border' : 'h-1.5 w-px bg-border'} /> | |
| {tick.label && <span className="absolute left-1 top-1 text-[8px] text-muted-foreground">{tick.label}</span>} | |
| </div> | |
| ))} | |
| {taskMarkers.map((marker, index) => { | |
| const dragging = dragPreview?.markerId === marker.id | |
| const displayMarker = dragging | |
| ? { ...marker, frame: dragPreview.frame } | |
| : marker | |
| return ( | |
| <TaskMarker | |
| key={displayMarker.id} | |
| marker={displayMarker} | |
| markerNumber={index + 1} | |
| frameRate={frameRate} | |
| left={MULTITRACK_LEFT_GUTTER + (displayMarker.frame / safeLength) * playableWidth} | |
| selected={displayMarker.id === selectedTaskMarkerId} | |
| dragging={dragging} | |
| onSelect={onSelectTaskMarker} | |
| onDragStart={handleTaskMarkerDragStart} | |
| onDelete={onDeleteTaskMarker} | |
| /> | |
| ) | |
| })} | |
| <div className="absolute top-0 h-full w-px bg-destructive" style={{ left: playheadLeft }} /> | |
| </div> | |
| ) | |
| } | |