From 79e650eeb396d6bf4314daa3924bcab09417c538 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sat, 6 Dec 2025 01:03:39 +0100 Subject: [PATCH 1/5] feat: place selection on bar bounds for initial beat --- packages/alphatab/src/AlphaTabApiBase.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/alphatab/src/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index 9b87bc9c3..4e1f23e69 100644 --- a/packages/alphatab/src/AlphaTabApiBase.ts +++ b/packages/alphatab/src/AlphaTabApiBase.ts @@ -2864,7 +2864,11 @@ export class AlphaTabApiBase { startBeat = endBeat; endBeat = t; } - const startX: number = startBeat.bounds!.realBounds.x; + let startX: number = startBeat.bounds!.realBounds.x; + if(startBeat.beat.index === 0) { + startX = startBeat.bounds!.barBounds.masterBarBounds.realBounds.x; + } + let endX: number = endBeat.bounds!.realBounds.x + endBeat.bounds!.realBounds.w; if (endBeat.beat.index === endBeat.beat.voice.beats.length - 1) { endX = From 1eced59afa1dbdf1f238a59096f9df9667ea9ff2 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sat, 6 Dec 2025 01:28:50 +0100 Subject: [PATCH 2/5] feat: Expose API for custom selection handling --- packages/alphatab/src/AlphaTabApiBase.ts | 238 ++++++++++++++++-- .../alphatab/src/rendering/utils/Bounds.ts | 15 +- 2 files changed, 226 insertions(+), 27 deletions(-) diff --git a/packages/alphatab/src/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index 4e1f23e69..eef4d4cfc 100644 --- a/packages/alphatab/src/AlphaTabApiBase.ts +++ b/packages/alphatab/src/AlphaTabApiBase.ts @@ -57,7 +57,7 @@ import type { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/Rend import { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; import { ScoreRendererWrapper } from '@coderline/alphatab/rendering/ScoreRendererWrapper'; import type { BeatBounds } from '@coderline/alphatab/rendering/utils/BeatBounds'; -import type { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; +import { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; import type { BoundsLookup } from '@coderline/alphatab/rendering/utils/BoundsLookup'; import type { MasterBarBounds } from '@coderline/alphatab/rendering/utils/MasterBarBounds'; import type { StaffSystemBounds } from '@coderline/alphatab/rendering/utils/StaffSystemBounds'; @@ -82,14 +82,44 @@ import type { PositionChangedEventArgs } from '@coderline/alphatab/synth/Positio /** * @internal + * @record */ -class SelectionInfo { - public beat: Beat; - public bounds: BeatBounds | null = null; +interface SelectionInfo { + beat: Beat; + bounds?: BeatBounds; +} - public constructor(beat: Beat) { - this.beat = beat; - } +/** + * Holds information about the highlights shown for the playback range. + * @public + * @record + */ +export interface PlaybackHighlightChangeEventArgs { + /** + * The beat where the selection starts. undefined if there is no selection. + */ + startBeat?: Beat; + + /** + * The bounds of the start beat to determine its location and size. + */ + startBeatBounds?: BeatBounds; + + /** + * The beat where the selection ends. undefined if there is no selection. + */ + endBeat?: Beat; + + /** + * The bounds of the end beat to determine its location and size. + */ + endBeatBounds?: BeatBounds; + + /** + * A list of the individual rectangular areas where highlight blocks are placed. + * If a selection spans multiple lines this array will hold all items. + */ + highlightBlocks?: Bounds[]; } /** @@ -2387,8 +2417,8 @@ export class AlphaTabApiBase { private _isBeatMouseDown: boolean = false; private _isNoteMouseDown: boolean = false; - private _selectionStart: SelectionInfo | null = null; - private _selectionEnd: SelectionInfo | null = null; + private _selectionStart?: SelectionInfo; + private _selectionEnd?: SelectionInfo; /** * This event is fired whenever a the user presses the mouse button on a beat. @@ -2640,8 +2670,8 @@ export class AlphaTabApiBase { } if (this._hasCursor && this.settings.player.enableUserInteraction) { - this._selectionStart = new SelectionInfo(beat); - this._selectionEnd = null; + this._selectionStart = { beat }; + this._selectionEnd = undefined; } this._isBeatMouseDown = true; (this.beatMouseDown as EventEmitterOfT).trigger(beat); @@ -2665,7 +2695,7 @@ export class AlphaTabApiBase { if (this.settings.player.enableUserInteraction) { if (!this._selectionEnd || this._selectionEnd.beat !== beat) { - this._selectionEnd = new SelectionInfo(beat); + this._selectionEnd = { beat }; this._cursorSelectRange(this._selectionStart, this._selectionEnd); } } @@ -2728,7 +2758,7 @@ export class AlphaTabApiBase { 50; this.playbackRange = range; } else { - this._selectionStart = null; + this._selectionStart = undefined; this.playbackRange = null; this._cursorSelectRange(this._selectionStart, this._selectionEnd); } @@ -2758,12 +2788,12 @@ export class AlphaTabApiBase { const startBeat = this._tickCache.findBeat(this._trackIndexLookup!, range.startTick); const endBeat = this._tickCache.findBeat(this._trackIndexLookup!, range.endTick); if (startBeat && endBeat) { - const selectionStart = new SelectionInfo(startBeat.beat); - const selectionEnd = new SelectionInfo(endBeat.beat); + const selectionStart = { beat: startBeat.beat }; + const selectionEnd = { beat: endBeat.beat }; this._cursorSelectRange(selectionStart, selectionEnd); } } else { - this._cursorSelectRange(null, null); + this._cursorSelectRange(undefined, undefined); } } @@ -2836,27 +2866,154 @@ export class AlphaTabApiBase { }); } - private _cursorSelectRange(startBeat: SelectionInfo | null, endBeat: SelectionInfo | null): void { + /** + * Places the highlight markers at the specified start and end-beat range. + * @param startBeat The start beat where the selection should start + * @param endBeat The end beat where the selection should end. + * + * @remarks + * Unlike actually setting {@link playbackRange} this method only places the selection markers without actually + * changing the playback range. This method can be used when building custom selection systems (e.g. having draggable handles). + * + * @category Methods - Player + * @since 1.8.0 + * + * @example + * JavaScript + * ```js + * const api = new alphaTab.AlphaTabApi(document.querySelector('#alphaTab')); + * const startBeat = api.score.tracks[0].staves[0].bars[0].voices[0].beats[0]; + * const endBeat = api.score.tracks[0].staves[0].bars[3].voices[0].beats[0]; + * api.highlightPlaybackRange(startBeat, endBeat); + * ``` + * + * @example + * C# + * ```cs + * var api = new AlphaTabApi(...); + * api.ChangeTrackVolume(new Track[] { api.Score.Tracks[0], api.Score.Tracks[1] }, 1.5); + * api.ChangeTrackVolume(new Track[] { api.Score.Tracks[2] }, 0.5); + * var startBeat = api.Score.Tracks[0].Staves[0].Bars[0].Voices[0].Beats[0]; + * var endBeat = api.Score.Tracks[0].Staves[0].Bars[3].Voices[0].Beats[0]; + * api.HighlightPlaybackRange(startBeat, endBeat); + * ``` + * + * @example + * Android + * ```kotlin + * val api = AlphaTabApi(...) + * val startBeat = api.score.tracks[0].staves[0].bars[0].voices[0].beats[0] + * val endBeat = api.score.tracks[0].staves[0].bars[3].voices[0].beats[0] + * api.highlightPlaybackRange(startBeat, endBeat) + * ``` + */ + public highlightPlaybackRange(startBeat: Beat, endBeat: Beat) { + this._cursorSelectRange({ beat: startBeat }, { beat: endBeat }); + } + + /** + * Clears the highlight markers marking the currently selected playback range. + * + * @remarks + * Unlike actually setting {@link playbackRange} this method only clears the selection markers without actually + * changing the playback range. This method can be used when building custom selection systems (e.g. having draggable handles). + * + * @category Methods - Player + * @since 1.8.0 + * + * @example + * JavaScript + * ```js + * const api = new alphaTab.AlphaTabApi(document.querySelector('#alphaTab')); + * api.clearPlaybackRangeHighlight(); + * ``` + * + * @example + * C# + * ```cs + * var api = new AlphaTabApi(...); + * api.clearPlaybackRangeHighlight(); + * ``` + * + * @example + * Android + * ```kotlin + * val api = AlphaTabApi(...) + * api.clearPlaybackRangeHighlight() + * ``` + */ + public clearPlaybackRangeHighlight() { + this._cursorSelectRange(undefined, undefined); + } + + /** + * This event is fired the shown highlights for the selected playback range changes. + * + * @remarks + * This event is fired already during selection and not only when the selection is completed. + * This event can be used to place additional custom selection markers (like drag handles). + * + * @eventProperty + * @category Events - Player + * @since 1.8.0 + * + * @example + * JavaScript + * ```js + * const api = new alphaTab.AlphaTabApi(document.querySelector('#alphaTab')); + * api.playbackRangeHighlightChanged.on(e => { + * updateSelectionHandles(e); + * }); + * ``` + * + * @example + * C# + * ```cs + * var api = new AlphaTabApi(...); + * api.PlaybackRangeHighlightChanged.On(e => + * { + * UpdateSelectionHandles(e); + * }); + * ``` + * + * @example + * Android + * ```kotlin + * val api = AlphaTabApi(...) + * api.playbackRangeHighlightChanged.on { e -> + * updateSelectionHandles(e) + * } + * ``` + * + */ + public readonly playbackRangeHighlightChanged: IEventEmitterOfT = + new EventEmitterOfT(); + + private _cursorSelectRange(startBeat: SelectionInfo | undefined, endBeat: SelectionInfo | undefined): void { const cache: BoundsLookup | null = this._renderer.boundsLookup; if (!cache) { + (this.playbackRangeHighlightChanged as EventEmitterOfT).trigger({}); return; } const selectionWrapper: IContainer | null = this._selectionWrapper; if (!selectionWrapper) { + (this.playbackRangeHighlightChanged as EventEmitterOfT).trigger({}); return; } selectionWrapper.clear(); if (!startBeat || !endBeat || startBeat.beat === endBeat.beat) { + (this.playbackRangeHighlightChanged as EventEmitterOfT).trigger({}); return; } if (!startBeat.bounds) { - startBeat.bounds = cache.findBeat(startBeat.beat); + startBeat.bounds = cache.findBeat(startBeat.beat) ?? undefined; } if (!endBeat.bounds) { - endBeat.bounds = cache.findBeat(endBeat.beat); + endBeat.bounds = cache.findBeat(endBeat.beat) ?? undefined; } + const startTick: number = this._tickCache?.getBeatStart(startBeat.beat) ?? startBeat.beat.absolutePlaybackStart; const endTick: number = this._tickCache?.getBeatStart(endBeat.beat) ?? endBeat.beat.absolutePlaybackStart; if (endTick < startTick) { @@ -2864,8 +3021,17 @@ export class AlphaTabApiBase { startBeat = endBeat; endBeat = t; } + + const eventArgs: PlaybackHighlightChangeEventArgs = { + startBeat: startBeat.beat, + startBeatBounds: startBeat.bounds, + endBeat: endBeat.beat, + endBeatBounds: endBeat.bounds, + highlightBlocks: [] + }; + let startX: number = startBeat.bounds!.realBounds.x; - if(startBeat.beat.index === 0) { + if (startBeat.beat.index === 0) { startX = startBeat.bounds!.barBounds.masterBarBounds.realBounds.x; } @@ -2888,33 +3054,57 @@ export class AlphaTabApiBase { startBeat.bounds!.barBounds.masterBarBounds.staffSystemBounds!.visualBounds.x + startBeat.bounds!.barBounds.masterBarBounds.staffSystemBounds!.visualBounds.w; const startSelection: IContainer = this.uiFacade.createSelectionElement()!; - startSelection.setBounds( + const startSelectionBounds = new Bounds( startX, startBeat.bounds!.barBounds.masterBarBounds.visualBounds.y, staffEndX - startX, startBeat.bounds!.barBounds.masterBarBounds.visualBounds.h ); + startSelection.setBounds( + startSelectionBounds.x, + startSelectionBounds.y, + startSelectionBounds.w, + startSelectionBounds.h + ); + eventArgs.highlightBlocks!.push(startSelectionBounds); + selectionWrapper.appendChild(startSelection); const staffStartIndex: number = startBeat.bounds!.barBounds.masterBarBounds.staffSystemBounds!.index + 1; const staffEndIndex: number = endBeat.bounds!.barBounds.masterBarBounds.staffSystemBounds!.index; for (let staffIndex: number = staffStartIndex; staffIndex < staffEndIndex; staffIndex++) { const staffBounds: StaffSystemBounds = cache.staffSystems[staffIndex]; const middleSelection: IContainer = this.uiFacade.createSelectionElement()!; - middleSelection.setBounds( + const middleSelectionBounds = new Bounds( staffStartX, staffBounds.visualBounds.y, staffEndX - staffStartX, staffBounds.visualBounds.h ); + eventArgs.highlightBlocks!.push(middleSelectionBounds); + + middleSelection.setBounds( + middleSelectionBounds.x, + middleSelectionBounds.y, + middleSelectionBounds.w, + middleSelectionBounds.h + ); selectionWrapper.appendChild(middleSelection); } const endSelection: IContainer = this.uiFacade.createSelectionElement()!; - endSelection.setBounds( + const endSelectionBounds = new Bounds( staffStartX, endBeat.bounds!.barBounds.masterBarBounds.visualBounds.y, endX - staffStartX, endBeat.bounds!.barBounds.masterBarBounds.visualBounds.h ); + eventArgs.highlightBlocks!.push(endSelectionBounds); + + endSelection.setBounds( + endSelectionBounds.x, + endSelectionBounds.y, + endSelectionBounds.w, + endSelectionBounds.h + ); selectionWrapper.appendChild(endSelection); } else { // if the beats are on the same staff, we simply highlight from the startbeat to endbeat @@ -2927,6 +3117,8 @@ export class AlphaTabApiBase { ); selectionWrapper.appendChild(selection); } + + (this.playbackRangeHighlightChanged as EventEmitterOfT).trigger(eventArgs); } /** diff --git a/packages/alphatab/src/rendering/utils/Bounds.ts b/packages/alphatab/src/rendering/utils/Bounds.ts index 21917edd7..7bf5c2737 100644 --- a/packages/alphatab/src/rendering/utils/Bounds.ts +++ b/packages/alphatab/src/rendering/utils/Bounds.ts @@ -6,22 +6,22 @@ export class Bounds { /** * Gets or sets the X-position of the rectangle within the music notation. */ - public x: number = 0; + public x: number; /** * Gets or sets the Y-position of the rectangle within the music notation. */ - public y: number = 0; + public y: number; /** * Gets or sets the width of the rectangle. */ - public w: number = 0; + public w: number; /** * Gets or sets the height of the rectangle. */ - public h: number = 0; + public h: number; public scaleWith(scale: number) { this.x *= scale; @@ -29,4 +29,11 @@ export class Bounds { this.w *= scale; this.h *= scale; } + + public constructor(x: number = 0, y: number = 0, w: number = 0, h: number = 0) { + this.x = x; + this.y = y; + this.h = h; + this.w = w; + } } From 4715422c9d75f1be82af0250aeb3b6d07b1280a2 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sat, 6 Dec 2025 12:59:47 +0100 Subject: [PATCH 3/5] feat: finish public api for selection handling --- packages/alphatab/src/AlphaTabApiBase.ts | 139 +++++++++++++++-------- packages/alphatab/src/alphaTab.core.ts | 2 +- 2 files changed, 94 insertions(+), 47 deletions(-) diff --git a/packages/alphatab/src/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index eef4d4cfc..6464f728e 100644 --- a/packages/alphatab/src/AlphaTabApiBase.ts +++ b/packages/alphatab/src/AlphaTabApiBase.ts @@ -2718,51 +2718,7 @@ export class AlphaTabApiBase { } if (this._hasCursor && this.settings.player.enableUserInteraction) { - if (this._selectionEnd) { - const startTick: number = - this._tickCache?.getBeatStart(this._selectionStart!.beat) ?? - this._selectionStart!.beat.absolutePlaybackStart; - const endTick: number = - this._tickCache?.getBeatStart(this._selectionEnd!.beat) ?? - this._selectionEnd!.beat.absolutePlaybackStart; - if (endTick < startTick) { - const t: SelectionInfo = this._selectionStart!; - this._selectionStart = this._selectionEnd; - this._selectionEnd = t; - } - } - if (this._selectionStart && this._tickCache) { - // get the start and stop ticks (which consider properly repeats) - const tickCache: MidiTickLookup = this._tickCache; - const realMasterBarStart: number = tickCache.getMasterBarStart( - this._selectionStart.beat.voice.bar.masterBar - ); - // move to selection start - this._currentBeat = null; // reset current beat so it is updating the cursor - if (this._player.state === PlayerState.Paused) { - this._cursorUpdateTick(this._tickCache.getBeatStart(this._selectionStart.beat), false, 1); - } - this.tickPosition = realMasterBarStart + this._selectionStart.beat.playbackStart; - // set playback range - if (this._selectionEnd && this._selectionStart.beat !== this._selectionEnd.beat) { - const realMasterBarEnd: number = tickCache.getMasterBarStart( - this._selectionEnd.beat.voice.bar.masterBar - ); - - const range = new PlaybackRange(); - range.startTick = realMasterBarStart + this._selectionStart.beat.playbackStart; - range.endTick = - realMasterBarEnd + - this._selectionEnd.beat.playbackStart + - this._selectionEnd.beat.playbackDuration - - 50; - this.playbackRange = range; - } else { - this._selectionStart = undefined; - this.playbackRange = null; - this._cursorSelectRange(this._selectionStart, this._selectionEnd); - } - } + this.applyPlaybackRangeFromHighlight(); } (this.beatMouseUp as EventEmitterOfT).trigger(beat); @@ -2908,7 +2864,98 @@ export class AlphaTabApiBase { * ``` */ public highlightPlaybackRange(startBeat: Beat, endBeat: Beat) { - this._cursorSelectRange({ beat: startBeat }, { beat: endBeat }); + this._selectionStart = { beat: startBeat }; + this._selectionEnd = { beat: endBeat }; + this._cursorSelectRange(this._selectionStart, this._selectionEnd); + } + + /** + * Applies the playback range from the currently highlighted range. + * + * @remarks + * This method can be used when building custom selection systems (e.g. having draggable handles). + * + * @category Methods - Player + * @since 1.8.0 + * + * @example + * JavaScript + * ```js + * const api = new alphaTab.AlphaTabApi(document.querySelector('#alphaTab')); + * const startBeat = api.score.tracks[0].staves[0].bars[0].voices[0].beats[0]; + * const endBeat = api.score.tracks[0].staves[0].bars[3].voices[0].beats[0]; + * api.highlightPlaybackRange(startBeat, endBeat); + * api.applyPlaybackRangeFromHighlight(); + * ``` + * + * @example + * C# + * ```cs + * var api = new AlphaTabApi(...); + * api.ChangeTrackVolume(new Track[] { api.Score.Tracks[0], api.Score.Tracks[1] }, 1.5); + * api.ChangeTrackVolume(new Track[] { api.Score.Tracks[2] }, 0.5); + * var startBeat = api.Score.Tracks[0].Staves[0].Bars[0].Voices[0].Beats[0]; + * var endBeat = api.Score.Tracks[0].Staves[0].Bars[3].Voices[0].Beats[0]; + * api.HighlightPlaybackRange(startBeat, endBeat); + * api.ApplyPlaybackRangeFromHighlight(); + * ``` + * + * @example + * Android + * ```kotlin + * val api = AlphaTabApi(...) + * val startBeat = api.score.tracks[0].staves[0].bars[0].voices[0].beats[0] + * val endBeat = api.score.tracks[0].staves[0].bars[3].voices[0].beats[0] + * api.highlightPlaybackRange(startBeat, endBeat) + * api.applyPlaybackRangeFromHighlight() + * ``` + */ + public applyPlaybackRangeFromHighlight() { + if (this._selectionEnd) { + const startTick: number = + this._tickCache?.getBeatStart(this._selectionStart!.beat) ?? + this._selectionStart!.beat.absolutePlaybackStart; + const endTick: number = + this._tickCache?.getBeatStart(this._selectionEnd!.beat) ?? + this._selectionEnd!.beat.absolutePlaybackStart; + if (endTick < startTick) { + const t: SelectionInfo = this._selectionStart!; + this._selectionStart = this._selectionEnd; + this._selectionEnd = t; + } + } + if (this._selectionStart && this._tickCache) { + // get the start and stop ticks (which consider properly repeats) + const tickCache: MidiTickLookup = this._tickCache; + const realMasterBarStart: number = tickCache.getMasterBarStart( + this._selectionStart.beat.voice.bar.masterBar + ); + // move to selection start + this._currentBeat = null; // reset current beat so it is updating the cursor + if (this._player.state === PlayerState.Paused) { + this._cursorUpdateTick(this._tickCache.getBeatStart(this._selectionStart.beat), false, 1); + } + this.tickPosition = realMasterBarStart + this._selectionStart.beat.playbackStart; + // set playback range + if (this._selectionEnd && this._selectionStart.beat !== this._selectionEnd.beat) { + const realMasterBarEnd: number = tickCache.getMasterBarStart( + this._selectionEnd.beat.voice.bar.masterBar + ); + + const range = new PlaybackRange(); + range.startTick = realMasterBarStart + this._selectionStart.beat.playbackStart; + range.endTick = + realMasterBarEnd + + this._selectionEnd.beat.playbackStart + + this._selectionEnd.beat.playbackDuration - + 50; + this.playbackRange = range; + } else { + this._selectionStart = undefined; + this.playbackRange = null; + this._cursorSelectRange(this._selectionStart, this._selectionEnd); + } + } } /** diff --git a/packages/alphatab/src/alphaTab.core.ts b/packages/alphatab/src/alphaTab.core.ts index b447b24ed..86cc284e1 100644 --- a/packages/alphatab/src/alphaTab.core.ts +++ b/packages/alphatab/src/alphaTab.core.ts @@ -39,7 +39,7 @@ export { Environment, RenderEngineFactory } from '@coderline/alphatab/Environmen export type { IEventEmitter, IEventEmitterOfT } from '@coderline/alphatab/EventEmitter'; export { AlphaTabApi } from '@coderline/alphatab/platform/javascript/AlphaTabApi'; -export { AlphaTabApiBase } from '@coderline/alphatab/AlphaTabApiBase'; +export { AlphaTabApiBase, type PlaybackHighlightChangeEventArgs } from '@coderline/alphatab/AlphaTabApiBase'; export { WebPlatform } from '@coderline/alphatab/platform/javascript/WebPlatform'; export { VersionInfo as meta } from '@coderline/alphatab/generated/VersionInfo'; From 847d19f98abfa06e9db6412cf1cdf5d23c114d82 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sat, 6 Dec 2025 13:00:06 +0100 Subject: [PATCH 4/5] feat(playground): add demo for selection handles to dev playground --- packages/playground/control.css | 2 + packages/playground/control.ts | 209 +------------------------ packages/playground/select-handles.css | 35 +++++ packages/playground/select-handles.ts | 176 +++++++++++++++++++++ 4 files changed, 214 insertions(+), 208 deletions(-) create mode 100644 packages/playground/select-handles.css create mode 100644 packages/playground/select-handles.ts diff --git a/packages/playground/control.css b/packages/playground/control.css index b7278eeeb..e4bc65d11 100644 --- a/packages/playground/control.css +++ b/packages/playground/control.css @@ -520,3 +520,5 @@ input[type='range']::-moz-range-thumb { cursor: ew-resize !important; } + +@import url('./select-handles.css'); \ No newline at end of file diff --git a/packages/playground/control.ts b/packages/playground/control.ts index 426d53c48..23dc29f7f 100644 --- a/packages/playground/control.ts +++ b/packages/playground/control.ts @@ -1,6 +1,7 @@ import * as alphaTab from '@coderline/alphatab'; import * as bootstrap from 'bootstrap'; import Handlebars from 'handlebars'; +import { setupSelectionHandles } from 'select-handles'; const toDomElement = (() => { const parser = document.createElement('div'); @@ -632,214 +633,6 @@ export function setupControl(selector: string, customSettings: alphaTab.json.Set return at; } -function createSelectionHandles(element: HTMLElement): { startHandle: HTMLElement; endHandle: HTMLElement } { - // create - const handleWrapper = document.createElement('div'); - handleWrapper.classList.add('at-selection-handles'); - element.insertBefore(handleWrapper, element.querySelector('at-surface')); - - const startHandle = document.createElement('div'); - startHandle.classList.add('at-selection-handle', 'at-selection-handle-start'); - handleWrapper.appendChild(startHandle); - - const endHandle = document.createElement('div'); - endHandle.classList.add('at-selection-handle', 'at-selection-handle-end'); - handleWrapper.appendChild(endHandle); - - return { startHandle, endHandle }; -} - -interface HandleDragState { - isDragging: 'start' | 'end' | undefined; -} -function setupHandleDrag( - element: HTMLElement, - handle: HTMLElement, - dragState: HandleDragState, - type: HandleDragState['isDragging'], - onMove: (e: MouseEvent) => void, - onDragEnd: (e: MouseEvent) => void -) { - handle.addEventListener( - 'mousedown', - e => { - e.preventDefault(); - element.classList.add('at-selection-handle-drag'); - handle.classList.add('at-selection-handle-drag'); - dragState.isDragging = type; - }, - false - ); - document.addEventListener( - 'mousemove', - e => { - if (dragState.isDragging !== type) { - return; - } - e.preventDefault(); - onMove(e); - }, - true - ); - document.addEventListener( - 'mouseup', - e => { - if (dragState.isDragging !== type) { - return; - } - e.preventDefault(); - dragState.isDragging = undefined; - element.classList.remove('at-selection-handle-drag'); - handle.classList.remove('at-selection-handle-drag'); - onDragEnd(e); - }, - true - ); -} - -function getRelativePosition(parent: HTMLElement, e: MouseEvent): { relX: number; relY: number } { - const parentPos = parent.getBoundingClientRect(); - const parentLeft: number = parentPos.left + parent.ownerDocument!.defaultView!.pageXOffset; - const parentTop: number = parentPos.top + parent.ownerDocument!.defaultView!.pageYOffset; - - const relX = e.pageX - parentLeft; - const relY = e.pageY - parentTop; - - return { relX, relY }; -} - -function getBeatFromEvent( - element: HTMLElement, - api: alphaTab.AlphaTabApi, - e: MouseEvent -): alphaTab.model.Beat | undefined { - const { relX, relY } = getRelativePosition(element, e); - const beat = api.boundsLookup?.getBeatAtPos(relX, relY); - if (!beat) { - return undefined; - } - - const bounds = api.boundsLookup!.findBeat(beat); - if (!bounds) { - return undefined; - } - - // only snap to beat beat if we are over the whitespace after the beat - const visualBoundsEnd = bounds.visualBounds.x + bounds.visualBounds.w; - const realBoundsEnd = bounds.realBounds.x + bounds.realBounds.w; - if (relX < visualBoundsEnd || relX > realBoundsEnd) { - return undefined; - } - - return beat; -} - -function setupSelectionHandles(element: HTMLElement, api: alphaTab.AlphaTabApi) { - const { startHandle, endHandle } = createSelectionHandles(element); - - // override internal logic for updating selection UI - interface SelectionInfo { - beat: alphaTab.model.Beat; - bounds: alphaTab.rendering.BeatBounds | null; - } - interface AlphaTabApiSelectionInternals { - _selectionStart: SelectionInfo | null; - _selectionEnd: SelectionInfo | null; - _cursorSelectRange(startBeat: SelectionInfo | null, endBeat: SelectionInfo | null): void; - } - - const apiWithInternals = api as unknown as AlphaTabApiSelectionInternals; - - // listen to selection range changes to place handles - const oldMethod = apiWithInternals._cursorSelectRange; - apiWithInternals._cursorSelectRange = function (startBeat, endBeat) { - oldMethod.call(this, startBeat, endBeat); - - // no selection - if (!startBeat || !endBeat || startBeat.beat === endBeat.beat) { - startHandle.classList.remove('active'); - endHandle.classList.remove('active'); - return; - } - - const selectionItems = element.querySelectorAll('.at-selection > div'); - if (selectionItems.length === 0) { - return; - } - - startHandle.classList.add('active'); - startHandle.style.left = `${startBeat.bounds!.realBounds.x}px`; - startHandle.style.top = `${startBeat.bounds!.barBounds.masterBarBounds.visualBounds.y}px`; - startHandle.style.height = `${startBeat.bounds!.barBounds.masterBarBounds.visualBounds.h}px`; - - endHandle.classList.add('active'); - endHandle.style.left = `${endBeat.bounds!.realBounds.x + endBeat.bounds!.realBounds.w}px`; - endHandle.style.top = `${endBeat.bounds!.barBounds.masterBarBounds.visualBounds.y}px`; - endHandle.style.height = `${endBeat.bounds!.barBounds.masterBarBounds.visualBounds.h}px`; - }; - - // setup dragging of handles - const dragState: HandleDragState = { isDragging: undefined }; - - function updatePlaybackRange() { - const realMasterBarStart: number = api.tickCache!.getMasterBarStart( - apiWithInternals._selectionStart!.beat.voice.bar.masterBar - ); - - const realMasterBarEnd: number = api.tickCache!.getMasterBarStart( - apiWithInternals._selectionEnd!.beat.voice.bar.masterBar - ); - - const range = new alphaTab.synth.PlaybackRange(); - range.startTick = realMasterBarStart + apiWithInternals._selectionStart!.beat.playbackStart; - range.endTick = - realMasterBarEnd + - apiWithInternals._selectionEnd!.beat.playbackStart + - apiWithInternals._selectionEnd!.beat.playbackDuration - - 50; - api.playbackRange = range; - } - - setupHandleDrag( - element, - startHandle, - dragState, - 'start', - e => { - const beat = getBeatFromEvent(element, api, e); - if (!beat) { - return; - } - - apiWithInternals._selectionStart = { - beat, - bounds: null - }; - apiWithInternals._cursorSelectRange(apiWithInternals._selectionStart, apiWithInternals._selectionEnd); - }, - updatePlaybackRange - ); - - setupHandleDrag( - element, - endHandle, - dragState, - 'end', - e => { - const beat = getBeatFromEvent(element, api, e); - if (!beat) { - return; - } - apiWithInternals._selectionEnd = { - beat, - bounds: null - }; - apiWithInternals._cursorSelectRange(apiWithInternals._selectionStart, apiWithInternals._selectionEnd); - }, - updatePlaybackRange - ); -} - function percentageToDegrees(percentage: number) { return (percentage / 100) * 360; } diff --git a/packages/playground/select-handles.css b/packages/playground/select-handles.css new file mode 100644 index 000000000..e49aa7ef6 --- /dev/null +++ b/packages/playground/select-handles.css @@ -0,0 +1,35 @@ +.at-selection-handles { + position : absolute; + pointer-events: none; + z-index: 1001; + display: inline; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.at-selection-handle { + position : absolute; + pointer-events: auto; + cursor: ew-resize; + background: #7cb9ff; + width: 4px; + opacity: 0; + transition: opacity 150ms ease-in-out; + display: none; +} + +.at-selection-handle:hover, +.at-selection-handle.at-selection-handle-drag { + opacity: 1; +} + +.at-selection-handle.active { + display: block; +} + +.at-selection-handle-drag * { + cursor: ew-resize !important; +} + diff --git a/packages/playground/select-handles.ts b/packages/playground/select-handles.ts new file mode 100644 index 000000000..80d3aeb29 --- /dev/null +++ b/packages/playground/select-handles.ts @@ -0,0 +1,176 @@ +import type * as alphaTab from '@coderline/alphatab'; + +interface HandleDragState { + isDragging: 'start' | 'end' | undefined; +} + +function createSelectionHandles(element: HTMLElement): { startHandle: HTMLElement; endHandle: HTMLElement } { + const handleWrapper = document.createElement('div'); + handleWrapper.classList.add('at-selection-handles'); + element.insertBefore(handleWrapper, element.querySelector('at-surface')); + + const startHandle = document.createElement('div'); + startHandle.classList.add('at-selection-handle', 'at-selection-handle-start'); + handleWrapper.appendChild(startHandle); + + const endHandle = document.createElement('div'); + endHandle.classList.add('at-selection-handle', 'at-selection-handle-end'); + handleWrapper.appendChild(endHandle); + + return { startHandle, endHandle }; +} + +function setupHandleDrag( + element: HTMLElement, + handle: HTMLElement, + dragState: HandleDragState, + type: HandleDragState['isDragging'], + onMove: (e: MouseEvent) => void, + onDragEnd: (e: MouseEvent) => void +) { + handle.addEventListener( + 'mousedown', + e => { + e.preventDefault(); + element.classList.add('at-selection-handle-drag'); + handle.classList.add('at-selection-handle-drag'); + dragState.isDragging = type; + }, + false + ); + document.addEventListener( + 'mousemove', + e => { + if (dragState.isDragging !== type) { + return; + } + e.preventDefault(); + onMove(e); + }, + true + ); + document.addEventListener( + 'mouseup', + e => { + if (dragState.isDragging !== type) { + return; + } + e.preventDefault(); + dragState.isDragging = undefined; + element.classList.remove('at-selection-handle-drag'); + handle.classList.remove('at-selection-handle-drag'); + onDragEnd(e); + }, + true + ); +} + +function getRelativePosition(parent: HTMLElement, e: MouseEvent): { relX: number; relY: number } { + const parentPos = parent.getBoundingClientRect(); + const parentLeft: number = parentPos.left + parent.ownerDocument!.defaultView!.pageXOffset; + const parentTop: number = parentPos.top + parent.ownerDocument!.defaultView!.pageYOffset; + + const relX = e.pageX - parentLeft; + const relY = e.pageY - parentTop; + + return { relX, relY }; +} + +function getBeatFromEvent( + element: HTMLElement, + api: alphaTab.AlphaTabApi, + e: MouseEvent +): alphaTab.model.Beat | undefined { + const { relX, relY } = getRelativePosition(element, e); + const beat = api.boundsLookup?.getBeatAtPos(relX, relY); + if (!beat) { + return undefined; + } + + const bounds = api.boundsLookup!.findBeat(beat); + if (!bounds) { + return undefined; + } + + // only snap to beat beat if we are over the whitespace after the beat + const visualBoundsEnd = bounds.visualBounds.x + bounds.visualBounds.w; + const realBoundsEnd = bounds.realBounds.x + bounds.realBounds.w; + if (relX < visualBoundsEnd || relX > realBoundsEnd) { + return undefined; + } + + return beat; +} + +export function setupSelectionHandles(element: HTMLElement, api: alphaTab.AlphaTabApi) { + const { startHandle, endHandle } = createSelectionHandles(element); + + // listen to selection range changes to place handles + let currentHighlight: alphaTab.PlaybackHighlightChangeEventArgs | undefined; + api.playbackRangeHighlightChanged.on(e => { + currentHighlight = e; + // no selection + if (!e.startBeat || !e.endBeat) { + startHandle.classList.remove('active'); + endHandle.classList.remove('active'); + return; + } + + startHandle.classList.add('active'); + startHandle.style.left = `${e.startBeatBounds!.realBounds.x}px`; + startHandle.style.top = `${e.startBeatBounds!.barBounds.masterBarBounds.visualBounds.y}px`; + startHandle.style.height = `${e.startBeatBounds!.barBounds.masterBarBounds.visualBounds.h}px`; + + endHandle.classList.add('active'); + endHandle.style.left = `${e.endBeatBounds!.realBounds.x + e.endBeatBounds!.realBounds.w}px`; + endHandle.style.top = `${e.endBeatBounds!.barBounds.masterBarBounds.visualBounds.y}px`; + endHandle.style.height = `${e.endBeatBounds!.barBounds.masterBarBounds.visualBounds.h}px`; + }); + + // setup dragging of handles + const dragState: HandleDragState = { isDragging: undefined }; + + setupHandleDrag( + element, + startHandle, + dragState, + 'start', + e => { + if (!currentHighlight?.startBeat) { + return; + } + + const beat = getBeatFromEvent(element, api, e); + if (!beat) { + return; + } + + api.highlightPlaybackRange(beat, currentHighlight.endBeat!); + }, + () => { + api.applyPlaybackRangeFromHighlight(); + } + ); + + setupHandleDrag( + element, + endHandle, + dragState, + 'end', + e => { + if (!currentHighlight?.startBeat) { + return; + } + + const beat = getBeatFromEvent(element, api, e); + if (!beat) { + return; + } + + api.highlightPlaybackRange(currentHighlight!.startBeat!, beat); + }, + () => { + api.applyPlaybackRangeFromHighlight(); + } + ); +} From 699d26a93033890d827f7a8321e32010095008d0 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sat, 6 Dec 2025 13:02:20 +0100 Subject: [PATCH 5/5] build: type for plain object --- packages/alphatab/src/AlphaTabApiBase.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/alphatab/src/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index 6464f728e..f9a42895f 100644 --- a/packages/alphatab/src/AlphaTabApiBase.ts +++ b/packages/alphatab/src/AlphaTabApiBase.ts @@ -2744,8 +2744,8 @@ export class AlphaTabApiBase { const startBeat = this._tickCache.findBeat(this._trackIndexLookup!, range.startTick); const endBeat = this._tickCache.findBeat(this._trackIndexLookup!, range.endTick); if (startBeat && endBeat) { - const selectionStart = { beat: startBeat.beat }; - const selectionEnd = { beat: endBeat.beat }; + const selectionStart: SelectionInfo = { beat: startBeat.beat }; + const selectionEnd: SelectionInfo = { beat: endBeat.beat }; this._cursorSelectRange(selectionStart, selectionEnd); } } else {