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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/mixin_markdown_widget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -167,6 +171,9 @@ class MarkdownDocumentParser {
version: version,
sourceOffset: sourceOffset,
initialKindCounts: initialKindCounts,
reusableIds: parseAppendedChunkAsNewBlocks
? null
: _reusableIdsOf(previousDocument.blocks.last),
);

final document = MarkdownDocument(
Expand Down Expand Up @@ -237,6 +244,7 @@ class MarkdownDocumentParser {
required int version,
required int sourceOffset,
required Map<MarkdownBlockKind, int> initialKindCounts,
Map<String, String>? reusableIds,
}) {
final timing = onTiming == null ? null : _MarkdownParserTimingBuilder();
final totalStopwatch = timing == null ? null : (Stopwatch()..start());
Expand All @@ -254,6 +262,7 @@ class MarkdownDocumentParser {
);
final builder = _MarkdownAstBuilder(
initialKindCounts: initialKindCounts,
reusableIds: reusableIds,
timing: timing,
);
var blocks = _measure(
Expand Down Expand Up @@ -389,19 +398,43 @@ class MarkdownDocumentParser {
BlockNode block,
Map<MarkdownBlockKind, int> 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<String, String> _reusableIdsOf(BlockNode block) {
final ids = <String, String>{};
_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;
}

if (block is ListBlock) {
for (final item in block.items) {
for (final child in item.children) {
_countBlockKindsInBlock(child, counts);
_visitBlockTree(child, visit);
}
}
return;
Expand All @@ -410,15 +443,15 @@ 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;
}

if (block is DetailsBlock) {
for (final child in block.children) {
_countBlockKindsInBlock(child, counts);
_visitBlockTree(child, visit);
}
return;
}
Expand All @@ -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);
}
}
}
Expand Down Expand Up @@ -1135,15 +1168,22 @@ class _MarkdownParserTimingBuilder {
class _MarkdownAstBuilder {
_MarkdownAstBuilder({
Map<MarkdownBlockKind, int>? initialKindCounts,
Map<String, String>? reusableIds,
_MarkdownParserTimingBuilder? timing,
}) : _timing = timing,
_reusableIds = reusableIds,
_kindCounters = <MarkdownBlockKind, int>{
...?initialKindCounts,
};

final Map<MarkdownBlockKind, int> _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<String, String>? _reusableIds;
int _blockDepth = 0;

Map<MarkdownBlockKind, int> get kindCounts =>
Map<MarkdownBlockKind, int>.unmodifiable(_kindCounters);

Expand All @@ -1154,9 +1194,14 @@ class _MarkdownAstBuilder {
blocks.addAll(buildBlocks(node.children ?? const <md.Node>[]));
continue;
}
_blockDepth += 1;
final block = _buildBlock(node);
_blockDepth -= 1;
if (block != null) {
blocks.add(block);
if (_blockDepth == 0) {
_reusableIds = null;
}
}
}
return blocks;
Expand Down Expand Up @@ -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)}';
},
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<LinkInline, TapGestureRecognizer> _recognizers =
Map<LinkInline, TapGestureRecognizer>.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<BlockNode> previous, List<BlockNode> current) {
if (_recognizers.isEmpty || identical(previous, current)) {
return;
}
final currentBlocks = Set<BlockNode>.identity()..addAll(current);
final removed = Set<LinkInline>.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<BlockNode>.identity()..addAll(previous);
final kept = Set<LinkInline>.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<LinkInline> links) {
void visitInlines(List<InlineNode> 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,
Expand All @@ -15,7 +124,7 @@ class MarkdownInlineBuilder {
});

final MarkdownThemeData theme;
final List<TapGestureRecognizer> recognizers;
final MarkdownLinkRecognizers recognizers;
final MarkdownTapLinkCallback? onTapLink;

List<MarkdownPretextInlineRun> buildPretextRuns(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading