feat: OneNote-style notebooks, text fonts, and page navigation
All checks were successful
CI / Windows build (push) Successful in 8m42s
All checks were successful
CI / Windows build (push) Successful in 8m42s
Add notebook.json containers with multi-member pages, fix PDF text editing (size/bold/drag/double-tap), index SidecarText in search, and share keyboard page shortcuts plus a PDF scrubber. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import '../../services/office/docx_parser.dart';
|
|||||||
import '../../services/office/office_document.dart';
|
import '../../services/office/office_document.dart';
|
||||||
import '../../services/office/pptx_parser.dart';
|
import '../../services/office/pptx_parser.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
|
import '../ui/page_nav_shortcuts.dart';
|
||||||
|
|
||||||
/// Unified native Office viewer + ink annotation (PPTX / DOCX).
|
/// Unified native Office viewer + ink annotation (PPTX / DOCX).
|
||||||
class OfficeDocumentScreen extends StatefulWidget {
|
class OfficeDocumentScreen extends StatefulWidget {
|
||||||
@@ -172,39 +173,47 @@ class _OfficeDocumentScreenState extends State<OfficeDocumentScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return pageNavShortcuts(
|
||||||
appBar: AppBar(
|
onPrevious: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null,
|
||||||
title: Text(p.basename(widget.filePath)),
|
onNext:
|
||||||
actions: [
|
_pageIndex < pageCount - 1 ? () => _goPage(_pageIndex + 1) : null,
|
||||||
IconButton(
|
onFirst: pageCount > 0 ? () => _goPage(0) : null,
|
||||||
onPressed: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null,
|
onLast: pageCount > 0 ? () => _goPage(pageCount - 1) : null,
|
||||||
icon: const Icon(Icons.chevron_left),
|
child: Scaffold(
|
||||||
),
|
appBar: AppBar(
|
||||||
Center(child: Text('${_pageIndex + 1} / $pageCount')),
|
title: Text(p.basename(widget.filePath)),
|
||||||
IconButton(
|
actions: [
|
||||||
onPressed:
|
IconButton(
|
||||||
_pageIndex < pageCount - 1 ? () => _goPage(_pageIndex + 1) : null,
|
onPressed: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null,
|
||||||
icon: const Icon(Icons.chevron_right),
|
icon: const Icon(Icons.chevron_left),
|
||||||
),
|
),
|
||||||
],
|
Center(child: Text('${_pageIndex + 1} / $pageCount')),
|
||||||
),
|
IconButton(
|
||||||
body: InteractiveViewer(
|
onPressed: _pageIndex < pageCount - 1
|
||||||
transformationController: _transform,
|
? () => _goPage(_pageIndex + 1)
|
||||||
minScale: 0.5,
|
: null,
|
||||||
maxScale: 4,
|
icon: const Icon(Icons.chevron_right),
|
||||||
child: Listener(
|
),
|
||||||
onPointerDown: _onPointerDown,
|
],
|
||||||
onPointerMove: _onPointerMove,
|
),
|
||||||
onPointerUp: _onPointerUp,
|
body: InteractiveViewer(
|
||||||
child: CustomPaint(
|
transformationController: _transform,
|
||||||
painter: _OfficePagePainter(
|
minScale: 0.5,
|
||||||
pptx: _pptx,
|
maxScale: 4,
|
||||||
docx: _docx,
|
child: Listener(
|
||||||
pageIndex: _pageIndex,
|
onPointerDown: _onPointerDown,
|
||||||
strokes: _strokes,
|
onPointerMove: _onPointerMove,
|
||||||
live: _live,
|
onPointerUp: _onPointerUp,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _OfficePagePainter(
|
||||||
|
pptx: _pptx,
|
||||||
|
docx: _docx,
|
||||||
|
pageIndex: _pageIndex,
|
||||||
|
strokes: _strokes,
|
||||||
|
live: _live,
|
||||||
|
),
|
||||||
|
size: _pageSize,
|
||||||
),
|
),
|
||||||
size: _pageSize,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import '../../l10n/app_localizations.dart';
|
|||||||
import '../../models/bookmark.dart';
|
import '../../models/bookmark.dart';
|
||||||
import '../../models/scratch_link.dart';
|
import '../../models/scratch_link.dart';
|
||||||
import '../../storage/badnote_sidecar.dart';
|
import '../../storage/badnote_sidecar.dart';
|
||||||
|
import '../../storage/notebook_manifest.dart' show kAnnotationFontFamily;
|
||||||
import '../engine/brush.dart';
|
import '../engine/brush.dart';
|
||||||
import '../engine/shape_geometry.dart';
|
import '../engine/shape_geometry.dart';
|
||||||
import '../engine/stroke_eraser.dart';
|
import '../engine/stroke_eraser.dart';
|
||||||
@@ -47,6 +48,7 @@ import '../input/pressure_curve.dart'
|
|||||||
show PressureCurve, kNaturalPressureFloor;
|
show PressureCurve, kNaturalPressureFloor;
|
||||||
import '../pdf/pen_capture_region.dart';
|
import '../pdf/pen_capture_region.dart';
|
||||||
import '../persistence/sidecar_repository.dart';
|
import '../persistence/sidecar_repository.dart';
|
||||||
|
import '../ui/page_nav_shortcuts.dart';
|
||||||
import '../ui/pen_settings_page.dart';
|
import '../ui/pen_settings_page.dart';
|
||||||
import '../ui/thumbnail_grid.dart';
|
import '../ui/thumbnail_grid.dart';
|
||||||
import 'editor_tool.dart';
|
import 'editor_tool.dart';
|
||||||
@@ -66,6 +68,13 @@ const double _kMarkerSize = 36.0;
|
|||||||
/// page.
|
/// page.
|
||||||
const double _kDefaultTextFontFraction = 0.03;
|
const double _kDefaultTextFontFraction = 0.03;
|
||||||
|
|
||||||
|
/// Convert a CSS-like numeric weight (100–900) to a [FontWeight], clamped to
|
||||||
|
/// the nearest of the 9 standard weights ([FontWeight.values] is w100..w900).
|
||||||
|
FontWeight _fontWeightFromValue(int weight) {
|
||||||
|
final idx = ((weight ~/ 100) - 1).clamp(0, FontWeight.values.length - 1);
|
||||||
|
return FontWeight.values[idx];
|
||||||
|
}
|
||||||
|
|
||||||
class PenEditorScreen extends StatefulWidget {
|
class PenEditorScreen extends StatefulWidget {
|
||||||
const PenEditorScreen({
|
const PenEditorScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -98,6 +107,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
/// page pill + thumbnail highlight.
|
/// page pill + thumbnail highlight.
|
||||||
int _pageIndex = 0;
|
int _pageIndex = 0;
|
||||||
|
|
||||||
|
/// Live 1-based value while the page pill's scrubber slider is being
|
||||||
|
/// dragged (like the slide editor's scrubber); null when not scrubbing.
|
||||||
|
double? _pageScrub;
|
||||||
|
|
||||||
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
/// Strokes per page, keyed by 0-based page index (normalized coords).
|
||||||
final Map<int, List<PenStroke>> _strokesByPage = {};
|
final Map<int, List<PenStroke>> _strokesByPage = {};
|
||||||
|
|
||||||
@@ -1079,6 +1092,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
text: '',
|
text: '',
|
||||||
fontSize: _kDefaultTextFontFraction,
|
fontSize: _kDefaultTextFontFraction,
|
||||||
color: _color.toARGB32(),
|
color: _color.toARGB32(),
|
||||||
|
fontFamily: kAnnotationFontFamily,
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_textsByPage[pageIndex] = [...?_textsByPage[pageIndex], box];
|
_textsByPage[pageIndex] = [...?_textsByPage[pageIndex], box];
|
||||||
@@ -1134,6 +1148,55 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
_bumpOverlay();
|
_bumpOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TEXT tool drag-to-move: nudge box [id] on [pageIndex] by [deltaPx] (a
|
||||||
|
/// screen-pixel pan delta), converting to a normalized delta via the page's
|
||||||
|
/// on-screen [pageW]/[pageH]. Persists (debounced) like other text edits.
|
||||||
|
void _dragTextBox(
|
||||||
|
int pageIndex,
|
||||||
|
String id,
|
||||||
|
Offset deltaPx,
|
||||||
|
double pageW,
|
||||||
|
double pageH,
|
||||||
|
) {
|
||||||
|
if (pageW <= 0 || pageH <= 0) return;
|
||||||
|
final list = _textsByPage[pageIndex];
|
||||||
|
if (list == null) return;
|
||||||
|
final idx = list.indexWhere((t) => t.id == id);
|
||||||
|
if (idx == -1) return;
|
||||||
|
final t = list[idx];
|
||||||
|
final nx = (t.nx + deltaPx.dx / pageW).clamp(0.0, 1.0);
|
||||||
|
final ny = (t.ny + deltaPx.dy / pageH).clamp(0.0, 1.0);
|
||||||
|
setState(() {
|
||||||
|
final next = List<SidecarText>.of(list);
|
||||||
|
next[idx] = t.copyWith(nx: nx, ny: ny);
|
||||||
|
_textsByPage[pageIndex] = next;
|
||||||
|
});
|
||||||
|
_scheduleTextsSave(pageIndex);
|
||||||
|
_bumpOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live style edit for the box currently being edited: updates its
|
||||||
|
/// page-relative [fontSize] fraction and/or numeric [fontWeight] via
|
||||||
|
/// copyWith, persisting (debounced) like [_updateEditingText].
|
||||||
|
void _updateEditingStyle({double? fontSize, int? fontWeight}) {
|
||||||
|
final editing = _editingText;
|
||||||
|
if (editing == null) return;
|
||||||
|
final list = _textsByPage[editing.page];
|
||||||
|
if (list == null) return;
|
||||||
|
final idx = list.indexWhere((t) => t.id == editing.id);
|
||||||
|
if (idx == -1) return;
|
||||||
|
setState(() {
|
||||||
|
final next = List<SidecarText>.of(list);
|
||||||
|
next[idx] = next[idx].copyWith(
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
);
|
||||||
|
_textsByPage[editing.page] = next;
|
||||||
|
});
|
||||||
|
_scheduleTextsSave(editing.page);
|
||||||
|
_bumpOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
/// Toggle the TEXT tool (drops [_editingText] when leaving, so a half-typed
|
/// Toggle the TEXT tool (drops [_editingText] when leaving, so a half-typed
|
||||||
/// box gets the empty-on-blur treatment).
|
/// box gets the empty-on-blur treatment).
|
||||||
void _toggleTextMode() {
|
void _toggleTextMode() {
|
||||||
@@ -1529,44 +1592,66 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l = AppLocalizations.of(context);
|
final l = AppLocalizations.of(context);
|
||||||
|
final formatBar = _buildTextFormatBar();
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Stack(
|
body: pageNavShortcuts(
|
||||||
children: [
|
onPrevious:
|
||||||
Positioned.fill(child: _buildViewer()),
|
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
|
||||||
// Floating Material You tool palette (top-center).
|
onNext: _pageIndex < _pageCount - 1
|
||||||
SafeArea(
|
? () => _goToPage(_pageIndex + 1)
|
||||||
child: Align(
|
: null,
|
||||||
alignment: Alignment.topCenter,
|
onFirst: _pageCount > 0 ? () => _goToPage(0) : null,
|
||||||
child: Padding(
|
onLast: _pageCount > 0 ? () => _goToPage(_pageCount - 1) : null,
|
||||||
padding: const EdgeInsets.only(top: 8),
|
child: Stack(
|
||||||
child: _buildToolPalette(),
|
children: [
|
||||||
),
|
Positioned.fill(child: _buildViewer()),
|
||||||
),
|
// Floating Material You tool palette (top-center).
|
||||||
),
|
|
||||||
// Floating page-control pill (bottom-center).
|
|
||||||
if (_viewerReady && _pageCount > 0)
|
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: _buildPagePill(),
|
child: _buildToolPalette(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Back button (top-left).
|
// Compact text-format bar (S/M/L + Bold), shown BELOW the tool
|
||||||
SafeArea(
|
// palette while a text box is being edited.
|
||||||
child: Padding(
|
if (formatBar != null)
|
||||||
padding: const EdgeInsets.all(8),
|
SafeArea(
|
||||||
child: RoundIconButton(
|
child: Align(
|
||||||
icon: Icons.arrow_back,
|
alignment: Alignment.topCenter,
|
||||||
tooltip: l.back,
|
child: Padding(
|
||||||
onPressed: () => Navigator.of(context).maybePop(),
|
padding: const EdgeInsets.only(top: 68),
|
||||||
|
child: formatBar,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Floating page-control pill (bottom-center).
|
||||||
|
if (_viewerReady && _pageCount > 0)
|
||||||
|
SafeArea(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: _buildPagePill(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Back button (top-left).
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: RoundIconButton(
|
||||||
|
icon: Icons.arrow_back,
|
||||||
|
tooltip: l.back,
|
||||||
|
onPressed: () => Navigator.of(context).maybePop(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (_showPenDebug) _buildDebugReadout(context),
|
||||||
if (_showPenDebug) _buildDebugReadout(context),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1697,7 +1782,20 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
text: t.text,
|
text: t.text,
|
||||||
fontSizePx: t.fontSize * pageW,
|
fontSizePx: t.fontSize * pageW,
|
||||||
color: Color(t.color),
|
color: Color(t.color),
|
||||||
|
fontWeight: _fontWeightFromValue(t.fontWeight),
|
||||||
|
fontFamily: t.fontFamily,
|
||||||
onTap: _textMode ? () => _editTextBox(pageIndex, t.id) : null,
|
onTap: _textMode ? () => _editTextBox(pageIndex, t.id) : null,
|
||||||
|
// Double-tap always edits, even outside text mode.
|
||||||
|
onDoubleTap: () => _editTextBox(pageIndex, t.id),
|
||||||
|
onPanUpdate: _textMode
|
||||||
|
? (details) => _dragTextBox(
|
||||||
|
pageIndex,
|
||||||
|
t.id,
|
||||||
|
details.delta,
|
||||||
|
pageW,
|
||||||
|
pageH,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Active editing field for a box on this page: a real Flutter
|
// Active editing field for a box on this page: a real Flutter
|
||||||
@@ -1714,6 +1812,8 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
initialText: t.text,
|
initialText: t.text,
|
||||||
fontSizePx: t.fontSize * pageW,
|
fontSizePx: t.fontSize * pageW,
|
||||||
color: Color(t.color),
|
color: Color(t.color),
|
||||||
|
fontWeight: _fontWeightFromValue(t.fontWeight),
|
||||||
|
fontFamily: t.fontFamily,
|
||||||
hintText: AppLocalizations.of(context).textPlaceholder,
|
hintText: AppLocalizations.of(context).textPlaceholder,
|
||||||
onChanged: _updateEditingText,
|
onChanged: _updateEditingText,
|
||||||
onDone: _finishTextEdit,
|
onDone: _finishTextEdit,
|
||||||
@@ -1969,6 +2069,64 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compact text-format bar shown (below the tool palette) while a text box
|
||||||
|
/// is being edited: S/M/L font-size presets + a Bold toggle. Edits apply
|
||||||
|
/// live to the editing [SidecarText] via [_updateEditingStyle]. Returns null
|
||||||
|
/// when nothing is being edited (or the box has since been removed).
|
||||||
|
Widget? _buildTextFormatBar() {
|
||||||
|
final editing = _editingText;
|
||||||
|
if (editing == null) return null;
|
||||||
|
final list = _textsByPage[editing.page];
|
||||||
|
if (list == null) return null;
|
||||||
|
final idx = list.indexWhere((t) => t.id == editing.id);
|
||||||
|
if (idx == -1) return null;
|
||||||
|
final current = list[idx];
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final isBold = current.fontWeight >= 700;
|
||||||
|
|
||||||
|
Widget sizeButton(String label, double fraction) {
|
||||||
|
final selected = (current.fontSize - fraction).abs() < 0.001;
|
||||||
|
return TextButton(
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
minimumSize: const Size(36, 36),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
backgroundColor:
|
||||||
|
selected ? cs.secondaryContainer : Colors.transparent,
|
||||||
|
foregroundColor:
|
||||||
|
selected ? cs.onSecondaryContainer : cs.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
onPressed: () => _updateEditingStyle(fontSize: fraction),
|
||||||
|
child: Text(label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
elevation: 3,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
sizeButton(l.textFontSmall, 0.022),
|
||||||
|
sizeButton(l.textFontMedium, 0.03),
|
||||||
|
sizeButton(l.textFontLarge, 0.045),
|
||||||
|
PaletteDivider(cs: cs),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.format_bold,
|
||||||
|
selected: isBold,
|
||||||
|
tooltip: l.textBold,
|
||||||
|
onPressed: () =>
|
||||||
|
_updateEditingStyle(fontWeight: isBold ? 400 : 700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _colorDot(Color c, ColorScheme cs) {
|
Widget _colorDot(Color c, ColorScheme cs) {
|
||||||
// Selected against the ACTIVE brush's remembered color; a tap updates only
|
// Selected against the ACTIVE brush's remembered color; a tap updates only
|
||||||
// that brush's entry (rnote per-brush color memory). Inert in select mode.
|
// that brush's entry (rnote per-brush color memory). Inert in select mode.
|
||||||
@@ -1992,47 +2150,80 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Floating page control: a COMPACT pill (prev / "n / total" / next).
|
/// Floating page control: a scrubber Slider (multi-page docs) above a
|
||||||
|
/// COMPACT pill (prev / "n / total" / next). Mirrors the slide editor's
|
||||||
|
/// scrubber; the center button now opens the thumbnail grid.
|
||||||
Widget _buildPagePill() {
|
Widget _buildPagePill() {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final l = AppLocalizations.of(context);
|
final l = AppLocalizations.of(context);
|
||||||
final total = _pageCount;
|
final total = _pageCount;
|
||||||
final shown = _pageIndex + 1;
|
final scrub = _pageScrub;
|
||||||
return Material(
|
final shown = (scrub ?? (_pageIndex + 1).toDouble()).round();
|
||||||
color: cs.surfaceContainerHigh,
|
return Column(
|
||||||
elevation: 3,
|
mainAxisSize: MainAxisSize.min,
|
||||||
borderRadius: BorderRadius.circular(28),
|
children: [
|
||||||
child: Padding(
|
if (total > 1)
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
Container(
|
||||||
child: Row(
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
mainAxisSize: MainAxisSize.min,
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
children: [
|
child: Material(
|
||||||
IconButton(
|
color: cs.surfaceContainerHigh,
|
||||||
tooltip: l.previousPage,
|
elevation: 3,
|
||||||
icon: const Icon(Icons.chevron_left),
|
borderRadius: BorderRadius.circular(28),
|
||||||
onPressed:
|
child: Padding(
|
||||||
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
),
|
child: Slider(
|
||||||
TextButton(
|
min: 1,
|
||||||
onPressed: null,
|
max: total.toDouble(),
|
||||||
child: Text(
|
value: (scrub ?? (_pageIndex + 1).toDouble())
|
||||||
l.pageOfPages(shown, total),
|
.clamp(1, total.toDouble()),
|
||||||
style: TextStyle(
|
divisions: total > 1 ? total - 1 : null,
|
||||||
color: cs.onSurface,
|
onChanged: (v) => setState(() => _pageScrub = v),
|
||||||
fontWeight: FontWeight.w600,
|
onChangeEnd: (v) {
|
||||||
|
setState(() => _pageScrub = null);
|
||||||
|
_goToPage(v.round() - 1);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
),
|
||||||
tooltip: l.nextPage,
|
Material(
|
||||||
icon: const Icon(Icons.chevron_right),
|
color: cs.surfaceContainerHigh,
|
||||||
onPressed: _pageIndex < total - 1
|
elevation: 3,
|
||||||
? () => _goToPage(_pageIndex + 1)
|
borderRadius: BorderRadius.circular(28),
|
||||||
: null,
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: l.previousPage,
|
||||||
|
icon: const Icon(Icons.chevron_left),
|
||||||
|
onPressed:
|
||||||
|
_pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _viewerReady ? _openThumbnails : null,
|
||||||
|
child: Text(
|
||||||
|
l.pageOfPages(shown, total),
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: l.nextPage,
|
||||||
|
icon: const Icon(Icons.chevron_right),
|
||||||
|
onPressed: _pageIndex < total - 1
|
||||||
|
? () => _goToPage(_pageIndex + 1)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2417,10 +2608,18 @@ class _TextPlacementLayerState extends State<_TextPlacementLayer> {
|
|||||||
PointerDeviceKind? _downKind;
|
PointerDeviceKind? _downKind;
|
||||||
Offset? _downLocal;
|
Offset? _downLocal;
|
||||||
|
|
||||||
|
/// A down→up drift beyond this (px) means the gesture was a scroll/pan
|
||||||
|
/// (e.g. a 1-finger drag that also reaches this translucent layer), not a
|
||||||
|
/// deliberate tap-to-place.
|
||||||
|
static const double _kMaxTapDriftPx = 12.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
// translucent: this layer must NOT swallow the touch/scroll gesture from
|
||||||
|
// pdfrx underneath (only opaque'd taps that resolve to a genuine
|
||||||
|
// tap-to-place, guarded by the drift check below, actually place a box).
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
onTapDown: (d) {
|
onTapDown: (d) {
|
||||||
_downKind = d.kind;
|
_downKind = d.kind;
|
||||||
_downLocal = d.localPosition;
|
_downLocal = d.localPosition;
|
||||||
@@ -2429,6 +2628,11 @@ class _TextPlacementLayerState extends State<_TextPlacementLayer> {
|
|||||||
// A mouse single-click does NOT place (mouse uses double-click); pen and
|
// A mouse single-click does NOT place (mouse uses double-click); pen and
|
||||||
// touch place on a single tap.
|
// touch place on a single tap.
|
||||||
if (_downKind == PointerDeviceKind.mouse) return;
|
if (_downKind == PointerDeviceKind.mouse) return;
|
||||||
|
final down = _downLocal;
|
||||||
|
if (down != null &&
|
||||||
|
(d.localPosition - down).distance > _kMaxTapDriftPx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
widget.onPlace(d.localPosition);
|
widget.onPlace(d.localPosition);
|
||||||
},
|
},
|
||||||
onDoubleTapDown: (d) {
|
onDoubleTapDown: (d) {
|
||||||
@@ -2450,24 +2654,43 @@ class _TextAnnotationLabel extends StatelessWidget {
|
|||||||
required this.text,
|
required this.text,
|
||||||
required this.fontSizePx,
|
required this.fontSizePx,
|
||||||
required this.color,
|
required this.color,
|
||||||
|
this.fontWeight = FontWeight.w400,
|
||||||
|
this.fontFamily,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.onDoubleTap,
|
||||||
|
this.onPanUpdate,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String text;
|
final String text;
|
||||||
final double fontSizePx;
|
final double fontSizePx;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
final FontWeight fontWeight;
|
||||||
|
final String? fontFamily;
|
||||||
|
|
||||||
|
/// Single-tap handler (only wired while the TEXT tool is active).
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
/// Double-tap handler: ALWAYS wired (regardless of active tool) so a box can
|
||||||
|
/// be reopened for editing at any time.
|
||||||
|
final VoidCallback? onDoubleTap;
|
||||||
|
|
||||||
|
/// TEXT tool drag-to-move (only wired while the TEXT tool is active).
|
||||||
|
final GestureDragUpdateCallback? onPanUpdate;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
onDoubleTap: onDoubleTap,
|
||||||
|
onPanUpdate: onPanUpdate,
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: fontSizePx,
|
fontSize: fontSizePx,
|
||||||
color: color,
|
color: color,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
fontFamily: fontFamily ?? kAnnotationFontFamily,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -2489,6 +2712,8 @@ class _TextAnnotationField extends StatefulWidget {
|
|||||||
required this.hintText,
|
required this.hintText,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
required this.onDone,
|
required this.onDone,
|
||||||
|
this.fontWeight = FontWeight.w400,
|
||||||
|
this.fontFamily,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String initialText;
|
final String initialText;
|
||||||
@@ -2497,6 +2722,8 @@ class _TextAnnotationField extends StatefulWidget {
|
|||||||
final String hintText;
|
final String hintText;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
final VoidCallback onDone;
|
final VoidCallback onDone;
|
||||||
|
final FontWeight fontWeight;
|
||||||
|
final String? fontFamily;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_TextAnnotationField> createState() => _TextAnnotationFieldState();
|
State<_TextAnnotationField> createState() => _TextAnnotationFieldState();
|
||||||
@@ -2545,6 +2772,8 @@ class _TextAnnotationFieldState extends State<_TextAnnotationField> {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: widget.fontSizePx,
|
fontSize: widget.fontSizePx,
|
||||||
color: widget.color,
|
color: widget.color,
|
||||||
|
fontWeight: widget.fontWeight,
|
||||||
|
fontFamily: widget.fontFamily ?? kAnnotationFontFamily,
|
||||||
height: 1.2,
|
height: 1.2,
|
||||||
),
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import '../input/pen_input_service.dart';
|
|||||||
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
import '../input/pressure_curve.dart' show kNaturalPressureGamma;
|
||||||
import '../layout/viewport_fit.dart';
|
import '../layout/viewport_fit.dart';
|
||||||
import '../pdf/slide_export.dart';
|
import '../pdf/slide_export.dart';
|
||||||
|
import '../ui/page_nav_shortcuts.dart';
|
||||||
import '../ui/pen_settings_page.dart';
|
import '../ui/pen_settings_page.dart';
|
||||||
import 'editor_tool.dart';
|
import 'editor_tool.dart';
|
||||||
import 'pen_canvas.dart';
|
import 'pen_canvas.dart';
|
||||||
@@ -335,7 +336,15 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
return Scaffold(
|
return pageNavShortcuts(
|
||||||
|
onPrevious:
|
||||||
|
_slideIndex > 0 ? () => _goToSlide(_slideIndex - 1) : null,
|
||||||
|
onNext: _slideIndex < _slideCount - 1
|
||||||
|
? () => _goToSlide(_slideIndex + 1)
|
||||||
|
: null,
|
||||||
|
onFirst: _slideCount > 0 ? () => _goToSlide(0) : null,
|
||||||
|
onLast: _slideCount > 0 ? () => _goToSlide(_slideCount - 1) : null,
|
||||||
|
child: Scaffold(
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(child: _buildCanvas()),
|
Positioned.fill(child: _buildCanvas()),
|
||||||
@@ -383,6 +392,7 @@ class _PenSlideScreenState extends State<PenSlideScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
57
lib/editor/ui/page_nav_shortcuts.dart
Normal file
57
lib/editor/ui/page_nav_shortcuts.dart
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// lib/editor/ui/page_nav_shortcuts.dart
|
||||||
|
//
|
||||||
|
// Shared keyboard page navigation for PDF / slide / office editors.
|
||||||
|
// Arrow keys + PageUp/PageDown (+ Home/End when provided).
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
class PreviousPageIntent extends Intent {
|
||||||
|
const PreviousPageIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class NextPageIntent extends Intent {
|
||||||
|
const NextPageIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class FirstPageIntent extends Intent {
|
||||||
|
const FirstPageIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
class LastPageIntent extends Intent {
|
||||||
|
const LastPageIntent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps [child] so ←/→/PageUp/PageDown(/Home/End) drive page changes.
|
||||||
|
Widget pageNavShortcuts({
|
||||||
|
required Widget child,
|
||||||
|
required VoidCallback? onPrevious,
|
||||||
|
required VoidCallback? onNext,
|
||||||
|
VoidCallback? onFirst,
|
||||||
|
VoidCallback? onLast,
|
||||||
|
}) {
|
||||||
|
return Focus(
|
||||||
|
autofocus: true,
|
||||||
|
child: CallbackShortcuts(
|
||||||
|
bindings: <ShortcutActivator, VoidCallback>{
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
|
||||||
|
onPrevious?.call(),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
|
||||||
|
onPrevious?.call(),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.pageUp): () =>
|
||||||
|
onPrevious?.call(),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowRight): () =>
|
||||||
|
onNext?.call(),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
|
||||||
|
onNext?.call(),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.pageDown): () =>
|
||||||
|
onNext?.call(),
|
||||||
|
if (onFirst != null)
|
||||||
|
const SingleActivator(LogicalKeyboardKey.home): onFirst,
|
||||||
|
if (onLast != null)
|
||||||
|
const SingleActivator(LogicalKeyboardKey.end): onLast,
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -237,5 +237,18 @@
|
|||||||
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
|
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
|
||||||
"diagExportFail": "Export failed: {error}",
|
"diagExportFail": "Export failed: {error}",
|
||||||
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
|
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
|
||||||
"processingOcr": "Processing OCR…"
|
"processingOcr": "Processing OCR…",
|
||||||
|
"notebooksSection": "Notebooks",
|
||||||
|
"addBlankPage": "Blank page",
|
||||||
|
"importIntoNotebook": "Import into notebook",
|
||||||
|
"notebookMembersEmpty": "No pages yet",
|
||||||
|
"memberCount": "{count} items",
|
||||||
|
"@memberCount": {
|
||||||
|
"placeholders": { "count": { "type": "int" } }
|
||||||
|
},
|
||||||
|
"textFontSmall": "S",
|
||||||
|
"textFontMedium": "M",
|
||||||
|
"textFontLarge": "L",
|
||||||
|
"textBold": "Bold",
|
||||||
|
"textDragHint": "Drag to move"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1153,6 +1153,66 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Processing OCR…'**
|
/// **'Processing OCR…'**
|
||||||
String get processingOcr;
|
String get processingOcr;
|
||||||
|
|
||||||
|
/// No description provided for @notebooksSection.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Notebooks'**
|
||||||
|
String get notebooksSection;
|
||||||
|
|
||||||
|
/// No description provided for @addBlankPage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Blank page'**
|
||||||
|
String get addBlankPage;
|
||||||
|
|
||||||
|
/// No description provided for @importIntoNotebook.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Import into notebook'**
|
||||||
|
String get importIntoNotebook;
|
||||||
|
|
||||||
|
/// No description provided for @notebookMembersEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No pages yet'**
|
||||||
|
String get notebookMembersEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @memberCount.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count} items'**
|
||||||
|
String memberCount(int count);
|
||||||
|
|
||||||
|
/// No description provided for @textFontSmall.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'S'**
|
||||||
|
String get textFontSmall;
|
||||||
|
|
||||||
|
/// No description provided for @textFontMedium.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'M'**
|
||||||
|
String get textFontMedium;
|
||||||
|
|
||||||
|
/// No description provided for @textFontLarge.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'L'**
|
||||||
|
String get textFontLarge;
|
||||||
|
|
||||||
|
/// No description provided for @textBold.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bold'**
|
||||||
|
String get textBold;
|
||||||
|
|
||||||
|
/// No description provided for @textDragHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Drag to move'**
|
||||||
|
String get textDragHint;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -588,4 +588,36 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get processingOcr => 'Processing OCR…';
|
String get processingOcr => 'Processing OCR…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notebooksSection => 'Notebooks';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addBlankPage => 'Blank page';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importIntoNotebook => 'Import into notebook';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notebookMembersEmpty => 'No pages yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String memberCount(int count) {
|
||||||
|
return '$count items';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textFontSmall => 'S';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textFontMedium => 'M';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textFontLarge => 'L';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textBold => 'Bold';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textDragHint => 'Drag to move';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -580,4 +580,36 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get processingOcr => '正在识别文字…';
|
String get processingOcr => '正在识别文字…';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notebooksSection => '笔记本';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addBlankPage => '空白页';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get importIntoNotebook => '导入到笔记本';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get notebookMembersEmpty => '还没有页面';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String memberCount(int count) {
|
||||||
|
return '$count 项';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textFontSmall => '小';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textFontMedium => '中';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textFontLarge => '大';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textBold => '粗体';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get textDragHint => '拖动移动';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,5 +207,18 @@
|
|||||||
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
|
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
|
||||||
"diagExportFail": "导出失败:{error}",
|
"diagExportFail": "导出失败:{error}",
|
||||||
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
|
"@diagExportFail": { "placeholders": { "error": { "type": "String" } } },
|
||||||
"processingOcr": "正在识别文字…"
|
"processingOcr": "正在识别文字…",
|
||||||
|
"notebooksSection": "笔记本",
|
||||||
|
"addBlankPage": "空白页",
|
||||||
|
"importIntoNotebook": "导入到笔记本",
|
||||||
|
"notebookMembersEmpty": "还没有页面",
|
||||||
|
"memberCount": "{count} 项",
|
||||||
|
"@memberCount": {
|
||||||
|
"placeholders": { "count": { "type": "int" } }
|
||||||
|
},
|
||||||
|
"textFontSmall": "小",
|
||||||
|
"textFontMedium": "中",
|
||||||
|
"textFontLarge": "大",
|
||||||
|
"textBold": "粗体",
|
||||||
|
"textDragHint": "拖动移动"
|
||||||
}
|
}
|
||||||
|
|||||||
46
lib/providers/notebook_container_provider.dart
Normal file
46
lib/providers/notebook_container_provider.dart
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// lib/providers/notebook_container_provider.dart
|
||||||
|
//
|
||||||
|
// Home-screen list of OneNote-style notebook containers: vault folders that
|
||||||
|
// hold a `notebook.json` manifest (see `storage/notebook_manifest.dart`). This
|
||||||
|
// mirrors `note_provider.dart` / `document_provider.dart`'s vault-scan pattern
|
||||||
|
// — the manifest on disk is the source of truth, there is no SQLite cache.
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../services/vault_service.dart';
|
||||||
|
import 'document_provider.dart' show vaultServiceProvider;
|
||||||
|
|
||||||
|
final notebookContainerListProvider = AsyncNotifierProvider<
|
||||||
|
NotebookContainerListNotifier, List<VaultContainer>>(
|
||||||
|
NotebookContainerListNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
class NotebookContainerListNotifier
|
||||||
|
extends AsyncNotifier<List<VaultContainer>> {
|
||||||
|
Future<VaultService> get _vault => ref.read(vaultServiceProvider.future);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<VaultContainer>> build() => _scan();
|
||||||
|
|
||||||
|
Future<List<VaultContainer>> _scan() async {
|
||||||
|
final vault = await _vault;
|
||||||
|
return vault.scanContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-scan the vault and publish the result. Used by pull-to-refresh and
|
||||||
|
/// after a container is created elsewhere.
|
||||||
|
Future<void> loadContainers() async {
|
||||||
|
state = const AsyncLoading();
|
||||||
|
state = await AsyncValue.guard(_scan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new notebook container (folder + `notebook.json` + one blank ink
|
||||||
|
/// page) titled [title], prepend it to the list, and return it so the caller
|
||||||
|
/// can navigate straight into it.
|
||||||
|
Future<VaultContainer> createContainer(String title) async {
|
||||||
|
final vault = await _vault;
|
||||||
|
final container = await vault.createNotebookContainer(title);
|
||||||
|
state = AsyncData([container, ...state.value ?? []]);
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import '../models/document.dart';
|
|||||||
import '../models/note.dart';
|
import '../models/note.dart';
|
||||||
import '../providers/document_provider.dart';
|
import '../providers/document_provider.dart';
|
||||||
import '../providers/note_provider.dart';
|
import '../providers/note_provider.dart';
|
||||||
|
import '../providers/notebook_container_provider.dart';
|
||||||
import '../providers/ocr_provider.dart';
|
import '../providers/ocr_provider.dart';
|
||||||
import '../providers/search_provider.dart';
|
import '../providers/search_provider.dart';
|
||||||
import '../editor/canvas/pen_editor_screen.dart';
|
import '../editor/canvas/pen_editor_screen.dart';
|
||||||
@@ -17,6 +18,7 @@ import '../services/pptx_service.dart';
|
|||||||
import '../services/vault_service.dart';
|
import '../services/vault_service.dart';
|
||||||
import '../editor/canvas/pen_note_screen.dart';
|
import '../editor/canvas/pen_note_screen.dart';
|
||||||
import '../editor/canvas/pen_slide_screen.dart';
|
import '../editor/canvas/pen_slide_screen.dart';
|
||||||
|
import 'notebook_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
@@ -88,8 +90,10 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
data: (notes) {
|
data: (notes) {
|
||||||
final documentsAsync = ref.watch(documentListProvider);
|
final documentsAsync = ref.watch(documentListProvider);
|
||||||
final documents = documentsAsync.valueOrNull ?? [];
|
final documents = documentsAsync.valueOrNull ?? [];
|
||||||
|
final containersAsync = ref.watch(notebookContainerListProvider);
|
||||||
|
final containers = containersAsync.valueOrNull ?? [];
|
||||||
|
|
||||||
if (notes.isEmpty && documents.isEmpty) {
|
if (notes.isEmpty && documents.isEmpty && containers.isEmpty) {
|
||||||
return _buildEmptyState(context, ref);
|
return _buildEmptyState(context, ref);
|
||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
@@ -97,10 +101,32 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
await Future.wait([
|
await Future.wait([
|
||||||
ref.read(noteListProvider.notifier).loadNotes(),
|
ref.read(noteListProvider.notifier).loadNotes(),
|
||||||
ref.read(documentListProvider.notifier).loadDocuments(),
|
ref.read(documentListProvider.notifier).loadDocuments(),
|
||||||
|
ref
|
||||||
|
.read(notebookContainerListProvider.notifier)
|
||||||
|
.loadContainers(),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
|
if (containers.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Text(
|
||||||
|
l.notebooksSection,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) =>
|
||||||
|
_ContainerTile(container: containers[index]),
|
||||||
|
childCount: containers.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
@@ -189,9 +215,10 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "Create notebook": prompt a title (defaulting to Untitled), create the
|
/// "Create notebook" (OneNote style): prompt a title (defaulting to
|
||||||
/// standalone notebook FOLDER + `notebook.badnote.json` via
|
/// Untitled), create the notebook container FOLDER + `notebook.json` (with
|
||||||
/// `VaultService.createEmptyNotebook`, then open the editor on the new note.
|
/// one blank ink page) via `VaultService.createNotebookContainer`, then open
|
||||||
|
/// [NotebookScreen] on it.
|
||||||
Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async {
|
Future<void> _createAndOpenNote(BuildContext context, WidgetRef ref) async {
|
||||||
final title = await _promptNotebookTitle(context);
|
final title = await _promptNotebookTitle(context);
|
||||||
if (title == null) return; // cancelled
|
if (title == null) return; // cancelled
|
||||||
@@ -199,12 +226,18 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
final resolved = title.trim().isEmpty
|
final resolved = title.trim().isEmpty
|
||||||
? (l?.untitledNote ?? 'Untitled')
|
? (l?.untitledNote ?? 'Untitled')
|
||||||
: title.trim();
|
: title.trim();
|
||||||
final note =
|
final container = await ref
|
||||||
await ref.read(noteListProvider.notifier).createNote(title: resolved);
|
.read(notebookContainerListProvider.notifier)
|
||||||
|
.createContainer(resolved);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
Navigator.of(
|
Navigator.of(context).push(
|
||||||
context,
|
MaterialPageRoute(
|
||||||
).push(MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)));
|
builder: (_) => NotebookScreen(
|
||||||
|
folderPath: container.folderPath,
|
||||||
|
title: container.title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +430,45 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Simple tile for a OneNote-style notebook container on the home screen.
|
||||||
|
/// Tapping opens [NotebookScreen] on the container's folder.
|
||||||
|
class _ContainerTile extends StatelessWidget {
|
||||||
|
const _ContainerTile({required this.container});
|
||||||
|
|
||||||
|
final VaultContainer container;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final dateStr = _formatDate(context, container.modified);
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.menu_book,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
container.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${l.memberCount(container.memberCount)} · $dateStr',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => NotebookScreen(
|
||||||
|
folderPath: container.folderPath,
|
||||||
|
title: container.title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _NoteTile extends ConsumerStatefulWidget {
|
class _NoteTile extends ConsumerStatefulWidget {
|
||||||
final Note note;
|
final Note note;
|
||||||
const _NoteTile({required this.note});
|
const _NoteTile({required this.note});
|
||||||
|
|||||||
239
lib/screens/notebook_screen.dart
Normal file
239
lib/screens/notebook_screen.dart
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
// lib/screens/notebook_screen.dart
|
||||||
|
//
|
||||||
|
// OneNote-style notebook container screen: lists the members (pages/imported
|
||||||
|
// documents) of one vault folder holding a `notebook.json` manifest, and
|
||||||
|
// routes taps to the matching editor by `NotebookMemberKind`. The open-by-type
|
||||||
|
// routing intentionally MIRRORS `home_screen.dart`'s `_DocumentTile._openDocument`
|
||||||
|
// so a member opens in exactly the editor its extension would on the home
|
||||||
|
// screen (pdf → PenEditorScreen; pptx/docx → OfficeDocumentScreen; legacy ppt →
|
||||||
|
// image-converted PenSlideScreen; note → PenNoteScreen).
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../editor/canvas/office_document_screen.dart';
|
||||||
|
import '../editor/canvas/pen_editor_screen.dart';
|
||||||
|
import '../editor/canvas/pen_note_screen.dart';
|
||||||
|
import '../editor/canvas/pen_slide_screen.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../models/note.dart';
|
||||||
|
import '../providers/document_provider.dart' show vaultServiceProvider;
|
||||||
|
import '../services/pptx_service.dart';
|
||||||
|
import '../services/vault_service.dart';
|
||||||
|
import '../storage/notebook_manifest.dart';
|
||||||
|
|
||||||
|
/// Shows one notebook container's table of contents and lets the user add a
|
||||||
|
/// blank ink page or import a file into it.
|
||||||
|
class NotebookScreen extends ConsumerStatefulWidget {
|
||||||
|
const NotebookScreen({
|
||||||
|
super.key,
|
||||||
|
required this.folderPath,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Absolute path to the notebook container folder (holds `notebook.json`).
|
||||||
|
final String folderPath;
|
||||||
|
|
||||||
|
/// Title shown before the manifest loads (and as a fallback if it has none).
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<NotebookScreen> createState() => _NotebookScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotebookScreenState extends ConsumerState<NotebookScreen> {
|
||||||
|
NotebookManifest? _manifest;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reload() async {
|
||||||
|
final manifest = await NotebookManifest.read(widget.folderPath);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_manifest = manifest;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final members = _manifest?.members ?? const [];
|
||||||
|
final title = (_manifest?.title.trim().isNotEmpty ?? false)
|
||||||
|
? _manifest!.title.trim()
|
||||||
|
: widget.title;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(title),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.note_add_outlined),
|
||||||
|
tooltip: l.addBlankPage,
|
||||||
|
onPressed: _addBlankPage,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.file_open),
|
||||||
|
tooltip: l.importIntoNotebook,
|
||||||
|
onPressed: _importFile,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: members.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
l.notebookMembersEmpty,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _reload,
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: members.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final member = members[index];
|
||||||
|
return _MemberTile(
|
||||||
|
member: member,
|
||||||
|
onTap: () => _openMember(member),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addBlankPage() async {
|
||||||
|
final vault = await ref.read(vaultServiceProvider.future);
|
||||||
|
await vault.addBlankPageToContainer(widget.folderPath);
|
||||||
|
await _reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _importFile() async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: VaultService.importableExtensions.toList(),
|
||||||
|
);
|
||||||
|
final pickedPath = result?.files.first.path;
|
||||||
|
if (pickedPath == null || !mounted) return;
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.processingImport)));
|
||||||
|
try {
|
||||||
|
final vault = await ref.read(vaultServiceProvider.future);
|
||||||
|
await vault.importFileIntoContainer(widget.folderPath, pickedPath);
|
||||||
|
await _reload();
|
||||||
|
} catch (e) {
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(l.importFailed('$e'))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route [member] to the correct editor by kind — same switch as
|
||||||
|
/// `home_screen.dart`'s `_DocumentTile._openDocument`.
|
||||||
|
Future<void> _openMember(NotebookMember member) async {
|
||||||
|
final vault = await ref.read(vaultServiceProvider.future);
|
||||||
|
final absolutePath = vault.memberAbsolutePath(widget.folderPath, member);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
switch (member.kind) {
|
||||||
|
case NotebookMemberKind.note:
|
||||||
|
final now = DateTime.now();
|
||||||
|
final note = Note(
|
||||||
|
id: absolutePath,
|
||||||
|
title: member.title,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)),
|
||||||
|
);
|
||||||
|
case NotebookMemberKind.pdf:
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => PenEditorScreen(pdfPath: absolutePath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case NotebookMemberKind.pptx:
|
||||||
|
case NotebookMemberKind.docx:
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => OfficeDocumentScreen(filePath: absolutePath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case NotebookMemberKind.ppt:
|
||||||
|
// Legacy binary PPT — same image-fallback path as the home screen.
|
||||||
|
await _openLegacyPpt(absolutePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openLegacyPpt(String filePath) async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final pptxService = PptxService();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l.processingPresentation)),
|
||||||
|
);
|
||||||
|
final slideImages = await pptxService.convertToImages(filePath);
|
||||||
|
final extractedText = await pptxService.extractText(filePath);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (slideImages.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l.couldNotOpenPresentation)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => PenSlideScreen(
|
||||||
|
filePath: filePath,
|
||||||
|
slideImagePaths: slideImages,
|
||||||
|
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MemberTile extends StatelessWidget {
|
||||||
|
const _MemberTile({required this.member, required this.onTap});
|
||||||
|
|
||||||
|
final NotebookMember member;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(_iconFor(member.kind), color: _colorFor(member.kind)),
|
||||||
|
title: Text(
|
||||||
|
member.title.isEmpty ? 'Untitled' : member.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(member.kind.name.toUpperCase()),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _iconFor(NotebookMemberKind kind) => switch (kind) {
|
||||||
|
NotebookMemberKind.note => Icons.edit_note,
|
||||||
|
NotebookMemberKind.pdf => Icons.picture_as_pdf,
|
||||||
|
NotebookMemberKind.pptx || NotebookMemberKind.ppt => Icons.slideshow,
|
||||||
|
NotebookMemberKind.docx => Icons.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
Color _colorFor(NotebookMemberKind kind) => switch (kind) {
|
||||||
|
NotebookMemberKind.note => Colors.blue,
|
||||||
|
NotebookMemberKind.pdf => Colors.red,
|
||||||
|
NotebookMemberKind.pptx || NotebookMemberKind.ppt => Colors.orange,
|
||||||
|
NotebookMemberKind.docx => Colors.indigo,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -166,8 +166,13 @@ class VaultSearchIndex {
|
|||||||
}) {
|
}) {
|
||||||
final parts = <String>[title];
|
final parts = <String>[title];
|
||||||
if (sidecar != null) {
|
if (sidecar != null) {
|
||||||
// Typed text boxes: a stroke carries `textContent` regardless of tool
|
// Typed text boxes on the pen-first text tool (SidecarText), plus any
|
||||||
// (EditorTool has only pen/highlighter/eraser; text is a content flag).
|
// legacy stroke-embedded textContent.
|
||||||
|
for (final pageTexts in sidecar.texts.values) {
|
||||||
|
for (final t in pageTexts) {
|
||||||
|
if (t.text.trim().isNotEmpty) parts.add(t.text.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
for (final pageStrokes in sidecar.strokes.values) {
|
for (final pageStrokes in sidecar.strokes.values) {
|
||||||
for (final stroke in pageStrokes) {
|
for (final stroke in pageStrokes) {
|
||||||
final text = stroke.textContent;
|
final text = stroke.textContent;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:path/path.dart' as p;
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../storage/badnote_sidecar.dart';
|
import '../storage/badnote_sidecar.dart';
|
||||||
|
import '../storage/notebook_manifest.dart';
|
||||||
import '../storage/sidecar_store.dart';
|
import '../storage/sidecar_store.dart';
|
||||||
|
|
||||||
/// Suffix appended to a source-file path to form its sidecar path. Kept in sync
|
/// Suffix appended to a source-file path to form its sidecar path. Kept in sync
|
||||||
@@ -78,6 +79,21 @@ class VaultNote {
|
|||||||
final DateTime modified;
|
final DateTime modified;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OneNote-style multi-document notebook: a vault folder with [kNotebookManifestName].
|
||||||
|
class VaultContainer {
|
||||||
|
const VaultContainer({
|
||||||
|
required this.folderPath,
|
||||||
|
required this.title,
|
||||||
|
required this.modified,
|
||||||
|
required this.memberCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String folderPath;
|
||||||
|
final String title;
|
||||||
|
final DateTime modified;
|
||||||
|
final int memberCount;
|
||||||
|
}
|
||||||
|
|
||||||
/// Records the user-picked vault root folder (an Obsidian-style vault) and
|
/// Records the user-picked vault root folder (an Obsidian-style vault) and
|
||||||
/// gates app startup behind a valid choice.
|
/// gates app startup behind a valid choice.
|
||||||
///
|
///
|
||||||
@@ -190,6 +206,169 @@ class VaultService {
|
|||||||
/// at least one importable source file. Returns the notebooks sorted by
|
/// at least one importable source file. Returns the notebooks sorted by
|
||||||
/// source-file mtime, most-recent first. An empty / missing vault yields an
|
/// source-file mtime, most-recent first. An empty / missing vault yields an
|
||||||
/// empty list (never throws).
|
/// empty list (never throws).
|
||||||
|
/// Scan vault folders that hold a [kNotebookManifestName] container.
|
||||||
|
Future<List<VaultContainer>> scanContainers() async {
|
||||||
|
final root = vaultRoot;
|
||||||
|
if (root == null || root.isEmpty) return const [];
|
||||||
|
final dir = Directory(root);
|
||||||
|
if (!await dir.exists()) return const [];
|
||||||
|
|
||||||
|
final out = <VaultContainer>[];
|
||||||
|
await for (final entity in dir.list(followLinks: false)) {
|
||||||
|
if (entity is! Directory) continue;
|
||||||
|
final folderName = p.basename(entity.path);
|
||||||
|
if (folderName.startsWith('.')) continue;
|
||||||
|
final c = await _readContainerFolder(entity);
|
||||||
|
if (c != null) out.add(c);
|
||||||
|
}
|
||||||
|
out.sort((a, b) => b.modified.compareTo(a.modified));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<VaultContainer?> _readContainerFolder(Directory folder) async {
|
||||||
|
final manifest = await NotebookManifest.read(folder.path);
|
||||||
|
if (manifest == null) return null;
|
||||||
|
final file = NotebookManifest.fileIn(folder.path);
|
||||||
|
final stat = await file.stat();
|
||||||
|
final title = manifest.title.trim().isNotEmpty
|
||||||
|
? manifest.title.trim()
|
||||||
|
: p.basename(folder.path);
|
||||||
|
return VaultContainer(
|
||||||
|
folderPath: folder.path,
|
||||||
|
title: title,
|
||||||
|
modified: stat.modified,
|
||||||
|
memberCount: manifest.members.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create an OneNote-style notebook container with one blank ink page.
|
||||||
|
Future<VaultContainer> createNotebookContainer(String title) async {
|
||||||
|
final root = vaultRoot;
|
||||||
|
if (root == null || root.isEmpty) {
|
||||||
|
throw StateError('No vault root is set; cannot create a notebook.');
|
||||||
|
}
|
||||||
|
final trimmed = title.trim();
|
||||||
|
final baseName = _sanitizeFolderName(trimmed);
|
||||||
|
final folder = await _uniqueNotebookFolder(root, baseName);
|
||||||
|
await folder.create(recursive: true);
|
||||||
|
|
||||||
|
final pageId = 'page-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final pageRel = p.join('pages', pageId, kNotebookBaseName);
|
||||||
|
final pageDir = Directory(p.join(folder.path, 'pages', pageId));
|
||||||
|
await pageDir.create(recursive: true);
|
||||||
|
|
||||||
|
final notePath = p.join(folder.path, pageRel);
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
final pageTitle = trimmed.isEmpty ? 'Untitled' : trimmed;
|
||||||
|
await SidecarStore.writeAtomic(
|
||||||
|
File('$notePath$kVaultSidecarSuffix'),
|
||||||
|
BadnoteSidecar(
|
||||||
|
docType: 'notebook',
|
||||||
|
title: pageTitle,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final manifest = NotebookManifest(
|
||||||
|
title: pageTitle,
|
||||||
|
members: [
|
||||||
|
NotebookMember(
|
||||||
|
id: pageId,
|
||||||
|
kind: NotebookMemberKind.note,
|
||||||
|
relativePath: pageRel.replaceAll('\\', '/'),
|
||||||
|
title: pageTitle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await NotebookManifest.write(folder.path, manifest);
|
||||||
|
|
||||||
|
return VaultContainer(
|
||||||
|
folderPath: folder.path,
|
||||||
|
title: pageTitle,
|
||||||
|
modified: now,
|
||||||
|
memberCount: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a blank ink page to an existing container. Returns the new member.
|
||||||
|
Future<NotebookMember> addBlankPageToContainer(
|
||||||
|
String folderPath, {
|
||||||
|
String title = 'Untitled page',
|
||||||
|
}) async {
|
||||||
|
final manifest = await NotebookManifest.read(folderPath);
|
||||||
|
if (manifest == null) {
|
||||||
|
throw StateError('Not a notebook container: $folderPath');
|
||||||
|
}
|
||||||
|
final pageId = 'page-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final pageRel = 'pages/$pageId/$kNotebookBaseName';
|
||||||
|
final pageDir = Directory(p.join(folderPath, 'pages', pageId));
|
||||||
|
await pageDir.create(recursive: true);
|
||||||
|
final notePath = p.join(folderPath, 'pages', pageId, kNotebookBaseName);
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
await SidecarStore.writeAtomic(
|
||||||
|
File('$notePath$kVaultSidecarSuffix'),
|
||||||
|
BadnoteSidecar(
|
||||||
|
docType: 'notebook',
|
||||||
|
title: title,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final member = NotebookMember(
|
||||||
|
id: pageId,
|
||||||
|
kind: NotebookMemberKind.note,
|
||||||
|
relativePath: pageRel,
|
||||||
|
title: title,
|
||||||
|
);
|
||||||
|
await NotebookManifest.write(
|
||||||
|
folderPath,
|
||||||
|
manifest.copyWith(members: [...manifest.members, member]),
|
||||||
|
);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy [sourceAbsolutePath] into the container and register it as a member.
|
||||||
|
Future<NotebookMember> importFileIntoContainer(
|
||||||
|
String folderPath,
|
||||||
|
String sourceAbsolutePath,
|
||||||
|
) async {
|
||||||
|
final manifest = await NotebookManifest.read(folderPath);
|
||||||
|
if (manifest == null) {
|
||||||
|
throw StateError('Not a notebook container: $folderPath');
|
||||||
|
}
|
||||||
|
final basename = p.basename(sourceAbsolutePath);
|
||||||
|
final ext = p.extension(basename).replaceFirst('.', '').toLowerCase();
|
||||||
|
final kind = notebookMemberKindFromExt(ext);
|
||||||
|
if (kind == null || kind == NotebookMemberKind.note) {
|
||||||
|
throw StateError('Unsupported import type: $ext');
|
||||||
|
}
|
||||||
|
final destRel = basename;
|
||||||
|
var destPath = p.join(folderPath, destRel);
|
||||||
|
var n = 2;
|
||||||
|
while (await File(destPath).exists()) {
|
||||||
|
final stem = p.basenameWithoutExtension(basename);
|
||||||
|
destPath = p.join(folderPath, '$stem $n.$ext');
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
await File(sourceAbsolutePath).copy(destPath);
|
||||||
|
final member = NotebookMember(
|
||||||
|
id: 'doc-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
kind: kind,
|
||||||
|
relativePath: p.basename(destPath),
|
||||||
|
title: p.basenameWithoutExtension(destPath),
|
||||||
|
);
|
||||||
|
await NotebookManifest.write(
|
||||||
|
folderPath,
|
||||||
|
manifest.copyWith(members: [...manifest.members, member]),
|
||||||
|
);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute path for a member inside [folderPath].
|
||||||
|
String memberAbsolutePath(String folderPath, NotebookMember member) =>
|
||||||
|
p.normalize(p.join(folderPath, member.relativePath));
|
||||||
|
|
||||||
Future<List<VaultNotebook>> scanNotebooks() async {
|
Future<List<VaultNotebook>> scanNotebooks() async {
|
||||||
final root = vaultRoot;
|
final root = vaultRoot;
|
||||||
if (root == null || root.isEmpty) return const [];
|
if (root == null || root.isEmpty) return const [];
|
||||||
@@ -200,7 +379,9 @@ class VaultService {
|
|||||||
await for (final entity in dir.list(followLinks: false)) {
|
await for (final entity in dir.list(followLinks: false)) {
|
||||||
if (entity is! Directory) continue;
|
if (entity is! Directory) continue;
|
||||||
final folderName = p.basename(entity.path);
|
final folderName = p.basename(entity.path);
|
||||||
if (folderName.startsWith('.')) continue; // skip .badnote etc.
|
if (folderName.startsWith('.')) continue;
|
||||||
|
// Container folders are listed by [scanContainers], not here.
|
||||||
|
if (await NotebookManifest.fileIn(entity.path).exists()) continue;
|
||||||
|
|
||||||
final notebook = await _readNotebookFolder(entity);
|
final notebook = await _readNotebookFolder(entity);
|
||||||
if (notebook != null) notebooks.add(notebook);
|
if (notebook != null) notebooks.add(notebook);
|
||||||
@@ -261,6 +442,7 @@ class VaultService {
|
|||||||
if (entity is! Directory) continue;
|
if (entity is! Directory) continue;
|
||||||
final folderName = p.basename(entity.path);
|
final folderName = p.basename(entity.path);
|
||||||
if (folderName.startsWith('.')) continue;
|
if (folderName.startsWith('.')) continue;
|
||||||
|
if (await NotebookManifest.fileIn(entity.path).exists()) continue;
|
||||||
|
|
||||||
final note = await _readNoteFolder(entity);
|
final note = await _readNoteFolder(entity);
|
||||||
if (note != null) notes.add(note);
|
if (note != null) notes.add(note);
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ class SidecarText {
|
|||||||
required this.text,
|
required this.text,
|
||||||
this.fontSize = 0.03,
|
this.fontSize = 0.03,
|
||||||
this.color = 0xFF000000,
|
this.color = 0xFF000000,
|
||||||
|
this.fontWeight = 400,
|
||||||
|
this.fontFamily,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Stable id (uuid) so edits/deletes address a specific box.
|
/// Stable id (uuid) so edits/deletes address a specific box.
|
||||||
@@ -141,6 +143,12 @@ class SidecarText {
|
|||||||
/// ARGB text color.
|
/// ARGB text color.
|
||||||
final int color;
|
final int color;
|
||||||
|
|
||||||
|
/// CSS-like numeric weight (100–900). Default 400 (regular).
|
||||||
|
final int fontWeight;
|
||||||
|
|
||||||
|
/// Optional family name. Null → editor default (IBM Plex Sans).
|
||||||
|
final String? fontFamily;
|
||||||
|
|
||||||
SidecarText copyWith({
|
SidecarText copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
double? nx,
|
double? nx,
|
||||||
@@ -148,6 +156,9 @@ class SidecarText {
|
|||||||
String? text,
|
String? text,
|
||||||
double? fontSize,
|
double? fontSize,
|
||||||
int? color,
|
int? color,
|
||||||
|
int? fontWeight,
|
||||||
|
String? fontFamily,
|
||||||
|
bool clearFontFamily = false,
|
||||||
}) =>
|
}) =>
|
||||||
SidecarText(
|
SidecarText(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -156,6 +167,9 @@ class SidecarText {
|
|||||||
text: text ?? this.text,
|
text: text ?? this.text,
|
||||||
fontSize: fontSize ?? this.fontSize,
|
fontSize: fontSize ?? this.fontSize,
|
||||||
color: color ?? this.color,
|
color: color ?? this.color,
|
||||||
|
fontWeight: fontWeight ?? this.fontWeight,
|
||||||
|
fontFamily:
|
||||||
|
clearFontFamily ? null : (fontFamily ?? this.fontFamily),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
@@ -165,6 +179,8 @@ class SidecarText {
|
|||||||
'text': text,
|
'text': text,
|
||||||
'fontSize': fontSize,
|
'fontSize': fontSize,
|
||||||
'color': color,
|
'color': color,
|
||||||
|
'fontWeight': fontWeight,
|
||||||
|
if (fontFamily != null) 'fontFamily': fontFamily,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory SidecarText.fromJson(Map<String, dynamic> json) => SidecarText(
|
factory SidecarText.fromJson(Map<String, dynamic> json) => SidecarText(
|
||||||
@@ -174,6 +190,8 @@ class SidecarText {
|
|||||||
text: (json['text'] as String?) ?? '',
|
text: (json['text'] as String?) ?? '',
|
||||||
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 0.03,
|
fontSize: (json['fontSize'] as num?)?.toDouble() ?? 0.03,
|
||||||
color: (json['color'] as num?)?.toInt() ?? 0xFF000000,
|
color: (json['color'] as num?)?.toInt() ?? 0xFF000000,
|
||||||
|
fontWeight: (json['fontWeight'] as num?)?.toInt() ?? 400,
|
||||||
|
fontFamily: json['fontFamily'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -186,15 +204,26 @@ class SidecarText {
|
|||||||
ny == other.ny &&
|
ny == other.ny &&
|
||||||
text == other.text &&
|
text == other.text &&
|
||||||
fontSize == other.fontSize &&
|
fontSize == other.fontSize &&
|
||||||
color == other.color;
|
color == other.color &&
|
||||||
|
fontWeight == other.fontWeight &&
|
||||||
|
fontFamily == other.fontFamily;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(id, nx, ny, text, fontSize, color);
|
int get hashCode => Object.hash(
|
||||||
|
id,
|
||||||
|
nx,
|
||||||
|
ny,
|
||||||
|
text,
|
||||||
|
fontSize,
|
||||||
|
color,
|
||||||
|
fontWeight,
|
||||||
|
fontFamily,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
'SidecarText(id: $id, nx: $nx, ny: $ny, text: $text, '
|
'SidecarText(id: $id, nx: $nx, ny: $ny, text: $text, '
|
||||||
'fontSize: $fontSize, color: $color)';
|
'fontSize: $fontSize, weight: $fontWeight, family: $fontFamily)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An anchor's private infinite scratchpad: a list of [InkStroke]s in ABSOLUTE
|
/// An anchor's private infinite scratchpad: a list of [InkStroke]s in ABSOLUTE
|
||||||
|
|||||||
160
lib/storage/notebook_manifest.dart
Normal file
160
lib/storage/notebook_manifest.dart
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// lib/storage/notebook_manifest.dart
|
||||||
|
//
|
||||||
|
// OneNote-style notebook container: a vault folder with `notebook.json` that
|
||||||
|
// lists members (blank notes + imported PDF/PPTX/DOCX). Each member still uses
|
||||||
|
// its own sidecar; this file is only the table of contents.
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
/// Filename of the notebook container manifest inside a vault folder.
|
||||||
|
const String kNotebookManifestName = 'notebook.json';
|
||||||
|
|
||||||
|
/// Default annotation font family (matches app UI theme).
|
||||||
|
const String kAnnotationFontFamily = 'IBM Plex Sans';
|
||||||
|
|
||||||
|
/// Kind of a notebook member.
|
||||||
|
enum NotebookMemberKind {
|
||||||
|
note,
|
||||||
|
pdf,
|
||||||
|
pptx,
|
||||||
|
ppt,
|
||||||
|
docx,
|
||||||
|
}
|
||||||
|
|
||||||
|
NotebookMemberKind? notebookMemberKindFromExt(String ext) {
|
||||||
|
switch (ext.toLowerCase()) {
|
||||||
|
case 'pdf':
|
||||||
|
return NotebookMemberKind.pdf;
|
||||||
|
case 'pptx':
|
||||||
|
return NotebookMemberKind.pptx;
|
||||||
|
case 'ppt':
|
||||||
|
return NotebookMemberKind.ppt;
|
||||||
|
case 'docx':
|
||||||
|
return NotebookMemberKind.docx;
|
||||||
|
case 'note':
|
||||||
|
case 'notebook':
|
||||||
|
return NotebookMemberKind.note;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String notebookMemberKindToExt(NotebookMemberKind kind) => switch (kind) {
|
||||||
|
NotebookMemberKind.note => 'note',
|
||||||
|
NotebookMemberKind.pdf => 'pdf',
|
||||||
|
NotebookMemberKind.pptx => 'pptx',
|
||||||
|
NotebookMemberKind.ppt => 'ppt',
|
||||||
|
NotebookMemberKind.docx => 'docx',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One page/section inside a notebook container.
|
||||||
|
class NotebookMember {
|
||||||
|
const NotebookMember({
|
||||||
|
required this.id,
|
||||||
|
required this.kind,
|
||||||
|
required this.relativePath,
|
||||||
|
required this.title,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final NotebookMemberKind kind;
|
||||||
|
|
||||||
|
/// Path relative to the notebook folder (POSIX separators preferred).
|
||||||
|
final String relativePath;
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'kind': kind.name,
|
||||||
|
'path': relativePath,
|
||||||
|
'title': title,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory NotebookMember.fromJson(Map<String, dynamic> json) {
|
||||||
|
final kindName = (json['kind'] as String?) ?? 'note';
|
||||||
|
final kind = NotebookMemberKind.values.firstWhere(
|
||||||
|
(k) => k.name == kindName,
|
||||||
|
orElse: () => NotebookMemberKind.note,
|
||||||
|
);
|
||||||
|
return NotebookMember(
|
||||||
|
id: (json['id'] as String?) ?? '',
|
||||||
|
kind: kind,
|
||||||
|
relativePath: (json['path'] as String?) ?? '',
|
||||||
|
title: (json['title'] as String?) ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
NotebookMember copyWith({String? title, String? relativePath}) =>
|
||||||
|
NotebookMember(
|
||||||
|
id: id,
|
||||||
|
kind: kind,
|
||||||
|
relativePath: relativePath ?? this.relativePath,
|
||||||
|
title: title ?? this.title,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Table of contents for a multi-document notebook folder.
|
||||||
|
class NotebookManifest {
|
||||||
|
const NotebookManifest({
|
||||||
|
required this.title,
|
||||||
|
required this.members,
|
||||||
|
this.version = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int version;
|
||||||
|
final String title;
|
||||||
|
final List<NotebookMember> members;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
'title': title,
|
||||||
|
'members': members.map((m) => m.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory NotebookManifest.fromJson(Map<String, dynamic> json) {
|
||||||
|
final raw = (json['members'] as List<dynamic>?) ?? const [];
|
||||||
|
return NotebookManifest(
|
||||||
|
version: (json['version'] as num?)?.toInt() ?? 1,
|
||||||
|
title: (json['title'] as String?) ?? '',
|
||||||
|
members: [
|
||||||
|
for (final e in raw)
|
||||||
|
NotebookMember.fromJson(e as Map<String, dynamic>),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
NotebookManifest copyWith({
|
||||||
|
String? title,
|
||||||
|
List<NotebookMember>? members,
|
||||||
|
}) =>
|
||||||
|
NotebookManifest(
|
||||||
|
version: version,
|
||||||
|
title: title ?? this.title,
|
||||||
|
members: members ?? this.members,
|
||||||
|
);
|
||||||
|
|
||||||
|
static File fileIn(String folderPath) =>
|
||||||
|
File(p.join(folderPath, kNotebookManifestName));
|
||||||
|
|
||||||
|
static Future<NotebookManifest?> read(String folderPath) async {
|
||||||
|
final file = fileIn(folderPath);
|
||||||
|
if (!await file.exists()) return null;
|
||||||
|
try {
|
||||||
|
final map = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
||||||
|
return NotebookManifest.fromJson(map);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> write(String folderPath, NotebookManifest manifest) async {
|
||||||
|
final file = fileIn(folderPath);
|
||||||
|
await file.writeAsString(
|
||||||
|
const JsonEncoder.withIndent(' ').convert(manifest.toJson()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -290,6 +290,24 @@ void main() {
|
|||||||
expect(t.text, '');
|
expect(t.text, '');
|
||||||
expect(t.fontSize, 0.03);
|
expect(t.fontSize, 0.03);
|
||||||
expect(t.color, 0xFF000000);
|
expect(t.color, 0xFF000000);
|
||||||
|
expect(t.fontWeight, 400);
|
||||||
|
expect(t.fontFamily, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SidecarText fontWeight and fontFamily round-trip', () {
|
||||||
|
const tx = SidecarText(
|
||||||
|
id: 't-bold',
|
||||||
|
nx: 0.1,
|
||||||
|
ny: 0.2,
|
||||||
|
text: '粗体',
|
||||||
|
fontSize: 0.045,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontFamily: 'IBM Plex Sans',
|
||||||
|
);
|
||||||
|
final decoded = SidecarText.fromJson(tx.toJson());
|
||||||
|
expect(decoded, tx);
|
||||||
|
expect(decoded.fontWeight, 700);
|
||||||
|
expect(decoded.fontFamily, 'IBM Plex Sans');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('paragraph-anchored bookmark round-trips its anchor + char index', () {
|
test('paragraph-anchored bookmark round-trips its anchor + char index', () {
|
||||||
|
|||||||
@@ -339,4 +339,30 @@ void main() {
|
|||||||
expect(notes.single.title, 'Loose Note');
|
expect(notes.single.title, 'Loose Note');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('notebook container (OneNote-style)', () {
|
||||||
|
test('createNotebookContainer writes manifest + blank page', () async {
|
||||||
|
final vault = await makeService();
|
||||||
|
await vault.setVaultRoot(tempDir.path);
|
||||||
|
|
||||||
|
final c = await vault.createNotebookContainer('Linear Algebra');
|
||||||
|
expect(c.title, 'Linear Algebra');
|
||||||
|
expect(c.memberCount, 1);
|
||||||
|
|
||||||
|
final containers = await vault.scanContainers();
|
||||||
|
expect(containers.map((x) => x.folderPath), contains(c.folderPath));
|
||||||
|
|
||||||
|
// Containers are excluded from the single-doc / free-note scans.
|
||||||
|
expect(await vault.scanNotes(), isEmpty);
|
||||||
|
expect(await vault.scanNotebooks(), isEmpty);
|
||||||
|
|
||||||
|
final page = await vault.addBlankPageToContainer(
|
||||||
|
c.folderPath,
|
||||||
|
title: 'Lecture 2',
|
||||||
|
);
|
||||||
|
expect(page.title, 'Lecture 2');
|
||||||
|
final again = await vault.scanContainers();
|
||||||
|
expect(again.single.memberCount, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user