From f1ae7a339a029b0d7e4e4a285d7360b52012180c Mon Sep 17 00:00:00 2001 From: bin <17426470+boyan01@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:36:29 +0800 Subject: [PATCH] mixin_markdown_widget: rework the right-click context menu Right clicking keeps a selection under the pointer. Elsewhere, macOS and iOS select the word under the pointer and other platforms clear the selection. The default menu shows Copy only when text is selected, Copy link over a link, Select all, and Copy all; Clear selection is gone. It closes on Escape, scrolling, and any click. MarkdownWidget and MixinSelectionArea share this logic in MarkdownSelectionHost. BREAKING CHANGE: MarkdownContextMenuBuilder is now (BuildContext, MarkdownContextMenuDetails). The details carry the anchors, the default button items, the selection controller, the link under the pointer, and hide(). contextMenuBuilder defaults to defaultMarkdownContextMenuBuilder; pass null to show no menu. MarkdownContextMenu is removed, and showCopyAllInContextMenu is deprecated in favor of filtering details.buttonItems. --- packages/mixin_markdown_widget/AGENTS.md | 3 +- packages/mixin_markdown_widget/README.md | 35 ++ .../example/lib/main.dart | 8 +- .../builder/markdown_block_builder.dart | 2 - .../src/render/markdown_document_view.dart | 42 +- .../lib/src/render/selectable_block.dart | 9 +- .../markdown_selection_gesture_detector.dart | 6 +- .../shortcuts/markdown_shortcuts_scope.dart | 69 --- .../src/selection/mixin_selection_area.dart | 38 +- .../lib/src/selection/selection_host.dart | 191 ++++++++- .../src/selection/selection_registrar.dart | 2 + .../selection/structured_block_selection.dart | 64 +++ .../lib/src/widgets/markdown_types.dart | 48 ++- .../lib/src/widgets/markdown_widget.dart | 9 +- .../test/markdown_context_menu_test.dart | 393 ++++++++++++++++++ 15 files changed, 782 insertions(+), 137 deletions(-) create mode 100644 packages/mixin_markdown_widget/test/markdown_context_menu_test.dart diff --git a/packages/mixin_markdown_widget/AGENTS.md b/packages/mixin_markdown_widget/AGENTS.md index 19df0960..18d8c37c 100644 --- a/packages/mixin_markdown_widget/AGENTS.md +++ b/packages/mixin_markdown_widget/AGENTS.md @@ -25,7 +25,8 @@ The public entrypoint is [packages/mixin_markdown_widget/lib/mixin_markdown_widg - `src/render/markdown_document_view.dart`: main state orchestrator and coordinator. Connects blocks, selections, gestures, and UI builders together without doing heavy layout math. - `src/render/builder/`: contains `markdown_block_builder.dart` and `markdown_inline_builder.dart` which convert `BlockNode` and `InlineNode` respectively into actual visual `Widget`s or `TextSpan`s. - `src/render/selection/`: contains `markdown_selectable_leaf.dart` (selectable text leaves, structural slots, and the per-block leaf registry), `markdown_selection_geometry.dart` (the one container algorithm for quotes, details, lists, footnotes, and tables), `markdown_block_keys_registry.dart` (top-level block keys, scroll controllers, viewport keys), `markdown_descriptor_extractor.dart` (rendered text and spans for definition lists, captions, and container text spans), and `markdown_selection_gesture_detector.dart` (drags, auto-scroll). -- `src/render/shortcuts/`: contains ContextMenu bindings and keyboard action Intents (like `SelectAllMarkdownIntent`). +- `src/render/shortcuts/`: keyboard action Intents (like `SelectAllMarkdownIntent`) and their bindings. +- `src/selection/selection_host.dart`: shared by `MarkdownDocumentView` and `MixinSelectionArea`. Owns focus, tap-outside clearing, and the right-click menu (selection on right click, default button items, closing on Escape, scrolling, and clicks). - `src/render/markdown_block_widgets.dart`: shared block UI widgets for lists, quotes, tables, code blocks, and other visual shells. - `src/render/pretext_text_block.dart`: pretext-backed inline layout and line geometry used by text leaves. - `src/selection/selection_controller.dart`: authoritative selection state. Clamping and select-all delegate to `MarkdownPlainTextSerializer`. diff --git a/packages/mixin_markdown_widget/README.md b/packages/mixin_markdown_widget/README.md index bc5bd275..dfb5f2c5 100644 --- a/packages/mixin_markdown_widget/README.md +++ b/packages/mixin_markdown_widget/README.md @@ -61,6 +61,41 @@ MarkdownWidget( ) ``` +### Right-Click Menu + +Right clicking keeps a selection under the pointer. Elsewhere, macOS and iOS +select the word under the pointer and other platforms clear the selection. The +default menu shows Copy when text is selected, Copy link over a link, Select +all, and Copy all. It closes on Escape, scrolling, or any click. + +`contextMenuBuilder` replaces the menu. `details.buttonItems` holds the default +items, which close the menu when pressed; call `details.hide` from your own. +Pass `null` to show no menu. + +```dart +MarkdownWidget( + data: data, + contextMenuBuilder: (context, details) { + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: details.anchors, + buttonItems: [ + ...details.buttonItems, + if (details.linkDestination case final url?) + ContextMenuButtonItem( + label: 'Open Link', + onPressed: () { + details.hide(); + openUrl(url); + }, + ), + ], + ); + }, +) +``` + +`MixinSelectionArea` takes the same `contextMenuBuilder`. + ### Generic Selection Area `MixinSelectionArea` can wrap non-Markdown widget trees. Text-like children opt in diff --git a/packages/mixin_markdown_widget/example/lib/main.dart b/packages/mixin_markdown_widget/example/lib/main.dart index 61bfa749..7a1f7c70 100644 --- a/packages/mixin_markdown_widget/example/lib/main.dart +++ b/packages/mixin_markdown_widget/example/lib/main.dart @@ -460,14 +460,14 @@ This content was appended through `MarkdownController.appendChunk`. content: Text('Link tapped: $destination')), ); }, - contextMenuBuilder: - (context, controller, buttonItems, anchors) { + contextMenuBuilder: (context, details) { return AdaptiveTextSelectionToolbar.buttonItems( - anchors: anchors, + anchors: details.anchors, buttonItems: [ - ...buttonItems, + ...details.buttonItems, ContextMenuButtonItem( onPressed: () { + details.hide(); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Custom menu action!'), diff --git a/packages/mixin_markdown_widget/lib/src/render/builder/markdown_block_builder.dart b/packages/mixin_markdown_widget/lib/src/render/builder/markdown_block_builder.dart index d0cfe8a5..8441b02e 100644 --- a/packages/mixin_markdown_widget/lib/src/render/builder/markdown_block_builder.dart +++ b/packages/mixin_markdown_widget/lib/src/render/builder/markdown_block_builder.dart @@ -38,7 +38,6 @@ class MarkdownBlockBuilder { this.codeBlockBuilder, this.bulletBuilder, this.onTapLink, - required this.onRequestContextMenu, required Map cachedBlockRows, required this.tableLayoutPlanCache, required this.codeHighlightCache, @@ -57,7 +56,6 @@ class MarkdownBlockBuilder { final MarkdownCodeBlockBuilder? codeBlockBuilder; final MarkdownBulletBuilder? bulletBuilder; final MarkdownTapLinkCallback? onTapLink; - final void Function(Offset) onRequestContextMenu; final MarkdownTableLayoutPlanCache tableLayoutPlanCache; final MarkdownCodeHighlightCache codeHighlightCache; diff --git a/packages/mixin_markdown_widget/lib/src/render/markdown_document_view.dart b/packages/mixin_markdown_widget/lib/src/render/markdown_document_view.dart index 8062ad42..9bed377f 100644 --- a/packages/mixin_markdown_widget/lib/src/render/markdown_document_view.dart +++ b/packages/mixin_markdown_widget/lib/src/render/markdown_document_view.dart @@ -6,6 +6,7 @@ import '../debug.dart'; import '../selection/selection_host.dart'; import '../selection/selection_controller.dart'; import '../selection/selection_registrar.dart'; +import '../selection/structured_block_selection.dart'; import '../widgets/markdown_theme.dart'; import '../widgets/markdown_types.dart'; import 'code_syntax_highlighter.dart'; @@ -17,7 +18,6 @@ import 'builder/markdown_inline_builder.dart'; import 'selection/markdown_descriptor_extractor.dart'; import 'selection/markdown_selection_gesture_detector.dart'; import 'selection/markdown_block_keys_registry.dart'; -import 'shortcuts/markdown_shortcuts_scope.dart'; class MarkdownDocumentView extends StatefulWidget { const MarkdownDocumentView({ @@ -66,7 +66,6 @@ class _MarkdownDocumentViewState extends State { final MarkdownLinkRecognizers _linkRecognizers = MarkdownLinkRecognizers(); final ScrollController _fallbackScrollController = ScrollController(); - final ContextMenuController _contextMenuController = ContextMenuController(); final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'markdown-scrollable'); final MarkdownPlainTextSerializer _plainTextSerializer = @@ -185,7 +184,6 @@ class _MarkdownDocumentViewState extends State { ..removeListener(_handleCodeHighlightCacheChanged) ..dispose(); _fallbackScrollController.dispose(); - _contextMenuController.remove(); if (_registeredWithSelectionRegistrar) { _selectionRegistrar?.unregisterParticipant(this); } @@ -254,6 +252,7 @@ class _MarkdownDocumentViewState extends State { selectWordAt: _selectWordAt, selectBlockAt: _selectBlockAt, selectSelectionUnitAt: _selectSelectionUnitAt, + linkDestinationAt: _linkDestinationAt, autoScrollTargets: _inheritedSelectionAutoScrollTargets, ); } @@ -277,20 +276,25 @@ class _MarkdownDocumentViewState extends State { widget.onTapLink?.call(destination, title, label); } - void _showToolbar(Offset globalPosition) { - if (widget.selectionController == null) { - return; + String? _linkDestinationAt(Offset globalPosition) { + final position = _hitTestExactTextPosition(globalPosition); + if (position == null) { + return null; } - MarkdownContextMenu.show( - context, - contextMenuController: _contextMenuController, - selectionController: widget.selectionController!, - document: widget.document, - globalPosition: globalPosition, - onCopyPlainText: widget.onCopyPlainText, - showCopyAllInContextMenu: widget.showCopyAllInContextMenu, - contextMenuBuilder: widget.contextMenuBuilder, - ); + final localBlockIndex = position.blockIndex - _selectionBlockIndexOffset; + if (localBlockIndex < 0 || + localBlockIndex >= widget.document.blocks.length) { + return null; + } + final block = widget.document.blocks[localBlockIndex]; + final upstream = _blockStateFor(block)?.isPointerOnPrecedingCharacter( + position.textOffset, + globalPosition, + ) ?? + false; + return StructuredBlockSelection.forBlock(block) + .linkAt(position, upstream: upstream) + ?.destination; } MarkdownSelectableBlockHandle? _blockStateFor(BlockNode block) { @@ -461,7 +465,6 @@ class _MarkdownDocumentViewState extends State { codeBlockBuilder: widget.codeBlockBuilder, bulletBuilder: widget.bulletBuilder, onTapLink: onTapLink, - onRequestContextMenu: _showToolbar, cachedBlockRows: _cachedBlockRows, tableLayoutPlanCache: _tableLayoutPlanCache, codeHighlightCache: _codeHighlightCache, @@ -540,7 +543,9 @@ class _MarkdownDocumentViewState extends State { isSelectable: widget.selectable, scrollableKey: _scrollableKey, scrollController: scrollController, - onRequestToolbar: _showToolbar, + contextMenuBuilder: widget.contextMenuBuilder, + linkDestinationAt: _linkDestinationAt, + showCopyAllInContextMenu: widget.showCopyAllInContextMenu, hitTestPosition: _hitTestPosition, hitTestExactTextPosition: _hitTestExactTextPosition, selectWordAt: _selectWordAt, @@ -548,7 +553,6 @@ class _MarkdownDocumentViewState extends State { selectSelectionUnitAt: _selectSelectionUnitAt, additionalAutoScrollTargets: _autoScrollTargets, onCopyPlainText: widget.onCopyPlainText, - onTapOutside: _contextMenuController.remove, child: scrollable, ), blockCount: widget.document.blocks.length, diff --git a/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart b/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart index e7e0330b..cc4f56ec 100644 --- a/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart +++ b/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart @@ -110,6 +110,10 @@ abstract interface class MarkdownSelectableBlockHandle { DocumentSelection selectWholeBlock(); + /// Whether [globalPosition] is over the character before the caret at + /// [textOffset] rather than the one after it. + bool isPointerOnPrecedingCharacter(int textOffset, Offset globalPosition); + DocumentSelection selectSelectionUnit( DocumentPosition position, { Offset? globalPosition, @@ -229,7 +233,7 @@ class SelectableMarkdownBlockState extends State return selectWholeBlock(); } final upstream = globalPosition != null && - _pointerIsOnPrecedingCharacter(position.textOffset, globalPosition); + isPointerOnPrecedingCharacter(position.textOffset, globalPosition); ({int start, int end}) wordRangeAt(String text, int offset) => markdownWordRangeAt(text, offset, upstream: upstream); final structure = widget.spec.selectionStructure; @@ -584,7 +588,8 @@ class SelectableMarkdownBlockState extends State /// Whether [globalPosition] is over the character that ends at /// [textOffset], rather than the one that starts there. - bool _pointerIsOnPrecedingCharacter(int textOffset, Offset globalPosition) { + @override + bool isPointerOnPrecedingCharacter(int textOffset, Offset globalPosition) { final renderObject = context.findRenderObject(); if (textOffset <= 0 || textOffset > widget.spec.plainText.length || diff --git a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_gesture_detector.dart b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_gesture_detector.dart index 10ac8b25..1d7f5f17 100644 --- a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_gesture_detector.dart +++ b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_gesture_detector.dart @@ -45,7 +45,7 @@ class MarkdownSelectionGestureDetector extends StatefulWidget { required this.isSelectable, required this.scrollableKey, required this.scrollController, - required this.onRequestToolbar, + required this.onSecondaryPointerDown, required this.hitTestPosition, required this.hitTestExactTextPosition, required this.selectWordAt, @@ -60,7 +60,7 @@ class MarkdownSelectionGestureDetector extends StatefulWidget { final bool isSelectable; final GlobalKey scrollableKey; final ScrollController scrollController; - final void Function(Offset) onRequestToolbar; + final void Function(Offset globalPosition) onSecondaryPointerDown; final MarkdownHitTestPositionCallback hitTestPosition; final MarkdownHitTestExactTextPositionCallback hitTestExactTextPosition; final MarkdownSelectWordCallback selectWordAt; @@ -163,7 +163,7 @@ class _MarkdownSelectionGestureDetectorState } if ((event.buttons & kSecondaryMouseButton) != 0) { - widget.onRequestToolbar(event.position); + widget.onSecondaryPointerDown(event.position); return; } diff --git a/packages/mixin_markdown_widget/lib/src/render/shortcuts/markdown_shortcuts_scope.dart b/packages/mixin_markdown_widget/lib/src/render/shortcuts/markdown_shortcuts_scope.dart index 95af5088..d8a9d91f 100644 --- a/packages/mixin_markdown_widget/lib/src/render/shortcuts/markdown_shortcuts_scope.dart +++ b/packages/mixin_markdown_widget/lib/src/render/shortcuts/markdown_shortcuts_scope.dart @@ -3,7 +3,6 @@ import 'package:flutter/services.dart'; import '../../core/document.dart'; import '../../selection/selection_controller.dart'; -import '../../widgets/markdown_types.dart'; class CopyFullDocumentPlainTextIntent extends Intent { const CopyFullDocumentPlainTextIntent(); @@ -109,71 +108,3 @@ class MarkdownShortcutsScope extends StatelessWidget { ); } } - -class MarkdownContextMenu { - static void show( - BuildContext context, { - required ContextMenuController contextMenuController, - required MarkdownSelectionController selectionController, - required MarkdownDocument document, - required Offset globalPosition, - VoidCallback? onCopyPlainText, - bool showCopyAllInContextMenu = true, - MarkdownContextMenuBuilder? contextMenuBuilder, - }) { - final buttonItems = [ - ContextMenuButtonItem( - onPressed: () { - contextMenuController.remove(); - selectionController.copySelectionToClipboard(); - }, - type: ContextMenuButtonType.copy, - ), - ContextMenuButtonItem( - onPressed: () { - contextMenuController.remove(); - selectionController.selectAll(document); - }, - type: ContextMenuButtonType.selectAll, - ), - if (onCopyPlainText != null && showCopyAllInContextMenu) - ContextMenuButtonItem( - onPressed: () { - contextMenuController.remove(); - onCopyPlainText.call(); - }, - label: 'Copy all', - ), - if (selectionController.hasSelection) - ContextMenuButtonItem( - onPressed: () { - contextMenuController.remove(); - selectionController.clear(); - }, - label: 'Clear selection', - ), - ]; - - final anchors = TextSelectionToolbarAnchors( - primaryAnchor: globalPosition, - ); - - contextMenuController.show( - context: context, - contextMenuBuilder: (context) { - if (contextMenuBuilder != null) { - return contextMenuBuilder( - context, - selectionController, - buttonItems, - anchors, - ); - } - return AdaptiveTextSelectionToolbar.buttonItems( - anchors: anchors, - buttonItems: buttonItems, - ); - }, - ); - } -} diff --git a/packages/mixin_markdown_widget/lib/src/selection/mixin_selection_area.dart b/packages/mixin_markdown_widget/lib/src/selection/mixin_selection_area.dart index df0eb8eb..98e08ead 100644 --- a/packages/mixin_markdown_widget/lib/src/selection/mixin_selection_area.dart +++ b/packages/mixin_markdown_widget/lib/src/selection/mixin_selection_area.dart @@ -8,7 +8,6 @@ import '../core/document.dart'; import '../render/pretext_text_block.dart'; import '../render/selectable_block.dart'; import '../render/selection/markdown_selection_gesture_detector.dart'; -import '../render/shortcuts/markdown_shortcuts_scope.dart'; import '../widgets/markdown_theme.dart'; import '../widgets/markdown_types.dart'; import 'selection_controller.dart'; @@ -46,8 +45,10 @@ class MixinSelectionArea extends StatefulWidget { this.controller, this.scrollController, this.selectionColor, - this.contextMenuBuilder, + this.contextMenuBuilder = defaultMarkdownContextMenuBuilder, this.enableCopyFullDocumentShortcut = true, + @Deprecated('Filter MarkdownContextMenuDetails.buttonItems in ' + 'contextMenuBuilder instead.') this.showCopyAllInContextMenu = true, }); @@ -55,8 +56,12 @@ class MixinSelectionArea extends StatefulWidget { final MarkdownSelectionController? controller; final ScrollController? scrollController; final Color? selectionColor; + + /// Builds the right-click menu; null shows none. final MarkdownContextMenuBuilder? contextMenuBuilder; final bool enableCopyFullDocumentShortcut; + @Deprecated('Filter MarkdownContextMenuDetails.buttonItems in ' + 'contextMenuBuilder instead.') final bool showCopyAllInContextMenu; @override @@ -66,7 +71,6 @@ class MixinSelectionArea extends StatefulWidget { class _MixinSelectionAreaState extends State { final MarkdownPlainTextSerializer _plainTextSerializer = const MarkdownPlainTextSerializer(); - final ContextMenuController _contextMenuController = ContextMenuController(); final GlobalKey _selectionAreaKey = GlobalKey(debugLabel: 'mixin-selection-area'); final ScrollController _fallbackScrollController = ScrollController(); @@ -106,7 +110,6 @@ class _MixinSelectionAreaState extends State { @override void dispose() { - _contextMenuController.remove(); _fallbackScrollController.dispose(); _fallbackController.dispose(); super.dispose(); @@ -210,19 +213,14 @@ class _MixinSelectionAreaState extends State { .compareTo(_registrationOrder[b.owner] ?? 0); } - void _showToolbar(Offset globalPosition) { - MarkdownContextMenu.show( - context, - contextMenuController: _contextMenuController, - selectionController: _controller, - document: _document, - globalPosition: globalPosition, - onCopyPlainText: widget.enableCopyFullDocumentShortcut - ? _copyFullDocumentPlainTextToClipboard - : null, - showCopyAllInContextMenu: widget.showCopyAllInContextMenu, - contextMenuBuilder: widget.contextMenuBuilder, - ); + String? _linkDestinationAt(Offset globalPosition) { + for (final participant in _orderedParticipants) { + final destination = participant.linkDestinationAt?.call(globalPosition); + if (destination != null) { + return destination; + } + } + return null; } void _copyFullDocumentPlainTextToClipboard() { @@ -362,14 +360,16 @@ class _MixinSelectionAreaState extends State { : null, scrollableKey: _selectionAreaKey, scrollController: _scrollController, - onRequestToolbar: _showToolbar, + contextMenuBuilder: widget.contextMenuBuilder, + linkDestinationAt: _linkDestinationAt, + // ignore: deprecated_member_use_from_same_package + showCopyAllInContextMenu: widget.showCopyAllInContextMenu, hitTestPosition: _hitTestPosition, hitTestExactTextPosition: _hitTestExactTextPosition, selectWordAt: _selectWordAt, selectBlockAt: _selectBlockAt, selectSelectionUnitAt: _selectSelectionUnitAt, additionalAutoScrollTargets: _autoScrollTargets, - onTapOutside: _contextMenuController.remove, child: KeyedSubtree( key: _selectionAreaKey, child: scopedChild, diff --git a/packages/mixin_markdown_widget/lib/src/selection/selection_host.dart b/packages/mixin_markdown_widget/lib/src/selection/selection_host.dart index 04f95731..70d5aeb6 100644 --- a/packages/mixin_markdown_widget/lib/src/selection/selection_host.dart +++ b/packages/mixin_markdown_widget/lib/src/selection/selection_host.dart @@ -1,8 +1,12 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../core/document.dart'; import '../render/selection/markdown_selection_gesture_detector.dart'; import '../render/shortcuts/markdown_shortcuts_scope.dart'; +import '../widgets/markdown_types.dart'; import 'selection_controller.dart'; class MarkdownSelectionHost extends StatefulWidget { @@ -13,14 +17,15 @@ class MarkdownSelectionHost extends StatefulWidget { required this.document, required this.scrollableKey, required this.scrollController, - required this.onRequestToolbar, required this.hitTestPosition, required this.hitTestExactTextPosition, required this.selectWordAt, required this.selectBlockAt, required this.selectSelectionUnitAt, + this.contextMenuBuilder, + this.linkDestinationAt, + this.showCopyAllInContextMenu = true, this.onCopyPlainText, - this.onTapOutside, this.additionalAutoScrollTargets, this.isSelectable = true, }); @@ -30,14 +35,15 @@ class MarkdownSelectionHost extends StatefulWidget { final MarkdownDocument document; final GlobalKey scrollableKey; final ScrollController scrollController; - final void Function(Offset) onRequestToolbar; final MarkdownHitTestPositionCallback hitTestPosition; final MarkdownHitTestExactTextPositionCallback hitTestExactTextPosition; final MarkdownSelectWordCallback selectWordAt; final MarkdownSelectBlockCallback selectBlockAt; final MarkdownSelectSelectionUnitCallback selectSelectionUnitAt; + final MarkdownContextMenuBuilder? contextMenuBuilder; + final String? Function(Offset globalPosition)? linkDestinationAt; + final bool showCopyAllInContextMenu; final VoidCallback? onCopyPlainText; - final VoidCallback? onTapOutside; final Iterable Function()? additionalAutoScrollTargets; final bool isSelectable; @@ -49,13 +55,138 @@ class MarkdownSelectionHost extends StatefulWidget { class _MarkdownSelectionHostState extends State { final FocusNode _selectionFocusNode = FocusNode(debugLabel: 'mixin_markdown_widget.selection'); + final ContextMenuController _contextMenuController = ContextMenuController(); + ScrollPosition? _ancestorScrollPosition; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // The menu sits in the overlay, so it would stay behind when an + // enclosing scrollable moves the text. + final position = Scrollable.maybeOf(context)?.position; + if (position != _ancestorScrollPosition) { + _ancestorScrollPosition?.removeListener(_hideContextMenu); + _ancestorScrollPosition = position?..addListener(_hideContextMenu); + } + } + + @override + void didUpdateWidget(covariant MarkdownSelectionHost oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.contextMenuBuilder == null) { + _hideContextMenu(); + } + } @override void dispose() { + _ancestorScrollPosition?.removeListener(_hideContextMenu); + _contextMenuController.remove(); _selectionFocusNode.dispose(); super.dispose(); } + void _hideContextMenu() { + if (_contextMenuController.isShown) { + _contextMenuController.remove(); + } + } + + void _handleSecondaryPointerDown(Offset globalPosition) { + final builder = widget.contextMenuBuilder; + if (builder == null) { + return; + } + _selectForContextMenu(globalPosition); + final linkDestination = widget.linkDestinationAt?.call(globalPosition); + final details = MarkdownContextMenuDetails( + anchors: TextSelectionToolbarAnchors(primaryAnchor: globalPosition), + buttonItems: _buttonItems(linkDestination), + selectionController: widget.selectionController, + linkDestination: linkDestination, + hide: _hideContextMenu, + ); + _contextMenuController.show( + context: context, + // Clicks on the menu are not outside the selection. + contextMenuBuilder: (context) => TapRegion( + groupId: this, + child: builder(context, details), + ), + ); + } + + /// Keeps a selection under the pointer. Otherwise Apple platforms select + /// the word under the pointer and others clear the selection. + void _selectForContextMenu(Offset globalPosition) { + final position = widget.hitTestExactTextPosition(globalPosition); + final range = widget.selectionController.normalizedRange; + if (position != null && + range != null && + range.start != range.end && + _compareOffsets(range.start, position) <= 0 && + _compareOffsets(position, range.end) <= 0) { + return; + } + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + if (position != null) { + widget.selectWordAt(position, globalPosition: globalPosition); + return; + } + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + break; + } + widget.selectionController.clear(); + } + + // Offsets are in block display space; the path at a leaf boundary can be + // either leaf. + static int _compareOffsets(DocumentPosition a, DocumentPosition b) { + final blockComparison = a.blockIndex.compareTo(b.blockIndex); + return blockComparison != 0 + ? blockComparison + : a.textOffset.compareTo(b.textOffset); + } + + List _buttonItems(String? linkDestination) { + final selectionController = widget.selectionController; + final range = selectionController.normalizedRange; + final onCopyPlainText = widget.onCopyPlainText; + VoidCallback hideAnd(VoidCallback action) => () { + _hideContextMenu(); + action(); + }; + return [ + if (range != null && range.start != range.end) + ContextMenuButtonItem( + type: ContextMenuButtonType.copy, + onPressed: hideAnd(selectionController.copySelectionToClipboard), + ), + if (linkDestination != null) + ContextMenuButtonItem( + label: 'Copy link', + onPressed: hideAnd( + () => Clipboard.setData(ClipboardData(text: linkDestination)), + ), + ), + ContextMenuButtonItem( + type: ContextMenuButtonType.selectAll, + onPressed: + hideAnd(() => selectionController.selectAll(widget.document)), + ), + if (onCopyPlainText != null && widget.showCopyAllInContextMenu) + ContextMenuButtonItem( + label: 'Copy all', + onPressed: hideAnd(onCopyPlainText), + ), + ]; + } + @override Widget build(BuildContext context) { final gestureDetectorWrap = MarkdownSelectionGestureDetector( @@ -64,7 +195,7 @@ class _MarkdownSelectionHostState extends State { isSelectable: widget.isSelectable, scrollableKey: widget.scrollableKey, scrollController: widget.scrollController, - onRequestToolbar: widget.onRequestToolbar, + onSecondaryPointerDown: _handleSecondaryPointerDown, hitTestPosition: widget.hitTestPosition, hitTestExactTextPosition: widget.hitTestExactTextPosition, selectWordAt: widget.selectWordAt, @@ -75,10 +206,11 @@ class _MarkdownSelectionHostState extends State { ); final tapRegion = TapRegion( + groupId: this, onTapOutside: (_) { widget.selectionController.clear(); _selectionFocusNode.unfocus(); - widget.onTapOutside?.call(); + _hideContextMenu(); }, child: Focus( focusNode: _selectionFocusNode, @@ -91,15 +223,48 @@ class _MarkdownSelectionHostState extends State { selectionController: widget.selectionController, document: widget.document, onCopyPlainText: widget.onCopyPlainText, - child: Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (_) { - if (!_selectionFocusNode.hasFocus) { - _selectionFocusNode.requestFocus(); - } + // Escape closes an open menu first; with no menu, the action is + // disabled and Escape reaches the scope above to clear the selection. + child: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): DismissIntent(), }, - child: tapRegion, + child: Actions( + actions: >{ + DismissIntent: _HideContextMenuAction(_contextMenuController), + }, + child: NotificationListener( + onNotification: (_) { + _hideContextMenu(); + return false; + }, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) { + if ((event.buttons & kSecondaryMouseButton) == 0) { + _hideContextMenu(); + } + if (!_selectionFocusNode.hasFocus) { + _selectionFocusNode.requestFocus(); + } + }, + child: tapRegion, + ), + ), + ), ), ); } } + +class _HideContextMenuAction extends Action { + _HideContextMenuAction(this.controller); + + final ContextMenuController controller; + + @override + bool isEnabled(DismissIntent intent) => controller.isShown; + + @override + void invoke(DismissIntent intent) => controller.remove(); +} diff --git a/packages/mixin_markdown_widget/lib/src/selection/selection_registrar.dart b/packages/mixin_markdown_widget/lib/src/selection/selection_registrar.dart index c993cb1f..5bdabc37 100644 --- a/packages/mixin_markdown_widget/lib/src/selection/selection_registrar.dart +++ b/packages/mixin_markdown_widget/lib/src/selection/selection_registrar.dart @@ -37,6 +37,7 @@ class MixinSelectionParticipant { required this.selectWordAt, required this.selectBlockAt, required this.selectSelectionUnitAt, + this.linkDestinationAt, this.autoScrollTargets, }); @@ -48,6 +49,7 @@ class MixinSelectionParticipant { final MixinSelectionParticipantSelectWord selectWordAt; final MixinSelectionParticipantSelectBlock selectBlockAt; final MixinSelectionParticipantSelectUnit selectSelectionUnitAt; + final String? Function(Offset globalPosition)? linkDestinationAt; final Iterable Function()? autoScrollTargets; } diff --git a/packages/mixin_markdown_widget/lib/src/selection/structured_block_selection.dart b/packages/mixin_markdown_widget/lib/src/selection/structured_block_selection.dart index 376d89fb..6881f73d 100644 --- a/packages/mixin_markdown_widget/lib/src/selection/structured_block_selection.dart +++ b/packages/mixin_markdown_widget/lib/src/selection/structured_block_selection.dart @@ -17,6 +17,7 @@ class StructuredBlockSelectionLeaf { required this.visibleText, required this.exportText, required this.segmentStart, + required this.inlines, }); final PathInBlock path; @@ -27,6 +28,9 @@ class StructuredBlockSelectionLeaf { final String exportText; final int segmentStart; + /// The inlines [visibleText] was flattened from, for text leaves. + final List inlines; + String get renderedText => '$leadingText${_renderPrefixedText(visibleText, firstLinePrefix, continuationPrefix)}'; @@ -73,6 +77,37 @@ class StructuredBlockSelectionLeaf { } return localOffset.clamp(0, visibleText.length).toInt(); } + + /// The link containing the character at [localOffset] of [visibleText]. + LinkInline? linkAt(int localOffset) { + if (localOffset < 0 || localOffset >= visibleText.length) { + return null; + } + return _linkContaining(inlines, localOffset); + } +} + +LinkInline? _linkContaining(List inlines, int offset) { + final builder = _StructuredBlockSelectionBuilder(); + var start = 0; + for (final inline in inlines) { + final end = start + builder._flattenVisibleInlines([inline]).length; + if (offset < end) { + return switch (inline) { + LinkInline() => inline, + EmphasisInline(:final children) || + StrongInline(:final children) || + StrikethroughInline(:final children) || + HighlightInline(:final children) || + SubscriptInline(:final children) || + SuperscriptInline(:final children) => + _linkContaining(children, offset - start), + _ => null, + }; + } + start = end; + } + return null; } @immutable @@ -124,6 +159,7 @@ class StructuredBlockSelection { visibleText: blueprint.visibleText, exportText: blueprint.exportText, segmentStart: segmentStart, + inlines: blueprint.inlines, ); leaves.add(leaf); visibleBuffer.write(leaf.renderedText); @@ -207,6 +243,24 @@ class StructuredBlockSelection { ); } + /// The link containing the character after [position], or before it when + /// [upstream] (the pointer is over the character before the caret). + LinkInline? linkAt(DocumentPosition position, {bool upstream = false}) { + final resolved = positionForDisplayedOffset( + blockIndex: position.blockIndex, + displayedOffset: position.textOffset, + affinity: upstream + ? StructuredSelectionAffinity.upstream + : StructuredSelectionAffinity.downstream, + ); + final leaf = leafForPath(resolved.path); + if (leaf == null) { + return null; + } + final localOffset = leaf.localOffsetForDisplayedOffset(resolved.textOffset); + return leaf.linkAt(upstream ? localOffset - 1 : localOffset); + } + DocumentPosition positionForDisplayedOffset({ required int blockIndex, required int displayedOffset, @@ -379,6 +433,7 @@ class _LeafBlueprint { required this.continuationPrefix, required this.visibleText, required this.exportText, + this.inlines = const [], }); final PathInBlock path; @@ -387,6 +442,7 @@ class _LeafBlueprint { final String continuationPrefix; final String visibleText; final String exportText; + final List inlines; _LeafBlueprint copyWith({ String? leadingText, @@ -398,6 +454,7 @@ class _LeafBlueprint { continuationPrefix: continuationPrefix, visibleText: visibleText, exportText: exportText, + inlines: inlines, ); } } @@ -444,6 +501,12 @@ class _StructuredBlockSelectionBuilder { continuationPrefix: continuationPrefix, visibleText: visibleText, exportText: exportText, + inlines: switch (block) { + HeadingBlock(:final inlines) || + ParagraphBlock(:final inlines) => + inlines, + _ => const [], + }, ), ]; case MarkdownBlockKind.details: @@ -626,6 +689,7 @@ class _StructuredBlockSelectionBuilder { continuationPrefix: continuationPrefix, visibleText: visibleText, exportText: _flattenExportInlines(row.cells[columnIndex].inlines), + inlines: row.cells[columnIndex].inlines, ), ); } diff --git a/packages/mixin_markdown_widget/lib/src/widgets/markdown_types.dart b/packages/mixin_markdown_widget/lib/src/widgets/markdown_types.dart index 5a860123..cbb5c76e 100644 --- a/packages/mixin_markdown_widget/lib/src/widgets/markdown_types.dart +++ b/packages/mixin_markdown_widget/lib/src/widgets/markdown_types.dart @@ -1,4 +1,4 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import '../core/document.dart'; import '../selection/selection_controller.dart'; @@ -31,9 +31,49 @@ typedef MarkdownBulletBuilder = Widget Function( MarkdownThemeData theme, ); +/// Builds the context menu shown on right click. Pass null as the builder to +/// show no menu; the default is [defaultMarkdownContextMenuBuilder]. typedef MarkdownContextMenuBuilder = Widget Function( BuildContext context, - MarkdownSelectionController selectionController, - List buttonItems, - TextSelectionToolbarAnchors anchors, + MarkdownContextMenuDetails details, ); + +/// What a [MarkdownContextMenuBuilder] knows about a right click. +@immutable +class MarkdownContextMenuDetails { + const MarkdownContextMenuDetails({ + required this.anchors, + required this.buttonItems, + required this.selectionController, + required this.hide, + this.linkDestination, + }); + + /// Where the menu opens: at the pointer. + final TextSelectionToolbarAnchors anchors; + + /// The default items for the current selection and pointer: Copy when text + /// is selected, Copy link over a link, Select all, and Copy all when the + /// widget copies the whole document. Each item closes the menu. + final List buttonItems; + + /// The selection, already updated for the right click. + final MarkdownSelectionController selectionController; + + /// The destination of the link under the pointer, if any. + final String? linkDestination; + + /// Closes the menu. Custom items call it after their action. + final VoidCallback hide; +} + +/// The platform's text selection toolbar with [MarkdownContextMenuDetails.buttonItems]. +Widget defaultMarkdownContextMenuBuilder( + BuildContext context, + MarkdownContextMenuDetails details, +) { + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: details.anchors, + buttonItems: details.buttonItems, + ); +} diff --git a/packages/mixin_markdown_widget/lib/src/widgets/markdown_widget.dart b/packages/mixin_markdown_widget/lib/src/widgets/markdown_widget.dart index c36435ae..b2a28be6 100644 --- a/packages/mixin_markdown_widget/lib/src/widgets/markdown_widget.dart +++ b/packages/mixin_markdown_widget/lib/src/widgets/markdown_widget.dart @@ -19,6 +19,8 @@ class MarkdownWidget extends StatefulWidget { this.useColumn = false, this.selectable = true, this.enableCopyFullDocumentShortcut = true, + @Deprecated('Filter MarkdownContextMenuDetails.buttonItems in ' + 'contextMenuBuilder instead.') this.showCopyAllInContextMenu = true, this.selectionController, this.padding, @@ -26,7 +28,7 @@ class MarkdownWidget extends StatefulWidget { this.imageBuilder, this.codeBlockBuilder, this.bulletBuilder, - this.contextMenuBuilder, + this.contextMenuBuilder = defaultMarkdownContextMenuBuilder, }) : assert( (data == null) != (controller == null), 'Provide exactly one of data or controller.', @@ -41,6 +43,8 @@ class MarkdownWidget extends StatefulWidget { final bool useColumn; final bool selectable; final bool enableCopyFullDocumentShortcut; + @Deprecated('Filter MarkdownContextMenuDetails.buttonItems in ' + 'contextMenuBuilder instead.') final bool showCopyAllInContextMenu; final MarkdownSelectionController? selectionController; final EdgeInsetsGeometry? padding; @@ -48,6 +52,8 @@ class MarkdownWidget extends StatefulWidget { final MarkdownImageBuilder? imageBuilder; final MarkdownCodeBlockBuilder? codeBlockBuilder; final MarkdownBulletBuilder? bulletBuilder; + + /// Builds the right-click menu; null shows none. final MarkdownContextMenuBuilder? contextMenuBuilder; @override @@ -148,6 +154,7 @@ class _MarkdownWidgetState extends State { }, enableCopyFullDocumentShortcut: widget.enableCopyFullDocumentShortcut, + // ignore: deprecated_member_use_from_same_package showCopyAllInContextMenu: widget.showCopyAllInContextMenu, onTapLink: widget.onTapLink, imageBuilder: widget.imageBuilder, diff --git a/packages/mixin_markdown_widget/test/markdown_context_menu_test.dart b/packages/mixin_markdown_widget/test/markdown_context_menu_test.dart new file mode 100644 index 00000000..63e27097 --- /dev/null +++ b/packages/mixin_markdown_widget/test/markdown_context_menu_test.dart @@ -0,0 +1,393 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mixin_markdown_widget/mixin_markdown_widget.dart'; + +const _document = 'Hello brave world\n\nSecond paragraph here'; + +Offset _characterCenter( + WidgetTester tester, { + required String containing, + required String character, +}) { + final finder = find.byWidgetPredicate( + (widget) => + widget is RichText && widget.text.toPlainText().contains(containing), + ); + final richText = tester.widget(finder.first); + final renderBox = tester.renderObject(finder.first); + final text = richText.text.toPlainText(); + final offset = text.indexOf(character); + final painter = TextPainter( + text: richText.text, + textDirection: TextDirection.ltr, + )..layout(maxWidth: renderBox.size.width); + final box = painter + .getBoxesForSelection( + TextSelection(baseOffset: offset, extentOffset: offset + 1), + ) + .first + .toRect(); + painter.dispose(); + return renderBox.localToGlobal(box.center); +} + +Future _rightClick( + WidgetTester tester, + Offset position, { + bool settle = true, +}) async { + await tester.tapAt( + position, + buttons: kSecondaryMouseButton, + kind: PointerDeviceKind.mouse, + ); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } +} + +Future _doubleClick(WidgetTester tester, Offset position) async { + await tester.tapAt(position, kind: PointerDeviceKind.mouse); + await tester.pump(); + await tester.tapAt(position, kind: PointerDeviceKind.mouse); + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 1)); +} + +Finder get _menu => find.byType(AdaptiveTextSelectionToolbar); + +/// Records what the app writes to the clipboard. +List _mockClipboard(WidgetTester tester) { + final copied = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied.add((call.arguments as Map)['text'] as String); + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + return copied; +} + +Future _pumpMarkdown( + WidgetTester tester, { + String data = _document, + ScrollController? scrollController, + MarkdownContextMenuBuilder? contextMenuBuilder = + defaultMarkdownContextMenuBuilder, +}) async { + final selectionController = MarkdownSelectionController(); + addTearDown(selectionController.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownWidget( + data: data, + selectionController: selectionController, + scrollController: scrollController, + contextMenuBuilder: contextMenuBuilder, + ), + ), + ), + ); + return selectionController; +} + +void main() { + group('right click selection', () { + testWidgets( + 'selects the word under the pointer outside the selection', + (tester) async { + final selection = await _pumpMarkdown(tester); + final brave = + _characterCenter(tester, containing: 'Hello', character: 'v'); + // Left of `brave`: the menu opens to the right of the pointer. + final hello = + _characterCenter(tester, containing: 'Hello', character: 'H'); + + await _rightClick(tester, brave); + expect(selection.selectedPlainText, 'brave'); + expect(_menu, findsOneWidget); + expect(find.text('Copy'), findsOneWidget); + + await _rightClick(tester, hello); + expect(selection.selectedPlainText, 'Hello'); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + ); + + testWidgets( + 'keeps the selection under the pointer and clears it elsewhere', + (tester) async { + final selection = await _pumpMarkdown(tester); + final brave = + _characterCenter(tester, containing: 'Hello', character: 'v'); + // Left of `brave`: the menu opens to the right of the pointer. + final hello = + _characterCenter(tester, containing: 'Hello', character: 'H'); + + await _doubleClick(tester, brave); + expect(selection.selectedPlainText, 'brave'); + + await _rightClick(tester, brave); + expect(selection.selectedPlainText, 'brave'); + expect(find.text('Copy'), findsOneWidget); + + await _rightClick(tester, hello); + expect(selection.selectedPlainText, isEmpty); + expect(_menu, findsOneWidget); + expect(find.text('Copy'), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.windows), + ); + + testWidgets( + 'applies inside MixinSelectionArea', + (tester) async { + final selection = MarkdownSelectionController(); + addTearDown(selection.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MixinSelectionArea( + controller: selection, + child: const MarkdownWidget(data: _document, useColumn: true), + ), + ), + ), + ); + + await tester.pump(); + await _rightClick( + tester, + _characterCenter(tester, containing: 'Hello', character: 'v'), + // MixinSelectionArea keeps scheduling frames, so it never settles. + settle: false, + ); + expect(selection.selectedPlainText, 'brave'); + expect(_menu, findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + ); + }); + + group('menu', () { + testWidgets( + 'buttons run their action and close the menu', + (tester) async { + final selection = await _pumpMarkdown(tester); + + await _rightClick( + tester, + _characterCenter(tester, containing: 'Hello', character: 'v'), + ); + await tester.tap(find.text('Select all')); + await tester.pumpAndSettle(); + + expect(selection.selectedPlainText, contains('Second paragraph')); + expect(_menu, findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.windows), + ); + + testWidgets( + 'Escape closes the menu and keeps the selection', + (tester) async { + final selection = await _pumpMarkdown(tester); + final brave = + _characterCenter(tester, containing: 'Hello', character: 'v'); + await _doubleClick(tester, brave); + await _rightClick(tester, brave); + expect(_menu, findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect(_menu, findsNothing); + expect(selection.selectedPlainText, 'brave'); + }, + variant: TargetPlatformVariant.only(TargetPlatform.windows), + ); + + testWidgets( + 'scrolling closes the menu', + (tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + await _pumpMarkdown( + tester, + data: [ + _document, + for (var index = 0; index < 60; index++) 'Line $index', + ].join('\n\n'), + scrollController: scrollController, + ); + + await _rightClick( + tester, + _characterCenter(tester, containing: 'Hello', character: 'v'), + ); + expect(_menu, findsOneWidget); + + scrollController.jumpTo(120); + await tester.pumpAndSettle(); + + expect(_menu, findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.windows), + ); + + testWidgets( + 'Copy copies the selection', + (tester) async { + final copied = _mockClipboard(tester); + await _pumpMarkdown(tester); + final brave = + _characterCenter(tester, containing: 'Hello', character: 'v'); + await _doubleClick(tester, brave); + await _rightClick(tester, brave); + + await tester.tap(find.text('Copy')); + await tester.pumpAndSettle(); + + expect(copied, ['brave']); + expect(_menu, findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.windows), + ); + + testWidgets( + 'a null builder shows no menu and keeps the selection', + (tester) async { + final selection = await _pumpMarkdown(tester, contextMenuBuilder: null); + final brave = + _characterCenter(tester, containing: 'Hello', character: 'v'); + await _doubleClick(tester, brave); + + await _rightClick( + tester, + _characterCenter(tester, containing: 'Second', character: 'S'), + ); + + expect(_menu, findsNothing); + expect(selection.selectedPlainText, 'brave'); + }, + variant: TargetPlatformVariant.only(TargetPlatform.windows), + ); + }); + + group('links', () { + for (final (name, data, containing, character, destination) in [ + ( + 'a plain link', + 'Read [the docs](https://example.com/docs) first', + 'Read', + 'docs', + 'https://example.com/docs', + ), + ( + 'an emphasized link', + 'Read **[bold docs](https://example.com/bold)** first', + 'Read', + 'bold', + 'https://example.com/bold', + ), + ( + 'a link in a table cell', + '| Name | Link |\n| --- | --- |\n| A | [cell docs](https://example.com/cell) |', + 'cell docs', + 'c', + 'https://example.com/cell', + ), + ]) { + testWidgets( + 'Copy link copies $name', + (tester) async { + final copied = _mockClipboard(tester); + await _pumpMarkdown(tester, data: data); + + await _rightClick( + tester, + _characterCenter( + tester, + containing: containing, + character: character, + ), + ); + await tester.tap(find.text('Copy link')); + await tester.pumpAndSettle(); + + expect(copied, [destination]); + expect(_menu, findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.windows), + ); + } + + testWidgets( + 'plain text has no Copy link', + (tester) async { + await _pumpMarkdown( + tester, + data: 'Read [the docs](https://example.com/docs) first', + ); + + await _rightClick( + tester, + _characterCenter(tester, containing: 'Read', character: 'R'), + ); + + expect(_menu, findsOneWidget); + expect(find.text('Copy link'), findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.windows), + ); + }); + + testWidgets( + 'custom builders get the details and can hide the menu', + (tester) async { + MarkdownContextMenuDetails? received; + final selection = await _pumpMarkdown( + tester, + data: 'Read [the docs](https://example.com/docs) first', + contextMenuBuilder: (context, details) { + received = details; + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: details.anchors, + buttonItems: [ + ...details.buttonItems, + ContextMenuButtonItem(label: 'Search', onPressed: details.hide), + ], + ); + }, + ); + final target = + _characterCenter(tester, containing: 'Read', character: 'docs'); + + await _rightClick(tester, target); + + expect(received!.linkDestination, 'https://example.com/docs'); + expect(received!.anchors.primaryAnchor, target); + expect(received!.selectionController, same(selection)); + expect( + received!.buttonItems.map((item) => item.type), + contains(ContextMenuButtonType.selectAll), + ); + + await tester.tap(find.text('Search')); + await tester.pumpAndSettle(); + expect(_menu, findsNothing); + }, + variant: TargetPlatformVariant.only(TargetPlatform.windows), + ); +}