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

View File

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

View File

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

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