feat: OneNote-style notebooks, text fonts, and page navigation
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:
2026-08-05 20:27:35 +08:00
parent 4a6fe7d05e
commit 2b1c6ba7e0
18 changed files with 1344 additions and 112 deletions

View File

@@ -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,
), ),
), ),
), ),

View File

@@ -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 (100900) 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(

View File

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

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

View File

@@ -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"
} }

View File

@@ -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

View File

@@ -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';
} }

View File

@@ -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 => '拖动移动';
} }

View File

@@ -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": "拖动移动"
} }

View 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;
}
}

View File

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

View 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,
};
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 (100900). 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

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

View File

@@ -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', () {

View File

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