feat: add keyboard shortcuts for notes, the note list and the app

This commit is contained in:
randogoth 2026-10-13 11:31:14 +03:00
parent ed2de5e67f
commit 722390ecec
9 changed files with 532 additions and 60 deletions

View file

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