feat: add built-in markdown editor with search jump and typing helpers

This commit is contained in:
randogoth 2026-10-13 10:47:34 +03:00
parent db27f64e42
commit ed2de5e67f
16 changed files with 1146 additions and 561 deletions

View file

@ -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(),
),
),
);
}