feat: OneNote-style notebooks, text fonts, and page navigation
All checks were successful
CI / Windows build (push) Successful in 8m42s
All checks were successful
CI / Windows build (push) Successful in 8m42s
Add notebook.json containers with multi-member pages, fix PDF text editing (size/bold/drag/double-tap), index SidecarText in search, and share keyboard page shortcuts plus a PDF scrubber. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import '../../services/office/docx_parser.dart';
|
||||
import '../../services/office/office_document.dart';
|
||||
import '../../services/office/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<OfficeDocumentScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<PenEditorScreen> {
|
||||
/// 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<int, List<PenStroke>> _strokesByPage = {};
|
||||
|
||||
@@ -1079,6 +1092,7 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
text: '',
|
||||
fontSize: _kDefaultTextFontFraction,
|
||||
color: _color.toARGB32(),
|
||||
fontFamily: kAnnotationFontFamily,
|
||||
);
|
||||
setState(() {
|
||||
_textsByPage[pageIndex] = [...?_textsByPage[pageIndex], box];
|
||||
@@ -1134,6 +1148,55 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
_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
|
||||
/// box gets the empty-on-blur treatment).
|
||||
void _toggleTextMode() {
|
||||
@@ -1529,44 +1592,66 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
||||
@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<PenEditorScreen> {
|
||||
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<PenEditorScreen> {
|
||||
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<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) {
|
||||
// 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<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() {
|
||||
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<String> 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(
|
||||
|
||||
@@ -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<PenSlideScreen> {
|
||||
@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<PenSlideScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
57
lib/editor/ui/page_nav_shortcuts.dart
Normal file
57
lib/editor/ui/page_nav_shortcuts.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
// lib/editor/ui/page_nav_shortcuts.dart
|
||||
//
|
||||
// Shared keyboard page navigation for PDF / slide / office editors.
|
||||
// Arrow keys + PageUp/PageDown (+ Home/End when provided).
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class PreviousPageIntent extends Intent {
|
||||
const PreviousPageIntent();
|
||||
}
|
||||
|
||||
class NextPageIntent extends Intent {
|
||||
const NextPageIntent();
|
||||
}
|
||||
|
||||
class FirstPageIntent extends Intent {
|
||||
const FirstPageIntent();
|
||||
}
|
||||
|
||||
class LastPageIntent extends Intent {
|
||||
const LastPageIntent();
|
||||
}
|
||||
|
||||
/// Wraps [child] so ←/→/PageUp/PageDown(/Home/End) drive page changes.
|
||||
Widget pageNavShortcuts({
|
||||
required Widget child,
|
||||
required VoidCallback? onPrevious,
|
||||
required VoidCallback? onNext,
|
||||
VoidCallback? onFirst,
|
||||
VoidCallback? onLast,
|
||||
}) {
|
||||
return Focus(
|
||||
autofocus: true,
|
||||
child: CallbackShortcuts(
|
||||
bindings: <ShortcutActivator, VoidCallback>{
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft): () =>
|
||||
onPrevious?.call(),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp): () =>
|
||||
onPrevious?.call(),
|
||||
const SingleActivator(LogicalKeyboardKey.pageUp): () =>
|
||||
onPrevious?.call(),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight): () =>
|
||||
onNext?.call(),
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown): () =>
|
||||
onNext?.call(),
|
||||
const SingleActivator(LogicalKeyboardKey.pageDown): () =>
|
||||
onNext?.call(),
|
||||
if (onFirst != null)
|
||||
const SingleActivator(LogicalKeyboardKey.home): onFirst,
|
||||
if (onLast != null)
|
||||
const SingleActivator(LogicalKeyboardKey.end): onLast,
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -237,5 +237,18 @@
|
||||
"@diagExported": { "placeholders": { "bytes": { "type": "int" } } },
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => '拖动移动';
|
||||
}
|
||||
|
||||
@@ -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": "拖动移动"
|
||||
}
|
||||
|
||||
46
lib/providers/notebook_container_provider.dart
Normal file
46
lib/providers/notebook_container_provider.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
// lib/providers/notebook_container_provider.dart
|
||||
//
|
||||
// Home-screen list of OneNote-style notebook containers: vault folders that
|
||||
// hold a `notebook.json` manifest (see `storage/notebook_manifest.dart`). This
|
||||
// mirrors `note_provider.dart` / `document_provider.dart`'s vault-scan pattern
|
||||
// — the manifest on disk is the source of truth, there is no SQLite cache.
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../services/vault_service.dart';
|
||||
import 'document_provider.dart' show vaultServiceProvider;
|
||||
|
||||
final notebookContainerListProvider = AsyncNotifierProvider<
|
||||
NotebookContainerListNotifier, List<VaultContainer>>(
|
||||
NotebookContainerListNotifier.new,
|
||||
);
|
||||
|
||||
class NotebookContainerListNotifier
|
||||
extends AsyncNotifier<List<VaultContainer>> {
|
||||
Future<VaultService> get _vault => ref.read(vaultServiceProvider.future);
|
||||
|
||||
@override
|
||||
Future<List<VaultContainer>> build() => _scan();
|
||||
|
||||
Future<List<VaultContainer>> _scan() async {
|
||||
final vault = await _vault;
|
||||
return vault.scanContainers();
|
||||
}
|
||||
|
||||
/// Re-scan the vault and publish the result. Used by pull-to-refresh and
|
||||
/// after a container is created elsewhere.
|
||||
Future<void> loadContainers() async {
|
||||
state = const AsyncLoading();
|
||||
state = await AsyncValue.guard(_scan);
|
||||
}
|
||||
|
||||
/// Create a new notebook container (folder + `notebook.json` + one blank ink
|
||||
/// page) titled [title], prepend it to the list, and return it so the caller
|
||||
/// can navigate straight into it.
|
||||
Future<VaultContainer> createContainer(String title) async {
|
||||
final vault = await _vault;
|
||||
final container = await vault.createNotebookContainer(title);
|
||||
state = AsyncData([container, ...state.value ?? []]);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import '../models/document.dart';
|
||||
import '../models/note.dart';
|
||||
import '../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<void> _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});
|
||||
|
||||
239
lib/screens/notebook_screen.dart
Normal file
239
lib/screens/notebook_screen.dart
Normal file
@@ -0,0 +1,239 @@
|
||||
// lib/screens/notebook_screen.dart
|
||||
//
|
||||
// OneNote-style notebook container screen: lists the members (pages/imported
|
||||
// documents) of one vault folder holding a `notebook.json` manifest, and
|
||||
// routes taps to the matching editor by `NotebookMemberKind`. The open-by-type
|
||||
// routing intentionally MIRRORS `home_screen.dart`'s `_DocumentTile._openDocument`
|
||||
// so a member opens in exactly the editor its extension would on the home
|
||||
// screen (pdf → PenEditorScreen; pptx/docx → OfficeDocumentScreen; legacy ppt →
|
||||
// image-converted PenSlideScreen; note → PenNoteScreen).
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../editor/canvas/office_document_screen.dart';
|
||||
import '../editor/canvas/pen_editor_screen.dart';
|
||||
import '../editor/canvas/pen_note_screen.dart';
|
||||
import '../editor/canvas/pen_slide_screen.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/note.dart';
|
||||
import '../providers/document_provider.dart' show vaultServiceProvider;
|
||||
import '../services/pptx_service.dart';
|
||||
import '../services/vault_service.dart';
|
||||
import '../storage/notebook_manifest.dart';
|
||||
|
||||
/// Shows one notebook container's table of contents and lets the user add a
|
||||
/// blank ink page or import a file into it.
|
||||
class NotebookScreen extends ConsumerStatefulWidget {
|
||||
const NotebookScreen({
|
||||
super.key,
|
||||
required this.folderPath,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
/// Absolute path to the notebook container folder (holds `notebook.json`).
|
||||
final String folderPath;
|
||||
|
||||
/// Title shown before the manifest loads (and as a fallback if it has none).
|
||||
final String title;
|
||||
|
||||
@override
|
||||
ConsumerState<NotebookScreen> createState() => _NotebookScreenState();
|
||||
}
|
||||
|
||||
class _NotebookScreenState extends ConsumerState<NotebookScreen> {
|
||||
NotebookManifest? _manifest;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_reload();
|
||||
}
|
||||
|
||||
Future<void> _reload() async {
|
||||
final manifest = await NotebookManifest.read(widget.folderPath);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_manifest = manifest;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context);
|
||||
final members = _manifest?.members ?? const [];
|
||||
final title = (_manifest?.title.trim().isNotEmpty ?? false)
|
||||
? _manifest!.title.trim()
|
||||
: widget.title;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.note_add_outlined),
|
||||
tooltip: l.addBlankPage,
|
||||
onPressed: _addBlankPage,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.file_open),
|
||||
tooltip: l.importIntoNotebook,
|
||||
onPressed: _importFile,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: members.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
l.notebookMembersEmpty,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _reload,
|
||||
child: ListView.builder(
|
||||
itemCount: members.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = members[index];
|
||||
return _MemberTile(
|
||||
member: member,
|
||||
onTap: () => _openMember(member),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addBlankPage() async {
|
||||
final vault = await ref.read(vaultServiceProvider.future);
|
||||
await vault.addBlankPageToContainer(widget.folderPath);
|
||||
await _reload();
|
||||
}
|
||||
|
||||
Future<void> _importFile() async {
|
||||
final l = AppLocalizations.of(context);
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: VaultService.importableExtensions.toList(),
|
||||
);
|
||||
final pickedPath = result?.files.first.path;
|
||||
if (pickedPath == null || !mounted) return;
|
||||
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(SnackBar(content: Text(l.processingImport)));
|
||||
try {
|
||||
final vault = await ref.read(vaultServiceProvider.future);
|
||||
await vault.importFileIntoContainer(widget.folderPath, pickedPath);
|
||||
await _reload();
|
||||
} catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(l.importFailed('$e'))));
|
||||
}
|
||||
}
|
||||
|
||||
/// Route [member] to the correct editor by kind — same switch as
|
||||
/// `home_screen.dart`'s `_DocumentTile._openDocument`.
|
||||
Future<void> _openMember(NotebookMember member) async {
|
||||
final vault = await ref.read(vaultServiceProvider.future);
|
||||
final absolutePath = vault.memberAbsolutePath(widget.folderPath, member);
|
||||
if (!mounted) return;
|
||||
|
||||
switch (member.kind) {
|
||||
case NotebookMemberKind.note:
|
||||
final now = DateTime.now();
|
||||
final note = Note(
|
||||
id: absolutePath,
|
||||
title: member.title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => PenNoteScreen(note: note)),
|
||||
);
|
||||
case NotebookMemberKind.pdf:
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PenEditorScreen(pdfPath: absolutePath),
|
||||
),
|
||||
);
|
||||
case NotebookMemberKind.pptx:
|
||||
case NotebookMemberKind.docx:
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => OfficeDocumentScreen(filePath: absolutePath),
|
||||
),
|
||||
);
|
||||
case NotebookMemberKind.ppt:
|
||||
// Legacy binary PPT — same image-fallback path as the home screen.
|
||||
await _openLegacyPpt(absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openLegacyPpt(String filePath) async {
|
||||
final l = AppLocalizations.of(context);
|
||||
final pptxService = PptxService();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.processingPresentation)),
|
||||
);
|
||||
final slideImages = await pptxService.convertToImages(filePath);
|
||||
final extractedText = await pptxService.extractText(filePath);
|
||||
if (!mounted) return;
|
||||
if (slideImages.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.couldNotOpenPresentation)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PenSlideScreen(
|
||||
filePath: filePath,
|
||||
slideImagePaths: slideImages,
|
||||
extractedText: extractedText.isEmpty ? null : extractedText,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberTile extends StatelessWidget {
|
||||
const _MemberTile({required this.member, required this.onTap});
|
||||
|
||||
final NotebookMember member;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
leading: Icon(_iconFor(member.kind), color: _colorFor(member.kind)),
|
||||
title: Text(
|
||||
member.title.isEmpty ? 'Untitled' : member.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(member.kind.name.toUpperCase()),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
IconData _iconFor(NotebookMemberKind kind) => switch (kind) {
|
||||
NotebookMemberKind.note => Icons.edit_note,
|
||||
NotebookMemberKind.pdf => Icons.picture_as_pdf,
|
||||
NotebookMemberKind.pptx || NotebookMemberKind.ppt => Icons.slideshow,
|
||||
NotebookMemberKind.docx => Icons.description,
|
||||
};
|
||||
|
||||
Color _colorFor(NotebookMemberKind kind) => switch (kind) {
|
||||
NotebookMemberKind.note => Colors.blue,
|
||||
NotebookMemberKind.pdf => Colors.red,
|
||||
NotebookMemberKind.pptx || NotebookMemberKind.ppt => Colors.orange,
|
||||
NotebookMemberKind.docx => Colors.indigo,
|
||||
};
|
||||
}
|
||||
@@ -166,8 +166,13 @@ class VaultSearchIndex {
|
||||
}) {
|
||||
final parts = <String>[title];
|
||||
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;
|
||||
|
||||
@@ -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<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 {
|
||||
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);
|
||||
|
||||
@@ -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<String, dynamic> toJson() => {
|
||||
@@ -165,6 +179,8 @@ class SidecarText {
|
||||
'text': text,
|
||||
'fontSize': fontSize,
|
||||
'color': color,
|
||||
'fontWeight': fontWeight,
|
||||
if (fontFamily != null) 'fontFamily': fontFamily,
|
||||
};
|
||||
|
||||
factory SidecarText.fromJson(Map<String, dynamic> 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
|
||||
|
||||
160
lib/storage/notebook_manifest.dart
Normal file
160
lib/storage/notebook_manifest.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
// lib/storage/notebook_manifest.dart
|
||||
//
|
||||
// OneNote-style notebook container: a vault folder with `notebook.json` that
|
||||
// lists members (blank notes + imported PDF/PPTX/DOCX). Each member still uses
|
||||
// its own sidecar; this file is only the table of contents.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// Filename of the notebook container manifest inside a vault folder.
|
||||
const String kNotebookManifestName = 'notebook.json';
|
||||
|
||||
/// Default annotation font family (matches app UI theme).
|
||||
const String kAnnotationFontFamily = 'IBM Plex Sans';
|
||||
|
||||
/// Kind of a notebook member.
|
||||
enum NotebookMemberKind {
|
||||
note,
|
||||
pdf,
|
||||
pptx,
|
||||
ppt,
|
||||
docx,
|
||||
}
|
||||
|
||||
NotebookMemberKind? notebookMemberKindFromExt(String ext) {
|
||||
switch (ext.toLowerCase()) {
|
||||
case 'pdf':
|
||||
return NotebookMemberKind.pdf;
|
||||
case 'pptx':
|
||||
return NotebookMemberKind.pptx;
|
||||
case 'ppt':
|
||||
return NotebookMemberKind.ppt;
|
||||
case 'docx':
|
||||
return NotebookMemberKind.docx;
|
||||
case 'note':
|
||||
case 'notebook':
|
||||
return NotebookMemberKind.note;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String notebookMemberKindToExt(NotebookMemberKind kind) => switch (kind) {
|
||||
NotebookMemberKind.note => 'note',
|
||||
NotebookMemberKind.pdf => 'pdf',
|
||||
NotebookMemberKind.pptx => 'pptx',
|
||||
NotebookMemberKind.ppt => 'ppt',
|
||||
NotebookMemberKind.docx => 'docx',
|
||||
};
|
||||
|
||||
/// One page/section inside a notebook container.
|
||||
class NotebookMember {
|
||||
const NotebookMember({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
required this.relativePath,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final NotebookMemberKind kind;
|
||||
|
||||
/// Path relative to the notebook folder (POSIX separators preferred).
|
||||
final String relativePath;
|
||||
|
||||
final String title;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'kind': kind.name,
|
||||
'path': relativePath,
|
||||
'title': title,
|
||||
};
|
||||
|
||||
factory NotebookMember.fromJson(Map<String, dynamic> json) {
|
||||
final kindName = (json['kind'] as String?) ?? 'note';
|
||||
final kind = NotebookMemberKind.values.firstWhere(
|
||||
(k) => k.name == kindName,
|
||||
orElse: () => NotebookMemberKind.note,
|
||||
);
|
||||
return NotebookMember(
|
||||
id: (json['id'] as String?) ?? '',
|
||||
kind: kind,
|
||||
relativePath: (json['path'] as String?) ?? '',
|
||||
title: (json['title'] as String?) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
NotebookMember copyWith({String? title, String? relativePath}) =>
|
||||
NotebookMember(
|
||||
id: id,
|
||||
kind: kind,
|
||||
relativePath: relativePath ?? this.relativePath,
|
||||
title: title ?? this.title,
|
||||
);
|
||||
}
|
||||
|
||||
/// Table of contents for a multi-document notebook folder.
|
||||
class NotebookManifest {
|
||||
const NotebookManifest({
|
||||
required this.title,
|
||||
required this.members,
|
||||
this.version = 1,
|
||||
});
|
||||
|
||||
final int version;
|
||||
final String title;
|
||||
final List<NotebookMember> members;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'version': version,
|
||||
'title': title,
|
||||
'members': members.map((m) => m.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory NotebookManifest.fromJson(Map<String, dynamic> json) {
|
||||
final raw = (json['members'] as List<dynamic>?) ?? const [];
|
||||
return NotebookManifest(
|
||||
version: (json['version'] as num?)?.toInt() ?? 1,
|
||||
title: (json['title'] as String?) ?? '',
|
||||
members: [
|
||||
for (final e in raw)
|
||||
NotebookMember.fromJson(e as Map<String, dynamic>),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
NotebookManifest copyWith({
|
||||
String? title,
|
||||
List<NotebookMember>? members,
|
||||
}) =>
|
||||
NotebookManifest(
|
||||
version: version,
|
||||
title: title ?? this.title,
|
||||
members: members ?? this.members,
|
||||
);
|
||||
|
||||
static File fileIn(String folderPath) =>
|
||||
File(p.join(folderPath, kNotebookManifestName));
|
||||
|
||||
static Future<NotebookManifest?> read(String folderPath) async {
|
||||
final file = fileIn(folderPath);
|
||||
if (!await file.exists()) return null;
|
||||
try {
|
||||
final map = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
||||
return NotebookManifest.fromJson(map);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> write(String folderPath, NotebookManifest manifest) async {
|
||||
final file = fileIn(folderPath);
|
||||
await file.writeAsString(
|
||||
const JsonEncoder.withIndent(' ').convert(manifest.toJson()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,24 @@ void main() {
|
||||
expect(t.text, '');
|
||||
expect(t.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', () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user