diff --git a/packages/playground/src/apps/ControlApp.ts b/packages/playground/src/apps/ControlApp.ts index d3af97c6a..b985b7484 100644 --- a/packages/playground/src/apps/ControlApp.ts +++ b/packages/playground/src/apps/ControlApp.ts @@ -118,7 +118,6 @@ export class ControlApp implements Mountable { -
`); @@ -138,11 +137,8 @@ export class ControlApp implements Mountable { '.cmp-footer', new Footer(this.api, { trackList: this.sidebar.trackList }) ); - this.selectionHandles = mount( - this.root, - '.cmp-selection-handles', - new SelectionHandles(this.api, viewport) - ); + this.selectionHandles = new SelectionHandles(this.api, viewport, canvas); + canvas.appendChild(this.selectionHandles.root); this.crosshair = new Crosshair(); this.dragDrop = new DragDrop(this.api, { onEnter: () => this.overlay.enterDrag(), diff --git a/packages/playground/src/components/SelectionHandles.ts b/packages/playground/src/components/SelectionHandles.ts index e471f779d..8a78e4b9e 100644 --- a/packages/playground/src/components/SelectionHandles.ts +++ b/packages/playground/src/components/SelectionHandles.ts @@ -24,6 +24,9 @@ injectStyles( transition: opacity 150ms ease-in-out; display: none; } + .at-selection-handle-end { + transform: translateX(-100%); + } .at-selection-handle:hover, .at-selection-handle.dragging { opacity: 1; @@ -41,6 +44,7 @@ export class SelectionHandles implements Mountable { private endHandle: HTMLElement; private currentHighlight: alphaTab.PlaybackHighlightChangeEventArgs | undefined; private dragging: DragSide | undefined; + private dragAnchor: alphaTab.model.Beat | undefined; private unsubHighlight: () => void; private onMouseMove = (e: MouseEvent) => { @@ -48,15 +52,13 @@ export class SelectionHandles implements Mountable { return; } e.preventDefault(); - const beat = this.beatFromEvent(e); - if (!beat || !this.currentHighlight) { + const anchor = this.dragAnchor; + const beat = anchor ? this.beatFromEvent(e, anchor) : undefined; + // the API treats a range of a single beat as no selection, keep the last range instead + if (!anchor || !beat || beat === anchor) { return; } - if (this.dragging === 'start') { - this.api.highlightPlaybackRange(beat, this.currentHighlight.endBeat!); - } else { - this.api.highlightPlaybackRange(this.currentHighlight.startBeat!, beat); - } + this.api.highlightPlaybackRange(anchor, beat); }; private onMouseUp = (e: MouseEvent) => { @@ -70,7 +72,8 @@ export class SelectionHandles implements Mountable { constructor( private api: alphaTab.AlphaTabApi, - private viewportEl: HTMLElement + private viewportEl: HTMLElement, + private canvasEl: HTMLElement ) { this.root = parseHtml(html`
@@ -92,6 +95,8 @@ export class SelectionHandles implements Mountable { private beginDrag(e: MouseEvent, side: DragSide): void { e.preventDefault(); this.dragging = side; + // the opposite edge stays fixed while dragging + this.dragAnchor = side === 'start' ? this.currentHighlight?.endBeat : this.currentHighlight?.startBeat; this.viewportEl.classList.add('at-selection-handle-drag'); const handle = side === 'start' ? this.startHandle : this.endHandle; handle.classList.add('dragging'); @@ -105,31 +110,39 @@ export class SelectionHandles implements Mountable { handle.classList.remove('dragging'); this.viewportEl.classList.remove('at-selection-handle-drag'); this.dragging = undefined; + this.dragAnchor = undefined; } private update(e: alphaTab.PlaybackHighlightChangeEventArgs): void { this.currentHighlight = e; - if (!e.startBeat || !e.endBeat) { + const blocks = e.highlightBlocks; + if (!e.startBeat || !e.endBeat || !blocks || blocks.length === 0) { this.startHandle.classList.remove('active'); this.endHandle.classList.remove('active'); return; } - const startBounds = e.startBeatBounds!; - const endBounds = e.endBeatBounds!; + // Align with the drawn highlight, which may extend to the bar edges. + const startBlock = blocks[0]; + const endBlock = blocks[blocks.length - 1]; this.startHandle.classList.add('active'); - this.startHandle.style.left = `${startBounds.realBounds.x}px`; - this.startHandle.style.top = `${startBounds.barBounds.masterBarBounds.visualBounds.y}px`; - this.startHandle.style.height = `${startBounds.barBounds.masterBarBounds.visualBounds.h}px`; + this.startHandle.style.left = `${startBlock.x}px`; + this.startHandle.style.top = `${startBlock.y}px`; + this.startHandle.style.height = `${startBlock.h}px`; this.endHandle.classList.add('active'); - this.endHandle.style.left = `${endBounds.realBounds.x + endBounds.realBounds.w}px`; - this.endHandle.style.top = `${endBounds.barBounds.masterBarBounds.visualBounds.y}px`; - this.endHandle.style.height = `${endBounds.barBounds.masterBarBounds.visualBounds.h}px`; + this.endHandle.style.left = `${endBlock.x + endBlock.w}px`; + this.endHandle.style.top = `${endBlock.y}px`; + this.endHandle.style.height = `${endBlock.h}px`; } - private beatFromEvent(e: MouseEvent): alphaTab.model.Beat | undefined { - const rect = this.viewportEl.getBoundingClientRect(); - const relX = e.clientX - rect.left; - const relY = e.clientY - rect.top; + private beatFromEvent(e: MouseEvent, anchor: alphaTab.model.Beat): alphaTab.model.Beat | undefined { + const surface = this.canvasEl.querySelector('.at-surface'); + if (!surface) { + return undefined; + } + // Bounds are relative to the score surface, not the scrolled viewport. + const rect = surface.getBoundingClientRect(); + const relX = (e.clientX - rect.left) * (surface.offsetWidth / rect.width); + const relY = (e.clientY - rect.top) * (surface.offsetHeight / rect.height); const beat = this.api.boundsLookup?.getBeatAtPos(relX, relY); if (!beat) { return undefined; @@ -138,12 +151,14 @@ export class SelectionHandles implements Mountable { if (!bounds) { return undefined; } - const visualEnd = bounds.visualBounds.x + bounds.visualBounds.w; - const realEnd = bounds.realBounds.x + bounds.realBounds.w; - if (relX < visualEnd || relX > realEnd) { - return undefined; + // A beat joins the selection once the pointer passes the center of its glyph: + // after the anchor the edge is the end of a beat, before it the start of one. + const isAfterAnchor = beat.absolutePlaybackStart >= anchor.absolutePlaybackStart; + const isRightHalf = relX >= bounds.visualBounds.x + bounds.visualBounds.w / 2; + if (isAfterAnchor) { + return (isRightHalf ? beat : beat.previousBeat) ?? undefined; } - return beat; + return (isRightHalf ? beat.nextBeat : beat) ?? undefined; } dispose(): void {