muzzle-velocity/lib/markdown_editing.dart

414 lines
13 KiB
Dart

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();
}
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),
),
);
}
/// Toggles emphasis [marker] (`*` or `**`) around the selection, leaving the
/// selection on the same text. With nothing selected, inserts an empty pair.
TextEditingValue toggleEmphasis(TextEditingValue value, String marker) {
final sel = value.selection;
if (!sel.isValid) return value;
final text = value.text;
final s = sel.start;
final e = sel.end;
final char = marker[0];
var before = 0;
while (before < s && text[s - before - 1] == char) {
before++;
}
var after = 0;
while (e + after < text.length && text[e + after] == char) {
after++;
}
// A run of 3 is bold plus italic, so either marker can be removed from it.
final run = before < after ? before : after;
final present = marker.length == 2 ? run >= 2 : run == 1 || run == 3;
final len = marker.length;
if (present) {
return TextEditingValue(
text: text.replaceRange(e, e + len, '').replaceRange(s - len, s, ''),
selection: TextSelection(
baseOffset: sel.baseOffset - len, extentOffset: sel.extentOffset - len),
);
}
return TextEditingValue(
text: text.replaceRange(s, e, '$marker${text.substring(s, e)}$marker'),
selection: TextSelection(
baseOffset: sel.baseOffset + len, extentOffset: sel.extentOffset + len),
);
}