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

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