From ed2de5e67fa408f76cbc781cd136669ad02f92ac Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 13 Oct 2026 10:47:34 +0300 Subject: [PATCH] feat: add built-in markdown editor with search jump and typing helpers --- lib/codefield.dart | 72 ---- lib/markdown_editing.dart | 377 ++++++++++++++++++ lib/markdown_view.dart | 81 ++++ lib/note_editor.dart | 111 +++++- lib/note_list.dart | 353 +++++++++------- lib/read_only.dart | 42 +- lib/texts.dart | 31 +- lib/themes/day.dart | 89 +++-- lib/themes/markdown.dart | 81 ---- lib/themes/night.dart | 89 +++-- macos/Flutter/GeneratedPluginRegistrant.swift | 4 +- pubspec.lock | 222 ++++------- pubspec.yaml | 6 +- test/markdown_editing_test.dart | 145 +++++++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 16 files changed, 1146 insertions(+), 561 deletions(-) delete mode 100644 lib/codefield.dart create mode 100644 lib/markdown_editing.dart create mode 100644 lib/markdown_view.dart delete mode 100644 lib/themes/markdown.dart create mode 100644 test/markdown_editing_test.dart diff --git a/lib/codefield.dart b/lib/codefield.dart deleted file mode 100644 index b42b26b..0000000 --- a/lib/codefield.dart +++ /dev/null @@ -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 { - 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, - ), - ); - } -} diff --git a/lib/markdown_editing.dart b/lib/markdown_editing.dart new file mode 100644 index 0000000..c310edc --- /dev/null +++ b/lib/markdown_editing.dart @@ -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'(?`[^`\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(); + } + + final 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), + ), + ); +} diff --git a/lib/markdown_view.dart b/lib/markdown_view.dart new file mode 100644 index 0000000..967c16b --- /dev/null +++ b/lib/markdown_view.dart @@ -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 { + 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), + ), + ); + } +} diff --git a/lib/note_editor.dart b/lib/note_editor.dart index e9e4a46..7d3d2a2 100644 --- a/lib/note_editor.dart +++ b/lib/note_editor.dart @@ -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 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 { - late CodeController _controller; + late MarkdownController _controller; late String _initialContent; + final _viewKey = GlobalKey(); + 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 { } 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 { } } + void _shareNote() { + if (_controller.text.isNotEmpty) { + Share.share(_controller.text, subject: widget.note.uri.pathSegments.last); + } + } + Future _onWillPop() async { if (_controller.text == _initialContent) return true; // No changes, exit @@ -132,6 +165,7 @@ class _NoteEditorScreenState extends State { void dispose() { _controller.removeListener(_onTextChanged); _controller.dispose(); + _editorFocus.dispose(); super.dispose(); } @@ -151,6 +185,11 @@ class _NoteEditorScreenState extends State { }, ), actions: [ + IconButton( + icon: Icon( + _isEditing ? Icons.edit_off_outlined : Icons.edit_outlined), + onPressed: _isLoading ? null : _toggleEditing, + ), Padding( padding: EdgeInsets.symmetric(horizontal: 15), child: PopupMenuButton( @@ -161,6 +200,9 @@ class _NoteEditorScreenState extends State { ), 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 { } }, itemBuilder: (BuildContext context) => [ + PopupMenuItem( // ✅ Share button + value: 'Share', + child: ListTile( + leading: Icon(Icons.share_outlined), + title: Text("Share"), + ), + ), + PopupMenuDivider(), PopupMenuItem( value: 'Save', child: ListTile( @@ -193,6 +243,7 @@ class _NoteEditorScreenState extends State { : "Enable Autosave"), ), ), + PopupMenuDivider(), PopupMenuItem( enabled: false, child: Column( @@ -219,6 +270,7 @@ class _NoteEditorScreenState extends State { ], ), ), + PopupMenuDivider(), PopupMenuItem( value: 'Toggle Preview', child: ListTile( @@ -288,20 +340,39 @@ class _NoteEditorScreenState extends State { ) : 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(), + ), ), ); } diff --git a/lib/note_list.dart b/lib/note_list.dart index 1ead6b8..0546b00 100644 --- a/lib/note_list.dart +++ b/lib/note_list.dart @@ -32,6 +32,7 @@ class NoteListScreen extends StatefulWidget { class _NoteListScreenState extends State { TextEditingController searchController = TextEditingController(); + final FocusNode searchFocusNode = FocusNode(); List notes = []; List filteredNotes = []; List _visibleNotes = []; @@ -52,6 +53,9 @@ class _NoteListScreenState extends State { searchController.addListener(() { _debouncedFilterNotes(); }); + WidgetsBinding.instance.addPostFrameCallback((_) { + FocusScope.of(context).requestFocus(searchFocusNode); + }); } void _debouncedFilterNotes() { @@ -116,12 +120,46 @@ class _NoteListScreenState extends State { 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 _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 { 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 { folderTimestamps[folder] = entry.lastModifiedSync(); } - final content = entry.readAsStringSync(); extractedTags[entry] = _extractTags(content); } } @@ -268,6 +312,11 @@ class _NoteListScreenState extends State { 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 { }); } + /// Search bar terms to locate inside an opened note, split like [_filterNotes]. + List _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 { 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 { 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 { 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 { 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 { 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 { actions: [ Padding( padding: EdgeInsets.symmetric(horizontal: 15), - child: PopupMenuButton( - 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( - value: 'Toggle Tags', - child: ListTile( - leading: - Icon(widget.showTags ? Icons.short_text : Icons.tag), - title: Text(widget.showTags ? 'Hide Tags' : 'Show Tags'), - ), + child: PopupMenuButton( + enabled: searchController.text.isEmpty, + icon: SvgPicture.asset( + "assets/blaster.svg", + width: 30, + color: Colors.orange, ), - PopupMenuItem( - 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( + value: 'Toggle Tags', + child: ListTile( + leading: + Icon(widget.showTags ? Icons.short_text : Icons.tag), + title: Text(widget.showTags ? 'Hide Tags' : 'Show Tags'), ), ), - ), - PopupMenuItem( - 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( - 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( - 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( + 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( - value: 'Select Folder', - child: ListTile( - leading: Icon(Icons.folder_shared_outlined), - title: Text('Set Notes Directory'), + PopupMenuItem( + 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( - value: 'Empty Trash', - child: ListTile( - leading: Icon(Icons.delete, color: Colors.redAccent), - title: Text('Empty Trash', - style: TextStyle(color: Colors.redAccent)), + PopupMenuItem( + 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( - value: 'Help', - child: ListTile( - leading: Icon(Icons.help_outline), - title: Text('Help'), + PopupMenuItem( + 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( - value: 'About', - child: ListTile( - leading: Icon(Icons.info_outline), - title: Text('About'), + PopupMenuItem( + value: 'Select Folder', + child: ListTile( + leading: Icon(Icons.folder_shared_outlined), + title: Text('Set Notes Directory'), + ), ), - ), - ], + PopupMenuDivider(), + PopupMenuItem( + value: 'Empty Trash', + child: ListTile( + leading: Icon(Icons.delete, color: Colors.redAccent), + title: Text('Empty Trash', + style: TextStyle(color: Colors.redAccent)), + ), + ), + PopupMenuDivider(), + PopupMenuItem( + value: 'Help', + child: ListTile( + leading: Icon(Icons.help_outline), + title: Text('Help'), + ), + ), + PopupMenuItem( + value: 'About', + child: ListTile( + leading: Icon(Icons.info_outline), + title: Text('About'), + ), + ), + ], + ), ), ), ], @@ -857,6 +941,7 @@ class _NoteListScreenState extends State { note: noteFile, isDarkMode: widget.isDarkMode, notesDirectoryPath: notesDirectoryPath, + searchTerms: _noteSearchTerms(), ), ), ); diff --git a/lib/read_only.dart b/lib/read_only.dart index 621f9cd..e318975 100644 --- a/lib/read_only.dart +++ b/lib/read_only.dart @@ -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, ), ); } -} \ No newline at end of file +} diff --git a/lib/texts.dart b/lib/texts.dart index 15b488d..ed65a52 100644 --- a/lib/texts.dart +++ b/lib/texts.dart @@ -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 """; \ No newline at end of file diff --git a/lib/themes/day.dart b/lib/themes/day.dart index da27690..0c347db 100644 --- a/lib/themes/day.dart +++ b/lib/themes/day.dart @@ -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, diff --git a/lib/themes/markdown.dart b/lib/themes/markdown.dart deleted file mode 100644 index e6efe51..0000000 --- a/lib/themes/markdown.dart +++ /dev/null @@ -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 -]); diff --git a/lib/themes/night.dart b/lib/themes/night.dart index e40f87b..d330b43 100644 --- a/lib/themes/night.dart +++ b/lib/themes/night.dart @@ -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, diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index b832e00..f12d843 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) } diff --git a/pubspec.lock b/pubspec.lock index fd9abfb..a329bcb 100644 --- a/pubspec.lock +++ b/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" diff --git a/pubspec.yaml b/pubspec.yaml index 05575a3..b891c66 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/markdown_editing_test.dart b/test/markdown_editing_test.dart new file mode 100644 index 0000000..05b2e6a --- /dev/null +++ b/test/markdown_editing_test.dart @@ -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 _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 {} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index a0d0bbe..d5013ba 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index c20a586..a0d1388 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST permission_handler_windows + share_plus url_launcher_windows )