From 8db7028f3a05adcc52c1639d86783f38b0bcaa12 Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 4 Nov 2026 21:14:35 +0800 Subject: [PATCH] fix(chat): a table's text selects as the text around it does A drag beside a row of a table that scrolls sideways selected every row below it: Flutter's scroll view took an edge past its side as past all of it, and kept from one drag to the next whether the drag had begun in it. The table now has its own selection: an edge beside a row is at that row's end (at the edge of what shows, scrolling on, when the drag began in the table). Copied, a row is a line, its cells apart by tabs. Co-Authored-By: BaoCode --- lib/chat/widgets/finite_selection_edges.dart | 6 +- lib/chat/widgets/markdown_view.dart | 38 ++-- lib/chat/widgets/table_selection.dart | 221 +++++++++++++++++++ test/markdown_test.dart | 115 ++++++++++ 4 files changed, 363 insertions(+), 17 deletions(-) create mode 100644 lib/chat/widgets/table_selection.dart diff --git a/lib/chat/widgets/finite_selection_edges.dart b/lib/chat/widgets/finite_selection_edges.dart index 536c5f68..c3428374 100644 --- a/lib/chat/widgets/finite_selection_edges.dart +++ b/lib/chat/widgets/finite_selection_edges.dart @@ -32,7 +32,7 @@ class FiniteSelectionEdges extends StatefulWidget { } class _FiniteSelectionEdgesState extends State { - final _delegate = _FiniteEdgesDelegate(); + final _delegate = FiniteEdgesDelegate(); @override void dispose() { @@ -47,7 +47,9 @@ class _FiniteSelectionEdgesState extends State { } } -class _FiniteEdgesDelegate extends StaticSelectionContainerDelegate +/// [FiniteSelectionEdges]'s: an edge sent at infinity put just past the +/// content. +class FiniteEdgesDelegate extends StaticSelectionContainerDelegate with SafeSelectionIndexes { bool joinsLines = false; diff --git a/lib/chat/widgets/markdown_view.dart b/lib/chat/widgets/markdown_view.dart index 0c46d1bf..ef636084 100644 --- a/lib/chat/widgets/markdown_view.dart +++ b/lib/chat/widgets/markdown_view.dart @@ -15,7 +15,6 @@ import '../composer/composer_embeds.dart' show FileImageChip; import '../side_panel/file_open.dart'; import 'code_citation.dart'; import 'content_image.dart'; -import 'finite_selection_edges.dart'; import 'hover_scrollbar.dart'; import 'inline_code.dart'; import 'lazy_segment.dart'; @@ -23,6 +22,7 @@ import 'markdown_cjk.dart'; import 'markdown_math.dart'; import 'markdown_tags.dart'; import 'mermaid_code_block.dart'; +import 'table_selection.dart'; import 'will_change_paint.dart'; /// GitHub-flavored markdown as plain widgets (so a surrounding @@ -900,16 +900,18 @@ class _TableState extends State<_Table> { vertical: 5, ), child: i < cells.length - ? InlineCodeText( - _inlines( - cells[i].children ?? const [], - header - ? style.copyWith( - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ) - : style, - widget.options, + ? TableCellSelection( + child: InlineCodeText( + _inlines( + cells[i].children ?? const [], + header + ? style.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ) + : style, + widget.options, + ), ), ) : const SizedBox.shrink(), @@ -930,10 +932,16 @@ class _TableState extends State<_Table> { clipBehavior: Clip.antiAlias, child: HoverScrollbar( controller: _scroll, - child: SingleChildScrollView( - controller: _scroll, - scrollDirection: Axis.horizontal, - child: FiniteSelectionEdges(child: WillChangePaint(child: table)), + // The table's selection is its own, not the scroll view's. + child: SelectionContainer.disabled( + child: SingleChildScrollView( + controller: _scroll, + scrollDirection: Axis.horizontal, + child: TableSelection( + registrar: SelectionContainer.maybeOf(context), + child: WillChangePaint(child: table), + ), + ), ), ), ), diff --git a/lib/chat/widgets/table_selection.dart b/lib/chat/widgets/table_selection.dart new file mode 100644 index 00000000..9040f948 --- /dev/null +++ b/lib/chat/widgets/table_selection.dart @@ -0,0 +1,221 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import 'finite_selection_edges.dart'; + +/// The cells of a table that scrolls sideways, selected as text is, and +/// copied a row to a line, its cells apart by tabs (as a spreadsheet takes +/// them). +/// +/// Put in the scroll view, the scroll view's own selection handling off +/// ([SelectionContainer.disabled] around it), [registrar] the one around +/// that. Flutter's took a drag to the left or right of the table as one +/// past all of it, and it kept, from one drag to the next, whether the +/// first had begun in the table: a drag in a row, after one across the +/// table, out past the edge selected every row below; one from above the +/// table to beside a row, the whole table. Here an edge beside a row is at +/// that row's end (or start): at the edge of what shows if the drag began +/// in the table, which scrolls on toward it, else past all of the row. +class TableSelection extends StatefulWidget { + const TableSelection({ + super.key, + required this.registrar, + required this.child, + }); + + final SelectionRegistrar? registrar; + + /// The table, each cell in a [TableCellSelection]. + final Widget child; + + @override + State createState() => _TableSelectionState(); +} + +class _TableSelectionState extends State { + final _delegate = _TableSelectionDelegate(); + + @override + void dispose() { + _delegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + _delegate.scrollable = Scrollable.maybeOf(context); + return SelectionContainer( + registrar: widget.registrar, + delegate: _delegate, + child: widget.child, + ); + } +} + +/// A cell of a [TableSelection]'s table: its text one piece to the table, +/// however many pieces it is in (a file link's icon splits it). +class TableCellSelection extends StatefulWidget { + const TableCellSelection({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _TableCellSelectionState(); +} + +class _TableCellSelectionState extends State { + final _delegate = StaticSelectionContainerDelegate(); + + @override + void dispose() { + _delegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => + SelectionContainer(delegate: _delegate, child: widget.child); +} + +class _TableSelectionDelegate extends FiniteEdgesDelegate { + ScrollableState? scrollable; + + /// The selection began in what of the table shows. + bool _begunWithin = false; + + /// What of the table shows, on screen. + Rect? get _shown { + final box = scrollable?.context.findRenderObject(); + if (box is! RenderBox || !box.hasSize) return null; + return MatrixUtils.transformRect( + box.getTransformTo(null), + Offset.zero & box.size, + ); + } + + @override + SelectionResult dispatchSelectionEvent(SelectionEvent event) { + switch (event) { + case SelectionEdgeUpdateEvent( + :final globalPosition, + :final type, + :final granularity, + ) + when globalPosition.isFinite && hasSize: + final shown = _shown; + if (shown == null) break; + final isEnd = type == SelectionEventType.endEdgeUpdate; + if (!isEnd) _begunWithin = shown.contains(globalPosition); + final table = MatrixUtils.transformRect( + getTransformTo(null), + Offset.zero & containerSize, + ); + final x = globalPosition.dx; + final besideRow = + globalPosition.dy >= table.top && + globalPosition.dy <= table.bottom && + (x < shown.left || x > shown.right); + if (!besideRow) { + _lastStep = null; + break; + } + final position = Offset( + _begunWithin + ? x.clamp(shown.left, shown.right) + : (x > shown.right ? table.right + 1 : table.left - 1), + globalPosition.dy, + ); + final result = super.dispatchSelectionEvent( + isEnd + ? SelectionEdgeUpdateEvent.forEnd( + globalPosition: position, + granularity: granularity, + ) + : SelectionEdgeUpdateEvent.forStart( + globalPosition: position, + granularity: granularity, + ), + ); + // Sent again next frame while it scrolls, to select what came in. + if (_begunWithin && isEnd && _scrollToward(x, shown)) { + return SelectionResult.pending; + } + _lastStep = null; + return result; + case SelectWordSelectionEvent(:final globalPosition) || + SelectParagraphSelectionEvent(:final globalPosition): + _begunWithin = _shown?.contains(globalPosition) ?? false; + case ClearSelectionEvent() || SelectAllSelectionEvent(): + _begunWithin = false; + _lastStep = null; + } + return super.dispatchSelectionEvent(event); + } + + /// The frame the table last scrolled in toward an edge held past it. + Duration? _lastStep; + + /// Scrolls the table a step toward [x], past what [shown]: as Flutter's + /// scroll views do, the further past the faster, up to 600 points a + /// second. Whether it scrolls on: not at the end. + bool _scrollToward(double x, Rect shown) { + final position = scrollable?.position; + if (position == null || !position.hasPixels) return false; + final past = x > shown.right ? x - shown.right : x - shown.left; + if (past > 0 + ? position.pixels >= position.maxScrollExtent + : position.pixels <= position.minScrollExtent) { + return false; + } + final scheduler = SchedulerBinding.instance; + final now = scheduler.currentSystemFrameTimeStamp; + final last = _lastStep; + _lastStep = now; + // The first step as long as a frame; none longer than a few. + final seconds = last == null + ? 1 / 60 + : math.min((now - last).inMicroseconds / 1e6, 0.05); + position.jumpTo( + (position.pixels + past.clamp(-20, 20) * 30 * seconds).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ), + ); + // The next frame, to send the edge again in. + scheduler.ensureVisualUpdate(); + return true; + } + + @override + SelectedContent? getSelectedContent() { + final rows = >[]; + Rect? previous; + for (final cell in selectables) { + final text = cell.getSelectedContent()?.plainText; + if (text == null) continue; + final transform = cell.getTransformTo(null); + final box = cell.boundingBoxes + .map((rect) => MatrixUtils.transformRect(transform, rect)) + .fold(null, (all, rect) => all?.expandToInclude(rect) ?? rect); + // A row's cells all begin at its top. + final sameRow = + previous != null && + box != null && + box.top < previous.bottom && + previous.top < box.bottom; + if (sameRow) { + rows.last.add(text); + } else { + rows.add([text]); + } + previous = box ?? previous; + } + if (rows.isEmpty) return null; + return SelectedContent( + plainText: rows.map((cells) => cells.join('\t')).join('\n'), + ); + } +} diff --git a/test/markdown_test.dart b/test/markdown_test.dart index 212b1633..0770f001 100644 --- a/test/markdown_test.dart +++ b/test/markdown_test.dart @@ -258,6 +258,121 @@ void main() { tester.getSize(find.byType(Table)).width, ); }); + + group('selected', () { + final wide = 'x' * 120; + final data = + 'above\n\n| h | k |\n| - | - |\n| a1 | b1 $wide |\n' + '| a2 | b2 |\n| a3 | b3 |\n\nbelow'; + String? selected; + + /// [data] in a selection area 500 wide, the table wider. + Future pumpTable(WidgetTester tester) => tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 500, + child: SelectionArea( + onSelectionChanged: (content) => + selected = content?.plainText, + child: MarkdownView(data), + ), + ), + ), + ), + ), + ); + + /// Where [text] begins. + Offset at(WidgetTester tester, String text) => + tester.getTopLeft( + find.textContaining(text, findRichText: true).last, + ) + + const Offset(1, 8); + + /// A mouse drag from [from] to [to], held there [frames] frames. + Future drag( + WidgetTester tester, + Offset from, + Offset to, { + int frames = 0, + }) async { + final gesture = await tester.startGesture( + from, + kind: PointerDeviceKind.mouse, + ); + await tester.pump(); + for (var i = 1; i <= 8; i++) { + await gesture.moveTo(Offset.lerp(from, to, i / 8)!); + await tester.pump(); + } + for (var i = 0; i < frames; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + await gesture.up(); + await tester.pump(); + } + + ScrollPosition table(WidgetTester tester) => tester + .state( + find.descendant( + of: find.byType(MarkdownView), + matching: find.byType(Scrollable), + ), + ) + .position; + + testWidgets('to beside a row, from above: up to that row\'s end, ' + 'a row to a line and its cells apart by tabs', (tester) async { + await pumpTable(tester); + await drag( + tester, + at(tester, 'above'), + Offset(600, at(tester, 'a1').dy), + ); + expect(selected, endsWith('h\tk\na1\tb1 $wide')); + expect(selected, startsWith('above')); + }); + + testWidgets('to beside a row, from below: from that row\'s end', ( + tester, + ) async { + await pumpTable(tester); + await drag( + tester, + at(tester, 'below') + const Offset(20, 0), + Offset(600, at(tester, 'a2').dy), + ); + expect(selected, startsWith('a3\tb3')); + expect(selected, isNot(contains('a2'))); + }); + + testWidgets('from a cell, held past the edge: the table scrolls to its ' + 'end, the row alone selected, after a drag across the table too', ( + tester, + ) async { + await pumpTable(tester); + await drag( + tester, + at(tester, 'above'), + at(tester, 'below') + const Offset(20, 0), + ); + expect(selected, contains('a1\tb1 $wide\na2\tb2\na3\tb3')); + + final from = at(tester, 'a1'); + await drag(tester, from, Offset(600, from.dy), frames: 150); + expect(table(tester).pixels, table(tester).maxScrollExtent); + expect(selected, 'a1\tb1 $wide'); + + table(tester).jumpTo(0); + await tester.pump(); + final row2 = at(tester, 'a2'); + await drag(tester, row2, Offset(600, row2.dy)); + expect(selected, 'a2\tb2'); + }); + }); }); group('math', () {