From 2b1c6ba7e01ee819b60da4de59dd118ea2197d4e Mon Sep 17 00:00:00 2001 From: Akiba So Date: Wed, 5 Aug 2026 20:27:35 +0800 Subject: [PATCH] feat: OneNote-style notebooks, text fonts, and page navigation 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 --- lib/editor/canvas/office_document_screen.dart | 73 ++-- lib/editor/canvas/pen_editor_screen.dart | 353 +++++++++++++++--- lib/editor/canvas/pen_slide_screen.dart | 12 +- lib/editor/ui/page_nav_shortcuts.dart | 57 +++ lib/l10n/app_en.arb | 15 +- lib/l10n/app_localizations.dart | 60 +++ lib/l10n/app_localizations_en.dart | 32 ++ lib/l10n/app_localizations_zh.dart | 32 ++ lib/l10n/app_zh.arb | 15 +- .../notebook_container_provider.dart | 46 +++ lib/screens/home_screen.dart | 90 ++++- lib/screens/notebook_screen.dart | 239 ++++++++++++ lib/services/vault_search_index.dart | 9 +- lib/services/vault_service.dart | 184 ++++++++- lib/storage/badnote_sidecar.dart | 35 +- lib/storage/notebook_manifest.dart | 160 ++++++++ test/badnote_sidecar_test.dart | 18 + test/vault_service_test.dart | 26 ++ 18 files changed, 1344 insertions(+), 112 deletions(-) create mode 100644 lib/editor/ui/page_nav_shortcuts.dart create mode 100644 lib/providers/notebook_container_provider.dart create mode 100644 lib/screens/notebook_screen.dart create mode 100644 lib/storage/notebook_manifest.dart diff --git a/lib/editor/canvas/office_document_screen.dart b/lib/editor/canvas/office_document_screen.dart index 0b40bcc..36fdc23 100644 --- a/lib/editor/canvas/office_document_screen.dart +++ b/lib/editor/canvas/office_document_screen.dart @@ -12,6 +12,7 @@ import '../../services/office/docx_parser.dart'; import '../../services/office/office_document.dart'; import '../../services/office/pptx_parser.dart'; import '../../theme/app_theme.dart'; +import '../ui/page_nav_shortcuts.dart'; /// Unified native Office viewer + ink annotation (PPTX / DOCX). class OfficeDocumentScreen extends StatefulWidget { @@ -172,39 +173,47 @@ class _OfficeDocumentScreenState extends State { ); } - return Scaffold( - appBar: AppBar( - title: Text(p.basename(widget.filePath)), - actions: [ - IconButton( - onPressed: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null, - icon: const Icon(Icons.chevron_left), - ), - Center(child: Text('${_pageIndex + 1} / $pageCount')), - IconButton( - onPressed: - _pageIndex < pageCount - 1 ? () => _goPage(_pageIndex + 1) : null, - icon: const Icon(Icons.chevron_right), - ), - ], - ), - body: InteractiveViewer( - transformationController: _transform, - minScale: 0.5, - maxScale: 4, - child: Listener( - onPointerDown: _onPointerDown, - onPointerMove: _onPointerMove, - onPointerUp: _onPointerUp, - child: CustomPaint( - painter: _OfficePagePainter( - pptx: _pptx, - docx: _docx, - pageIndex: _pageIndex, - strokes: _strokes, - live: _live, + return pageNavShortcuts( + onPrevious: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null, + onNext: + _pageIndex < pageCount - 1 ? () => _goPage(_pageIndex + 1) : null, + onFirst: pageCount > 0 ? () => _goPage(0) : null, + onLast: pageCount > 0 ? () => _goPage(pageCount - 1) : null, + child: Scaffold( + appBar: AppBar( + title: Text(p.basename(widget.filePath)), + actions: [ + IconButton( + onPressed: _pageIndex > 0 ? () => _goPage(_pageIndex - 1) : null, + icon: const Icon(Icons.chevron_left), + ), + Center(child: Text('${_pageIndex + 1} / $pageCount')), + IconButton( + onPressed: _pageIndex < pageCount - 1 + ? () => _goPage(_pageIndex + 1) + : null, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + body: InteractiveViewer( + transformationController: _transform, + minScale: 0.5, + maxScale: 4, + child: Listener( + onPointerDown: _onPointerDown, + onPointerMove: _onPointerMove, + onPointerUp: _onPointerUp, + child: CustomPaint( + painter: _OfficePagePainter( + pptx: _pptx, + docx: _docx, + pageIndex: _pageIndex, + strokes: _strokes, + live: _live, + ), + size: _pageSize, ), - size: _pageSize, ), ), ), diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 8fcbb6a..10c2aee 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -33,6 +33,7 @@ import '../../l10n/app_localizations.dart'; import '../../models/bookmark.dart'; import '../../models/scratch_link.dart'; import '../../storage/badnote_sidecar.dart'; +import '../../storage/notebook_manifest.dart' show kAnnotationFontFamily; import '../engine/brush.dart'; import '../engine/shape_geometry.dart'; import '../engine/stroke_eraser.dart'; @@ -47,6 +48,7 @@ import '../input/pressure_curve.dart' show PressureCurve, kNaturalPressureFloor; import '../pdf/pen_capture_region.dart'; import '../persistence/sidecar_repository.dart'; +import '../ui/page_nav_shortcuts.dart'; import '../ui/pen_settings_page.dart'; import '../ui/thumbnail_grid.dart'; import 'editor_tool.dart'; @@ -66,6 +68,13 @@ const double _kMarkerSize = 36.0; /// page. 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 { const PenEditorScreen({ super.key, @@ -98,6 +107,10 @@ class _PenEditorScreenState extends State { /// page pill + thumbnail highlight. 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). final Map> _strokesByPage = {}; @@ -1079,6 +1092,7 @@ class _PenEditorScreenState extends State { text: '', fontSize: _kDefaultTextFontFraction, color: _color.toARGB32(), + fontFamily: kAnnotationFontFamily, ); setState(() { _textsByPage[pageIndex] = [...?_textsByPage[pageIndex], box]; @@ -1134,6 +1148,55 @@ class _PenEditorScreenState extends State { _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.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.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 /// box gets the empty-on-blur treatment). void _toggleTextMode() { @@ -1529,44 +1592,66 @@ class _PenEditorScreenState extends State { @override Widget build(BuildContext context) { final l = AppLocalizations.of(context); + final formatBar = _buildTextFormatBar(); return Scaffold( - body: Stack( - children: [ - Positioned.fill(child: _buildViewer()), - // Floating Material You tool palette (top-center). - SafeArea( - child: Align( - alignment: Alignment.topCenter, - child: Padding( - padding: const EdgeInsets.only(top: 8), - child: _buildToolPalette(), - ), - ), - ), - // Floating page-control pill (bottom-center). - if (_viewerReady && _pageCount > 0) + body: pageNavShortcuts( + onPrevious: + _pageIndex > 0 ? () => _goToPage(_pageIndex - 1) : null, + onNext: _pageIndex < _pageCount - 1 + ? () => _goToPage(_pageIndex + 1) + : null, + onFirst: _pageCount > 0 ? () => _goToPage(0) : null, + onLast: _pageCount > 0 ? () => _goToPage(_pageCount - 1) : null, + child: Stack( + children: [ + Positioned.fill(child: _buildViewer()), + // Floating Material You tool palette (top-center). SafeArea( child: Align( - alignment: Alignment.bottomCenter, + alignment: Alignment.topCenter, child: Padding( - padding: const EdgeInsets.only(bottom: 16), - child: _buildPagePill(), + padding: const EdgeInsets.only(top: 8), + child: _buildToolPalette(), ), ), ), - // 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(), + // Compact text-format bar (S/M/L + Bold), shown BELOW the tool + // palette while a text box is being edited. + if (formatBar != null) + SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: Padding( + 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 { text: t.text, fontSizePx: t.fontSize * pageW, color: Color(t.color), + fontWeight: _fontWeightFromValue(t.fontWeight), + fontFamily: t.fontFamily, 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 @@ -1714,6 +1812,8 @@ class _PenEditorScreenState extends State { initialText: t.text, fontSizePx: t.fontSize * pageW, color: Color(t.color), + fontWeight: _fontWeightFromValue(t.fontWeight), + fontFamily: t.fontFamily, hintText: AppLocalizations.of(context).textPlaceholder, onChanged: _updateEditingText, onDone: _finishTextEdit, @@ -1969,6 +2069,64 @@ class _PenEditorScreenState extends State { ); } + /// 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) { // 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. @@ -1992,47 +2150,80 @@ class _PenEditorScreenState extends State { ); } - /// 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() { final cs = Theme.of(context).colorScheme; final l = AppLocalizations.of(context); final total = _pageCount; - final shown = _pageIndex + 1; - return Material( - color: cs.surfaceContainerHigh, - elevation: 3, - borderRadius: BorderRadius.circular(28), - 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: null, - child: Text( - l.pageOfPages(shown, total), - style: TextStyle( - color: cs.onSurface, - fontWeight: FontWeight.w600, + final scrub = _pageScrub; + final shown = (scrub ?? (_pageIndex + 1).toDouble()).round(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (total > 1) + Container( + margin: const EdgeInsets.only(bottom: 8), + constraints: const BoxConstraints(maxWidth: 420), + child: Material( + color: cs.surfaceContainerHigh, + elevation: 3, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Slider( + min: 1, + max: total.toDouble(), + value: (scrub ?? (_pageIndex + 1).toDouble()) + .clamp(1, total.toDouble()), + divisions: total > 1 ? total - 1 : null, + onChanged: (v) => setState(() => _pageScrub = v), + onChangeEnd: (v) { + setState(() => _pageScrub = null); + _goToPage(v.round() - 1); + }, ), ), ), - IconButton( - tooltip: l.nextPage, - icon: const Icon(Icons.chevron_right), - onPressed: _pageIndex < total - 1 - ? () => _goToPage(_pageIndex + 1) - : null, + ), + Material( + color: cs.surfaceContainerHigh, + elevation: 3, + borderRadius: BorderRadius.circular(28), + 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; 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 Widget build(BuildContext context) { 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) { _downKind = d.kind; _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 // touch place on a single tap. if (_downKind == PointerDeviceKind.mouse) return; + final down = _downLocal; + if (down != null && + (d.localPosition - down).distance > _kMaxTapDriftPx) { + return; + } widget.onPlace(d.localPosition); }, onDoubleTapDown: (d) { @@ -2450,24 +2654,43 @@ class _TextAnnotationLabel extends StatelessWidget { required this.text, required this.fontSizePx, required this.color, + this.fontWeight = FontWeight.w400, + this.fontFamily, this.onTap, + this.onDoubleTap, + this.onPanUpdate, }); final String text; final double fontSizePx; final Color color; + final FontWeight fontWeight; + final String? fontFamily; + + /// Single-tap handler (only wired while the TEXT tool is active). 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 Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, + onDoubleTap: onDoubleTap, + onPanUpdate: onPanUpdate, child: Text( text, style: TextStyle( fontSize: fontSizePx, color: color, + fontWeight: fontWeight, + fontFamily: fontFamily ?? kAnnotationFontFamily, height: 1.2, ), ), @@ -2489,6 +2712,8 @@ class _TextAnnotationField extends StatefulWidget { required this.hintText, required this.onChanged, required this.onDone, + this.fontWeight = FontWeight.w400, + this.fontFamily, }); final String initialText; @@ -2497,6 +2722,8 @@ class _TextAnnotationField extends StatefulWidget { final String hintText; final ValueChanged onChanged; final VoidCallback onDone; + final FontWeight fontWeight; + final String? fontFamily; @override State<_TextAnnotationField> createState() => _TextAnnotationFieldState(); @@ -2545,6 +2772,8 @@ class _TextAnnotationFieldState extends State<_TextAnnotationField> { style: TextStyle( fontSize: widget.fontSizePx, color: widget.color, + fontWeight: widget.fontWeight, + fontFamily: widget.fontFamily ?? kAnnotationFontFamily, height: 1.2, ), decoration: InputDecoration( diff --git a/lib/editor/canvas/pen_slide_screen.dart b/lib/editor/canvas/pen_slide_screen.dart index 5019f30..359b947 100644 --- a/lib/editor/canvas/pen_slide_screen.dart +++ b/lib/editor/canvas/pen_slide_screen.dart @@ -22,6 +22,7 @@ import '../input/pen_input_service.dart'; import '../input/pressure_curve.dart' show kNaturalPressureGamma; import '../layout/viewport_fit.dart'; import '../pdf/slide_export.dart'; +import '../ui/page_nav_shortcuts.dart'; import '../ui/pen_settings_page.dart'; import 'editor_tool.dart'; import 'pen_canvas.dart'; @@ -335,7 +336,15 @@ class _PenSlideScreenState extends State { @override Widget build(BuildContext context) { 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( children: [ Positioned.fill(child: _buildCanvas()), @@ -383,6 +392,7 @@ class _PenSlideScreenState extends State { ), ], ), + ), ); } diff --git a/lib/editor/ui/page_nav_shortcuts.dart b/lib/editor/ui/page_nav_shortcuts.dart new file mode 100644 index 0000000..730c33e --- /dev/null +++ b/lib/editor/ui/page_nav_shortcuts.dart @@ -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: { + 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, + ), + ); +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 18da9bf..d686069 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -237,5 +237,18 @@ "@diagExported": { "placeholders": { "bytes": { "type": "int" } } }, "diagExportFail": "Export failed: {error}", "@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" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ce94ec5..796f546 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1153,6 +1153,66 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Processing OCR…'** 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 diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e9b70d0..a220e9b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -588,4 +588,36 @@ class AppLocalizationsEn extends AppLocalizations { @override 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'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 44e8552..f5669e3 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -580,4 +580,36 @@ class AppLocalizationsZh extends AppLocalizations { @override 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 => '拖动移动'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 700cfe2..c097d54 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -207,5 +207,18 @@ "@diagExported": { "placeholders": { "bytes": { "type": "int" } } }, "diagExportFail": "导出失败:{error}", "@diagExportFail": { "placeholders": { "error": { "type": "String" } } }, - "processingOcr": "正在识别文字…" + "processingOcr": "正在识别文字…", + "notebooksSection": "笔记本", + "addBlankPage": "空白页", + "importIntoNotebook": "导入到笔记本", + "notebookMembersEmpty": "还没有页面", + "memberCount": "{count} 项", + "@memberCount": { + "placeholders": { "count": { "type": "int" } } + }, + "textFontSmall": "小", + "textFontMedium": "中", + "textFontLarge": "大", + "textBold": "粗体", + "textDragHint": "拖动移动" } diff --git a/lib/providers/notebook_container_provider.dart b/lib/providers/notebook_container_provider.dart new file mode 100644 index 0000000..a1faaa0 --- /dev/null +++ b/lib/providers/notebook_container_provider.dart @@ -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>( + NotebookContainerListNotifier.new, +); + +class NotebookContainerListNotifier + extends AsyncNotifier> { + Future get _vault => ref.read(vaultServiceProvider.future); + + @override + Future> build() => _scan(); + + Future> _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 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 createContainer(String title) async { + final vault = await _vault; + final container = await vault.createNotebookContainer(title); + state = AsyncData([container, ...state.value ?? []]); + return container; + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index b6f2175..66fbc00 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -9,6 +9,7 @@ import '../models/document.dart'; import '../models/note.dart'; import '../providers/document_provider.dart'; import '../providers/note_provider.dart'; +import '../providers/notebook_container_provider.dart'; import '../providers/ocr_provider.dart'; import '../providers/search_provider.dart'; import '../editor/canvas/pen_editor_screen.dart'; @@ -17,6 +18,7 @@ import '../services/pptx_service.dart'; import '../services/vault_service.dart'; import '../editor/canvas/pen_note_screen.dart'; import '../editor/canvas/pen_slide_screen.dart'; +import 'notebook_screen.dart'; import 'search_screen.dart'; import 'settings_screen.dart'; @@ -88,8 +90,10 @@ class HomeScreen extends ConsumerWidget { data: (notes) { final documentsAsync = ref.watch(documentListProvider); 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 RefreshIndicator( @@ -97,10 +101,32 @@ class HomeScreen extends ConsumerWidget { await Future.wait([ ref.read(noteListProvider.notifier).loadNotes(), ref.read(documentListProvider.notifier).loadDocuments(), + ref + .read(notebookContainerListProvider.notifier) + .loadContainers(), ]); }, child: CustomScrollView( 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( child: Padding( 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 - /// standalone notebook FOLDER + `notebook.badnote.json` via - /// `VaultService.createEmptyNotebook`, then open the editor on the new note. + /// "Create notebook" (OneNote style): prompt a title (defaulting to + /// Untitled), create the notebook container FOLDER + `notebook.json` (with + /// one blank ink page) via `VaultService.createNotebookContainer`, then open + /// [NotebookScreen] on it. Future _createAndOpenNote(BuildContext context, WidgetRef ref) async { final title = await _promptNotebookTitle(context); if (title == null) return; // cancelled @@ -199,12 +226,18 @@ class HomeScreen extends ConsumerWidget { final resolved = title.trim().isEmpty ? (l?.untitledNote ?? 'Untitled') : title.trim(); - final note = - await ref.read(noteListProvider.notifier).createNote(title: resolved); + final container = await ref + .read(notebookContainerListProvider.notifier) + .createContainer(resolved); if (context.mounted) { - Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => PenNoteScreen(note: note))); + Navigator.of(context).push( + MaterialPageRoute( + 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 { final Note note; const _NoteTile({required this.note}); diff --git a/lib/screens/notebook_screen.dart b/lib/screens/notebook_screen.dart new file mode 100644 index 0000000..a0b0e92 --- /dev/null +++ b/lib/screens/notebook_screen.dart @@ -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 createState() => _NotebookScreenState(); +} + +class _NotebookScreenState extends ConsumerState { + NotebookManifest? _manifest; + bool _loading = true; + + @override + void initState() { + super.initState(); + _reload(); + } + + Future _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 _addBlankPage() async { + final vault = await ref.read(vaultServiceProvider.future); + await vault.addBlankPageToContainer(widget.folderPath); + await _reload(); + } + + Future _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 _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 _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, + }; +} diff --git a/lib/services/vault_search_index.dart b/lib/services/vault_search_index.dart index 851dd62..c23cfa0 100644 --- a/lib/services/vault_search_index.dart +++ b/lib/services/vault_search_index.dart @@ -166,8 +166,13 @@ class VaultSearchIndex { }) { final parts = [title]; if (sidecar != null) { - // Typed text boxes: a stroke carries `textContent` regardless of tool - // (EditorTool has only pen/highlighter/eraser; text is a content flag). + // Typed text boxes on the pen-first text tool (SidecarText), plus any + // 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 stroke in pageStrokes) { final text = stroke.textContent; diff --git a/lib/services/vault_service.dart b/lib/services/vault_service.dart index 42bf1be..7bfeb15 100644 --- a/lib/services/vault_service.dart +++ b/lib/services/vault_service.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart' as p; import 'package:shared_preferences/shared_preferences.dart'; import '../storage/badnote_sidecar.dart'; +import '../storage/notebook_manifest.dart'; import '../storage/sidecar_store.dart'; /// Suffix appended to a source-file path to form its sidecar path. Kept in sync @@ -78,6 +79,21 @@ class VaultNote { 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 /// gates app startup behind a valid choice. /// @@ -190,6 +206,169 @@ class VaultService { /// at least one importable source file. Returns the notebooks sorted by /// source-file mtime, most-recent first. An empty / missing vault yields an /// empty list (never throws). + /// Scan vault folders that hold a [kNotebookManifestName] container. + Future> scanContainers() async { + final root = vaultRoot; + if (root == null || root.isEmpty) return const []; + final dir = Directory(root); + if (!await dir.exists()) return const []; + + final out = []; + 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 _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 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 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 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> scanNotebooks() async { final root = vaultRoot; if (root == null || root.isEmpty) return const []; @@ -200,7 +379,9 @@ class VaultService { await for (final entity in dir.list(followLinks: false)) { if (entity is! Directory) continue; 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); if (notebook != null) notebooks.add(notebook); @@ -261,6 +442,7 @@ class VaultService { if (entity is! Directory) continue; final folderName = p.basename(entity.path); if (folderName.startsWith('.')) continue; + if (await NotebookManifest.fileIn(entity.path).exists()) continue; final note = await _readNoteFolder(entity); if (note != null) notes.add(note); diff --git a/lib/storage/badnote_sidecar.dart b/lib/storage/badnote_sidecar.dart index b14c4c2..ca18ea9 100644 --- a/lib/storage/badnote_sidecar.dart +++ b/lib/storage/badnote_sidecar.dart @@ -121,6 +121,8 @@ class SidecarText { required this.text, this.fontSize = 0.03, this.color = 0xFF000000, + this.fontWeight = 400, + this.fontFamily, }); /// Stable id (uuid) so edits/deletes address a specific box. @@ -141,6 +143,12 @@ class SidecarText { /// ARGB text 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({ String? id, double? nx, @@ -148,6 +156,9 @@ class SidecarText { String? text, double? fontSize, int? color, + int? fontWeight, + String? fontFamily, + bool clearFontFamily = false, }) => SidecarText( id: id ?? this.id, @@ -156,6 +167,9 @@ class SidecarText { text: text ?? this.text, fontSize: fontSize ?? this.fontSize, color: color ?? this.color, + fontWeight: fontWeight ?? this.fontWeight, + fontFamily: + clearFontFamily ? null : (fontFamily ?? this.fontFamily), ); Map toJson() => { @@ -165,6 +179,8 @@ class SidecarText { 'text': text, 'fontSize': fontSize, 'color': color, + 'fontWeight': fontWeight, + if (fontFamily != null) 'fontFamily': fontFamily, }; factory SidecarText.fromJson(Map json) => SidecarText( @@ -174,6 +190,8 @@ class SidecarText { text: (json['text'] as String?) ?? '', fontSize: (json['fontSize'] as num?)?.toDouble() ?? 0.03, color: (json['color'] as num?)?.toInt() ?? 0xFF000000, + fontWeight: (json['fontWeight'] as num?)?.toInt() ?? 400, + fontFamily: json['fontFamily'] as String?, ); @override @@ -186,15 +204,26 @@ class SidecarText { ny == other.ny && text == other.text && fontSize == other.fontSize && - color == other.color; + color == other.color && + fontWeight == other.fontWeight && + fontFamily == other.fontFamily; @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 String toString() => '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 diff --git a/lib/storage/notebook_manifest.dart b/lib/storage/notebook_manifest.dart new file mode 100644 index 0000000..8be9ecc --- /dev/null +++ b/lib/storage/notebook_manifest.dart @@ -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 toJson() => { + 'id': id, + 'kind': kind.name, + 'path': relativePath, + 'title': title, + }; + + factory NotebookMember.fromJson(Map 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 members; + + Map toJson() => { + 'version': version, + 'title': title, + 'members': members.map((m) => m.toJson()).toList(), + }; + + factory NotebookManifest.fromJson(Map json) { + final raw = (json['members'] as List?) ?? 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), + ], + ); + } + + NotebookManifest copyWith({ + String? title, + List? members, + }) => + NotebookManifest( + version: version, + title: title ?? this.title, + members: members ?? this.members, + ); + + static File fileIn(String folderPath) => + File(p.join(folderPath, kNotebookManifestName)); + + static Future read(String folderPath) async { + final file = fileIn(folderPath); + if (!await file.exists()) return null; + try { + final map = jsonDecode(await file.readAsString()) as Map; + return NotebookManifest.fromJson(map); + } catch (_) { + return null; + } + } + + static Future write(String folderPath, NotebookManifest manifest) async { + final file = fileIn(folderPath); + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(manifest.toJson()), + ); + } +} diff --git a/test/badnote_sidecar_test.dart b/test/badnote_sidecar_test.dart index 70e16a5..325656d 100644 --- a/test/badnote_sidecar_test.dart +++ b/test/badnote_sidecar_test.dart @@ -290,6 +290,24 @@ void main() { expect(t.text, ''); expect(t.fontSize, 0.03); 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', () { diff --git a/test/vault_service_test.dart b/test/vault_service_test.dart index 0196a94..ccdbf53 100644 --- a/test/vault_service_test.dart +++ b/test/vault_service_test.dart @@ -339,4 +339,30 @@ void main() { 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); + }); + }); }