414 lines
13 KiB
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),
|
|
);
|
|
}
|