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'(?`[^`\n]+`)' r'|(?\*\*[^*\n]+?\*\*|__[^_\n]+?__)' r'|(?\*[^*\s][^*\n]*?\*|(?~~[^~\n]+?~~)' r'|(?\[[^\]\n]+\]\([^)\n]*\))' r'|(?(?<=^|\s)[#@+][A-Za-z0-9_]+)' r'|(?(?<=^|\s):[A-Za-z0-9_]+)' r'|(?(?<=^|\s)/[A-Za-z0-9_/]+)', ); const _tagClasses = {'#': 'tag-hash', '+': 'tag-plus', '@': 'tag-at'}; /// Splits [text] into sorted, non-overlapping highlight segments. List highlightMarkdown(String text) { final segments = []; 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 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 theme; List _terms = const []; List _matches = const []; int _currentMatch = 0; String? _segmentsText; List _segments = const []; List get matches => _matches; int get currentMatch => _currentMatch; /// Finds case-insensitive occurrences of [terms], kept up to date on edits. void search(List 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 = [ 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 = {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 = []; 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), ); }