From 32dfb3ef9f5a0a537d58c773de0c58225cd41f78 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 16 Sep 2026 18:02:04 +0500 Subject: [PATCH 1/3] fix(selection): drop toolbar overlay when chrome ownership is lost Sibling scopes sharing a controller cleared handles on ownership loss but left the adaptive context menu up. Remove this scope's menu without clearing toolbarWanted so the owner can still restore on settle. --- CHANGELOG.md | 6 ++ lib/src/selection_scope.dart | 9 ++- test/selection/selection_host_gates_test.dart | 78 +++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1a9ae5..ca4f485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,12 @@ extension points or reach into a default block painter's fields. Ordinary that reference that scope’s own `LayerLink`s, so chat dual mounts keep handles after non-collapsed range commits (including repeated handle-drag settles). +- **FIXED**: A scope that loses `ownsSelectionChrome` for the live selection + document now removes its own context-menu overlay (and does the same when + handles are disabled for that reason). Previously only handles were cleared, + so a prior bubble’s adaptive toolbar could linger after selection moved to a + sibling scope. `toolbarWanted` is left alone so the owning scope can still + restore on settle. - **ADDED**: `MarkdownSelectionSurface.hasSelectionHandleLeaders`, `clearSelectionHandleLayersIfLinked`, and `MarkdownSelectionScopeState.selectionHandleLeadersAttached` for observing diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index 2cc106c..9af6c08 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -401,7 +401,11 @@ class MarkdownSelectionScopeState extends State } if (sel case final current? when !current.isCollapsed) { - if (_dragGlobal == null) { + if (!_ownsChromeForCurrentSelection) { + // Sibling / prior subject: drop this scope's overlay without clearing + // [toolbarWanted] (the owning scope may still need restore intent). + _contextMenuController.remove(); + } else if (_dragGlobal == null) { // Outside a drag: restore when [toolbarWanted] (scroll remount / // geometry change). Skip while disabled — chrome comes back on // the enabled flip in [didUpdateWidget]. @@ -498,6 +502,9 @@ class MarkdownSelectionScopeState extends State void _updateHandlesAndOverlay() { if (!_handlesEnabled) { _clearHandles(); + // Losing chrome ownership (or disabled) must also drop a lingering + // context menu — handles alone were cleared before. + _contextMenuController.remove(); return; } final endpoints = controller.selectionHandleEndpoints(); diff --git a/test/selection/selection_host_gates_test.dart b/test/selection/selection_host_gates_test.dart index 00b1a34..f38cff3 100644 --- a/test/selection/selection_host_gates_test.dart +++ b/test/selection/selection_host_gates_test.dart @@ -459,6 +459,84 @@ void main() { }); }); + testWidgets( + 'losing chrome ownership drops this scope toolbar without clearing ' + 'toolbarWanted', (tester) async { + await _withPlatform(TargetPlatform.android, () async { + final controller = _controllerWith({ + 'a': 'First message body text', + 'b': 'Second message body text', + }); + addTearDown(controller.dispose); + + late MarkdownSelectionScopeState scopeA; + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MarkdownSelectionScope( + controller: controller, + ownsSelectionChrome: (id) => id == 'a', + child: Builder(builder: (context) { + scopeA = MarkdownSelectionScope.stateOf(context)!; + return const _Doc('a'); + }), + ), + MarkdownSelectionScope( + controller: controller, + ownsSelectionChrome: (id) => id == 'b', + child: const _Doc('b'), + ), + ], + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + + final first = tester.getRect(find.byType(MarkdownWidget).first); + await _longPressDrag( + tester, + first.topLeft + const Offset(2, 4), + first.topLeft + const Offset(60, 4), + ); + await tester.pumpAndSettle(); + + expect(controller.selection!.base.documentId, 'a'); + expect(scopeA.toolbarIsVisible, isTrue); + expect(controller.toolbarWanted, isTrue); + + // Retarget the live range onto B. Scope A no longer owns chrome for + // the selection document and must drop its overlay; toolbarWanted + // stays so B can restore on settle. + final second = tester.getRect(find.byType(MarkdownWidget).at(1)); + controller.selectWordAtGlobal(second.center); + await tester.pumpAndSettle(); + + expect(controller.selection!.base.documentId, 'b'); + expect( + scopeA.toolbarIsVisible, + isFalse, + reason: + 'prior owner must remove its context menu when the selection ' + 'document moves to a sibling scope', + ); + expect( + controller.toolbarWanted, + isTrue, + reason: 'toolbarWanted is shared restore intent, not this scope', + ); + expect(scopeA.selectionHandleLeadersAttached, isFalse); + }); + }); + testWidgets('a disabled sibling does not strip the owner handles', (tester) async { await _withPlatform(TargetPlatform.android, () async { From dd064dde3940605993ba5a2477fc4e8af5f7134b Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 16 Sep 2026 18:23:57 +0500 Subject: [PATCH 2/3] docs: drop tdesktop/Telegram wording from changelog and selection docs Describe surface-bounds gesture starts and empty-click dismiss in plain terms so the package history stays host-agnostic. --- CHANGELOG.md | 8 ++++---- docs/selection.md | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca4f485..a449336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,11 +48,11 @@ extension points or reach into a default block painter's fields. Ordinary boxes**, not the full max-width layout of a paragraph. Empty horizontal gutter beside a short line is no longer I-beam / click-to-open. - **CHANGED**: Gesture *starts* (`hitsSelectableContent`) stay **surface- - bounds** (press inside the document/bubble arms text selection — tdesktop - `PointState::Inside` parity). Drag-extend may still clamp outside glyphs. + bounds**. A press inside the document or bubble arms text selection. + Drag-extend may still clamp outside glyphs. - **CHANGED**: Mouse single-click that misses selectable content while a - non-collapsed range is active clears the selection (tdesktop empty - Selecting / dismiss parity), instead of leaving the old range. + non-collapsed range is active clears the selection, instead of leaving + the old range. - **ADDED**: `SelectableBlockPainter.hitsRenderedTextAt`, `MarkdownSelectionSurface.hitsSelectableGlyphs`, and `MarkdownSelectionController.hitsSelectableGlyphs`. diff --git a/docs/selection.md b/docs/selection.md index af9af2e..acb5ed8 100644 --- a/docs/selection.md +++ b/docs/selection.md @@ -174,8 +174,8 @@ Statics: `MarkdownSelectionScope.of/maybeOf` (→ controller), `stateOf` (→ st tap-down and shows chrome on tap-up, **triple** selects the block, `Shift`-click extends, and a drag after a double/triple keeps word/block granularity. The touch recognizer and long-press only join the arena on a selectable hit - inside a mounted surface’s bounds (`hitsSelectableContent` — tdesktop-style - bubble Inside, including padding / empty line gutter; I-beam / link hits stay + inside a mounted surface’s bounds (`hitsSelectableContent`: press inside the + document or bubble, including padding / empty line gutter; I-beam / link hits stay glyph-tight via `hitsSelectableGlyphs`), or while a non-collapsed selection / toolbar is up, and only when `enableTouchGestures` is true. When `enableTouchConsecutiveTaps` is false, From d644a9076e0df48aa9341d232055be183c7f679f Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 16 Sep 2026 23:42:06 +0500 Subject: [PATCH 3/3] fix(selection): skip toolbar geometry restore while a drag is active Autoscroll scroll notifications were re-presenting the adaptive toolbar mid-expand when toolbarWanted got re-armed (host clamping a range via the public selection setter). Gate scroll / geometry restore on _dragGlobal. --- CHANGELOG.md | 5 ++ lib/src/selection_scope.dart | 14 +++- test/selection/selection_widget_test.dart | 78 +++++++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a449336..d07a1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,11 @@ extension points or reach into a default block painter's fields. Ordinary so a prior bubble’s adaptive toolbar could linger after selection moved to a sibling scope. `toolbarWanted` is left alone so the owning scope can still restore on settle. +- **FIXED**: Scroll / geometry toolbar restore is skipped while a selection + drag is active (`_dragGlobal`). Autoscroll `ScrollNotification`s must not + re-present the adaptive toolbar mid-gesture even when `toolbarWanted` was + re-armed (for example a host restoring a clamped range via the public + `selection` setter). - **ADDED**: `MarkdownSelectionSurface.hasSelectionHandleLeaders`, `clearSelectionHandleLayersIfLinked`, and `MarkdownSelectionScopeState.selectionHandleLeadersAttached` for observing diff --git a/lib/src/selection_scope.dart b/lib/src/selection_scope.dart index 9af6c08..44b47e3 100644 --- a/lib/src/selection_scope.dart +++ b/lib/src/selection_scope.dart @@ -1501,6 +1501,12 @@ class MarkdownSelectionScopeState extends State } return; } + if (_dragGlobal != null) { + // Absolute toolbar anchors are dormant mid-drag; handles still track + // via LeaderLayer. Drag-end [showToolbar] re-presents. + _syncOverlay(); + return; + } _refreshToolbarForVisibleGeometry(); // Scroll notifications can run before child layout finishes (common when // the scope itself was outside the viewport cache). Retry next frame. @@ -1518,7 +1524,9 @@ class MarkdownSelectionScopeState extends State } void _scheduleToolbarGeometryRefresh() { - if (!controller.toolbarWanted || _toolbarGeometryRefreshScheduled) { + if (!controller.toolbarWanted || + _dragGlobal != null || + _toolbarGeometryRefreshScheduled) { return; } _toolbarGeometryRefreshScheduled = true; @@ -1535,6 +1543,10 @@ class MarkdownSelectionScopeState extends State /// Must not run during layout/build — geometry walks use [localToGlobal]. void _refreshToolbarForVisibleGeometry() { if (!widget.enabled || !controller.toolbarWanted) return; + // Mid-drag: keep the menu hidden until drag-end [showToolbar]. Scroll / + // remount restore must not re-present while a pointer still owns the + // gesture (autoscroll delivers ScrollNotifications during edge hold). + if (_dragGlobal != null) return; if (!_ownsChromeForCurrentSelection) { _contextMenuController.remove(); return; diff --git a/test/selection/selection_widget_test.dart b/test/selection/selection_widget_test.dart index c73b592..b7ac1ed 100644 --- a/test/selection/selection_widget_test.dart +++ b/test/selection/selection_widget_test.dart @@ -599,6 +599,84 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets( + 'expand drag keeps toolbar hidden across scroll even if toolbarWanted ' + 'is re-armed mid-drag', + (tester) async { + // Hosts may restore a clamped same-document range via paths that arm + // toolbarWanted on mobile. Autoscroll scroll notifications must not + // re-present the menu until drag end. + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final controller = MarkdownSelectionController() + ..setDocuments([ + MarkdownDocumentRef( + id: 'd', + model: Markdown.fromString('Hello selectable world again'), + ), + ]); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView( + controller: scrollController, + children: [ + const SizedBox(height: 80), + MarkdownSelectionScope( + controller: controller, + child: const SizedBox(width: 400, child: _Doc('d')), + ), + const SizedBox(height: 1200), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final tl = tester.getTopLeft(find.byType(MarkdownWidget)); + final pos = tl + const Offset(8, 8); + await tester.tapAt(pos); + await tester.pump(const Duration(milliseconds: 40)); + await tester.tapAt(pos); + await tester.pumpAndSettle(); + final state = tester.state( + find.byType(MarkdownSelectionScope), + ); + expect(state.toolbarIsVisible, isTrue); + + final gesture = await tester.startGesture(pos); + await tester.pump(const Duration(seconds: 1)); + await tester.pump(); + expect(state.toolbarIsVisible, isFalse); + + // Simulate a host restoring a clamped range via paths that arm + // toolbarWanted (public selection setter on a changed range). + controller.toolbarWanted = true; + expect(controller.toolbarWanted, isTrue); + await gesture.moveBy(const Offset(60, 0)); + await tester.pump(); + + scrollController.jumpTo(40); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + + expect( + state.toolbarIsVisible, + isFalse, + reason: + 'scroll geometry refresh must not show the toolbar while an ' + 'expand drag is still active', + ); + + await gesture.up(); + await tester.pumpAndSettle(); + expect(state.toolbarIsVisible, isTrue); + expect(tester.takeException(), isNull); + }, + ); + testWidgets( 'Android long-press fires forLongPress haptic while handles deferred', (tester) async {