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

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

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