From efb526c8f851b1e2ba4205bb175586cea7282bc6 Mon Sep 17 00:00:00 2001 From: bin <17426470+boyan01@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:10:06 +0800 Subject: [PATCH 1/4] mixin_markdown_widget: pin selection geometry and gestures before the leaf refactor Add safety-net tests that pass on the current renderer so the selection geometry can be restructured without visual or behavioral drift. - selection_geometry_snapshot_test.dart records the block-local rects, clip rect, and paint order that each block's selection painter receives for paragraphs, list items with marker prefixes, nested lists, nested quotes with headings, details, definition lists, footnotes, table cells across rows, a scrolled table inside a quote, a scrolled code block, and inline code/math. Rects are compared within 0.5px. - selection_gesture_edge_test.dart drives mouse gestures for gaps between quote/list children, list markers, pointers past a line end, drags across top-level blocks and into blank space, double/triple clicks in nested lists, quotes and table cells, link taps with selection enabled, and copying a drag selection that spans a block scrolled out of a ListView. --- .../selection_geometry_snapshot_test.dart | 550 ++++++++++++++++++ .../test/selection_gesture_edge_test.dart | 489 ++++++++++++++++ 2 files changed, 1039 insertions(+) create mode 100644 packages/mixin_markdown_widget/test/selection_geometry_snapshot_test.dart create mode 100644 packages/mixin_markdown_widget/test/selection_gesture_edge_test.dart 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..6197e758 --- /dev/null +++ b/packages/mixin_markdown_widget/test/selection_gesture_edge_test.dart @@ -0,0 +1,489 @@ +// 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); +} + +/// 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('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 { + 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); + }); + + 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 tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyC); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(copiedText, expectedCopiedText); + }); +} From bcd8573f5f755aeddc154fe3133f1b8cfa6742d0 Mon Sep 17 00:00:00 2001 From: bin <17426470+boyan01@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:56:03 +0800 Subject: [PATCH 2/4] mixin_markdown_widget: resolve selection geometry from text leaves Each selectable text leaf now answers its own selection geometry, and container blocks only order leaves. - Cache StructuredBlockSelection and serialized block text per BlockNode. The plain text serializer is the single source for copy, clamping, and select-all; the selection controller delegates to it, so a serializer subclass changes both consistently. - Add MarkdownSelectableLeaf: text, code, table cell, and placeholder leaves register by PathInBlock in a per-block scope and expose rects, offsets, line ranges, an optional viewport clip, and their origin. - Replace the per-container resolvers with one container algorithm for quotes, details, lists, footnotes, and tables. Leaf-local and block display offsets are converted once, through the structured leaves. Container specs now take their text from the structured selection. - Remove the old selection resolver, descriptor-based unit ranges, and the per-child GlobalKey maps for list items, quote/details children, and table cells. Block keys, scroll controllers, and viewport keys stay. Public API and selection semantics are unchanged. --- packages/mixin_markdown_widget/AGENTS.md | 32 +- .../src/clipboard/plain_text_serializer.dart | 134 +- .../builder/markdown_block_builder.dart | 1314 +++----- .../src/render/markdown_block_widgets.dart | 55 +- .../src/render/markdown_document_view.dart | 10 +- .../lib/src/render/pretext_text_block.dart | 5 - .../lib/src/render/selectable_block.dart | 21 +- .../markdown_block_keys_registry.dart | 38 + .../markdown_descriptor_extractor.dart | 619 +--- .../selection/markdown_selectable_leaf.dart | 782 +++++ .../markdown_selection_geometry.dart | 845 +++++ .../markdown_selection_resolver.dart | 2970 ----------------- .../src/selection/selection_controller.dart | 182 +- .../selection/structured_block_selection.dart | 29 +- 14 files changed, 2233 insertions(+), 4803 deletions(-) create mode 100644 packages/mixin_markdown_widget/lib/src/render/selection/markdown_block_keys_registry.dart create mode 100644 packages/mixin_markdown_widget/lib/src/render/selection/markdown_selectable_leaf.dart create mode 100644 packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_geometry.dart delete mode 100644 packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_resolver.dart 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/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..9c6c7888 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() { @@ -407,7 +423,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}) { From 70058ab659ebaaf3a436b62666dd2916fba79f4b Mon Sep 17 00:00:00 2001 From: bin <17426470+boyan01@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:12:30 +0800 Subject: [PATCH 3/4] mixin_markdown_widget: raise example macOS deployment target to 12.0 Flutter tooling updated the example project when running it on macOS. --- .../example/macos/Runner.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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; From d49526b44af331515d0944b169df619a60339e96 Mon Sep 17 00:00:00 2001 From: bin <17426470+boyan01@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:46:04 +0800 Subject: [PATCH 4/4] mixin_markdown_widget: select the word under the pointer at the end of a leaf Double clicking the right half of the last character of a table cell, quote paragraph, list item, or details summary selected the word of the next leaf instead. Hit testing turns the pointer into a block display offset and resolves it downstream, so the end of a leaf lands on the start of the next leaf, and word selection then looked up the word there. For a table such as `| Alpha | | Charlie |`, the word was `Charlie` and copy returned the wrong cell. selectWord already detects that the pointer is over the character before the offset; it now resolves the position upstream in that case, so the word comes from the leaf that ends there. --- .../lib/src/render/selectable_block.dart | 11 +- .../test/selection_gesture_edge_test.dart | 148 +++++++++++++++--- 2 files changed, 138 insertions(+), 21 deletions(-) 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 9c6c7888..532c9b3e 100644 --- a/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart +++ b/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart @@ -233,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) { diff --git a/packages/mixin_markdown_widget/test/selection_gesture_edge_test.dart b/packages/mixin_markdown_widget/test/selection_gesture_edge_test.dart index 6197e758..a21abe4f 100644 --- a/packages/mixin_markdown_widget/test/selection_gesture_edge_test.dart +++ b/packages/mixin_markdown_widget/test/selection_gesture_edge_test.dart @@ -112,6 +112,45 @@ Offset _charCenter(WidgetTester tester, String text, int 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; @@ -413,6 +452,92 @@ void main() { } }); + 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', @@ -437,20 +562,7 @@ void main() { testWidgets( 'copying a drag selection that spans a block scrolled out of a ListView', (tester) async { - 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); - }); - + final copiedText = _captureClipboard(); final paragraphs = List.generate(30, (index) => 'Paragraph $index'); final expectedCopiedText = [ paragraphs.first.substring(3), @@ -479,11 +591,7 @@ void main() { _expectSelection(controller, '0[0]@3 -> 24[0]@7 | "${expectedCopiedText.replaceAll('\n', r'\n')}"'); - await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); - await tester.sendKeyEvent(LogicalKeyboardKey.keyC); - await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); - await tester.pump(); - - expect(copiedText, expectedCopiedText); + await _copy(tester); + expect(copiedText(), expectedCopiedText); }); }