Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/mixin_markdown_widget/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
35 changes: 35 additions & 0 deletions packages/mixin_markdown_widget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions packages/mixin_markdown_widget/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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!'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ class MarkdownBlockBuilder {
this.codeBlockBuilder,
this.bulletBuilder,
this.onTapLink,
required this.onRequestContextMenu,
required Map<String, CachedBlockRow> cachedBlockRows,
required this.tableLayoutPlanCache,
required this.codeHighlightCache,
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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({
Expand Down Expand Up @@ -66,7 +66,6 @@ class _MarkdownDocumentViewState extends State<MarkdownDocumentView> {

final MarkdownLinkRecognizers _linkRecognizers = MarkdownLinkRecognizers();
final ScrollController _fallbackScrollController = ScrollController();
final ContextMenuController _contextMenuController = ContextMenuController();
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'markdown-scrollable');

final MarkdownPlainTextSerializer _plainTextSerializer =
Expand Down Expand Up @@ -185,7 +184,6 @@ class _MarkdownDocumentViewState extends State<MarkdownDocumentView> {
..removeListener(_handleCodeHighlightCacheChanged)
..dispose();
_fallbackScrollController.dispose();
_contextMenuController.remove();
if (_registeredWithSelectionRegistrar) {
_selectionRegistrar?.unregisterParticipant(this);
}
Expand Down Expand Up @@ -254,6 +252,7 @@ class _MarkdownDocumentViewState extends State<MarkdownDocumentView> {
selectWordAt: _selectWordAt,
selectBlockAt: _selectBlockAt,
selectSelectionUnitAt: _selectSelectionUnitAt,
linkDestinationAt: _linkDestinationAt,
autoScrollTargets: _inheritedSelectionAutoScrollTargets,
);
}
Expand All @@ -277,20 +276,25 @@ class _MarkdownDocumentViewState extends State<MarkdownDocumentView> {
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) {
Expand Down Expand Up @@ -461,7 +465,6 @@ class _MarkdownDocumentViewState extends State<MarkdownDocumentView> {
codeBlockBuilder: widget.codeBlockBuilder,
bulletBuilder: widget.bulletBuilder,
onTapLink: onTapLink,
onRequestContextMenu: _showToolbar,
cachedBlockRows: _cachedBlockRows,
tableLayoutPlanCache: _tableLayoutPlanCache,
codeHighlightCache: _codeHighlightCache,
Expand Down Expand Up @@ -540,15 +543,16 @@ class _MarkdownDocumentViewState extends State<MarkdownDocumentView> {
isSelectable: widget.selectable,
scrollableKey: _scrollableKey,
scrollController: scrollController,
onRequestToolbar: _showToolbar,
contextMenuBuilder: widget.contextMenuBuilder,
linkDestinationAt: _linkDestinationAt,
showCopyAllInContextMenu: widget.showCopyAllInContextMenu,
hitTestPosition: _hitTestPosition,
hitTestExactTextPosition: _hitTestExactTextPosition,
selectWordAt: _selectWordAt,
selectBlockAt: _selectBlockAt,
selectSelectionUnitAt: _selectSelectionUnitAt,
additionalAutoScrollTargets: _autoScrollTargets,
onCopyPlainText: widget.onCopyPlainText,
onTapOutside: _contextMenuController.remove,
child: scrollable,
),
blockCount: widget.document.blocks.length,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -229,7 +233,7 @@ class SelectableMarkdownBlockState extends State<SelectableMarkdownBlock>
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;
Expand Down Expand Up @@ -584,7 +588,8 @@ class SelectableMarkdownBlockState extends State<SelectableMarkdownBlock>

/// 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 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -163,7 +163,7 @@ class _MarkdownSelectionGestureDetectorState
}

if ((event.buttons & kSecondaryMouseButton) != 0) {
widget.onRequestToolbar(event.position);
widget.onSecondaryPointerDown(event.position);
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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>[
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,
);
},
);
}
}
Loading
Loading