From 4805c517f6edbcd5a075282d472f028e6ed84c85 Mon Sep 17 00:00:00 2001 From: bin <17426470+boyan01@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:46:12 +0800 Subject: [PATCH] mixin_markdown_widget: fix streaming block identity, highlight flicker, and selection details - Keep the id of a streamed tail block (and its nested blocks) stable across appendChunk, so its state, code block scroll position, and caches survive each chunk. Full parses and setData keep content-hashed ids. - While a streamed code block grows, show its last highlight with the new tail in plain text instead of flashing back to plain, and run at most one highlight per block at a time instead of one per chunk. - Keep one tap recognizer per link while the link stays in the document, so cached rows never hold a disposed recognizer. - Call onTapLink through a stable forwarder, so passing a new closure on every parent rebuild no longer clears the row cache. - Double-click selects words in CJK text: CJK punctuation separates words and non-ASCII runs narrow to ICU word boundaries. ASCII text keeps its rule. - Type the block state access of the document view instead of using dynamic. - Add MarkdownController.updateData, which appends when the new text extends the current data and parses in full otherwise. Deprecate the misspelled MarkownWidget alias and MarkdownDocumentParser.parseAppending, which ignores its previous document. --- packages/mixin_markdown_widget/README.md | 9 + .../src/parser/markdown_document_parser.dart | 60 +++- .../builder/markdown_inline_builder.dart | 121 +++++++- .../src/render/code_syntax_highlighter.dart | 232 ++++++++++++---- .../src/render/markdown_document_view.dart | 66 ++--- .../lib/src/render/selectable_block.dart | 260 ++++++++++++++---- .../markdown_selection_gesture_detector.dart | 7 +- .../src/selection/mixin_selection_area.dart | 18 +- .../src/selection/selection_registrar.dart | 5 +- .../selection/structured_block_selection.dart | 26 +- .../lib/src/widgets/markdown_controller.dart | 31 +++ .../lib/src/widgets/markdown_widget.dart | 2 + .../markdown_code_highlight_cache_test.dart | 180 ++++++++++++ .../markdown_controller_streaming_test.dart | 215 +++++++++++++++ .../test/markdown_link_recognizer_test.dart | 175 ++++++++++++ .../test/markdown_word_selection_test.dart | 175 ++++++++++++ .../test/mixin_markdown_widget_test.dart | 2 +- 17 files changed, 1399 insertions(+), 185 deletions(-) create mode 100644 packages/mixin_markdown_widget/test/markdown_code_highlight_cache_test.dart create mode 100644 packages/mixin_markdown_widget/test/markdown_controller_streaming_test.dart create mode 100644 packages/mixin_markdown_widget/test/markdown_link_recognizer_test.dart create mode 100644 packages/mixin_markdown_widget/test/markdown_word_selection_test.dart diff --git a/packages/mixin_markdown_widget/README.md b/packages/mixin_markdown_widget/README.md index eafa1343..bc5bd275 100644 --- a/packages/mixin_markdown_widget/README.md +++ b/packages/mixin_markdown_widget/README.md @@ -94,4 +94,13 @@ controller.appendChunk('\n\nNew content arrived.'); controller.commitStream(); ``` +If you receive the full text so far instead of deltas, pass it to `updateData`. It appends only the new suffix when the text extends the current data, falls back to a full parse otherwise, and commits the stream unless `streaming` is true: + +```dart +controller.updateData(fullTextSoFar, streaming: true); + +// When stream finishes: +controller.updateData(finalText); +``` + See the `/example` directory for a fully runnable desktop demo. diff --git a/packages/mixin_markdown_widget/lib/src/parser/markdown_document_parser.dart b/packages/mixin_markdown_widget/lib/src/parser/markdown_document_parser.dart index cfb62864..d8ce0b99 100644 --- a/packages/mixin_markdown_widget/lib/src/parser/markdown_document_parser.dart +++ b/packages/mixin_markdown_widget/lib/src/parser/markdown_document_parser.dart @@ -49,6 +49,10 @@ class MarkdownDocumentParser { ); } + @Deprecated( + 'This ignores previousDocument and parses the whole source. Use parse, ' + 'or MarkdownController.appendChunk / updateData for incremental parsing.', + ) MarkdownDocument parseAppending( String source, { required MarkdownDocument previousDocument, @@ -167,6 +171,9 @@ class MarkdownDocumentParser { version: version, sourceOffset: sourceOffset, initialKindCounts: initialKindCounts, + reusableIds: parseAppendedChunkAsNewBlocks + ? null + : _reusableIdsOf(previousDocument.blocks.last), ); final document = MarkdownDocument( @@ -237,6 +244,7 @@ class MarkdownDocumentParser { required int version, required int sourceOffset, required Map initialKindCounts, + Map? reusableIds, }) { final timing = onTiming == null ? null : _MarkdownParserTimingBuilder(); final totalStopwatch = timing == null ? null : (Stopwatch()..start()); @@ -254,6 +262,7 @@ class MarkdownDocumentParser { ); final builder = _MarkdownAstBuilder( initialKindCounts: initialKindCounts, + reusableIds: reusableIds, timing: timing, ); var blocks = _measure( @@ -389,11 +398,35 @@ class MarkdownDocumentParser { BlockNode block, Map counts, ) { - counts[block.kind] = (counts[block.kind] ?? 0) + 1; + _visitBlockTree(block, (node) { + counts[node.kind] = (counts[node.kind] ?? 0) + 1; + }); + } + + /// The ids of [block] and its nested blocks by their `kind-count` part, so a + /// reparse of the same growing tail keeps its ids instead of churning the + /// content hash on every appended chunk. Images are left out: their error + /// state is keyed by id and must not follow a changed url. + Map _reusableIdsOf(BlockNode block) { + final ids = {}; + _visitBlockTree(block, (node) { + if (node.kind == MarkdownBlockKind.image) { + return; + } + final hashStart = node.id.lastIndexOf('-'); + if (hashStart > 0) { + ids[node.id.substring(0, hashStart)] = node.id; + } + }); + return ids; + } + + void _visitBlockTree(BlockNode block, void Function(BlockNode block) visit) { + visit(block); if (block is QuoteBlock) { for (final child in block.children) { - _countBlockKindsInBlock(child, counts); + _visitBlockTree(child, visit); } return; } @@ -401,7 +434,7 @@ class MarkdownDocumentParser { if (block is ListBlock) { for (final item in block.items) { for (final child in item.children) { - _countBlockKindsInBlock(child, counts); + _visitBlockTree(child, visit); } } return; @@ -410,7 +443,7 @@ class MarkdownDocumentParser { if (block is FootnoteListBlock) { for (final item in block.items) { for (final child in item.children) { - _countBlockKindsInBlock(child, counts); + _visitBlockTree(child, visit); } } return; @@ -418,7 +451,7 @@ class MarkdownDocumentParser { if (block is DetailsBlock) { for (final child in block.children) { - _countBlockKindsInBlock(child, counts); + _visitBlockTree(child, visit); } return; } @@ -427,7 +460,7 @@ class MarkdownDocumentParser { for (final item in block.items) { for (final definition in item.definitions) { for (final child in definition) { - _countBlockKindsInBlock(child, counts); + _visitBlockTree(child, visit); } } } @@ -1135,8 +1168,10 @@ class _MarkdownParserTimingBuilder { class _MarkdownAstBuilder { _MarkdownAstBuilder({ Map? initialKindCounts, + Map? reusableIds, _MarkdownParserTimingBuilder? timing, }) : _timing = timing, + _reusableIds = reusableIds, _kindCounters = { ...?initialKindCounts, }; @@ -1144,6 +1179,11 @@ class _MarkdownAstBuilder { final Map _kindCounters; final _MarkdownParserTimingBuilder? _timing; + /// Ids of the previous tail block, which the first top-level block of an + /// append reparse takes over. See [MarkdownDocumentParser._reusableIdsOf]. + Map? _reusableIds; + int _blockDepth = 0; + Map get kindCounts => Map.unmodifiable(_kindCounters); @@ -1154,9 +1194,14 @@ class _MarkdownAstBuilder { blocks.addAll(buildBlocks(node.children ?? const [])); continue; } + _blockDepth += 1; final block = _buildBlock(node); + _blockDepth -= 1; if (block != null) { blocks.add(block); + if (_blockDepth == 0) { + _reusableIds = null; + } } } return blocks; @@ -1973,7 +2018,8 @@ class _MarkdownAstBuilder { () { final nextCount = (_kindCounters[kind] ?? 0) + 1; _kindCounters[kind] = nextCount; - return '${kind.name}-$nextCount-${_stableHash(signature)}'; + final prefix = '${kind.name}-$nextCount'; + return _reusableIds?[prefix] ?? '$prefix-${_stableHash(signature)}'; }, ); } diff --git a/packages/mixin_markdown_widget/lib/src/render/builder/markdown_inline_builder.dart b/packages/mixin_markdown_widget/lib/src/render/builder/markdown_inline_builder.dart index 837d857f..6ee96f1e 100644 --- a/packages/mixin_markdown_widget/lib/src/render/builder/markdown_inline_builder.dart +++ b/packages/mixin_markdown_widget/lib/src/render/builder/markdown_inline_builder.dart @@ -7,6 +7,115 @@ import '../../widgets/markdown_theme.dart'; import '../../widgets/markdown_types.dart'; import '../pretext_text_block.dart'; +/// The tap recognizers of the links of a document. +/// +/// A link keeps one recognizer while its [LinkInline] stays in the document, +/// so block rows and descriptors cached across builds never hold a disposed +/// recognizer. +class MarkdownLinkRecognizers { + final Map _recognizers = + Map.identity(); + + int get length => _recognizers.length; + + TapGestureRecognizer recognizerFor(LinkInline link, VoidCallback onTap) { + return _recognizers.putIfAbsent( + link, + () => TapGestureRecognizer()..onTap = onTap, + ); + } + + /// Disposes the recognizers of the links that the blocks of [previous] had + /// and the blocks of [current] no longer have. + void disposeRemovedLinks(List previous, List current) { + if (_recognizers.isEmpty || identical(previous, current)) { + return; + } + final currentBlocks = Set.identity()..addAll(current); + final removed = Set.identity(); + for (final block in previous) { + if (!currentBlocks.contains(block)) { + _collectLinks(block, removed); + } + } + if (removed.isEmpty) { + return; + } + // A reparsed block can share link nodes with the block it replaces. + final previousBlocks = Set.identity()..addAll(previous); + final kept = Set.identity(); + for (final block in current) { + if (!previousBlocks.contains(block)) { + _collectLinks(block, kept); + } + } + for (final link in removed) { + if (!kept.contains(link)) { + _recognizers.remove(link)?.dispose(); + } + } + } + + void dispose() { + for (final recognizer in _recognizers.values) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + static void _collectLinks(BlockNode block, Set links) { + void visitInlines(List inlines) { + for (final inline in inlines) { + switch (inline) { + case LinkInline(:final children): + links.add(inline); + visitInlines(children); + case EmphasisInline(:final children) || + StrongInline(:final children) || + StrikethroughInline(:final children) || + HighlightInline(:final children) || + SubscriptInline(:final children) || + SuperscriptInline(:final children): + visitInlines(children); + default: + } + } + } + + void visitBlock(BlockNode block) { + switch (block) { + case HeadingBlock(:final inlines) || ParagraphBlock(:final inlines): + visitInlines(inlines); + case QuoteBlock(:final children): + children.forEach(visitBlock); + case DetailsBlock(:final summary, :final children): + visitInlines(summary); + children.forEach(visitBlock); + case ListBlock(:final items) || FootnoteListBlock(:final items): + for (final item in items) { + item.children.forEach(visitBlock); + } + case DefinitionListBlock(:final items): + for (final item in items) { + visitInlines(item.term); + for (final definition in item.definitions) { + definition.forEach(visitBlock); + } + } + case TableBlock(:final rows): + for (final row in rows) { + for (final cell in row.cells) { + visitInlines(cell.inlines); + } + } + default: + } + } + + visitBlock(block); + } +} + class MarkdownInlineBuilder { MarkdownInlineBuilder({ required this.theme, @@ -15,7 +124,7 @@ class MarkdownInlineBuilder { }); final MarkdownThemeData theme; - final List recognizers; + final MarkdownLinkRecognizers recognizers; final MarkdownTapLinkCallback? onTapLink; List buildPretextRuns( @@ -110,7 +219,7 @@ class MarkdownInlineBuilder { final label = flattenInlineText(link.children); final recognizer = onTapLink == null ? null - : _registerLink(() { + : recognizers.recognizerFor(link, () { onTapLink!(link.destination, link.title, label); }); final style = _linkTextStyle(baseStyle, linkStyle); @@ -298,7 +407,7 @@ class MarkdownInlineBuilder { final label = flattenInlineText(link.children); final recognizer = onTapLink == null ? null - : _registerLink(() { + : recognizers.recognizerFor(link, () { onTapLink!(link.destination, link.title, label); }); final style = _linkTextStyle(baseStyle, linkStyle); @@ -400,12 +509,6 @@ class MarkdownInlineBuilder { return inlineFontSize * baseFontSize / bodyFontSize; } - TapGestureRecognizer _registerLink(VoidCallback onTap) { - final recognizer = TapGestureRecognizer()..onTap = onTap; - recognizers.add(recognizer); - return recognizer; - } - TextStyle highlightStyle(TextStyle baseStyle) { final accent = theme.linkStyle.color ?? theme.dividerColor; return baseStyle.copyWith( diff --git a/packages/mixin_markdown_widget/lib/src/render/code_syntax_highlighter.dart b/packages/mixin_markdown_widget/lib/src/render/code_syntax_highlighter.dart index 29e698b6..6f1df5e7 100644 --- a/packages/mixin_markdown_widget/lib/src/render/code_syntax_highlighter.dart +++ b/packages/mixin_markdown_widget/lib/src/render/code_syntax_highlighter.dart @@ -349,6 +349,7 @@ class MarkdownCodeHighlightCache extends ChangeNotifier { final MarkdownCodeSyntaxHighlighter _highlighter; final Map _entries = {}; + final Set _highlightingBlockIds = {}; int _nextRequestId = 0; bool _isDisposed = false; @@ -377,8 +378,15 @@ class MarkdownCodeHighlightCache extends ChangeNotifier { } final requestId = ++_nextRequestId; + final request = _MarkdownCodeHighlightRequest( + source: source, + baseStyle: baseStyle, + theme: theme, + language: language, + ); if (_highlighter.shouldDegradeHighlight(source: source, theme: theme)) { _entries[blockId] = _MarkdownCodeHighlightCacheEntry( + request: request, contentSignature: contentSignature, presentationSignature: presentationSignature, requestId: requestId, @@ -396,39 +404,50 @@ class MarkdownCodeHighlightCache extends ChangeNotifier { ); if (cachedPresentation != null) { _entries[blockId] = _MarkdownCodeHighlightCacheEntry( + request: request, contentSignature: contentSignature, presentationSignature: presentationSignature, requestId: requestId, presentation: cachedPresentation, isPending: false, + highlightedPrefix: cachedPresentation.isHighlighted + ? _MarkdownHighlightedPrefix( + source: source, + styleSignature: request.styleSignature, + presentation: cachedPresentation, + ) + : null, ); return cachedPresentation; } + // While a streamed block grows, show its last highlight with the new tail + // in plain text instead of flashing the whole block back to plain. + final highlightedPrefix = existing?.highlightedPrefix; final placeholderPresentation = existing != null && existing.contentSignature == contentSignature && existing.presentation.isHighlighted ? existing.presentation - : plainPresentation; + : highlightedPrefix?.extendTo( + source, + styleSignature: request.styleSignature, + baseStyle: baseStyle, + ) ?? + plainPresentation; _entries[blockId] = _MarkdownCodeHighlightCacheEntry( + request: request, contentSignature: contentSignature, presentationSignature: presentationSignature, requestId: requestId, presentation: placeholderPresentation, isPending: true, + highlightedPrefix: highlightedPrefix, ); - unawaited( - _startHighlight( - blockId: blockId, - requestId: requestId, - contentSignature: contentSignature, - presentationSignature: presentationSignature, - source: source, - baseStyle: baseStyle, - theme: theme, - language: language, - ), - ); + // At most one highlight runs for a block; it picks up the latest source + // when it finishes, so a streamed block does not start one per chunk. + if (_highlightingBlockIds.add(blockId)) { + unawaited(_runHighlights(blockId)); + } return placeholderPresentation; } @@ -439,55 +458,67 @@ class MarkdownCodeHighlightCache extends ChangeNotifier { } final state = entry.presentation.isHighlighted ? 'ready' : 'plain'; final pending = entry.isPending ? 'pending' : 'stable'; - return 'highlight:$state:$pending:${entry.presentationSignature}'; + return 'highlight:$state:$pending:${entry.presentationSignature}:' + '${identityHashCode(entry.presentation)}'; } - Future _startHighlight({ - required String blockId, - required int requestId, - required int contentSignature, - required int presentationSignature, - required String source, - required TextStyle baseStyle, - required MarkdownThemeData theme, - required String? language, - }) async { - if (_isDisposed) { - return; - } - final current = _entries[blockId]; - if (current == null || - current.requestId != requestId || - current.presentationSignature != presentationSignature || - !current.isPending) { - return; - } - - final presentation = await _highlighter.buildPresentationAsync( - source: source, - baseStyle: baseStyle, - theme: theme, - language: language, - ); - if (_isDisposed) { - return; - } - final latest = _entries[blockId]; - if (latest == null || - latest.requestId != requestId || - latest.presentationSignature != presentationSignature) { - return; - } - - _entries[blockId] = _MarkdownCodeHighlightCacheEntry( - contentSignature: contentSignature, - presentationSignature: presentationSignature, - requestId: requestId, - presentation: presentation, - isPending: false, - ); - if (!_isDisposed) { - notifyListeners(); + Future _runHighlights(String blockId) async { + try { + while (!_isDisposed) { + final entry = _entries[blockId]; + if (entry == null || !entry.isPending) { + return; + } + final request = entry.request; + final presentation = await _highlighter.buildPresentationAsync( + source: request.source, + baseStyle: request.baseStyle, + theme: request.theme, + language: request.language, + ); + if (_isDisposed) { + return; + } + final latest = _entries[blockId]; + if (latest == null) { + return; + } + final highlightedPrefix = presentation.isHighlighted + ? _MarkdownHighlightedPrefix( + source: request.source, + styleSignature: request.styleSignature, + presentation: presentation, + ) + : latest.highlightedPrefix; + if (latest.requestId == entry.requestId) { + _entries[blockId] = latest.copyWith( + presentation: presentation, + isPending: false, + highlightedPrefix: highlightedPrefix, + ); + notifyListeners(); + return; + } + if (!latest.isPending) { + return; + } + // The source moved on while this ran: show what is highlighted so + // far, then run the latest source. + final placeholder = highlightedPrefix?.extendTo( + latest.request.source, + styleSignature: latest.request.styleSignature, + baseStyle: latest.request.baseStyle, + ); + _entries[blockId] = latest.copyWith( + presentation: placeholder ?? latest.presentation, + highlightedPrefix: highlightedPrefix, + ); + if (placeholder != null) { + notifyListeners(); + } + } + } finally { + _highlightingBlockIds.remove(blockId); } } @@ -510,20 +541,101 @@ class MarkdownCodeHighlightCache extends ChangeNotifier { } } +class _MarkdownCodeHighlightRequest { + const _MarkdownCodeHighlightRequest({ + required this.source, + required this.baseStyle, + required this.theme, + required this.language, + }); + + final String source; + final TextStyle baseStyle; + final MarkdownThemeData theme; + final String? language; + + int get styleSignature => Object.hash(language, baseStyle, theme); +} + +class _MarkdownHighlightedPrefix { + const _MarkdownHighlightedPrefix({ + required this.source, + required this.styleSignature, + required this.presentation, + }); + + final String source; + final int styleSignature; + final MarkdownCodeHighlightPresentation presentation; + + /// This highlight followed by the rest of [source] in plain text, or null + /// when [source] does not extend it in the same style. + MarkdownCodeHighlightPresentation? extendTo( + String source, { + required int styleSignature, + required TextStyle baseStyle, + }) { + if (styleSignature != this.styleSignature || + source.length <= this.source.length || + !source.startsWith(this.source)) { + return null; + } + final suffix = source.substring(this.source.length); + final span = presentation.span; + return MarkdownCodeHighlightPresentation( + span: TextSpan( + style: span.style, + children: [ + ...?span.children, + if (span.text != null) TextSpan(text: span.text), + TextSpan(text: suffix, style: baseStyle), + ], + ), + runs: [ + ...presentation.runs, + MarkdownPretextInlineRun(text: suffix, style: baseStyle), + ], + isHighlighted: false, + ); + } +} + class _MarkdownCodeHighlightCacheEntry { const _MarkdownCodeHighlightCacheEntry({ + required this.request, required this.contentSignature, required this.presentationSignature, required this.requestId, required this.presentation, required this.isPending, + this.highlightedPrefix, }); + final _MarkdownCodeHighlightRequest request; final int contentSignature; final int presentationSignature; final int requestId; final MarkdownCodeHighlightPresentation presentation; final bool isPending; + + /// The latest real highlight of this block, kept to fill placeholders. + final _MarkdownHighlightedPrefix? highlightedPrefix; + + _MarkdownCodeHighlightCacheEntry copyWith({ + required MarkdownCodeHighlightPresentation presentation, + bool? isPending, + _MarkdownHighlightedPrefix? highlightedPrefix, + }) { + return _MarkdownCodeHighlightCacheEntry( + request: request, + contentSignature: contentSignature, + presentationSignature: presentationSignature, + requestId: requestId, + presentation: presentation, + isPending: isPending ?? this.isPending, + highlightedPrefix: highlightedPrefix ?? this.highlightedPrefix, + ); + } } @immutable 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 1942df6d..95021097 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 @@ -1,4 +1,3 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../clipboard/plain_text_serializer.dart'; @@ -11,6 +10,7 @@ import '../widgets/markdown_theme.dart'; import '../widgets/markdown_types.dart'; import 'code_syntax_highlighter.dart'; import 'markdown_block_widgets.dart'; +import 'selectable_block.dart'; import 'builder/markdown_block_builder.dart'; import 'builder/markdown_inline_builder.dart'; @@ -64,7 +64,7 @@ class MarkdownDocumentView extends StatefulWidget { class _MarkdownDocumentViewState extends State { static const double _slowBuildLogThresholdMs = 8; - final List _recognizers = []; + final MarkdownLinkRecognizers _linkRecognizers = MarkdownLinkRecognizers(); final ScrollController _fallbackScrollController = ScrollController(); final ContextMenuController _contextMenuController = ContextMenuController(); final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'markdown-scrollable'); @@ -162,9 +162,15 @@ class _MarkdownDocumentViewState extends State { _descriptorCache.cleanup(validIds); _tableLayoutPlanCache.cleanup(validIds); _codeHighlightCache.cleanup(validIds); + _linkRecognizers.disposeRemovedLinks( + oldWidget.document.blocks, + widget.document.blocks, + ); + // Rows call onTapLink through _handleTapLink, so a new callback only + // matters to them when it turns links on or off. if (oldWidget.theme != widget.theme || - oldWidget.onTapLink != widget.onTapLink || + (oldWidget.onTapLink == null) != (widget.onTapLink == null) || oldWidget.imageBuilder != widget.imageBuilder || oldWidget.selectable != widget.selectable) { _cachedBlockRows.clear(); @@ -174,7 +180,7 @@ class _MarkdownDocumentViewState extends State { @override void dispose() { - _disposeRecognizers(); + _linkRecognizers.dispose(); _codeHighlightCache ..removeListener(_handleCodeHighlightCacheChanged) ..dispose(); @@ -267,11 +273,8 @@ class _MarkdownDocumentViewState extends State { setState(() {}); } - void _disposeRecognizers() { - for (final recognizer in _recognizers) { - recognizer.dispose(); - } - _recognizers.clear(); + void _handleTapLink(String destination, String? title, String label) { + widget.onTapLink?.call(destination, title, label); } void _showToolbar(Offset globalPosition) { @@ -290,26 +293,27 @@ class _MarkdownDocumentViewState extends State { ); } + MarkdownSelectableBlockHandle? _blockStateFor(BlockNode block) { + final Object? state = _keysRegistry.blockKeys[block.id]?.currentState; + return state is MarkdownSelectableBlockHandle ? state : null; + } + DocumentPosition? _hitTestPosition(Offset globalPosition, {required bool clamp}) { for (final block in widget.document.blocks) { - // Depending on imports / architecture, blockKeys might store State directly - final blockState = - _keysRegistry.blockKeys[block.id]?.currentState as dynamic; - final hit = blockState?.hitTestGlobal(globalPosition); + final hit = _blockStateFor(block)?.hitTestGlobal(globalPosition); if (hit != null) { - return hit as DocumentPosition; + return hit; } } if (!clamp) { return null; } - dynamic nearestState; + MarkdownSelectableBlockHandle? nearestState; double bestDistance = double.infinity; for (final block in widget.document.blocks) { - final blockState = - _keysRegistry.blockKeys[block.id]?.currentState as dynamic; + final blockState = _blockStateFor(block); final rect = blockState?.globalRect; if (blockState == null || rect == null) { continue; @@ -330,23 +334,20 @@ class _MarkdownDocumentViewState extends State { nearestState = blockState; } } - return nearestState?.boundaryPositionForGlobal(globalPosition) - as DocumentPosition?; + return nearestState?.boundaryPositionForGlobal(globalPosition); } DocumentPosition? _hitTestExactTextPosition(Offset globalPosition) { for (final block in widget.document.blocks) { - final blockState = - _keysRegistry.blockKeys[block.id]?.currentState as dynamic; - final hit = blockState?.hitTestTextGlobal(globalPosition); + final hit = _blockStateFor(block)?.hitTestTextGlobal(globalPosition); if (hit != null) { - return hit as DocumentPosition; + return hit; } } return null; } - void _selectWordAt(DocumentPosition position) { + void _selectWordAt(DocumentPosition position, {Offset? globalPosition}) { final selectionController = widget.selectionController; if (selectionController == null) { return; @@ -358,7 +359,9 @@ class _MarkdownDocumentViewState extends State { ); return; } - selectionController.setSelection(blockState.selectWord(position)); + selectionController.setSelection( + blockState.selectWord(position, globalPosition: globalPosition), + ); } void _selectBlockAt(int blockIndex) { @@ -394,14 +397,13 @@ class _MarkdownDocumentViewState extends State { ); } - dynamic _blockStateForIndex(int blockIndex) { + MarkdownSelectableBlockHandle? _blockStateForIndex(int blockIndex) { final localBlockIndex = blockIndex - _selectionBlockIndexOffset; if (localBlockIndex < 0 || localBlockIndex >= widget.document.blocks.length) { return null; } - final block = widget.document.blocks[localBlockIndex]; - return _keysRegistry.blockKeys[block.id]?.currentState as dynamic; + return _blockStateFor(widget.document.blocks[localBlockIndex]); } Widget _finishBuildWithLog( @@ -429,12 +431,12 @@ class _MarkdownDocumentViewState extends State { @override Widget build(BuildContext context) { final buildStopwatch = Stopwatch()..start(); - _disposeRecognizers(); + final onTapLink = widget.onTapLink == null ? null : _handleTapLink; final inlineBuilder = MarkdownInlineBuilder( theme: widget.theme, - recognizers: _recognizers, - onTapLink: widget.onTapLink, + recognizers: _linkRecognizers, + onTapLink: onTapLink, ); final descriptorExtractor = MarkdownDescriptorExtractor( @@ -466,7 +468,7 @@ class _MarkdownDocumentViewState extends State { imageBuilder: widget.imageBuilder, codeBlockBuilder: widget.codeBlockBuilder, bulletBuilder: widget.bulletBuilder, - onTapLink: widget.onTapLink, + onTapLink: onTapLink, onRequestContextMenu: _showToolbar, cachedBlockRows: _cachedBlockRows, tableLayoutPlanCache: _tableLayoutPlanCache, 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 f552a0b5..df5366bc 100644 --- a/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart +++ b/packages/mixin_markdown_widget/lib/src/render/selectable_block.dart @@ -87,7 +87,35 @@ class SelectableMarkdownBlock extends StatefulWidget { SelectableMarkdownBlockState(); } -class SelectableMarkdownBlockState extends State { +/// The selection queries the document view makes on the states of its +/// top-level block keys. +abstract interface class MarkdownSelectableBlockHandle { + Rect? get globalRect; + + DocumentPosition? hitTestGlobal(Offset globalPosition); + + DocumentPosition? hitTestTextGlobal(Offset globalPosition); + + DocumentPosition boundaryPositionForGlobal(Offset globalPosition); + + /// The word at [position]. A [globalPosition] tells which side of the + /// caret position the pointer is on, which matters where words are a + /// single character, as in CJK text. + DocumentSelection selectWord( + DocumentPosition position, { + Offset? globalPosition, + }); + + DocumentSelection selectWholeBlock(); + + DocumentSelection selectSelectionUnit( + DocumentPosition position, { + Offset? globalPosition, + }); +} + +class SelectableMarkdownBlockState extends State + implements MarkdownSelectableBlockHandle { VoidCallback? _repaintListener; @override @@ -111,6 +139,7 @@ class SelectableMarkdownBlockState extends State { super.dispose(); } + @override Rect? get globalRect { final renderObject = context.findRenderObject(); if (renderObject is! RenderBox || !renderObject.hasSize) { @@ -150,6 +179,7 @@ class SelectableMarkdownBlockState extends State { int get textLength => widget.spec.plainText.length; + @override DocumentSelection selectWholeBlock() { final structure = widget.spec.selectionStructure; if (structure != null && !structure.isEmpty) { @@ -172,36 +202,40 @@ class SelectableMarkdownBlockState extends State { ); } - DocumentSelection selectWord(DocumentPosition position) { + @override + DocumentSelection selectWord( + DocumentPosition position, { + Offset? globalPosition, + }) { if (widget.spec.hitTestBehavior == SelectableBlockHitTestBehavior.block || widget.spec.plainText.isEmpty) { return selectWholeBlock(); } + final upstream = globalPosition != null && + _pointerIsOnPrecedingCharacter(position.textOffset, globalPosition); + ({int start, int end}) wordRangeAt(String text, int offset) => + markdownWordRangeAt(text, offset, upstream: upstream); final structure = widget.spec.selectionStructure; if (structure != null) { final range = structure.wordRangeForPosition( blockIndex: widget.blockIndex, position: position, - isWordCharacter: _isWordCharacter, + wordRangeAt: wordRangeAt, ); if (range != null) { return DocumentSelection(base: range.start, extent: range.end); } } - var start = position.textOffset < 0 - ? 0 - : position.textOffset > widget.spec.plainText.length - ? widget.spec.plainText.length - : position.textOffset; - var end = start; - - while (start > 0 && _isWordCharacter(widget.spec.plainText[start - 1])) { - start -= 1; - } - while (end < widget.spec.plainText.length && - _isWordCharacter(widget.spec.plainText[end])) { - end += 1; - } + final word = wordRangeAt( + widget.spec.plainText, + position.textOffset < 0 + ? 0 + : position.textOffset > widget.spec.plainText.length + ? widget.spec.plainText.length + : position.textOffset, + ); + var start = word.start; + var end = word.end; if (start == end && widget.spec.plainText.isNotEmpty) { if (end < widget.spec.plainText.length) { @@ -225,6 +259,7 @@ class SelectableMarkdownBlockState extends State { ); } + @override DocumentSelection selectSelectionUnit( DocumentPosition position, { Offset? globalPosition, @@ -264,6 +299,7 @@ class SelectableMarkdownBlockState extends State { return DocumentSelection(base: range.start, extent: range.end); } + @override DocumentPosition? hitTestGlobal(Offset globalPosition) { final renderObject = context.findRenderObject(); if (renderObject is! RenderBox || !renderObject.hasSize) { @@ -276,6 +312,7 @@ class SelectableMarkdownBlockState extends State { return _hitTestLocal(localPosition, renderObject.size); } + @override DocumentPosition? hitTestTextGlobal(Offset globalPosition) { final renderObject = context.findRenderObject(); if (renderObject is! RenderBox || !renderObject.hasSize) { @@ -303,6 +340,7 @@ class SelectableMarkdownBlockState extends State { return position; } + @override DocumentPosition boundaryPositionForGlobal(Offset globalPosition) { final renderObject = context.findRenderObject(); if (renderObject is! RenderBox || !renderObject.hasSize) { @@ -515,6 +553,62 @@ class SelectableMarkdownBlockState extends State { return false; } + /// Whether [globalPosition] is over the character that ends at + /// [textOffset], rather than the one that starts there. + bool _pointerIsOnPrecedingCharacter(int textOffset, Offset globalPosition) { + final renderObject = context.findRenderObject(); + if (textOffset <= 0 || + textOffset > widget.spec.plainText.length || + renderObject is! RenderBox || + !renderObject.hasSize) { + return false; + } + final localPosition = renderObject.globalToLocal(globalPosition); + final List rects; + final selectionRectResolver = widget.spec.selectionRectResolver; + if (selectionRectResolver != null) { + final structure = widget.spec.selectionStructure; + rects = selectionRectResolver( + context, + renderObject.size, + structure?.rangeForDisplayedOffsets( + blockIndex: widget.blockIndex, + startOffset: textOffset - 1, + endOffset: textOffset, + ) ?? + DocumentRange( + start: DocumentPosition( + blockIndex: widget.blockIndex, + path: const PathInBlock([0]), + textOffset: textOffset - 1, + ), + end: DocumentPosition( + blockIndex: widget.blockIndex, + path: const PathInBlock([0]), + textOffset: textOffset, + ), + ), + ); + } else if (widget.spec.textSpan != null) { + final textPainter = + _buildTextPainter(renderObject.size, Directionality.of(context)); + final contentOffset = Offset( + widget.spec.measurementPadding.left, + widget.spec.measurementPadding.top, + ); + rects = textPainter + .getBoxesForSelection( + TextSelection(baseOffset: textOffset - 1, extentOffset: textOffset), + ) + .map((box) => box.toRect().shift(contentOffset)) + .toList(growable: false); + textPainter.dispose(); + } else { + return false; + } + return rects.any((rect) => rect.contains(localPosition)); + } + DocumentPosition _edgePosition({required bool preferEnd}) { final structure = widget.spec.selectionStructure; if (structure != null && !structure.isEmpty) { @@ -592,40 +686,6 @@ class SelectableMarkdownBlockState extends State { }); return containsWidget; } - - bool _isWordCharacter(String character) { - if (character.trim().isEmpty) { - return false; - } - const separators = { - '.', - ',', - ';', - ':', - '!', - '?', - '(', - ')', - '[', - ']', - '{', - '}', - '<', - '>', - '/', - '\\', - '"', - '\'', - '`', - '|', - '-', - '+', - '=', - '*', - '&', - }; - return !separators.contains(character); - } } class _BlockSelectionPainter extends CustomPainter { @@ -1024,3 +1084,101 @@ enum _SelectionCornerQuadrant { bottomLeft, bottomRight, } + +/// The word around [offset] in [text], for double-click selection. +/// +/// A word is a run of characters between whitespace and the separators of +/// [_isWordCharacter], which include CJK punctuation. Text such as Chinese +/// does not put spaces between words, so a run with non-ASCII characters is +/// narrowed to its ICU word boundary: the word of the character after +/// [offset], or before it when [upstream]. ASCII runs keep the separator rule. +({int start, int end}) markdownWordRangeAt( + String text, + int offset, { + bool upstream = false, +}) { + var start = offset; + var end = offset; + while (start > 0 && _isWordCharacter(text[start - 1])) { + start -= 1; + } + while (end < text.length && _isWordCharacter(text[end])) { + end += 1; + } + if (start == end || !text.substring(start, end).runes.any((r) => r > 0x7f)) { + return (start: start, end: end); + } + + final run = text.substring(start, end); + final painter = TextPainter( + text: TextSpan(text: run), + textDirection: TextDirection.ltr, + )..layout(); + final boundary = painter.getWordBoundary( + TextPosition( + offset: (upstream ? offset - start - 1 : offset - start) + .clamp(0, run.length - 1), + ), + ); + painter.dispose(); + return (start: start + boundary.start, end: start + boundary.end); +} + +bool _isWordCharacter(String character) { + if (character.trim().isEmpty) { + return false; + } + const separators = { + '.', + ',', + ';', + ':', + '!', + '?', + '(', + ')', + '[', + ']', + '{', + '}', + '<', + '>', + '/', + '\\', + '"', + '\'', + '`', + '|', + '-', + '+', + '=', + '*', + '&', + // Full-width and CJK punctuation. + ',', + '。', + '!', + '?', + ';', + ':', + '、', + '(', + ')', + '「', + '」', + '『', + '』', + '《', + '》', + '〈', + '〉', + '【', + '】', + '“', + '”', + '…', + '—', + '~', + }; + return !separators.contains(character); +} diff --git a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_gesture_detector.dart b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_gesture_detector.dart index 116fbabd..10ac8b25 100644 --- a/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_gesture_detector.dart +++ b/packages/mixin_markdown_widget/lib/src/render/selection/markdown_selection_gesture_detector.dart @@ -13,7 +13,10 @@ typedef MarkdownHitTestPositionCallback = DocumentPosition? Function( typedef MarkdownHitTestExactTextPositionCallback = DocumentPosition? Function( Offset globalPosition, ); -typedef MarkdownSelectWordCallback = void Function(DocumentPosition position); +typedef MarkdownSelectWordCallback = void Function( + DocumentPosition position, { + Offset? globalPosition, +}); typedef MarkdownSelectBlockCallback = void Function(int blockIndex); typedef MarkdownSelectSelectionUnitCallback = void Function( Offset globalPosition, @@ -188,7 +191,7 @@ class _MarkdownSelectionGestureDetectorState } if (_consecutiveTapCount == 2) { if (exactPosition != null) { - widget.selectWordAt(position); + widget.selectWordAt(position, globalPosition: event.position); } else { widget.selectSelectionUnitAt(event.position, position); } diff --git a/packages/mixin_markdown_widget/lib/src/selection/mixin_selection_area.dart b/packages/mixin_markdown_widget/lib/src/selection/mixin_selection_area.dart index 2fe529cc..df0eb8eb 100644 --- a/packages/mixin_markdown_widget/lib/src/selection/mixin_selection_area.dart +++ b/packages/mixin_markdown_widget/lib/src/selection/mixin_selection_area.dart @@ -281,7 +281,7 @@ class _MixinSelectionAreaState extends State { return null; } - void _selectWordAt(DocumentPosition position) { + void _selectWordAt(DocumentPosition position, {Offset? globalPosition}) { final participant = _participantForBlockIndex(position.blockIndex); if (participant == null) { _controller.setSelection( @@ -289,7 +289,7 @@ class _MixinSelectionAreaState extends State { ); return; } - participant.selectWordAt(position); + participant.selectWordAt(position, globalPosition: globalPosition); } void _selectBlockAt(int blockIndex) { @@ -492,8 +492,10 @@ class _MixinSelectableState extends State { : hitTestGlobal(globalPosition); }, hitTestExactTextPosition: hitTestTextGlobal, - selectWordAt: (position) { - _registrar?.controller.setSelection(selectWord(position)); + selectWordAt: (position, {globalPosition}) { + _registrar?.controller.setSelection( + selectWord(position, globalPosition: globalPosition), + ); }, selectBlockAt: (blockIndex) { _registrar?.controller.setSelection(selectWholeBlock()); @@ -558,8 +560,12 @@ class _MixinSelectableState extends State { ); } - DocumentSelection selectWord(DocumentPosition position) { - return _blockKey.currentState?.selectWord(position) ?? + DocumentSelection selectWord( + DocumentPosition position, { + Offset? globalPosition, + }) { + return _blockKey.currentState + ?.selectWord(position, globalPosition: globalPosition) ?? DocumentSelection(base: position, extent: position); } diff --git a/packages/mixin_markdown_widget/lib/src/selection/selection_registrar.dart b/packages/mixin_markdown_widget/lib/src/selection/selection_registrar.dart index bc01078e..c993cb1f 100644 --- a/packages/mixin_markdown_widget/lib/src/selection/selection_registrar.dart +++ b/packages/mixin_markdown_widget/lib/src/selection/selection_registrar.dart @@ -14,8 +14,9 @@ typedef MixinSelectionParticipantExactHitTest = DocumentPosition? Function( ); typedef MixinSelectionParticipantSelectWord = void Function( - DocumentPosition position, -); + DocumentPosition position, { + Offset? globalPosition, +}); typedef MixinSelectionParticipantSelectBlock = void Function( int blockIndex, 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 d3489060..ae60d022 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 @@ -262,7 +262,8 @@ class StructuredBlockSelection { DocumentRange? wordRangeForPosition({ required int blockIndex, required DocumentPosition position, - required bool Function(String character) isWordCharacter, + required ({int start, int end}) Function(String text, int offset) + wordRangeAt, }) { final leaf = leafForPath(position.path) ?? _leafContainingOffset(position.textOffset); @@ -270,23 +271,18 @@ class StructuredBlockSelection { return null; } + final text = leaf.visibleText; var localOffset = leaf.localOffsetForDisplayedOffset(position.textOffset); - if (localOffset > 0 && - localOffset == leaf.visibleText.length && - !isWordCharacter(leaf.visibleText[localOffset - 1])) { - localOffset -= 1; + if (localOffset > 0 && localOffset == text.length) { + final trailing = wordRangeAt(text, localOffset); + if (trailing.start == trailing.end) { + localOffset -= 1; + } } - var start = localOffset.clamp(0, leaf.visibleText.length).toInt(); - var end = start; - - while (start > 0 && isWordCharacter(leaf.visibleText[start - 1])) { - start -= 1; - } - while (end < leaf.visibleText.length && - isWordCharacter(leaf.visibleText[end])) { - end += 1; - } + final word = wordRangeAt(text, localOffset.clamp(0, text.length).toInt()); + var start = word.start; + var end = word.end; if (start == end && leaf.visibleText.isNotEmpty) { if (end < leaf.visibleText.length) { diff --git a/packages/mixin_markdown_widget/lib/src/widgets/markdown_controller.dart b/packages/mixin_markdown_widget/lib/src/widgets/markdown_controller.dart index 2a62780b..522f9847 100644 --- a/packages/mixin_markdown_widget/lib/src/widgets/markdown_controller.dart +++ b/packages/mixin_markdown_widget/lib/src/widgets/markdown_controller.dart @@ -94,6 +94,36 @@ class MarkdownController extends ChangeNotifier { notifyListeners(); } + /// Shows [data] as the whole source. + /// + /// When [data] extends the current [data], only the appended part is parsed, + /// as with [appendChunk]; otherwise it is parsed in full, as with [setData]. + /// Unless [streaming] is true, the stream is then committed, as with + /// [commitStream]. Listeners are notified at most once. + void updateData(String data, {bool streaming = false}) { + final current = _source.text; + var changed = false; + if (data != current) { + // A pending CR swallows the next LF, so its delta would not append as is. + if (data.startsWith(current) && !_source.hasPendingCarriageReturn) { + _streamingDraftMode = true; + _appendData(data.substring(current.length)); + } else { + _streamingDraftMode = false; + _replaceData(data); + } + changed = true; + } + if (!streaming && _streamingDraftMode) { + _streamingDraftMode = false; + _syncStreamingState(); + changed = true; + } + if (changed) { + notifyListeners(); + } + } + void clear() { if (_source.isEmpty) { return; @@ -206,6 +236,7 @@ class _MarkdownSourceBuffer { bool get isEmpty => _length == 0; int get length => _length; List get lines => _lines; + bool get hasPendingCarriageReturn => _pendingCarriageReturn; bool get endsWithNewline => _length > 0 && _lines.length > 1 && _lines.last.isEmpty; diff --git a/packages/mixin_markdown_widget/lib/src/widgets/markdown_widget.dart b/packages/mixin_markdown_widget/lib/src/widgets/markdown_widget.dart index a82ba189..c36435ae 100644 --- a/packages/mixin_markdown_widget/lib/src/widgets/markdown_widget.dart +++ b/packages/mixin_markdown_widget/lib/src/widgets/markdown_widget.dart @@ -54,6 +54,8 @@ class MarkdownWidget extends StatefulWidget { State createState() => _MarkdownWidgetState(); } +@Deprecated( + 'Use MarkdownWidget instead. This misspelled alias will be removed.') typedef MarkownWidget = MarkdownWidget; class _MarkdownWidgetState extends State { diff --git a/packages/mixin_markdown_widget/test/markdown_code_highlight_cache_test.dart b/packages/mixin_markdown_widget/test/markdown_code_highlight_cache_test.dart new file mode 100644 index 00000000..6c892f7f --- /dev/null +++ b/packages/mixin_markdown_widget/test/markdown_code_highlight_cache_test.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +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/code_syntax_highlighter.dart'; +import 'package:mixin_markdown_widget/src/render/pretext_text_block.dart'; + +const _keywordColor = Color(0xFFFF0000); + +class _FakeHighlighter extends MarkdownCodeSyntaxHighlighter { + final List requests = []; + final List> _pending = + >[]; + + @override + MarkdownCodeHighlightPresentation? cachedPresentation({ + required String source, + required TextStyle baseStyle, + required MarkdownThemeData theme, + String? language, + }) => + null; + + @override + Future buildPresentationAsync({ + required String source, + required TextStyle baseStyle, + required MarkdownThemeData theme, + String? language, + }) { + requests.add(source); + final completer = Completer(); + _pending.add(completer); + return completer.future; + } + + /// Finishes the oldest running highlight with every character colored. + void finishNext(TextStyle baseStyle) { + final source = requests[requests.length - _pending.length]; + final style = baseStyle.copyWith(color: _keywordColor); + _pending.removeAt(0).complete( + MarkdownCodeHighlightPresentation( + span: TextSpan( + style: baseStyle, + children: [TextSpan(text: source, style: style)], + ), + runs: [ + MarkdownPretextInlineRun(text: source, style: style), + ], + isHighlighted: true, + ), + ); + } +} + +void main() { + late MarkdownThemeData theme; + + Future pumpTheme(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + theme = MarkdownThemeData.fallback(context); + return const SizedBox.shrink(); + }, + ), + ), + ); + } + + testWidgets( + 'a growing code block keeps its highlighted prefix as the placeholder', + (tester) async { + await pumpTheme(tester); + final highlighter = _FakeHighlighter(); + final cache = MarkdownCodeHighlightCache(highlighter: highlighter); + addTearDown(cache.dispose); + final baseStyle = theme.codeBlockStyle; + + MarkdownCodeHighlightPresentation resolve(String source) => cache.resolve( + blockId: 'code', + source: source, + baseStyle: baseStyle, + theme: theme, + language: 'dart', + ); + + expect(resolve('a = 1;').isHighlighted, isFalse); + highlighter.finishNext(baseStyle); + await tester.pump(); + expect(resolve('a = 1;').isHighlighted, isTrue); + + final placeholder = resolve('a = 1;\nb = 2;'); + expect(placeholder.isHighlighted, isFalse); + expect(placeholder.span.toPlainText(), 'a = 1;\nb = 2;'); + expect( + placeholder.runs.map((run) => (run.text, run.style.color)).toList(), + <(String, Color?)>[ + ('a = 1;', _keywordColor), + ('\nb = 2;', baseStyle.color), + ], + ); + }); + + testWidgets('a growing code block runs one highlight at a time', + (tester) async { + await pumpTheme(tester); + final highlighter = _FakeHighlighter(); + final cache = MarkdownCodeHighlightCache(highlighter: highlighter); + addTearDown(cache.dispose); + final baseStyle = theme.codeBlockStyle; + var notifications = 0; + cache.addListener(() => notifications += 1); + + MarkdownCodeHighlightPresentation resolve(String source) => cache.resolve( + blockId: 'code', + source: source, + baseStyle: baseStyle, + theme: theme, + language: 'dart', + ); + + resolve('a'); + resolve('a = 1;'); + resolve('a = 1;\nb'); + resolve('a = 1;\nb = 2;'); + expect(highlighter.requests, ['a']); + + // The first run ends: the latest source runs next, not every chunk. + highlighter.finishNext(baseStyle); + await tester.pump(); + expect(highlighter.requests, ['a', 'a = 1;\nb = 2;']); + expect(notifications, 1); + final partial = resolve('a = 1;\nb = 2;'); + expect(partial.isHighlighted, isFalse); + expect(partial.runs.first.text, 'a'); + expect(partial.runs.first.style.color, _keywordColor); + expect(partial.span.toPlainText(), 'a = 1;\nb = 2;'); + + highlighter.finishNext(baseStyle); + await tester.pump(); + expect(highlighter.requests, hasLength(2)); + expect(notifications, 2); + final done = resolve('a = 1;\nb = 2;'); + expect(done.isHighlighted, isTrue); + expect(done.runs.single.text, 'a = 1;\nb = 2;'); + }); + + testWidgets('a code block that is not extended falls back to plain text', + (tester) async { + await pumpTheme(tester); + final highlighter = _FakeHighlighter(); + final cache = MarkdownCodeHighlightCache(highlighter: highlighter); + addTearDown(cache.dispose); + final baseStyle = theme.codeBlockStyle; + + cache.resolve( + blockId: 'code', + source: 'a = 1;', + baseStyle: baseStyle, + theme: theme, + language: 'dart', + ); + highlighter.finishNext(baseStyle); + await tester.pump(); + + final edited = cache.resolve( + blockId: 'code', + source: 'b = 1;', + baseStyle: baseStyle, + theme: theme, + language: 'dart', + ); + expect(edited.isHighlighted, isFalse); + expect(edited.runs.single.text, 'b = 1;'); + expect(edited.runs.single.style.color, baseStyle.color); + }); +} diff --git a/packages/mixin_markdown_widget/test/markdown_controller_streaming_test.dart b/packages/mixin_markdown_widget/test/markdown_controller_streaming_test.dart new file mode 100644 index 00000000..3a47569f --- /dev/null +++ b/packages/mixin_markdown_widget/test/markdown_controller_streaming_test.dart @@ -0,0 +1,215 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mixin_markdown_widget/mixin_markdown_widget.dart'; + +List _blockIds(List blocks) { + final ids = []; + void visit(BlockNode block) { + ids.add(block.id); + switch (block) { + case QuoteBlock(:final children) || DetailsBlock(:final children): + children.forEach(visit); + case ListBlock(:final items) || FootnoteListBlock(:final items): + for (final item in items) { + item.children.forEach(visit); + } + default: + } + } + + blocks.forEach(visit); + return ids; +} + +void main() { + group('appendChunk block ids', () { + test('a streamed paragraph keeps its id', () { + final controller = MarkdownController(data: '# Title\n\nStreaming'); + final heading = controller.document.blocks.first; + final id = controller.document.blocks.last.id; + + for (final chunk in [' text', ' keeps', ' growing.']) { + controller.appendChunk(chunk); + expect(controller.document.blocks, hasLength(2)); + expect(identical(controller.document.blocks.first, heading), isTrue); + expect(controller.document.blocks.last.id, id); + } + expect( + controller.plainText, + contains('Streaming text keeps growing.'), + ); + }); + + test('a streamed fenced code block keeps its id after it closes', () { + final controller = MarkdownController( + data: 'Intro\n\n```dart\nfinal a = 1;', + ); + final id = controller.document.blocks.last.id; + + for (final chunk in [ + '\nfinal b = 2;', + '\nfinal c = 3;', + '\n```' + ]) { + controller.appendChunk(chunk); + final code = controller.document.blocks.last as CodeBlock; + expect(code.id, id); + } + expect( + (controller.document.blocks.last as CodeBlock).code, + 'final a = 1;\nfinal b = 2;\nfinal c = 3;', + ); + + controller.appendChunk('\n\nAfter'); + expect(controller.document.blocks, hasLength(3)); + expect(controller.document.blocks[1].id, id); + expect(controller.document.blocks.last, isA()); + expect(controller.document.blocks.last.id, isNot(id)); + }); + + test('a streamed list keeps its id and the ids of its items', () { + final controller = MarkdownController(data: 'Intro\n\n- first item'); + final list = controller.document.blocks.last as ListBlock; + final firstItemIds = _blockIds(list.items.first.children); + + controller.appendChunk(' grows'); + controller.appendChunk('\n- second'); + controller.appendChunk(' item'); + + final streamed = controller.document.blocks.last as ListBlock; + expect(streamed.id, list.id); + expect(streamed.items, hasLength(2)); + expect(_blockIds(streamed.items.first.children), firstItemIds); + + final secondItemIds = _blockIds(streamed.items.last.children); + controller.appendChunk(' too'); + final grown = controller.document.blocks.last as ListBlock; + expect(grown.id, list.id); + expect(_blockIds(grown.items.last.children), secondItemIds); + }); + + test('streamed ids stay unique in the document', () { + final controller = MarkdownController(data: '# Title\n\nOne'); + for (final chunk in [ + ' more', + '\n\n- a', + '\n- b', + '\n\n> quote', + ' text', + '\n\nTwo', + '\n\n```\ncode', + '\n```', + ]) { + controller.appendChunk(chunk); + final ids = _blockIds(controller.document.blocks); + expect(ids.toSet(), hasLength(ids.length), reason: chunk); + } + }); + + test('setData still derives ids from the content', () { + final controller = MarkdownController(data: '# Title\n\nStreaming'); + controller.appendChunk(' text'); + final streamedId = controller.document.blocks.last.id; + + controller.setData('# Title\n\nStreaming text, replaced'); + final parsed = const MarkdownDocumentParser() + .parse('# Title\n\nStreaming text, replaced'); + expect( + _blockIds(controller.document.blocks), + _blockIds(parsed.blocks), + ); + expect(controller.document.blocks.last.id, isNot(streamedId)); + }); + }); + + group('updateData', () { + test('appends the new suffix while streaming', () { + final controller = MarkdownController(data: '# Title\n\nIntro'); + final heading = controller.document.blocks.first; + final paragraphId = controller.document.blocks.last.id; + var notifications = 0; + controller.addListener(() => notifications += 1); + + controller.updateData('# Title\n\nIntro text', streaming: true); + + expect(notifications, 1); + expect(controller.data, '# Title\n\nIntro text'); + expect(identical(controller.document.blocks.first, heading), isTrue); + expect(controller.document.blocks.last.id, paragraphId); + expect(controller.streamingState.hasDraft, isTrue); + }); + + test('commits the stream when streaming ends, notifying once', () { + final controller = MarkdownController(data: 'Intro'); + controller.updateData('Intro text', streaming: true); + var notifications = 0; + controller.addListener(() => notifications += 1); + + controller.updateData('Intro text, done.'); + + expect(notifications, 1); + expect(controller.data, 'Intro text, done.'); + expect(controller.streamingState.hasDraft, isFalse); + expect(controller.streamingState.committedBlocks, hasLength(1)); + }); + + test('commits unchanged data and then does nothing', () { + final controller = MarkdownController(data: 'Intro'); + controller.updateData('Intro text', streaming: true); + var notifications = 0; + controller.addListener(() => notifications += 1); + + controller.updateData('Intro text'); + expect(notifications, 1); + expect(controller.streamingState.hasDraft, isFalse); + + controller.updateData('Intro text'); + controller.updateData('Intro text', streaming: true); + expect(notifications, 1); + }); + + test('parses in full when the data does not extend the current data', () { + final controller = MarkdownController(data: '# Title\n\nIntro'); + final heading = controller.document.blocks.first; + var notifications = 0; + controller.addListener(() => notifications += 1); + + controller.updateData('# Other\n\nIntro', streaming: true); + + expect(notifications, 1); + expect(controller.data, '# Other\n\nIntro'); + expect(identical(controller.document.blocks.first, heading), isFalse); + expect(controller.streamingState.hasDraft, isFalse); + }); + + test('matches setData for the final text', () { + const finalText = '# Title\n\nIntro text\n\n- a\n- b\n\n```dart\nx\n```'; + final streamed = MarkdownController(); + for (var end = 1; end <= finalText.length; end += 3) { + streamed.updateData(finalText.substring(0, end), streaming: true); + } + streamed.updateData(finalText); + final full = MarkdownController(data: finalText); + + expect(streamed.data, full.data); + expect(streamed.plainText, full.plainText); + expect( + streamed.document.blocks.map((block) => block.runtimeType).toList(), + full.document.blocks.map((block) => block.runtimeType).toList(), + ); + }); + + test('normalizes CRLF data and does not append across a pending CR', () { + final controller = MarkdownController(data: 'Line'); + controller.updateData('Line\r\nNext', streaming: true); + expect(controller.data, 'Line\nNext'); + + final pendingCr = MarkdownController(data: 'Line'); + pendingCr.appendChunk('\r'); + expect(pendingCr.data, 'Line\n'); + // The appended '\n' must not be taken as the LF of the pending CR. + pendingCr.updateData('Line\n\nNext'); + expect(pendingCr.data, 'Line\n\nNext'); + expect(pendingCr.document.blocks, hasLength(2)); + }); + }); +} diff --git a/packages/mixin_markdown_widget/test/markdown_link_recognizer_test.dart b/packages/mixin_markdown_widget/test/markdown_link_recognizer_test.dart new file mode 100644 index 00000000..eb6d7410 --- /dev/null +++ b/packages/mixin_markdown_widget/test/markdown_link_recognizer_test.dart @@ -0,0 +1,175 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +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/pretext_text_block.dart'; +import 'package:mixin_markdown_widget/src/render/selectable_block.dart'; + +const _listData = '- Visit [Example](https://example.com)\n\nTail'; + +GestureRecognizer? _linkRecognizer(WidgetTester tester) { + for (final block in tester.widgetList( + find.byType(MarkdownPretextTextBlock), + )) { + for (final run in block.runs ?? const []) { + if (run.text.contains('Example') && run.recognizer != null) { + return run.recognizer; + } + } + } + return null; +} + +Future _tapLink(WidgetTester tester) async { + final richTextFinder = find.byWidgetPredicate( + (widget) => + widget is RichText && + widget.text.toPlainText().contains('Visit Example'), + ); + final richText = tester.widget(richTextFinder); + final renderBox = tester.renderObject(richTextFinder); + final text = richText.text.toPlainText(); + final start = text.indexOf('Example'); + final painter = TextPainter( + text: richText.text, + textDirection: TextDirection.ltr, + )..layout(maxWidth: renderBox.size.width); + final boxes = painter.getBoxesForSelection( + TextSelection(baseOffset: start, extentOffset: start + 'Example'.length), + ); + painter.dispose(); + await tester.tapAt(renderBox.localToGlobal(boxes.first.toRect().center)); + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 1)); +} + +SelectableMarkdownBlock _firstBlockRow(WidgetTester tester) { + return tester.widget( + find.byType(SelectableMarkdownBlock).first, + ); +} + +void main() { + late Set disposed; + void recordDisposed(ObjectEvent event) { + if (event is ObjectDisposed) { + disposed.add(event.object); + } + } + + setUp(() { + disposed = Set.identity(); + FlutterMemoryAllocations.instance.addListener(recordDisposed); + }); + + tearDown(() { + FlutterMemoryAllocations.instance.removeListener(recordDisposed); + }); + + testWidgets('a cached list row keeps a live link recognizer', (tester) async { + final controller = MarkdownController(data: _listData); + addTearDown(controller.dispose); + final taps = []; + void onTapLink(String destination, String? title, String label) { + taps.add(destination); + } + + late StateSetter setState; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setter) { + setState = setter; + return MarkdownWidget( + controller: controller, + onTapLink: onTapLink, + ); + }, + ), + ), + ), + ); + final recognizer = _linkRecognizer(tester); + expect(recognizer, isNotNull); + + // Rebuilds and an append that leaves the list alone reuse its row. + final row = _firstBlockRow(tester); + setState(() {}); + await tester.pump(); + controller.appendChunk(' paragraph'); + await tester.pump(); + expect(identical(_firstBlockRow(tester), row), isTrue); + expect(identical(_linkRecognizer(tester), recognizer), isTrue); + expect(disposed.contains(recognizer), isFalse); + + await _tapLink(tester); + expect(taps, ['https://example.com']); + + // The recognizer goes with its link. + controller.setData('No links here'); + await tester.pump(); + expect(disposed.contains(recognizer), isTrue); + }); + + testWidgets('a new onTapLink closure keeps cached rows and is called', + (tester) async { + final taps = []; + var generation = 0; + late StateSetter setState; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setter) { + setState = setter; + final current = generation; + return MarkdownWidget( + data: _listData, + onTapLink: (destination, title, label) { + taps.add('$current:$destination'); + }, + ); + }, + ), + ), + ), + ); + final row = _firstBlockRow(tester); + final recognizer = _linkRecognizer(tester); + + for (var index = 0; index < 3; index += 1) { + setState(() => generation += 1); + await tester.pump(); + } + + expect(identical(_firstBlockRow(tester), row), isTrue); + expect(identical(_linkRecognizer(tester), recognizer), isTrue); + expect(disposed.contains(recognizer), isFalse); + await _tapLink(tester); + expect(taps, ['3:https://example.com']); + }); + + testWidgets('removing onTapLink rebuilds rows without link recognizers', + (tester) async { + MarkdownTapLinkCallback? onTapLink = (destination, title, label) {}; + late StateSetter setState; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setter) { + setState = setter; + return MarkdownWidget(data: _listData, onTapLink: onTapLink); + }, + ), + ), + ), + ); + expect(_linkRecognizer(tester), isNotNull); + + setState(() => onTapLink = null); + await tester.pump(); + expect(_linkRecognizer(tester), isNull); + }); +} diff --git a/packages/mixin_markdown_widget/test/markdown_word_selection_test.dart b/packages/mixin_markdown_widget/test/markdown_word_selection_test.dart new file mode 100644 index 00000000..ce5d1b7a --- /dev/null +++ b/packages/mixin_markdown_widget/test/markdown_word_selection_test.dart @@ -0,0 +1,175 @@ +import 'package:flutter/gestures.dart'; +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'; + +/// The separator-only word rule that ASCII text keeps. +({int start, int end}) _asciiWordRangeAt(String text, int offset) { + const separators = r'.,;:!?()[]{}<>/\"' "'`|-+=*&"; + bool isWordCharacter(String character) => + character.trim().isNotEmpty && !separators.contains(character); + var start = offset; + var end = offset; + while (start > 0 && isWordCharacter(text[start - 1])) { + start -= 1; + } + while (end < text.length && isWordCharacter(text[end])) { + end += 1; + } + return (start: start, end: end); +} + +String _wordAt(String text, int offset) { + final range = markdownWordRangeAt(text, offset); + return text.substring(range.start, range.end); +} + +Future _doubleTapCharacter( + WidgetTester tester, { + required String containing, + required String character, +}) async { + final finder = find.byWidgetPredicate( + (widget) => + widget is RichText && widget.text.toPlainText().contains(containing), + ); + final richText = tester.widget(finder.first); + final renderBox = tester.renderObject(finder.first); + final text = richText.text.toPlainText(); + final offset = text.indexOf(character); + final painter = TextPainter( + text: richText.text, + textDirection: TextDirection.ltr, + )..layout(maxWidth: renderBox.size.width); + final box = painter + .getBoxesForSelection( + TextSelection(baseOffset: offset, extentOffset: offset + 1), + ) + .first + .toRect(); + painter.dispose(); + final target = renderBox.localToGlobal(box.center); + await tester.tapAt(target); + await tester.pump(); + await tester.tapAt(target); + await tester.pump(); +} + +/// Test pointer events carry no timestamps, so taps count as one series +/// until one lands far away. +Future _resetTapCount(WidgetTester tester) async { + await tester.tapAt(const Offset(780, 580)); + await tester.pump(kDoubleTapTimeout + const Duration(milliseconds: 1)); +} + +void main() { + testWidgets('ASCII text keeps the separator word rule', (tester) async { + const text = "foo_bar baz-qux (x.y) #tag don't a+b café_au " + 'path/to/file.dart'; + for (var offset = 0; offset <= text.length; offset += 1) { + final ascii = _asciiWordRangeAt(text, offset); + if (text.substring(ascii.start, ascii.end).contains('é')) { + continue; + } + expect(markdownWordRangeAt(text, offset), ascii, reason: '$offset'); + } + }); + + testWidgets('Chinese punctuation ends a word', (tester) async { + const text = '你好,世界!再见。'; + for (final (offset, punctuation) in <(int, String)>[ + (0, ','), + (1, ','), + (3, '!'), + (4, '!'), + (6, '。'), + ]) { + final word = _wordAt(text, offset); + expect(word, isNotEmpty); + expect(word, isNot(contains(punctuation))); + expect(word, contains(text[offset])); + } + // Right before a punctuation mark, as after an ASCII word. + expect(_wordAt(text, 2), isNot(contains(','))); + expect('你好', contains(_wordAt(text, 2))); + }); + + testWidgets('Chinese text breaks at ICU word boundaries', (tester) async { + const text = '我们使用Flutter开发桌面应用'; + final flutterStart = text.indexOf('Flutter'); + for (var offset = flutterStart; offset < flutterStart + 7; offset += 1) { + expect(_wordAt(text, offset), 'Flutter'); + } + final develop = _wordAt(text, text.indexOf('开')); + expect(develop, startsWith('开')); + expect(develop.length, lessThan(text.length - flutterStart - 7)); + }); + + testWidgets('double click selects a word in a Chinese paragraph', + (tester) async { + final selectionController = MarkdownSelectionController(); + addTearDown(selectionController.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownWidget( + data: '我们使用Flutter开发桌面应用,效果很好。', + selectionController: selectionController, + ), + ), + ), + ); + + await _doubleTapCharacter( + tester, + containing: '我们使用', + character: 'F', + ); + expect(selectionController.selectedPlainText, 'Flutter'); + + await _resetTapCount(tester); + await _doubleTapCharacter( + tester, + containing: '我们使用', + character: '效', + ); + final selected = selectionController.selectedPlainText; + expect(selected, startsWith('效')); + expect(selected, isNot(contains(','))); + expect(selected, isNot(contains('。'))); + expect(selected.length, lessThan('效果很好'.length + 1)); + }); + + testWidgets('double click selects a word in Chinese list text', + (tester) async { + final selectionController = MarkdownSelectionController(); + addTearDown(selectionController.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownWidget( + data: '- 第一项:使用Flutter开发\n- 第二项', + selectionController: selectionController, + ), + ), + ), + ); + + await _doubleTapCharacter( + tester, + containing: '第一项', + character: 'F', + ); + expect(selectionController.selectedPlainText, 'Flutter'); + + await _resetTapCount(tester); + await _doubleTapCharacter( + tester, + containing: '第一项', + character: '项', + ); + expect(selectionController.selectedPlainText, isNot(contains(':'))); + expect(selectionController.selectedPlainText, contains('项')); + }); +} diff --git a/packages/mixin_markdown_widget/test/mixin_markdown_widget_test.dart b/packages/mixin_markdown_widget/test/mixin_markdown_widget_test.dart index ec86d8a9..4be46ea2 100644 --- a/packages/mixin_markdown_widget/test/mixin_markdown_widget_test.dart +++ b/packages/mixin_markdown_widget/test/mixin_markdown_widget_test.dart @@ -3927,7 +3927,7 @@ const value = 42; final paragraph = document.blocks.single as ParagraphBlock; final builder = MarkdownInlineBuilder( theme: theme, - recognizers: [], + recognizers: MarkdownLinkRecognizers(), ); final runs = builder.buildPretextRuns(theme.bodyStyle, paragraph.inlines); final layout = computeMarkdownPretextLayoutFromRuns(