feat: add keyboard shortcuts for notes, the note list and the app
This commit is contained in:
parent
ed2de5e67f
commit
722390ecec
9 changed files with 532 additions and 60 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_markdown_latex/flutter_markdown_latex.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
|
|
@ -10,18 +11,19 @@ import 'themes/day.dart';
|
|||
import 'themes/night.dart';
|
||||
import 'markdown_editing.dart';
|
||||
import 'markdown_view.dart';
|
||||
import 'shortcuts.dart';
|
||||
|
||||
class NoteEditorScreen extends StatefulWidget {
|
||||
final File note;
|
||||
final String? notesDirectoryPath; // ✅ Add this
|
||||
final bool isDarkMode;
|
||||
final List<String> searchTerms;
|
||||
final Future<void> Function() onQuit;
|
||||
|
||||
NoteEditorScreen(
|
||||
{required this.note,
|
||||
required this.isDarkMode,
|
||||
required this.notesDirectoryPath,
|
||||
this.searchTerms = const []});
|
||||
this.searchTerms = const [],
|
||||
this.onQuit = quitApp});
|
||||
|
||||
@override
|
||||
_NoteEditorScreenState createState() => _NoteEditorScreenState();
|
||||
|
|
@ -32,6 +34,9 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
late String _initialContent;
|
||||
final _viewKey = GlobalKey<MarkdownViewState>();
|
||||
final _editorFocus = FocusNode();
|
||||
final _screenFocus = FocusNode();
|
||||
final _sourceScroll = ScrollController();
|
||||
final _previewScroll = ScrollController();
|
||||
bool _isEditing = false;
|
||||
bool _isPreviewMode = false;
|
||||
bool _isLoading = true;
|
||||
|
|
@ -71,7 +76,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
setState(() {
|
||||
_controller = MarkdownController(
|
||||
text: _initialContent,
|
||||
theme: widget.isDarkMode ? nightTheme : dayTheme,
|
||||
theme: _styles,
|
||||
)..search(widget.searchTerms);
|
||||
_controller.addListener(_onTextChanged);
|
||||
_isLoading = false;
|
||||
|
|
@ -93,13 +98,112 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
_isPreviewMode = false;
|
||||
});
|
||||
if (_isEditing) {
|
||||
// Without a caret, focusing would put it at the end and scroll there.
|
||||
if (!_controller.selection.isValid) {
|
||||
final offset = _viewKey.currentState?.firstVisibleOffset() ?? 0;
|
||||
_controller.selection = TextSelection.collapsed(offset: offset);
|
||||
}
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => _editorFocus.requestFocus());
|
||||
} else {
|
||||
_editorFocus.unfocus();
|
||||
// Keep focus inside the screen so its shortcuts stay active.
|
||||
_screenFocus.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
void _togglePreview() {
|
||||
setState(() {
|
||||
_isPreviewMode = !_isPreviewMode;
|
||||
});
|
||||
if (_isPreviewMode) _screenFocus.requestFocus();
|
||||
}
|
||||
|
||||
void _stepFontSize(double step) {
|
||||
_updateFontSize((_fontSize + step).clamp(12.0, 24.0));
|
||||
}
|
||||
|
||||
void _toggleEmphasis(String marker) {
|
||||
if (_isEditing && !_isPreviewMode) {
|
||||
_controller.value = toggleEmphasis(_controller.value, marker);
|
||||
}
|
||||
}
|
||||
|
||||
ScrollController get _activeScroll =>
|
||||
_isPreviewMode ? _previewScroll : _sourceScroll;
|
||||
|
||||
void _scroll(double lines) => _scrollBy(lines * _fontSize * 1.5);
|
||||
|
||||
void _scrollPage(int direction) {
|
||||
if (!_activeScroll.hasClients) return;
|
||||
// Keep a little of the previous page in view for context.
|
||||
_scrollBy(direction * _activeScroll.position.viewportDimension * 0.9);
|
||||
}
|
||||
|
||||
void _scrollBy(double pixels) {
|
||||
if (!_activeScroll.hasClients) return;
|
||||
final position = _activeScroll.position;
|
||||
_activeScroll.jumpTo((position.pixels + pixels)
|
||||
.clamp(position.minScrollExtent, position.maxScrollExtent));
|
||||
}
|
||||
|
||||
bool get _isDark => Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
Map<String, TextStyle> get _styles => _isDark ? nightTheme : dayTheme;
|
||||
|
||||
Future<void> _backToList() async {
|
||||
if (await _onWillPop() && mounted) Navigator.pop(context);
|
||||
}
|
||||
|
||||
Future<void> _quit() async {
|
||||
if (await _onWillPop()) await widget.onQuit();
|
||||
}
|
||||
|
||||
Future<void> _escape() async {
|
||||
if (_isPreviewMode) {
|
||||
_togglePreview();
|
||||
} else if (_isEditing) {
|
||||
_toggleEditing();
|
||||
} else {
|
||||
await _backToList();
|
||||
}
|
||||
}
|
||||
|
||||
Map<ShortcutActivator, VoidCallback> get _shortcuts {
|
||||
const primary = primaryShortcut;
|
||||
void previous() => _showMatch(_controller.currentMatch - 1);
|
||||
void next() => _showMatch(_controller.currentMatch + 1);
|
||||
|
||||
return {
|
||||
quitShortcut: _quit,
|
||||
primary(LogicalKeyboardKey.keyL): _backToList,
|
||||
primary(LogicalKeyboardKey.keyE): _toggleEditing,
|
||||
primary(LogicalKeyboardKey.keyS): _saveNote,
|
||||
primary(LogicalKeyboardKey.keyP): _togglePreview,
|
||||
const SingleActivator(LogicalKeyboardKey.escape): _escape,
|
||||
primary(LogicalKeyboardKey.keyB): () => _toggleEmphasis('**'),
|
||||
primary(LogicalKeyboardKey.keyI): () => _toggleEmphasis('*'),
|
||||
const SingleActivator(LogicalKeyboardKey.f3): next,
|
||||
const SingleActivator(LogicalKeyboardKey.f3, shift: true): previous,
|
||||
primary(LogicalKeyboardKey.keyG): next,
|
||||
primary(LogicalKeyboardKey.keyG, shift: true): previous,
|
||||
primary(LogicalKeyboardKey.equal): () => _stepFontSize(2),
|
||||
primary(LogicalKeyboardKey.equal, shift: true): () => _stepFontSize(2),
|
||||
primary(LogicalKeyboardKey.numpadAdd): () => _stepFontSize(2),
|
||||
primary(LogicalKeyboardKey.minus): () => _stepFontSize(-2),
|
||||
primary(LogicalKeyboardKey.numpadSubtract): () => _stepFontSize(-2),
|
||||
primary(LogicalKeyboardKey.digit0): () => _updateFontSize(16),
|
||||
// In edit mode the arrows move the caret instead.
|
||||
if (_isPreviewMode || !_isEditing) ...{
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown): () => _scroll(3),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp): () => _scroll(-3),
|
||||
const SingleActivator(LogicalKeyboardKey.pageDown): () =>
|
||||
_scrollPage(1),
|
||||
const SingleActivator(LogicalKeyboardKey.pageUp): () =>
|
||||
_scrollPage(-1),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
if (_autoSaveEnabled && _controller.text != _initialContent) {
|
||||
_saveNote();
|
||||
|
|
@ -166,14 +270,24 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
_controller.removeListener(_onTextChanged);
|
||||
_controller.dispose();
|
||||
_editorFocus.dispose();
|
||||
_screenFocus.dispose();
|
||||
_sourceScroll.dispose();
|
||||
_previewScroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Follow theme switches made while the note is open.
|
||||
if (!_isLoading) _controller.theme = _styles;
|
||||
return WillPopScope(
|
||||
onWillPop: _onWillPop,
|
||||
child: Scaffold(
|
||||
child: CallbackShortcuts(
|
||||
bindings: _isLoading ? const {} : _shortcuts,
|
||||
child: Focus(
|
||||
focusNode: _screenFocus,
|
||||
autofocus: true,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.note.uri.pathSegments.last.replaceAll('.md', '')),
|
||||
leading: IconButton(
|
||||
|
|
@ -210,9 +324,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
_setAutoSaveEnabled(!_autoSaveEnabled);
|
||||
break;
|
||||
case 'Toggle Preview':
|
||||
setState(() {
|
||||
_isPreviewMode = !_isPreviewMode;
|
||||
});
|
||||
_togglePreview();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
|
@ -291,11 +403,12 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
? Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SingleChildScrollView(
|
||||
controller: _previewScroll,
|
||||
child: MarkdownBody(
|
||||
builders: {
|
||||
'latex': LatexElementBuilder(
|
||||
textStyle: TextStyle(
|
||||
color: widget.isDarkMode
|
||||
color: _isDark
|
||||
? Colors.white
|
||||
: Colors.black)),
|
||||
},
|
||||
|
|
@ -308,24 +421,24 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
styleSheet: MarkdownStyleSheet(
|
||||
p: TextStyle(
|
||||
fontSize: _fontSize,
|
||||
color: widget.isDarkMode
|
||||
color: _isDark
|
||||
? Colors.white
|
||||
: Colors.black),
|
||||
h1: TextStyle(
|
||||
fontSize: _fontSize + 8,
|
||||
color: widget.isDarkMode
|
||||
color: _isDark
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
fontWeight: FontWeight.bold),
|
||||
h2: TextStyle(
|
||||
fontSize: _fontSize + 6,
|
||||
color: widget.isDarkMode
|
||||
color: _isDark
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
fontWeight: FontWeight.bold),
|
||||
h3: TextStyle(
|
||||
fontSize: _fontSize + 4,
|
||||
color: widget.isDarkMode
|
||||
color: _isDark
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
fontWeight: FontWeight.bold),
|
||||
|
|
@ -346,6 +459,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
readOnly: !_isEditing,
|
||||
fontSize: _fontSize,
|
||||
focusNode: _editorFocus,
|
||||
scrollController: _sourceScroll,
|
||||
),
|
||||
),
|
||||
floatingActionButton: _isLoading || _isPreviewMode
|
||||
|
|
@ -373,6 +487,8 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
|
|||
)
|
||||
: SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue