From 8a46718016f05da1a5ed168091d0f7a595615729 Mon Sep 17 00:00:00 2001 From: thaodx Date: Thu, 3 Sep 2026 22:31:55 +0700 Subject: [PATCH] feat(timeline): add draggable zoom toolbar with zoom-to-fit Adds a floating zoom control to the timeline: zoom-out/zoom-in buttons, an exponential zoom slider with a live percentage readout, and a Fit button that frames the scene's content span. The pill drags anywhere via a :: grip, and its position is persisted per user. The slider tracks pointer-synchronously (a local draft, not the frame-sampled zoom) and coalesces store writes to one per frame; only the drag's end reports back to the scene's timeline view. Zoom and fit both clamp to the existing resolution range and anchor the viewport center/left padding the way the canvas camera does. --- apps/web/src/components/timeline/index.ts | 1 + .../timeline/timeline-zoom-toolbar.tsx | 271 ++++++++++++++++++ apps/web/src/components/timeline/timeline.tsx | 2 + apps/web/src/engine/timeline/config.ts | 3 + apps/web/src/engine/timeline/controller.ts | 70 ++++- 5 files changed, 346 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/timeline/timeline-zoom-toolbar.tsx diff --git a/apps/web/src/components/timeline/index.ts b/apps/web/src/components/timeline/index.ts index e2b2a402..fc6bf847 100644 --- a/apps/web/src/components/timeline/index.ts +++ b/apps/web/src/components/timeline/index.ts @@ -3,4 +3,5 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ export * from './layers'; +export * from './timeline-zoom-toolbar'; export * from './timeline'; diff --git a/apps/web/src/components/timeline/timeline-zoom-toolbar.tsx b/apps/web/src/components/timeline/timeline-zoom-toolbar.tsx new file mode 100644 index 00000000..c858523c --- /dev/null +++ b/apps/web/src/components/timeline/timeline-zoom-toolbar.tsx @@ -0,0 +1,271 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * The timeline's zoom control: a floating pill over the canvas with + * out/slider/in and a Fit button. Drag the `::` grip to move it anywhere — + * position is persisted per user (see `store.define`). + * + * While the thumb is being dragged, the slider position is held in a local + * signal so it follows the pointer pointer-synchronously — the scene's zoom + * is sampled once a frame (see `useDerived`), which is fine for a click but + * makes a controlled slider feel laggy under a drag. The zoom writes are + * coalesced to one per frame; only the drag's end reports to the file. + */ + +import { Show, createMemo, createSignal, onCleanup } from 'solid-js'; +import { useWorld } from '@diffusionstudio/koota-solid'; + +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { Icon } from '@/components/ui/icon'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Slider, SliderFill, SliderThumb, SliderTrack } from '@/components/ui/slider'; +import { useTimeline } from '@/context/timeline'; +import { useLayout } from '@/context/layout'; +import { useDerived } from '@/engine/hooks'; +import { store } from '@/init'; +import { clamp } from '@/utils'; +import { createStoredSignal } from '@/lib/store'; +import { + DEFAULT_TIMELINE_RESOLUTION, + TIMELINE_RESOLUTION_RANGE, + getResolution, + getTimelineScene, +} from '@/engine/timeline'; + +const LOG_MIN = Math.log(TIMELINE_RESOLUTION_RANGE[0]); +const LOG_SPAN = Math.log(TIMELINE_RESOLUTION_RANGE[1]) - LOG_MIN; + +/** A zoom level (pixels per frame) as the slider's 0..1 position. */ +const zoomToSlider = (resolution: number): number => { + if (resolution <= 0) return 0.5; + return clamp01((Math.log(resolution) - LOG_MIN) / LOG_SPAN); +}; + +/** A slider position as a zoom level (pixels per frame). */ +const sliderToZoom = (fraction: number): number => { + return Math.exp(LOG_MIN + clamp01(fraction) * LOG_SPAN); +}; + +function clamp01(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +type ToolbarPosition = { right: number; bottom: number }; + +const DEFAULT_POSITION: ToolbarPosition = { right: 8, bottom: 8 }; + +export function TimelineZoomToolbar() { + const world = useWorld(); + const timeline = useTimeline(); + const { timelineMinimized } = useLayout(); + + // Where the pill floats, in px from the container's right/bottom edges. + // Dragging updates a draft; the stored value is only written on release. + const [savedPosition, setSavedPosition] = createStoredSignal( + store.define('timeline.zoomToolbarPosition', DEFAULT_POSITION), + ); + const [draftPosition, setDraftPosition] = createSignal(null); + const position = createMemo(() => draftPosition() ?? savedPosition()); + + // Sampled once a frame so the control always reflects where the timeline + // is looking, however it got there (wheel, restore from file, the slider). + const resolution = useDerived(() => { + const scene = getTimelineScene(world); + return scene === null ? 0 : getResolution(world, scene); + }); + + // While the thumb is being dragged, the thumb's own position; null means + // the slider follows the scene. + const [draft, setDraft] = createSignal(null); + + const zoomPercent = createMemo(() => { + const res = resolution(); + return res === 0 ? 100 : Math.round((res / DEFAULT_TIMELINE_RESOLUTION) * 100); + }); + + const sliderValue = createMemo(() => { + const d = draft(); + if (d !== null) return d; + const res = resolution(); + return res === 0 ? 0.5 : zoomToSlider(res); + }); + + // A pointermove writes the store at whatever rate it arrives at; coalesce + // to one zoom write per frame while the thumb still tracks the pointer. + let pendingZoom: number | null = null; + let rafPending = 0; + const scheduleZoomLive = (resolution: number): void => { + pendingZoom = resolution; + if (rafPending) return; + rafPending = requestAnimationFrame(() => { + rafPending = 0; + const next = pendingZoom; + pendingZoom = null; + if (next !== null) timeline.zoomToLive(next); + }); + }; + + const handleValueChange = ([value]: number[]): void => { + const next = clamp01(value); + setDraft(next); + scheduleZoomLive(sliderToZoom(next)); + }; + + const handleValueChangeEnd = ([value]: number[]): void => { + if (rafPending) { + cancelAnimationFrame(rafPending); + rafPending = 0; + pendingZoom = null; + } + setDraft(null); + timeline.zoomTo(sliderToZoom(clamp01(value))); + }; + + // --- Drag-to-move ------------------------------------------------------- + + const [dragging, setDragging] = createSignal(false); + let pillEl: HTMLDivElement | undefined; + let dragStart: { + startX: number; + startY: number; + origin: ToolbarPosition; + maxRight: number; + maxBottom: number; + } | null = null; + + const handleGripMove = (event: PointerEvent): void => { + if (!dragStart) return; + const dx = event.clientX - dragStart.startX; + const dy = event.clientY - dragStart.startY; + setDraftPosition({ + right: clamp(dragStart.origin.right - dx, 0, dragStart.maxRight), + bottom: clamp(dragStart.origin.bottom - dy, 0, dragStart.maxBottom), + }); + }; + + const handleGripUp = (): void => { + if (!dragStart) return; + document.removeEventListener('pointermove', handleGripMove); + document.removeEventListener('pointerup', handleGripUp); + + const final = draftPosition(); + if (final) setSavedPosition(final); + setDraftPosition(null); + dragStart = null; + setDragging(false); + }; + + const handleGripDown = (event: PointerEvent): void => { + event.preventDefault(); + event.stopPropagation(); + if (dragStart !== null || !pillEl) return; + + const container = pillEl.parentElement; + if (!container) return; + const pillRect = pillEl.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + dragStart = { + startX: event.clientX, + startY: event.clientY, + origin: position(), + maxRight: Math.max(0, containerRect.width - pillRect.width), + maxBottom: Math.max(0, containerRect.height - pillRect.height), + }; + setDragging(true); + + document.addEventListener('pointermove', handleGripMove); + document.addEventListener('pointerup', handleGripUp); + }; + + onCleanup(() => { + if (rafPending) cancelAnimationFrame(rafPending); + document.removeEventListener('pointermove', handleGripMove); + document.removeEventListener('pointerup', handleGripUp); + }); + + return ( + +
event.stopPropagation()} + > +
+
+
+
+ + + timeline.zoomBy(1 / 1.25)} + > + + + Zoom out + + + + + + + + + + + {zoomPercent()}% + + + + timeline.zoomBy(1.25)} + > + + + Zoom in + + + + + + timeline.zoomToFit()} + > + + + Fit to timeline + +
+ + ); +} \ No newline at end of file diff --git a/apps/web/src/components/timeline/timeline.tsx b/apps/web/src/components/timeline/timeline.tsx index 77064060..485a1caa 100644 --- a/apps/web/src/components/timeline/timeline.tsx +++ b/apps/web/src/components/timeline/timeline.tsx @@ -12,6 +12,7 @@ import { insertAssetsInNewScene } from '@/engine/new-scene'; import { useLibrary } from '@/engine/library'; import { useTimeline } from '@/context/timeline'; import { ASSET_DRAG_TYPE } from '@/components/sidebar-left/folder-item'; +import { TimelineZoomToolbar } from './timeline-zoom-toolbar'; /** * The timeline's canvas. What is drawn on it is the timeline system's @@ -81,6 +82,7 @@ export function Timeline() { on:drop={handleDrop} on:dragover={handleDragOver} /> +
); } diff --git a/apps/web/src/engine/timeline/config.ts b/apps/web/src/engine/timeline/config.ts index bd65c127..36e65e4d 100644 --- a/apps/web/src/engine/timeline/config.ts +++ b/apps/web/src/engine/timeline/config.ts @@ -11,6 +11,9 @@ export const TIMELINE_RESOLUTION_RANGE = [0.03, 120] as const; export const DEFAULT_TIMELINE_RESOLUTION = 1 / 0.7; export const DEFAULT_CLIP_HEIGHT = 40; +/** Zoom step factor for the toolbar's in/out buttons, like the canvas camera's. */ +export const ZOOM_STEP = 1.25; + export const TIMELINE_PADDING_LEFT = 8; export const WHEEL_LINE_HEIGHT = 16; diff --git a/apps/web/src/engine/timeline/controller.ts b/apps/web/src/engine/timeline/controller.ts index 03c52391..d70d3974 100644 --- a/apps/web/src/engine/timeline/controller.ts +++ b/apps/web/src/engine/timeline/controller.ts @@ -9,7 +9,7 @@ * controller itself never paints. */ -import { getTimelineView } from '@diffusionstudio/runtime'; +import { getTimelineView, store, Computed } from '@diffusionstudio/runtime'; import { assert, clamp } from '@/utils'; import { getDocumentEditor } from '@/engine/editor'; @@ -29,6 +29,7 @@ import { } from './view'; import { SCROLL_X_SENSITIVITY, + TIMELINE_PADDING_LEFT, TIMELINE_RESOLUTION_RANGE, WHEEL_LINE_HEIGHT, WHEEL_PAGE_HEIGHT, @@ -214,6 +215,69 @@ export function createTimelineController(world: World) { surface.minimized = minimized; }; + // The resolution bounds, in pixels per frame. The wheel clamps against the + // same numbers, inverted. + const MIN_RESOLUTION = 1 / TIMELINE_RESOLUTION_RANGE[1]; + const MAX_RESOLUTION = 1 / TIMELINE_RESOLUTION_RANGE[0]; + + /** + * Sets the zoom (pixels per frame), keeping the frame under the center of + * the viewport where it was — the way the wheel zooms around the pointer. + * Reports the view so the scene remembers it (``). + */ + const zoomTo = (resolution: number): void => { + withScene((scene) => applyZoom(scene, resolution, true)); + }; + + /** + * The same, without reporting: what a slider drag asks for while the thumb + * is in flight. Every pixel of a drag is a lot of file writes otherwise. + * The drag's end reports once (see `zoomTo`). + */ + const zoomToLive = (resolution: number): void => { + withScene((scene) => applyZoom(scene, resolution, false)); + }; + + const zoomBy = (factor: number): void => { + const scene = getTimelineScene(world); + if (scene !== null) applyZoom(scene, getResolution(world, scene) * factor, true); + }; + + /** + * Fits the longest track to the viewport: zoom so the scene's content + * span (its computed start..end) fills the canvas width, with the content + * start lining up with the left padding. + */ + const zoomToFit = (): void => { + withScene((scene) => { + const computed = store(world, Computed); + const width = Math.max(1, (surface.layout.width || 0) - TIMELINE_PADDING_LEFT * 2); + const start = computed.start[scene.id()] ?? 0; + const frames = Math.max(1, (computed.end[scene.id()] ?? 0) - start); + const next = clamp(width / frames, MIN_RESOLUTION, MAX_RESOLUTION); + + setResolution(world, scene, next); + setScrollX(world, scene, start - TIMELINE_PADDING_LEFT / next); + updateTimelineTransform(world, scene); + reportView(scene); + }); + }; + + /** One zoom write: clamp, restore the center anchor, render, report. */ + const applyZoom = (scene: Entity, resolution: number, report: boolean): void => { + const current = getResolution(world, scene); + const next = clamp(resolution, MIN_RESOLUTION, MAX_RESOLUTION); + if (next === current) return; + + const anchor = (surface.layout.width || 0) / 2; + const scrollX = getScrollX(world, scene); + + setResolution(world, scene, next); + setScrollX(world, scene, scrollX + anchor / current - anchor / next); + updateTimelineTransform(world, scene); + if (report) reportView(scene); + }; + const attachCanvas = (): void => { const canvas = document.getElementById('timeline-canvas') as HTMLCanvasElement | null; assert(canvas, 'Timeline canvas must be defined'); @@ -285,6 +349,10 @@ export function createTimelineController(world: World) { clientToFrame, clientToTime, setMinimized, + zoomTo, + zoomToLive, + zoomBy, + zoomToFit, }; }