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), + ); +}