diff --git a/web/libs/editor/src/lib/AudioUltra/Common/Utils.ts b/web/libs/editor/src/lib/AudioUltra/Common/Utils.ts index 7e8c13ce1256..5936a7aaef5a 100644 --- a/web/libs/editor/src/lib/AudioUltra/Common/Utils.ts +++ b/web/libs/editor/src/lib/AudioUltra/Common/Utils.ts @@ -212,3 +212,28 @@ 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 thickness of the scrollbar's handle 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 width of the scrollbar. + * + * 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_SCROLLBAR_WIDTH = ((): 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"; + scrollDiv.style.scrollbarGutter = "stable"; + 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..e563f6958b90 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_SCROLLBAR_WIDTH, 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)); } /** @@ -639,17 +646,47 @@ export class Visualizer extends Events { this.wrapper = document.createElement("div"); this.wrapper.style.height = "100%"; - 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); + this.initScrollBar(); + mainLayer.appendTo(this.wrapper); container.appendChild(this.wrapper); } + initScrollBar() { + this.wrapper.style.position = "relative"; + this.wrapper.style.overflowX = "scroll"; + this.wrapper.style.overflowY = "hidden"; + this.wrapper.style.scrollbarGutter = "stable"; + + const mainLayer = this.getLayer("main") as Layer; + // The parent element scrolls natively, and the canvas is redrawn accordingly. + // To maintain its position during scrolling, the element must use "sticky" positioning. + mainLayer.canvas.style.position = "sticky"; + mainLayer.canvas.style.top = "0"; + mainLayer.canvas.style.left = "0"; + mainLayer.canvas.style.zIndex = "2"; + // Adds a scroll filler element to adjust the size of the scrollable area + this.scrollFiller = document.createElement("div"); + this.scrollFiller.style.position = "absolute"; + this.scrollFiller.style.width = "100%"; + this.scrollFiller.style.height = `${BROWSER_SCROLLBAR_WIDTH}px`; + this.scrollFiller.style.top = "100%"; + this.scrollFiller.style.minHeight = "1px"; + mainLayer.canvas.style.zIndex = "1"; + this.wrapper.appendChild(this.scrollFiller); + } + + updateScrollFiller() { + const { fullWidth } = this; + this.scrollFiller.style.width = `${fullWidth}px`; + } + reserveSpace({ height }: { height: number }) { this.reservedSpace = height; } @@ -792,6 +829,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 +893,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 +909,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 +984,7 @@ export class Visualizer extends Events { }; private setContainerHeight() { - this.container.style.height = `${this.height}px`; + this.container.style.height = `${this.height + BROWSER_SCROLLBAR_WIDTH}px`; } private updateSize() { @@ -958,6 +1004,8 @@ export class Visualizer extends Events { this.wf.renderTimeline(); this.resetWaveformRender(); this.draw(false, true); + this.updateScrollFiller(); + this.setScrollLeft(this.scrollLeft); }); }; diff --git a/web/libs/frontend-test/src/helpers/LSF/AudioView.ts b/web/libs/frontend-test/src/helpers/LSF/AudioView.ts index 87263031dd25..a4331a890367 100644 --- a/web/libs/frontend-test/src/helpers/LSF/AudioView.ts +++ b/web/libs/frontend-test/src/helpers/LSF/AudioView.ts @@ -1,6 +1,7 @@ import TriggerOptions = Cypress.TriggerOptions; import ObjectLike = Cypress.ObjectLike; import ClickOptions = Cypress.ClickOptions; +import { LabelStudio } from "@humansignal/frontend-test/helpers/LSF/LabelStudio"; type MouseInteractionOptions = Partial; @@ -36,7 +37,14 @@ export const AudioView = { return this.root.get("loading-progress-bar", { timeout: 10000 }); }, isReady() { + LabelStudio.waitForObjectsReady(); this.loadingBar.should("not.exist"); + /** + * There is a time gap between setting `isReady` to `true` and getting the last initial draw at the canvas, + * which for now we are going to compensate by waiting approximately 2 frames of render (16 * 2 = 32 milliseconds) + * @todo: remove wait when `isReady` in audio become more precise + */ + cy.wait(32); }, get playButton() { return cy.get(`[data-testid="playback-button:play"]`);