diff --git a/packages/alphatab/src/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index 898c226c06..9e57e72863 100644 --- a/packages/alphatab/src/AlphaTabApiBase.ts +++ b/packages/alphatab/src/AlphaTabApiBase.ts @@ -2931,7 +2931,6 @@ export class AlphaTabApiBase { (this.beatMouseUp as EventEmitterOfT).trigger(beat); this.uiFacade.triggerEvent(this.container, 'beatMouseUp', beat, originalEvent); - this._isBeatMouseDown = false; } private _seekToBeat(beat: Beat) { @@ -2969,7 +2968,6 @@ export class AlphaTabApiBase { (this.noteMouseUp as EventEmitterOfT).trigger(note); this.uiFacade.triggerEvent(this.container, 'noteMouseUp', note, originalEvent); - this._isNoteMouseDown = false; } private _updateSelectionCursor(range: PlaybackRange | null) { @@ -2997,6 +2995,8 @@ export class AlphaTabApiBase { if (this.settings.player.enablePlaybackRangeSelection || this.settings.player.enableSeekToClick) { e.preventDefault(); } + this._isBeatMouseDown = false; + this._isNoteMouseDown = false; const relX: number = e.getX(this.canvasElement); const relY: number = e.getY(this.canvasElement); const beat: Beat | null = this._renderer.boundsLookup?.getBeatAtPos(relX, relY) ?? null; @@ -3039,23 +3039,36 @@ export class AlphaTabApiBase { const relX: number = e.getX(this.canvasElement); const relY: number = e.getY(this.canvasElement); const beat: Beat | null = this._renderer.boundsLookup?.getBeatAtPos(relX, relY) ?? null; - this._onBeatMouseUp(e, beat); + try { + this._onBeatMouseUp(e, beat); - if (this._isNoteMouseDown) { - if (beat) { - const note = this._renderer.boundsLookup?.getNoteAtPos(beat, relX, relY) ?? null; - this._onNoteMouseUp(e, note); - } else { - this._onNoteMouseUp(e, null); + if (this._isNoteMouseDown) { + if (beat) { + const note = this._renderer.boundsLookup?.getNoteAtPos(beat, relX, relY) ?? null; + this._onNoteMouseUp(e, note); + } else { + this._onNoteMouseUp(e, null); + } } + } finally { + this._isBeatMouseDown = false; + this._isNoteMouseDown = false; } }); - this._renderer.postRenderFinished.on(() => { - if (!this._selectionStart || !this._hasCursor || !this.settings.player.enablePlaybackRangeSelection) { - return; - } - this._cursorSelectRange(this._selectionStart, this._selectionEnd); - }); + } + + /** + * Rebuilds the selection highlight against the current layout. A selection without an end beat yields an empty highlight. + */ + private _refreshSelectionHighlight() { + if (!this._selectionStart) { + return; + } + this._selectionStart.bounds = undefined; + if (this._selectionEnd) { + this._selectionEnd.bounds = undefined; + } + this._cursorSelectRange(this._selectionStart, this._selectionEnd); } /** @@ -3681,9 +3694,7 @@ export class AlphaTabApiBase { this._currentBeat = null; this._cursorUpdateTick(this._previousTick, false, 1, true, true); - if (this._selectionStart) { - this.highlightPlaybackRange(this._selectionStart.beat, this._selectionEnd!.beat); - } + this._refreshSelectionHighlight(); (this.postRenderFinished as EventEmitter).trigger(); this.uiFacade.triggerEvent(this.container, 'postRenderFinished', null); diff --git a/packages/alphatab/src/platform/IContainer.ts b/packages/alphatab/src/platform/IContainer.ts index 346bff6df7..da1af54b4c 100644 --- a/packages/alphatab/src/platform/IContainer.ts +++ b/packages/alphatab/src/platform/IContainer.ts @@ -75,11 +75,18 @@ export interface IContainer { /** * This event occurs when a mouse/finger moves on top of the control. + * + * @remarks + * While a press started on this control is active, moves are reported even if they happen outside the control. */ mouseMove: IEventEmitterOfT; /** * This event occurs when a mouse/finger is released from the control. + * + * @remarks + * Every press started on this control ends with exactly one mouseUp, also when the release happens + * outside the control or the gesture is cancelled by the system. */ mouseUp: IEventEmitterOfT; } diff --git a/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts b/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts index a440ec5eb0..c63b0b19e1 100644 --- a/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts +++ b/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts @@ -1,4 +1,4 @@ -import type { IEventEmitter, IEventEmitterOfT } from '@coderline/alphatab/EventEmitter'; +import { EventEmitterOfT, type IEventEmitter, type IEventEmitterOfT } from '@coderline/alphatab/EventEmitter'; import type { IContainer } from '@coderline/alphatab/platform/IContainer'; import type { IMouseEventArgs } from '@coderline/alphatab/platform/IMouseEventArgs'; import { BrowserMouseEventArgs } from '@coderline/alphatab/platform/javascript/BrowserMouseEventArgs'; @@ -81,53 +81,9 @@ export class HtmlElementContainer implements IHtmlElementContainer { public constructor(element: HTMLElement) { this.element = element; - this.mouseDown = { - on: (value: any) => { - const nativeListener: (e: MouseEvent) => void = e => { - value(new BrowserMouseEventArgs(e)); - }; - this.element.addEventListener('mousedown', nativeListener, true); - return () => { - this.element.removeEventListener('mousedown', nativeListener, true); - }; - }, - off: (_value: any) => { - // not supported due to wrapping - } - }; - - this.mouseUp = { - on: (value: any) => { - const nativeListener: (e: MouseEvent) => void = e => { - value(new BrowserMouseEventArgs(e)); - }; - - this.element.addEventListener('mouseup', nativeListener, true); - - return () => { - this.element.removeEventListener('mouseup', nativeListener, true); - }; - }, - off: (_value: any) => { - // not supported due to wrapping - } - }; - - this.mouseMove = { - on: (value: any) => { - const nativeListener: (e: MouseEvent) => void = e => { - value(new BrowserMouseEventArgs(e)); - }; - this.element.addEventListener('mousemove', nativeListener, true); - - return () => { - this.element.removeEventListener('mousemove', nativeListener, true); - }; - }, - off: (_: any) => { - // not supported due to wrapping - } - }; + this.mouseDown = this._createMouseEmitter(this._mouseDown); + this.mouseMove = this._createMouseEmitter(this._mouseMove); + this.mouseUp = this._createMouseEmitter(this._mouseUp); const container = this; this.resize = { @@ -151,6 +107,100 @@ export class HtmlElementContainer implements IHtmlElementContainer { }; } + private readonly _mouseDown = new EventEmitterOfT(); + private readonly _mouseMove = new EventEmitterOfT(); + private readonly _mouseUp = new EventEmitterOfT(); + private _mouseListenersAttached = false; + /** + * The document receiving the move and release events of the active left button press, if any. + */ + private _pressDocument: Document | null = null; + /** + * The last event handled by the document listeners. The same event reaches the element listeners afterwards + * and must not be reported twice. + */ + private _pressHandledEvent: MouseEvent | null = null; + + private _createMouseEmitter(emitter: EventEmitterOfT): IEventEmitterOfT { + return { + on: (value: (e: IMouseEventArgs) => void) => { + this._attachMouseListeners(); + return emitter.on(value); + }, + off: (value: (e: IMouseEventArgs) => void) => { + emitter.off(value); + } + }; + } + + private _attachMouseListeners() { + if (this._mouseListenersAttached) { + return; + } + this._mouseListenersAttached = true; + this.element.addEventListener('mousedown', this._onElementMouseDown, true); + this.element.addEventListener('mousemove', this._onElementMouseMove, true); + this.element.addEventListener('mouseup', this._onElementMouseUp, true); + } + + private _onElementMouseDown = (e: MouseEvent) => { + if (e.button === 0) { + this._beginPress(); + } + this._mouseDown.trigger(new BrowserMouseEventArgs(e)); + }; + + private _onElementMouseMove = (e: MouseEvent) => { + if (!this._pressDocument && e !== this._pressHandledEvent) { + this._mouseMove.trigger(new BrowserMouseEventArgs(e)); + } + }; + + private _onElementMouseUp = (e: MouseEvent) => { + if (!this._pressDocument && e !== this._pressHandledEvent) { + this._mouseUp.trigger(new BrowserMouseEventArgs(e)); + } + }; + + private _onPressMouseMove = (e: MouseEvent) => { + this._pressHandledEvent = e; + if ((e.buttons & 1) === 0) { + // the release happened where this document could not observe it (e.g. outside the browser window) + this._endPress(); + this._mouseUp.trigger(new BrowserMouseEventArgs(e)); + return; + } + this._mouseMove.trigger(new BrowserMouseEventArgs(e)); + }; + + private _onPressMouseUp = (e: MouseEvent) => { + this._pressHandledEvent = e; + if (e.button === 0) { + this._endPress(); + } + this._mouseUp.trigger(new BrowserMouseEventArgs(e)); + }; + + private _beginPress() { + if (this._pressDocument) { + return; + } + const doc = this.element.ownerDocument; + this._pressDocument = doc; + doc.addEventListener('mousemove', this._onPressMouseMove, true); + doc.addEventListener('mouseup', this._onPressMouseUp, true); + } + + private _endPress() { + const doc = this._pressDocument; + if (!doc) { + return; + } + this._pressDocument = null; + doc.removeEventListener('mousemove', this._onPressMouseMove, true); + doc.removeEventListener('mouseup', this._onPressMouseUp, true); + } + public stopAnimation(): void { this.element.style.transition = 'none'; } @@ -194,12 +244,14 @@ export class HtmlElementContainer implements IHtmlElementContainer { public mouseDown: IEventEmitterOfT; /** - * This event occurs when a mouse/finger moves on top of the control. + * This event occurs when a mouse/finger moves on top of the control, + * or anywhere in the document while a left button press started on the control is active. */ public mouseMove: IEventEmitterOfT; /** * This event occurs when a mouse/finger is released from the control. + * A left button press started on the control always ends with a mouseUp, wherever the release happens. */ public mouseUp: IEventEmitterOfT; diff --git a/packages/alphatab/test/AlphaTabApiMouse.test.ts b/packages/alphatab/test/AlphaTabApiMouse.test.ts new file mode 100644 index 0000000000..07c0f8e65c --- /dev/null +++ b/packages/alphatab/test/AlphaTabApiMouse.test.ts @@ -0,0 +1,184 @@ +import { AlphaTabApiBase, type PlaybackHighlightChangeEventArgs } from '@coderline/alphatab/AlphaTabApiBase'; +import type { EventEmitterOfT } from '@coderline/alphatab/EventEmitter'; +import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; +import type { Beat } from '@coderline/alphatab/model/Beat'; +import type { IContainer } from '@coderline/alphatab/platform/IContainer'; +import type { IMouseEventArgs } from '@coderline/alphatab/platform/IMouseEventArgs'; +import { PlayerMode } from '@coderline/alphatab/PlayerSettings'; +import { Settings } from '@coderline/alphatab/Settings'; +import { TestUiFacade } from 'test/visualTests/TestUiFacade'; +import { describe, expect, it } from 'vitest'; + +/** + * @internal + */ +class TestMouseEventArgs implements IMouseEventArgs { + private readonly _x: number; + private readonly _y: number; + + public readonly isLeftMouseButton: boolean = true; + + public constructor(x: number, y: number) { + this._x = x; + this._y = y; + } + + public getX(_relativeTo: IContainer): number { + return this._x; + } + + public getY(_relativeTo: IContainer): number { + return this._y; + } + + public preventDefault(): void {} +} + +describe('AlphaTabApiMouseTests', () => { + async function createApi(configure?: (settings: Settings) => void): Promise> { + const settings = new Settings(); + settings.core.engine = 'svg'; + settings.player.playerMode = PlayerMode.EnabledSynthesizer; + settings.player.enableCursor = true; + settings.player.enablePlaybackRangeSelection = true; + configure?.(settings); + + const score = ScoreLoader.loadAlphaTex('C4 D4 E4 F4 | G4 A4 B4 C5', settings); + + const facade = new TestUiFacade(); + facade.rootContainer.width = 1300; + const api = new AlphaTabApiBase(facade, settings); + const rendered = Promise.withResolvers(); + api.postRenderFinished.on(() => rendered.resolve(true)); + api.error.on(e => rendered.reject(e)); + api.renderScore(score, [0]); + await rendered.promise; + return api; + } + + async function rerender(api: AlphaTabApiBase): Promise { + const rendered = Promise.withResolvers(); + const unsubscribeRender = api.postRenderFinished.on(() => rendered.resolve(true)); + const unsubscribeError = api.error.on(e => rendered.reject(e)); + try { + api.render(); + await rendered.promise; + } finally { + unsubscribeRender(); + unsubscribeError(); + } + } + + function beats(api: AlphaTabApiBase): Beat[] { + return api.score!.tracks[0].staves[0].bars[0].voices[0].beats; + } + + function argsAtBeat(api: AlphaTabApiBase, beat: Beat): TestMouseEventArgs { + const bounds = api.boundsLookup!.findBeat(beat)!.realBounds; + return new TestMouseEventArgs(bounds.x + bounds.w / 2, bounds.y + bounds.h / 2); + } + + function mouseDown(api: AlphaTabApiBase, args: IMouseEventArgs) { + (api.canvasElement.mouseDown as EventEmitterOfT).trigger(args); + } + + function mouseMove(api: AlphaTabApiBase, args: IMouseEventArgs) { + (api.canvasElement.mouseMove as EventEmitterOfT).trigger(args); + } + + function mouseUp(api: AlphaTabApiBase, args: IMouseEventArgs) { + (api.canvasElement.mouseUp as EventEmitterOfT).trigger(args); + } + + function trackHighlights(api: AlphaTabApiBase): PlaybackHighlightChangeEventArgs[] { + const highlights: PlaybackHighlightChangeEventArgs[] = []; + api.playbackRangeHighlightChanged.on(e => { + highlights.push(e); + }); + return highlights; + } + + it('render-during-press-on-single-beat', async () => { + const api = await createApi(); + mouseDown(api, argsAtBeat(api, beats(api)[1])); + + const highlights = trackHighlights(api); + await rerender(api); + + expect(highlights.length).toBe(1); + expect(highlights[0].startBeat).toBeUndefined(); + + api.destroy(); + }); + + it('render-after-click-without-range-reset', async () => { + const api = await createApi(s => { + s.player.resetPlaybackRangeOnClick = false; + }); + const args = argsAtBeat(api, beats(api)[1]); + mouseDown(api, args); + mouseUp(api, args); + + await rerender(api); + + api.destroy(); + }); + + it('render-during-press-across-beats-uses-new-layout', async () => { + const api = await createApi(); + const start = beats(api)[0]; + const end = beats(api)[2]; + mouseDown(api, argsAtBeat(api, start)); + const highlights = trackHighlights(api); + mouseMove(api, argsAtBeat(api, end)); + + expect(highlights.length).toBe(1); + const oldStartBounds = highlights[0].startBeatBounds!; + const oldEndBounds = highlights[0].endBeatBounds!; + + await rerender(api); + + expect(highlights.length).toBe(2); + const refreshed = highlights[1]; + expect(refreshed.startBeat).toBe(start); + expect(refreshed.endBeat).toBe(end); + expect(refreshed.startBeatBounds).toBe(api.boundsLookup!.findBeat(start)); + expect(refreshed.endBeatBounds).toBe(api.boundsLookup!.findBeat(end)); + expect(refreshed.startBeatBounds).not.toBe(oldStartBounds); + expect(refreshed.endBeatBounds).not.toBe(oldEndBounds); + + api.destroy(); + }); + + it('api-highlight-survives-render-without-range-selection', async () => { + const api = await createApi(s => { + s.player.enablePlaybackRangeSelection = false; + }); + const start = beats(api)[0]; + const end = beats(api)[3]; + api.highlightPlaybackRange(start, end); + + const highlights = trackHighlights(api); + await rerender(api); + + expect(highlights.length).toBe(1); + expect(highlights[0].startBeat).toBe(start); + expect(highlights[0].endBeat).toBe(end); + expect(highlights[0].startBeatBounds).toBe(api.boundsLookup!.findBeat(start)); + + api.destroy(); + }); + + it('release-outside-beats-ends-press', async () => { + const api = await createApi(); + mouseDown(api, argsAtBeat(api, beats(api)[0])); + mouseUp(api, new TestMouseEventArgs(-100, -100)); + + const highlights = trackHighlights(api); + mouseMove(api, argsAtBeat(api, beats(api)[2])); + + expect(highlights.length).toBe(0); + + api.destroy(); + }); +}); diff --git a/packages/csharp/src/AlphaTab.Windows/WinForms/ControlContainer.cs b/packages/csharp/src/AlphaTab.Windows/WinForms/ControlContainer.cs index 338a4da14d..0d1ff8299d 100644 --- a/packages/csharp/src/AlphaTab.Windows/WinForms/ControlContainer.cs +++ b/packages/csharp/src/AlphaTab.Windows/WinForms/ControlContainer.cs @@ -21,28 +21,93 @@ public ControlContainer(Control control) MouseDown = new DelegatedEventEmitter( value => { - Control.MouseDown += (sender, args) => value(new WinFormsMouseEventArgs(Control, args)); + AttachMouseHandlers(); + _mouseDown += value; }, - value => { } + value => { _mouseDown -= value; } ); MouseMove = new DelegatedEventEmitter( value => { - Control.MouseMove += (sender, args) => value(new WinFormsMouseEventArgs(Control, args)); + AttachMouseHandlers(); + _mouseMove += value; }, - value => { } + value => { _mouseMove -= value; } ); MouseUp = new DelegatedEventEmitter( value => { - Control.MouseUp += (sender, args) => value(new WinFormsMouseEventArgs(Control, args)); + AttachMouseHandlers(); + _mouseUp += value; }, - value => { } + value => { _mouseUp -= value; } ); } + private Action? _mouseDown; + private Action? _mouseMove; + private Action? _mouseUp; + private bool _mouseHandlersAttached; + private bool _isPressed; + + private void AttachMouseHandlers() + { + if (_mouseHandlersAttached) + { + return; + } + + _mouseHandlersAttached = true; + + // WinForms captures the mouse on press, so releases outside the control are delivered here. + // Losing that capture (e.g. alt-tab, a modal dialog) ends the press without a MouseUp. + Control.MouseDown += (sender, args) => + { + if (args.Button == MouseButtons.Left) + { + _isPressed = true; + } + + _mouseDown?.Invoke(new WinFormsMouseEventArgs(Control, args)); + }; + + Control.MouseMove += (sender, args) => _mouseMove?.Invoke(new WinFormsMouseEventArgs(Control, args)); + + Control.MouseUp += (sender, args) => + { + if (args.Button == MouseButtons.Left) + { + _isPressed = false; + } + + _mouseUp?.Invoke(new WinFormsMouseEventArgs(Control, args)); + }; + + Control.MouseCaptureChanged += (sender, args) => + { + if (!_isPressed || Control.Capture) + { + return; + } + + // deferred: a regular release may drop the capture before its MouseUp is raised + Control.BeginInvoke(() => + { + if (!_isPressed) + { + return; + } + + _isPressed = false; + var location = Control.PointToClient(Cursor.Position); + _mouseUp?.Invoke(new WinFormsMouseEventArgs(Control, + new MouseEventArgs(MouseButtons.Left, 0, location.X, location.Y, 0))); + }); + }; + } + public double Width { get => Control.ClientSize.Width - Control.Padding.Horizontal; diff --git a/packages/csharp/src/AlphaTab.Windows/Wpf/FrameworkElementContainer.cs b/packages/csharp/src/AlphaTab.Windows/Wpf/FrameworkElementContainer.cs index b6ee7c3176..f417a43faf 100644 --- a/packages/csharp/src/AlphaTab.Windows/Wpf/FrameworkElementContainer.cs +++ b/packages/csharp/src/AlphaTab.Windows/Wpf/FrameworkElementContainer.cs @@ -1,6 +1,7 @@ using System; using System.Windows; using System.Windows.Controls; +using System.Windows.Input; using System.Windows.Media.Animation; using AlphaTab.Platform; @@ -22,28 +23,82 @@ public FrameworkElementContainer(FrameworkElement control) MouseDown = new DelegatedEventEmitter( value => { - Control.MouseDown += (sender, args) => value(new WpfMouseEventArgs(args)); + AttachMouseHandlers(); + _mouseDown += value; }, - value => { } + value => { _mouseDown -= value; } ); MouseMove = new DelegatedEventEmitter( value => { - Control.MouseMove += (sender, args) => value(new WpfMouseEventArgs(args)); + AttachMouseHandlers(); + _mouseMove += value; }, - value => { } + value => { _mouseMove -= value; } ); MouseUp = new DelegatedEventEmitter( value => { - Control.MouseUp += (sender, args) => value(new WpfMouseEventArgs(args)); + AttachMouseHandlers(); + _mouseUp += value; }, - value => { } + value => { _mouseUp -= value; } ); } + private Action? _mouseDown; + private Action? _mouseMove; + private Action? _mouseUp; + private bool _mouseHandlersAttached; + private bool _isPressed; + + private void AttachMouseHandlers() + { + if (_mouseHandlersAttached) + { + return; + } + + _mouseHandlersAttached = true; + + Control.MouseDown += (sender, args) => + { + if (args.ChangedButton == MouseButton.Left && !_isPressed && Control.CaptureMouse()) + { + _isPressed = true; + } + + _mouseDown?.Invoke(new WpfMouseEventArgs(args)); + }; + + Control.MouseMove += (sender, args) => _mouseMove?.Invoke(new WpfMouseEventArgs(args)); + + Control.MouseUp += (sender, args) => + { + if (args.ChangedButton == MouseButton.Left && _isPressed) + { + // cleared before releasing so LostMouseCapture does not report this press a second time + _isPressed = false; + Control.ReleaseMouseCapture(); + } + + _mouseUp?.Invoke(new WpfMouseEventArgs(args)); + }; + + Control.LostMouseCapture += (sender, args) => + { + if (!_isPressed) + { + return; + } + + _isPressed = false; + _mouseUp?.Invoke(new WpfMouseEventArgs(args)); + }; + } + public double Width { get => (float)Control.ActualWidth; diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidViewContainer.kt b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidViewContainer.kt index c0b04cda21..b1e4908b4a 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidViewContainer.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidViewContainer.kt @@ -74,7 +74,7 @@ internal class AndroidViewContainer : GestureDetector.SimpleOnGestureListener, I } override fun onTouch(v: View, event: MotionEvent): Boolean { - when (event.action) { + when (event.actionMasked) { MotionEvent.ACTION_MOVE -> { if (_isLongDown) { val args = AndroidMouseEventArgs(event) @@ -82,7 +82,7 @@ internal class AndroidViewContainer : GestureDetector.SimpleOnGestureListener, I } } - MotionEvent.ACTION_UP -> { + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { _horizontalScrollView!!.isUserScrollingEnabled = true _verticalScrollView!!.isUserScrollingEnabled = true