feat: add built-in markdown editor with search jump and typing helpers
This commit is contained in:
parent
db27f64e42
commit
ed2de5e67f
16 changed files with 1146 additions and 561 deletions
145
test/markdown_editing_test.dart
Normal file
145
test/markdown_editing_test.dart
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:muzzle_velocity/markdown_editing.dart';
|
||||
|
||||
final _formatter = MarkdownInputFormatter();
|
||||
|
||||
TextEditingValue _value(String text, int start, [int? end]) => TextEditingValue(
|
||||
text: text,
|
||||
selection: TextSelection(baseOffset: start, extentOffset: end ?? start),
|
||||
);
|
||||
|
||||
/// Simulates typing [typed] over the current selection of [old].
|
||||
TextEditingValue _type(TextEditingValue old, String typed) {
|
||||
final s = old.selection.start;
|
||||
final raw = _value(old.text.replaceRange(s, old.selection.end, typed),
|
||||
s + typed.length);
|
||||
return _formatter.formatEditUpdate(old, raw);
|
||||
}
|
||||
|
||||
/// Text with `|` marking the caret.
|
||||
TextEditingValue _caret(String marked) =>
|
||||
_value(marked.replaceFirst('|', ''), marked.indexOf('|'));
|
||||
|
||||
String _marked(TextEditingValue v) =>
|
||||
v.text.replaceRange(v.selection.start, v.selection.start, '|');
|
||||
|
||||
Map<String, String> _classes(String text) => {
|
||||
for (final s in highlightMarkdown(text))
|
||||
text.substring(s.start, s.end): s.className,
|
||||
};
|
||||
|
||||
void main() {
|
||||
group('wrap selection', () {
|
||||
test('asterisk wraps and keeps the word selected', () {
|
||||
final once = _type(_value('a word here', 2, 6), '*');
|
||||
expect(once.text, 'a *word* here');
|
||||
expect(once.selection, const TextSelection(baseOffset: 3, extentOffset: 7));
|
||||
expect(_type(once, '*').text, 'a **word** here');
|
||||
});
|
||||
|
||||
test('bracket uses its closing pair', () {
|
||||
expect(_type(_value('see link', 4, 8), '[').text, 'see [link]');
|
||||
});
|
||||
|
||||
test('a letter still replaces the selection', () {
|
||||
final result = _type(_value('a word', 2, 6), 'x');
|
||||
expect(result.text, 'a x');
|
||||
});
|
||||
});
|
||||
|
||||
group('new line', () {
|
||||
test('keeps indentation', () {
|
||||
expect(_marked(_type(_caret(' code|'), '\n')), ' code\n |');
|
||||
});
|
||||
|
||||
test('continues bullets, numbers, tasks and quotes', () {
|
||||
expect(_marked(_type(_caret('- one|'), '\n')), '- one\n- |');
|
||||
expect(_marked(_type(_caret(' * one|'), '\n')), ' * one\n * |');
|
||||
expect(_marked(_type(_caret('9. nine|'), '\n')), '9. nine\n10. |');
|
||||
expect(_marked(_type(_caret('1) a|'), '\n')), '1) a\n2) |');
|
||||
expect(_marked(_type(_caret('- [x] done|'), '\n')), '- [x] done\n- [ ] |');
|
||||
expect(_marked(_type(_caret('> quote|'), '\n')), '> quote\n> |');
|
||||
});
|
||||
|
||||
test('splits an item when the caret is mid-line', () {
|
||||
expect(_marked(_type(_caret('- one|two'), '\n')), '- one\n- |two');
|
||||
});
|
||||
|
||||
test('an empty item ends the list', () {
|
||||
expect(_marked(_type(_caret('- one\n- |'), '\n')), '- one\n|');
|
||||
expect(_marked(_type(_caret('1. a\n2. |'), '\n')), '1. a\n|');
|
||||
});
|
||||
|
||||
test('inside a code fence only indentation carries over', () {
|
||||
expect(_marked(_type(_caret('```\n - x|'), '\n')), '```\n - x\n |');
|
||||
});
|
||||
|
||||
test('emphasis at line start is not a bullet', () {
|
||||
expect(_marked(_type(_caret('*bold* text|'), '\n')), '*bold* text\n|');
|
||||
});
|
||||
});
|
||||
|
||||
group('indentLines', () {
|
||||
test('indents and outdents all selected lines', () {
|
||||
final value = _value('a\nb\nc', 0, 3);
|
||||
final indented = indentLines(value);
|
||||
expect(indented.text, ' a\n b\nc');
|
||||
expect(indentLines(indented, outdent: true).text, 'a\nb\nc');
|
||||
});
|
||||
});
|
||||
|
||||
group('highlightMarkdown', () {
|
||||
test('line and inline syntax', () {
|
||||
final classes = _classes(
|
||||
'## Title\n- [ ] task **bold** and *it* `code` ~~gone~~\n[text](url) #tag +proj @ctx');
|
||||
expect(classes['## Title'], 'header-h2');
|
||||
expect(classes['- [ ] '], 'task-list');
|
||||
expect(classes['**bold**'], 'bold');
|
||||
expect(classes['*it*'], 'italic');
|
||||
expect(classes['`code`'], 'code');
|
||||
expect(classes['~~gone~~'], 'strikethrough');
|
||||
expect(classes['[text]'], 'link-text');
|
||||
expect(classes['(url)'], 'link-url');
|
||||
expect(classes['#tag'], 'tag-hash');
|
||||
expect(classes['+proj'], 'tag-plus');
|
||||
expect(classes['@ctx'], 'tag-at');
|
||||
});
|
||||
|
||||
test('fenced code spans lines without inline highlighting', () {
|
||||
final classes = _classes('```\n**not bold**\n```\n**bold**');
|
||||
expect(classes['**not bold**'], 'code');
|
||||
expect(classes['**bold**'], 'bold');
|
||||
});
|
||||
|
||||
test('segments are sorted and do not overlap', () {
|
||||
final segments = highlightMarkdown('- a **b** #c\n> q\n---');
|
||||
for (var i = 1; i < segments.length; i++) {
|
||||
expect(segments[i].start, greaterThanOrEqualTo(segments[i - 1].end));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('MarkdownController search', () {
|
||||
test('finds terms case-insensitively and follows edits', () {
|
||||
final controller =
|
||||
MarkdownController(text: 'Foo bar foo #tag', theme: const {});
|
||||
controller.search(['foo', '#tag']);
|
||||
expect(controller.matches.map((m) => m.start), [0, 8, 12]);
|
||||
|
||||
controller.text = 'bar foo';
|
||||
expect(controller.matches.map((m) => m.start), [4]);
|
||||
});
|
||||
|
||||
test('builds spans covering the whole text', () {
|
||||
final controller =
|
||||
MarkdownController(text: '# Hi **there**', theme: const {});
|
||||
controller.search(['there']);
|
||||
final span = controller.buildTextSpan(
|
||||
context: _FakeContext(), withComposing: false);
|
||||
expect(span.toPlainText(), '# Hi **there**');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeContext extends Fake implements BuildContext {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue