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
377
lib/markdown_editing.dart
Normal file
377
lib/markdown_editing.dart
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// A highlighted range of text, named by a key of the theme maps in `themes/`.
|
||||
class HighlightSegment {
|
||||
final int start;
|
||||
final int end;
|
||||
final String className;
|
||||
|
||||
const HighlightSegment(this.start, this.end, this.className);
|
||||
}
|
||||
|
||||
final _fence = RegExp(r'^\s*(```|~~~)');
|
||||
final _heading = RegExp(r'^(#{1,6})\s');
|
||||
final _rule = RegExp(r'^ {0,3}([-*_])( *\1){2,} *$');
|
||||
final _quote = RegExp(r'^\s*>');
|
||||
final _listMarker = RegExp(r'^\s*([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?');
|
||||
final _inline = RegExp(
|
||||
r'(?<code>`[^`\n]+`)'
|
||||
r'|(?<bold>\*\*[^*\n]+?\*\*|__[^_\n]+?__)'
|
||||
r'|(?<italic>\*[^*\s][^*\n]*?\*|(?<!\w)_[^_\s][^_\n]*?_(?!\w))'
|
||||
r'|(?<strike>~~[^~\n]+?~~)'
|
||||
r'|(?<link>\[[^\]\n]+\]\([^)\n]*\))'
|
||||
r'|(?<tag>(?<=^|\s)[#@+][A-Za-z0-9_]+)'
|
||||
r'|(?<command>(?<=^|\s):[A-Za-z0-9_]+)'
|
||||
r'|(?<path>(?<=^|\s)/[A-Za-z0-9_/]+)',
|
||||
);
|
||||
|
||||
const _tagClasses = {'#': 'tag-hash', '+': 'tag-plus', '@': 'tag-at'};
|
||||
|
||||
/// Splits [text] into sorted, non-overlapping highlight segments.
|
||||
List<HighlightSegment> highlightMarkdown(String text) {
|
||||
final segments = <HighlightSegment>[];
|
||||
var inFence = false;
|
||||
var lineStart = 0;
|
||||
|
||||
while (lineStart <= text.length) {
|
||||
var lineEnd = text.indexOf('\n', lineStart);
|
||||
if (lineEnd == -1) lineEnd = text.length;
|
||||
final line = text.substring(lineStart, lineEnd);
|
||||
|
||||
void whole(String className) {
|
||||
if (line.isNotEmpty) {
|
||||
segments.add(HighlightSegment(lineStart, lineEnd, className));
|
||||
}
|
||||
}
|
||||
|
||||
if (_fence.hasMatch(line)) {
|
||||
inFence = !inFence;
|
||||
whole('code');
|
||||
} else if (inFence) {
|
||||
whole('code');
|
||||
} else if (_heading.firstMatch(line) case final m?) {
|
||||
whole('header-h${m.group(1)!.length}');
|
||||
} else if (_rule.hasMatch(line)) {
|
||||
whole('horizontal-rule');
|
||||
} else if (_quote.hasMatch(line)) {
|
||||
whole('blockquote');
|
||||
} else {
|
||||
var inlineStart = 0;
|
||||
final marker = _listMarker.firstMatch(line);
|
||||
if (marker != null) {
|
||||
final className = marker.group(2) != null ? 'task-list' : 'list';
|
||||
segments.add(HighlightSegment(lineStart, lineStart + marker.end, className));
|
||||
inlineStart = marker.end;
|
||||
}
|
||||
_highlightInline(line, inlineStart, lineStart, segments);
|
||||
}
|
||||
|
||||
lineStart = lineEnd + 1;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
void _highlightInline(
|
||||
String line, int from, int offset, List<HighlightSegment> segments) {
|
||||
for (final m in _inline.allMatches(line, from)) {
|
||||
final start = offset + m.start;
|
||||
final end = offset + m.end;
|
||||
if (m.namedGroup('link') case final link?) {
|
||||
final textEnd = start + link.indexOf('](') + 1;
|
||||
segments
|
||||
..add(HighlightSegment(start, textEnd, 'link-text'))
|
||||
..add(HighlightSegment(textEnd, end, 'link-url'));
|
||||
} else if (m.namedGroup('tag') case final tag?) {
|
||||
segments.add(HighlightSegment(start, end, _tagClasses[tag[0]]!));
|
||||
} else {
|
||||
final className = m.namedGroup('code') != null
|
||||
? 'code'
|
||||
: m.namedGroup('bold') != null
|
||||
? 'bold'
|
||||
: m.namedGroup('italic') != null
|
||||
? 'italic'
|
||||
: m.namedGroup('strike') != null
|
||||
? 'strikethrough'
|
||||
: m.namedGroup('command') != null
|
||||
? 'command'
|
||||
: 'link-url';
|
||||
segments.add(HighlightSegment(start, end, className));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _matchColor = Color(0x40FF9800);
|
||||
const _currentMatchColor = Color(0xA0FF9800);
|
||||
|
||||
/// Text controller that renders markdown highlighting and search matches.
|
||||
class MarkdownController extends TextEditingController {
|
||||
MarkdownController({super.text, required this.theme}) {
|
||||
_updateMatches();
|
||||
}
|
||||
|
||||
final Map<String, TextStyle> theme;
|
||||
|
||||
List<String> _terms = const [];
|
||||
List<TextRange> _matches = const [];
|
||||
int _currentMatch = 0;
|
||||
|
||||
String? _segmentsText;
|
||||
List<HighlightSegment> _segments = const [];
|
||||
|
||||
List<TextRange> get matches => _matches;
|
||||
int get currentMatch => _currentMatch;
|
||||
|
||||
/// Finds case-insensitive occurrences of [terms], kept up to date on edits.
|
||||
void search(List<String> terms) {
|
||||
_terms = terms.where((t) => t.isNotEmpty).toList();
|
||||
_currentMatch = 0;
|
||||
_updateMatches();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void selectMatch(int index) {
|
||||
_currentMatch = index;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
set value(TextEditingValue newValue) {
|
||||
final textChanged = newValue.text != text;
|
||||
super.value = newValue;
|
||||
if (textChanged && _terms.isNotEmpty) {
|
||||
_updateMatches();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _updateMatches() {
|
||||
final found = <TextRange>[
|
||||
for (final term in _terms)
|
||||
for (final m in RegExp(RegExp.escape(term), caseSensitive: false)
|
||||
.allMatches(text))
|
||||
TextRange(start: m.start, end: m.end),
|
||||
]..sort((a, b) => a.start.compareTo(b.start));
|
||||
|
||||
// Overlapping terms (e.g. a phrase containing a tag) would split spans oddly.
|
||||
_matches = [];
|
||||
for (final range in found) {
|
||||
if (_matches.isEmpty || range.start >= _matches.last.end) {
|
||||
_matches.add(range);
|
||||
}
|
||||
}
|
||||
if (_currentMatch >= _matches.length) _currentMatch = 0;
|
||||
}
|
||||
|
||||
@override
|
||||
TextSpan buildTextSpan({
|
||||
required BuildContext context,
|
||||
TextStyle? style,
|
||||
required bool withComposing,
|
||||
}) {
|
||||
if (_segmentsText != text) {
|
||||
_segments = highlightMarkdown(text);
|
||||
_segmentsText = text;
|
||||
}
|
||||
final composing =
|
||||
withComposing && value.isComposingRangeValid ? value.composing : null;
|
||||
|
||||
final breaks = <int>{0, text.length};
|
||||
for (final s in _segments) {
|
||||
breaks..add(s.start)..add(s.end);
|
||||
}
|
||||
for (final m in _matches) {
|
||||
breaks..add(m.start)..add(m.end);
|
||||
}
|
||||
if (composing != null) breaks..add(composing.start)..add(composing.end);
|
||||
final points = breaks.toList()..sort();
|
||||
|
||||
final base = (style ?? const TextStyle())
|
||||
.merge(TextStyle(color: theme['root']?.color));
|
||||
final children = <TextSpan>[];
|
||||
var seg = 0;
|
||||
var match = 0;
|
||||
for (var i = 0; i + 1 < points.length; i++) {
|
||||
final start = points[i];
|
||||
final end = points[i + 1];
|
||||
while (seg < _segments.length && _segments[seg].end <= start) {
|
||||
seg++;
|
||||
}
|
||||
while (match < _matches.length && _matches[match].end <= start) {
|
||||
match++;
|
||||
}
|
||||
|
||||
var spanStyle = base;
|
||||
if (seg < _segments.length && _segments[seg].start <= start) {
|
||||
spanStyle = spanStyle.merge(theme[_segments[seg].className]);
|
||||
}
|
||||
if (match < _matches.length && _matches[match].start <= start) {
|
||||
spanStyle = spanStyle.copyWith(
|
||||
backgroundColor:
|
||||
match == _currentMatch ? _currentMatchColor : _matchColor,
|
||||
);
|
||||
}
|
||||
if (composing != null &&
|
||||
composing.start <= start &&
|
||||
end <= composing.end) {
|
||||
spanStyle = spanStyle.copyWith(decoration: TextDecoration.underline);
|
||||
}
|
||||
children.add(TextSpan(text: text.substring(start, end), style: spanStyle));
|
||||
}
|
||||
return TextSpan(style: base, children: children);
|
||||
}
|
||||
}
|
||||
|
||||
const _wrapPairs = {
|
||||
'*': '*',
|
||||
'_': '_',
|
||||
'`': '`',
|
||||
'~': '~',
|
||||
'[': ']',
|
||||
'(': ')',
|
||||
};
|
||||
|
||||
final _listPrefix = RegExp(r'^([ \t]*)([-*+]|\d+[.)])( +)(\[[ xX]\] +)?');
|
||||
final _quotePrefix = RegExp(r'^([ \t]*)((?:> ?)+)');
|
||||
final _indent = RegExp(r'^[ \t]*');
|
||||
|
||||
/// Markdown typing helpers that work from the edit diff, so they behave the
|
||||
/// same for hardware keys and soft keyboards.
|
||||
class MarkdownInputFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
return _wrapSelection(oldValue, newValue) ??
|
||||
_continueLine(oldValue, newValue) ??
|
||||
newValue;
|
||||
}
|
||||
|
||||
/// Typing a markup character over a selection wraps it instead of replacing it.
|
||||
TextEditingValue? _wrapSelection(
|
||||
TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
final sel = oldValue.selection;
|
||||
if (!sel.isValid || sel.isCollapsed) return null;
|
||||
final s = sel.start;
|
||||
final e = sel.end;
|
||||
if (newValue.text.length != oldValue.text.length - (e - s) + 1) return null;
|
||||
|
||||
final typed = newValue.text.substring(s, s + 1);
|
||||
final closer = _wrapPairs[typed];
|
||||
if (closer == null || newValue.text != oldValue.text.replaceRange(s, e, typed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final inner = oldValue.text.substring(s, e);
|
||||
return TextEditingValue(
|
||||
text: oldValue.text.replaceRange(s, e, '$typed$inner$closer'),
|
||||
selection: TextSelection(baseOffset: s + 1, extentOffset: e + 1),
|
||||
);
|
||||
}
|
||||
|
||||
/// A new line keeps the previous line's indentation and list or quote marker.
|
||||
TextEditingValue? _continueLine(
|
||||
TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
final sel = oldValue.selection;
|
||||
if (!sel.isValid) return null;
|
||||
final s = sel.start;
|
||||
final e = sel.end;
|
||||
final old = oldValue.text;
|
||||
if (newValue.text != old.replaceRange(s, e, '\n')) return null;
|
||||
|
||||
final lineStart = s == 0 ? 0 : old.lastIndexOf('\n', s - 1) + 1;
|
||||
final before = old.substring(lineStart, s);
|
||||
final lineEnd = old.indexOf('\n', e);
|
||||
final after = old.substring(e, lineEnd == -1 ? old.length : lineEnd);
|
||||
|
||||
String prefix;
|
||||
String? marker;
|
||||
if (_insideFence(old, lineStart)) {
|
||||
prefix = _indent.stringMatch(before)!;
|
||||
} else if (_listPrefix.firstMatch(before) case final m?) {
|
||||
marker = m.group(0);
|
||||
final bullet = m.group(2)!;
|
||||
final number = int.tryParse(bullet.substring(0, bullet.length - 1));
|
||||
final next = number != null
|
||||
? '${number + 1}${bullet[bullet.length - 1]}'
|
||||
: bullet;
|
||||
prefix = '${m.group(1)}$next${m.group(3)}${m.group(4) != null ? '[ ] ' : ''}';
|
||||
} else if (_quotePrefix.firstMatch(before) case final m?) {
|
||||
marker = m.group(0);
|
||||
prefix = marker!;
|
||||
} else {
|
||||
prefix = _indent.stringMatch(before)!;
|
||||
}
|
||||
|
||||
// Enter on an empty item ends the list instead of adding another one.
|
||||
if (marker != null && before == marker && after.trim().isEmpty) {
|
||||
return TextEditingValue(
|
||||
text: old.replaceRange(lineStart, e, ''),
|
||||
selection: TextSelection.collapsed(offset: lineStart),
|
||||
);
|
||||
}
|
||||
|
||||
final insert = '\n$prefix';
|
||||
return TextEditingValue(
|
||||
text: old.replaceRange(s, e, insert),
|
||||
selection: TextSelection.collapsed(offset: s + insert.length),
|
||||
);
|
||||
}
|
||||
|
||||
bool _insideFence(String text, int lineStart) {
|
||||
var inside = false;
|
||||
for (final line in text.substring(0, lineStart).split('\n')) {
|
||||
if (_fence.hasMatch(line)) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
}
|
||||
|
||||
/// Indents or outdents every line touched by the selection by two spaces.
|
||||
TextEditingValue indentLines(TextEditingValue value, {bool outdent = false}) {
|
||||
final sel = value.selection;
|
||||
if (!sel.isValid) return value;
|
||||
final text = value.text;
|
||||
final firstLine = sel.start == 0 ? 0 : text.lastIndexOf('\n', sel.start - 1) + 1;
|
||||
// A selection ending at the very start of a line doesn't include that line.
|
||||
final lastPos =
|
||||
sel.end > sel.start && text[sel.end - 1] == '\n' ? sel.end - 1 : sel.end;
|
||||
|
||||
final buffer = StringBuffer(text.substring(0, firstLine));
|
||||
var startDelta = 0;
|
||||
var totalDelta = 0;
|
||||
var lineStart = firstLine;
|
||||
while (true) {
|
||||
var lineEnd = text.indexOf('\n', lineStart);
|
||||
if (lineEnd == -1) lineEnd = text.length;
|
||||
var line = text.substring(lineStart, lineEnd);
|
||||
var delta = 2;
|
||||
if (outdent) {
|
||||
final remove = line.startsWith('\t')
|
||||
? 1
|
||||
: line.length - line.replaceFirst(RegExp(r'^ {1,2}'), '').length;
|
||||
line = line.substring(remove);
|
||||
delta = -remove;
|
||||
} else {
|
||||
line = ' $line';
|
||||
}
|
||||
if (lineStart == firstLine) startDelta = delta;
|
||||
totalDelta += delta;
|
||||
buffer.write(line);
|
||||
if (lineEnd >= lastPos || lineEnd == text.length) {
|
||||
buffer.write(text.substring(lineEnd));
|
||||
break;
|
||||
}
|
||||
buffer.write('\n');
|
||||
lineStart = lineEnd + 1;
|
||||
}
|
||||
|
||||
int shift(int offset, int delta) =>
|
||||
(offset + delta).clamp(firstLine, buffer.length);
|
||||
return TextEditingValue(
|
||||
text: buffer.toString(),
|
||||
selection: TextSelection(
|
||||
baseOffset: shift(sel.baseOffset, sel.baseOffset == sel.start ? startDelta : totalDelta),
|
||||
extentOffset: shift(sel.extentOffset, sel.extentOffset == sel.start ? startDelta : totalDelta),
|
||||
),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue