diff --git a/web/libs/editor/src/lib/AudioUltra/Common/Utils.ts b/web/libs/editor/src/lib/AudioUltra/Common/Utils.ts index 7e8c13ce1256..cf6d7382a1fa 100644 --- a/web/libs/editor/src/lib/AudioUltra/Common/Utils.ts +++ b/web/libs/editor/src/lib/AudioUltra/Common/Utils.ts @@ -212,3 +212,27 @@ export const getCursorTime = (e: MouseEvent, visualizer: Visualizer, duration: n export const isTimeSimilar = (a: number, b: number) => Math.abs(a - b) < TIME_TOLERANCE; export const isTimeRelativelySimilar = (a: number, b: number, observedDuration: number) => isTimeSimilar(a / observedDuration, b / observedDuration); + +/** + * A constant representing the width of the browser's scrollbar in pixels. + * This value is calculated dynamically by creating a temporary DOM element + * with a scrollable area and comparing its offset width to its client width. + * Useful for making precise layout adjustments that depend on the scrollbar size. + * + * Note: The calculation is performed immediately when the variable is defined + * and retains its value for the duration of runtime. + * + * @constant {number} + */ +export const BROWSER_SCROLL_SIZE = ((): number => { + const scrollDiv = document.createElement("div"); + scrollDiv.style.width = "100px"; + scrollDiv.style.height = "100px"; + scrollDiv.style.overflow = "scroll"; + scrollDiv.style.position = "absolute"; + scrollDiv.style.top = "-9999px"; + document.body.appendChild(scrollDiv); + const scrollSize = scrollDiv.offsetWidth - scrollDiv.clientWidth; + document.body.removeChild(scrollDiv); + return scrollSize; +})(); diff --git a/web/libs/editor/src/lib/AudioUltra/Visual/Visualizer.ts b/web/libs/editor/src/lib/AudioUltra/Visual/Visualizer.ts index e0846fdd7950..4a6d0137ff40 100644 --- a/web/libs/editor/src/lib/AudioUltra/Visual/Visualizer.ts +++ b/web/libs/editor/src/lib/AudioUltra/Visual/Visualizer.ts @@ -1,5 +1,5 @@ import type { WaveformAudio } from "../Media/WaveformAudio"; -import { averageMinMax, clamp, debounce, defaults, warn } from "../Common/Utils"; +import { averageMinMax, BROWSER_SCROLL_SIZE, clamp, debounce, defaults, warn } from "../Common/Utils"; import type { Waveform, WaveformOptions } from "../Waveform"; import { type CanvasCompositeOperation, Layer, type RenderingContext } from "./Layer"; import { Events } from "../Common/Events"; @@ -48,6 +48,7 @@ export type VisualizerOptions = Pick< export class Visualizer extends Events { private wrapper!: HTMLElement; + private scrollFiller!: HTMLElement; private layers = new Map(); private observer!: ResizeObserver; private currentTime = 0; @@ -183,6 +184,7 @@ export class Visualizer extends Events { } this.getSamplesPerPx(); + this.updateScrollFiller(); this.wf.invoke("zoom", [this.zoom]); this.draw(); @@ -193,6 +195,11 @@ export class Visualizer extends Events { } setScrollLeft(value: number, redraw = true, forceDraw = false) { + this.wrapper.scrollLeft = value * this.fullWidth; + this._setScrollLeft(value, redraw, forceDraw); + } + + _setScrollLeft(value: number, redraw = true, forceDraw = false) { this.scrollLeft = value; if (redraw) { @@ -274,13 +281,13 @@ export class Visualizer extends Events { centerToCurrentTime() { if (this.zoom === 1) { - this.scrollLeft = 0; + this.setScrollLeft(0); return; } const offset = this.width / 2 / this.zoomedWidth; - this.scrollLeft = clamp(this.currentTime - offset, 0, 1); + this.setScrollLeft(clamp(this.currentTime - offset, 0, 1)); } /** @@ -638,18 +645,38 @@ export class Visualizer extends Events { this.wrapper = document.createElement("div"); this.wrapper.style.height = "100%"; + this.wrapper.style.position = "relative"; + this.wrapper.style.overflow = "scroll hidden"; - this.createLayer({ name: "main" }); + const mainLayer = this.createLayer({ name: "main" }); this.createLayer({ name: "background", offscreen: true, zIndex: 0, isVisible: false }); this.createLayer({ name: "waveform", offscreen: true, zIndex: 100 }); this.createLayerGroup({ name: "regions", offscreen: true, zIndex: 101, compositeOperation: "source-over" }); const controlsLayer = this.createLayer({ name: "controls", offscreen: true, zIndex: 1000 }); this.playhead.setLayer(controlsLayer); - this.layers.get("main")?.appendTo(this.wrapper); + + mainLayer.canvas.style.position = "sticky"; + mainLayer.canvas.style.top = "0"; + mainLayer.canvas.style.left = "0"; + mainLayer.canvas.style.zIndex = "2"; + mainLayer?.appendTo(this.wrapper); + + this.scrollFiller = document.createElement("div"); + this.scrollFiller.style.position = "absolute"; + this.scrollFiller.style.width = "100%"; + this.scrollFiller.style.height = `${BROWSER_SCROLL_SIZE}px`; + this.scrollFiller.style.top = "100%"; + this.wrapper.appendChild(this.scrollFiller); + container.appendChild(this.wrapper); } + updateScrollFiller() { + const { fullWidth } = this; + this.scrollFiller.style.width = `${fullWidth}px`; + } + reserveSpace({ height }: { height: number }) { this.reservedSpace = height; } @@ -792,6 +819,12 @@ export class Visualizer extends Events { this.wrapper.addEventListener("click", this.handleSeek); this.wrapper.addEventListener("mousedown", this.handleMouseDown); + this.wrapper.addEventListener("scroll", (e) => { + const scrollLeft = this.wrapper.scrollLeft / this.fullWidth; + this.wf.invoke("scroll", [scrollLeft]); + this._setScrollLeft(scrollLeft); + }); + // Cursor events this.on("mouseMove", this.playHeadMove); @@ -850,6 +883,8 @@ export class Visualizer extends Events { }; private handleSeek = (e: MouseEvent) => { + if (e.offsetY > this.height) return; + const mainLayer = this.getLayer("main"); if (!this.wf.loaded || this.seekLocked || !(e.target && mainLayer?.canvas?.contains(e.target))) return; @@ -864,6 +899,7 @@ export class Visualizer extends Events { }; private handleMouseDown = (e: MouseEvent) => { + if (e.offsetY > this.height) return; if (!this.wf.loaded) return; this.playhead.invoke("mouseDown", [e]); }; @@ -938,7 +974,7 @@ export class Visualizer extends Events { }; private setContainerHeight() { - this.container.style.height = `${this.height}px`; + this.container.style.height = `${this.height + BROWSER_SCROLL_SIZE}px`; } private updateSize() { @@ -958,6 +994,8 @@ export class Visualizer extends Events { this.wf.renderTimeline(); this.resetWaveformRender(); this.draw(false, true); + this.updateScrollFiller(); + this.setScrollLeft(this.scrollLeft); }); };