diff --git a/packages/mixin_markdown_widget/AGENTS.md b/packages/mixin_markdown_widget/AGENTS.md index 99536e54..19df0960 100644 --- a/packages/mixin_markdown_widget/AGENTS.md +++ b/packages/mixin_markdown_widget/AGENTS.md @@ -20,15 +20,16 @@ The public entrypoint is [packages/mixin_markdown_widget/lib/mixin_markdown_widg - `src/widgets/markdown_widget.dart`: top-level widget API, fallback controller ownership, theme wiring, and document/selection attachment. - `src/widgets/markdown_controller.dart`: document ownership, `setData`, `replaceAll`, `appendChunk`, `commitStream`, and plain-text copy helpers. -- `src/core/document.dart`: internal document model (`BlockNode`, `InlineNode`, selections, table cell ranges, image block metadata). +- `src/core/document.dart`: internal document model (`BlockNode`, `InlineNode`, selections, image block metadata). - `src/parser/markdown_document_parser.dart`: Markdown AST to internal model conversion, including append-only incremental reparsing. - `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_selection_resolver.dart` (heavy geometric rules for offsets/rects), `markdown_descriptor_extractor.dart` (converts visual blocks to selectable descriptors), and `markdown_selection_gesture_detector.dart` (drags, auto-scroll). +- `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/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 selection geometry for headings, paragraphs, list text, quote text, and table cells. -- `src/selection/selection_controller.dart`: authoritative selection state for text and table-cell selections. +- `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`. +- `src/selection/structured_block_selection.dart`: the structured selection model of each block (leaves, display/export text), cached per `BlockNode`. - `src/clipboard/plain_text_serializer.dart`: canonical plain-text export semantics for whole documents, ranges, lists, quotes, footnotes, and tables. ## Non-Negotiable Invariants @@ -51,13 +52,14 @@ The public entrypoint is [packages/mixin_markdown_widget/lib/mixin_markdown_widg ### Selection architecture -- Selection is model-based: `DocumentPosition`, `DocumentSelection`, `DocumentRange`, and `TableCellSelection` must stay coherent with what is rendered. -- `MarkdownPlainTextSerializer` defines copy semantics. If rendering or selection behavior changes, make sure serializer output still matches the logical selection. -- Table text selection and table-cell selection are separate behaviors. Do not collapse them into one mechanism. +- Selection is model-based: `DocumentPosition`, `DocumentSelection`, and `DocumentRange` must stay coherent with what is rendered. +- `MarkdownPlainTextSerializer` defines copy semantics and selection bounds. The selection controller clamps and selects all through it, so a serializer subclass changes both consistently. If rendering or selection behavior changes, make sure serializer output still matches the logical selection. +- A table is selected as text: cells joined with `\t` and rows with `\n`. Each non-empty cell is a leaf at path `[row, column]`; there is no separate table-cell selection type. +- `StructuredBlockSelection.forBlock` is cached per `BlockNode` and is the single source of block text for the serializer, clamping, select-all, and container geometry. Do not rebuild block text elsewhere. - Composite blocks such as lists, quotes, tables, and footnotes now use a structured selection index: `DocumentPosition.path` identifies the semantic leaf while `textOffset` stays in block display-text space. Do not flatten these selections back to `PathInBlock([0])` just to make offset math easier. - Range-copy serialization should use the structured visible-text space when selection offsets come from rendered interaction, and full-document export/select-all should come from the same structured model's export leaves. - Structured blocks now also carry export-text leaves. Full-block export and `selectAll` should use those structured export leaves while keeping selection offsets in visible text space; do not depend on serialized-length mismatches to trigger fallback behavior. -- Recursive hit-test and selection-unit helpers for list/quote/table containers must return block-local displayed offsets or ranges; convert back to parent space exactly once at the parent boundary. +- Leaf-local and block display offsets are converted exactly once, through the `StructuredBlockSelectionLeaf` of the leaf (`displayedOffsetForLocalOffset` / `localOffsetForDisplayedOffset`). Containers never re-convert per nesting level. - Double-click word selection and triple-click selection-unit resolution must stay aligned with the same offset mapping semantics used by drag selection. ### Pretext-backed text rules @@ -65,13 +67,15 @@ The public entrypoint is [packages/mixin_markdown_widget/lib/mixin_markdown_widg - Headings, paragraphs, list item text, quote text, and table cell text share pretext-backed inline rendering in important paths. - Pretext selection rectangles must use the rendered line box height (`lineHeight`) for top/bottom edges, not glyph box heights from `TextPainter`, or selection backgrounds will look vertically short. - If selection looks horizontally correct but vertically off, inspect `pretext_text_block.dart` first. -- Pretext selection units should prefer the clicked visual line when local pointer geometry is available; if only a text offset is available, fall back to explicit newline boundaries from descriptor plain text. +- Pretext selection units should prefer the clicked visual line when local pointer geometry is available; if only a text offset is available, fall back to explicit newline boundaries of the leaf text. -### Nested quote and list geometry rules +### Selection geometry: leaves and containers -- Nested quote selection cannot be measured from a flattened synthetic descriptor alone. Geometry must follow the real rendered child block subtree using stable child keys. -- Nested list selection has the same constraint. Measure against the actual child blocks inside each list item, not just the flattened list descriptor. -- List marker/prefix selection height should align with the first rendered child block in the item, not with the full row height when nested children make the row taller. +- Every selectable piece of text is a `MarkdownSelectableLeaf`, registered by its `PathInBlock` in the `MarkdownSelectionLeafScope` of its `SelectableMarkdownBlock`. A leaf answers its own geometry in its box coordinates and local offsets: `rectsFor`, `offsetAt`, `lineRangeAt`, an optional `clipRect` (the viewport of a horizontally scrolling code block or table), and its origin in the block. +- Leaf kinds: text leaves (`MarkdownTextLeafGeometry`, reading the live `RenderParagraph` when the runs render as one rich text and pretext otherwise), and placeholder leaves (`MarkdownPlaceholderLeafGeometry`) for images, thematic breaks, standalone line breaks, and custom code block builders, which select as a whole. +- Paragraphs, headings, code blocks, definition lists, and image captions are one leaf at `[0]`. Quotes, details, lists, footnotes, and tables use `MarkdownContainerSelectionGeometry`, which only orders leaves and reads `MarkdownSelectionSlot` boxes (child blocks, list rows, list content). Containers do not measure text themselves and keep no per-child `GlobalKey`s. +- Container rules: in a gap between stacked children, split at the gap midpoint (within 24px of horizontal overlap); otherwise use the nearest child's start or end. On a list item, the marker selects the item start and the marker rect spans the marker extent, aligned to the first line of the item's first child. A leading paragraph or heading takes its marker along in line selection. A table resolves to the nearest non-empty cell, and its selection unit is the whole cell. +- Leaf paths must match `StructuredBlockSelection`: quote child `[..., i]`, details summary `[..., 0]` and children `[..., i + 1]`, list marker `[..., item, 0]` and item children `[..., item, c + 1]`, table cell `[..., row, column]`. ### Decorated blocks and overlays @@ -91,7 +95,7 @@ The public entrypoint is [packages/mixin_markdown_widget/lib/mixin_markdown_widg ## High-Risk Change Areas -- `src/render/selection/markdown_selection_resolver.dart`: small geometry changes can break hit testing, copy semantics, nested selection, or link tapping. +- `src/render/selection/markdown_selection_geometry.dart` and `markdown_selectable_leaf.dart`: small geometry changes can break hit testing, copy semantics, nested selection, or link tapping. - `markdown_document_parser.dart`: parser shape changes often require matching updates in rendering, serializer logic, and regression tests. - `plain_text_serializer.dart`: changing output shape affects copy/paste, select-all, and many selection assertions. - `pretext_text_block.dart`: selection height/offset bugs often originate here even when the visible issue appears in quotes or lists. diff --git a/packages/mixin_markdown_widget/example/macos/Runner.xcodeproj/project.pbxproj b/packages/mixin_markdown_widget/example/macos/Runner.xcodeproj/project.pbxproj index 2f481d87..b04350e5 100644 --- a/packages/mixin_markdown_widget/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/mixin_markdown_widget/example/macos/Runner.xcodeproj/project.pbxproj @@ -461,7 +461,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -543,7 +543,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -593,7 +593,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/packages/mixin_markdown_widget/lib/src/clipboard/plain_text_serializer.dart b/packages/mixin_markdown_widget/lib/src/clipboard/plain_text_serializer.dart index b6c64b33..612b3083 100644 --- a/packages/mixin_markdown_widget/lib/src/clipboard/plain_text_serializer.dart +++ b/packages/mixin_markdown_widget/lib/src/clipboard/plain_text_serializer.dart @@ -2,6 +2,13 @@ import 'copy_serializer.dart'; import '../core/document.dart'; import '../selection/structured_block_selection.dart'; +/// Serializes markdown documents and selections as plain text. +/// +/// This is also the single source for selection bounds: the selection +/// controller clamps and selects all through [clampSelection] and +/// [createFullDocumentSelection], so a subclass changes both copy output and +/// selection bounds consistently. Block text comes from the cached +/// [StructuredBlockSelection] of each block. class MarkdownPlainTextSerializer extends MarkdownCopySerializer { const MarkdownPlainTextSerializer(); @@ -12,8 +19,8 @@ class MarkdownPlainTextSerializer extends MarkdownCopySerializer { @override String serialize(MarkdownDocument document) { final sections = []; - for (final indexedBlock in _indexBlocks(document)) { - final section = indexedBlock.text.trimRight(); + for (var index = 0; index < document.blocks.length; index++) { + final section = _indexedBlockAt(document, index).text.trimRight(); if (section.isNotEmpty) { sections.add(section); } @@ -22,12 +29,11 @@ class MarkdownPlainTextSerializer extends MarkdownCopySerializer { } DocumentSelection? createFullDocumentSelection(MarkdownDocument document) { - final indexedBlocks = _indexBlocks(document); - if (indexedBlocks.isEmpty) { + final bounds = _boundsOf(document); + if (bounds == null) { return null; } - final first = indexedBlocks.first; - final last = indexedBlocks.last; + final (first, last) = bounds; return DocumentSelection( base: DocumentPosition( blockIndex: first.blockIndex, @@ -41,7 +47,7 @@ class MarkdownPlainTextSerializer extends MarkdownCopySerializer { path: last.structure == null ? const PathInBlock([0]) : last.structure!.endPosition(blockIndex: last.blockIndex).path, - textOffset: last.structure?.plainText.length ?? last.text.length, + textOffset: last.visibleTextLength, ), ); } @@ -50,13 +56,13 @@ class MarkdownPlainTextSerializer extends MarkdownCopySerializer { MarkdownDocument document, DocumentSelection selection, ) { - final indexedBlocks = _indexBlocks(document); - if (indexedBlocks.isEmpty) { + final bounds = _boundsOf(document); + if (bounds == null) { return null; } return DocumentSelection( - base: _clampPosition(indexedBlocks, selection.base), - extent: _clampPosition(indexedBlocks, selection.extent), + base: _clampPosition(document, bounds, selection.base), + extent: _clampPosition(document, bounds, selection.extent), ); } @@ -68,40 +74,34 @@ class MarkdownPlainTextSerializer extends MarkdownCopySerializer { } String serializeRange(MarkdownDocument document, DocumentRange range) { - final indexedBlocks = _indexBlocks(document); - if (indexedBlocks.isEmpty) { + final bounds = _boundsOf(document); + if (bounds == null) { return ''; } - final start = _clampPosition(indexedBlocks, range.start); - final end = _clampPosition(indexedBlocks, range.end); + final start = _clampPosition(document, bounds, range.start); + final end = _clampPosition(document, bounds, range.end); final normalizedRange = start.compareTo(end) <= 0 ? DocumentRange(start: start, end: end) : DocumentRange(start: end, end: start); final sections = []; - for (final indexedBlock in indexedBlocks) { - if (indexedBlock.blockIndex < normalizedRange.start.blockIndex || - indexedBlock.blockIndex > normalizedRange.end.blockIndex) { + for (var blockIndex = normalizedRange.start.blockIndex; + blockIndex <= normalizedRange.end.blockIndex; + blockIndex++) { + final indexedBlock = _indexedBlockAt(document, blockIndex); + if (indexedBlock.text.isEmpty) { continue; } - final blockStart = - indexedBlock.blockIndex == normalizedRange.start.blockIndex - ? normalizedRange.start.textOffset - : 0; - final structure = indexedBlock.structure; - final blockEnd = indexedBlock.blockIndex == normalizedRange.end.blockIndex + final blockStart = blockIndex == normalizedRange.start.blockIndex + ? normalizedRange.start.textOffset + : 0; + final blockEnd = blockIndex == normalizedRange.end.blockIndex ? normalizedRange.end.textOffset : indexedBlock.visibleTextLength; + final structure = indexedBlock.structure; final section = structure != null - ? structure.serializeDisplayedRange( - start: blockStart, - end: blockEnd, - ) - : _sliceBlock( - indexedBlock, - start: blockStart, - end: blockEnd, - ); + ? structure.serializeDisplayedRange(start: blockStart, end: blockEnd) + : _sliceBlock(indexedBlock, start: blockStart, end: blockEnd); if (section.isNotEmpty) { sections.add(section); } @@ -109,44 +109,58 @@ class MarkdownPlainTextSerializer extends MarkdownCopySerializer { return sections.join('\n\n'); } - List<_IndexedPlainTextBlock> _indexBlocks(MarkdownDocument document) { - return <_IndexedPlainTextBlock>[ - for (var index = 0; index < document.blocks.length; index++) - _indexedBlockFor(document.blocks[index], index), - ].where((block) => block.text.isNotEmpty).toList(growable: false); - } - - _IndexedPlainTextBlock _indexedBlockFor(BlockNode block, int blockIndex) { + _IndexedPlainTextBlock _indexedBlockAt( + MarkdownDocument document, + int blockIndex, + ) { + final block = document.blocks[blockIndex]; final structure = StructuredBlockSelection.forBlock(block); - if (!structure.isEmpty) { - return _IndexedPlainTextBlock( - blockIndex: blockIndex, - text: structure.serializedText, - structure: structure, - ); - } return _IndexedPlainTextBlock( blockIndex: blockIndex, text: StructuredBlockSelection.serializeBlockText(block), - structure: null, + structure: structure.isEmpty ? null : structure, ); } + /// The first and last blocks that have text, or null when none has. + (_IndexedPlainTextBlock, _IndexedPlainTextBlock)? _boundsOf( + MarkdownDocument document, + ) { + _IndexedPlainTextBlock? first; + for (var index = 0; index < document.blocks.length; index++) { + final block = _indexedBlockAt(document, index); + if (block.text.isNotEmpty) { + first = block; + break; + } + } + if (first == null) { + return null; + } + for (var index = document.blocks.length - 1; + index > first.blockIndex; + index--) { + final block = _indexedBlockAt(document, index); + if (block.text.isNotEmpty) { + return (first, block); + } + } + return (first, first); + } + DocumentPosition _clampPosition( - List<_IndexedPlainTextBlock> indexedBlocks, + MarkdownDocument document, + (_IndexedPlainTextBlock, _IndexedPlainTextBlock) bounds, DocumentPosition position, ) { - final minBlockIndex = indexedBlocks.first.blockIndex; - final maxBlockIndex = indexedBlocks.last.blockIndex; - final targetBlockIndex = position.blockIndex < minBlockIndex - ? minBlockIndex - : position.blockIndex > maxBlockIndex - ? maxBlockIndex + final (first, last) = bounds; + final targetBlockIndex = position.blockIndex < first.blockIndex + ? first.blockIndex + : position.blockIndex > last.blockIndex + ? last.blockIndex : position.blockIndex; - final block = indexedBlocks.firstWhere( - (item) => item.blockIndex == targetBlockIndex, - orElse: () => indexedBlocks.last, - ); + final candidate = _indexedBlockAt(document, targetBlockIndex); + final block = candidate.text.isEmpty ? last : candidate; final targetOffset = position.textOffset < 0 ? 0 : position.textOffset > block.visibleTextLength 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 08a37a16..d0cfe8a5 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 @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart' show RenderParagraph; import 'package:flutter/services.dart'; import 'dart:math' as math; @@ -16,7 +15,9 @@ import '../pretext_text_block.dart'; import '../selectable_block.dart'; import 'markdown_inline_builder.dart'; import '../selection/markdown_descriptor_extractor.dart'; -import '../selection/markdown_selection_resolver.dart'; +import '../selection/markdown_selectable_leaf.dart'; +import '../selection/markdown_selection_geometry.dart'; +import '../selection/markdown_block_keys_registry.dart'; import '../local_image_provider_stub.dart' if (dart.library.io) '../local_image_provider_io.dart'; @@ -30,7 +31,6 @@ class MarkdownBlockBuilder { required this.isSelectable, required this.keysRegistry, required this.descriptorExtractor, - required this.selectionResolver, required this.inlineBuilder, required this.codeSyntaxHighlighter, required this.plainTextSerializer, @@ -50,7 +50,6 @@ class MarkdownBlockBuilder { final bool isSelectable; final MarkdownBlockKeysRegistry keysRegistry; final MarkdownDescriptorExtractor descriptorExtractor; - final MarkdownSelectionResolver selectionResolver; final MarkdownInlineBuilder inlineBuilder; final MarkdownCodeSyntaxHighlighter codeSyntaxHighlighter; final MarkdownPlainTextSerializer plainTextSerializer; @@ -373,27 +372,19 @@ class MarkdownBlockBuilder { final style = theme.headingStyleForLevel(heading.level); final plainText = MarkdownInlineBuilder.flattenInlineText(heading.inlines); - final runs = inlineBuilder.buildPretextRuns(style, heading.inlines); - final directTextKey = _createDirectTextKeyIfNeeded(runs); - return _buildPretextTextSpec( + final geometry = MarkdownTextLeafGeometry( + runs: inlineBuilder.buildPretextRuns(style, heading.inlines), + fallbackStyle: style, + textAlign: + MarkdownInlineBuilder.resolvedInlineTextAlign(heading.inlines), + ); + return _buildTextLeafSpec( child: _wrapHeadingBlock( level: heading.level, - child: MarkdownPretextTextBlock.rich( - runs: runs, - fallbackStyle: style, - directTextKey: directTextKey, - preferDirectRichText: directTextKey != null, - textAlign: MarkdownInlineBuilder.resolvedInlineTextAlign( - heading.inlines, - ), - ), + child: _textLeaf(_topLevelLeafPath, geometry), ), plainText: plainText, - runs: runs, - fallbackStyle: style, - directTextKey: directTextKey, - textAlign: - MarkdownInlineBuilder.resolvedInlineTextAlign(heading.inlines), + geometry: geometry, ); case MarkdownBlockKind.paragraph: final paragraph = block as ParagraphBlock; @@ -406,127 +397,64 @@ class MarkdownBlockBuilder { hitTestBehavior: SelectableBlockHitTestBehavior.block, ); } - final runs = inlineBuilder.buildPretextRuns( - theme.bodyStyle, - paragraph.inlines, - ); - final directTextKey = _createDirectTextKeyIfNeeded(runs); - return _buildPretextTextSpec( - plainText: plainText, - runs: runs, + final geometry = MarkdownTextLeafGeometry( + runs: inlineBuilder.buildPretextRuns( + theme.bodyStyle, + paragraph.inlines, + ), fallbackStyle: theme.bodyStyle, - directTextKey: directTextKey, textAlign: MarkdownInlineBuilder.resolvedInlineTextAlign(paragraph.inlines), ); + return _buildTextLeafSpec( + child: _textLeaf(_topLevelLeafPath, geometry), + plainText: plainText, + geometry: geometry, + ); case MarkdownBlockKind.quote: final quoteBlock = block as QuoteBlock; final descriptor = descriptorExtractor.buildSelectableDescriptorForBlock(quoteBlock); - return SelectableBlockSpec( + return _buildContainerSpec( + block: quoteBlock, child: MarkdownQuoteBlockView( theme: theme, child: _buildQuoteContent( context, quoteBlock, - childKeys: keysRegistry.quoteChildKeysFor(quoteBlock), + path: const [], ), ), - plainText: descriptor.plainText, - selectionStructure: _matchingSelectionStructure( - quoteBlock, - descriptor.plainText, - ), - hitTestBehavior: SelectableBlockHitTestBehavior.text, textSpan: descriptor.span, highlightBorderRadius: theme.quoteBorderRadius, selectionPaintOrder: SelectableBlockSelectionPaintOrder.aboveChild, selectionColor: _quoteSelectionColor, repaintListenable: _nestedScrollableRepaintListenableForBlocks(quoteBlock.children), - selectionRectResolver: (context, _, range) => - selectionResolver.resolveQuoteSelectionRects( - context, - quoteBlock, - range, - ), - textOffsetResolver: (context, _, localPosition) => - selectionResolver.resolveQuoteTextOffset( - context, - quoteBlock, - localPosition, - ), - selectionUnitRangeResolver: (context, _, localPosition, position) => - localPosition == null - ? descriptorExtractor.resolveSelectionUnitRange( - position, - quoteBlock, - ) - : selectionResolver.resolveQuoteSelectionUnitRange( - context, - quoteBlock, - localPosition, - position, - ) ?? - descriptorExtractor.resolveSelectionUnitRange( - position, - quoteBlock, - ), ); case MarkdownBlockKind.orderedList: case MarkdownBlockKind.unorderedList: final listBlock = block as ListBlock; final descriptor = descriptorExtractor.buildSelectableDescriptorForBlock(listBlock); - final itemRowKeys = keysRegistry.listItemKeysFor(listBlock); - final itemContentKeys = keysRegistry.listItemContentKeysFor(listBlock); - return SelectableBlockSpec( + return _buildContainerSpec( + block: listBlock, child: MarkdownListBlockView( theme: theme, block: listBlock, - itemBuilder: (index, item) => - _buildListItemContent(context, listBlock, index, item), - itemRowKeyBuilder: (index) => itemRowKeys[index], - itemContentKeyBuilder: (index) => itemContentKeys[index], + itemBuilder: (index, item) => _buildListItemContent( + context, + listBlock, + index, + item, + path: const [], + ), + itemSlotBuilder: _listItemSlotBuilder(const []), bulletBuilder: bulletBuilder, ), - plainText: descriptor.plainText, - selectionStructure: _matchingSelectionStructure( - listBlock, - descriptor.plainText, - ), - hitTestBehavior: SelectableBlockHitTestBehavior.text, textSpan: descriptor.span, repaintListenable: _nestedScrollableRepaintListenableForListBlock(listBlock), - selectionRectResolver: (context, _, range) => - selectionResolver.resolveListSelectionRects( - context, - listBlock, - range, - ), - textOffsetResolver: (context, _, localPosition) => - selectionResolver.resolveListTextOffset( - context, - listBlock, - localPosition, - ), - selectionUnitRangeResolver: (context, _, localPosition, position) => - localPosition == null - ? descriptorExtractor.resolveSelectionUnitRange( - position, - listBlock, - ) - : selectionResolver.resolveListSelectionUnitRange( - context, - listBlock, - localPosition, - position, - ) ?? - descriptorExtractor.resolveSelectionUnitRange( - position, - listBlock, - ), selectionPaintOrder: SelectableBlockSelectionPaintOrder.aboveChild, ); case MarkdownBlockKind.definitionList: @@ -546,111 +474,45 @@ class MarkdownBlockBuilder { MarkdownDescriptorExtractor.footnoteListAsOrderedList(footnoteList); final footnoteDescriptor = descriptorExtractor .buildFootnoteListSelectableDescriptor(footnoteList); - final itemRowKeys = keysRegistry.listItemKeysFor(orderedFootnotes); - final itemContentKeys = - keysRegistry.listItemContentKeysFor(orderedFootnotes); - return SelectableBlockSpec( + return _buildContainerSpec( + block: footnoteList, child: _buildFootnoteListContainer( child: MarkdownListBlockView( theme: theme, block: orderedFootnotes, - itemBuilder: (index, item) => - _buildListItemContent(context, orderedFootnotes, index, item), - itemRowKeyBuilder: (index) => itemRowKeys[index], - itemContentKeyBuilder: (index) => itemContentKeys[index], + itemBuilder: (index, item) => _buildListItemContent( + context, + orderedFootnotes, + index, + item, + path: const [], + ), + itemSlotBuilder: _listItemSlotBuilder(const []), bulletBuilder: bulletBuilder, ), ), - plainText: footnoteDescriptor.plainText, - selectionStructure: _matchingSelectionStructure( - orderedFootnotes, - footnoteDescriptor.plainText, - ), - hitTestBehavior: SelectableBlockHitTestBehavior.text, textSpan: footnoteDescriptor.span, repaintListenable: _nestedScrollableRepaintListenableForListBlock(orderedFootnotes), - selectionRectResolver: (context, _, range) => - selectionResolver.resolveListSelectionRects( - context, - orderedFootnotes, - range, - ), - textOffsetResolver: (context, _, localPosition) => - selectionResolver.resolveListTextOffset( - context, - orderedFootnotes, - localPosition, - ), - selectionUnitRangeResolver: (context, _, localPosition, position) => - localPosition == null - ? descriptorExtractor.resolveSelectionUnitRange( - position, - orderedFootnotes, - ) - : selectionResolver.resolveListSelectionUnitRange( - context, - orderedFootnotes, - localPosition, - position, - ) ?? - descriptorExtractor.resolveSelectionUnitRange( - position, - orderedFootnotes, - ), highlightBorderRadius: BorderRadius.circular(8), ); case MarkdownBlockKind.details: final detailsBlock = block as DetailsBlock; final descriptor = descriptorExtractor.buildDetailsSelectableDescriptor(detailsBlock); - final childKeys = keysRegistry.detailsChildKeysFor(detailsBlock); - return SelectableBlockSpec( + return _buildContainerSpec( + block: detailsBlock, child: _buildDetails( context, detailsBlock, - childKeys: childKeys, + path: const [], ), - plainText: descriptor.plainText, - selectionStructure: _matchingSelectionStructure( - detailsBlock, - descriptor.plainText, - ), - hitTestBehavior: SelectableBlockHitTestBehavior.text, textSpan: descriptor.span, highlightBorderRadius: theme.quoteBorderRadius, selectionPaintOrder: SelectableBlockSelectionPaintOrder.aboveChild, repaintListenable: _nestedScrollableRepaintListenableForBlocks( detailsBlock.children, ), - selectionRectResolver: (context, _, range) => - selectionResolver.resolveDetailsSelectionRects( - context, - detailsBlock, - range, - ), - textOffsetResolver: (context, _, localPosition) => - selectionResolver.resolveDetailsTextOffset( - context, - detailsBlock, - localPosition, - ), - selectionUnitRangeResolver: (context, _, localPosition, position) => - localPosition == null - ? descriptorExtractor.resolveSelectionUnitRange( - position, - detailsBlock, - ) - : selectionResolver.resolveDetailsSelectionUnitRange( - context, - detailsBlock, - localPosition, - position, - ) ?? - descriptorExtractor.resolveSelectionUnitRange( - position, - detailsBlock, - ), ); case MarkdownBlockKind.codeBlock: final codeBlock = block as CodeBlock; @@ -661,29 +523,32 @@ class MarkdownBlockBuilder { theme: theme, language: codeBlock.language, ); - final codeRuns = codeHighlightPresentation.runs; - final codeSpan = codeHighlightPresentation.span; - final directTextKey = _createDirectTextKeyIfNeeded(codeRuns); final scrollController = keysRegistry.codeBlockScrollControllers .putIfAbsent(codeBlock.id, ScrollController.new); final viewportKey = keysRegistry.codeBlockViewportKeys.putIfAbsent( codeBlock.id, () => GlobalKey(debugLabel: 'code-viewport-${codeBlock.id}'), ); - return _buildPretextTextSpec( - child: _buildDecoratedCodeBlock( - codeBlock, - codeSpan: codeSpan, - directTextKey: directTextKey, - scrollController: scrollController, - viewportKey: viewportKey, + final geometry = MarkdownTextLeafGeometry( + runs: codeHighlightPresentation.runs, + fallbackStyle: theme.codeBlockStyle, + direct: true, + viewportKey: viewportKey, + ); + return _buildTextLeafSpec( + child: MarkdownSelectableLeaf( + path: _topLevelLeafPath, + geometry: geometry, + child: _buildDecoratedCodeBlock( + codeBlock, + codeSpan: codeHighlightPresentation.span, + scrollController: scrollController, + viewportKey: viewportKey, + ), ), plainText: codeBlock.code, - runs: codeRuns, - fallbackStyle: theme.codeBlockStyle, - directTextKey: directTextKey, - selectionClipRectResolver: (context, _) => - _resolveChildViewportRect(context, viewportKey), + geometry: geometry, + clipToLeaf: true, measurementPadding: EdgeInsets.fromLTRB( theme.codeBlockPadding.resolve(Directionality.of(context)).left, math.max( @@ -728,8 +593,6 @@ class MarkdownBlockBuilder { ); case MarkdownBlockKind.table: final tableBlock = block as TableBlock; - final cellKeys = keysRegistry.tableCellKeysFor(tableBlock); - final cellTextKeys = keysRegistry.tableCellTextKeysFor(tableBlock); final descriptor = descriptorExtractor.buildSelectableDescriptorForBlock(tableBlock); final scrollController = keysRegistry.tableScrollControllers @@ -738,55 +601,19 @@ class MarkdownBlockBuilder { tableBlock.id, () => GlobalKey(debugLabel: 'table-viewport-${tableBlock.id}'), ); - return SelectableBlockSpec( + return _buildContainerSpec( + block: tableBlock, child: _buildTable( context, tableBlock, - cellKeyBuilder: (rowIndex, columnIndex) => - cellKeys[rowIndex][columnIndex], - cellTextKeyBuilder: (rowIndex, columnIndex) => - cellTextKeys[rowIndex][columnIndex], scrollController: scrollController, viewportKey: viewportKey, ), - plainText: descriptor.plainText, - selectionStructure: _matchingSelectionStructure( - tableBlock, - descriptor.plainText, - ), - hitTestBehavior: SelectableBlockHitTestBehavior.text, textSpan: descriptor.span, highlightBorderRadius: theme.tableBorderRadius, selectionPaintOrder: SelectableBlockSelectionPaintOrder.aboveChild, repaintListenable: scrollController, selectionColor: theme.selectionColor, - selectionRectResolver: (context, _, range) => - selectionResolver.resolveNestedBlockSelectionRects( - context, - rootRenderObject: context.findRenderObject() as RenderBox, - block: tableBlock, - descriptor: descriptor, - range: range, - renderObject: context.findRenderObject() as RenderBox, - origin: Offset.zero, - textDirection: Directionality.of(context), - ), - textOffsetResolver: (context, _, localPosition) => - selectionResolver.resolveNestedBlockTextOffset( - context, - rootRenderObject: context.findRenderObject() as RenderBox, - block: tableBlock, - descriptor: descriptor, - renderObject: context.findRenderObject() as RenderBox, - globalPosition: (context.findRenderObject() as RenderBox) - .localToGlobal(localPosition), - textDirection: Directionality.of(context), - ), - selectionUnitRangeResolver: (_, __, ___, position) => - descriptorExtractor.resolveSelectionUnitRange( - position, - tableBlock, - ), ); case MarkdownBlockKind.image: return SelectableBlockSpec( @@ -808,33 +635,98 @@ class MarkdownBlockBuilder { } } - SelectableBlockSpec _buildPretextTextSpec({ - Widget? child, + static const PathInBlock _topLevelLeafPath = PathInBlock([0]); + + /// The spec of a container block: a quote, details, list, footnote list, + /// or table. Its text and offsets are those of the block's + /// [StructuredBlockSelection], and its geometry is read from the leaves and + /// slots rendered in [child]. + SelectableBlockSpec _buildContainerSpec({ + required BlockNode block, + required Widget child, + required InlineSpan textSpan, + BorderRadius? highlightBorderRadius, + SelectableBlockSelectionPaintOrder selectionPaintOrder = + SelectableBlockSelectionPaintOrder.behindChild, + Color? selectionColor, + Listenable? repaintListenable, + }) { + final structure = StructuredBlockSelection.forBlock(block); + return SelectableBlockSpec( + child: child, + plainText: structure.plainText, + selectionStructure: structure.isEmpty ? null : structure, + hitTestBehavior: SelectableBlockHitTestBehavior.text, + textSpan: textSpan, + highlightBorderRadius: highlightBorderRadius, + selectionPaintOrder: selectionPaintOrder, + selectionColor: selectionColor, + repaintListenable: repaintListenable, + selectionRectResolver: (context, _, range) => + MarkdownContainerSelectionGeometry.rects(context, block, range), + textOffsetResolver: (context, _, localPosition) => + MarkdownContainerSelectionGeometry.offsetAt( + context, + block, + localPosition, + ), + selectionUnitRangeResolver: (context, _, localPosition, position) => + MarkdownContainerSelectionGeometry.unitAt( + context, + block, + localPosition, + position, + ), + ); + } + + /// A selectable text leaf rendering the runs of [geometry]. + Widget _textLeaf(PathInBlock path, MarkdownTextLeafGeometry geometry) { + return MarkdownSelectableLeaf( + path: path, + geometry: geometry, + child: MarkdownPretextTextBlock.rich( + runs: geometry.runs, + fallbackStyle: geometry.fallbackStyle, + textAlign: geometry.textAlign, + preferDirectRichText: geometry.direct, + ), + ); + } + + /// The spec of a block that is a single text leaf at [_topLevelLeafPath], + /// whose local offsets are the block's display offsets. Before the leaf is + /// laid out, geometry falls back to the pretext layout at the block width. + SelectableBlockSpec _buildTextLeafSpec({ + required Widget child, required String plainText, - required List runs, - required TextStyle fallbackStyle, - TextAlign textAlign = TextAlign.start, - GlobalKey? directTextKey, + required MarkdownTextLeafGeometry geometry, EdgeInsets measurementPadding = EdgeInsets.zero, BorderRadius? highlightBorderRadius, SelectableBlockSelectionPaintOrder? selectionPaintOrder, Listenable? repaintListenable, EdgeInsets? selectionClipPadding, StructuredBlockSelection? selectionStructure, - Rect? Function(BuildContext context, Size size)? selectionClipRectResolver, + bool clipToLeaf = false, }) { - final hasInlineSurface = runs.any( + final hasInlineSurface = geometry.runs.any( (run) => run.decoration != null || run.renderSpan != null, ); + ({MarkdownSelectableLeafState leaf, Offset origin})? leafOf( + BuildContext context, + ) { + final leaf = SelectableMarkdownBlockState.leafRegistryOf(context) + ?.leaf(_topLevelLeafPath); + final blockBox = context.findRenderObject(); + if (leaf == null || blockBox is! RenderBox || !blockBox.hasSize) { + return null; + } + final origin = leaf.originIn(blockBox); + return origin == null ? null : (leaf: leaf, origin: origin); + } + return SelectableBlockSpec( - child: child ?? - MarkdownPretextTextBlock.rich( - runs: runs, - fallbackStyle: fallbackStyle, - directTextKey: directTextKey, - preferDirectRichText: directTextKey != null, - textAlign: textAlign, - ), + child: child, plainText: plainText, hitTestBehavior: SelectableBlockHitTestBehavior.text, selectionStructure: selectionStructure, @@ -846,100 +738,58 @@ class MarkdownBlockBuilder { : SelectableBlockSelectionPaintOrder.behindChild), repaintListenable: repaintListenable, selectionClipPadding: selectionClipPadding, - selectionClipRectResolver: selectionClipRectResolver, - selectionRectResolver: (context, constraints, range) { - if (directTextKey != null) { - final directRects = _resolveDirectRichTextSelectionRects( - context, - directTextKey, - range, - runs, - ); - if (directRects != null) { - return directRects; - } + selectionClipRectResolver: clipToLeaf + ? (context, _) { + final resolved = leafOf(context); + return resolved?.leaf.clipRect?.shift(resolved.origin); + } + : null, + selectionRectResolver: (context, size, range) { + final start = range.start.textOffset; + final end = range.end.textOffset; + final resolved = leafOf(context); + if (resolved == null) { + return geometry.layoutRects(context, size.width, start, end); } - final textScaler = - MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling; - final layout = computeMarkdownPretextLayoutFromRuns( - runs: runs, - fallbackStyle: fallbackStyle, - maxWidth: constraints.width, - textScaleFactor: textScaler.scale(1.0), - textAlign: textAlign, - textDirection: Directionality.of(context), - ); - return layout.selectionRectsForRange( - range, - textDirection: Directionality.of(context), - ); + return resolved.leaf + .rectsFor(start, end) + .map((rect) => rect.shift(resolved.origin)) + .toList(growable: false); }, textOffsetResolver: (context, size, localPosition) { - if (directTextKey != null) { - final directOffset = _resolveDirectRichTextTextOffset( - context, - directTextKey, - localPosition, - runs, - ); - if (directOffset != null) { - return directOffset; - } + final resolved = leafOf(context); + if (resolved == null) { + return geometry.layoutOffsetAt(context, size.width, localPosition); } - final textScaler = - MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling; - final layout = computeMarkdownPretextLayoutFromRuns( - runs: runs, - fallbackStyle: fallbackStyle, - maxWidth: size.width, - textScaleFactor: textScaler.scale(1.0), - textAlign: textAlign, - textDirection: Directionality.of(context), - ); - return layout.textOffsetAt( - localPosition, - textDirection: Directionality.of(context), - ); + return resolved.leaf.offsetAt(localPosition - resolved.origin); }, selectionUnitRangeResolver: (context, size, localPosition, position) { - if (directTextKey != null) { - final directRange = _resolveDirectRichTextLineRange( - context, - directTextKey, - position, - localPosition, - runs, - ); - if (directRange != null) { - return directRange; - } - } - final textScaler = - MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling; - final layout = computeMarkdownPretextLayoutFromRuns( - runs: runs, - fallbackStyle: fallbackStyle, - maxWidth: size.width, - textScaleFactor: textScaler.scale(1.0), - textAlign: textAlign, - textDirection: Directionality.of(context), - ); - final lineRange = localPosition == null - ? layout.lineRangeForTextOffset(position.textOffset) - : layout.visualLineRangeForLocalPosition(localPosition) ?? - layout.lineRangeForTextOffset(position.textOffset); + final resolved = leafOf(context); + final lineRange = resolved == null + ? geometry.layoutLineRangeAt( + context, + size.width, + position.textOffset, + localPosition: localPosition, + ) + : resolved.leaf.lineRangeAt( + position.textOffset, + localPosition: localPosition == null + ? null + : localPosition - resolved.origin, + ); if (lineRange == null) { return null; } return DocumentRange( start: DocumentPosition( blockIndex: position.blockIndex, - path: const PathInBlock([0]), + path: _topLevelLeafPath, textOffset: lineRange.start, ), end: DocumentPosition( blockIndex: position.blockIndex, - path: const PathInBlock([0]), + path: _topLevelLeafPath, textOffset: lineRange.end, ), ); @@ -947,321 +797,6 @@ class MarkdownBlockBuilder { ); } - DocumentRange? _resolveDirectRichTextLineRange( - BuildContext context, - GlobalKey directTextKey, - DocumentPosition position, - Offset? localPosition, - List runs, - ) { - final blockRenderObject = context.findRenderObject(); - final renderParagraph = _resolveDirectRenderParagraph( - directTextKey, - runs: runs, - fallbackRoot: blockRenderObject, - ); - if (renderParagraph == null || - blockRenderObject is! RenderBox || - !blockRenderObject.hasSize) { - return null; - } - - final textPosition = localPosition == null - ? TextPosition( - offset: markdownPretextRenderOffsetForPlainOffset( - runs, - position.textOffset, - preferEnd: false, - ), - ) - : renderParagraph.getPositionForOffset( - renderParagraph.globalToLocal( - blockRenderObject.localToGlobal(localPosition), - ), - ); - final lineBoundary = _resolveDirectRenderParagraphLineBoundary( - renderParagraph, - textPosition.offset, - ); - if (lineBoundary == null) { - return null; - } - final plainText = runs.map((run) => run.text).join(); - var plainStart = markdownPretextPlainOffsetForRenderOffset( - runs, - lineBoundary.start, - ); - var plainEnd = markdownPretextPlainOffsetForRenderOffset( - runs, - lineBoundary.end, - ); - if (plainStart < plainText.length && - plainText.codeUnitAt(plainStart) == 0x0A) { - plainStart += 1; - } - if (plainEnd > plainStart && - plainEnd <= plainText.length && - plainText.codeUnitAt(plainEnd - 1) == 0x0A) { - plainEnd -= 1; - } - - return DocumentRange( - start: DocumentPosition( - blockIndex: position.blockIndex, - path: const PathInBlock([0]), - textOffset: plainStart, - ), - end: DocumentPosition( - blockIndex: position.blockIndex, - path: const PathInBlock([0]), - textOffset: plainEnd, - ), - ); - } - - ({int start, int end})? _resolveDirectRenderParagraphLineBoundary( - RenderParagraph renderParagraph, - int renderOffset, - ) { - final fullText = - renderParagraph.text.toPlainText(includePlaceholders: true); - if (fullText.isEmpty) { - return const (start: 0, end: 0); - } - final lineExtents = _computeParagraphLineExtents(renderParagraph); - if (lineExtents.isEmpty) { - return null; - } - - var probe = renderOffset.clamp(0, fullText.length).toInt(); - if (probe == fullText.length && probe > 0) { - probe -= 1; - } - - List probeBoxes = _boxesForDirectRenderOffset( - renderParagraph, - fullText, - probe, - ); - while (probeBoxes.isEmpty && probe > 0) { - probe -= 1; - probeBoxes = - _boxesForDirectRenderOffset(renderParagraph, fullText, probe); - } - while (probeBoxes.isEmpty && probe < fullText.length - 1) { - probe += 1; - probeBoxes = - _boxesForDirectRenderOffset(renderParagraph, fullText, probe); - } - if (probeBoxes.isEmpty) { - return null; - } - - final lineExtent = lineExtents.firstWhere( - (extent) => _boxesOverlapLineExtent(probeBoxes, extent), - orElse: () => lineExtents.last, - ); - - var start = probe; - while (start > 0) { - final previousBoxes = _boxesForDirectRenderOffset( - renderParagraph, - fullText, - start - 1, - ); - if (previousBoxes.isEmpty || - !_boxesOverlapLineExtent(previousBoxes, lineExtent)) { - break; - } - start -= 1; - } - - var end = probe + 1; - while (end < fullText.length) { - final nextBoxes = _boxesForDirectRenderOffset( - renderParagraph, - fullText, - end, - ); - if (nextBoxes.isEmpty || - !_boxesOverlapLineExtent(nextBoxes, lineExtent)) { - break; - } - end += 1; - } - - return (start: start, end: end); - } - - List _boxesForDirectRenderOffset( - RenderParagraph renderParagraph, - String fullText, - int renderOffset, - ) { - if (fullText.isEmpty) { - return const []; - } - final start = renderOffset.clamp(0, fullText.length - 1).toInt(); - final end = math.min(start + 1, fullText.length); - if (start >= end) { - return const []; - } - return renderParagraph.getBoxesForSelection( - TextSelection(baseOffset: start, extentOffset: end), - ); - } - - bool _boxesOverlapLineExtent( - List boxes, - ({double top, double bottom}) lineExtent, - ) { - for (final box in boxes) { - final overlapTop = math.max(box.top, lineExtent.top); - final overlapBottom = math.min(box.bottom, lineExtent.bottom); - if (overlapBottom - overlapTop > 0.5) { - return true; - } - } - return false; - } - - GlobalKey? _createDirectTextKeyIfNeeded( - List runs, - ) { - if (markdownPretextCanUseDirectRichTextGeometry(runs)) { - return GlobalKey(); - } - return null; - } - - List? _resolveDirectRichTextSelectionRects( - BuildContext context, - GlobalKey directTextKey, - DocumentRange range, - List runs, - ) { - final blockRenderObject = context.findRenderObject(); - final renderParagraph = _resolveDirectRenderParagraph( - directTextKey, - runs: runs, - fallbackRoot: blockRenderObject, - ); - if (renderParagraph == null || - blockRenderObject is! RenderBox || - !blockRenderObject.hasSize) { - return null; - } - - final renderSelection = TextSelection( - baseOffset: markdownPretextRenderOffsetForPlainOffset( - runs, - range.start.textOffset, - preferEnd: false, - ), - extentOffset: markdownPretextRenderOffsetForPlainOffset( - runs, - range.end.textOffset, - preferEnd: true, - ), - ); - final paragraphOrigin = blockRenderObject.globalToLocal( - renderParagraph.localToGlobal(Offset.zero), - ); - // The paragraph found via GlobalKey may not have completed layout - // in the current frame (e.g. during scroll-triggered rebuilds). - final List selectionBoxes; - List<({double top, double bottom})> lineExtents; - try { - lineExtents = _computeParagraphLineExtents(renderParagraph); - selectionBoxes = renderParagraph.getBoxesForSelection(renderSelection); - } on AssertionError { - return null; - } - final boxes = _mergeDirectTextSelectionBoxes( - _normalizeBoxesToLineExtents(selectionBoxes, lineExtents), - ); - return boxes - .map( - (box) => Rect.fromLTRB( - box.left + paragraphOrigin.dx - 1.5, - box.top + paragraphOrigin.dy, - box.right + paragraphOrigin.dx + 1.5, - box.bottom + paragraphOrigin.dy, - ), - ) - .toList(growable: false); - } - - int? _resolveDirectRichTextTextOffset( - BuildContext context, - GlobalKey directTextKey, - Offset localPosition, - List runs, - ) { - final blockRenderObject = context.findRenderObject(); - final renderParagraph = _resolveDirectRenderParagraph( - directTextKey, - runs: runs, - fallbackRoot: blockRenderObject, - ); - if (renderParagraph == null || - blockRenderObject is! RenderBox || - !blockRenderObject.hasSize) { - return null; - } - - final paragraphPosition = renderParagraph.globalToLocal( - blockRenderObject.localToGlobal(localPosition), - ); - final textPosition = renderParagraph.getPositionForOffset( - paragraphPosition, - ); - return markdownPretextPlainOffsetForRenderOffset(runs, textPosition.offset); - } - - RenderParagraph? _resolveDirectRenderParagraph( - GlobalKey directTextKey, { - List? runs, - RenderObject? fallbackRoot, - }) { - final renderObject = directTextKey.currentContext?.findRenderObject(); - final expectedRenderText = - runs == null ? null : markdownPretextRenderText(runs); - if (renderObject is RenderParagraph && - renderObject.hasSize && - (expectedRenderText == null || - renderObject.text.toPlainText(includePlaceholders: true) == - expectedRenderText)) { - return renderObject; - } - RenderParagraph? paragraph; - - void visit(RenderObject node) { - if (paragraph != null) { - return; - } - if (node is RenderParagraph && - node.hasSize && - (expectedRenderText == null || - node.text.toPlainText(includePlaceholders: true) == - expectedRenderText)) { - paragraph = node; - return; - } - node.visitChildren(visit); - } - - if (renderObject != null) { - visit(renderObject); - } - if (paragraph == null && - fallbackRoot != null && - fallbackRoot != renderObject) { - visit(fallbackRoot); - } - return paragraph; - } - TextStyle _styleForContext(TextStyle style, TextStyle? textStyleContext) { if (textStyleContext == null) { return style; @@ -1292,10 +827,19 @@ class MarkdownBlockBuilder { ); } + /// The leaf path of a block at [path]: a top-level block's own leaf is + /// at `[0]`. + PathInBlock _leafPath(List path) { + return PathInBlock(path.isEmpty ? const [0] : path); + } + + /// Builds a block nested at [path] in its top-level block, tagging its + /// leaves and slots with the paths of the block's selection structure. Widget _buildNestedBlockContent( BuildContext context, BlockNode block, { TextStyle? paragraphStyle, + List path = const [], }) { final effectiveParagraphStyle = paragraphStyle ?? theme.bodyStyle; final effectiveInlineCodeStyle = _styleForContext( @@ -1317,6 +861,7 @@ class MarkdownBlockBuilder { inlines: heading.inlines, inlineCodeStyle: effectiveInlineCodeStyle, linkStyle: effectiveLinkStyle, + path: _leafPath(path), ), ); case MarkdownBlockKind.paragraph: @@ -1332,33 +877,45 @@ class MarkdownBlockBuilder { inlines: paragraph.inlines, inlineCodeStyle: effectiveInlineCodeStyle, linkStyle: effectiveLinkStyle, + path: _leafPath(path), ); case MarkdownBlockKind.quote: - return _buildQuote(context, block as QuoteBlock); + return _buildQuote(context, block as QuoteBlock, path: path); case MarkdownBlockKind.orderedList: case MarkdownBlockKind.unorderedList: return _buildList( context, block as ListBlock, paragraphStyle: paragraphStyle, + path: path, ); case MarkdownBlockKind.definitionList: - return _buildDefinitionList(context, block as DefinitionListBlock); + return _buildDefinitionList( + context, + block as DefinitionListBlock, + path: path, + ); case MarkdownBlockKind.footnoteList: - return _buildFootnoteList(context, block as FootnoteListBlock); + return _buildFootnoteList( + context, + block as FootnoteListBlock, + path: path, + ); case MarkdownBlockKind.details: return _buildDetails( context, block as DetailsBlock, paragraphStyle: paragraphStyle, + path: path, ); case MarkdownBlockKind.codeBlock: - return _buildCodeBlock(context, block as CodeBlock); + return _buildCodeBlock(context, block as CodeBlock, path: path); case MarkdownBlockKind.table: return _buildTable( context, block as TableBlock, textStyleContext: paragraphStyle, + path: path, ); case MarkdownBlockKind.image: return _buildImage(context, block as ImageBlock); @@ -1371,13 +928,17 @@ class MarkdownBlockBuilder { } } - Widget _buildQuote(BuildContext context, QuoteBlock block) { + Widget _buildQuote( + BuildContext context, + QuoteBlock block, { + required List path, + }) { return MarkdownQuoteBlockView( theme: theme, child: _buildQuoteContent( context, block, - childKeys: keysRegistry.quoteChildKeysFor(block), + path: path, ), ); } @@ -1385,7 +946,7 @@ class MarkdownBlockBuilder { Widget _buildQuoteContent( BuildContext context, QuoteBlock block, { - List? childKeys, + required List path, }) { return DefaultTextStyle.merge( style: theme.quoteStyle, @@ -1395,8 +956,7 @@ class MarkdownBlockBuilder { context, block.children, paragraphStyle: theme.quoteStyle, - blockKeyBuilder: - childKeys == null ? null : (index) => childKeys[index], + pathFor: (index) => [...path, index], ), ), ); @@ -1406,13 +966,14 @@ class MarkdownBlockBuilder { BuildContext context, List blocks, { TextStyle? paragraphStyle, - Key? Function(int index)? blockKeyBuilder, + required List Function(int index) pathFor, }) { return [ for (var index = 0; index < blocks.length; index++) - KeyedSubtree( - key: blockKeyBuilder?.call(index), - child: Padding( + _nestedChildSlot( + blocks[index], + pathFor(index), + Padding( padding: EdgeInsets.only( bottom: index == blocks.length - 1 ? 0 @@ -1426,19 +987,46 @@ class MarkdownBlockBuilder { context, blocks[index], paragraphStyle: paragraphStyle, + path: pathFor(index), ), ), ), ]; } + /// The slot of a child block of a quote, details, or list item. A child + /// drawn as a box, such as an image, is a placeholder leaf spanning the + /// whole slot. + Widget _nestedChildSlot(BlockNode block, List path, Widget child) { + final textLength = _isPlaceholderLeafBlock(block) + ? StructuredBlockSelection.forBlock(block).plainText.length + : null; + return MarkdownSelectionSlot( + path: PathInBlock(path), + role: MarkdownSelectionSlotRole.child, + child: textLength == null + ? child + : MarkdownSelectableLeaf( + path: PathInBlock(path), + geometry: MarkdownPlaceholderLeafGeometry(textLength: textLength), + child: child, + ), + ); + } + + bool _isPlaceholderLeafBlock(BlockNode block) { + return block.kind == MarkdownBlockKind.image || + block.kind == MarkdownBlockKind.thematicBreak || + _isStandaloneLineBreakBlock(block) || + (block.kind == MarkdownBlockKind.codeBlock && codeBlockBuilder != null); + } + Widget _buildList( BuildContext context, ListBlock block, { TextStyle? paragraphStyle, + required List path, }) { - final itemRowKeys = keysRegistry.listItemKeysFor(block); - final itemContentKeys = keysRegistry.listItemContentKeysFor(block); return MarkdownListBlockView( theme: theme, block: block, @@ -1448,34 +1036,53 @@ class MarkdownBlockBuilder { index, item, paragraphStyle: paragraphStyle, + path: path, ), - itemRowKeyBuilder: (index) => itemRowKeys[index], - itemContentKeyBuilder: (index) => itemContentKeys[index], + itemSlotBuilder: _listItemSlotBuilder(path), markerStyle: _styleForContext(theme.bodyStyle, paragraphStyle), bulletBuilder: bulletBuilder, ); } - Widget _buildDefinitionList(BuildContext context, DefinitionListBlock block) { + /// Wraps the row and the content of each list item in the slots the list + /// selection geometry reads. + MarkdownListItemSlotBuilder _listItemSlotBuilder(List path) { + return (index, role, child) => MarkdownSelectionSlot( + path: PathInBlock([...path, index]), + role: role, + child: child, + ); + } + + Widget _buildDefinitionList( + BuildContext context, + DefinitionListBlock block, { + required List path, + }) { final descriptor = descriptorExtractor.buildDefinitionListSelectableDescriptor(block); - return _buildDescriptorTextWidget(descriptor); + return _buildDescriptorTextWidget(descriptor, leafPath: _leafPath(path)); } - Widget _buildFootnoteList(BuildContext context, FootnoteListBlock block) { + Widget _buildFootnoteList( + BuildContext context, + FootnoteListBlock block, { + required List path, + }) { final orderedFootnotes = MarkdownDescriptorExtractor.footnoteListAsOrderedList(block); - final itemRowKeys = keysRegistry.listItemKeysFor(orderedFootnotes); - final itemContentKeys = - keysRegistry.listItemContentKeysFor(orderedFootnotes); return _buildFootnoteListContainer( child: MarkdownListBlockView( theme: theme, block: orderedFootnotes, - itemBuilder: (index, item) => - _buildListItemContent(context, orderedFootnotes, index, item), - itemRowKeyBuilder: (index) => itemRowKeys[index], - itemContentKeyBuilder: (index) => itemContentKeys[index], + itemBuilder: (index, item) => _buildListItemContent( + context, + orderedFootnotes, + index, + item, + path: path, + ), + itemSlotBuilder: _listItemSlotBuilder(path), bulletBuilder: bulletBuilder, ), ); @@ -1484,24 +1091,26 @@ class MarkdownBlockBuilder { Widget _buildDetails( BuildContext context, DetailsBlock block, { - List? childKeys, TextStyle? paragraphStyle, + required List path, }) { - final resolvedKeys = childKeys ?? keysRegistry.detailsChildKeysFor(block); final summaryStyle = (paragraphStyle ?? theme.bodyStyle).copyWith( fontWeight: FontWeight.w600, ); + final summaryPath = PathInBlock([...path, 0]); return MarkdownDetailsBlockView( theme: theme, initiallyExpanded: block.initiallyExpanded, - summary: KeyedSubtree( - key: resolvedKeys.first, + summary: MarkdownSelectionSlot( + path: summaryPath, + role: MarkdownSelectionSlotRole.child, child: _buildTextBlock( style: summaryStyle, inlines: block.summary, inlineCodeStyle: _styleForContext(theme.inlineCodeStyle, paragraphStyle), linkStyle: _linkStyleForContext(paragraphStyle), + path: summaryPath, ), ), content: block.children.isEmpty @@ -1512,31 +1121,35 @@ class MarkdownBlockBuilder { context, block.children, paragraphStyle: paragraphStyle, - blockKeyBuilder: (index) => resolvedKeys[index + 1], + pathFor: (index) => [...path, index + 1], ), ), ); } + /// The content of list item [itemIndex] of the list at [path]. Widget _buildListItemContent( BuildContext context, ListBlock block, int itemIndex, ListItemNode item, { TextStyle? paragraphStyle, + required List path, }) { if (item.children.isEmpty) { return const SizedBox.shrink(); } - final childKeys = keysRegistry.listItemChildKeysFor(block, itemIndex); + List childPath(int index) => [...path, itemIndex, index + 1]; if (item.children.length == 1) { - return KeyedSubtree( - key: childKeys.first, - child: _buildNestedBlockContent( + return _nestedChildSlot( + item.children.first, + childPath(0), + _buildNestedBlockContent( context, item.children.first, paragraphStyle: paragraphStyle, + path: childPath(0), ), ); } @@ -1546,7 +1159,7 @@ class MarkdownBlockBuilder { context, item.children, paragraphStyle: paragraphStyle, - blockKeyBuilder: (index) => childKeys[index], + pathFor: childPath, ), ); } @@ -1557,18 +1170,20 @@ class MarkdownBlockBuilder { TextAlign textAlign = TextAlign.start, TextStyle? inlineCodeStyle, TextStyle? linkStyle, + required PathInBlock path, }) { - final runs = inlineBuilder.buildPretextRuns( - style, - inlines, - inlineCodeStyle: inlineCodeStyle, - linkStyle: linkStyle, - ); - return MarkdownPretextTextBlock.rich( - runs: runs, - fallbackStyle: style, - textAlign: textAlign, - preferDirectRichText: markdownPretextCanUseDirectRichTextGeometry(runs), + return _textLeaf( + path, + MarkdownTextLeafGeometry( + runs: inlineBuilder.buildPretextRuns( + style, + inlines, + inlineCodeStyle: inlineCodeStyle, + linkStyle: linkStyle, + ), + fallbackStyle: style, + textAlign: textAlign, + ), ); } @@ -1626,7 +1241,11 @@ class MarkdownBlockBuilder { ); } - Widget _buildCodeBlock(BuildContext context, CodeBlock block) { + Widget _buildCodeBlock( + BuildContext context, + CodeBlock block, { + List path = const [], + }) { if (codeBlockBuilder != null) { return codeBlockBuilder!(context, block.code, block.language, theme); } @@ -1643,11 +1262,20 @@ class MarkdownBlockBuilder { block.id, () => GlobalKey(debugLabel: 'code-viewport-${block.id}'), ); - return _buildDecoratedCodeBlock( - block, - codeSpan: codeHighlightPresentation.span, - scrollController: scrollController, - viewportKey: viewportKey, + return MarkdownSelectableLeaf( + path: _leafPath(path), + geometry: MarkdownTextLeafGeometry( + runs: codeHighlightPresentation.runs, + fallbackStyle: theme.codeBlockStyle, + direct: true, + viewportKey: viewportKey, + ), + child: _buildDecoratedCodeBlock( + block, + codeSpan: codeHighlightPresentation.span, + scrollController: scrollController, + viewportKey: viewportKey, + ), ); } @@ -1655,13 +1283,11 @@ class MarkdownBlockBuilder { CodeBlock block, { required InlineSpan codeSpan, ScrollController? scrollController, - GlobalKey? directTextKey, GlobalKey? viewportKey, }) { return MarkdownCodeBlockView( theme: theme, codeSpan: codeSpan, - directTextKey: directTextKey, viewportKey: viewportKey, scrollController: scrollController ?? keysRegistry.codeBlockScrollControllers @@ -1672,21 +1298,6 @@ class MarkdownBlockBuilder { ); } - Rect? _resolveChildViewportRect(BuildContext context, GlobalKey viewportKey) { - final blockRenderObject = context.findRenderObject(); - final viewportRenderObject = viewportKey.currentContext?.findRenderObject(); - if (blockRenderObject is! RenderBox || - !blockRenderObject.hasSize || - viewportRenderObject is! RenderBox || - !viewportRenderObject.hasSize) { - return null; - } - final origin = blockRenderObject.globalToLocal( - viewportRenderObject.localToGlobal(Offset.zero), - ); - return origin & viewportRenderObject.size; - } - Listenable? _nestedScrollableRepaintListenableForListBlock(ListBlock block) { return _nestedScrollableRepaintListenableForBlocks( block.items.expand((item) => item.children), @@ -1765,13 +1376,10 @@ class MarkdownBlockBuilder { BuildContext context, TableBlock block, { TextStyle? textStyleContext, - Key? Function(int rowIndex, int columnIndex)? cellKeyBuilder, - GlobalKey? Function(int rowIndex, int columnIndex)? cellTextKeyBuilder, ScrollController? scrollController, GlobalKey? viewportKey, + List path = const [], }) { - final resolvedCellKeys = keysRegistry.tableCellKeysFor(block); - final resolvedCellTextKeys = keysRegistry.tableCellTextKeysFor(block); final resolvedViewportKey = viewportKey ?? keysRegistry.tableViewportKeys.putIfAbsent( block.id, @@ -1781,7 +1389,41 @@ class MarkdownBlockBuilder { theme: theme, block: block, layoutPlan: tableLayoutPlanCache.planFor(block), - textWidgetBuilder: _buildInlineTextWidget, + textWidgetBuilder: ( + context, + style, + inlines, + textAlign, { + required int rowIndex, + required int columnIndex, + bool alignInlineMathToBaseline = true, + TextStyle? inlineCodeStyle, + TextStyle? linkStyle, + }) { + final runs = inlineBuilder.buildPretextRuns( + style, + inlines, + alignInlineMathToBaseline: alignInlineMathToBaseline, + inlineCodeStyle: inlineCodeStyle, + linkStyle: linkStyle, + ); + return MarkdownSelectableLeaf( + path: PathInBlock([...path, rowIndex, columnIndex]), + geometry: MarkdownTextLeafGeometry( + runs: runs, + fallbackStyle: style, + textAlign: textAlign, + direct: true, + viewportKey: resolvedViewportKey, + ), + child: MarkdownPretextTextBlock.rich( + runs: runs, + fallbackStyle: style, + textAlign: textAlign, + intrinsicWidthSafe: true, + ), + ); + }, bodyStyle: _styleForContext(theme.bodyStyle, textStyleContext), tableHeaderStyle: _styleForContext( theme.tableHeaderStyle, @@ -1792,11 +1434,6 @@ class MarkdownBlockBuilder { textStyleContext, ), linkStyle: _linkStyleForContext(textStyleContext), - cellKeyBuilder: cellKeyBuilder ?? - (rowIndex, columnIndex) => resolvedCellKeys[rowIndex][columnIndex], - cellTextKeyBuilder: cellTextKeyBuilder ?? - (rowIndex, columnIndex) => - resolvedCellTextKeys[rowIndex][columnIndex], viewportKey: resolvedViewportKey, scrollController: scrollController ?? keysRegistry.tableScrollControllers @@ -1804,31 +1441,6 @@ class MarkdownBlockBuilder { ); } - Widget _buildInlineTextWidget( - BuildContext context, - TextStyle style, - List inlines, - TextAlign textAlign, { - GlobalKey? directTextKey, - bool alignInlineMathToBaseline = true, - TextStyle? inlineCodeStyle, - TextStyle? linkStyle, - }) { - return MarkdownPretextTextBlock.rich( - runs: inlineBuilder.buildPretextRuns( - style, - inlines, - alignInlineMathToBaseline: alignInlineMathToBaseline, - inlineCodeStyle: inlineCodeStyle, - linkStyle: linkStyle, - ), - fallbackStyle: style, - textAlign: textAlign, - intrinsicWidthSafe: true, - directTextKey: directTextKey, - ); - } - Widget _buildImage(BuildContext context, ImageBlock block) { if (imageBuilder != null) { return _wrapLinkedImage( @@ -2016,7 +1628,7 @@ class MarkdownBlockBuilder { Widget _buildDescriptorTextWidget( SelectableTextDescriptor descriptor, { bool intrinsicWidthSafe = false, - GlobalKey? directTextKey, + PathInBlock? leafPath, }) { final pretext = descriptor.pretext; if (pretext == null) { @@ -2025,10 +1637,19 @@ class MarkdownBlockBuilder { style: pretext?.fallbackStyle, ); } + if (leafPath != null) { + return _textLeaf( + leafPath, + MarkdownTextLeafGeometry( + runs: pretext.runs, + fallbackStyle: pretext.fallbackStyle, + textAlign: pretext.textAlign, + ), + ); + } return MarkdownPretextTextBlock.rich( runs: pretext.runs, fallbackStyle: pretext.fallbackStyle, - directTextKey: directTextKey, textAlign: pretext.textAlign, intrinsicWidthSafe: intrinsicWidthSafe, preferDirectRichText: @@ -2036,169 +1657,6 @@ class MarkdownBlockBuilder { ); } - List _mergeDirectTextSelectionBoxes(List boxes) { - if (boxes.length < 2) { - return boxes; - } - - const lineTolerance = 2.0; - const gapTolerance = double.infinity; - - final sorted = boxes.toList(growable: false) - ..sort((a, b) { - final topCompare = a.top.compareTo(b.top); - if (topCompare != 0) { - return topCompare; - } - return a.left.compareTo(b.left); - }); - - final merged = []; - var current = sorted.first; - for (final next in sorted.skip(1)) { - final sameLine = (next.top - current.top).abs() <= lineTolerance && - (next.bottom - current.bottom).abs() <= lineTolerance; - final closeEnough = next.left <= current.right + gapTolerance; - if (sameLine && closeEnough) { - current = TextBox.fromLTRBD( - math.min(current.left, next.left), - math.min(current.top, next.top), - math.max(current.right, next.right), - math.max(current.bottom, next.bottom), - current.direction, - ); - continue; - } - merged.add(current); - current = next; - } - merged.add(current); - return merged; - } - - /// Compute each visual line's vertical extent by grouping the full - /// paragraph's boxes (all characters selected) by vertical overlap. - List<({double top, double bottom})> _computeParagraphLineExtents( - RenderParagraph renderParagraph, - ) { - final fullText = - renderParagraph.text.toPlainText(includePlaceholders: true); - if (fullText.isEmpty) { - return const <({double top, double bottom})>[]; - } - if (fullText.contains('\uFFFC')) { - final allBoxes = renderParagraph.getBoxesForSelection( - TextSelection(baseOffset: 0, extentOffset: fullText.length), - ); - return _resolveContiguousLinesFromBoxes(allBoxes); - } - - final textPainter = TextPainter( - text: renderParagraph.text, - textAlign: renderParagraph.textAlign, - textDirection: renderParagraph.textDirection, - textScaler: renderParagraph.textScaler, - maxLines: null, - )..layout(maxWidth: renderParagraph.size.width); - final lines = textPainter.computeLineMetrics(); - if (lines.isEmpty) { - return const <({double top, double bottom})>[]; - } - - // Eliminate vertical gaps between lines by expanding their top/bottom bounds - // to meet halfway. TextPainter limits boxes to glyph metrics, which omits - // the line-height multiplier spacing and leaves ugly horizontal gaps in the - // selection background. - final contiguousLines = <({double top, double bottom})>[]; - for (var i = 0; i < lines.length; i++) { - final current = lines[i]; - final previous = i > 0 ? lines[i - 1] : null; - final next = i < lines.length - 1 ? lines[i + 1] : null; - final currentTop = current.baseline - current.ascent; - final currentBottom = currentTop + current.height; - - final resolvedTop = previous != null - ? (previous.baseline - - previous.ascent + - previous.height + - currentTop) / - 2.0 - : currentTop; - final resolvedBottom = next != null - ? (currentBottom + next.baseline - next.ascent) / 2.0 - : currentBottom; - - contiguousLines.add((top: resolvedTop, bottom: resolvedBottom)); - } - return contiguousLines; - } - - List<({double top, double bottom})> _resolveContiguousLinesFromBoxes( - List allBoxes, - ) { - if (allBoxes.isEmpty) { - return const <({double top, double bottom})>[]; - } - final sorted = allBoxes.toList(growable: false) - ..sort((a, b) => a.top.compareTo(b.top)); - - final lines = <({double top, double bottom})>[]; - var lineTop = sorted.first.top; - var lineBottom = sorted.first.bottom; - for (final box in sorted.skip(1)) { - if (box.top < lineBottom - 1.0) { - lineTop = math.min(lineTop, box.top); - lineBottom = math.max(lineBottom, box.bottom); - } else { - lines.add((top: lineTop, bottom: lineBottom)); - lineTop = box.top; - lineBottom = box.bottom; - } - } - lines.add((top: lineTop, bottom: lineBottom)); - - final contiguousLines = <({double top, double bottom})>[]; - for (var i = 0; i < lines.length; i++) { - final current = lines[i]; - final previous = i > 0 ? lines[i - 1] : null; - final next = i < lines.length - 1 ? lines[i + 1] : null; - - final resolvedTop = previous != null - ? (previous.bottom + current.top) / 2.0 - : current.top; - final resolvedBottom = - next != null ? (current.bottom + next.top) / 2.0 : current.bottom; - - contiguousLines.add((top: resolvedTop, bottom: resolvedBottom)); - } - return contiguousLines; - } - - List _normalizeBoxesToLineExtents( - List boxes, - List<({double top, double bottom})> lineExtents, - ) { - if (lineExtents.isEmpty) { - return boxes; - } - return boxes.map((box) { - for (final extent in lineExtents) { - final overlapTop = math.max(box.top, extent.top); - final overlapBottom = math.min(box.bottom, extent.bottom); - if (overlapBottom - overlapTop > 0.5) { - return TextBox.fromLTRBD( - box.left, - extent.top, - box.right, - extent.bottom, - box.direction, - ); - } - } - return box; - }).toList(growable: false); - } - SelectableBlockSpec _buildSelectableDescriptorTextSpec({ required SelectableTextDescriptor descriptor, StructuredBlockSelection? selectionStructure, @@ -2213,17 +1671,15 @@ class MarkdownBlockBuilder { selectionStructure: selectionStructure, ); } - final directTextKey = _createDirectTextKeyIfNeeded(pretext.runs); - return _buildPretextTextSpec( - child: _buildDescriptorTextWidget( - descriptor, - directTextKey: directTextKey, - ), - plainText: descriptor.plainText, + final geometry = MarkdownTextLeafGeometry( runs: pretext.runs, fallbackStyle: pretext.fallbackStyle, - directTextKey: directTextKey, textAlign: pretext.textAlign, + ); + return _buildTextLeafSpec( + child: _textLeaf(_topLevelLeafPath, geometry), + plainText: descriptor.plainText, + geometry: geometry, selectionStructure: selectionStructure, ); } diff --git a/packages/mixin_markdown_widget/lib/src/render/markdown_block_widgets.dart b/packages/mixin_markdown_widget/lib/src/render/markdown_block_widgets.dart index fae639c4..140a3085 100644 --- a/packages/mixin_markdown_widget/lib/src/render/markdown_block_widgets.dart +++ b/packages/mixin_markdown_widget/lib/src/render/markdown_block_widgets.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import '../core/document.dart'; import '../widgets/markdown_theme.dart'; import '../widgets/markdown_types.dart'; +import 'selection/markdown_selectable_leaf.dart'; double markdownListMarkerWidth(ListBlock block, int index) { final item = block.items[index]; @@ -35,12 +36,21 @@ typedef MarkdownInlineTextWidgetBuilder = Widget Function( TextStyle style, List inlines, TextAlign textAlign, { - GlobalKey? directTextKey, + required int rowIndex, + required int columnIndex, bool alignInlineMathToBaseline, TextStyle? inlineCodeStyle, TextStyle? linkStyle, }); +/// Wraps the row ([MarkdownSelectionSlotRole.listRow]) or the content +/// ([MarkdownSelectionSlotRole.listContent]) of list item [index]. +typedef MarkdownListItemSlotBuilder = Widget Function( + int index, + MarkdownSelectionSlotRole role, + Widget child, +); + typedef MarkdownTableWidgetBuilder = Widget Function( Map? columnWidths, TableColumnWidth defaultColumnWidth, @@ -428,8 +438,7 @@ class MarkdownListBlockView extends StatelessWidget { required this.theme, required this.block, required this.itemBuilder, - this.itemRowKeyBuilder, - this.itemContentKeyBuilder, + this.itemSlotBuilder, this.markerStyle, this.bulletBuilder, }); @@ -437,8 +446,7 @@ class MarkdownListBlockView extends StatelessWidget { final MarkdownThemeData theme; final ListBlock block; final Widget Function(int index, ListItemNode item) itemBuilder; - final Key? Function(int index)? itemRowKeyBuilder; - final Key? Function(int index)? itemContentKeyBuilder; + final MarkdownListItemSlotBuilder? itemSlotBuilder; final TextStyle? markerStyle; final MarkdownBulletBuilder? bulletBuilder; @@ -452,9 +460,13 @@ class MarkdownListBlockView extends StatelessWidget { builder: (context) { final markerWidth = markdownListMarkerWidth(block, index); final markerGap = markdownListMarkerGap(block, index); - return KeyedSubtree( - key: itemRowKeyBuilder?.call(index), - child: Padding( + Widget slot(MarkdownSelectionSlotRole role, Widget child) { + return itemSlotBuilder?.call(index, role, child) ?? child; + } + + return slot( + MarkdownSelectionSlotRole.listRow, + Padding( padding: EdgeInsets.only( bottom: index == block.items.length - 1 ? 0 @@ -472,9 +484,9 @@ class MarkdownListBlockView extends StatelessWidget { ), SizedBox(width: markerGap), Expanded( - child: KeyedSubtree( - key: itemContentKeyBuilder?.call(index), - child: itemBuilder(index, block.items[index]), + child: slot( + MarkdownSelectionSlotRole.listContent, + itemBuilder(index, block.items[index]), ), ), ], @@ -535,7 +547,6 @@ class MarkdownCodeBlockView extends StatelessWidget { required this.codeSpan, required this.onCopyCode, required this.scrollController, - this.directTextKey, this.viewportKey, }); @@ -543,7 +554,6 @@ class MarkdownCodeBlockView extends StatelessWidget { final InlineSpan codeSpan; final VoidCallback onCopyCode; final ScrollController scrollController; - final GlobalKey? directTextKey; final GlobalKey? viewportKey; @override @@ -575,7 +585,6 @@ class MarkdownCodeBlockView extends StatelessWidget { controller: scrollController, scrollDirection: Axis.horizontal, child: Text.rich( - key: directTextKey, codeSpan, style: theme.codeBlockStyle, softWrap: false, @@ -621,8 +630,6 @@ class MarkdownTableBlockView extends StatelessWidget { this.tableHeaderStyle, this.inlineCodeStyle, this.linkStyle, - this.cellKeyBuilder, - this.cellTextKeyBuilder, this.scrollController, this.viewportKey, }); @@ -635,8 +642,6 @@ class MarkdownTableBlockView extends StatelessWidget { final TextStyle? tableHeaderStyle; final TextStyle? inlineCodeStyle; final TextStyle? linkStyle; - final Key? Function(int rowIndex, int columnIndex)? cellKeyBuilder; - final GlobalKey? Function(int rowIndex, int columnIndex)? cellTextKeyBuilder; final ScrollController? scrollController; final GlobalKey? viewportKey; @@ -682,7 +687,6 @@ class MarkdownTableBlockView extends StatelessWidget { alignment: index < block.alignments.length ? block.alignments[index] : MarkdownTableColumnAlignment.none, - directTextKey: cellTextKeyBuilder?.call(rowIndex, index), ), ], ), @@ -697,7 +701,6 @@ class MarkdownTableBlockView extends StatelessWidget { required int rowIndex, required int cellIndex, required MarkdownTableColumnAlignment alignment, - GlobalKey? directTextKey, }) { final cell = cellIndex < row.cells.length ? row.cells[cellIndex] @@ -705,7 +708,7 @@ class MarkdownTableBlockView extends StatelessWidget { final textStyle = row.isHeader ? tableHeaderStyle ?? theme.tableHeaderStyle : bodyStyle ?? theme.bodyStyle; - Widget child = Padding( + return Padding( padding: theme.tableCellPadding.resolve(Directionality.of(context)), child: Align( alignment: _alignmentFor(alignment), @@ -714,20 +717,14 @@ class MarkdownTableBlockView extends StatelessWidget { textStyle, cell.inlines, _textAlignFor(alignment), - directTextKey: directTextKey, + rowIndex: rowIndex, + columnIndex: cellIndex, alignInlineMathToBaseline: false, inlineCodeStyle: inlineCodeStyle, linkStyle: linkStyle, ), ), ); - if (cellKeyBuilder != null) { - child = KeyedSubtree( - key: cellKeyBuilder!(rowIndex, cellIndex), - child: child, - ); - } - return child; } Alignment _alignmentFor(MarkdownTableColumnAlignment alignment) { 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 95021097..8062ad42 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 @@ -16,7 +16,7 @@ import 'builder/markdown_block_builder.dart'; import 'builder/markdown_inline_builder.dart'; import 'selection/markdown_descriptor_extractor.dart'; import 'selection/markdown_selection_gesture_detector.dart'; -import 'selection/markdown_selection_resolver.dart'; +import 'selection/markdown_block_keys_registry.dart'; import 'shortcuts/markdown_shortcuts_scope.dart'; class MarkdownDocumentView extends StatefulWidget { @@ -447,13 +447,6 @@ class _MarkdownDocumentViewState extends State { cache: _descriptorCache, ); - final selectionResolver = MarkdownSelectionResolver( - theme: widget.theme, - extractor: descriptorExtractor, - keysRegistry: _keysRegistry, - codeSyntaxHighlighter: _codeSyntaxHighlighter, - ); - _blockBuilder = MarkdownBlockBuilder( theme: widget.theme, selectionController: widget.selectionController, @@ -461,7 +454,6 @@ class _MarkdownDocumentViewState extends State { isSelectable: widget.selectable, keysRegistry: _keysRegistry, descriptorExtractor: descriptorExtractor, - selectionResolver: selectionResolver, inlineBuilder: inlineBuilder, codeSyntaxHighlighter: _codeSyntaxHighlighter, plainTextSerializer: _plainTextSerializer, diff --git a/packages/mixin_markdown_widget/lib/src/render/pretext_text_block.dart b/packages/mixin_markdown_widget/lib/src/render/pretext_text_block.dart index 978e8db8..14dd7fe3 100644 --- a/packages/mixin_markdown_widget/lib/src/render/pretext_text_block.dart +++ b/packages/mixin_markdown_widget/lib/src/render/pretext_text_block.dart @@ -83,7 +83,6 @@ class MarkdownPretextTextBlock extends StatelessWidget { required this.style, this.textAlign = TextAlign.start, this.intrinsicWidthSafe = false, - this.directTextKey, this.preferDirectRichText = false, }) : runs = null, fallbackStyle = style; @@ -94,7 +93,6 @@ class MarkdownPretextTextBlock extends StatelessWidget { required this.fallbackStyle, this.textAlign = TextAlign.start, this.intrinsicWidthSafe = false, - this.directTextKey, this.preferDirectRichText = false, }) : text = '', style = fallbackStyle; @@ -105,7 +103,6 @@ class MarkdownPretextTextBlock extends StatelessWidget { final TextStyle fallbackStyle; final TextAlign textAlign; final bool intrinsicWidthSafe; - final GlobalKey? directTextKey; final bool preferDirectRichText; @override @@ -125,7 +122,6 @@ class MarkdownPretextTextBlock extends StatelessWidget { runs: effectiveRuns, fallbackStyle: fallbackStyle, ), - key: directTextKey, style: fallbackStyle, textAlign: textAlign, textScaler: textScaler, @@ -141,7 +137,6 @@ class MarkdownPretextTextBlock extends StatelessWidget { runs: effectiveRuns, fallbackStyle: fallbackStyle, ), - key: directTextKey, style: fallbackStyle, textAlign: textAlign, textScaler: textScaler, 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 df5366bc..532c9b3e 100644 --- a/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart +++ b/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../core/document.dart'; import '../selection/structured_block_selection.dart'; +import 'selection/markdown_selectable_leaf.dart'; enum SelectableBlockHitTestBehavior { text, @@ -117,6 +118,21 @@ abstract interface class MarkdownSelectableBlockHandle { class SelectableMarkdownBlockState extends State implements MarkdownSelectableBlockHandle { VoidCallback? _repaintListener; + final MarkdownSelectionLeafRegistry _leafRegistry = + MarkdownSelectionLeafRegistry(); + + /// The leaves and slots of the selectable block at or above [context]. + static MarkdownSelectionLeafRegistry? leafRegistryOf(BuildContext context) { + if (context is StatefulElement) { + final state = context.state; + if (state is SelectableMarkdownBlockState) { + return state._leafRegistry; + } + } + return context + .findAncestorStateOfType() + ?._leafRegistry; + } @override void initState() { @@ -217,9 +233,18 @@ class SelectableMarkdownBlockState extends State markdownWordRangeAt(text, offset, upstream: upstream); final structure = widget.spec.selectionStructure; if (structure != null) { + // The end of a leaf is also the start of the next one, and hit testing + // resolves it downstream. A pointer over the character before it + // belongs to the leaf that ends there. final range = structure.wordRangeForPosition( blockIndex: widget.blockIndex, - position: position, + position: upstream + ? structure.positionForDisplayedOffset( + blockIndex: widget.blockIndex, + displayedOffset: position.textOffset, + affinity: StructuredSelectionAffinity.upstream, + ) + : position, wordRangeAt: wordRangeAt, ); if (range != null) { @@ -407,7 +432,10 @@ class SelectableMarkdownBlockState extends State SelectableBlockSelectionPaintOrder.aboveChild ? selectionPainter : null, - child: widget.spec.child, + child: MarkdownSelectionLeafScope( + registry: _leafRegistry, + child: widget.spec.child, + ), ); if (widget.spec.highlightBorderRadius == null) { return paintedChild; diff --git a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_block_keys_registry.dart b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_block_keys_registry.dart new file mode 100644 index 00000000..bbb45d85 --- /dev/null +++ b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_block_keys_registry.dart @@ -0,0 +1,38 @@ +import 'package:flutter/widgets.dart'; + +/// Per-block state of a rendered document that outlives a single build: the +/// keys of top-level selectable blocks, the scroll controllers and viewports +/// of horizontally scrolling code blocks and tables, and image error +/// notifiers. Entries are keyed by block id. +class MarkdownBlockKeysRegistry { + final Map>> blockKeys = {}; + final Map tableScrollControllers = {}; + final Map tableViewportKeys = {}; + final Map codeBlockScrollControllers = {}; + final Map codeBlockViewportKeys = {}; + final Map> imageErrorNotifiers = {}; + + void cleanupKeys(Set validIds) { + blockKeys.removeWhere((key, _) => !validIds.contains(key)); + final staleCodeBlockIds = codeBlockScrollControllers.keys + .where((key) => !validIds.contains(key)) + .toList(growable: false); + for (final blockId in staleCodeBlockIds) { + codeBlockScrollControllers.remove(blockId)?.dispose(); + } + codeBlockViewportKeys.removeWhere((key, _) => !validIds.contains(key)); + final staleTableIds = tableScrollControllers.keys + .where((key) => !validIds.contains(key)) + .toList(growable: false); + for (final blockId in staleTableIds) { + tableScrollControllers.remove(blockId)?.dispose(); + } + tableViewportKeys.removeWhere((key, _) => !validIds.contains(key)); + final staleImageNotifierIds = imageErrorNotifiers.keys + .where((key) => !validIds.contains(key)) + .toList(growable: false); + for (final blockId in staleImageNotifierIds) { + imageErrorNotifiers.remove(blockId)?.dispose(); + } + } +} diff --git a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_descriptor_extractor.dart b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_descriptor_extractor.dart index 49e7e407..c913f513 100644 --- a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_descriptor_extractor.dart +++ b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_descriptor_extractor.dart @@ -46,62 +46,9 @@ class _PrefixedPretextRuns { final List runs; } -@immutable -class IndexedListSelectionDescriptor { - const IndexedListSelectionDescriptor({ - required this.itemIndex, - required this.startOffset, - required this.prefixLength, - required this.contentIndentLevel, - required this.descriptor, - required this.contentDescriptor, - }); - - final int itemIndex; - final int startOffset; - final int prefixLength; - final int contentIndentLevel; - final SelectableTextDescriptor descriptor; - final SelectableTextDescriptor contentDescriptor; -} - -@immutable -class IndexedBlockDescriptor { - const IndexedBlockDescriptor({ - required this.childIndex, - required this.block, - required this.startOffset, - required this.indentLevel, - required this.descriptor, - }); - - final int childIndex; - final BlockNode block; - final int startOffset; - final int indentLevel; - final SelectableTextDescriptor descriptor; -} - -@immutable -class ResolvedIndexedBlockEntry { - const ResolvedIndexedBlockEntry({ - required this.entry, - required this.renderObject, - required this.rect, - }); - - final IndexedBlockDescriptor entry; - final RenderBox renderObject; - final Rect rect; -} - class MarkdownDescriptorCache { final Map _blockDescriptors = {}; - final Map _indexedBlockDescriptors = - {}; - final Map _indexedListDescriptors = - {}; _CachedBlockDescriptor? _blockDescriptor(String key) => _blockDescriptors[key]; @@ -121,64 +68,10 @@ class MarkdownDescriptorCache { ); } - _CachedIndexedBlockDescriptors? _indexedBlockDescriptorsFor(String key) => - _indexedBlockDescriptors[key]; - - void _storeIndexedBlockDescriptors( - String key, - String ownerId, - List blocks, - int indentLevel, - String separator, - List descriptors, - ) { - _indexedBlockDescriptors[key] = _CachedIndexedBlockDescriptors( - ownerId: ownerId, - blocks: blocks, - indentLevel: indentLevel, - separator: separator, - descriptors: descriptors, - ); - } - - _CachedIndexedListDescriptors? _indexedListDescriptorsFor(String key) => - _indexedListDescriptors[key]; - - void _storeIndexedListDescriptors( - String key, - ListBlock block, - int indentLevel, - List descriptors, - ) { - _indexedListDescriptors[key] = _CachedIndexedListDescriptors( - block: block, - indentLevel: indentLevel, - descriptors: descriptors, - ); - } - void cleanup(Set validIds) { _blockDescriptors.removeWhere( (_, entry) => !validIds.contains(entry.blockId), ); - _indexedBlockDescriptors.removeWhere( - (_, entry) => !_ownerIdIsValid(entry.ownerId, validIds), - ); - _indexedListDescriptors.removeWhere( - (_, entry) => !validIds.contains(entry.block.id), - ); - } - - bool _ownerIdIsValid(String ownerId, Set validIds) { - for (final part in ownerId.split('|')) { - if (part.isEmpty || part == 'empty') { - continue; - } - if (!validIds.contains(part)) { - return false; - } - } - return true; } } @@ -196,34 +89,6 @@ class _CachedBlockDescriptor { final SelectableTextDescriptor descriptor; } -class _CachedIndexedBlockDescriptors { - const _CachedIndexedBlockDescriptors({ - required this.ownerId, - required this.blocks, - required this.indentLevel, - required this.separator, - required this.descriptors, - }); - - final String ownerId; - final List blocks; - final int indentLevel; - final String separator; - final List descriptors; -} - -class _CachedIndexedListDescriptors { - const _CachedIndexedListDescriptors({ - required this.block, - required this.indentLevel, - required this.descriptors, - }); - - final ListBlock block; - final int indentLevel; - final List descriptors; -} - class MarkdownDescriptorExtractor { MarkdownDescriptorExtractor({ required this.theme, @@ -363,11 +228,17 @@ class MarkdownDescriptorExtractor { int indentLevel = 0, TextStyle? paragraphStyle, }) { - final itemDescriptors = buildIndexedListSelectionDescriptors( - block, - indentLevel: indentLevel, - paragraphStyle: paragraphStyle, - ).map((entry) => entry.descriptor).toList(growable: false); + final itemDescriptors = [ + for (var index = 0; index < block.items.length; index++) + if (_buildListItemDescriptor( + block, + index, + indentLevel: indentLevel, + paragraphStyle: paragraphStyle, + ) + case final descriptor?) + descriptor, + ]; return joinSelectableTextDescriptors( itemDescriptors, separator: '\n', @@ -435,156 +306,21 @@ class MarkdownDescriptorExtractor { SelectableTextDescriptor buildDetailsSelectableDescriptor( DetailsBlock block, ) { - final descriptors = buildIndexedBlockDescriptors( - detailsSelectionBlocks(block), - separator: '\n\n', - ).map((entry) => entry.descriptor).toList(growable: false); return joinSelectableTextDescriptors( - descriptors, + [ + for (final child in detailsSelectionBlocks(block)) + buildSelectableDescriptorForBlock(child), + ], separator: '\n\n', separatorStyle: theme.bodyStyle, ); } - List buildIndexedBlockDescriptors( - List blocks, { - int indentLevel = 0, - required String separator, - String? cacheOwnerId, - TextStyle? paragraphStyle, - }) { - if (cacheOwnerId != null && paragraphStyle == null) { - final cacheKey = '$cacheOwnerId:$indentLevel:$separator'; - final cached = cache._indexedBlockDescriptorsFor(cacheKey); - if (cached != null && - cached.ownerId == cacheOwnerId && - cached.indentLevel == indentLevel && - cached.separator == separator && - _sameBlockList(cached.blocks, blocks)) { - return cached.descriptors; - } - - final descriptors = _buildIndexedBlockDescriptors( - blocks, - indentLevel: indentLevel, - separator: separator, - ); - cache._storeIndexedBlockDescriptors( - cacheKey, - cacheOwnerId, - blocks, - indentLevel, - separator, - descriptors, - ); - return descriptors; - } - - return _buildIndexedBlockDescriptors( - blocks, - indentLevel: indentLevel, - separator: separator, - paragraphStyle: paragraphStyle, - ); - } - - List _buildIndexedBlockDescriptors( - List blocks, { - int indentLevel = 0, - required String separator, - TextStyle? paragraphStyle, - }) { - final entries = []; - var offset = 0; - for (var index = 0; index < blocks.length; index++) { - final descriptor = buildSelectableDescriptorForBlock( - blocks[index], - indentLevel: indentLevel, - paragraphStyle: paragraphStyle, - ); - if (descriptor.isEmpty) { - continue; - } - if (entries.isNotEmpty) { - offset += separator.length; - } - entries.add( - IndexedBlockDescriptor( - childIndex: index, - block: blocks[index], - startOffset: offset, - indentLevel: indentLevel, - descriptor: descriptor, - ), - ); - offset += descriptor.plainText.length; - } - return entries; - } - - List buildIndexedListSelectionDescriptors( - ListBlock block, { - int indentLevel = 0, - TextStyle? paragraphStyle, - }) { - if (paragraphStyle != null) { - return _buildIndexedListSelectionDescriptors( - block, - indentLevel: indentLevel, - paragraphStyle: paragraphStyle, - ); - } - - final cacheKey = '${block.id}:$indentLevel'; - final cached = cache._indexedListDescriptorsFor(cacheKey); - if (cached != null && - identical(cached.block, block) && - cached.indentLevel == indentLevel) { - return cached.descriptors; - } - - final entries = _buildIndexedListSelectionDescriptors( - block, - indentLevel: indentLevel, - ); - cache._storeIndexedListDescriptors(cacheKey, block, indentLevel, entries); - return entries; - } - - List _buildIndexedListSelectionDescriptors( - ListBlock block, { - int indentLevel = 0, - TextStyle? paragraphStyle, - }) { - final entries = []; - var offset = 0; - for (var index = 0; index < block.items.length; index++) { - if (entries.isNotEmpty) { - offset += 1; - } - final entry = _buildIndexedListSelectionDescriptor( - block, - index, - startOffset: offset, - indentLevel: indentLevel, - paragraphStyle: paragraphStyle, - ); - if (entry == null) { - if (entries.isNotEmpty) { - offset -= 1; - } - continue; - } - entries.add(entry); - offset += entry.descriptor.plainText.length; - } - return entries; - } - - IndexedListSelectionDescriptor? _buildIndexedListSelectionDescriptor( + /// The text of list item [index]: its marker followed by its content, or + /// null for an empty item without a task checkbox. + SelectableTextDescriptor? _buildListItemDescriptor( ListBlock block, int index, { - required int startOffset, int indentLevel = 0, TextStyle? paragraphStyle, }) { @@ -603,31 +339,14 @@ class MarkdownDescriptorExtractor { return null; } if (contentDescriptor.isEmpty) { - final prefixOnly = prefix.trimRight(); - return IndexedListSelectionDescriptor( - itemIndex: index, - startOffset: startOffset, - prefixLength: prefixOnly.length, - contentIndentLevel: indentLevel + 1, - descriptor: plainTextDescriptor(prefixOnly, effectiveParagraphStyle), - contentDescriptor: plainTextDescriptor('', effectiveParagraphStyle), - ); + return plainTextDescriptor(prefix.trimRight(), effectiveParagraphStyle); } - final continuationPrefix = ' ' * prefix.length; - final descriptor = prefixSelectableTextDescriptor( + return prefixSelectableTextDescriptor( contentDescriptor, firstPrefix: prefix, - continuationPrefix: continuationPrefix, + continuationPrefix: ' ' * prefix.length, style: effectiveParagraphStyle, ); - return IndexedListSelectionDescriptor( - itemIndex: index, - startOffset: startOffset, - prefixLength: prefix.length, - contentIndentLevel: indentLevel + 1, - descriptor: descriptor, - contentDescriptor: contentDescriptor, - ); } SelectableTextDescriptor buildListItemSelectableDescriptor( @@ -702,91 +421,6 @@ class MarkdownDescriptorExtractor { ); } - DocumentRange? resolveSelectionUnitRange( - DocumentPosition position, - BlockNode block, { - int baseOffset = 0, - int indentLevel = 0, - }) { - switch (block.kind) { - case MarkdownBlockKind.quote: - final quoteBlock = block as QuoteBlock; - return _resolveQuoteBlockSelectionRange( - position, - quoteBlock, - indexedBlocks: buildIndexedBlockDescriptors( - quoteBlock.children, - cacheOwnerId: quoteBlock.id, - separator: '\n\n', - paragraphStyle: theme.quoteStyle, - ), - baseOffset: baseOffset, - ); - case MarkdownBlockKind.details: - final detailsBlock = block as DetailsBlock; - return _resolveQuoteBlockSelectionRange( - position, - QuoteBlock( - id: detailsBlock.id, - children: MarkdownDescriptorExtractor.detailsSelectionBlocks( - detailsBlock, - ), - ), - indexedBlocks: buildIndexedBlockDescriptors( - MarkdownDescriptorExtractor.detailsSelectionBlocks(detailsBlock), - separator: '\n\n', - ), - baseOffset: baseOffset, - ); - case MarkdownBlockKind.orderedList: - case MarkdownBlockKind.unorderedList: - final listBlock = block as ListBlock; - return _resolveListItemSelectionRange( - position, - listBlock, - indexedItems: buildIndexedListSelectionDescriptors( - listBlock, - indentLevel: indentLevel, - ), - baseOffset: baseOffset, - ); - case MarkdownBlockKind.footnoteList: - return resolveSelectionUnitRange( - position, - footnoteListAsOrderedList(block as FootnoteListBlock), - baseOffset: baseOffset, - indentLevel: indentLevel, - ); - case MarkdownBlockKind.table: - return resolveTableCellSelectionRange( - position, - block as TableBlock, - baseOffset: baseOffset, - ); - default: - return null; - } - } - - DocumentRange? resolveTableCellSelectionRange( - DocumentPosition position, - TableBlock block, { - int baseOffset = 0, - }) { - final cellRange = _tableCellTextRange( - block, - position.textOffset - baseOffset, - ); - if (cellRange == null) { - return null; - } - return _rangeForBlockOffsets( - blockIndex: position.blockIndex, - startOffset: baseOffset + cellRange.start, - endOffset: baseOffset + cellRange.end, - ); - } - SelectableTextDescriptor buildImageCaptionDescriptor(ImageBlock block) { final caption = imageCaptionText(block); return plainTextDescriptor(caption, imageCaptionStyle); @@ -942,217 +576,6 @@ class MarkdownDescriptorExtractor { ); } - DocumentRange? _resolveListItemSelectionRange( - DocumentPosition position, - ListBlock block, { - required List indexedItems, - int baseOffset = 0, - }) { - if (indexedItems.isEmpty) { - return null; - } - final offset = position.textOffset; - for (final item in indexedItems) { - final start = baseOffset + item.startOffset; - final end = start + item.descriptor.plainText.length; - if (offset >= start && offset <= end) { - final childUnit = _resolveListItemChildSelectionRange( - position, - block.items[item.itemIndex].children, - itemStartOffset: start, - contentBaseOffset: start + item.prefixLength, - indentLevel: item.contentIndentLevel, - continuationIndent: item.prefixLength, - ); - if (childUnit != null) { - return childUnit; - } - return _rangeForBlockOffsets( - blockIndex: position.blockIndex, - startOffset: start, - endOffset: end, - ); - } - } - final last = indexedItems.last; - return _rangeForBlockOffsets( - blockIndex: position.blockIndex, - startOffset: baseOffset + last.startOffset, - endOffset: - baseOffset + last.startOffset + last.descriptor.plainText.length, - ); - } - - DocumentRange? _resolveQuoteBlockSelectionRange( - DocumentPosition position, - QuoteBlock block, { - required List indexedBlocks, - int baseOffset = 0, - }) { - if (indexedBlocks.isEmpty) { - return null; - } - final offset = position.textOffset; - for (final entry in indexedBlocks) { - final start = baseOffset + entry.startOffset; - final end = start + entry.descriptor.plainText.length; - if (offset >= start && offset <= end) { - final nested = resolveSelectionUnitRange( - position, - entry.block, - baseOffset: start, - indentLevel: entry.indentLevel, - ); - if (nested != null) { - return nested; - } - return _rangeForBlockOffsets( - blockIndex: position.blockIndex, - startOffset: start, - endOffset: end, - ); - } - } - final last = indexedBlocks.last; - return _rangeForBlockOffsets( - blockIndex: position.blockIndex, - startOffset: baseOffset + last.startOffset, - endOffset: - baseOffset + last.startOffset + last.descriptor.plainText.length, - ); - } - - DocumentRange? _resolveListItemChildSelectionRange( - DocumentPosition position, - List blocks, { - required int itemStartOffset, - required int contentBaseOffset, - required int indentLevel, - int continuationIndent = 0, - }) { - final indexedBlocks = buildIndexedBlockDescriptors( - blocks, - indentLevel: indentLevel, - separator: '\n', - cacheOwnerId: _childBlockOwnerId(blocks), - ); - if (indexedBlocks.isEmpty) { - return null; - } - final offset = position.textOffset; - for (final entry in indexedBlocks) { - final rawChildBaseOffset = contentBaseOffset + entry.startOffset; - final rawChildEndOffset = - rawChildBaseOffset + entry.descriptor.plainText.length; - if (offset < rawChildBaseOffset || offset > rawChildEndOffset) { - continue; - } - - final childUnit = resolveSelectionUnitRange( - position, - entry.block, - baseOffset: rawChildBaseOffset + - (entry.childIndex > 0 ? continuationIndent : 0), - indentLevel: entry.indentLevel, - ) ?? - _rangeForBlockOffsets( - blockIndex: position.blockIndex, - startOffset: rawChildBaseOffset, - endOffset: rawChildEndOffset, - ); - - if (entry.childIndex == 0 && _isLeadListTextBlock(entry.block)) { - return _rangeForBlockOffsets( - blockIndex: position.blockIndex, - startOffset: itemStartOffset, - endOffset: childUnit.end.textOffset, - ); - } - return childUnit; - } - return null; - } - - bool _sameBlockList(List left, List right) { - if (identical(left, right)) { - return true; - } - if (left.length != right.length) { - return false; - } - for (var index = 0; index < left.length; index++) { - if (!identical(left[index], right[index])) { - return false; - } - } - return true; - } - - String _childBlockOwnerId(List blocks) { - if (blocks.isEmpty) { - return 'empty'; - } - return blocks.map((block) => block.id).join('|'); - } - - bool _isLeadListTextBlock(BlockNode block) { - switch (block.kind) { - case MarkdownBlockKind.paragraph: - case MarkdownBlockKind.heading: - return true; - case MarkdownBlockKind.quote: - case MarkdownBlockKind.orderedList: - case MarkdownBlockKind.unorderedList: - case MarkdownBlockKind.definitionList: - case MarkdownBlockKind.footnoteList: - case MarkdownBlockKind.details: - case MarkdownBlockKind.codeBlock: - case MarkdownBlockKind.table: - case MarkdownBlockKind.image: - case MarkdownBlockKind.thematicBreak: - return false; - } - } - - DocumentRange _rangeForBlockOffsets({ - required int blockIndex, - required int startOffset, - required int endOffset, - }) { - return DocumentRange( - start: DocumentPosition( - blockIndex: blockIndex, - path: const PathInBlock([0]), - textOffset: startOffset, - ), - end: DocumentPosition( - blockIndex: blockIndex, - path: const PathInBlock([0]), - textOffset: endOffset, - ), - ); - } - - ({int start, int end})? _tableCellTextRange( - TableBlock block, int textOffset) { - var currentOffset = 0; - for (var rowIndex = 0; rowIndex < block.rows.length; rowIndex++) { - final row = block.rows[rowIndex]; - for (var columnIndex = 0; columnIndex < row.cells.length; columnIndex++) { - final cellLength = MarkdownInlineBuilder.flattenInlineText( - row.cells[columnIndex].inlines, - ).length; - final cellStart = currentOffset; - final cellEnd = cellStart + cellLength; - if (textOffset <= cellEnd) { - return (start: cellStart, end: cellEnd); - } - currentOffset = cellEnd + 1; - } - } - return null; - } - SelectableTextDescriptor prefixSelectableTextDescriptor( SelectableTextDescriptor descriptor, { required String firstPrefix, diff --git a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selectable_leaf.dart b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selectable_leaf.dart new file mode 100644 index 00000000..bf59ea76 --- /dev/null +++ b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selectable_leaf.dart @@ -0,0 +1,782 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import '../../core/document.dart'; +import '../pretext_text_block.dart'; + +/// The boxes a container block needs besides its text leaves. +enum MarkdownSelectionSlotRole { + /// A child block of a quote, details, or list item, including the spacing + /// below it. + child, + + /// A whole list item row: marker, gap, and content. + listRow, + + /// The content column of a list item, to the right of the marker. + listContent, +} + +/// The leaves and slots rendered inside one selectable block, keyed by their +/// [PathInBlock] in the block's selection structure. +class MarkdownSelectionLeafRegistry { + final Map _leaves = + {}; + final Map<(PathInBlock, MarkdownSelectionSlotRole), + _MarkdownSelectionSlotState> _slots = + <(PathInBlock, MarkdownSelectionSlotRole), _MarkdownSelectionSlotState>{}; + + MarkdownSelectableLeafState? leaf(PathInBlock path) => _leaves[path]; + + RenderBox? slotBox(PathInBlock path, MarkdownSelectionSlotRole role) { + return _laidOutBox(_slots[(path, role)]?.context); + } +} + +/// Provides the [MarkdownSelectionLeafRegistry] of the enclosing selectable +/// block to the leaves and slots below it. +class MarkdownSelectionLeafScope extends InheritedWidget { + const MarkdownSelectionLeafScope({ + super.key, + required this.registry, + required super.child, + }); + + final MarkdownSelectionLeafRegistry registry; + + static MarkdownSelectionLeafRegistry? _maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType() + ?.registry; + } + + @override + bool updateShouldNotify(MarkdownSelectionLeafScope oldWidget) { + return registry != oldWidget.registry; + } +} + +/// A selectable text leaf. It answers its own selection geometry in its box +/// coordinates and in offsets into its own visible text; the enclosing block +/// converts both to block space. +class MarkdownSelectableLeaf extends StatefulWidget { + const MarkdownSelectableLeaf({ + super.key, + required this.path, + required this.geometry, + required this.child, + }); + + final PathInBlock path; + final MarkdownLeafGeometry geometry; + final Widget child; + + @override + State createState() => MarkdownSelectableLeafState(); +} + +class MarkdownSelectableLeafState extends State { + MarkdownSelectionLeafRegistry? _registry; + + /// The laid-out box of the leaf, or null before its first layout. + RenderBox? get box => _laidOutBox(context); + + /// Selection rects for local offsets [start, end), in leaf coordinates. + List rectsFor(int start, int end) { + final box = this.box; + if (box == null || start >= end) { + return const []; + } + return widget.geometry.rectsFor(context, box, start, end); + } + + /// The local offset at [localPosition], in leaf coordinates. + int offsetAt(Offset localPosition) { + final box = this.box; + if (box == null) { + return 0; + } + return widget.geometry.offsetAt(context, box, localPosition); + } + + /// The local range of the line at [offset], or under [localPosition] when + /// it is given. + ({int start, int end})? lineRangeAt(int offset, {Offset? localPosition}) { + final box = this.box; + if (box == null) { + return null; + } + return widget.geometry.lineRangeAt( + context, + box, + offset, + localPosition: localPosition, + ); + } + + /// The rect that selection rects of this leaf are clipped to, in leaf + /// coordinates, such as the viewport of a horizontal scroll view. + Rect? get clipRect { + final box = this.box; + return box == null ? null : widget.geometry.clipRect(box); + } + + /// Whether selecting anywhere in the leaf selects all of it. + bool get selectsWhole => widget.geometry.selectsWhole; + + /// The leaf origin in the coordinates of [ancestor]. + Offset? originIn(RenderBox ancestor) { + final box = this.box; + return box?.localToGlobal(Offset.zero, ancestor: ancestor); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final registry = MarkdownSelectionLeafScope._maybeOf(context); + if (!identical(registry, _registry)) { + _unregister(widget.path); + _registry = registry; + _registry?._leaves[widget.path] = this; + } + } + + @override + void didUpdateWidget(covariant MarkdownSelectableLeaf oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.path != widget.path) { + _unregister(oldWidget.path); + _registry?._leaves[widget.path] = this; + } + } + + @override + void dispose() { + _unregister(widget.path); + super.dispose(); + } + + void _unregister(PathInBlock path) { + final leaves = _registry?._leaves; + if (leaves != null && identical(leaves[path], this)) { + leaves.remove(path); + } + } + + @override + Widget build(BuildContext context) => widget.child; +} + +/// A structural box of a container block, such as a list item row, that the +/// container geometry reads besides its leaves. +class MarkdownSelectionSlot extends StatefulWidget { + const MarkdownSelectionSlot({ + super.key, + required this.path, + required this.role, + required this.child, + }); + + final PathInBlock path; + final MarkdownSelectionSlotRole role; + final Widget child; + + @override + State createState() => _MarkdownSelectionSlotState(); +} + +class _MarkdownSelectionSlotState extends State { + MarkdownSelectionLeafRegistry? _registry; + + (PathInBlock, MarkdownSelectionSlotRole) _keyOf(MarkdownSelectionSlot slot) { + return (slot.path, slot.role); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final registry = MarkdownSelectionLeafScope._maybeOf(context); + if (!identical(registry, _registry)) { + _unregister(_keyOf(widget)); + _registry = registry; + _registry?._slots[_keyOf(widget)] = this; + } + } + + @override + void didUpdateWidget(covariant MarkdownSelectionSlot oldWidget) { + super.didUpdateWidget(oldWidget); + if (_keyOf(oldWidget) != _keyOf(widget)) { + _unregister(_keyOf(oldWidget)); + _registry?._slots[_keyOf(widget)] = this; + } + } + + @override + void dispose() { + _unregister(_keyOf(widget)); + super.dispose(); + } + + void _unregister((PathInBlock, MarkdownSelectionSlotRole) key) { + final slots = _registry?._slots; + if (slots != null && identical(slots[key], this)) { + slots.remove(key); + } + } + + @override + Widget build(BuildContext context) => widget.child; +} + +RenderBox? _laidOutBox(BuildContext? context) { + if (context == null || !context.mounted) { + return null; + } + final renderObject = context.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) { + return null; + } + return renderObject; +} + +/// How a leaf maps between its local text offsets and its box coordinates. +abstract class MarkdownLeafGeometry { + const MarkdownLeafGeometry(); + + List rectsFor(BuildContext context, RenderBox box, int start, int end); + + int offsetAt(BuildContext context, RenderBox box, Offset localPosition); + + ({int start, int end})? lineRangeAt( + BuildContext context, + RenderBox box, + int offset, { + Offset? localPosition, + }); + + Rect? clipRect(RenderBox box) => null; + + bool get selectsWhole => false; +} + +/// A leaf drawn as a box rather than text, such as an image, a thematic +/// break, or a standalone line break. It is selected as a whole. +class MarkdownPlaceholderLeafGeometry extends MarkdownLeafGeometry { + const MarkdownPlaceholderLeafGeometry({required this.textLength}); + + final int textLength; + + @override + bool get selectsWhole => true; + + @override + List rectsFor(BuildContext context, RenderBox box, int start, int end) { + final size = box.size; + return [Rect.fromLTRB(-1.5, 0, size.width + 1.5, size.height)]; + } + + @override + int offsetAt(BuildContext context, RenderBox box, Offset localPosition) { + final size = box.size; + final preferEnd = + localPosition.dy > size.height / 2 || localPosition.dx > size.width / 2; + return preferEnd ? textLength : 0; + } + + @override + ({int start, int end})? lineRangeAt( + BuildContext context, + RenderBox box, + int offset, { + Offset? localPosition, + }) { + return (start: 0, end: textLength); + } +} + +/// A leaf rendered from pretext runs, either as one rich text paragraph or as +/// pretext lines. Paragraph geometry is read from the live [RenderParagraph] +/// whose text matches [runs]; pretext geometry is computed from the runs. +class MarkdownTextLeafGeometry extends MarkdownLeafGeometry { + MarkdownTextLeafGeometry({ + required this.runs, + required this.fallbackStyle, + this.textAlign = TextAlign.start, + bool? direct, + this.viewportKey, + }) : direct = direct ?? markdownPretextCanUseDirectRichTextGeometry(runs); + + final List runs; + final TextStyle fallbackStyle; + final TextAlign textAlign; + + /// Whether the leaf renders [runs] as a single rich text paragraph. + final bool direct; + + /// The viewport of an enclosing horizontal scroll view, if any. + final GlobalKey? viewportKey; + + late final String _renderText = markdownPretextRenderText(runs); + late final String _plainText = runs.map((run) => run.text).join(); + + @override + Rect? clipRect(RenderBox box) { + final viewport = viewportKey?.currentContext?.findRenderObject(); + if (viewport is! RenderBox || !viewport.hasSize) { + return null; + } + return box.globalToLocal(viewport.localToGlobal(Offset.zero)) & + viewport.size; + } + + @override + List rectsFor(BuildContext context, RenderBox box, int start, int end) { + final paragraph = _paragraphIn(box); + if (paragraph != null) { + final rects = _paragraphRects(box, paragraph, start, end); + if (rects != null) { + return rects; + } + } + return _layout(context, box.size.width).selectionRectsForRange( + _localRange(start, end), + textDirection: Directionality.of(context), + ); + } + + @override + int offsetAt(BuildContext context, RenderBox box, Offset localPosition) { + final paragraph = _paragraphIn(box); + if (paragraph != null) { + final position = paragraph.getPositionForOffset( + paragraph.globalToLocal(box.localToGlobal(localPosition)), + ); + return markdownPretextPlainOffsetForRenderOffset(runs, position.offset); + } + return _layout(context, box.size.width).textOffsetAt( + localPosition, + textDirection: Directionality.of(context), + ); + } + + @override + ({int start, int end})? lineRangeAt( + BuildContext context, + RenderBox box, + int offset, { + Offset? localPosition, + }) { + final paragraph = _paragraphIn(box); + if (paragraph != null) { + final range = _paragraphLineRange(box, paragraph, offset, localPosition); + if (range != null) { + return range; + } + } + final layout = _layout(context, box.size.width); + return localPosition == null + ? layout.lineRangeForTextOffset(offset) + : layout.visualLineRangeForLocalPosition(localPosition) ?? + layout.lineRangeForTextOffset(offset); + } + + /// Pretext geometry of the runs at [maxWidth], for when no live paragraph + /// is available. + MarkdownPretextLayoutResult _layout(BuildContext context, double maxWidth) { + final textScaler = + MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling; + return computeMarkdownPretextLayoutFromRuns( + runs: runs, + fallbackStyle: fallbackStyle, + maxWidth: maxWidth, + textScaleFactor: textScaler.scale(1.0), + textAlign: textAlign, + textDirection: Directionality.of(context), + ); + } + + /// Rects from the pretext layout at [maxWidth], in the coordinates the + /// runs would be laid out in. Used before the leaf has been laid out. + List layoutRects( + BuildContext context, + double maxWidth, + int start, + int end, + ) { + return _layout(context, maxWidth).selectionRectsForRange( + _localRange(start, end), + textDirection: Directionality.of(context), + ); + } + + int layoutOffsetAt(BuildContext context, double maxWidth, Offset position) { + return _layout(context, maxWidth).textOffsetAt( + position, + textDirection: Directionality.of(context), + ); + } + + ({int start, int end})? layoutLineRangeAt( + BuildContext context, + double maxWidth, + int offset, { + Offset? localPosition, + }) { + final layout = _layout(context, maxWidth); + return localPosition == null + ? layout.lineRangeForTextOffset(offset) + : layout.visualLineRangeForLocalPosition(localPosition) ?? + layout.lineRangeForTextOffset(offset); + } + + RenderParagraph? _paragraphIn(RenderBox box) { + if (!direct) { + return null; + } + RenderParagraph? paragraph; + void visit(RenderObject node) { + if (paragraph != null) { + return; + } + if (node is RenderParagraph && + node.hasSize && + node.text.toPlainText(includePlaceholders: true) == _renderText) { + paragraph = node; + return; + } + node.visitChildren(visit); + } + + visit(box); + return paragraph; + } + + List? _paragraphRects( + RenderBox box, + RenderParagraph paragraph, + int start, + int end, + ) { + final renderSelection = TextSelection( + baseOffset: markdownPretextRenderOffsetForPlainOffset( + runs, + start, + preferEnd: false, + ), + extentOffset: markdownPretextRenderOffsetForPlainOffset( + runs, + end, + preferEnd: true, + ), + ); + final origin = box.globalToLocal(paragraph.localToGlobal(Offset.zero)); + // The paragraph may not have completed layout in the current frame, for + // example during scroll-triggered rebuilds. + final List boxes; + final List<_LineExtent> lineExtents; + try { + lineExtents = _paragraphLineExtents(paragraph); + boxes = paragraph.getBoxesForSelection(renderSelection); + } on AssertionError { + return null; + } + return _mergeSameLineBoxes(_normalizeBoxesToLineExtents(boxes, lineExtents)) + .map( + (box) => Rect.fromLTRB( + box.left + origin.dx - 1.5, + box.top + origin.dy, + box.right + origin.dx + 1.5, + box.bottom + origin.dy, + ), + ) + .toList(growable: false); + } + + ({int start, int end})? _paragraphLineRange( + RenderBox box, + RenderParagraph paragraph, + int offset, + Offset? localPosition, + ) { + final renderOffset = localPosition == null + ? markdownPretextRenderOffsetForPlainOffset( + runs, + offset, + preferEnd: false, + ) + : paragraph + .getPositionForOffset( + paragraph.globalToLocal(box.localToGlobal(localPosition)), + ) + .offset; + final boundary = _paragraphLineBoundary(paragraph, renderOffset); + if (boundary == null) { + return null; + } + var start = markdownPretextPlainOffsetForRenderOffset(runs, boundary.start); + var end = markdownPretextPlainOffsetForRenderOffset(runs, boundary.end); + if (start < _plainText.length && _plainText.codeUnitAt(start) == 0x0A) { + start += 1; + } + if (end > start && + end <= _plainText.length && + _plainText.codeUnitAt(end - 1) == 0x0A) { + end -= 1; + } + return (start: start, end: end); + } + + DocumentRange _localRange(int start, int end) { + return DocumentRange( + start: DocumentPosition( + blockIndex: 0, + path: const PathInBlock([0]), + textOffset: start, + ), + end: DocumentPosition( + blockIndex: 0, + path: const PathInBlock([0]), + textOffset: end, + ), + ); + } +} + +typedef _LineExtent = ({double top, double bottom}); + +/// The render-text range of the visual line that contains [renderOffset]. +({int start, int end})? _paragraphLineBoundary( + RenderParagraph paragraph, + int renderOffset, +) { + final fullText = paragraph.text.toPlainText(includePlaceholders: true); + if (fullText.isEmpty) { + return const (start: 0, end: 0); + } + final lineExtents = _paragraphLineExtents(paragraph); + if (lineExtents.isEmpty) { + return null; + } + + var probe = renderOffset.clamp(0, fullText.length).toInt(); + if (probe == fullText.length && probe > 0) { + probe -= 1; + } + + var probeBoxes = _characterBoxes(paragraph, fullText, probe); + while (probeBoxes.isEmpty && probe > 0) { + probe -= 1; + probeBoxes = _characterBoxes(paragraph, fullText, probe); + } + while (probeBoxes.isEmpty && probe < fullText.length - 1) { + probe += 1; + probeBoxes = _characterBoxes(paragraph, fullText, probe); + } + if (probeBoxes.isEmpty) { + return null; + } + + final lineExtent = lineExtents.firstWhere( + (extent) => _boxesOverlapLineExtent(probeBoxes, extent), + orElse: () => lineExtents.last, + ); + + var start = probe; + while (start > 0) { + final previousBoxes = _characterBoxes(paragraph, fullText, start - 1); + if (previousBoxes.isEmpty || + !_boxesOverlapLineExtent(previousBoxes, lineExtent)) { + break; + } + start -= 1; + } + + var end = probe + 1; + while (end < fullText.length) { + final nextBoxes = _characterBoxes(paragraph, fullText, end); + if (nextBoxes.isEmpty || !_boxesOverlapLineExtent(nextBoxes, lineExtent)) { + break; + } + end += 1; + } + return (start: start, end: end); +} + +List _characterBoxes( + RenderParagraph paragraph, + String fullText, + int renderOffset, +) { + if (fullText.isEmpty) { + return const []; + } + final start = renderOffset.clamp(0, fullText.length - 1).toInt(); + final end = math.min(start + 1, fullText.length); + if (start >= end) { + return const []; + } + return paragraph.getBoxesForSelection( + TextSelection(baseOffset: start, extentOffset: end), + ); +} + +bool _boxesOverlapLineExtent(List boxes, _LineExtent lineExtent) { + for (final box in boxes) { + final overlapTop = math.max(box.top, lineExtent.top); + final overlapBottom = math.min(box.bottom, lineExtent.bottom); + if (overlapBottom - overlapTop > 0.5) { + return true; + } + } + return false; +} + +/// Each visual line's vertical extent, with the gaps between lines split +/// halfway so that selection rects of adjacent lines meet. +List<_LineExtent> _paragraphLineExtents(RenderParagraph paragraph) { + final fullText = paragraph.text.toPlainText(includePlaceholders: true); + if (fullText.isEmpty) { + return const <_LineExtent>[]; + } + if (fullText.contains('\uFFFC')) { + return _contiguousLinesFromBoxes( + paragraph.getBoxesForSelection( + TextSelection(baseOffset: 0, extentOffset: fullText.length), + ), + ); + } + + final textPainter = TextPainter( + text: paragraph.text, + textAlign: paragraph.textAlign, + textDirection: paragraph.textDirection, + textScaler: paragraph.textScaler, + maxLines: null, + )..layout(maxWidth: paragraph.size.width); + final lines = textPainter.computeLineMetrics(); + textPainter.dispose(); + final contiguousLines = <_LineExtent>[]; + for (var i = 0; i < lines.length; i++) { + final current = lines[i]; + final previous = i > 0 ? lines[i - 1] : null; + final next = i < lines.length - 1 ? lines[i + 1] : null; + final currentTop = current.baseline - current.ascent; + final currentBottom = currentTop + current.height; + contiguousLines.add(( + top: previous != null + ? (previous.baseline - + previous.ascent + + previous.height + + currentTop) / + 2.0 + : currentTop, + bottom: next != null + ? (currentBottom + next.baseline - next.ascent) / 2.0 + : currentBottom, + )); + } + return contiguousLines; +} + +List<_LineExtent> _contiguousLinesFromBoxes(List allBoxes) { + if (allBoxes.isEmpty) { + return const <_LineExtent>[]; + } + final sorted = allBoxes.toList(growable: false) + ..sort((a, b) => a.top.compareTo(b.top)); + + final lines = <_LineExtent>[]; + var lineTop = sorted.first.top; + var lineBottom = sorted.first.bottom; + for (final box in sorted.skip(1)) { + if (box.top < lineBottom - 1.0) { + lineTop = math.min(lineTop, box.top); + lineBottom = math.max(lineBottom, box.bottom); + } else { + lines.add((top: lineTop, bottom: lineBottom)); + lineTop = box.top; + lineBottom = box.bottom; + } + } + lines.add((top: lineTop, bottom: lineBottom)); + + final contiguousLines = <_LineExtent>[]; + for (var i = 0; i < lines.length; i++) { + final current = lines[i]; + final previous = i > 0 ? lines[i - 1] : null; + final next = i < lines.length - 1 ? lines[i + 1] : null; + contiguousLines.add(( + top: previous != null + ? (previous.bottom + current.top) / 2.0 + : current.top, + bottom: next != null ? (current.bottom + next.top) / 2.0 : current.bottom, + )); + } + return contiguousLines; +} + +List _normalizeBoxesToLineExtents( + List boxes, + List<_LineExtent> lineExtents, +) { + if (lineExtents.isEmpty) { + return boxes; + } + return boxes.map((box) { + for (final extent in lineExtents) { + final overlapTop = math.max(box.top, extent.top); + final overlapBottom = math.min(box.bottom, extent.bottom); + if (overlapBottom - overlapTop > 0.5) { + return TextBox.fromLTRBD( + box.left, + extent.top, + box.right, + extent.bottom, + box.direction, + ); + } + } + return box; + }).toList(growable: false); +} + +/// Merges the boxes of each line into one box spanning them. +List _mergeSameLineBoxes(List boxes) { + if (boxes.length < 2) { + return boxes; + } + const lineTolerance = 2.0; + + final sorted = boxes.toList(growable: false) + ..sort((a, b) { + final topCompare = a.top.compareTo(b.top); + if (topCompare != 0) { + return topCompare; + } + return a.left.compareTo(b.left); + }); + + final merged = []; + var current = sorted.first; + for (final next in sorted.skip(1)) { + final sameLine = (next.top - current.top).abs() <= lineTolerance && + (next.bottom - current.bottom).abs() <= lineTolerance; + if (sameLine) { + current = TextBox.fromLTRBD( + math.min(current.left, next.left), + math.min(current.top, next.top), + math.max(current.right, next.right), + math.max(current.bottom, next.bottom), + current.direction, + ); + continue; + } + merged.add(current); + current = next; + } + merged.add(current); + return merged; +} diff --git a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_geometry.dart b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_geometry.dart new file mode 100644 index 00000000..aec77c76 --- /dev/null +++ b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_geometry.dart @@ -0,0 +1,845 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import '../../core/document.dart'; +import '../../selection/structured_block_selection.dart'; +import '../markdown_block_widgets.dart'; +import '../selectable_block.dart'; +import 'markdown_descriptor_extractor.dart'; +import 'markdown_selectable_leaf.dart'; + +/// Selection geometry of container blocks: quotes, details, lists, +/// footnotes, and tables. +/// +/// A container only orders its leaves. Each leaf answers its own rects and +/// offsets ([MarkdownSelectableLeafState]); this class walks the block's +/// selection tree, reads the boxes of leaves and slots in block coordinates, +/// and converts between leaf-local and block display offsets exactly once, +/// through the leaves of the block's [StructuredBlockSelection]. +abstract final class MarkdownContainerSelectionGeometry { + static List rects( + BuildContext context, + BlockNode block, + DocumentRange range, + ) { + final query = _Query.of(context, block); + if (query == null) { + return const []; + } + return query.rects( + query.root, + range.start.textOffset, + range.end.textOffset, + ); + } + + static int? offsetAt( + BuildContext context, + BlockNode block, + Offset localPosition, + ) { + final query = _Query.of(context, block); + return query?.offsetAt(query.root, localPosition); + } + + /// The range a triple click selects: the visual line of a leaf, a whole + /// list item when on its marker, or a whole table cell. Without a pointer + /// position, or when the pointer is outside every leaf, the unit is + /// resolved from [position] alone. + static DocumentRange? unitAt( + BuildContext context, + BlockNode block, + Offset? localPosition, + DocumentPosition position, + ) { + final root = _treeFor(block); + if (root == null) { + return null; + } + final query = localPosition == null ? null : _Query.of(context, block); + final unit = query?.unitAt(root, localPosition!) ?? + _modelUnitAt(root, position.textOffset); + if (unit == null) { + return null; + } + return DocumentRange( + start: DocumentPosition( + blockIndex: position.blockIndex, + path: const PathInBlock([0]), + textOffset: unit.start, + ), + end: DocumentPosition( + blockIndex: position.blockIndex, + path: const PathInBlock([0]), + textOffset: unit.end, + ), + ); + } +} + +typedef _Range = ({int start, int end}); + +/// A node of a block's selection tree. Nested nodes are found through the +/// slot at their [path], which is also the prefix of their leaf paths. +sealed class _Node { + _Node(this.path); + + final PathInBlock path; + + int get start; + + int get end; +} + +final class _LeafNode extends _Node { + _LeafNode(super.path, this.leaf, {required this.leadsText}) + : start = leaf.displayedOffsetForLocalOffset(0), + end = leaf.segmentEnd; + + final StructuredBlockSelectionLeaf leaf; + + /// Whether the leaf is a paragraph or heading, whose line selection takes + /// the list marker along when it starts the item. + final bool leadsText; + + @override + final int start; + + @override + final int end; +} + +/// A quote or details: children stacked vertically. +final class _SequenceNode extends _Node { + _SequenceNode(super.path, this.children); + + final List<_Node> children; + + @override + int get start => children.first.start; + + @override + int get end => children.last.end; +} + +final class _ListNode extends _Node { + _ListNode(super.path, this.list, this.items); + + final ListBlock list; + final List<_ListItemNode> items; + + @override + int get start => items.first.start; + + @override + int get end => items.last.end; +} + +final class _ListItemNode { + _ListItemNode({ + required this.index, + required this.path, + required this.marker, + required this.children, + required this.leadTextChild, + }); + + final int index; + final PathInBlock path; + final StructuredBlockSelectionLeaf marker; + final List<_Node> children; + + /// The first model child of the item when it is a paragraph or heading. + final _Node? leadTextChild; + + int get start => marker.displayedOffsetForLocalOffset(0); + + int get contentStart => marker.segmentEnd; + + int get end => children.isEmpty ? marker.segmentEnd : children.last.end; +} + +/// A table: each non-empty cell is a leaf. +final class _TableNode extends _Node { + _TableNode(super.path, this.cells); + + final List<_LeafNode> cells; + + @override + int get start => cells.first.start; + + @override + int get end => cells.last.end; +} + +final Expando<_Tree> _trees = Expando<_Tree>('MarkdownSelectionTree'); + +class _Tree { + const _Tree(this.root); + + final _Node? root; +} + +_Node? _treeFor(BlockNode block) { + return (_trees[block] ??= _Tree( + _TreeBuilder(StructuredBlockSelection.forBlock(block)).build( + block, + const [], + ), + )) + .root; +} + +/// Builds the selection tree of a block from its model, following the same +/// paths as [StructuredBlockSelection] so that each node finds its leaves. +class _TreeBuilder { + _TreeBuilder(this.structure); + + final StructuredBlockSelection structure; + + _Node? build(BlockNode block, List path) { + switch (block.kind) { + case MarkdownBlockKind.heading: + case MarkdownBlockKind.paragraph: + case MarkdownBlockKind.definitionList: + case MarkdownBlockKind.codeBlock: + case MarkdownBlockKind.image: + case MarkdownBlockKind.thematicBreak: + final leaf = structure.leafForPath( + PathInBlock(path.isEmpty ? const [0] : path), + ); + if (leaf == null) { + return null; + } + return _LeafNode( + PathInBlock(path), + leaf, + leadsText: block.kind == MarkdownBlockKind.paragraph || + block.kind == MarkdownBlockKind.heading, + ); + case MarkdownBlockKind.quote: + final quote = block as QuoteBlock; + return _sequence(path, <_Node?>[ + for (var index = 0; index < quote.children.length; index++) + build(quote.children[index], [...path, index]), + ]); + case MarkdownBlockKind.details: + final details = block as DetailsBlock; + return _sequence(path, <_Node?>[ + build( + ParagraphBlock( + id: '${details.id}:summary', + inlines: details.summary, + ), + [...path, 0], + ), + for (var index = 0; index < details.children.length; index++) + build(details.children[index], [...path, index + 1]), + ]); + case MarkdownBlockKind.orderedList: + case MarkdownBlockKind.unorderedList: + return _list(block as ListBlock, path); + case MarkdownBlockKind.footnoteList: + return _list( + MarkdownDescriptorExtractor.footnoteListAsOrderedList( + block as FootnoteListBlock, + ), + path, + ); + case MarkdownBlockKind.table: + final table = block as TableBlock; + final cells = <_LeafNode>[ + for (var row = 0; row < table.rows.length; row++) + for (var column = 0; + column < table.rows[row].cells.length; + column++) + if (structure + .leafForPath(PathInBlock([...path, row, column])) + case final leaf?) + _LeafNode(leaf.path, leaf, leadsText: false), + ]; + return cells.isEmpty ? null : _TableNode(PathInBlock(path), cells); + } + } + + _Node? _sequence(List path, List<_Node?> children) { + final present = children.nonNulls.toList(growable: false); + return present.isEmpty ? null : _SequenceNode(PathInBlock(path), present); + } + + _Node? _list(ListBlock list, List path) { + final items = <_ListItemNode>[]; + for (var index = 0; index < list.items.length; index++) { + final marker = + structure.leafForPath(PathInBlock([...path, index, 0])); + if (marker == null) { + continue; + } + final item = list.items[index]; + _Node? leadTextChild; + final children = <_Node>[]; + for (var child = 0; child < item.children.length; child++) { + final node = + build(item.children[child], [...path, index, child + 1]); + if (node == null) { + continue; + } + if (child == 0 && node is _LeafNode && node.leadsText) { + leadTextChild = node; + } + children.add(node); + } + items.add( + _ListItemNode( + index: index, + path: PathInBlock([...path, index]), + marker: marker, + children: children, + leadTextChild: leadTextChild, + ), + ); + } + return items.isEmpty ? null : _ListNode(PathInBlock(path), list, items); + } +} + +/// The selection unit at [offset] from the model alone: the leaf or list +/// item that contains it, with the list marker taken along by a leading +/// paragraph. +_Range? _modelUnitAt(_Node node, int offset) { + bool contains(int start, int end) => offset >= start && offset <= end; + _Range rangeOf(_Node node) => (start: node.start, end: node.end); + _Range childUnit(_Node child) => + (child is _LeafNode ? null : _modelUnitAt(child, offset)) ?? + rangeOf(child); + + switch (node) { + case _LeafNode(): + return null; + case _SequenceNode(:final children): + for (final child in children) { + if (contains(child.start, child.end)) { + return childUnit(child); + } + } + return rangeOf(children.last); + case _ListNode(:final items): + for (final item in items) { + if (!contains(item.start, item.end)) { + continue; + } + for (final child in item.children) { + if (!contains(child.start, child.end)) { + continue; + } + final unit = childUnit(child); + return identical(child, item.leadTextChild) + ? (start: item.start, end: unit.end) + : unit; + } + return (start: item.start, end: item.end); + } + return (start: items.last.start, end: items.last.end); + case _TableNode(:final cells): + for (final cell in cells) { + if (contains(cell.start, cell.end)) { + return rangeOf(cell); + } + } + return null; + } +} + +/// One geometry query against the live leaves and slots of a block, in the +/// block's coordinates. +class _Query { + _Query._(this.registry, this.blockBox, this.root); + + static _Query? of(BuildContext context, BlockNode block) { + final registry = SelectableMarkdownBlockState.leafRegistryOf(context); + final blockBox = context.findRenderObject(); + final root = _treeFor(block); + if (registry == null || + blockBox is! RenderBox || + !blockBox.hasSize || + root == null) { + return null; + } + return _Query._(registry, blockBox, root); + } + + final MarkdownSelectionLeafRegistry registry; + final RenderBox blockBox; + final _Node root; + + Rect? _slotRect(PathInBlock path, MarkdownSelectionSlotRole role) { + final box = registry.slotBox(path, role); + if (box == null) { + return null; + } + return box.localToGlobal(Offset.zero, ancestor: blockBox) & box.size; + } + + Rect? _childRect(_Node node) { + return _slotRect(node.path, MarkdownSelectionSlotRole.child); + } + + ({MarkdownSelectableLeafState state, Offset origin})? _leaf(_LeafNode node) { + final state = registry.leaf(node.leaf.path); + final origin = state?.originIn(blockBox); + if (state == null || origin == null) { + return null; + } + return (state: state, origin: origin); + } + + // Rects. + + List rects(_Node node, int start, int end) { + switch (node) { + case _LeafNode(): + return _leafRects(node, start, end); + case _SequenceNode(:final children): + return _childrenRects(children, start, end); + case _ListNode(): + return _listRects(node, start, end); + case _TableNode(:final cells): + return _childrenRects(cells, start, end); + } + } + + List _childrenRects(List<_Node> children, int start, int end) { + final rects = []; + for (final child in children) { + final childStart = math.max(start, child.start); + final childEnd = math.min(end, child.end); + if (childStart < childEnd) { + rects.addAll(this.rects(child, childStart, childEnd)); + } + } + return rects; + } + + List _leafRects(_LeafNode node, int start, int end) { + final leaf = _leaf(node); + if (leaf == null) { + return const []; + } + final rects = leaf.state + .rectsFor( + node.leaf.localOffsetForDisplayedOffset(start), + node.leaf.localOffsetForDisplayedOffset(end), + ) + .map((rect) => rect.shift(leaf.origin)); + final clipRect = leaf.state.clipRect?.shift(leaf.origin); + if (clipRect == null) { + return rects.toList(growable: false); + } + return rects + .map((rect) => rect.intersect(clipRect)) + .where((rect) => !rect.isEmpty) + .toList(growable: false); + } + + List _listRects(_ListNode list, int start, int end) { + final rects = []; + for (final item in list.items) { + final contentSelectionStart = math.max(start, item.contentStart); + final contentSelectionEnd = math.min(end, item.end); + final prefixSelectionStart = math.max(start, item.start); + final prefixSelectionEnd = math.min(end, item.contentStart); + if (contentSelectionStart >= contentSelectionEnd && + prefixSelectionStart >= prefixSelectionEnd) { + continue; + } + + final contentRect = + _slotRect(item.path, MarkdownSelectionSlotRole.listContent); + final rowRect = _slotRect(item.path, MarkdownSelectionSlotRole.listRow); + final contentRects = + contentSelectionStart < contentSelectionEnd && contentRect != null + ? _childrenRects( + item.children, + contentSelectionStart, + contentSelectionEnd, + ) + : const []; + final prefixRect = + prefixSelectionStart < prefixSelectionEnd && rowRect != null + ? _listPrefixRect(list, item, rowRect, contentRect) + : null; + + if (contentSelectionStart >= contentSelectionEnd || contentRect == null) { + if (prefixRect != null) { + rects.add(prefixRect); + } + continue; + } + if (prefixRect != null && contentRects.isNotEmpty) { + final firstContentRect = contentRects.first; + if (_rectsShareLine(prefixRect, firstContentRect)) { + rects + ..add( + Rect.fromLTRB( + prefixRect.left, + firstContentRect.top, + firstContentRect.right, + firstContentRect.bottom, + ), + ) + ..addAll(contentRects.skip(1)); + continue; + } + rects.add(prefixRect); + } + rects.addAll(contentRects); + } + return rects; + } + + /// The marker part of a list item selection: as wide as the marker extent + /// and aligned to the first line of the item's first child. + Rect _listPrefixRect( + _ListNode list, + _ListItemNode item, + Rect rowRect, + Rect? contentRect, + ) { + var top = rowRect.top; + var height = rowRect.height; + final firstChild = item.children.isEmpty ? null : item.children.first; + final firstChildRect = firstChild == null ? null : _childRect(firstChild); + if (firstChild != null && firstChildRect != null) { + final firstLineRect = _firstLineRect(firstChild); + top = firstLineRect?.top ?? firstChildRect.top; + height = firstLineRect?.height ?? firstChildRect.height; + } + final extent = _listMarkerExtent(list, item, rowRect, contentRect); + return Rect.fromLTRB( + rowRect.left, + top, + rowRect.left + extent, + top + height, + ); + } + + Rect? _firstLineRect(_Node node) { + final rects = this + .rects( + node, + node.start, + math.min(node.start + 1, node.end), + ) + .toList(); + if (rects.isEmpty) { + return null; + } + rects.sort((a, b) { + final topCompare = a.top.compareTo(b.top); + return topCompare != 0 ? topCompare : a.left.compareTo(b.left); + }); + return rects.first; + } + + double _listMarkerExtent( + _ListNode list, + _ListItemNode item, + Rect rowRect, + Rect? contentRect, + ) { + if (contentRect != null) { + final contentOriginDx = contentRect.left - rowRect.left; + if (contentOriginDx > 0) { + return math.min(contentOriginDx, rowRect.width); + } + } + return math.min( + markdownListMarkerExtent(list.list, item.index), + rowRect.width, + ); + } + + // Offsets. + + int? offsetAt(_Node node, Offset position) { + switch (node) { + case _LeafNode(): + final leaf = _leaf(node); + if (leaf == null) { + return null; + } + return node.leaf.displayedOffsetForLocalOffset( + leaf.state.offsetAt(position - leaf.origin), + ); + case _SequenceNode(:final children): + return _childrenOffsetAt(children, position); + case _ListNode(): + return _listOffsetAt(node, position); + case _TableNode(): + final cell = _nearestCell(node, position); + if (cell == null) { + return null; + } + return cell.node.leaf.displayedOffsetForLocalOffset( + cell.leaf.state.offsetAt(position - cell.leaf.origin), + ); + } + } + + /// The offset among stacked children: inside the child that contains the + /// position; in a gap between two children, the end of the upper or the + /// start of the lower one, split at the gap midpoint; otherwise an edge of + /// the nearest child. + int? _childrenOffsetAt(List<_Node> children, Offset position) { + final resolved = <({_Node node, Rect rect})>[]; + _Node? nearest; + Rect? nearestRect; + var bestDistance = double.infinity; + for (final child in children) { + final rect = _childRect(child); + if (rect == null) { + continue; + } + resolved.add((node: child, rect: rect)); + if (rect.contains(position)) { + return offsetAt(child, position) ?? child.start; + } + final distance = _distanceToRect(position, rect); + if (distance < bestDistance) { + bestDistance = distance; + nearest = child; + nearestRect = rect; + } + } + + resolved.sort((a, b) => a.rect.top.compareTo(b.rect.top)); + for (var index = 0; index < resolved.length - 1; index++) { + final current = resolved[index]; + final next = resolved[index + 1]; + if (position.dy < current.rect.bottom || position.dy > next.rect.top) { + continue; + } + final overlapLeft = math.min(current.rect.right, next.rect.right); + final overlapRight = math.max(current.rect.left, next.rect.left); + if (position.dx < overlapRight - 24 || position.dx > overlapLeft + 24) { + continue; + } + final midpointY = (current.rect.bottom + next.rect.top) / 2; + return position.dy <= midpointY ? current.node.end : next.node.start; + } + + if (nearest == null || nearestRect == null) { + return null; + } + final preferEnd = position.dy > nearestRect.center.dy || + position.dx > nearestRect.center.dx; + return preferEnd ? nearest.end : nearest.start; + } + + int? _listOffsetAt(_ListNode list, Offset position) { + _ListItemNode? nearest; + Rect? nearestRowRect; + var bestDistance = double.infinity; + + for (final item in list.items) { + final contentRect = + _slotRect(item.path, MarkdownSelectionSlotRole.listContent); + int? childOffset; + if (contentRect != null) { + final childContainsPosition = item.children.any( + (child) => _childRect(child)?.contains(position) ?? false, + ); + childOffset = _childrenOffsetAt(item.children, position); + if (contentRect.contains(position)) { + if (!childContainsPosition) { + final projected = _childrenOffsetAt( + item.children, + _projectIntoContent(contentRect, position), + ); + if (projected != null) { + return projected; + } + } + return childOffset ?? item.contentStart; + } + } + + final rowRect = _slotRect(item.path, MarkdownSelectionSlotRole.listRow); + if (rowRect == null) { + continue; + } + final distance = _distanceToRect(position, rowRect); + if (distance < bestDistance) { + bestDistance = distance; + nearest = item; + nearestRowRect = rowRect; + } + if (!rowRect.contains(position)) { + continue; + } + final localDx = position.dx - rowRect.left; + final markerWidth = math.min( + markdownListMarkerWidth(list.list, item.index), + rowRect.width, + ); + if (localDx <= markerWidth) { + return item.start; + } + if (contentRect != null && + position.dy >= contentRect.top && + position.dy <= contentRect.bottom) { + final projected = _childrenOffsetAt( + item.children, + _projectIntoContent(contentRect, position), + ); + if (projected != null) { + return projected; + } + } + if (localDx <= _listMarkerExtent(list, item, rowRect, contentRect)) { + return item.start; + } + return childOffset ?? item.contentStart; + } + + if (nearest == null || nearestRowRect == null) { + return null; + } + final preferEnd = position.dy > nearestRowRect.center.dy || + position.dx > nearestRowRect.center.dx; + return preferEnd ? nearest.end : nearest.start; + } + + /// [position] moved to the left edge of the item content, at the same + /// height inside it. + Offset _projectIntoContent(Rect contentRect, Offset position) { + final dy = contentRect.height < 1 + ? contentRect.center.dy + : position.dy.clamp(contentRect.top + 0.5, contentRect.bottom - 0.5); + return Offset(contentRect.left + 1, dy.toDouble()); + } + + /// The cell whose text is nearest to [position]. + ({ + _LeafNode node, + ({MarkdownSelectableLeafState state, Offset origin}) leaf, + })? _nearestCell(_TableNode table, Offset position) { + ({ + _LeafNode node, + ({MarkdownSelectableLeafState state, Offset origin}) leaf, + })? nearest; + var bestDistance = double.infinity; + for (final cell in table.cells) { + final leaf = _leaf(cell); + final size = leaf?.state.box?.size; + if (leaf == null || size == null) { + continue; + } + final distance = _distanceToRect(position, leaf.origin & size); + if (distance < bestDistance) { + bestDistance = distance; + nearest = (node: cell, leaf: leaf); + } + } + return nearest; + } + + // Selection units. + + _Range? unitAt(_Node node, Offset position) { + switch (node) { + case _LeafNode(): + final leaf = _leaf(node); + if (leaf == null) { + return null; + } + if (leaf.state.selectsWhole) { + return (start: node.start, end: node.end); + } + final localPosition = position - leaf.origin; + final range = leaf.state.lineRangeAt( + leaf.state.offsetAt(localPosition), + localPosition: localPosition, + ); + if (range == null) { + return null; + } + return ( + start: node.leaf.displayedOffsetForLocalOffset(range.start), + end: node.leaf.displayedOffsetForLocalOffset(range.end), + ); + case _SequenceNode(:final children): + final child = _childContaining(children, position); + return child == null ? null : unitAt(child, position); + case _ListNode(): + return _listUnitAt(node, position); + case _TableNode(): + final cell = _nearestCell(node, position)?.node; + return cell == null ? null : (start: cell.start, end: cell.end); + } + } + + _Range? _listUnitAt(_ListNode list, Offset position) { + for (final item in list.items) { + final rowRect = _slotRect(item.path, MarkdownSelectionSlotRole.listRow); + if (rowRect != null && + rowRect.contains(position) && + position.dx - rowRect.left <= + _listMarkerExtent(list, item, rowRect, null)) { + return (start: item.start, end: item.end); + } + + final contentRect = + _slotRect(item.path, MarkdownSelectionSlotRole.listContent); + if (contentRect == null || !contentRect.contains(position)) { + continue; + } + final child = _childContaining(item.children, position) ?? + _childContaining( + item.children, + _projectIntoContent(contentRect, position), + ); + if (child == null) { + continue; + } + final unit = unitAt(child, position); + if (unit == null) { + return null; + } + final includeMarker = + identical(child, item.leadTextChild) && unit.start == child.start; + return (start: includeMarker ? item.start : unit.start, end: unit.end); + } + return null; + } + + _Node? _childContaining(List<_Node> children, Offset position) { + for (final child in children) { + if (_childRect(child)?.contains(position) ?? false) { + return child; + } + } + return null; + } +} + +bool _rectsShareLine(Rect a, Rect b) { + final overlapTop = math.max(a.top, b.top); + final overlapBottom = math.min(a.bottom, b.bottom); + return overlapBottom - overlapTop > 1.0; +} + +double _distanceToRect(Offset point, Rect rect) { + final dx = point.dx < rect.left + ? rect.left - point.dx + : point.dx > rect.right + ? point.dx - rect.right + : 0.0; + final dy = point.dy < rect.top + ? rect.top - point.dy + : point.dy > rect.bottom + ? point.dy - rect.bottom + : 0.0; + return dx * dx + dy * dy; +} diff --git a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_resolver.dart b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_resolver.dart deleted file mode 100644 index cde71aeb..00000000 --- a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_resolver.dart +++ /dev/null @@ -1,2970 +0,0 @@ -import 'dart:math' as math; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart' show RenderObject, RenderParagraph; - -import '../../core/document.dart'; -import '../../widgets/markdown_theme.dart'; -import '../code_syntax_highlighter.dart'; -import '../markdown_block_widgets.dart'; -import '../builder/markdown_inline_builder.dart'; -import '../pretext_text_block.dart'; -import 'markdown_descriptor_extractor.dart'; - -class MarkdownBlockKeysRegistry { - final Map>> blockKeys = {}; - final Map> listItemKeysByBlock = {}; - final Map> listItemContentKeysByBlock = {}; - final Map>> listItemChildKeysByBlock = {}; - final Map> quoteChildKeysByBlock = {}; - final Map> detailsChildKeysByBlock = {}; - final Map>> tableCellKeysByBlock = {}; - final Map>> tableCellTextKeysByBlock = {}; - final Map>> tableBlockKeys = {}; - final Map tableScrollControllers = {}; - final Map tableViewportKeys = {}; - final Map codeBlockScrollControllers = {}; - final Map codeBlockViewportKeys = {}; - final Map> imageErrorNotifiers = {}; - - List> tableCellKeysFor(TableBlock block) { - return _ensureTableKeys(block, tableCellKeysByBlock, 'table'); - } - - List> tableCellTextKeysFor(TableBlock block) { - return _ensureTableKeys(block, tableCellTextKeysByBlock, 'table-text'); - } - - List> _ensureTableKeys( - TableBlock block, - Map>> registry, - String prefix, - ) { - final keys = registry.putIfAbsent(block.id, () => >[]); - while (keys.length < block.rows.length) { - keys.add([]); - } - if (keys.length > block.rows.length) { - keys.removeRange(block.rows.length, keys.length); - } - for (var rowIndex = 0; rowIndex < block.rows.length; rowIndex++) { - final rowKeys = keys[rowIndex]; - final row = block.rows[rowIndex]; - while (rowKeys.length < row.cells.length) { - rowKeys.add(GlobalKey( - debugLabel: '$prefix-${block.id}-$rowIndex-${rowKeys.length}')); - } - if (rowKeys.length > row.cells.length) { - rowKeys.removeRange(row.cells.length, rowKeys.length); - } - } - return keys; - } - - List listItemKeysFor(ListBlock block) { - final keys = listItemKeysByBlock.putIfAbsent(block.id, () => []); - while (keys.length < block.items.length) { - keys.add(GlobalKey(debugLabel: 'list-${block.id}-${keys.length}')); - } - if (keys.length > block.items.length) { - keys.removeRange(block.items.length, keys.length); - } - return keys; - } - - List listItemContentKeysFor(ListBlock block) { - final keys = - listItemContentKeysByBlock.putIfAbsent(block.id, () => []); - while (keys.length < block.items.length) { - keys.add( - GlobalKey(debugLabel: 'list-content-${block.id}-${keys.length}')); - } - if (keys.length > block.items.length) { - keys.removeRange(block.items.length, keys.length); - } - return keys; - } - - List listItemChildKeysFor(ListBlock block, int itemIndex) { - final keySets = listItemChildKeysByBlock.putIfAbsent( - block.id, () => >[]); - while (keySets.length < block.items.length) { - keySets.add([]); - } - if (keySets.length > block.items.length) { - keySets.removeRange(block.items.length, keySets.length); - } - - final keys = keySets[itemIndex]; - final childCount = block.items[itemIndex].children.length; - while (keys.length < childCount) { - keys.add(GlobalKey( - debugLabel: 'list-child-${block.id}-$itemIndex-${keys.length}')); - } - if (keys.length > childCount) { - keys.removeRange(childCount, keys.length); - } - return keys; - } - - List quoteChildKeysFor(QuoteBlock block) { - final keys = - quoteChildKeysByBlock.putIfAbsent(block.id, () => []); - while (keys.length < block.children.length) { - keys.add(GlobalKey(debugLabel: 'quote-${block.id}-${keys.length}')); - } - if (keys.length > block.children.length) { - keys.removeRange(block.children.length, keys.length); - } - return keys; - } - - List detailsChildKeysFor(DetailsBlock block) { - final keys = - detailsChildKeysByBlock.putIfAbsent(block.id, () => []); - final childCount = block.children.length + 1; - while (keys.length < childCount) { - keys.add(GlobalKey(debugLabel: 'details-${block.id}-${keys.length}')); - } - if (keys.length > childCount) { - keys.removeRange(childCount, keys.length); - } - return keys; - } - - void cleanupKeys(Set validIds) { - blockKeys.removeWhere((key, _) => !validIds.contains(key)); - listItemKeysByBlock.removeWhere((key, _) => !validIds.contains(key)); - listItemContentKeysByBlock.removeWhere((key, _) => !validIds.contains(key)); - listItemChildKeysByBlock.removeWhere((key, _) => !validIds.contains(key)); - quoteChildKeysByBlock.removeWhere((key, _) => !validIds.contains(key)); - detailsChildKeysByBlock.removeWhere((key, _) => !validIds.contains(key)); - tableCellKeysByBlock.removeWhere((key, _) => !validIds.contains(key)); - tableCellTextKeysByBlock.removeWhere((key, _) => !validIds.contains(key)); - tableBlockKeys.removeWhere((key, _) => !validIds.contains(key)); - final staleCodeBlockIds = codeBlockScrollControllers.keys - .where((key) => !validIds.contains(key)) - .toList(growable: false); - for (final blockId in staleCodeBlockIds) { - codeBlockScrollControllers.remove(blockId)?.dispose(); - } - codeBlockViewportKeys.removeWhere((key, _) => !validIds.contains(key)); - final staleTableIds = tableScrollControllers.keys - .where((key) => !validIds.contains(key)) - .toList(growable: false); - for (final blockId in staleTableIds) { - tableScrollControllers.remove(blockId)?.dispose(); - } - tableViewportKeys.removeWhere((key, _) => !validIds.contains(key)); - final staleImageNotifierIds = imageErrorNotifiers.keys - .where((key) => !validIds.contains(key)) - .toList(growable: false); - for (final blockId in staleImageNotifierIds) { - imageErrorNotifiers.remove(blockId)?.dispose(); - } - } -} - -class MarkdownSelectionResolver { - const MarkdownSelectionResolver({ - required this.theme, - required this.extractor, - required this.keysRegistry, - required this.codeSyntaxHighlighter, - }); - - final MarkdownThemeData theme; - final MarkdownDescriptorExtractor extractor; - final MarkdownBlockKeysRegistry keysRegistry; - final MarkdownCodeSyntaxHighlighter codeSyntaxHighlighter; - - int? resolveListTextOffset( - BuildContext context, - ListBlock block, - Offset localPosition, - ) { - final rootRenderObject = context.findRenderObject(); - if (rootRenderObject is! RenderBox || !rootRenderObject.hasSize) { - return null; - } - - return _resolveListTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - block: block, - globalPosition: rootRenderObject.localToGlobal(localPosition), - ); - } - - DocumentRange? resolveListSelectionUnitRange( - BuildContext context, - ListBlock block, - Offset localPosition, - DocumentPosition position, - ) { - final rootRenderObject = context.findRenderObject(); - if (rootRenderObject is! RenderBox || !rootRenderObject.hasSize) { - return null; - } - - return _resolveListSelectionUnitRangeInRoot( - context, - rootRenderObject: rootRenderObject, - block: block, - globalPosition: rootRenderObject.localToGlobal(localPosition), - blockIndex: position.blockIndex, - ); - } - - DocumentRange? resolveQuoteSelectionUnitRange( - BuildContext context, - QuoteBlock block, - Offset localPosition, - DocumentPosition position, - ) { - final rootRenderObject = context.findRenderObject(); - if (rootRenderObject is! RenderBox || !rootRenderObject.hasSize) { - return null; - } - - return _resolveQuoteSelectionUnitRangeInRoot( - context, - rootRenderObject: rootRenderObject, - block: block, - globalPosition: rootRenderObject.localToGlobal(localPosition), - blockIndex: position.blockIndex, - ); - } - - DocumentRange? resolveDetailsSelectionUnitRange( - BuildContext context, - DetailsBlock block, - Offset localPosition, - DocumentPosition position, - ) { - final rootRenderObject = context.findRenderObject(); - if (rootRenderObject is! RenderBox || !rootRenderObject.hasSize) { - return null; - } - - return _resolveDetailsSelectionUnitRangeInRoot( - context, - rootRenderObject: rootRenderObject, - block: block, - globalPosition: rootRenderObject.localToGlobal(localPosition), - blockIndex: position.blockIndex, - ); - } - - int? _resolveListTextOffsetInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required ListBlock block, - required Offset globalPosition, - int indentLevel = 0, - }) { - final indexedDescriptors = extractor.buildIndexedListSelectionDescriptors( - block, - indentLevel: indentLevel, - ); - if (indexedDescriptors.isEmpty) { - return null; - } - - final contentKeys = keysRegistry.listItemContentKeysFor(block); - final rowKeys = keysRegistry.listItemKeysFor(block); - IndexedListSelectionDescriptor? nearestEntry; - Rect? nearestRowRect; - double bestDistance = double.infinity; - - for (final entry in indexedDescriptors) { - final contentContext = contentKeys[entry.itemIndex].currentContext; - final contentRenderObject = contentContext?.findRenderObject(); - Rect? contentRect; - List? childEntries; - int? childOffset; - if (contentRenderObject is RenderBox && contentRenderObject.hasSize) { - contentRect = contentRenderObject.localToGlobal(Offset.zero) & - contentRenderObject.size; - childEntries = extractor.buildIndexedBlockDescriptors( - block.items[entry.itemIndex].children, - indentLevel: entry.contentIndentLevel, - separator: '\n', - ); - final childKeys = - keysRegistry.listItemChildKeysFor(block, entry.itemIndex); - var childContainsPosition = false; - for (final childEntry in childEntries) { - final childContext = childKeys[childEntry.childIndex].currentContext; - final childRenderObject = childContext?.findRenderObject(); - if (childRenderObject is! RenderBox || !childRenderObject.hasSize) { - continue; - } - final childRect = childRenderObject.localToGlobal(Offset.zero) & - childRenderObject.size; - if (childRect.contains(globalPosition)) { - childContainsPosition = true; - break; - } - } - childOffset = resolveIndexedBlockTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - entries: childEntries, - childKeys: childKeys, - globalPosition: globalPosition, - ); - if (contentRect.contains(globalPosition)) { - if (!childContainsPosition) { - final projectedChildOffset = resolveIndexedBlockTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - entries: childEntries, - childKeys: childKeys, - globalPosition: Offset( - contentRect.left + 1, - globalPosition.dy.clamp( - contentRect.top + 0.5, - contentRect.bottom - 0.5, - ), - ), - ); - if (projectedChildOffset != null) { - return _mapListContentOffsetToDisplayedItemOffset( - entry, - projectedChildOffset, - ); - } - } - return _mapListContentOffsetToDisplayedItemOffset( - entry, - childOffset ?? 0, - ); - } - } - - final rowContext = rowKeys[entry.itemIndex].currentContext; - final rowRenderObject = rowContext?.findRenderObject(); - if (rowRenderObject is RenderBox && rowRenderObject.hasSize) { - final rect = - rowRenderObject.localToGlobal(Offset.zero) & rowRenderObject.size; - final distance = _distanceToRect(globalPosition, rect); - if (distance < bestDistance) { - bestDistance = distance; - nearestEntry = entry; - nearestRowRect = rect; - } - if (rect.contains(globalPosition)) { - final localRowPosition = - rowRenderObject.globalToLocal(globalPosition); - final markerWidth = math.min( - markdownListMarkerWidth(block, entry.itemIndex), - rowRenderObject.size.width, - ); - final markerExtent = _resolveListMarkerExtent( - block: block, - itemIndex: entry.itemIndex, - rowRenderObject: rowRenderObject, - contentRenderObject: - contentRenderObject is RenderBox ? contentRenderObject : null, - ); - if (localRowPosition.dx <= markerWidth) { - return entry.startOffset; - } - if (contentRect != null && - childEntries != null && - globalPosition.dy >= contentRect.top && - globalPosition.dy <= contentRect.bottom) { - final projectedChildOffset = resolveIndexedBlockTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - entries: childEntries, - childKeys: - keysRegistry.listItemChildKeysFor(block, entry.itemIndex), - globalPosition: Offset( - contentRect.left + 1, - globalPosition.dy.clamp( - contentRect.top + 0.5, - contentRect.bottom - 0.5, - ), - ), - ); - if (projectedChildOffset != null) { - return _mapListContentOffsetToDisplayedItemOffset( - entry, - projectedChildOffset, - ); - } - } - if (localRowPosition.dx <= markerExtent) { - return entry.startOffset; - } - return _mapListContentOffsetToDisplayedItemOffset( - entry, - childOffset ?? 0, - ); - } - } - } - - if (nearestEntry != null && nearestRowRect != null) { - final preferEnd = globalPosition.dy > nearestRowRect.center.dy || - globalPosition.dx > nearestRowRect.center.dx; - return nearestEntry.startOffset + - (preferEnd ? nearestEntry.descriptor.plainText.length : 0); - } - - return null; - } - - DocumentRange? _resolveListSelectionUnitRangeInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required ListBlock block, - required Offset globalPosition, - required int blockIndex, - int indentLevel = 0, - }) { - final indexedDescriptors = extractor.buildIndexedListSelectionDescriptors( - block, - indentLevel: indentLevel, - ); - if (indexedDescriptors.isEmpty) { - return null; - } - - final contentKeys = keysRegistry.listItemContentKeysFor(block); - final rowKeys = keysRegistry.listItemKeysFor(block); - final textDirection = Directionality.of(context); - - for (final entry in indexedDescriptors) { - final itemStart = entry.startOffset; - final itemEnd = itemStart + entry.descriptor.plainText.length; - final rowContext = rowKeys[entry.itemIndex].currentContext; - final rowRenderObject = rowContext?.findRenderObject(); - if (rowRenderObject is RenderBox && rowRenderObject.hasSize) { - final rowRect = - rowRenderObject.localToGlobal(Offset.zero) & rowRenderObject.size; - if (rowRect.contains(globalPosition)) { - final localRowPosition = - rowRenderObject.globalToLocal(globalPosition); - final markerExtent = _resolveListMarkerExtent( - block: block, - itemIndex: entry.itemIndex, - rowRenderObject: rowRenderObject, - contentRenderObject: null, - ); - if (localRowPosition.dx <= markerExtent) { - return DocumentRange( - start: DocumentPosition( - blockIndex: blockIndex, - path: const PathInBlock([0]), - textOffset: itemStart, - ), - end: DocumentPosition( - blockIndex: blockIndex, - path: const PathInBlock([0]), - textOffset: itemEnd, - ), - ); - } - } - } - - final contentContext = contentKeys[entry.itemIndex].currentContext; - final contentRenderObject = contentContext?.findRenderObject(); - if (contentRenderObject is! RenderBox || !contentRenderObject.hasSize) { - continue; - } - final contentRect = contentRenderObject.localToGlobal(Offset.zero) & - contentRenderObject.size; - if (!contentRect.contains(globalPosition)) { - continue; - } - - final childEntries = extractor.buildIndexedBlockDescriptors( - block.items[entry.itemIndex].children, - indentLevel: entry.contentIndentLevel, - separator: '\n', - ); - final childKeys = - keysRegistry.listItemChildKeysFor(block, entry.itemIndex); - final childHit = _resolveIndexedBlockHitInRoot( - context, - rootRenderObject: rootRenderObject, - entries: childEntries, - childKeys: childKeys, - globalPosition: globalPosition, - ) ?? - _resolveIndexedBlockHitInRoot( - context, - rootRenderObject: rootRenderObject, - entries: childEntries, - childKeys: childKeys, - globalPosition: Offset( - contentRect.left + 1, - globalPosition.dy.clamp( - contentRect.top + 0.5, - contentRect.bottom - 0.5, - ), - ), - ); - if (childHit == null) { - continue; - } - - final childUnit = _resolveNestedBlockSelectionUnitRangeInRoot( - context, - rootRenderObject: rootRenderObject, - hit: childHit, - globalPosition: globalPosition, - textDirection: textDirection, - ); - if (childUnit == null) { - return null; - } - - return _mapListChildRangeToItemRange( - blockIndex: blockIndex, - itemEntry: entry, - childEntry: childHit.entry, - childRange: childUnit, - ); - } - - return null; - } - - DocumentRange? _resolveQuoteSelectionUnitRangeInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required QuoteBlock block, - required Offset globalPosition, - required int blockIndex, - }) { - final entries = extractor.buildIndexedBlockDescriptors( - block.children, - separator: '\n\n', - paragraphStyle: theme.quoteStyle, - ); - if (entries.isEmpty) { - return null; - } - - final childHit = _resolveIndexedBlockHitInRoot( - context, - rootRenderObject: rootRenderObject, - entries: entries, - childKeys: keysRegistry.quoteChildKeysFor(block), - globalPosition: globalPosition, - ); - if (childHit == null) { - return null; - } - - final childUnit = _resolveNestedBlockSelectionUnitRangeInRoot( - context, - rootRenderObject: rootRenderObject, - hit: childHit, - globalPosition: globalPosition, - textDirection: Directionality.of(context), - ); - if (childUnit == null) { - return null; - } - - return _mapIndexedChildRangeToParentRange( - blockIndex: blockIndex, - childEntry: childHit.entry, - childRange: childUnit, - ); - } - - DocumentRange? _resolveDetailsSelectionUnitRangeInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required DetailsBlock block, - required Offset globalPosition, - required int blockIndex, - }) { - final entries = extractor.buildIndexedBlockDescriptors( - MarkdownDescriptorExtractor.detailsSelectionBlocks(block), - separator: '\n\n', - ); - if (entries.isEmpty) { - return null; - } - - final childHit = _resolveIndexedBlockHitInRoot( - context, - rootRenderObject: rootRenderObject, - entries: entries, - childKeys: keysRegistry.detailsChildKeysFor(block), - globalPosition: globalPosition, - ); - if (childHit == null) { - return null; - } - - final childUnit = _resolveNestedBlockSelectionUnitRangeInRoot( - context, - rootRenderObject: rootRenderObject, - hit: childHit, - globalPosition: globalPosition, - textDirection: Directionality.of(context), - ); - if (childUnit == null) { - return null; - } - - return _mapIndexedChildRangeToParentRange( - blockIndex: blockIndex, - childEntry: childHit.entry, - childRange: childUnit, - ); - } - - int resolveTextOffsetInBox( - InlineSpan span, - int textLength, - Offset localPosition, - Size size, - TextDirection textDirection, - ) { - final textPainter = TextPainter( - text: span, - textDirection: textDirection, - maxLines: null, - )..layout(maxWidth: size.width); - final clampedOffset = Offset( - localPosition.dx.clamp(0.0, math.max(textPainter.width, 0.0)), - localPosition.dy.clamp(0.0, math.max(textPainter.height, 0.0)), - ); - final textPosition = textPainter.getPositionForOffset(clampedOffset); - final offset = textPosition.offset; - if (offset < 0) { - return 0; - } - if (offset > textLength) { - return textLength; - } - return offset; - } - - int resolveDescriptorTextOffset( - BuildContext context, { - required SelectableTextDescriptor descriptor, - required Offset localPosition, - required Size size, - required TextDirection textDirection, - }) { - final pretext = descriptor.pretext; - if (pretext != null) { - final layout = computeDescriptorPretextLayout( - context, - descriptor: descriptor, - maxWidth: size.width, - textDirection: textDirection, - ); - return layout.textOffsetAt( - localPosition, - textDirection: textDirection, - ); - } - - return resolveTextOffsetInBox( - descriptor.span, - descriptor.plainText.length, - localPosition, - size, - textDirection, - ); - } - - DocumentRange? _resolveNestedBlockSelectionUnitRangeInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required _ResolvedIndexedBlockHit hit, - required Offset globalPosition, - required TextDirection textDirection, - }) { - switch (hit.entry.block.kind) { - case MarkdownBlockKind.heading: - case MarkdownBlockKind.paragraph: - case MarkdownBlockKind.definitionList: - if (_isStandaloneLineBreakBlock(hit.entry.block)) { - return DocumentRange( - start: const DocumentPosition( - blockIndex: 0, - path: PathInBlock([0]), - textOffset: 0, - ), - end: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: hit.entry.descriptor.plainText.length, - ), - ); - } - return _resolveDescriptorLineRange( - context, - descriptor: hit.entry.descriptor, - renderObject: hit.renderObject, - globalPosition: globalPosition, - textDirection: textDirection, - ); - case MarkdownBlockKind.codeBlock: - return _resolveCodeBlockLineRange( - context, - block: hit.entry.block as CodeBlock, - descriptor: hit.entry.descriptor, - renderObject: hit.renderObject, - globalPosition: globalPosition, - textDirection: textDirection, - ); - case MarkdownBlockKind.orderedList: - case MarkdownBlockKind.unorderedList: - return _resolveListSelectionUnitRangeInRoot( - context, - rootRenderObject: rootRenderObject, - block: hit.entry.block as ListBlock, - globalPosition: globalPosition, - blockIndex: 0, - indentLevel: hit.entry.indentLevel, - ); - case MarkdownBlockKind.footnoteList: - return _resolveListSelectionUnitRangeInRoot( - context, - rootRenderObject: rootRenderObject, - block: MarkdownDescriptorExtractor.footnoteListAsOrderedList( - hit.entry.block as FootnoteListBlock, - ), - globalPosition: globalPosition, - blockIndex: 0, - indentLevel: hit.entry.indentLevel, - ); - case MarkdownBlockKind.quote: - return _resolveQuoteSelectionUnitRangeInRoot( - context, - rootRenderObject: rootRenderObject, - block: hit.entry.block as QuoteBlock, - globalPosition: globalPosition, - blockIndex: 0, - ); - case MarkdownBlockKind.details: - return _resolveDetailsSelectionUnitRangeInRoot( - context, - rootRenderObject: rootRenderObject, - block: hit.entry.block as DetailsBlock, - globalPosition: globalPosition, - blockIndex: 0, - ); - case MarkdownBlockKind.table: - return _resolveTableSelectionUnitRangeInRoot( - context, - rootRenderObject: rootRenderObject, - block: hit.entry.block as TableBlock, - globalPosition: globalPosition, - blockIndex: 0, - ); - case MarkdownBlockKind.image: - case MarkdownBlockKind.thematicBreak: - if (hit.entry.descriptor.plainText.isEmpty) { - return null; - } - return DocumentRange( - start: const DocumentPosition( - blockIndex: 0, - path: PathInBlock([0]), - textOffset: 0, - ), - end: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: hit.entry.descriptor.plainText.length, - ), - ); - } - } - - DocumentRange? _resolveDescriptorLineRange( - BuildContext context, { - required SelectableTextDescriptor descriptor, - required RenderBox renderObject, - required Offset globalPosition, - required TextDirection textDirection, - }) { - final localPosition = renderObject.globalToLocal(globalPosition); - final textOffset = _descriptorUsesDirectRichTextMetrics(descriptor) - ? _resolveDirectRenderParagraphTextOffset( - renderObject, - globalPosition: globalPosition, - runs: descriptor.pretext!.runs, - ) ?? - 0 - : resolveDescriptorTextOffset( - context, - descriptor: descriptor, - localPosition: localPosition, - size: renderObject.size, - textDirection: textDirection, - ); - - final pretext = descriptor.pretext; - if (pretext != null && _descriptorUsesDirectRichTextMetrics(descriptor)) { - final directRange = _resolveDirectRenderParagraphLineRange( - renderObject, - globalPosition: globalPosition, - runs: pretext.runs, - ); - if (directRange != null) { - return directRange; - } - } - if (pretext != null) { - final layout = computeDescriptorPretextLayout( - context, - descriptor: descriptor, - maxWidth: renderObject.size.width, - textDirection: textDirection, - ); - final lineRange = layout.visualLineRangeForLocalPosition(localPosition) ?? - layout.lineRangeForTextOffset(textOffset); - if (lineRange == null) { - return null; - } - return DocumentRange( - start: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: lineRange.start, - ), - end: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: lineRange.end, - ), - ); - } - - final textPainter = TextPainter( - text: descriptor.span, - textDirection: textDirection, - maxLines: null, - )..layout(maxWidth: renderObject.size.width); - final clampedOffset = textOffset.clamp(0, descriptor.plainText.length); - final boundary = textPainter.getLineBoundary( - TextPosition(offset: clampedOffset), - ); - return DocumentRange( - start: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: boundary.start, - ), - end: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: boundary.end, - ), - ); - } - - DocumentRange? _resolveCodeBlockLineRange( - BuildContext context, { - required CodeBlock block, - required SelectableTextDescriptor descriptor, - required RenderBox renderObject, - required Offset globalPosition, - required TextDirection textDirection, - }) { - final localPosition = renderObject.globalToLocal(globalPosition); - final codeRuns = descriptor.pretext?.runs ?? - codeSyntaxHighlighter.buildPretextRuns( - source: block.code, - baseStyle: theme.codeBlockStyle, - theme: theme, - language: block.language, - ); - final measurementPadding = - theme.codeBlockPadding.resolve(Directionality.of(context)) + - const EdgeInsets.only(top: 36); - final textOffset = markdownPretextCanUseDirectRichTextGeometry(codeRuns) - ? _resolveDirectRenderParagraphTextOffset( - renderObject, - globalPosition: globalPosition, - runs: codeRuns, - ) ?? - 0 - : resolveTextSpanTextOffset( - codeSyntaxHighlighter.buildTextSpan( - source: block.code, - baseStyle: theme.codeBlockStyle, - theme: theme, - language: block.language, - ), - descriptor.plainText.length, - localPosition: localPosition, - size: renderObject.size, - textDirection: textDirection, - measurementPadding: measurementPadding, - ); - - final lineRange = markdownPretextCanUseDirectRichTextGeometry(codeRuns) - ? _resolveDirectRenderParagraphLineRange( - renderObject, - globalPosition: globalPosition, - runs: codeRuns, - ) - : null; - if (lineRange != null) { - return lineRange; - } - - final logicalLineRange = _resolveLogicalLineRange( - descriptor.plainText, - textOffset, - ); - if (logicalLineRange == null) { - return null; - } - return DocumentRange( - start: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: logicalLineRange.start, - ), - end: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: logicalLineRange.end, - ), - ); - } - - DocumentRange _mapListChildRangeToItemRange({ - required int blockIndex, - required IndexedListSelectionDescriptor itemEntry, - required IndexedBlockDescriptor childEntry, - required DocumentRange childRange, - }) { - final itemStart = itemEntry.startOffset; - final mappedStart = _mapListContentOffsetToDisplayedItemOffset( - itemEntry, - childEntry.startOffset + childRange.start.textOffset, - ); - final mappedEnd = _mapListContentOffsetToDisplayedItemOffset( - itemEntry, - childEntry.startOffset + childRange.end.textOffset, - ); - final includeMarker = childEntry.childIndex == 0 && - _isLeadListTextBlock(childEntry.block) && - childRange.start.textOffset == 0; - return DocumentRange( - start: DocumentPosition( - blockIndex: blockIndex, - path: const PathInBlock([0]), - textOffset: includeMarker ? itemStart : mappedStart, - ), - end: DocumentPosition( - blockIndex: blockIndex, - path: const PathInBlock([0]), - textOffset: mappedEnd, - ), - ); - } - - DocumentRange _mapIndexedChildRangeToParentRange({ - required int blockIndex, - required IndexedBlockDescriptor childEntry, - required DocumentRange childRange, - }) { - return DocumentRange( - start: DocumentPosition( - blockIndex: blockIndex, - path: const PathInBlock([0]), - textOffset: childEntry.startOffset + childRange.start.textOffset, - ), - end: DocumentPosition( - blockIndex: blockIndex, - path: const PathInBlock([0]), - textOffset: childEntry.startOffset + childRange.end.textOffset, - ), - ); - } - - DocumentRange? _resolveTableSelectionUnitRangeInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required TableBlock block, - required Offset globalPosition, - required int blockIndex, - }) { - final textOffset = _resolveTableTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - block: block, - globalPosition: globalPosition, - ); - if (textOffset == null) { - return null; - } - - return extractor.resolveTableCellSelectionRange( - DocumentPosition( - blockIndex: blockIndex, - path: const PathInBlock([0]), - textOffset: textOffset, - ), - block, - ); - } - - ({int start, int end})? _resolveLogicalLineRange( - String plainText, - int textOffset, - ) { - if (plainText.isEmpty) { - return null; - } - var clampedOffset = textOffset.clamp(0, plainText.length).toInt(); - if (clampedOffset == plainText.length && clampedOffset > 0) { - clampedOffset -= 1; - } - if (clampedOffset > 0 && - clampedOffset < plainText.length && - plainText.codeUnitAt(clampedOffset) == 0x0A) { - clampedOffset -= 1; - } - - var start = clampedOffset; - while (start > 0 && plainText.codeUnitAt(start - 1) != 0x0A) { - start -= 1; - } - var end = clampedOffset; - while (end < plainText.length && plainText.codeUnitAt(end) != 0x0A) { - end += 1; - } - return (start: start, end: end); - } - - int _mapDisplayedItemOffsetToContentOffset( - IndexedListSelectionDescriptor itemEntry, - int displayedOffset, - ) { - final raw = - displayedOffset - itemEntry.startOffset - itemEntry.prefixLength; - if (raw <= 0 || itemEntry.prefixLength == 0) { - return math.max(0, raw); - } - final plainText = itemEntry.contentDescriptor.plainText; - // Walk through content lines, subtracting the continuation prefix - // for each line boundary crossed. - var contentOffset = 0; - var remaining = raw; - for (var i = 0; i < plainText.length && remaining > 0; i++) { - if (plainText.codeUnitAt(i) == 0x0A) { - // Need 1 char for the newline itself plus prefixLength for the - // continuation prefix that was inserted in the displayed text. - final needed = 1 + itemEntry.prefixLength; - if (remaining < needed) { - contentOffset += remaining; - remaining = 0; - break; - } - remaining -= needed; - contentOffset += 1; - } else { - remaining -= 1; - contentOffset += 1; - } - } - contentOffset += remaining; - return contentOffset.clamp(0, plainText.length).toInt(); - } - - int _mapListContentOffsetToDisplayedItemOffset( - IndexedListSelectionDescriptor itemEntry, - int contentOffset, - ) { - final plainText = itemEntry.contentDescriptor.plainText; - final clampedOffset = contentOffset.clamp(0, plainText.length).toInt(); - final lineIndex = _lineIndexForOffset(plainText, clampedOffset); - return itemEntry.startOffset + - itemEntry.prefixLength + - clampedOffset + - itemEntry.prefixLength * lineIndex; - } - - int _lineIndexForOffset(String plainText, int offset) { - var lineIndex = 0; - for (var index = 0; index < offset; index++) { - if (plainText.codeUnitAt(index) == 0x0A) { - lineIndex += 1; - } - } - return lineIndex; - } - - bool _isLeadListTextBlock(BlockNode block) { - switch (block.kind) { - case MarkdownBlockKind.paragraph: - case MarkdownBlockKind.heading: - return true; - case MarkdownBlockKind.quote: - case MarkdownBlockKind.orderedList: - case MarkdownBlockKind.unorderedList: - case MarkdownBlockKind.definitionList: - case MarkdownBlockKind.footnoteList: - case MarkdownBlockKind.details: - case MarkdownBlockKind.codeBlock: - case MarkdownBlockKind.table: - case MarkdownBlockKind.image: - case MarkdownBlockKind.thematicBreak: - return false; - } - } - - _ResolvedIndexedBlockHit? _resolveIndexedBlockHitInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required List entries, - required List childKeys, - required Offset globalPosition, - }) { - final textDirection = Directionality.of(context); - for (final entry in entries) { - final childContext = childKeys[entry.childIndex].currentContext; - final childRenderObject = childContext?.findRenderObject(); - if (childRenderObject is! RenderBox || !childRenderObject.hasSize) { - continue; - } - final rect = - childRenderObject.localToGlobal(Offset.zero) & childRenderObject.size; - if (!rect.contains(globalPosition)) { - continue; - } - final childOffset = resolveNestedBlockTextOffset( - context, - rootRenderObject: rootRenderObject, - block: entry.block, - descriptor: entry.descriptor, - renderObject: childRenderObject, - globalPosition: globalPosition, - textDirection: textDirection, - indentLevel: entry.indentLevel, - ); - return _ResolvedIndexedBlockHit( - entry: entry, - renderObject: childRenderObject, - textOffset: childOffset, - ); - } - return null; - } - - MarkdownPretextLayoutResult computeDescriptorPretextLayout( - BuildContext context, { - required SelectableTextDescriptor descriptor, - required double maxWidth, - required TextDirection textDirection, - }) { - final pretext = descriptor.pretext!; - final textScaler = - MediaQuery.maybeTextScalerOf(context) ?? TextScaler.noScaling; - return computeMarkdownPretextLayoutFromRuns( - runs: pretext.runs, - fallbackStyle: pretext.fallbackStyle, - maxWidth: maxWidth, - textScaleFactor: textScaler.scale(1.0), - textAlign: pretext.textAlign, - textDirection: textDirection, - ); - } - - bool _descriptorUsesDirectRichTextMetrics( - SelectableTextDescriptor descriptor, - ) { - final pretext = descriptor.pretext; - return pretext != null && - markdownPretextCanUseDirectRichTextGeometry(pretext.runs); - } - - bool _isStandaloneLineBreakBlock(BlockNode block) { - if (block is! ParagraphBlock || block.inlines.length != 1) { - return false; - } - return block.inlines.single.kind == MarkdownInlineKind.hardBreak; - } - - List _placeholderBlockSelectionRects( - RenderBox renderObject, - Offset origin, - ) { - final rect = origin & renderObject.size; - return [ - Rect.fromLTRB(rect.left - 1.5, rect.top, rect.right + 1.5, rect.bottom), - ]; - } - - RenderParagraph? _findDirectRenderParagraphForRuns( - RenderObject renderObject, - List runs, - ) { - final expectedRenderText = markdownPretextRenderText(runs); - RenderParagraph? paragraph; - - void visit(RenderObject node) { - if (node is RenderParagraph && - node.text.toPlainText(includePlaceholders: true) == - expectedRenderText) { - paragraph = node; - return; - } - if (paragraph != null) { - return; - } - node.visitChildren(visit); - } - - visit(renderObject); - return paragraph; - } - - int? _resolveDirectRenderParagraphTextOffset( - RenderObject renderObject, { - required Offset globalPosition, - required List runs, - }) { - final paragraph = _findDirectRenderParagraphForRuns(renderObject, runs); - if (paragraph == null || !paragraph.hasSize) { - return null; - } - - final localPosition = paragraph.globalToLocal(globalPosition); - final textPosition = paragraph.getPositionForOffset(localPosition); - return markdownPretextPlainOffsetForRenderOffset(runs, textPosition.offset); - } - - DocumentRange? _resolveDirectRenderParagraphLineRange( - RenderObject renderObject, { - required Offset globalPosition, - required List runs, - }) { - final paragraph = _findDirectRenderParagraphForRuns(renderObject, runs); - if (paragraph == null || !paragraph.hasSize) { - return null; - } - - final textPosition = paragraph.getPositionForOffset( - paragraph.globalToLocal(globalPosition), - ); - final lineBoundary = _resolveDirectParagraphRenderLineBoundary( - paragraph, textPosition.offset); - if (lineBoundary == null) { - return null; - } - - final plainText = runs.map((run) => run.text).join(); - var plainStart = markdownPretextPlainOffsetForRenderOffset( - runs, - lineBoundary.start, - ); - var plainEnd = markdownPretextPlainOffsetForRenderOffset( - runs, - lineBoundary.end, - ); - if (plainStart < plainText.length && - plainText.codeUnitAt(plainStart) == 0x0A) { - plainStart += 1; - } - if (plainEnd > plainStart && - plainEnd <= plainText.length && - plainText.codeUnitAt(plainEnd - 1) == 0x0A) { - plainEnd -= 1; - } - return DocumentRange( - start: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: plainStart, - ), - end: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: plainEnd, - ), - ); - } - - ({int start, int end})? _resolveDirectParagraphRenderLineBoundary( - RenderParagraph paragraph, - int renderOffset, - ) { - final fullText = paragraph.text.toPlainText(includePlaceholders: true); - if (fullText.isEmpty) { - return const (start: 0, end: 0); - } - final lineExtents = _computeParagraphLineExtents(paragraph); - if (lineExtents.isEmpty) { - return null; - } - - var probe = renderOffset.clamp(0, fullText.length).toInt(); - if (probe == fullText.length && probe > 0) { - probe -= 1; - } - - List probeBoxes = - _boxesForDirectParagraphOffset(paragraph, fullText, probe); - while (probeBoxes.isEmpty && probe > 0) { - probe -= 1; - probeBoxes = _boxesForDirectParagraphOffset(paragraph, fullText, probe); - } - while (probeBoxes.isEmpty && probe < fullText.length - 1) { - probe += 1; - probeBoxes = _boxesForDirectParagraphOffset(paragraph, fullText, probe); - } - if (probeBoxes.isEmpty) { - return null; - } - - final lineExtent = lineExtents.firstWhere( - (extent) => _boxesOverlapParagraphLineExtent(probeBoxes, extent), - orElse: () => lineExtents.last, - ); - - var start = probe; - while (start > 0) { - final previousBoxes = - _boxesForDirectParagraphOffset(paragraph, fullText, start - 1); - if (previousBoxes.isEmpty || - !_boxesOverlapParagraphLineExtent(previousBoxes, lineExtent)) { - break; - } - start -= 1; - } - - var end = probe + 1; - while (end < fullText.length) { - final nextBoxes = - _boxesForDirectParagraphOffset(paragraph, fullText, end); - if (nextBoxes.isEmpty || - !_boxesOverlapParagraphLineExtent(nextBoxes, lineExtent)) { - break; - } - end += 1; - } - return (start: start, end: end); - } - - List _boxesForDirectParagraphOffset( - RenderParagraph paragraph, - String fullText, - int renderOffset, - ) { - if (fullText.isEmpty) { - return const []; - } - final start = renderOffset.clamp(0, fullText.length - 1).toInt(); - final end = math.min(start + 1, fullText.length); - if (start >= end) { - return const []; - } - return paragraph.getBoxesForSelection( - TextSelection(baseOffset: start, extentOffset: end), - ); - } - - bool _boxesOverlapParagraphLineExtent( - List boxes, - ({double top, double bottom}) lineExtent, - ) { - for (final box in boxes) { - final overlapTop = math.max(box.top, lineExtent.top); - final overlapBottom = math.min(box.bottom, lineExtent.bottom); - if (overlapBottom - overlapTop > 0.5) { - return true; - } - } - return false; - } - - List _resolveTableSelectionRectsInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required TableBlock block, - required DocumentRange range, - }) { - final cellKeys = keysRegistry.tableCellKeysFor(block); - final cellTextKeys = keysRegistry.tableCellTextKeysFor(block); - final rects = []; - var currentOffset = 0; - final textDirection = Directionality.of(context); - final padding = theme.tableCellPadding.resolve(textDirection); - - for (var rowIndex = 0; rowIndex < block.rows.length; rowIndex++) { - final row = block.rows[rowIndex]; - for (var columnIndex = 0; columnIndex < row.cells.length; columnIndex++) { - final cell = row.cells[columnIndex]; - final cellText = MarkdownInlineBuilder.flattenInlineText(cell.inlines); - final cellLength = cellText.length; - final cellStart = currentOffset; - final cellEnd = cellStart + cellLength; - - if (range.start.textOffset <= cellEnd && - range.end.textOffset >= cellStart) { - final textKey = cellTextKeys[rowIndex][columnIndex]; - final paragraph = - _findDirectRenderParagraphForKey(rootRenderObject, textKey); - - if (paragraph != null && paragraph.hasSize) { - final localStart = math.max(0, range.start.textOffset - cellStart); - final localEnd = - math.min(cellLength, range.end.textOffset - cellStart); - - final alignment = columnIndex < block.alignments.length - ? block.alignments[columnIndex] - : MarkdownTableColumnAlignment.none; - final cellDescriptor = extractor.descriptorFromInlines( - theme.bodyStyle, - cell.inlines, - textAlign: _textAlignForTableColumn(alignment), - ); - - final pretext = cellDescriptor.pretext; - if (pretext != null) { - final renderSelection = TextSelection( - baseOffset: markdownPretextRenderOffsetForPlainOffset( - pretext.runs, - localStart, - preferEnd: false, - ), - extentOffset: markdownPretextRenderOffsetForPlainOffset( - pretext.runs, - localEnd, - preferEnd: true, - ), - ); - final lineExtents = _computeParagraphLineExtents(paragraph); - final boxes = _mergeAdjacentTextSelectionBoxes( - _normalizeBoxesToLineExtents( - paragraph.getBoxesForSelection(renderSelection), - lineExtents, - ), - ); - rects.addAll(boxes.map((box) => Rect.fromLTRB( - box.left + - paragraph - .localToGlobal(Offset.zero, - ancestor: rootRenderObject) - .dx - - 1.5, - box.top + - paragraph - .localToGlobal(Offset.zero, - ancestor: rootRenderObject) - .dy, - box.right + - paragraph - .localToGlobal(Offset.zero, - ancestor: rootRenderObject) - .dx + - 1.5, - box.bottom + - paragraph - .localToGlobal(Offset.zero, - ancestor: rootRenderObject) - .dy, - ))); - } else { - final boxes = paragraph.getBoxesForSelection(TextSelection( - baseOffset: localStart, - extentOffset: localEnd, - )); - rects.addAll(boxes.map((box) => Rect.fromLTRB( - box.left + - paragraph - .localToGlobal(Offset.zero, - ancestor: rootRenderObject) - .dx - - 1.5, - box.top + - paragraph - .localToGlobal(Offset.zero, - ancestor: rootRenderObject) - .dy, - box.right + - paragraph - .localToGlobal(Offset.zero, - ancestor: rootRenderObject) - .dx + - 1.5, - box.bottom + - paragraph - .localToGlobal(Offset.zero, - ancestor: rootRenderObject) - .dy, - ))); - } - } else { - // Fallback - final cellContext = cellKeys[rowIndex][columnIndex].currentContext; - final cellRenderObject = cellContext?.findRenderObject(); - if (cellRenderObject is RenderBox && cellRenderObject.hasSize) { - final cellOrigin = cellRenderObject.localToGlobal(Offset.zero, - ancestor: rootRenderObject); - final localStart = - math.max(0, range.start.textOffset - cellStart); - final localEnd = - math.min(cellLength, range.end.textOffset - cellStart); - - final alignment = columnIndex < block.alignments.length - ? block.alignments[columnIndex] - : MarkdownTableColumnAlignment.none; - final textAlign = _textAlignForTableColumn(alignment); - - final cellDescriptor = extractor.descriptorFromInlines( - theme.bodyStyle, - cell.inlines, - textAlign: textAlign, - ); - - final cellRects = resolveDescriptorSelectionRects( - context, - descriptor: cellDescriptor, - range: DocumentRange( - start: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: localStart), - end: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: localEnd), - ), - size: Size( - math.max(0, cellRenderObject.size.width - padding.horizontal), - math.max(0, cellRenderObject.size.height - padding.vertical), - ), - textDirection: textDirection, - origin: cellOrigin + padding.topLeft, - ); - rects.addAll(cellRects); - } - } - } - currentOffset = cellEnd + 1; - } - } - return rects; - } - - int? _resolveTableTextOffsetInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required TableBlock block, - required Offset globalPosition, - }) { - final cellKeys = keysRegistry.tableCellKeysFor(block); - final cellTextKeys = keysRegistry.tableCellTextKeysFor(block); - var currentOffset = 0; - final textDirection = Directionality.of(context); - final padding = theme.tableCellPadding.resolve(textDirection); - - int? bestOffset; - double bestDistance = double.infinity; - - for (var rowIndex = 0; rowIndex < block.rows.length; rowIndex++) { - final row = block.rows[rowIndex]; - for (var columnIndex = 0; columnIndex < row.cells.length; columnIndex++) { - final cell = row.cells[columnIndex]; - final cellText = MarkdownInlineBuilder.flattenInlineText(cell.inlines); - final cellLength = cellText.length; - - final textKey = cellTextKeys[rowIndex][columnIndex]; - final paragraph = - _findDirectRenderParagraphForKey(rootRenderObject, textKey); - - if (paragraph != null && paragraph.hasSize) { - final rect = paragraph.localToGlobal(Offset.zero) & paragraph.size; - final distance = _distanceToRect(globalPosition, rect); - - if (distance < bestDistance) { - bestDistance = distance; - final localPos = paragraph.globalToLocal(globalPosition); - final textPosition = paragraph.getPositionForOffset(localPos); - - final alignment = columnIndex < block.alignments.length - ? block.alignments[columnIndex] - : MarkdownTableColumnAlignment.none; - final cellDescriptor = extractor.descriptorFromInlines( - theme.bodyStyle, - cell.inlines, - textAlign: _textAlignForTableColumn(alignment), - ); - - final pretext = cellDescriptor.pretext; - if (pretext != null) { - bestOffset = currentOffset + - markdownPretextPlainOffsetForRenderOffset( - pretext.runs, textPosition.offset); - } else { - bestOffset = currentOffset + textPosition.offset; - } - } - } else { - final cellContext = cellKeys[rowIndex][columnIndex].currentContext; - final cellRenderObject = cellContext?.findRenderObject(); - if (cellRenderObject is RenderBox && cellRenderObject.hasSize) { - final bounds = cellRenderObject.localToGlobal(Offset.zero) & - cellRenderObject.size; - final distance = _distanceToRect(globalPosition, bounds); - - if (distance < bestDistance) { - bestDistance = distance; - final localPosition = - cellRenderObject.globalToLocal(globalPosition); - - final alignment = columnIndex < block.alignments.length - ? block.alignments[columnIndex] - : MarkdownTableColumnAlignment.none; - final textAlign = _textAlignForTableColumn(alignment); - - final cellDescriptor = extractor.descriptorFromInlines( - theme.bodyStyle, - cell.inlines, - textAlign: textAlign, - ); - final offsetInCell = resolveDescriptorTextOffset( - context, - descriptor: cellDescriptor, - localPosition: localPosition - padding.topLeft, - size: Size( - math.max(0, cellRenderObject.size.width - padding.horizontal), - math.max(0, cellRenderObject.size.height - padding.vertical), - ), - textDirection: textDirection, - ); - bestOffset = currentOffset + offsetInCell; - } - } - } - currentOffset += cellLength + 1; - } - } - return bestOffset; - } - - double _distanceToRect(Offset p, Rect rect) { - final dx = p.dx < rect.left - ? rect.left - p.dx - : p.dx > rect.right - ? p.dx - rect.right - : 0.0; - final dy = p.dy < rect.top - ? rect.top - p.dy - : p.dy > rect.bottom - ? p.dy - rect.bottom - : 0.0; - return dx * dx + dy * dy; - } - - RenderParagraph? _findDirectRenderParagraphForKey( - RenderObject root, GlobalKey key) { - final context = key.currentContext; - if (context == null) { - return null; - } - final ro = context.findRenderObject(); - if (ro is RenderParagraph) { - return ro; - } - - RenderParagraph? result; - void visit(RenderObject node) { - if (node is RenderParagraph) { - result = node; - return; - } - if (result != null) { - return; - } - node.visitChildren(visit); - } - - if (ro != null) { - visit(ro); - } - return result; - } - - Rect? _resolveViewportRectInRoot( - RenderBox rootRenderObject, - GlobalKey? viewportKey, - ) { - final viewportRenderObject = - viewportKey?.currentContext?.findRenderObject(); - if (viewportRenderObject is! RenderBox || !viewportRenderObject.hasSize) { - return null; - } - final origin = rootRenderObject.globalToLocal( - viewportRenderObject.localToGlobal(Offset.zero), - ); - return origin & viewportRenderObject.size; - } - - List _clipSelectionRectsToViewport( - List rects, - Rect? viewportRect, - ) { - if (viewportRect == null || rects.isEmpty) { - return rects; - } - - final clippedRects = []; - for (final rect in rects) { - final clipped = rect.intersect(viewportRect); - if (!clipped.isEmpty) { - clippedRects.add(clipped); - } - } - return clippedRects; - } - - TextAlign _textAlignForTableColumn(MarkdownTableColumnAlignment alignment) { - switch (alignment) { - case MarkdownTableColumnAlignment.center: - return TextAlign.center; - case MarkdownTableColumnAlignment.right: - return TextAlign.right; - case MarkdownTableColumnAlignment.left: - case MarkdownTableColumnAlignment.none: - return TextAlign.left; - } - } - - List? _resolveDirectRenderParagraphSelectionRects( - RenderBox renderObject, { - required DocumentRange range, - required Offset origin, - required List runs, - }) { - final paragraph = _findDirectRenderParagraphForRuns(renderObject, runs); - if (paragraph == null || !paragraph.hasSize) { - return null; - } - - final renderSelection = TextSelection( - baseOffset: markdownPretextRenderOffsetForPlainOffset( - runs, - range.start.textOffset, - preferEnd: false, - ), - extentOffset: markdownPretextRenderOffsetForPlainOffset( - runs, - range.end.textOffset, - preferEnd: true, - ), - ); - final paragraphOrigin = renderObject.globalToLocal( - paragraph.localToGlobal(Offset.zero), - ); - final lineExtents = _computeParagraphLineExtents(paragraph); - final boxes = _mergeAdjacentTextSelectionBoxes( - _normalizeBoxesToLineExtents( - paragraph.getBoxesForSelection(renderSelection), - lineExtents, - ), - ); - return boxes - .map( - (box) => Rect.fromLTRB( - box.left + paragraphOrigin.dx + origin.dx - 1.5, - box.top + paragraphOrigin.dy + origin.dy, - box.right + paragraphOrigin.dx + origin.dx + 1.5, - box.bottom + paragraphOrigin.dy + origin.dy, - ), - ) - .toList(growable: false); - } - - FlutterError _missingDirectRenderParagraphError( - SelectableTextDescriptor descriptor, - ) { - final plainText = descriptor.plainText; - final snippet = plainText.length <= 120 - ? plainText - : '${plainText.substring(0, 120)}...'; - final renderText = descriptor.pretext == null - ? plainText - : markdownPretextRenderText(descriptor.pretext!.runs); - return FlutterError( - 'Expected a live RenderParagraph for a direct-geometry markdown block, ' - 'but none could be resolved. This block must not fall back to a different ' - 'geometry model because that produces incorrect selection rects and hit ' - 'testing. plainText="$snippet" renderText="$renderText"', - ); - } - - List resolveDescriptorSelectionRects( - BuildContext context, { - required SelectableTextDescriptor descriptor, - required DocumentRange range, - required Size size, - required TextDirection textDirection, - Offset origin = Offset.zero, - }) { - final pretext = descriptor.pretext; - if (pretext != null) { - final layout = computeDescriptorPretextLayout( - context, - descriptor: descriptor, - maxWidth: size.width, - textDirection: textDirection, - ); - return layout - .selectionRectsForRange(range, textDirection: textDirection) - .map((rect) => rect.shift(origin)) - .toList(growable: false); - } - - final textPainter = TextPainter( - text: descriptor.span, - textDirection: textDirection, - maxLines: null, - )..layout(maxWidth: size.width); - final boxes = textPainter.getBoxesForSelection( - TextSelection( - baseOffset: range.start.textOffset, - extentOffset: range.end.textOffset, - ), - ); - return boxes - .map( - (box) => Rect.fromLTRB( - box.left + origin.dx - 1.5, - box.top + origin.dy, - box.right + origin.dx + 1.5, - box.bottom + origin.dy, - ), - ) - .toList(growable: false); - } - - List resolveListSelectionRects( - BuildContext context, - ListBlock block, - DocumentRange range, - ) { - final rootRenderObject = context.findRenderObject(); - if (rootRenderObject is! RenderBox || !rootRenderObject.hasSize) { - return const []; - } - - return _resolveListSelectionRectsInRoot( - context, - rootRenderObject: rootRenderObject, - block: block, - range: range, - ); - } - - List _resolveListSelectionRectsInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required ListBlock block, - required DocumentRange range, - int indentLevel = 0, - }) { - final indexedDescriptors = extractor.buildIndexedListSelectionDescriptors( - block, - indentLevel: indentLevel, - ); - if (indexedDescriptors.isEmpty) { - return const []; - } - - final rects = []; - final rowKeys = keysRegistry.listItemKeysFor(block); - final contentKeys = keysRegistry.listItemContentKeysFor(block); - - for (final entry in indexedDescriptors) { - final itemStart = entry.startOffset; - final itemEnd = itemStart + entry.descriptor.plainText.length; - final contentSelectionStart = math.max( - range.start.textOffset, - itemStart + entry.prefixLength, - ); - final contentSelectionEnd = math.min(range.end.textOffset, itemEnd); - final prefixSelectionStart = math.max(range.start.textOffset, itemStart); - final prefixSelectionEnd = math.min( - range.end.textOffset, - itemStart + entry.prefixLength, - ); - if (contentSelectionStart >= contentSelectionEnd && - prefixSelectionStart >= prefixSelectionEnd) { - continue; - } - - final contentContext = contentKeys[entry.itemIndex].currentContext; - final contentRenderObject = contentContext?.findRenderObject(); - final rowContext = rowKeys[entry.itemIndex].currentContext; - final rowRenderObject = rowContext?.findRenderObject(); - Rect? prefixRect; - var contentRects = const []; - if (contentSelectionStart < contentSelectionEnd && - contentRenderObject is RenderBox && - contentRenderObject.hasSize) { - final itemRange = DocumentRange( - start: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: _mapDisplayedItemOffsetToContentOffset( - entry, - contentSelectionStart, - ), - ), - end: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: _mapDisplayedItemOffsetToContentOffset( - entry, - contentSelectionEnd, - ), - ), - ); - final childEntries = extractor.buildIndexedBlockDescriptors( - block.items[entry.itemIndex].children, - indentLevel: entry.contentIndentLevel, - separator: '\n', - ); - contentRects = resolveIndexedBlockSelectionRectsInRoot( - context, - rootRenderObject: rootRenderObject, - entries: childEntries, - childKeys: keysRegistry.listItemChildKeysFor(block, entry.itemIndex), - range: itemRange, - ); - } - - if (prefixSelectionStart < prefixSelectionEnd && - rowRenderObject is RenderBox && - rowRenderObject.hasSize) { - prefixRect = _resolveListPrefixSelectionRectInRoot( - context: context, - rootRenderObject: rootRenderObject, - block: block, - itemIndex: entry.itemIndex, - rowRenderObject: rowRenderObject, - contentRenderObject: - contentRenderObject is RenderBox ? contentRenderObject : null, - contentIndentLevel: entry.contentIndentLevel, - textDirection: Directionality.of(context), - ); - } - - if (contentSelectionStart >= contentSelectionEnd) { - if (prefixRect != null) { - rects.add(prefixRect); - } - continue; - } - - if (contentRenderObject is! RenderBox || !contentRenderObject.hasSize) { - if (prefixRect != null) { - rects.add(prefixRect); - } - continue; - } - - if (prefixRect != null && contentRects.isNotEmpty) { - final firstContentRect = contentRects.first; - if (_rectsShareLine(prefixRect, firstContentRect)) { - rects.add( - Rect.fromLTRB( - prefixRect.left, - firstContentRect.top, - firstContentRect.right, - firstContentRect.bottom, - ), - ); - rects.addAll(contentRects.skip(1)); - continue; - } - rects.add(prefixRect); - } - - rects.addAll(contentRects); - } - - return rects; - } - - Rect _resolveListPrefixSelectionRectInRoot({ - required BuildContext context, - required RenderBox rootRenderObject, - required ListBlock block, - required int itemIndex, - required RenderBox rowRenderObject, - RenderBox? contentRenderObject, - required int contentIndentLevel, - required TextDirection textDirection, - }) { - final rowOrigin = rootRenderObject.globalToLocal( - rowRenderObject.localToGlobal(Offset.zero), - ); - var top = rowOrigin.dy; - var height = rowRenderObject.size.height; - final childEntries = extractor.buildIndexedBlockDescriptors( - block.items[itemIndex].children, - indentLevel: contentIndentLevel, - separator: '\n', - ); - final childKeys = keysRegistry.listItemChildKeysFor(block, itemIndex); - if (childEntries.isNotEmpty && childKeys.isNotEmpty) { - final firstEntry = childEntries.first; - final firstChildContext = childKeys[firstEntry.childIndex].currentContext; - final firstChildRenderObject = firstChildContext?.findRenderObject(); - if (firstChildRenderObject is RenderBox && - firstChildRenderObject.hasSize) { - final firstChildOrigin = rootRenderObject.globalToLocal( - firstChildRenderObject.localToGlobal(Offset.zero), - ); - final firstLineRect = _resolveFirstLineRectForNestedBlockInRoot( - context, - rootRenderObject: rootRenderObject, - block: firstEntry.block, - descriptor: firstEntry.descriptor, - renderObject: firstChildRenderObject, - origin: firstChildOrigin, - textDirection: textDirection, - indentLevel: firstEntry.indentLevel, - ); - if (firstLineRect != null) { - top = firstLineRect.top; - height = firstLineRect.height; - } else { - top = firstChildOrigin.dy; - height = firstChildRenderObject.size.height; - } - } - } - - final extent = _resolveListMarkerExtent( - block: block, - itemIndex: itemIndex, - rowRenderObject: rowRenderObject, - contentRenderObject: contentRenderObject, - ); - return Rect.fromLTRB( - rowOrigin.dx, - top, - rowOrigin.dx + extent, - top + height, - ); - } - - bool _rectsShareLine(Rect a, Rect b) { - final overlapTop = math.max(a.top, b.top); - final overlapBottom = math.min(a.bottom, b.bottom); - return overlapBottom - overlapTop > 1.0; - } - - double _resolveListMarkerExtent({ - required ListBlock block, - required int itemIndex, - required RenderBox rowRenderObject, - RenderBox? contentRenderObject, - }) { - if (contentRenderObject != null && contentRenderObject.hasSize) { - final contentOrigin = rowRenderObject.globalToLocal( - contentRenderObject.localToGlobal(Offset.zero), - ); - if (contentOrigin.dx > 0) { - return math.min(contentOrigin.dx, rowRenderObject.size.width); - } - } - return math.min( - markdownListMarkerExtent(block, itemIndex), - rowRenderObject.size.width, - ); - } - - Rect? _resolveFirstLineRectForNestedBlockInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required BlockNode block, - required SelectableTextDescriptor descriptor, - required RenderBox renderObject, - required Offset origin, - required TextDirection textDirection, - required int indentLevel, - }) { - if (descriptor.plainText.isEmpty) { - return null; - } - - final rects = resolveNestedBlockSelectionRects( - context, - rootRenderObject: rootRenderObject, - block: block, - descriptor: descriptor, - range: DocumentRange( - start: const DocumentPosition( - blockIndex: 0, - path: PathInBlock([0]), - textOffset: 0, - ), - end: DocumentPosition( - blockIndex: 0, - path: PathInBlock([0]), - textOffset: math.min(1, descriptor.plainText.length), - ), - ), - renderObject: renderObject, - origin: origin, - textDirection: textDirection, - indentLevel: indentLevel, - ); - if (rects.isEmpty) { - return null; - } - rects.sort((a, b) { - final topCompare = a.top.compareTo(b.top); - if (topCompare != 0) { - return topCompare; - } - return a.left.compareTo(b.left); - }); - return rects.first; - } - - List resolveQuoteSelectionRects( - BuildContext context, - QuoteBlock block, - DocumentRange range, - ) { - final rootRenderObject = context.findRenderObject(); - if (rootRenderObject is! RenderBox || !rootRenderObject.hasSize) { - return const []; - } - return _resolveQuoteSelectionRectsInRoot( - context, - rootRenderObject: rootRenderObject, - block: block, - range: range, - ); - } - - List _resolveQuoteSelectionRectsInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required QuoteBlock block, - required DocumentRange range, - }) { - final entries = extractor.buildIndexedBlockDescriptors( - block.children, - separator: '\n\n', - paragraphStyle: theme.quoteStyle, - ); - if (entries.isEmpty) { - return const []; - } - return resolveIndexedBlockSelectionRectsInRoot( - context, - rootRenderObject: rootRenderObject, - entries: entries, - childKeys: keysRegistry.quoteChildKeysFor(block), - range: range, - ); - } - - int? resolveQuoteTextOffset( - BuildContext context, - QuoteBlock block, - Offset localPosition, - ) { - final rootRenderObject = context.findRenderObject(); - if (rootRenderObject is! RenderBox || !rootRenderObject.hasSize) { - return null; - } - return _resolveQuoteTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - block: block, - globalPosition: rootRenderObject.localToGlobal(localPosition), - ); - } - - int? _resolveQuoteTextOffsetInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required QuoteBlock block, - required Offset globalPosition, - }) { - final entries = extractor.buildIndexedBlockDescriptors( - block.children, - separator: '\n\n', - paragraphStyle: theme.quoteStyle, - ); - if (entries.isEmpty) { - return null; - } - return resolveIndexedBlockTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - entries: entries, - childKeys: keysRegistry.quoteChildKeysFor(block), - globalPosition: globalPosition, - ); - } - - List resolveDetailsSelectionRects( - BuildContext context, - DetailsBlock block, - DocumentRange range, - ) { - final rootRenderObject = context.findRenderObject(); - if (rootRenderObject is! RenderBox || !rootRenderObject.hasSize) { - return const []; - } - return _resolveDetailsSelectionRectsInRoot( - context, - rootRenderObject: rootRenderObject, - block: block, - range: range, - ); - } - - int? resolveDetailsTextOffset( - BuildContext context, - DetailsBlock block, - Offset localPosition, - ) { - final rootRenderObject = context.findRenderObject(); - if (rootRenderObject is! RenderBox || !rootRenderObject.hasSize) { - return null; - } - return _resolveDetailsTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - block: block, - globalPosition: rootRenderObject.localToGlobal(localPosition), - ); - } - - List _resolveDetailsSelectionRectsInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required DetailsBlock block, - required DocumentRange range, - }) { - final entries = extractor.buildIndexedBlockDescriptors( - MarkdownDescriptorExtractor.detailsSelectionBlocks(block), - separator: '\n\n', - ); - if (entries.isEmpty) { - return const []; - } - return resolveIndexedBlockSelectionRectsInRoot( - context, - rootRenderObject: rootRenderObject, - entries: entries, - childKeys: keysRegistry.detailsChildKeysFor(block), - range: range, - ); - } - - int? _resolveDetailsTextOffsetInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required DetailsBlock block, - required Offset globalPosition, - }) { - final entries = extractor.buildIndexedBlockDescriptors( - MarkdownDescriptorExtractor.detailsSelectionBlocks(block), - separator: '\n\n', - ); - if (entries.isEmpty) { - return null; - } - return resolveIndexedBlockTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - entries: entries, - childKeys: keysRegistry.detailsChildKeysFor(block), - globalPosition: globalPosition, - ); - } - - List resolveIndexedBlockSelectionRectsInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required List entries, - required List childKeys, - required DocumentRange range, - }) { - final textDirection = Directionality.of(context); - final rects = []; - - for (final entry in entries) { - final childStart = entry.startOffset; - final childEnd = childStart + entry.descriptor.plainText.length; - final selectionStart = math.max(range.start.textOffset, childStart); - final selectionEnd = math.min(range.end.textOffset, childEnd); - if (selectionStart >= selectionEnd) { - continue; - } - - final childContext = childKeys[entry.childIndex].currentContext; - final childRenderObject = childContext?.findRenderObject(); - if (childRenderObject is! RenderBox || !childRenderObject.hasSize) { - continue; - } - final childOrigin = rootRenderObject.globalToLocal( - childRenderObject.localToGlobal(Offset.zero), - ); - final childRange = DocumentRange( - start: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: selectionStart - childStart, - ), - end: DocumentPosition( - blockIndex: 0, - path: const PathInBlock([0]), - textOffset: selectionEnd - childStart, - ), - ); - - rects.addAll( - resolveNestedBlockSelectionRects( - context, - rootRenderObject: rootRenderObject, - block: entry.block, - descriptor: entry.descriptor, - range: childRange, - renderObject: childRenderObject, - origin: childOrigin, - textDirection: textDirection, - indentLevel: entry.indentLevel, - ), - ); - } - - return rects; - } - - int? resolveIndexedBlockTextOffsetInRoot( - BuildContext context, { - required RenderBox rootRenderObject, - required List entries, - required List childKeys, - required Offset globalPosition, - }) { - final textDirection = Directionality.of(context); - final resolvedEntries = []; - IndexedBlockDescriptor? nearestEntry; - double bestDistance = double.infinity; - - for (final entry in entries) { - final childContext = childKeys[entry.childIndex].currentContext; - final childRenderObject = childContext?.findRenderObject(); - if (childRenderObject is! RenderBox || !childRenderObject.hasSize) { - continue; - } - final rect = - childRenderObject.localToGlobal(Offset.zero) & childRenderObject.size; - resolvedEntries.add( - ResolvedIndexedBlockEntry( - entry: entry, - renderObject: childRenderObject, - rect: rect, - ), - ); - if (rect.contains(globalPosition)) { - return entry.startOffset + - resolveNestedBlockTextOffset( - context, - rootRenderObject: rootRenderObject, - block: entry.block, - descriptor: entry.descriptor, - renderObject: childRenderObject, - globalPosition: globalPosition, - textDirection: textDirection, - indentLevel: entry.indentLevel, - ); - } - final distance = _distanceToRect(globalPosition, rect); - if (distance < bestDistance) { - bestDistance = distance; - nearestEntry = entry; - } - } - - resolvedEntries.sort((a, b) => a.rect.top.compareTo(b.rect.top)); - for (var index = 0; index < resolvedEntries.length - 1; index++) { - final current = resolvedEntries[index]; - final next = resolvedEntries[index + 1]; - if (globalPosition.dy < current.rect.bottom || - globalPosition.dy > next.rect.top) { - continue; - } - - final overlapLeft = math.min(current.rect.right, next.rect.right); - final overlapRight = math.max(current.rect.left, next.rect.left); - if (globalPosition.dx < overlapRight - 24 || - globalPosition.dx > overlapLeft + 24) { - continue; - } - - final midpointY = (current.rect.bottom + next.rect.top) / 2; - if (globalPosition.dy <= midpointY) { - return current.entry.startOffset + - current.entry.descriptor.plainText.length; - } - return next.entry.startOffset; - } - - if (nearestEntry == null) { - return null; - } - - final childContext = childKeys[nearestEntry.childIndex].currentContext; - final childRenderObject = childContext?.findRenderObject(); - if (childRenderObject is! RenderBox || !childRenderObject.hasSize) { - return null; - } - final childRect = - childRenderObject.localToGlobal(Offset.zero) & childRenderObject.size; - final preferEnd = globalPosition.dy > childRect.center.dy || - globalPosition.dx > childRect.center.dx; - return nearestEntry.startOffset + - (preferEnd ? nearestEntry.descriptor.plainText.length : 0); - } - - List resolveNestedBlockSelectionRects( - BuildContext context, { - required RenderBox rootRenderObject, - required BlockNode block, - required SelectableTextDescriptor descriptor, - required DocumentRange range, - required RenderBox renderObject, - required Offset origin, - required TextDirection textDirection, - int indentLevel = 0, - }) { - switch (block.kind) { - case MarkdownBlockKind.table: - final tableBlock = block as TableBlock; - return _clipSelectionRectsToViewport( - _resolveTableSelectionRectsInRoot( - context, - rootRenderObject: rootRenderObject, - block: tableBlock, - range: range, - ), - _resolveViewportRectInRoot( - rootRenderObject, - keysRegistry.tableViewportKeys[tableBlock.id], - ), - ); - case MarkdownBlockKind.heading: - case MarkdownBlockKind.paragraph: - case MarkdownBlockKind.definitionList: - if (_isStandaloneLineBreakBlock(block)) { - return _placeholderBlockSelectionRects(renderObject, origin); - } - if (_descriptorUsesDirectRichTextMetrics(descriptor)) { - final directRects = _resolveDirectRenderParagraphSelectionRects( - renderObject, - range: range, - origin: origin, - runs: descriptor.pretext!.runs, - ); - if (directRects != null) { - return directRects; - } - throw _missingDirectRenderParagraphError(descriptor); - } - return resolveDescriptorSelectionRects( - context, - descriptor: descriptor, - range: range, - size: renderObject.size, - textDirection: textDirection, - origin: origin, - ); - case MarkdownBlockKind.orderedList: - case MarkdownBlockKind.unorderedList: - return _resolveListSelectionRectsInRoot( - context, - rootRenderObject: rootRenderObject, - block: block as ListBlock, - range: range, - indentLevel: indentLevel, - ); - case MarkdownBlockKind.footnoteList: - return _resolveListSelectionRectsInRoot( - context, - rootRenderObject: rootRenderObject, - block: MarkdownDescriptorExtractor.footnoteListAsOrderedList( - block as FootnoteListBlock), - range: range, - indentLevel: indentLevel, - ); - case MarkdownBlockKind.quote: - return _resolveQuoteSelectionRectsInRoot( - context, - rootRenderObject: rootRenderObject, - block: block as QuoteBlock, - range: range, - ); - case MarkdownBlockKind.details: - return _resolveDetailsSelectionRectsInRoot( - context, - rootRenderObject: rootRenderObject, - block: block as DetailsBlock, - range: range, - ); - case MarkdownBlockKind.codeBlock: - final codeBlock = block as CodeBlock; - final codeRuns = codeSyntaxHighlighter.buildPretextRuns( - source: codeBlock.code, - baseStyle: theme.codeBlockStyle, - theme: theme, - language: codeBlock.language, - ); - final viewportRect = _resolveViewportRectInRoot( - rootRenderObject, - keysRegistry.codeBlockViewportKeys[codeBlock.id], - ); - if (markdownPretextCanUseDirectRichTextGeometry(codeRuns)) { - final directRects = _resolveDirectRenderParagraphSelectionRects( - renderObject, - range: range, - origin: origin, - runs: codeRuns, - ); - if (directRects != null) { - return _clipSelectionRectsToViewport(directRects, viewportRect); - } - throw _missingDirectRenderParagraphError(descriptor); - } - return _clipSelectionRectsToViewport( - resolveTextSpanSelectionRects( - codeSyntaxHighlighter.buildTextSpan( - source: codeBlock.code, - baseStyle: theme.codeBlockStyle, - theme: theme, - language: codeBlock.language, - ), - range, - size: renderObject.size, - textDirection: textDirection, - measurementPadding: - theme.codeBlockPadding.resolve(Directionality.of(context)) + - const EdgeInsets.only(top: 36), // _codeToolbarHeight - origin: origin, - ), - viewportRect, - ); - case MarkdownBlockKind.image: - case MarkdownBlockKind.thematicBreak: - final rect = origin & renderObject.size; - return [ - Rect.fromLTRB( - rect.left - 1.5, rect.top, rect.right + 1.5, rect.bottom), - ]; - } - } - - int resolveNestedBlockTextOffset( - BuildContext context, { - required RenderBox rootRenderObject, - required BlockNode block, - required SelectableTextDescriptor descriptor, - required RenderBox renderObject, - required Offset globalPosition, - required TextDirection textDirection, - int indentLevel = 0, - }) { - final localPosition = renderObject.globalToLocal(globalPosition); - switch (block.kind) { - case MarkdownBlockKind.table: - return _resolveTableTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - block: block as TableBlock, - globalPosition: globalPosition, - ) ?? - 0; - case MarkdownBlockKind.heading: - case MarkdownBlockKind.paragraph: - case MarkdownBlockKind.definitionList: - if (_isStandaloneLineBreakBlock(block)) { - return localPosition.dy > renderObject.size.height / 2 || - localPosition.dx > renderObject.size.width / 2 - ? descriptor.plainText.length - : 0; - } - if (_descriptorUsesDirectRichTextMetrics(descriptor)) { - final directOffset = _resolveDirectRenderParagraphTextOffset( - renderObject, - globalPosition: globalPosition, - runs: descriptor.pretext!.runs, - ); - if (directOffset != null) { - return directOffset; - } - throw _missingDirectRenderParagraphError(descriptor); - } - return resolveDescriptorTextOffset( - context, - descriptor: descriptor, - localPosition: localPosition, - size: renderObject.size, - textDirection: textDirection, - ); - case MarkdownBlockKind.orderedList: - case MarkdownBlockKind.unorderedList: - return _resolveListTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - block: block as ListBlock, - globalPosition: globalPosition, - indentLevel: indentLevel, - ) ?? - 0; - case MarkdownBlockKind.footnoteList: - return _resolveListTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - block: MarkdownDescriptorExtractor.footnoteListAsOrderedList( - block as FootnoteListBlock), - globalPosition: globalPosition, - indentLevel: indentLevel, - ) ?? - 0; - case MarkdownBlockKind.quote: - return _resolveQuoteTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - block: block as QuoteBlock, - globalPosition: globalPosition, - ) ?? - 0; - case MarkdownBlockKind.details: - return _resolveDetailsTextOffsetInRoot( - context, - rootRenderObject: rootRenderObject, - block: block as DetailsBlock, - globalPosition: globalPosition, - ) ?? - 0; - case MarkdownBlockKind.codeBlock: - final codeBlock = block as CodeBlock; - final codeRuns = codeSyntaxHighlighter.buildPretextRuns( - source: codeBlock.code, - baseStyle: theme.codeBlockStyle, - theme: theme, - language: codeBlock.language, - ); - if (markdownPretextCanUseDirectRichTextGeometry(codeRuns)) { - final directOffset = _resolveDirectRenderParagraphTextOffset( - renderObject, - globalPosition: globalPosition, - runs: codeRuns, - ); - if (directOffset != null) { - return directOffset; - } - throw _missingDirectRenderParagraphError(descriptor); - } - return resolveTextSpanTextOffset( - codeSyntaxHighlighter.buildTextSpan( - source: codeBlock.code, - baseStyle: theme.codeBlockStyle, - theme: theme, - language: codeBlock.language, - ), - descriptor.plainText.length, - localPosition: localPosition, - size: renderObject.size, - textDirection: textDirection, - measurementPadding: - theme.codeBlockPadding.resolve(Directionality.of(context)) + - const EdgeInsets.only(top: 36), // _codeToolbarHeight - ); - case MarkdownBlockKind.image: - case MarkdownBlockKind.thematicBreak: - return localPosition.dy > renderObject.size.height / 2 || - localPosition.dx > renderObject.size.width / 2 - ? descriptor.plainText.length - : 0; - } - } - - List resolveTextSpanSelectionRects( - InlineSpan span, - DocumentRange range, { - required Size size, - required TextDirection textDirection, - required EdgeInsets measurementPadding, - Offset origin = Offset.zero, - }) { - final textPainter = TextPainter( - text: span, - textDirection: textDirection, - maxLines: null, - )..layout( - maxWidth: math.max(size.width - measurementPadding.horizontal, 0), - ); - final lineExtents = _computeTextPainterLineExtents(textPainter); - final boxes = _mergeAdjacentTextSelectionBoxes( - _normalizeBoxesToLineExtents( - textPainter.getBoxesForSelection( - TextSelection( - baseOffset: range.start.textOffset, - extentOffset: range.end.textOffset, - ), - ), - lineExtents, - ), - ); - return boxes - .map( - (box) => Rect.fromLTRB( - box.left + measurementPadding.left + origin.dx - 1.5, - box.top + measurementPadding.top + origin.dy, - box.right + measurementPadding.left + origin.dx + 1.5, - box.bottom + measurementPadding.top + origin.dy, - ), - ) - .toList(growable: false); - } - - List<({double top, double bottom})> _computeParagraphLineExtents( - RenderParagraph renderParagraph, - ) { - final fullText = - renderParagraph.text.toPlainText(includePlaceholders: true); - if (fullText.isEmpty) { - return const <({double top, double bottom})>[]; - } - if (fullText.contains('\uFFFC')) { - final allBoxes = renderParagraph.getBoxesForSelection( - TextSelection(baseOffset: 0, extentOffset: fullText.length), - ); - return _resolveContiguousLinesFromBoxes(allBoxes); - } - - final textPainter = TextPainter( - text: renderParagraph.text, - textAlign: renderParagraph.textAlign, - textDirection: renderParagraph.textDirection, - textScaler: renderParagraph.textScaler, - maxLines: null, - )..layout(maxWidth: renderParagraph.size.width); - return _resolveContiguousLinesFromMetrics( - textPainter.computeLineMetrics(), - ); - } - - List<({double top, double bottom})> _computeTextPainterLineExtents( - TextPainter textPainter, - ) { - final fullText = - textPainter.text?.toPlainText(includePlaceholders: true) ?? ''; - if (fullText.contains('\uFFFC')) { - final allBoxes = textPainter.getBoxesForSelection( - TextSelection(baseOffset: 0, extentOffset: fullText.length), - ); - return _resolveContiguousLinesFromBoxes(allBoxes); - } - return _resolveContiguousLinesFromMetrics(textPainter.computeLineMetrics()); - } - - List<({double top, double bottom})> _resolveContiguousLinesFromBoxes( - List allBoxes, - ) { - if (allBoxes.isEmpty) { - return const <({double top, double bottom})>[]; - } - final sorted = allBoxes.toList(growable: false) - ..sort((a, b) => a.top.compareTo(b.top)); - - final lines = <({double top, double bottom})>[]; - var lineTop = sorted.first.top; - var lineBottom = sorted.first.bottom; - for (final box in sorted.skip(1)) { - if (box.top < lineBottom - 1.0) { - lineTop = math.min(lineTop, box.top); - lineBottom = math.max(lineBottom, box.bottom); - } else { - lines.add((top: lineTop, bottom: lineBottom)); - lineTop = box.top; - lineBottom = box.bottom; - } - } - lines.add((top: lineTop, bottom: lineBottom)); - - final contiguousLines = <({double top, double bottom})>[]; - for (var i = 0; i < lines.length; i++) { - final current = lines[i]; - final previous = i > 0 ? lines[i - 1] : null; - final next = i < lines.length - 1 ? lines[i + 1] : null; - - final resolvedTop = previous != null - ? (previous.bottom + current.top) / 2.0 - : current.top; - final resolvedBottom = - next != null ? (current.bottom + next.top) / 2.0 : current.bottom; - - contiguousLines.add((top: resolvedTop, bottom: resolvedBottom)); - } - return contiguousLines; - } - - List<({double top, double bottom})> _resolveContiguousLinesFromMetrics( - List lines, - ) { - if (lines.isEmpty) { - return const <({double top, double bottom})>[]; - } - final contiguousLines = <({double top, double bottom})>[]; - for (var i = 0; i < lines.length; i++) { - final current = lines[i]; - final previous = i > 0 ? lines[i - 1] : null; - final next = i < lines.length - 1 ? lines[i + 1] : null; - final currentTop = current.baseline - current.ascent; - final currentBottom = currentTop + current.height; - - final resolvedTop = previous != null - ? (previous.baseline - - previous.ascent + - previous.height + - currentTop) / - 2.0 - : currentTop; - final resolvedBottom = next != null - ? (currentBottom + next.baseline - next.ascent) / 2.0 - : currentBottom; - - contiguousLines.add((top: resolvedTop, bottom: resolvedBottom)); - } - return contiguousLines; - } - - List _normalizeBoxesToLineExtents( - List boxes, - List<({double top, double bottom})> lineExtents, - ) { - if (lineExtents.isEmpty) { - return boxes; - } - return boxes.map((box) { - for (final extent in lineExtents) { - final overlapTop = math.max(box.top, extent.top); - final overlapBottom = math.min(box.bottom, extent.bottom); - if (overlapBottom - overlapTop > 0.5) { - return TextBox.fromLTRBD( - box.left, - extent.top, - box.right, - extent.bottom, - box.direction, - ); - } - } - return box; - }).toList(growable: false); - } - - List _mergeAdjacentTextSelectionBoxes(List boxes) { - if (boxes.length < 2) { - return boxes; - } - - const lineTolerance = 2.0; - const gapTolerance = double.infinity; - - final sorted = boxes.toList(growable: false) - ..sort((a, b) { - final topCompare = a.top.compareTo(b.top); - if (topCompare != 0) { - return topCompare; - } - return a.left.compareTo(b.left); - }); - - final merged = []; - var current = sorted.first; - for (final next in sorted.skip(1)) { - final sameLine = (next.top - current.top).abs() <= lineTolerance && - (next.bottom - current.bottom).abs() <= lineTolerance; - final overlappingOrAdjacent = next.left <= current.right + gapTolerance; - if (sameLine && overlappingOrAdjacent) { - current = TextBox.fromLTRBD( - math.min(current.left, next.left), - math.min(current.top, next.top), - math.max(current.right, next.right), - math.max(current.bottom, next.bottom), - current.direction, - ); - continue; - } - merged.add(current); - current = next; - } - merged.add(current); - return merged; - } - - int resolveTextSpanTextOffset( - InlineSpan span, - int textLength, { - required Offset localPosition, - required Size size, - required TextDirection textDirection, - required EdgeInsets measurementPadding, - }) { - return resolveTextOffsetInBox( - span, - textLength, - Offset( - localPosition.dx - measurementPadding.left, - localPosition.dy - measurementPadding.top, - ), - Size( - math.max(size.width - measurementPadding.horizontal, 0), - math.max(size.height - measurementPadding.vertical, 0), - ), - textDirection, - ); - } -} - -class ResolvedIndexedBlockEntry { - const ResolvedIndexedBlockEntry({ - required this.entry, - required this.renderObject, - required this.rect, - }); - - final IndexedBlockDescriptor entry; - final RenderBox renderObject; - final Rect rect; -} - -class _ResolvedIndexedBlockHit { - const _ResolvedIndexedBlockHit({ - required this.entry, - required this.renderObject, - required this.textOffset, - }); - - final IndexedBlockDescriptor entry; - final RenderBox renderObject; - final int textOffset; -} diff --git a/packages/mixin_markdown_widget/lib/src/selection/selection_controller.dart b/packages/mixin_markdown_widget/lib/src/selection/selection_controller.dart index a6dbee1c..9223ab57 100644 --- a/packages/mixin_markdown_widget/lib/src/selection/selection_controller.dart +++ b/packages/mixin_markdown_widget/lib/src/selection/selection_controller.dart @@ -3,7 +3,6 @@ import 'package:flutter/services.dart'; import '../clipboard/plain_text_serializer.dart'; import '../core/document.dart'; -import 'structured_block_selection.dart'; class MarkdownSelectionController extends ChangeNotifier { MarkdownSelectionController({ @@ -14,11 +13,6 @@ class MarkdownSelectionController extends ChangeNotifier { MarkdownDocument _document = const MarkdownDocument.empty(); DocumentSelection? _selection; - final Map _blockInfoCache = - {}; - MarkdownDocument? _boundsDocument; - bool _boundsResolved = false; - _SelectionDocumentBounds? _bounds; MarkdownDocument get document => _document; DocumentSelection? get selection => _selection; @@ -35,13 +29,7 @@ class MarkdownSelectionController extends ChangeNotifier { } void attachDocument(MarkdownDocument document) { - if (!identical(_document, document)) { - _document = document; - _invalidateDocumentBounds(); - _blockInfoCache.removeWhere( - (index, _) => index >= document.blocks.length, - ); - } + _document = document; var changed = false; final selection = _selection; @@ -94,179 +82,15 @@ class MarkdownSelectionController extends ChangeNotifier { return Clipboard.setData(ClipboardData(text: selectedPlainText)); } - bool get _canUseDefaultSerializerFastPath => - _serializer.runtimeType == MarkdownPlainTextSerializer; - - void _invalidateDocumentBounds() { - _boundsDocument = null; - _boundsResolved = false; - _bounds = null; - } - void _attachDocumentWithoutClamping(MarkdownDocument document) { - if (identical(_document, document)) { - return; - } _document = document; - _invalidateDocumentBounds(); - _blockInfoCache.removeWhere( - (index, _) => index >= document.blocks.length, - ); } DocumentSelection? _clampSelection(DocumentSelection selection) { - if (!_canUseDefaultSerializerFastPath) { - return _serializer.clampSelection(_document, selection); - } - final bounds = _resolveDocumentBounds(); - if (bounds == null) { - return null; - } - return DocumentSelection( - base: _clampPosition(bounds, selection.base), - extent: _clampPosition(bounds, selection.extent), - ); + return _serializer.clampSelection(_document, selection); } DocumentSelection? _createFullDocumentSelection() { - if (!_canUseDefaultSerializerFastPath) { - return _serializer.createFullDocumentSelection(_document); - } - final bounds = _resolveDocumentBounds(); - if (bounds == null) { - return null; - } - final first = bounds.first; - final last = bounds.last; - return DocumentSelection( - base: DocumentPosition( - blockIndex: first.blockIndex, - path: first.structure == null - ? const PathInBlock([0]) - : first.structure!.startPosition(blockIndex: first.blockIndex).path, - textOffset: 0, - ), - extent: DocumentPosition( - blockIndex: last.blockIndex, - path: last.structure == null - ? const PathInBlock([0]) - : last.structure!.endPosition(blockIndex: last.blockIndex).path, - textOffset: last.visibleTextLength, - ), - ); - } - - _SelectionDocumentBounds? _resolveDocumentBounds() { - if (_boundsResolved && identical(_boundsDocument, _document)) { - return _bounds; - } - - _CachedSelectionBlockInfo? first; - _CachedSelectionBlockInfo? last; - for (var index = 0; index < _document.blocks.length; index++) { - final info = _blockInfoFor(index); - if (info.text.isEmpty) { - continue; - } - first ??= info; - last = info; - } - - _boundsDocument = _document; - _boundsResolved = true; - _bounds = first == null || last == null - ? null - : _SelectionDocumentBounds(first: first, last: last); - return _bounds; - } - - _CachedSelectionBlockInfo _blockInfoFor(int blockIndex) { - final block = _document.blocks[blockIndex]; - final cached = _blockInfoCache[blockIndex]; - if (cached != null && identical(cached.block, block)) { - return cached; - } - - final structure = StructuredBlockSelection.forBlock(block); - final info = !structure.isEmpty - ? _CachedSelectionBlockInfo( - blockIndex: blockIndex, - block: block, - text: structure.serializedText, - structure: structure, - ) - : _CachedSelectionBlockInfo( - blockIndex: blockIndex, - block: block, - text: StructuredBlockSelection.serializeBlockText(block), - structure: null, - ); - _blockInfoCache[blockIndex] = info; - return info; - } - - DocumentPosition _clampPosition( - _SelectionDocumentBounds bounds, - DocumentPosition position, - ) { - final minBlockIndex = bounds.first.blockIndex; - final maxBlockIndex = bounds.last.blockIndex; - final targetBlockIndex = position.blockIndex < minBlockIndex - ? minBlockIndex - : position.blockIndex > maxBlockIndex - ? maxBlockIndex - : position.blockIndex; - final candidate = _blockInfoFor(targetBlockIndex); - final block = candidate.text.isEmpty ? bounds.last : candidate; - final targetOffset = position.textOffset < 0 - ? 0 - : position.textOffset > block.visibleTextLength - ? block.visibleTextLength - : position.textOffset; - final structure = block.structure; - if (structure != null) { - return structure.normalizePosition( - blockIndex: block.blockIndex, - position: DocumentPosition( - blockIndex: block.blockIndex, - path: position.path, - textOffset: targetOffset, - ), - affinity: targetOffset == structure.plainText.length - ? StructuredSelectionAffinity.upstream - : StructuredSelectionAffinity.downstream, - ); - } - return DocumentPosition( - blockIndex: block.blockIndex, - path: const PathInBlock([0]), - textOffset: targetOffset, - ); + return _serializer.createFullDocumentSelection(_document); } } - -class _SelectionDocumentBounds { - const _SelectionDocumentBounds({ - required this.first, - required this.last, - }); - - final _CachedSelectionBlockInfo first; - final _CachedSelectionBlockInfo last; -} - -class _CachedSelectionBlockInfo { - const _CachedSelectionBlockInfo({ - required this.blockIndex, - required this.block, - required this.text, - required this.structure, - }); - - final int blockIndex; - final BlockNode block; - final String text; - final StructuredBlockSelection? structure; - - int get visibleTextLength => structure?.plainText.length ?? text.length; -} 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 ae60d022..376d89fb 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 @@ -77,19 +77,30 @@ class StructuredBlockSelectionLeaf { @immutable class StructuredBlockSelection { - const StructuredBlockSelection._({ + StructuredBlockSelection._({ required this.plainText, required this.serializedText, required this.leaves, }); + // Blocks are immutable, so the selection index of a block is computed once + // and shared by the renderer, the controller, and the serializer. + static final Expando _cache = + Expando('StructuredBlockSelection'); + static final Expando _serializedBlockTextCache = + Expando('serializedBlockText'); + factory StructuredBlockSelection.forBlock(BlockNode block) { - return StructuredBlockSelection._fromBlueprints( + return _cache[block] ??= StructuredBlockSelection._fromBlueprints( _StructuredBlockSelectionBuilder().build(block), ); } static String serializeBlockText(BlockNode block) { + return _serializedBlockTextCache[block] ??= _serializeBlockText(block); + } + + static String _serializeBlockText(BlockNode block) { final structure = StructuredBlockSelection.forBlock(block); if (!structure.isEmpty) { return structure.serializedText; @@ -97,7 +108,7 @@ class StructuredBlockSelection { return _StructuredBlockSelectionBuilder().exportBlockText(block); } - factory StructuredBlockSelection._fromBlueprints( + static StructuredBlockSelection _fromBlueprints( List<_LeafBlueprint> blueprints, ) { final leaves = []; @@ -130,15 +141,15 @@ class StructuredBlockSelection { final String serializedText; final List leaves; + late final Map _leavesByPath = + { + for (final leaf in leaves) leaf.path: leaf, + }; + bool get isEmpty => leaves.isEmpty; StructuredBlockSelectionLeaf? leafForPath(PathInBlock path) { - for (final leaf in leaves) { - if (leaf.path == path) { - return leaf; - } - } - return null; + return _leavesByPath[path]; } DocumentPosition startPosition({required int blockIndex}) { diff --git a/packages/mixin_markdown_widget/test/selection_geometry_snapshot_test.dart b/packages/mixin_markdown_widget/test/selection_geometry_snapshot_test.dart new file mode 100644 index 00000000..1a0ad9d9 --- /dev/null +++ b/packages/mixin_markdown_widget/test/selection_geometry_snapshot_test.dart @@ -0,0 +1,550 @@ +// Rect snapshots of the selection highlight that each block paints. +// +// The values are literal block-local rects recorded from the renderer and +// compared within [_tolerance]. They pin the painted geometry so that the +// selection layout can be restructured without visual drift. To re-record +// after an intentional geometry change, run: +// +// flutter test test/selection_geometry_snapshot_test.dart \ +// --dart-define=RECORD_SELECTION_SNAPSHOTS=true +// +// and paste the printed literals back into [_cases]. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mixin_markdown_widget/mixin_markdown_widget.dart'; +import 'package:mixin_markdown_widget/src/render/selectable_block.dart'; + +const bool _record = bool.fromEnvironment('RECORD_SELECTION_SNAPSHOTS'); +const double _tolerance = 0.5; + +const SelectableBlockSelectionPaintOrder _behind = + SelectableBlockSelectionPaintOrder.behindChild; +const SelectableBlockSelectionPaintOrder _above = + SelectableBlockSelectionPaintOrder.aboveChild; + +class _BlockPaint { + const _BlockPaint({ + required this.order, + required this.rects, + this.clip, + }); + + final SelectableBlockSelectionPaintOrder order; + final Rect? clip; + final List rects; +} + +class _Case { + const _Case( + this.name, + this.data, { + required this.startBlock, + required this.startOffset, + required this.endBlock, + required this.endOffset, + required this.expected, + this.horizontalScroll, + }); + + final String name; + final String data; + final int startBlock; + final int startOffset; + final int endBlock; + final int endOffset; + + /// Scrolls the first horizontal scrollable inside block 0 before selecting. + final double? horizontalScroll; + final Map expected; +} + +const List<_Case> _cases = <_Case>[ + _Case( + 'multi-line paragraph', + 'The quick brown fox jumps over the lazy dog while the selection ' + 'geometry spans several wrapped lines of plain paragraph text.', + startBlock: 0, + startOffset: 2, + endBlock: 0, + endOffset: 123, + expected: { + 0: _BlockPaint( + order: _behind, + rects: [ + Rect.fromLTRB(29.13, -0.45, 306.63, 20.55), + Rect.fromLTRB(-1.38, 20.55, 306.63, 41.55), + Rect.fromLTRB(-1.38, 41.55, 215.13, 62.55), + Rect.fromLTRB(-1.38, 62.55, 291.38, 83.55), + Rect.fromLTRB(-1.38, 83.55, 337.13, 104.55), + Rect.fromLTRB(-1.38, 104.55, 230.38, 125.55), + Rect.fromLTRB(-1.38, 125.55, 199.88, 146.55), + ], + ), + }, + ), + _Case( + 'selection across list items with marker prefix rects', + '1. First item text\n' + '2. Second item with more words to wrap around the line\n' + '3. Third', + startBlock: 0, + startOffset: 2, + endBlock: 0, + endOffset: 80, + expected: { + 0: _BlockPaint( + order: _above, + rects: [ + Rect.fromLTRB(0.00, -0.45, 254.38, 20.55), + Rect.fromLTRB(0.00, 46.55, 284.88, 67.55), + Rect.fromLTRB(22.63, 67.55, 315.38, 88.55), + Rect.fromLTRB(22.63, 88.55, 254.38, 109.55), + Rect.fromLTRB(0.00, 112.55, 71.38, 133.55), + ], + ), + }, + ), + _Case( + 'nested list', + '- Fruit\n - Apple\n - Green\n - Banana\n- Veg', + startBlock: 0, + startOffset: 2, + endBlock: 0, + endOffset: 54, + expected: { + 0: _BlockPaint( + order: _above, + rects: [ + Rect.fromLTRB(18.63, -0.45, 97.88, 20.55), + Rect.fromLTRB(20.00, 23.55, 117.88, 44.55), + Rect.fromLTRB(40.00, 47.55, 137.88, 68.55), + Rect.fromLTRB(20.00, 72.55, 133.13, 93.55), + Rect.fromLTRB(0.00, 97.55, 36.88, 118.55), + ], + ), + }, + ), + _Case( + 'nested quote with heading', + '> # Quoted Title\n>\n' + '> > Nested quote paragraph with enough text to wrap around.\n>\n' + '> Tail para', + startBlock: 0, + startOffset: 2, + endBlock: 0, + endOffset: 78, + expected: { + 0: _BlockPaint( + order: _above, + rects: [ + Rect.fromLTRB(86.50, 6.08, 269.50, 50.08), + Rect.fromLTRB(14.50, 50.08, 197.50, 94.08), + Rect.fromLTRB(30.63, 121.12, 231.88, 142.12), + Rect.fromLTRB(30.63, 142.12, 262.38, 163.12), + Rect.fromLTRB(30.63, 163.12, 262.38, 184.12), + Rect.fromLTRB(30.63, 184.12, 216.63, 205.12), + Rect.fromLTRB(14.63, 219.69, 124.38, 240.69), + ], + ), + }, + ), + _Case( + 'details', + '
\nSummary line\n\n' + 'Body paragraph\n\n- item one\n- item two\n\n
', + startBlock: 0, + startOffset: 2, + endBlock: 0, + endOffset: 49, + expected: { + 0: _BlockPaint( + order: _above, + rects: [ + Rect.fromLTRB(53.13, 1.55, 208.63, 22.55), + Rect.fromLTRB(-1.38, 34.63, 215.13, 55.63), + Rect.fromLTRB(0.00, 58.63, 143.63, 79.63), + Rect.fromLTRB(0.00, 83.63, 113.13, 104.63), + ], + ), + }, + ), + _Case( + 'definition list', + 'Term\n: Definition text that wraps around the available width', + startBlock: 0, + startOffset: 2, + endBlock: 0, + endOffset: 58, + expected: { + 0: _BlockPaint( + order: _behind, + rects: [ + Rect.fromLTRB(29.13, -0.45, 62.63, 20.55), + Rect.fromLTRB(-1.38, 20.55, 276.13, 41.55), + Rect.fromLTRB(-1.38, 41.55, 337.13, 62.55), + Rect.fromLTRB(-1.38, 62.55, 199.88, 83.55), + ], + ), + }, + ), + _Case( + 'footnotes', + 'See[^1] and[^2].\n\n[^1]: First note.\n[^2]: Second note text.', + startBlock: 0, + startOffset: 2, + endBlock: 1, + endOffset: 35, + expected: { + 0: _BlockPaint( + order: _behind, + rects: [ + Rect.fromLTRB(29.13, -0.45, 148.73, 20.55), + ], + ), + 1: _BlockPaint( + order: _behind, + rects: [ + Rect.fromLTRB(0.00, 11.55, 208.63, 32.55), + Rect.fromLTRB(0.00, 58.55, 269.63, 79.55), + ], + ), + }, + ), + _Case( + 'table cells across rows', + '| Name | Value |\n| --- | --- |\n| row | 42 |\n| next | 7 |', + startBlock: 0, + startOffset: 2, + endBlock: 0, + endOffset: 22, + expected: { + 0: _BlockPaint( + order: _above, + rects: [ + Rect.fromLTRB(41.13, 9.55, 74.63, 30.55), + Rect.fromLTRB(173.63, 9.55, 252.88, 30.55), + Rect.fromLTRB(10.63, 50.55, 59.38, 71.55), + Rect.fromLTRB(173.63, 50.55, 207.13, 71.55), + Rect.fromLTRB(10.63, 91.55, 74.63, 112.55), + ], + ), + }, + ), + _Case( + 'quote with a horizontally scrolled table', + '> | Alpha column | Beta column | Gamma column | Delta column | ' + 'Epsilon column |\n' + '> | --- | --- | --- | --- | --- |\n' + '> | a1 value | b1 value | c1 value | d1 value | e1 value |\n' + '> | a2 value | b2 value | c2 value | d2 value | e2 value |', + startBlock: 0, + startOffset: 10, + endBlock: 0, + endOffset: 143, + horizontalScroll: 80, + expected: { + 0: _BlockPaint( + order: _above, + rects: [ + Rect.fromLTRB(16.00, 36.55, 41.13, 57.55), + Rect.fromLTRB(90.63, 15.55, 169.88, 36.55), + Rect.fromLTRB(90.63, 36.55, 185.13, 57.55), + Rect.fromLTRB(224.63, 15.55, 312.00, 36.55), + Rect.fromLTRB(224.63, 36.55, 312.00, 57.55), + Rect.fromLTRB(16.00, 98.55, 25.88, 119.55), + Rect.fromLTRB(90.63, 77.55, 139.38, 98.55), + Rect.fromLTRB(90.63, 98.55, 169.88, 119.55), + Rect.fromLTRB(224.63, 77.55, 273.38, 98.55), + Rect.fromLTRB(224.63, 98.55, 303.88, 119.55), + Rect.fromLTRB(16.00, 160.55, 25.88, 181.55), + Rect.fromLTRB(90.63, 139.55, 139.38, 160.55), + Rect.fromLTRB(90.63, 160.55, 169.88, 181.55), + Rect.fromLTRB(224.63, 139.55, 273.38, 160.55), + Rect.fromLTRB(224.63, 160.55, 303.88, 181.55), + ], + ), + }, + ), + _Case( + 'horizontally scrolled code block', + '```dart\n' + 'final veryLongIdentifierName = ' + 'computeSomethingLong(alpha, beta, gamma, delta);\n' + 'short();\n' + 'anotherVeryLongLine = makeTheLineScrollHorizontallyPastTheViewport();\n' + '```', + startBlock: 0, + startOffset: 10, + endBlock: 0, + endOffset: 146, + horizontalScroll: 80, + expected: { + 0: _BlockPaint( + order: _above, + clip: Rect.fromLTRB(16.00, 16.00, 268.00, 79.00), + rects: [ + Rect.fromLTRB(87.13, 15.55, 1142.38, 36.55), + Rect.fromLTRB(-65.38, 36.55, 59.63, 57.55), + Rect.fromLTRB(-65.38, 57.55, 806.88, 78.55), + ], + ), + }, + ), + _Case( + 'inline code and math inside a paragraph', + r'Alpha `code span` beta $x^2$ gamma', + startBlock: 0, + startOffset: 2, + endBlock: 0, + endOffset: 28, + expected: { + 0: _BlockPaint( + order: _above, + rects: [ + Rect.fromLTRB(29.13, 1.03, 331.88, 20.78), + Rect.fromLTRB(-1.38, 20.78, 92.88, 38.78), + ], + ), + }, + ), +]; + +Future _pump( + WidgetTester tester, + String data, +) async { + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final controller = MarkdownSelectionController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 360, + height: 600, + child: MarkdownWidget( + data: data, + selectionController: controller, + ), + ), + ), + ), + ), + ); + await tester.pump(); + return controller; +} + +SelectableMarkdownBlockState _blockState(WidgetTester tester, int index) { + return tester + .stateList( + find.byType(SelectableMarkdownBlock), + ) + .firstWhere((state) => state.widget.blockIndex == index); +} + +DocumentPosition _position( + WidgetTester tester, + int blockIndex, + int displayedOffset, +) { + final structure = + _blockState(tester, blockIndex).widget.spec.selectionStructure; + return structure?.positionForDisplayedOffset( + blockIndex: blockIndex, + displayedOffset: displayedOffset, + ) ?? + DocumentPosition( + blockIndex: blockIndex, + path: const PathInBlock([0]), + textOffset: displayedOffset, + ); +} + +/// Mirrors what the block's selection painter receives: the block-clamped +/// range, the resolved rects, and the clip rect. +_BlockPaint? _paintedSelection( + WidgetTester tester, + SelectableMarkdownBlockState state, +) { + final widget = state.widget; + final range = widget.selectionRange; + if (range == null) { + return null; + } + final index = widget.blockIndex; + if (index < range.start.blockIndex || index > range.end.blockIndex) { + return null; + } + final start = index == range.start.blockIndex ? range.start.textOffset : 0; + final end = index == range.end.blockIndex + ? range.end.textOffset + : widget.spec.plainText.length; + if (start >= end) { + return null; + } + final size = (state.context.findRenderObject()! as RenderBox).size; + final rects = widget.spec.selectionRectResolver?.call( + state.context, + size, + DocumentRange( + start: DocumentPosition( + blockIndex: index, + path: const PathInBlock([0]), + textOffset: start, + ), + end: DocumentPosition( + blockIndex: index, + path: const PathInBlock([0]), + textOffset: end, + ), + ), + ); + return _BlockPaint( + order: widget.spec.selectionPaintOrder, + clip: widget.spec.selectionClipRectResolver?.call(state.context, size), + rects: rects ?? const [], + ); +} + +String _rectLiteral(Rect rect) { + String f(double value) => value.toStringAsFixed(2); + return 'Rect.fromLTRB(${f(rect.left)}, ${f(rect.top)}, ' + '${f(rect.right)}, ${f(rect.bottom)})'; +} + +void _expectRect(Rect actual, Rect expected, String reason) { + final matches = (actual.left - expected.left).abs() <= _tolerance && + (actual.top - expected.top).abs() <= _tolerance && + (actual.right - expected.right).abs() <= _tolerance && + (actual.bottom - expected.bottom).abs() <= _tolerance; + expect( + matches, + isTrue, + reason: '$reason: expected ${_rectLiteral(expected)}, ' + 'got ${_rectLiteral(actual)}', + ); +} + +void main() { + for (final testCase in _cases) { + testWidgets('selection geometry snapshot: ${testCase.name}', ( + tester, + ) async { + final controller = await _pump(tester, testCase.data); + + final scroll = testCase.horizontalScroll; + if (scroll != null) { + final scrollable = tester.state( + find + .descendant( + of: find.byType(SelectableMarkdownBlock).first, + matching: find.byType(Scrollable), + ) + .first, + ); + expect(scrollable.position.axis, Axis.horizontal); + scrollable.position.jumpTo(scroll); + await tester.pump(); + } + + controller.setSelection( + DocumentSelection( + base: _position(tester, testCase.startBlock, testCase.startOffset), + extent: _position(tester, testCase.endBlock, testCase.endOffset), + ), + ); + await tester.pump(); + + final actual = {}; + for (final state in tester.stateList( + find.byType(SelectableMarkdownBlock), + )) { + final paint = _paintedSelection(tester, state); + if (paint != null) { + actual[state.widget.blockIndex] = paint; + } + } + + if (_record) { + final buffer = StringBuffer('// ${testCase.name}\n') + ..writeln('expected: {'); + for (final entry in actual.entries) { + final paint = entry.value; + buffer + ..writeln(' ${entry.key}: _BlockPaint(') + ..writeln( + ' order: ${paint.order == _above ? '_above' : '_behind'},', + ); + if (paint.clip != null) { + buffer.writeln(' clip: ${_rectLiteral(paint.clip!)},'); + } + buffer.writeln(' rects: ['); + for (final rect in paint.rects) { + buffer.writeln(' ${_rectLiteral(rect)},'); + } + buffer + ..writeln(' ],') + ..writeln(' ),'); + } + buffer.writeln('},'); + // ignore: avoid_print + print(buffer); + return; + } + + expect(actual.keys, testCase.expected.keys); + for (final entry in testCase.expected.entries) { + final expected = entry.value; + final paint = actual[entry.key]!; + final reason = '${testCase.name} block ${entry.key}'; + expect(paint.order, expected.order, reason: '$reason paint order'); + if (expected.clip == null) { + expect(paint.clip, isNull, reason: '$reason clip'); + } else { + expect(paint.clip, isNotNull, reason: '$reason clip'); + _expectRect(paint.clip!, expected.clip!, '$reason clip'); + } + expect( + paint.rects.length, + expected.rects.length, + reason: '$reason rect count: ' + '${paint.rects.map(_rectLiteral).join(', ')}', + ); + for (var i = 0; i < expected.rects.length; i++) { + _expectRect(paint.rects[i], expected.rects[i], '$reason rect $i'); + } + } + }); + } + + testWidgets( + 'horizontally scrolled code block paints selection in the foreground', + (tester) async { + final controller = await _pump( + tester, + '```dart\nfinal value = computeSomethingLongEnoughToScroll(alpha);\n```', + ); + controller.setSelection( + DocumentSelection( + base: _position(tester, 0, 0), + extent: _position(tester, 0, 10), + ), + ); + await tester.pump(); + + final paint = find.descendant( + of: find.byType(SelectableMarkdownBlock), + matching: find.byWidgetPredicate( + (widget) => widget is CustomPaint && widget.foregroundPainter != null, + ), + ); + expect(paint, findsOneWidget); + expect(tester.widget(paint).painter, isNull); + }); +} diff --git a/packages/mixin_markdown_widget/test/selection_gesture_edge_test.dart b/packages/mixin_markdown_widget/test/selection_gesture_edge_test.dart new file mode 100644 index 00000000..a21abe4f --- /dev/null +++ b/packages/mixin_markdown_widget/test/selection_gesture_edge_test.dart @@ -0,0 +1,597 @@ +// Pointer edge cases of the custom markdown selection. +// +// Each test drives real mouse gestures and pins the resulting selection as +// `base -> extent | "selected text"`, where a position is written as +// `block[path]@displayedOffset`. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mixin_markdown_widget/mixin_markdown_widget.dart'; +import 'package:mixin_markdown_widget/src/render/selectable_block.dart'; + +// Synthetic pointer events default to a zero timestamp, which would chain +// unrelated clicks into multi-clicks. Every interaction advances this clock. +Duration _clock = Duration.zero; + +Duration _advance([Duration step = const Duration(seconds: 1)]) { + return _clock += step; +} + +Future _pump( + WidgetTester tester, + String data, { + double height = 600, + ScrollController? scrollController, + MarkdownTapLinkCallback? onTapLink, +}) async { + tester.view.physicalSize = const Size(800, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final controller = MarkdownSelectionController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 360, + height: height, + child: MarkdownWidget( + data: data, + selectionController: controller, + scrollController: scrollController, + onTapLink: onTapLink, + ), + ), + ), + ), + ), + ); + await tester.pump(); + return controller; +} + +Future _click(WidgetTester tester, Offset at, {int count = 1}) async { + var timeStamp = _advance(); + for (var index = 0; index < count; index++) { + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: at, timeStamp: timeStamp); + await gesture.down(at, timeStamp: timeStamp); + await tester.pump(); + await gesture.up(timeStamp: timeStamp); + await tester.pump(); + await gesture.removePointer(timeStamp: timeStamp); + timeStamp = _advance(const Duration(milliseconds: 50)); + } +} + +Future _drag( + WidgetTester tester, + Offset from, + Offset Function() to, { + Future Function()? beforeEnd, +}) async { + final timeStamp = _advance(); + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: from, timeStamp: timeStamp); + await gesture.down(from, timeStamp: timeStamp); + await tester.pump(); + await gesture.moveTo(from + const Offset(24, 0), timeStamp: timeStamp); + await tester.pump(); + if (beforeEnd != null) { + await beforeEnd(); + } + await gesture.moveTo(to(), timeStamp: timeStamp); + await tester.pump(); + await gesture.up(timeStamp: timeStamp); + await tester.pump(); + await gesture.removePointer(timeStamp: timeStamp); +} + +Finder _richTextContaining(String text) { + return find.byWidgetPredicate( + (widget) => widget is RichText && widget.text.toPlainText().contains(text), + ); +} + +/// Global center of the [charIndex]th character of [text], measured on the +/// rendered paragraph that contains it. +Offset _charCenter(WidgetTester tester, String text, int charIndex) { + final finder = _richTextContaining(text).first; + final paragraph = tester.renderObject(finder); + final plain = paragraph.text.toPlainText(); + final offset = plain.indexOf(text) + charIndex; + final boxes = paragraph.getBoxesForSelection( + TextSelection(baseOffset: offset, extentOffset: offset + 1), + ); + expect(boxes, isNotEmpty, reason: 'no box for "$text"[$charIndex]'); + return paragraph.localToGlobal(boxes.first.toRect().center); +} + +/// A global point on the right half of the [charIndex]th character of [text]. +Offset _charRightHalf(WidgetTester tester, String text, int charIndex) { + final finder = _richTextContaining(text).first; + final paragraph = tester.renderObject(finder); + final plain = paragraph.text.toPlainText(); + final offset = plain.indexOf(text) + charIndex; + final boxes = paragraph.getBoxesForSelection( + TextSelection(baseOffset: offset, extentOffset: offset + 1), + ); + expect(boxes, isNotEmpty, reason: 'no box for "$text"[$charIndex]'); + final box = boxes.first.toRect(); + return paragraph.localToGlobal(Offset(box.right - 1, box.center.dy)); +} + +/// Records the text written to the clipboard; the getter returns the last. +String? Function() _captureClipboard() { + String? copiedText; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (methodCall) async { + if (methodCall.method == 'Clipboard.setData') { + final arguments = methodCall.arguments as Map; + copiedText = arguments['text'] as String?; + } + return null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + return () => copiedText; +} + +Future _copy(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); +} + +/// Global rect of the rendered characters of [text]. +Rect _textRect(WidgetTester tester, String text) { + final finder = _richTextContaining(text).first; + final paragraph = tester.renderObject(finder); + final plain = paragraph.text.toPlainText(); + final start = plain.indexOf(text); + final boxes = paragraph.getBoxesForSelection( + TextSelection(baseOffset: start, extentOffset: start + text.length), + ); + expect(boxes, isNotEmpty, reason: 'no boxes for "$text"'); + var rect = boxes.first.toRect(); + for (final box in boxes.skip(1)) { + rect = rect.expandToInclude(box.toRect()); + } + return rect.shift(paragraph.localToGlobal(Offset.zero)); +} + +Rect _blockRect(WidgetTester tester, int index) { + return tester.getRect( + find.byWidgetPredicate( + (widget) => + widget is SelectableMarkdownBlock && widget.blockIndex == index, + ), + ); +} + +String _describePosition(DocumentPosition position) { + return '${position.blockIndex}${position.path.segments}' + '@${position.textOffset}'; +} + +String _describe(MarkdownSelectionController controller) { + final selection = controller.selection; + if (selection == null) { + return 'none'; + } + final text = controller.selectedPlainText.replaceAll('\n', r'\n'); + return '${_describePosition(selection.base)} -> ' + '${_describePosition(selection.extent)} | "$text"'; +} + +void _expectSelection( + MarkdownSelectionController controller, + String expected, +) { + expect(_describe(controller), expected); +} + +void main() { + group('gaps between child blocks', () { + const quote = '> First paragraph\n>\n> Second paragraph'; + const list = '- First item\n\n Second paragraph\n- Next item'; + + for (final entry in >{ + quote: [ + '0[0]@0 -> 0[0]@8 | "First pa"', + '0[0]@0 -> 0[0]@8 | "First pa"', + '0[0]@0 -> 0[1]@25 | "First paragraph\\n\\nSecond p"' + ], + list: [ + '0[0, 1]@2 -> 0[0, 1]@7 | "First"', + '0[0, 1]@2 -> 0[0, 1]@7 | "First"', + '0[0, 1]@2 -> 0[0, 2]@20 | "First item\\n Secon"' + ], + }.entries) { + final label = entry.key == quote ? 'quote' : 'list'; + final pointsInGap = { + 'just below the first child': (first, second) => first.bottom + 1, + 'at the gap midpoint': (first, second) => + (first.bottom + second.top) / 2, + 'just above the second child': (first, second) => second.top - 1, + }; + var index = 0; + for (final point in pointsInGap.entries) { + final expected = entry.value[index++]; + testWidgets('$label: drag ending ${point.key}', (tester) async { + final controller = await _pump(tester, entry.key); + final first = _textRect( + tester, + entry.key == quote ? 'First paragraph' : 'First item', + ); + final second = _textRect(tester, 'Second paragraph'); + await _drag( + tester, + first.centerLeft + const Offset(2, 0), + () => Offset(first.center.dx, point.value(first, second)), + ); + _expectSelection(controller, expected); + }); + } + } + + testWidgets('list: drag ending between two items', (tester) async { + final controller = await _pump(tester, '- Alpha item\n- Beta item'); + final first = _textRect(tester, 'Alpha item'); + final second = _textRect(tester, 'Beta item'); + await _drag( + tester, + first.centerLeft + const Offset(2, 0), + () => Offset(first.center.dx, (first.bottom + second.top) / 2), + ); + _expectSelection(controller, '0[0, 1]@2 -> 0[1, 0]@12 | "Alpha item"'); + }); + }); + + group('list markers', () { + const data = '1. Alpha item\n2. Beta item'; + + Offset markerOf(WidgetTester tester, String itemText) { + final text = _textRect(tester, itemText); + final block = _blockRect(tester, 0); + return Offset((block.left + text.left) / 2, text.center.dy); + } + + testWidgets('drag starting on a list marker', (tester) async { + final controller = await _pump(tester, data); + final end = _textRect(tester, 'Beta item'); + await _drag( + tester, + markerOf(tester, 'Beta item'), + () => end.centerRight - const Offset(2, 0), + ); + _expectSelection(controller, '0[1, 0]@14 -> 0[1, 1]@26 | "2. Beta item"'); + }); + + testWidgets('drag ending on a list marker', (tester) async { + final controller = await _pump(tester, data); + final start = _textRect(tester, 'Alpha item'); + await _drag( + tester, + start.centerLeft + const Offset(2, 0), + () => markerOf(tester, 'Beta item'), + ); + _expectSelection(controller, '0[0, 1]@3 -> 0[1, 0]@14 | "Alpha item\\n"'); + }); + + testWidgets('double click on a list marker', (tester) async { + final controller = await _pump(tester, data); + await _click(tester, markerOf(tester, 'Beta item'), count: 2); + _expectSelection(controller, '0[1, 0]@14 -> 0[1, 0]@15 | "2"'); + }); + + testWidgets('triple click on a list marker', (tester) async { + final controller = await _pump(tester, data); + await _click(tester, markerOf(tester, 'Beta item'), count: 3); + _expectSelection(controller, '0[1, 0]@14 -> 0[1, 1]@26 | "2. Beta item"'); + }); + }); + + group('pointer to the right of a line end', () { + testWidgets('wrapped paragraph', (tester) async { + final controller = await _pump( + tester, + 'The quick brown fox jumps over the lazy dog while the selection ' + 'geometry spans several wrapped lines of plain paragraph text.', + ); + final block = _blockRect(tester, 0); + final start = _charCenter(tester, 'The quick', 0); + // The second rendered line spans y = 21..42 inside the block. + await _drag( + tester, + start, + () => Offset(block.right - 2, block.top + 31), + ); + _expectSelection(controller, + '0[0]@1 -> 0[0]@40 | "he quick brown fox jumps over the lazy "'); + }); + + testWidgets('short list item', (tester) async { + final controller = await _pump(tester, '- short\n- other item'); + final block = _blockRect(tester, 0); + final start = _charCenter(tester, 'short', 0); + await _drag( + tester, + start, + () => Offset(block.right - 2, start.dy), + ); + _expectSelection(controller, '0[0, 1]@3 -> 0[1, 0]@7 | "hort"'); + }); + + testWidgets('table cell', (tester) async { + final controller = await _pump( + tester, + '| Name | Value |\n| --- | --- |\n| ab | 42 |', + ); + final start = _charCenter(tester, 'Name', 0); + final cell = _textRect(tester, 'ab'); + await _drag( + tester, + start, + () => Offset(cell.right + 8, cell.center.dy), + ); + _expectSelection( + controller, '0[0, 0]@1 -> 0[1, 1]@13 | "ame\tValue\\nab"'); + }); + }); + + group('drag across top-level blocks', () { + const data = 'Intro paragraph\n\n' + '- one\n- two three\n\n' + '> quoted text\n\n' + '| alpha | beta |\n| - | - |\n| gamma | delta |'; + + testWidgets('from a paragraph into a list item', (tester) async { + final controller = await _pump(tester, data); + await _drag( + tester, + _charCenter(tester, 'Intro', 1), + () => _charCenter(tester, 'two three', 5), + ); + _expectSelection(controller, + '0[0]@2 -> 1[1, 1]@14 | "tro paragraph\\n\\n- one\\n- two th"'); + }); + + testWidgets('from a quote into a table cell', (tester) async { + final controller = await _pump(tester, data); + await _drag( + tester, + _charCenter(tester, 'quoted', 2), + () => _textRect(tester, 'delta').center, + ); + _expectSelection(controller, + '2[0]@3 -> 3[1, 1]@20 | "ted text\\n\\nalpha\tbeta\\ngamma\tdel"'); + }); + + testWidgets('backwards from a table cell into a list item', ( + tester, + ) async { + final controller = await _pump(tester, data); + await _drag( + tester, + _textRect(tester, 'gamma').center, + () => _charCenter(tester, 'one', 1), + ); + _expectSelection(controller, + '3[1, 0]@14 -> 1[0, 1]@4 | "e\\n- two three\\n\\nquoted text\\n\\nalpha\tbeta\\ngam"'); + }); + + testWidgets('ending in blank space below the document', (tester) async { + final controller = await _pump(tester, data); + await _drag( + tester, + _charCenter(tester, 'Intro', 0), + () => const Offset(120, 560), + ); + _expectSelection(controller, + '0[0]@1 -> 3[1, 1]@22 | "ntro paragraph\\n\\n- one\\n- two three\\n\\nquoted text\\n\\nalpha\tbeta\\ngamma\tdelta"'); + }); + }); + + group('double and triple click in nested blocks', () { + const nestedList = '- Fruit\n - Apple pie\n - Green apple'; + const nestedQuote = '> # Title\n>\n> > inner quote words here'; + const table = '| Name | Value |\n| --- | --- |\n| row one | 42 |'; + + final targets = { + 'nested list': (nestedList, 'Apple pie', 7), + 'deep nested list': (nestedList, 'Green apple', 2), + 'nested quote': (nestedQuote, 'inner quote', 8), + 'quote heading': (nestedQuote, 'Title', 1), + 'table cell': (table, 'row one', 5), + }; + final expected = { + 'nested list': ( + '0[0, 2, 0, 1]@20 -> 0[0, 2, 0, 1]@23 | "pie"', + '0[0, 2, 0, 0]@10 -> 0[0, 2, 0, 1]@23 | " - Apple pie"' + ), + 'deep nested list': ( + '0[0, 2, 0, 2, 0, 1]@36 -> 0[0, 2, 0, 2, 0, 1]@41 | "Green"', + '0[0, 2, 0, 2, 0, 0]@30 -> 0[0, 2, 0, 2, 0, 1]@47 | " - Green apple"' + ), + 'nested quote': ( + '0[1, 0]@13 -> 0[1, 0]@18 | "quote"', + '0[1, 0]@7 -> 0[1, 0]@25 | "inner quote words "' + ), + 'quote heading': ( + '0[0]@0 -> 0[0]@5 | "Title"', + '0[0]@0 -> 0[0]@5 | "Title"' + ), + 'table cell': ( + '0[1, 0]@15 -> 0[1, 0]@18 | "one"', + '0[1, 0]@11 -> 0[1, 0]@18 | "row one"' + ), + }; + + for (final entry in targets.entries) { + final (data, text, charIndex) = entry.value; + testWidgets('double click in ${entry.key}', (tester) async { + final controller = await _pump(tester, data); + await _click(tester, _charCenter(tester, text, charIndex), count: 2); + _expectSelection(controller, expected[entry.key]!.$1); + }); + + testWidgets('triple click in ${entry.key}', (tester) async { + final controller = await _pump(tester, data); + await _click(tester, _charCenter(tester, text, charIndex), count: 3); + _expectSelection(controller, expected[entry.key]!.$2); + }); + } + }); + + group('double click on the right half of the last character of a leaf', () { + // The end offset of a leaf is also the start of the next one; the click + // must select the word under the pointer, not the next leaf's word. + const sparseTable = '| Alpha | | Charlie |\n' + '| ----- | ----- | ------- |\n' + '| | Bravo | z |'; + + testWidgets('table cell before an empty cell copies its word', ( + tester, + ) async { + final copiedText = _captureClipboard(); + final controller = await _pump(tester, sparseTable); + await _click(tester, _charRightHalf(tester, 'Alpha', 4), count: 2); + _expectSelection(controller, '0[0, 0]@0 -> 0[0, 0]@5 | "Alpha"'); + await _copy(tester); + expect(copiedText(), 'Alpha'); + }); + + final targets = { + 'last cell of a table row': ( + sparseTable, + 'Charlie', + 6, + '0[0, 2]@6 -> 0[0, 2]@13 | "Charlie"', + ), + 'table cell after an empty cell': ( + sparseTable, + 'Bravo', + 4, + '0[1, 1]@14 -> 0[1, 1]@19 | "Bravo"', + ), + 'table cell before a filled cell': ( + '| Alpha | Bravo |\n| --- | --- |\n| x | y |', + 'Alpha', + 4, + '0[0, 0]@0 -> 0[0, 0]@5 | "Alpha"', + ), + 'quote paragraph': ( + '> First\n>\n> Second', + 'First', + 4, + '0[0]@0 -> 0[0]@5 | "First"', + ), + 'list item': ( + '- one\n- two', + 'one', + 2, + '0[0, 1]@2 -> 0[0, 1]@5 | "one"', + ), + 'list item before a nested list': ( + '- one\n - two', + 'one', + 2, + '0[0, 1]@2 -> 0[0, 1]@5 | "one"', + ), + 'details summary': ( + '
Sum\n\nBody text\n\n
', + 'Sum', + 2, + '0[0]@0 -> 0[0]@3 | "Sum"', + ), + }; + for (final entry in targets.entries) { + final (data, text, charIndex, expected) = entry.value; + testWidgets(entry.key, (tester) async { + final controller = await _pump(tester, data); + await _click( + tester, + _charRightHalf(tester, text, charIndex), + count: 2, + ); + _expectSelection(controller, expected); + }); + } + + testWidgets('left half of the first character of the next cell', ( + tester, + ) async { + final controller = await _pump(tester, sparseTable); + final charlie = _textRect(tester, 'Charlie'); + await _click(tester, Offset(charlie.left + 1, charlie.center.dy), + count: 2); + _expectSelection(controller, '0[0, 2]@6 -> 0[0, 2]@13 | "Charlie"'); + }); + }); + + group('link taps with selection enabled', () { + final cases = { + 'list': '- see [Link](https://list.example) here', + 'quote': '> see [Link](https://quote.example) here', + 'table': '| head |\n| --- |\n| see [Link](https://table.example) |', + }; + for (final entry in cases.entries) { + testWidgets('tapping a link inside a ${entry.key}', (tester) async { + final tapped = []; + final controller = await _pump( + tester, + entry.value, + onTapLink: (destination, title, label) => tapped.add(destination), + ); + await _click(tester, _charCenter(tester, 'Link', 1)); + expect(tapped, ['https://${entry.key}.example']); + expect(controller.hasSelection, isFalse); + }); + } + }); + + testWidgets( + 'copying a drag selection that spans a block scrolled out of a ListView', + (tester) async { + final copiedText = _captureClipboard(); + final paragraphs = List.generate(30, (index) => 'Paragraph $index'); + final expectedCopiedText = [ + paragraphs.first.substring(3), + ...paragraphs.sublist(1, 24), + paragraphs[24].substring(0, 7), + ].join('\n\n'); + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + final controller = await _pump( + tester, + paragraphs.join('\n\n'), + height: 240, + scrollController: scrollController, + ); + + await _drag( + tester, + _charCenter(tester, 'Paragraph 0', 2), + () => _charCenter(tester, 'Paragraph 24', 6), + beforeEnd: () async { + scrollController.jumpTo(scrollController.position.maxScrollExtent); + await tester.pump(); + expect(_richTextContaining('Paragraph 0'), findsNothing); + }, + ); + _expectSelection(controller, + '0[0]@3 -> 24[0]@7 | "${expectedCopiedText.replaceAll('\n', r'\n')}"'); + + await _copy(tester); + expect(copiedText(), expectedCopiedText); + }); +}