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
|
|
@ -1,72 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||
|
||||
class WrappedCodeField extends StatefulWidget {
|
||||
final CodeController controller;
|
||||
final bool expands;
|
||||
final bool wrap;
|
||||
final TextStyle? textStyle;
|
||||
final Color? cursorColor;
|
||||
final EdgeInsets padding;
|
||||
final bool readOnly;
|
||||
final FocusNode? focusNode;
|
||||
|
||||
const WrappedCodeField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.expands = false,
|
||||
this.wrap = true, // ✅ Enable wrapping by default
|
||||
this.textStyle,
|
||||
this.cursorColor,
|
||||
this.padding = EdgeInsets.zero,
|
||||
this.readOnly = false,
|
||||
this.focusNode,
|
||||
});
|
||||
|
||||
@override
|
||||
_WrappedCodeFieldState createState() => _WrappedCodeFieldState();
|
||||
}
|
||||
|
||||
class _WrappedCodeFieldState extends State<WrappedCodeField> {
|
||||
late FocusNode _focusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = widget.focusNode ?? FocusNode();
|
||||
_focusNode.attach(context, onKeyEvent: _onKeyEvent);
|
||||
}
|
||||
|
||||
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
||||
return widget.controller.onKey(event);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: widget.padding,
|
||||
child: TextField(
|
||||
focusNode: _focusNode,
|
||||
controller: widget.controller,
|
||||
expands: widget.expands,
|
||||
minLines: widget.expands ? null : 1,
|
||||
maxLines: widget.expands ? null : null, // Allows text to grow dynamically
|
||||
scrollPhysics: widget.wrap ? const NeverScrollableScrollPhysics() : null,
|
||||
style: widget.textStyle ?? const TextStyle(fontSize: 16),
|
||||
cursorColor: widget.cursorColor ?? Colors.white,
|
||||
decoration: const InputDecoration(
|
||||
isCollapsed: true,
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
81
lib/markdown_view.dart
Normal file
81
lib/markdown_view.dart
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'markdown_editing.dart';
|
||||
|
||||
/// Highlighted markdown source that is either read-only or editable.
|
||||
class MarkdownView extends StatefulWidget {
|
||||
final MarkdownController controller;
|
||||
final bool readOnly;
|
||||
final double fontSize;
|
||||
final FocusNode? focusNode;
|
||||
|
||||
const MarkdownView({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.readOnly = true,
|
||||
this.fontSize = 16,
|
||||
this.focusNode,
|
||||
});
|
||||
|
||||
@override
|
||||
MarkdownViewState createState() => MarkdownViewState();
|
||||
}
|
||||
|
||||
class MarkdownViewState extends State<MarkdownView> {
|
||||
final _fieldKey = GlobalKey();
|
||||
final _formatters = [MarkdownInputFormatter()];
|
||||
|
||||
/// Scrolls [offset] into view without focusing the field, so read mode
|
||||
/// doesn't bring up the keyboard.
|
||||
void revealOffset(int offset) {
|
||||
final editable = _findEditable(_fieldKey.currentContext?.findRenderObject());
|
||||
if (editable == null) return;
|
||||
final caret = editable.getLocalRectForCaret(TextPosition(offset: offset));
|
||||
editable.showOnScreen(
|
||||
rect: Rect.fromLTRB(
|
||||
caret.left, caret.top - 80, caret.right, caret.bottom + 80),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
RenderEditable? _findEditable(RenderObject? node) {
|
||||
if (node is RenderEditable) return node;
|
||||
RenderEditable? found;
|
||||
node?.visitChildren((child) => found ??= _findEditable(child));
|
||||
return found;
|
||||
}
|
||||
|
||||
void _indent({required bool outdent}) {
|
||||
final controller = widget.controller;
|
||||
controller.value = indentLines(controller.value, outdent: outdent);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Tab would otherwise move focus out of the editor on desktop.
|
||||
return CallbackShortcuts(
|
||||
bindings: widget.readOnly
|
||||
? const {}
|
||||
: {
|
||||
const SingleActivator(LogicalKeyboardKey.tab): () =>
|
||||
_indent(outdent: false),
|
||||
const SingleActivator(LogicalKeyboardKey.tab, shift: true): () =>
|
||||
_indent(outdent: true),
|
||||
},
|
||||
child: TextField(
|
||||
key: _fieldKey,
|
||||
controller: widget.controller,
|
||||
focusNode: widget.focusNode,
|
||||
readOnly: widget.readOnly,
|
||||
showCursor: !widget.readOnly,
|
||||
expands: true,
|
||||
maxLines: null,
|
||||
decoration: null,
|
||||
inputFormatters: widget.readOnly ? null : _formatters,
|
||||
style: TextStyle(fontSize: widget.fontSize),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +1,38 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_markdown_latex/flutter_markdown_latex.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'themes/day.dart';
|
||||
import 'themes/night.dart';
|
||||
import 'themes/markdown.dart';
|
||||
import 'codefield.dart';
|
||||
import 'markdown_editing.dart';
|
||||
import 'markdown_view.dart';
|
||||
|
||||
class NoteEditorScreen extends StatefulWidget {
|
||||
final File note;
|
||||
final String? notesDirectoryPath; // ✅ Add this
|
||||
final bool isDarkMode;
|
||||
final List<String> searchTerms;
|
||||
|
||||
NoteEditorScreen(
|
||||
{required this.note,
|
||||
required this.isDarkMode,
|
||||
required this.notesDirectoryPath});
|
||||
required this.notesDirectoryPath,
|
||||
this.searchTerms = const []});
|
||||
|
||||
@override
|
||||
_NoteEditorScreenState createState() => _NoteEditorScreenState();
|
||||
}
|
||||
|
||||
class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
||||
late CodeController _controller;
|
||||
late MarkdownController _controller;
|
||||
late String _initialContent;
|
||||
final _viewKey = GlobalKey<MarkdownViewState>();
|
||||
final _editorFocus = FocusNode();
|
||||
bool _isEditing = false;
|
||||
bool _isPreviewMode = false;
|
||||
bool _isLoading = true;
|
||||
bool _autoSaveEnabled = false; // Controlled by user toggle
|
||||
|
|
@ -64,13 +69,35 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
}
|
||||
|
||||
setState(() {
|
||||
_controller = CodeController(
|
||||
_controller = MarkdownController(
|
||||
text: _initialContent,
|
||||
language: markdown,
|
||||
);
|
||||
theme: widget.isDarkMode ? nightTheme : dayTheme,
|
||||
)..search(widget.searchTerms);
|
||||
_controller.addListener(_onTextChanged);
|
||||
_isLoading = false;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _showMatch(0));
|
||||
}
|
||||
|
||||
void _showMatch(int index) {
|
||||
final matches = _controller.matches;
|
||||
if (matches.isEmpty) return;
|
||||
final current = index % matches.length;
|
||||
_controller.selectMatch(current);
|
||||
_viewKey.currentState?.revealOffset(matches[current].start);
|
||||
}
|
||||
|
||||
void _toggleEditing() {
|
||||
setState(() {
|
||||
_isEditing = !_isEditing;
|
||||
_isPreviewMode = false;
|
||||
});
|
||||
if (_isEditing) {
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => _editorFocus.requestFocus());
|
||||
} else {
|
||||
_editorFocus.unfocus();
|
||||
}
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
|
|
@ -92,6 +119,12 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
void _shareNote() {
|
||||
if (_controller.text.isNotEmpty) {
|
||||
Share.share(_controller.text, subject: widget.note.uri.pathSegments.last);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _onWillPop() async {
|
||||
if (_controller.text == _initialContent) return true; // No changes, exit
|
||||
|
||||
|
|
@ -132,6 +165,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
void dispose() {
|
||||
_controller.removeListener(_onTextChanged);
|
||||
_controller.dispose();
|
||||
_editorFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -151,6 +185,11 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
},
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isEditing ? Icons.edit_off_outlined : Icons.edit_outlined),
|
||||
onPressed: _isLoading ? null : _toggleEditing,
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 15),
|
||||
child: PopupMenuButton<String>(
|
||||
|
|
@ -161,6 +200,9 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
),
|
||||
onSelected: (String value) {
|
||||
switch (value) {
|
||||
case 'Share': // ✅ New case for sharing
|
||||
_shareNote();
|
||||
break;
|
||||
case 'Save':
|
||||
_saveNote();
|
||||
break;
|
||||
|
|
@ -175,6 +217,14 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) => [
|
||||
PopupMenuItem<String>( // ✅ Share button
|
||||
value: 'Share',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.share_outlined),
|
||||
title: Text("Share"),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Save',
|
||||
child: ListTile(
|
||||
|
|
@ -193,6 +243,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
: "Enable Autosave"),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
enabled: false,
|
||||
child: Column(
|
||||
|
|
@ -219,6 +270,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
],
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Toggle Preview',
|
||||
child: ListTile(
|
||||
|
|
@ -288,20 +340,39 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: CodeTheme(
|
||||
data: CodeThemeData(
|
||||
styles: widget.isDarkMode ? nightTheme : dayTheme),
|
||||
child: SingleChildScrollView(
|
||||
child: WrappedCodeField(
|
||||
wrap: true,
|
||||
controller: _controller,
|
||||
textStyle: TextStyle(
|
||||
fontSize: _fontSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: MarkdownView(
|
||||
key: _viewKey,
|
||||
controller: _controller,
|
||||
readOnly: !_isEditing,
|
||||
fontSize: _fontSize,
|
||||
focusNode: _editorFocus,
|
||||
),
|
||||
),
|
||||
floatingActionButton: _isLoading || _isPreviewMode
|
||||
? null
|
||||
: ListenableBuilder(
|
||||
listenable: _controller,
|
||||
builder: (context, _) => _controller.matches.length > 1
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'previous-match',
|
||||
onPressed: () =>
|
||||
_showMatch(_controller.currentMatch - 1),
|
||||
child: Icon(Icons.keyboard_arrow_up),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
FloatingActionButton.small(
|
||||
heroTag: 'next-match',
|
||||
onPressed: () =>
|
||||
_showMatch(_controller.currentMatch + 1),
|
||||
child: Icon(Icons.keyboard_arrow_down),
|
||||
),
|
||||
],
|
||||
)
|
||||
: SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class NoteListScreen extends StatefulWidget {
|
|||
|
||||
class _NoteListScreenState extends State<NoteListScreen> {
|
||||
TextEditingController searchController = TextEditingController();
|
||||
final FocusNode searchFocusNode = FocusNode();
|
||||
List<File> notes = [];
|
||||
List<File> filteredNotes = [];
|
||||
List<File> _visibleNotes = [];
|
||||
|
|
@ -52,6 +53,9 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
searchController.addListener(() {
|
||||
_debouncedFilterNotes();
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
FocusScope.of(context).requestFocus(searchFocusNode);
|
||||
});
|
||||
}
|
||||
|
||||
void _debouncedFilterNotes() {
|
||||
|
|
@ -116,12 +120,46 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
setState(() {
|
||||
defaultDir = dir.path;
|
||||
_useExternalEditor = prefs.getBool('use_external_editor') ?? false;
|
||||
_includeSubdirectories = prefs.getBool('include_subdirectories') ?? true; // ✅ Load setting
|
||||
_includeSubdirectories = prefs.getBool('include_subdirectories') ?? true;
|
||||
});
|
||||
|
||||
notesDirectoryPath = prefs.getString('notes_directory') ?? defaultDir;
|
||||
|
||||
// 🚀 Check if it's the first start OR if the directory is empty
|
||||
final directory = Directory(notesDirectoryPath!);
|
||||
if (prefs.getBool('initialized') == null ||
|
||||
!directory.listSync().any((entity) => entity is File && entity.path.endsWith('.md'))) {
|
||||
await _createDefaultNotes();
|
||||
await prefs.setBool('initialized', true);
|
||||
}
|
||||
|
||||
await _loadNotes();
|
||||
}
|
||||
|
||||
Future<void> _createDefaultNotes() async {
|
||||
final quickStartNote = File(path.join(notesDirectoryPath!, "QuickStart.md"));
|
||||
|
||||
if (!quickStartNote.existsSync()) {
|
||||
quickStartNote.writeAsStringSync("""
|
||||
# Quick Start Guide
|
||||
|
||||
Welcome to *Muzzle Velocity*.
|
||||
This is your fast and minimal note-taking app.
|
||||
|
||||
1. ✍️ Create Notes quickly: just type a title in the search bar and press enter.
|
||||
2. 🔍 Type keywords to filter notes instantly.
|
||||
3. 💻 Type commands like :help to get more information.
|
||||
4. 🏷️ Use hashtags like #quickstart, context tags like @help, or project tags like +muzzle in your notes for categorization.
|
||||
5. 🔫 Tap the blaster at the top right for more settings or to clear the search field.
|
||||
|
||||
*Muzzle Velocity* is built for speed and simplicity! 🚀
|
||||
|
||||
Happy note taking! ✨
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _toggleIncludeSubdirectories(bool value) {
|
||||
setState(() {
|
||||
_includeSubdirectories = value;
|
||||
|
|
@ -206,12 +244,19 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
final entries = dir.listSync(recursive: _includeSubdirectories);
|
||||
|
||||
for (var entry in entries) {
|
||||
if (entry is File && entry.path.endsWith('.md')) {
|
||||
if (entry is File && _isValidNoteFile(entry)) {
|
||||
|
||||
if (path.basename(path.dirname(entry.path)) == "trash") {
|
||||
continue;
|
||||
}
|
||||
|
||||
final String content;
|
||||
try {
|
||||
content = entry.readAsStringSync();
|
||||
} on FileSystemException {
|
||||
continue; // Not text, e.g. a Mega Drive ROM with a .md extension.
|
||||
}
|
||||
|
||||
allNotes.add(entry);
|
||||
final folder = path.dirname(entry.path);
|
||||
|
||||
|
|
@ -220,7 +265,6 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
folderTimestamps[folder] = entry.lastModifiedSync();
|
||||
}
|
||||
|
||||
final content = entry.readAsStringSync();
|
||||
extractedTags[entry] = _extractTags(content);
|
||||
}
|
||||
}
|
||||
|
|
@ -268,6 +312,11 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
return sortedTags.toSet(); // Convert back to a set after sorting
|
||||
}
|
||||
|
||||
bool _isValidNoteFile(File file) {
|
||||
final validExtensions = ['.md', '.txt', '.markdown'];
|
||||
return validExtensions.any((ext) => file.path.toLowerCase().endsWith(ext));
|
||||
}
|
||||
|
||||
void _filterNotes() {
|
||||
final query = searchController.text.trim().toLowerCase();
|
||||
|
||||
|
|
@ -301,6 +350,17 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
});
|
||||
}
|
||||
|
||||
/// Search bar terms to locate inside an opened note, split like [_filterNotes].
|
||||
List<String> _noteSearchTerms() {
|
||||
final terms = searchController.text
|
||||
.trim()
|
||||
.split(' ')
|
||||
.where((t) => t.isNotEmpty && !t.startsWith(RegExp(r'[/:]')));
|
||||
final tags = terms.where((t) => t.startsWith(RegExp(r'[#@+]')));
|
||||
final text = terms.where((t) => !t.startsWith(RegExp(r'[#@+]'))).join(' ');
|
||||
return [if (text.isNotEmpty) text, ...tags];
|
||||
}
|
||||
|
||||
void _loadMoreNotes() {
|
||||
if (_isLoadingMore || _visibleNotes.length >= filteredNotes.length) return;
|
||||
|
||||
|
|
@ -351,6 +411,15 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
String notePath = title.trim();
|
||||
String? subfolder;
|
||||
String safeTitle;
|
||||
String extension = '.md'; // Default extension
|
||||
|
||||
// ✅ Check if user specified an extension manually
|
||||
if (title.contains('.')) {
|
||||
final ext = path.extension(title);
|
||||
if (['.txt', '.markdown', '.md'].contains(ext)) {
|
||||
extension = ext;
|
||||
}
|
||||
}
|
||||
|
||||
if (notePath.startsWith('/')) {
|
||||
final parts = notePath.split('/');
|
||||
|
|
@ -358,15 +427,15 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
if (parts.length > 1) {
|
||||
subfolder = parts.sublist(0, parts.length - 1).join('/');
|
||||
}
|
||||
safeTitle = _sanitizeFilename(parts.last);
|
||||
safeTitle = _sanitizeFilename(parts.last.replaceAll(extension, '')); // Remove extension from input
|
||||
} else {
|
||||
safeTitle = _sanitizeFilename(title);
|
||||
safeTitle = _sanitizeFilename(title.replaceAll(extension, ''));
|
||||
}
|
||||
|
||||
// Construct the final file path
|
||||
String fullPath = subfolder != null
|
||||
? path.join(notesDirectoryPath!, subfolder, '$safeTitle.md')
|
||||
: path.join(notesDirectoryPath!, '$safeTitle.md');
|
||||
? path.join(notesDirectoryPath!, subfolder, '$safeTitle$extension')
|
||||
: path.join(notesDirectoryPath!, '$safeTitle$extension');
|
||||
|
||||
final newNote = File(fullPath);
|
||||
|
||||
|
|
@ -416,7 +485,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
|
||||
if (notePath.startsWith('/')) {
|
||||
final parts = notePath.split('/');
|
||||
parts.removeWhere((element) => element.isEmpty); // Remove empty elements
|
||||
parts.removeWhere((element) => element.isEmpty);
|
||||
if (parts.length > 1) {
|
||||
subfolder = parts.sublist(0, parts.length - 1).join('/');
|
||||
}
|
||||
|
|
@ -425,12 +494,18 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
safeTitle = _sanitizeFilename(title);
|
||||
}
|
||||
|
||||
// Construct the expected file path
|
||||
String fullPath = subfolder != null
|
||||
? path.join(notesDirectoryPath!, subfolder, '$safeTitle.md')
|
||||
: path.join(notesDirectoryPath!, '$safeTitle.md');
|
||||
// ✅ Allow multiple file extensions
|
||||
for (var ext in ['.md', '.txt', '.markdown']) {
|
||||
String fullPath = subfolder != null
|
||||
? path.join(notesDirectoryPath!, subfolder, '$safeTitle$ext')
|
||||
: path.join(notesDirectoryPath!, '$safeTitle$ext');
|
||||
|
||||
return !notes.any((note) => note.path == fullPath);
|
||||
if (notes.any((note) => note.path == fullPath)) {
|
||||
return false; // A note with this name already exists
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
String _sanitizeFilename(String title) {
|
||||
|
|
@ -603,6 +678,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
title: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||
child: TextField(
|
||||
focusNode: searchFocusNode,
|
||||
enableInteractiveSelection: true,
|
||||
autocorrect: false,
|
||||
textInputAction: TextInputAction.done,
|
||||
|
|
@ -626,137 +702,145 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
actions: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 15),
|
||||
child: PopupMenuButton<String>(
|
||||
icon: SvgPicture.asset(
|
||||
"assets/blaster.svg",
|
||||
width: 30,
|
||||
color: Colors.orange,
|
||||
),
|
||||
onSelected: (String value) {
|
||||
switch (value) {
|
||||
case 'Toggle Tags':
|
||||
widget.toggleTags();
|
||||
break;
|
||||
case 'Toggle Theme':
|
||||
setState(() {
|
||||
widget.toggleTheme(!widget.isDarkMode);
|
||||
});
|
||||
break;
|
||||
case 'External Editor':
|
||||
_toggleExternalEditor(!_useExternalEditor);
|
||||
break;
|
||||
case 'Select Folder':
|
||||
_showDirectoryChoiceDialog(
|
||||
notesDirectoryPath ?? ''); // Re-trigger popup
|
||||
break;
|
||||
case 'Empty Trash':
|
||||
_confirmEmptyTrash();
|
||||
break;
|
||||
case 'Help':
|
||||
_showInfoPage(context, "Help", helpContent);
|
||||
break;
|
||||
case 'About':
|
||||
_showInfoPage(context, "About", aboutContent);
|
||||
break;
|
||||
}
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (searchController.text.isNotEmpty) {
|
||||
searchController.clear();
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) => [
|
||||
PopupMenuItem<String>(
|
||||
value: 'Toggle Tags',
|
||||
child: ListTile(
|
||||
leading:
|
||||
Icon(widget.showTags ? Icons.short_text : Icons.tag),
|
||||
title: Text(widget.showTags ? 'Hide Tags' : 'Show Tags'),
|
||||
),
|
||||
child: PopupMenuButton<String>(
|
||||
enabled: searchController.text.isEmpty,
|
||||
icon: SvgPicture.asset(
|
||||
"assets/blaster.svg",
|
||||
width: 30,
|
||||
color: Colors.orange,
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Toggle Theme',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? Icons.light_mode
|
||||
: Icons.dark_mode,
|
||||
),
|
||||
title: Text(
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? 'Switch to Light Mode'
|
||||
: 'Switch to Dark Mode',
|
||||
onSelected: (String value) {
|
||||
switch (value) {
|
||||
case 'Toggle Tags':
|
||||
widget.toggleTags();
|
||||
break;
|
||||
case 'Toggle Theme':
|
||||
setState(() {
|
||||
widget.toggleTheme(!widget.isDarkMode);
|
||||
});
|
||||
break;
|
||||
case 'External Editor':
|
||||
_toggleExternalEditor(!_useExternalEditor);
|
||||
break;
|
||||
case 'Select Folder':
|
||||
_showDirectoryChoiceDialog(
|
||||
notesDirectoryPath ?? ''); // Re-trigger popup
|
||||
break;
|
||||
case 'Empty Trash':
|
||||
_confirmEmptyTrash();
|
||||
break;
|
||||
case 'Help':
|
||||
_showInfoPage(context, "Help", helpContent);
|
||||
break;
|
||||
case 'About':
|
||||
_showInfoPage(context, "About", aboutContent);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) => [
|
||||
PopupMenuItem<String>(
|
||||
value: 'Toggle Tags',
|
||||
child: ListTile(
|
||||
leading:
|
||||
Icon(widget.showTags ? Icons.short_text : Icons.tag),
|
||||
title: Text(widget.showTags ? 'Hide Tags' : 'Show Tags'),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Include Subdirectories',
|
||||
child: ListTile(
|
||||
leading: Icon(_includeSubdirectories ? Icons.file_copy_outlined : Icons.folder_copy_outlined),
|
||||
title: Text(_includeSubdirectories ? 'Exclude Subdirectories' : 'Include Subdirectories'),
|
||||
onTap: () {
|
||||
_toggleIncludeSubdirectories(!_includeSubdirectories);
|
||||
Navigator.pop(context); // Close menu after toggle
|
||||
},
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Include File Content',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
_includeFileContent ? Icons.highlight_off : Icons.file_open_outlined,
|
||||
),
|
||||
title: Text(
|
||||
_includeFileContent ? 'Exclude File Content' : 'Include File Content',
|
||||
),
|
||||
onTap: () {
|
||||
_toggleIncludeFileContent(!_includeFileContent);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'External Editor',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
_useExternalEditor
|
||||
? Icons.open_in_new_off
|
||||
: Icons.open_in_new,
|
||||
),
|
||||
title: Text(
|
||||
!_useExternalEditor
|
||||
? 'Use External Editor'
|
||||
: 'Use Internal Editor',
|
||||
PopupMenuItem<String>(
|
||||
value: 'Toggle Theme',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? Icons.light_mode
|
||||
: Icons.dark_mode,
|
||||
),
|
||||
title: Text(
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? 'Switch to Light Mode'
|
||||
: 'Switch to Dark Mode',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Select Folder',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.folder_shared_outlined),
|
||||
title: Text('Set Notes Directory'),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Include Subdirectories',
|
||||
child: ListTile(
|
||||
leading: Icon(_includeSubdirectories ? Icons.file_copy_outlined : Icons.folder_copy_outlined),
|
||||
title: Text(_includeSubdirectories ? 'Exclude Subdirectories' : 'Search Subdirectories'),
|
||||
onTap: () {
|
||||
_toggleIncludeSubdirectories(!_includeSubdirectories);
|
||||
Navigator.pop(context); // Close menu after toggle
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Empty Trash',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete, color: Colors.redAccent),
|
||||
title: Text('Empty Trash',
|
||||
style: TextStyle(color: Colors.redAccent)),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Include File Content',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
_includeFileContent ? Icons.highlight_off : Icons.file_open_outlined,
|
||||
),
|
||||
title: Text(
|
||||
_includeFileContent ? 'Exclude File Content' : 'Search File Content',
|
||||
),
|
||||
onTap: () {
|
||||
_toggleIncludeFileContent(!_includeFileContent);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Help',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.help_outline),
|
||||
title: Text('Help'),
|
||||
PopupMenuItem<String>(
|
||||
value: 'External Editor',
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
_useExternalEditor
|
||||
? Icons.open_in_new_off
|
||||
: Icons.open_in_new,
|
||||
),
|
||||
title: Text(
|
||||
!_useExternalEditor
|
||||
? 'Use External Editor'
|
||||
: 'Use Internal Editor',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'About',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('About'),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Select Folder',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.folder_shared_outlined),
|
||||
title: Text('Set Notes Directory'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Empty Trash',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete, color: Colors.redAccent),
|
||||
title: Text('Empty Trash',
|
||||
style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
),
|
||||
PopupMenuDivider(),
|
||||
PopupMenuItem<String>(
|
||||
value: 'Help',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.help_outline),
|
||||
title: Text('Help'),
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'About',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.info_outline),
|
||||
title: Text('About'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -857,6 +941,7 @@ class _NoteListScreenState extends State<NoteListScreen> {
|
|||
note: noteFile,
|
||||
isDarkMode: widget.isDarkMode,
|
||||
notesDirectoryPath: notesDirectoryPath,
|
||||
searchTerms: _noteSearchTerms(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||
import 'themes//markdown.dart';
|
||||
import 'markdown_editing.dart';
|
||||
import 'markdown_view.dart';
|
||||
import 'themes/day.dart';
|
||||
import 'themes/night.dart';
|
||||
import 'codefield.dart'; // Ensure this includes WrappedCodeField
|
||||
|
||||
class ReadOnlyPage extends StatelessWidget {
|
||||
final String title;
|
||||
|
|
@ -18,7 +17,12 @@ class ReadOnlyPage extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = isDarkMode ? nightTheme : dayTheme;
|
||||
final view = MarkdownView(
|
||||
controller: MarkdownController(
|
||||
text: content,
|
||||
theme: isDarkMode ? nightTheme : dayTheme,
|
||||
),
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
|
|
@ -33,35 +37,11 @@ class ReadOnlyPage extends StatelessWidget {
|
|||
width: 200,
|
||||
)
|
||||
),
|
||||
CodeTheme(
|
||||
data: CodeThemeData(styles: theme),
|
||||
child: SingleChildScrollView(
|
||||
child: WrappedCodeField(
|
||||
controller: CodeController(
|
||||
text: content,
|
||||
language: markdown,
|
||||
),
|
||||
wrap: true,
|
||||
readOnly: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(child: view),
|
||||
]
|
||||
)
|
||||
: CodeTheme(
|
||||
data: CodeThemeData(styles: theme),
|
||||
child: SingleChildScrollView(
|
||||
child: WrappedCodeField(
|
||||
controller: CodeController(
|
||||
text: content,
|
||||
language: markdown,
|
||||
),
|
||||
wrap: true,
|
||||
readOnly: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
: view,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ String helpContent = """
|
|||
|
||||
Type to filter notes by title.
|
||||
Use #tags, +projects, or @contexts to find notes with specific tags.
|
||||
Start with a / to filter notes within /subdirectories.
|
||||
Start with a / to filter notes within /folders and /sub/folders.
|
||||
Mix text, tags, and folders (/work project #important).
|
||||
|
||||
# Create Notes
|
||||
|
||||
Type the title of a new note in the search bar and hit Enter.
|
||||
Use /folder/note title to create a note inside a subdirectory.
|
||||
Use /folder/note to create a note inside a subdirectory.
|
||||
Subfolders will be automatically created if they don’t exist.
|
||||
|
||||
# Delete Notes
|
||||
|
|
@ -32,17 +32,17 @@ Use tags anywhere in the notes and they will be detected.
|
|||
|
||||
# Context Menu
|
||||
|
||||
Tap the orange blaster at the top right to open the Preferences.
|
||||
Tap the orange blaster at the top right to open the Preference menu.
|
||||
|
||||
Here you find toggles to show/hide the tags below the note titles and switch between light and dark themes.
|
||||
|
||||
You can also exclude subdirectories from your search.
|
||||
|
||||
By default the search bar filters by note titles, folders, and tags, but you can also toggle including the file contents. Please be aware that this might slow down the app if you have many files.
|
||||
|
||||
If you prefer to use a different Markdown editor for your files you can toggle that and when you tap a file in the list you can choose what to open the file with.
|
||||
|
||||
By default the notes are saved in the App's own directory which is inaccessible to other apps. If you want to choose your own custom folder for this, you can set it. You will be asked for special permissions to do so. This will enable you to save the notes wherever you want, for example to sync them with a different app. You can also choose a folder that already has notes in it.
|
||||
- *Hide/Show Tags* below the note titles in the list.
|
||||
- *Switch to Light/Dark Mode* themes.
|
||||
- *Exclude/Search Subdirectories* to control the scope of your search.
|
||||
- *Search/Exclude File Content* in your search. Please be aware that this might slow down the app if you have a lot of files.
|
||||
- *Use External/Internal Editor* to enjoy your favorite note editing experience when tapping a note in the list.
|
||||
- *Set Note Directory* to your preferred folder anywhere on the device (with granted permission request) or the default one.
|
||||
- *Empty Trash* to purge the trash directory.
|
||||
- *Help* will show you this text.
|
||||
- *About* is for credits.
|
||||
|
||||
# File Organization
|
||||
|
||||
|
|
@ -67,12 +67,11 @@ Use commands by starting with a colon :
|
|||
""";
|
||||
|
||||
String aboutContent = """
|
||||
**Muzzle Velocity** is a fast and minimalist note-taking app inspired by *Notational Velocity* and *Terminal Velocity*.
|
||||
v.1.0.2
|
||||
|
||||
**Muzzle Velocity** is a fast and minimalist but opinionated note-taking app inspired by the legendary *Notational Velocity* on MacOS and *Terminal Velocity* for command line terminals.
|
||||
|
||||
It is built for speed and efficiency, eliminating unnecessary UI clutter. Every action should be as fast and frictionless as possible.
|
||||
|
||||
Made with 🧡 by randogoth
|
||||
|
||||
with the invaluable help of
|
||||
OpenAI's 🤖 ChatGPT
|
||||
""";
|
||||
|
|
@ -19,37 +19,64 @@ const TextTheme dayText = TextTheme(
|
|||
);
|
||||
|
||||
const dayTheme = {
|
||||
'root':
|
||||
TextStyle(backgroundColor: Color(0xffF6F4F1), color: Color(0xff333333)),
|
||||
'comment': TextStyle(color: Color(0xff969896)),
|
||||
'meta': TextStyle(color: Color(0xff969896)),
|
||||
'variable': TextStyle(color: Color(0xffdf5000)),
|
||||
'template-variable': TextStyle(color: Color(0xffdf5000)),
|
||||
'strong': TextStyle(color: Color(0xffdf5000)),
|
||||
'emphasis': TextStyle(color: Color(0xffdf5000)),
|
||||
'quote': TextStyle(color: Color(0xffdf5000)),
|
||||
'keyword': TextStyle(color: Color(0xffd73a49)),
|
||||
'selector-tag': TextStyle(color: Color(0xffd73a49)),
|
||||
'type': TextStyle(color: Color(0xffd73a49)),
|
||||
'literal': TextStyle(color: Color(0xff0086b3)),
|
||||
'symbol': TextStyle(color: Color(0xff0086b3)),
|
||||
'bullet': TextStyle(color: Color(0xff0086b3)),
|
||||
'attribute': TextStyle(color: Color(0xff0086b3)),
|
||||
'section': TextStyle(color: Color(0xff63a35c)),
|
||||
'name': TextStyle(color: Color(0xff63a35c)),
|
||||
'tag': TextStyle(color: Color(0xff333333)),
|
||||
'title': TextStyle(color: Color(0xff6f42c1)),
|
||||
'attr': TextStyle(color: Color(0xff6f42c1)),
|
||||
'selector-id': TextStyle(color: Color(0xff6f42c1)),
|
||||
'selector-class': TextStyle(color: Color(0xff6f42c1)),
|
||||
'selector-attr': TextStyle(color: Color(0xff6f42c1)),
|
||||
'selector-pseudo': TextStyle(color: Color(0xff6f42c1)),
|
||||
'addition':
|
||||
TextStyle(color: Color(0xff55a532), backgroundColor: Color(0xffeaffea)),
|
||||
'deletion':
|
||||
TextStyle(color: Color(0xffbd2c00), backgroundColor: Color(0xffffecec)),
|
||||
'number': TextStyle(color: Color(0xff005cc5)),
|
||||
'string': TextStyle(color: Color(0xff032f62)),
|
||||
// General text
|
||||
'root': TextStyle(backgroundColor: Color(0xffF6F4F1), color: Color(0xff333333)),
|
||||
|
||||
// Headers
|
||||
'header-h1': TextStyle(color: Color(0xff63a35c)), // Green for H1
|
||||
'header-h2': TextStyle(color: Color(0xff63a35c), fontWeight: FontWeight.w600, fontSize: 22), // Green for H2
|
||||
'header-h3': TextStyle(color: Color(0xff63a35c), fontWeight: FontWeight.w600, fontSize: 20), // Green for H3
|
||||
'header-h4': TextStyle(color: Color(0xff63a35c), fontWeight: FontWeight.w600, fontSize: 18), // Green for H4
|
||||
'header-h5': TextStyle(color: Color(0xff63a35c), fontWeight: FontWeight.w600, fontSize: 16), // Green for H5
|
||||
'header-h6': TextStyle(color: Color(0xff63a35c), fontWeight: FontWeight.w600, fontSize: 14), // Green for H6
|
||||
'header-underline': TextStyle(color: Color(0xff63a35c)), // Green for underlined headers
|
||||
|
||||
// Lists
|
||||
'list': TextStyle(color: Color(0xff0086b3)), // Blue for lists
|
||||
|
||||
// Bold text
|
||||
'bold': TextStyle(fontWeight: FontWeight.bold, color: Color(0xffdf5000)), // Orange for bold text
|
||||
|
||||
// Italic text
|
||||
'italic': TextStyle(fontStyle: FontStyle.italic, color: Color(0xffdf5000)), // Orange for italic text
|
||||
|
||||
// Blockquotes
|
||||
'blockquote': TextStyle(color: Color(0xffdf5000)), // Orange for blockquotes
|
||||
|
||||
// Code blocks and inline code
|
||||
'code': TextStyle(color: Color(0xff005cc5), backgroundColor: Color(0xffeaffea)), // Blue for code
|
||||
|
||||
// Horizontal rules
|
||||
'horizontal-rule': TextStyle(color: Color(0xffbd2c00)), // Red for horizontal rules
|
||||
|
||||
// Horizontal rules
|
||||
'command': TextStyle(fontStyle: FontStyle.italic, color: Color(0xffbd2c00)), // Red for horizontal rules
|
||||
|
||||
// Links and images
|
||||
'link-text': TextStyle(color: Color(0xff0086b3)), // Blue for link text
|
||||
'link-url': TextStyle(color: Color(0xffd73a49)), // Red for link URLs
|
||||
'link-reference': TextStyle(color: Color(0xff0086b3)), // Blue for link references
|
||||
|
||||
// Strikethrough text
|
||||
'strikethrough': TextStyle(color: Color(0xffbd2c00), decoration: TextDecoration.lineThrough), // Red for strikethrough
|
||||
|
||||
// Tables
|
||||
'table': TextStyle(color: Color(0xff333333)), // Black for tables
|
||||
'table-header': TextStyle(color: Color(0xff6f42c1), fontWeight: FontWeight.w600), // Purple for table headers
|
||||
'table-row': TextStyle(color: Color(0xff333333)), // Black for table rows
|
||||
'table-cell': TextStyle(color: Color(0xff333333)), // Black for table cells
|
||||
|
||||
// Task lists
|
||||
'task-list': TextStyle(color: Color(0xff0086b3)), // Blue for task lists
|
||||
|
||||
// Emphasis and strong
|
||||
'emphasis': TextStyle(fontStyle: FontStyle.italic, color: Color(0xffdf5000)), // Orange for emphasis
|
||||
'strong': TextStyle(fontWeight: FontWeight.bold, color: Color(0xffdf5000)), // Orange for strong
|
||||
|
||||
// Comments and quotes
|
||||
'comment': TextStyle(color: Color(0xff969896)), // Gray for comments
|
||||
'quote': TextStyle(color: Color(0xffdf5000)), // Orange for quotes
|
||||
|
||||
'tag-hash': TextStyle(
|
||||
color: Colors.pinkAccent,
|
||||
fontStyle: FontStyle.italic,
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
import 'package:highlight/highlight.dart';
|
||||
|
||||
final markdown = Mode(refs: {}, aliases: [
|
||||
"md",
|
||||
"mkdown",
|
||||
"mkd"
|
||||
], contains: [
|
||||
Mode(className: "section", variants: [
|
||||
Mode(begin: "^#\\s{1,6}", end: "\$"),
|
||||
Mode(begin: "^.+?\\n[=-]{2,}\$")
|
||||
]),
|
||||
Mode(begin: "<", end: ">", subLanguage: ["xml"], relevance: 0),
|
||||
Mode(className: "bullet", begin: "^\\s*([*+-]|(\\d+\\.))\\s+"),
|
||||
Mode(className: "strong", begin: "[*_]{2}.+?[*_]{2}"),
|
||||
Mode(
|
||||
className: "emphasis",
|
||||
variants: [Mode(begin: "\\*.+?\\*"), Mode(begin: "_.+?_", relevance: 0)]),
|
||||
Mode(className: "quote", begin: "^>\\s+", end: "\$"),
|
||||
Mode(className: "code", variants: [
|
||||
Mode(begin: "^```\\w*\\s*\$", end: "^```[ ]*\$"),
|
||||
Mode(begin: "`.+?`"),
|
||||
Mode(begin: "^( {4}|\\t)", end: "\$", relevance: 0)
|
||||
]),
|
||||
Mode(begin: "^[-\\*]{3,}", end: "\$"),
|
||||
Mode(
|
||||
begin: "\\[.+?\\][\\(\\[].*?[\\)\\]]",
|
||||
returnBegin: true,
|
||||
contains: [
|
||||
Mode(
|
||||
className: "string",
|
||||
begin: "\\[",
|
||||
end: "\\]",
|
||||
excludeBegin: true,
|
||||
returnEnd: true,
|
||||
relevance: 0),
|
||||
Mode(
|
||||
className: "link",
|
||||
begin: "\\]\\(",
|
||||
end: "\\)",
|
||||
excludeBegin: true,
|
||||
excludeEnd: true),
|
||||
Mode(
|
||||
className: "symbol",
|
||||
begin: "\\]\\[",
|
||||
end: "\\]",
|
||||
excludeBegin: true,
|
||||
excludeEnd: true)
|
||||
],
|
||||
relevance: 10),
|
||||
Mode(begin: "^\\[[^\\n]+\\]:", returnBegin: true, contains: [
|
||||
Mode(
|
||||
className: "symbol",
|
||||
begin: "\\[",
|
||||
end: "\\]",
|
||||
excludeBegin: true,
|
||||
excludeEnd: true),
|
||||
Mode(className: "link", begin: ":\\s*", end: "\$", excludeBegin: true)
|
||||
]),
|
||||
|
||||
// Custom Tag Highlighting
|
||||
Mode(
|
||||
className: "tag-hash",
|
||||
begin: "(?<=\\s|^)#[a-zA-Z0-9_]+",
|
||||
relevance: 10), // Magenta
|
||||
Mode(
|
||||
className: "tag-plus",
|
||||
begin: "(?<=\\s|^)\\+[a-zA-Z0-9_]+",
|
||||
relevance: 10), // Cyan
|
||||
Mode(
|
||||
className: "tag-at",
|
||||
begin: "(?<=\\s|^)@[a-zA-Z0-9_]+",
|
||||
relevance: 10), // Yellow
|
||||
Mode(
|
||||
className: "keyword",
|
||||
begin: "(?<=\\s|^):[a-zA-Z0-9_]+",
|
||||
relevance: 10),
|
||||
Mode(
|
||||
className: "variable",
|
||||
begin: "(?<=\\s|^)\/[a-zA-Z0-9_\/]+",
|
||||
relevance: 10), // Yellow
|
||||
]);
|
||||
|
|
@ -19,37 +19,64 @@ const TextTheme nightText = TextTheme(
|
|||
);
|
||||
|
||||
const nightTheme = {
|
||||
'comment': TextStyle(color: Color(0xff969896)),
|
||||
'quote': TextStyle(color: Color(0xff969896)),
|
||||
'variable': TextStyle(color: Color(0xffcc6666)),
|
||||
'template-variable': TextStyle(color: Color(0xffcc6666)),
|
||||
'tag': TextStyle(color: Color(0xffcc6666)),
|
||||
'name': TextStyle(color: Color(0xffcc6666)),
|
||||
'selector-id': TextStyle(color: Color(0xffcc6666)),
|
||||
'selector-class': TextStyle(color: Color(0xffcc6666)),
|
||||
'regexp': TextStyle(color: Color(0xffcc6666)),
|
||||
'deletion': TextStyle(color: Color(0xffcc6666)),
|
||||
'number': TextStyle(color: Color(0xffde935f)),
|
||||
'built_in': TextStyle(color: Color(0xffde935f)),
|
||||
'builtin-name': TextStyle(color: Color(0xffde935f)),
|
||||
'literal': TextStyle(color: Color(0xffde935f)),
|
||||
'type': TextStyle(color: Color(0xffde935f)),
|
||||
'params': TextStyle(color: Color(0xffde935f)),
|
||||
'meta': TextStyle(color: Color(0xffde935f)),
|
||||
'link': TextStyle(color: Color(0xffde935f)),
|
||||
'attribute': TextStyle(color: Color(0xfff0c674)),
|
||||
'string': TextStyle(color: Color(0xffb5bd68)),
|
||||
'symbol': TextStyle(color: Color(0xffb5bd68)),
|
||||
'bullet': TextStyle(color: Color(0xffb5bd68)),
|
||||
'addition': TextStyle(color: Color(0xffb5bd68)),
|
||||
'title': TextStyle(color: Color(0xff81a2be)),
|
||||
'section': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600, fontSize: 18),
|
||||
'keyword': TextStyle(color: Color(0xffb294bb)),
|
||||
'selector-tag': TextStyle(color: Color(0xffb294bb)),
|
||||
'root':
|
||||
TextStyle(backgroundColor: Color(0xFF121212), color: Color(0xffc5c8c6)),
|
||||
'emphasis': TextStyle(fontStyle: FontStyle.italic),
|
||||
'strong': TextStyle(fontWeight: FontWeight.bold),
|
||||
// General text
|
||||
'root': TextStyle(backgroundColor: Color(0xFF121212), color: Color(0xffc5c8c6)),
|
||||
|
||||
// Headers
|
||||
'header-h1': TextStyle(color: Color(0xff81a2be)), // Blue for H1
|
||||
'header-h2': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600, fontSize: 22), // Blue for H2
|
||||
'header-h3': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600, fontSize: 20), // Blue for H3
|
||||
'header-h4': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600, fontSize: 18), // Blue for H4
|
||||
'header-h5': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600, fontSize: 16), // Blue for H5
|
||||
'header-h6': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600, fontSize: 14), // Blue for H6
|
||||
'header-underline': TextStyle(color: Color(0xff81a2be)), // Blue for underlined headers
|
||||
|
||||
// Lists
|
||||
'list': TextStyle(color: Color(0xffb5bd68)), // Green for lists
|
||||
|
||||
// Bold text
|
||||
'bold': TextStyle(fontWeight: FontWeight.bold, color: Color(0xffb5bd68)), // White for bold text
|
||||
|
||||
// Italic text
|
||||
'italic': TextStyle(fontStyle: FontStyle.italic, color: Color(0xffb5bd68)), // White for italic text
|
||||
|
||||
// Blockquotes
|
||||
'blockquote': TextStyle(color: Color(0xff969896)), // Gray for blockquotes
|
||||
|
||||
// Code blocks and inline code
|
||||
'code': TextStyle(color: Color(0xffde935f), backgroundColor: Color(0xff1d1f21)), // Orange for code
|
||||
|
||||
// Horizontal rules
|
||||
'horizontal-rule': TextStyle(color: Color(0xffcc6666)), // Red for horizontal rules
|
||||
|
||||
// Horizontal rules
|
||||
'command': TextStyle(fontStyle: FontStyle.italic, color: Color(0xffcc6666)), // Red for horizontal rules
|
||||
|
||||
// Links and images
|
||||
'link-text': TextStyle(color: Color(0xff81a2be)), // Blue for link text
|
||||
'link-url': TextStyle(color: Color(0xffde935f)), // Orange for link URLs
|
||||
'link-reference': TextStyle(color: Color(0xffb5bd68)), // Green for link references
|
||||
|
||||
// Strikethrough text
|
||||
'strikethrough': TextStyle(color: Color(0xffcc6666), decoration: TextDecoration.lineThrough), // Red for strikethrough
|
||||
|
||||
// Tables
|
||||
'table': TextStyle(color: Color(0xffc5c8c6)), // White for tables
|
||||
'table-header': TextStyle(color: Color(0xff81a2be), fontWeight: FontWeight.w600), // Blue for table headers
|
||||
'table-row': TextStyle(color: Color(0xffc5c8c6)), // White for table rows
|
||||
'table-cell': TextStyle(color: Color(0xffc5c8c6)), // White for table cells
|
||||
|
||||
// Task lists
|
||||
'task-list': TextStyle(color: Color(0xffb5bd68)), // Green for task lists
|
||||
|
||||
// Emphasis and strong
|
||||
'emphasis': TextStyle(fontStyle: FontStyle.italic, color: Color(0xffc5c8c6)), // White for emphasis
|
||||
'strong': TextStyle(fontWeight: FontWeight.bold, color: Color(0xffc5c8c6)), // White for strong
|
||||
|
||||
// Comments and quotes
|
||||
'comment': TextStyle(color: Color(0xff969896)), // Gray for comments
|
||||
'quote': TextStyle(color: Color(0xff969896)), // Gray for quotes
|
||||
|
||||
'tag-hash': TextStyle(
|
||||
color: Colors.pinkAccent,
|
||||
fontStyle: FontStyle.italic,
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import Foundation
|
|||
|
||||
import file_picker
|
||||
import path_provider_foundation
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
}
|
||||
|
|
|
|||
222
pubspec.lock
222
pubspec.lock
|
|
@ -25,14 +25,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.0"
|
||||
autotrie:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: autotrie
|
||||
sha256: "55da6faefb53cfcb0abb2f2ca8636123fb40e35286bb57440d2cf467568188f8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -45,18 +37,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: charcode
|
||||
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -77,18 +61,18 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.0"
|
||||
version: "1.19.1"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -113,22 +97,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
equatable:
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.7"
|
||||
version: "0.7.12"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -149,31 +133,23 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: "6f6bfa8797f296965bdc3e1f702574ab49a540c19b9237b401e7c2b25dfe594c"
|
||||
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.0"
|
||||
version: "10.3.10"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_code_editor:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_code_editor
|
||||
sha256: "505ad56dcc8a7be4b782c8113574571bc4b9723499b0c1f385b3e2c3fae11f5d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.2"
|
||||
flutter_highlight:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_highlight
|
||||
sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -210,10 +186,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: flutter_math_fork
|
||||
sha256: "284bab89b2fbf1bc3a0baf13d011c1dd324d004e35d177626b77f2fc056366ac"
|
||||
sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.3"
|
||||
version: "0.7.4"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -244,26 +220,10 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82
|
||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.1"
|
||||
highlight:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: highlight
|
||||
sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
hive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hive
|
||||
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
version: "6.3.3"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -300,34 +260,26 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.7"
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.8"
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
linked_scroll_controller:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: linked_scroll_controller
|
||||
sha256: e6020062bcf4ffc907ee7fd090fa971e65d8dfaac3c62baf601a3ced0b37986a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -348,34 +300,34 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.16+1"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.0"
|
||||
mocktail:
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mocktail
|
||||
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
version: "2.0.0"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -396,10 +348,10 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: path
|
||||
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.0"
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -544,14 +496,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.2"
|
||||
scrollable_positioned_list:
|
||||
dependency: transitive
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: scrollable_positioned_list
|
||||
sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287"
|
||||
name: share_plus
|
||||
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.8"
|
||||
version: "10.1.4"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -621,22 +581,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.0"
|
||||
sprintf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sprintf
|
||||
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.0"
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -657,10 +625,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.3"
|
||||
version: "0.7.10"
|
||||
tuple:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -677,30 +645,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.1"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.14"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "16a513b6c12bb419304e72ea0ae2ab4fed569920d1c7cb850263fe3acc824626"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -709,14 +653,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -741,6 +677,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.1"
|
||||
vector_graphics:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -769,10 +713,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -822,5 +766,5 @@ packages:
|
|||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.6.0 <4.0.0"
|
||||
flutter: ">=3.27.0"
|
||||
dart: ">=3.9.0 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ dependencies:
|
|||
flutter:
|
||||
sdk: flutter
|
||||
cupertino_icons: ^1.0.8
|
||||
file_picker: ^9.0.0
|
||||
flutter_highlight: ^0.7.0
|
||||
flutter_code_editor: ^0.3.2
|
||||
file_picker: ^10.3.10
|
||||
flutter_markdown: ^0.7.6+2
|
||||
flutter_markdown_latex: ^0.3.4
|
||||
google_fonts: ^6.2.1
|
||||
|
|
@ -21,10 +19,10 @@ dependencies:
|
|||
path_provider: ^2.1.5
|
||||
permission_handler: ^11.4.0
|
||||
shared_preferences: ^2.5.2
|
||||
highlight: ^0.7.0
|
||||
open_filex: ^4.6.0
|
||||
flutter_svg: ^2.0.17
|
||||
flutter_launcher_icons: ^0.14.3
|
||||
share_plus: ^10.1.4
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
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 {}
|
||||
|
|
@ -7,11 +7,14 @@
|
|||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
permission_handler_windows
|
||||
share_plus
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue