Files
BadNote/lib/storage/badnote_sidecar.dart
Akiba So 2b1c6ba7e0
All checks were successful
CI / Windows build (push) Successful in 8m42s
feat: OneNote-style notebooks, text fonts, and page navigation
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>
2026-08-05 20:27:35 +08:00

530 lines
18 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lib/storage/badnote_sidecar.dart
//
// The on-disk sidecar model: all annotations for one source file, serialized as
// `<file>.badnote.json` next to the file ("跟着文件走"). This is Phase 1 of the
// file-based storage plan (docs/plans/2026-06-24-file-based-storage.md §A) — a
// pure model with NO runtime wiring yet.
//
// Design rule: REUSE the existing JSON shapes verbatim; do not invent a parallel
// stroke format. Specifically:
// * per-page ink → List<EditorStroke> (lib/editor/engine/stroke_model.dart;
// byte-for-byte the `ink.stroke_json` column today)
// * scratchpad ink → List<InkStroke> (lib/models/ink_stroke.dart; the exact
// format scratchpads already persist, absolute world px)
// * scratch anchors → ScratchLink (lib/models/scratch_link.dart)
// * bookmarks → Bookmark (lib/models/bookmark.dart)
//
// Only the *containers* and the (previously in-memory-only) highlight rect are
// new here. Unknown JSON fields are ignored on read so the schema is
// forward-compatible (e.g. a future `brush` field — see §A.5 brush TODO).
import 'dart:ui' show Rect;
import '../editor/engine/stroke_model.dart';
import '../models/bookmark.dart';
import '../models/ink_stroke.dart';
import '../models/scratch_link.dart';
/// Current sidecar schema version. Persisted as `badnoteSidecarVersion` for
/// forward-compat; readers tolerate unknown extra fields.
const int kBadnoteSidecarVersion = 1;
/// A single highlighted text rectangle on a page, normalized to the page rect
/// ([0,1] for l/t/r/b — exactly as `_highlightSelection` computes it in
/// pen_editor_screen.dart) plus an ARGB [color]. There is no existing highlight
/// MODEL in the codebase (highlights are in-memory `Rect`s today, see
/// `TODO(persist-highlights)`), so this small value class is the representation.
class SidecarHighlight {
const SidecarHighlight({
required this.l,
required this.t,
required this.r,
required this.b,
this.color = 0xFFFFFF00,
});
/// Normalized left edge in [0,1].
final double l;
/// Normalized top edge in [0,1].
final double t;
/// Normalized right edge in [0,1].
final double r;
/// Normalized bottom edge in [0,1].
final double b;
/// ARGB color of the highlight.
final int color;
/// Builds a highlight from a normalized [Rect] (as stored in
/// `_highlightsByPage`) and an ARGB color.
factory SidecarHighlight.fromRect(Rect rect, {int color = 0xFFFFFF00}) =>
SidecarHighlight(
l: rect.left,
t: rect.top,
r: rect.right,
b: rect.bottom,
color: color,
);
/// The normalized rect (page-relative) for rendering.
Rect toRect() => Rect.fromLTRB(l, t, r, b);
Map<String, dynamic> toJson() => {
'l': l,
't': t,
'r': r,
'b': b,
'color': color,
};
factory SidecarHighlight.fromJson(Map<String, dynamic> json) =>
SidecarHighlight(
l: (json['l'] as num).toDouble(),
t: (json['t'] as num).toDouble(),
r: (json['r'] as num).toDouble(),
b: (json['b'] as num).toDouble(),
color: (json['color'] as num?)?.toInt() ?? 0xFFFFFF00,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SidecarHighlight &&
runtimeType == other.runtimeType &&
l == other.l &&
t == other.t &&
r == other.r &&
b == other.b &&
color == other.color;
@override
int get hashCode => Object.hash(l, t, r, b, color);
@override
String toString() =>
'SidecarHighlight(l: $l, t: $t, r: $r, b: $b, color: $color)';
}
/// A single typed-text annotation on a page (PDF editor for now). Position is
/// NORMALIZED to the page rect ([nx],[ny] in [0,1]) so the box stays glued under
/// zoom/scroll, exactly like [SidecarHighlight] / [ScratchLink]. [fontSize] is
/// PAGE-RELATIVE (a fraction of the page width), so the rendered text scales
/// with the page; the editor multiplies it by the on-screen page width.
class SidecarText {
const SidecarText({
required this.id,
required this.nx,
required this.ny,
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.
final String id;
/// Normalized x of the box's top-left in [0,1].
final double nx;
/// Normalized y of the box's top-left in [0,1].
final double ny;
/// The typed text.
final String text;
/// Font size as a fraction of page WIDTH (page-relative; scales with zoom).
final double fontSize;
/// 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,
double? ny,
String? text,
double? fontSize,
int? color,
int? fontWeight,
String? fontFamily,
bool clearFontFamily = false,
}) =>
SidecarText(
id: id ?? this.id,
nx: nx ?? this.nx,
ny: ny ?? this.ny,
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() => {
'id': id,
'nx': nx,
'ny': ny,
'text': text,
'fontSize': fontSize,
'color': color,
'fontWeight': fontWeight,
if (fontFamily != null) 'fontFamily': fontFamily,
};
factory SidecarText.fromJson(Map<String, dynamic> json) => SidecarText(
id: json['id'] as String,
nx: (json['nx'] as num).toDouble(),
ny: (json['ny'] as num).toDouble(),
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
bool operator ==(Object other) =>
identical(this, other) ||
other is SidecarText &&
runtimeType == other.runtimeType &&
id == other.id &&
nx == other.nx &&
ny == other.ny &&
text == other.text &&
fontSize == other.fontSize &&
color == other.color &&
fontWeight == other.fontWeight &&
fontFamily == other.fontFamily;
@override
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, weight: $fontWeight, family: $fontFamily)';
}
/// An anchor's private infinite scratchpad: a list of [InkStroke]s in ABSOLUTE
/// world pixels (unchanged format from `SplitViewScreen`), plus the world size
/// so it restores (today the canvas always resets to 4000×4000).
class SidecarScratchpad {
const SidecarScratchpad({
this.canvasWidth = 4000.0,
this.canvasHeight = 4000.0,
this.strokes = const [],
});
final double canvasWidth;
final double canvasHeight;
/// Absolute-world-pixel strokes, in `InkStroke.toJson()` format.
final List<InkStroke> strokes;
Map<String, dynamic> toJson() => {
'canvasWidth': canvasWidth,
'canvasHeight': canvasHeight,
'strokes': strokes.map((s) => s.toJson()).toList(),
};
factory SidecarScratchpad.fromJson(Map<String, dynamic> json) =>
SidecarScratchpad(
canvasWidth: (json['canvasWidth'] as num?)?.toDouble() ?? 4000.0,
canvasHeight: (json['canvasHeight'] as num?)?.toDouble() ?? 4000.0,
strokes: ((json['strokes'] as List<dynamic>?) ?? const [])
.map((e) => InkStroke.fromJson(e as Map<String, dynamic>))
.toList(),
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SidecarScratchpad &&
runtimeType == other.runtimeType &&
canvasWidth == other.canvasWidth &&
canvasHeight == other.canvasHeight &&
_listEq(strokes, other.strokes);
@override
int get hashCode =>
Object.hash(canvasWidth, canvasHeight, Object.hashAll(strokes));
@override
String toString() => 'SidecarScratchpad(canvasWidth: $canvasWidth, '
'canvasHeight: $canvasHeight, strokes: ${strokes.length})';
}
/// A scratch link anchor that EMBEDS its private scratchpad (merges today's two
/// SQLite tables — `scratch_links` geometry + `scratchpads` ink — see §A.2).
class SidecarScratchLink {
const SidecarScratchLink({
required this.link,
this.scratchpad = const SidecarScratchpad(),
});
/// Anchor geometry (reuses [ScratchLink] verbatim).
final ScratchLink link;
/// The anchor's private scratchpad.
final SidecarScratchpad scratchpad;
Map<String, dynamic> toJson() => {
...link.toJson(),
'scratchpad': scratchpad.toJson(),
};
factory SidecarScratchLink.fromJson(Map<String, dynamic> json) =>
SidecarScratchLink(
link: ScratchLink.fromJson(json),
scratchpad: json['scratchpad'] == null
? const SidecarScratchpad()
: SidecarScratchpad.fromJson(
json['scratchpad'] as Map<String, dynamic>),
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SidecarScratchLink &&
runtimeType == other.runtimeType &&
link == other.link &&
scratchpad == other.scratchpad;
@override
int get hashCode => Object.hash(link, scratchpad);
@override
String toString() =>
'SidecarScratchLink(link: $link, scratchpad: $scratchpad)';
}
/// The whole sidecar: all annotations for one source file.
///
/// Maps to the JSON in §A.2 of the plan. `strokes` and `highlights` are keyed by
/// page index. Strokes reuse [EditorStroke] JSON; bookmarks reuse [Bookmark]
/// JSON; scratch links reuse [ScratchLink] JSON (embedding [InkStroke] JSON for
/// the scratchpad).
class BadnoteSidecar {
BadnoteSidecar({
this.version = kBadnoteSidecarVersion,
this.sourceFile,
this.docType,
this.title,
this.pageCount,
this.rotation = 0,
this.createdAt,
this.updatedAt,
Map<int, List<EditorStroke>>? strokes,
Map<int, List<SidecarHighlight>>? highlights,
Map<int, List<SidecarText>>? texts,
List<Bookmark>? bookmarks,
List<SidecarScratchLink>? scratchLinks,
Map<int, String>? legacyAnnotations,
this.ocrText,
this.pageText,
this.legacyId,
this.background,
}) : strokes = strokes ?? <int, List<EditorStroke>>{},
highlights = highlights ?? <int, List<SidecarHighlight>>{},
texts = texts ?? <int, List<SidecarText>>{},
bookmarks = bookmarks ?? <Bookmark>[],
scratchLinks = scratchLinks ?? <SidecarScratchLink>[],
legacyAnnotations = legacyAnnotations ?? <int, String>{};
/// Schema version (`badnoteSidecarVersion`).
final int version;
/// Basename of the annotated source file, e.g. `Calculus Lecture 3.pdf`.
final String? sourceFile;
/// `pdf` / `pptx` / `notebook` etc.
final String? docType;
/// Display title for a standalone (non-file-backed) notebook (`docType ==
/// 'notebook'`). Null for file-backed sidecars, whose title is the filename.
final String? title;
final int? pageCount;
final int rotation;
final DateTime? createdAt;
final DateTime? updatedAt;
/// Page index → committed [EditorStroke]s (normalized page coords).
final Map<int, List<EditorStroke>> strokes;
/// Page index → highlighted text rects (normalized).
final Map<int, List<SidecarHighlight>> highlights;
/// Page index → typed-text annotations (normalized position, page-relative
/// font size). PDF editor only for now (note text is a later increment).
final Map<int, List<SidecarText>> texts;
final List<Bookmark> bookmarks;
final List<SidecarScratchLink> scratchLinks;
/// Raw legacy per-page `annotation_json` blobs preserved verbatim from the
/// DEAD pre-editor `annotations` SQLite table (keyed by page number). Populated
/// only by the one-time SQLite→sidecar migration so no legacy data is silently
/// dropped; the live editor ignores it. Empty for all freshly authored
/// sidecars.
final Map<int, String> legacyAnnotations;
/// Searchable text recovered from this notebook's handwriting via local OCR
/// (Phase 6 search index). Persisted in the sidecar — the source of truth —
/// so the vault-scan search index can find handwritten notes WITHOUT the
/// (rebuildable, per-device) SQLite cache. Typed text already lives in the
/// strokes' `textContent`, so this holds ONLY the OCR'd handwriting. Null when
/// the notebook has no handwriting or OCR hasn't run.
final String? ocrText;
/// Searchable text of the underlying DOCUMENT BODY for a file-backed notebook
/// (a PDF), captured ONCE at import time so the vault-scan search index covers
/// the document — not just the user's annotations. It is either the PDF's
/// embedded (printed) text layer, or — for a RASTERIZED / scanned PDF with no
/// text layer — the result of a background OCR pass over the rendered pages.
/// Pages are joined with `\f` (form feed) but the index treats it as a flat
/// blob. Null when the document has not been indexed yet (back-compat: an old
/// sidecar simply omits the field) or has no extractable/recognized text. This
/// is distinct from [ocrText], which holds ONLY handwriting OCR.
final String? pageText;
/// The legacy SQLite row id this sidecar was migrated from (a `documents.id`
/// or `notes.id`). Set ONLY by the one-time migration; it makes the migration
/// idempotent (a re-run recognizes an already-migrated item by this id even if
/// its folder name collided). Null for all freshly authored sidecars.
final String? legacyId;
/// The page-background template for a standalone notebook, stored as the
/// [NoteBackground] enum `name` (e.g. `dots`, `cornell`). Kept as a raw String
/// here so the storage model stays UI-decoupled; the editor decodes it via
/// `noteBackgroundFromName` (missing/unknown → blank, back-compat).
final String? background;
Map<String, dynamic> toJson() => {
'badnoteSidecarVersion': version,
if (sourceFile != null) 'sourceFile': sourceFile,
if (docType != null) 'docType': docType,
if (title != null) 'title': title,
if (pageCount != null) 'pageCount': pageCount,
'rotation': rotation,
if (createdAt != null) 'createdAt': createdAt!.toIso8601String(),
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
'strokes': {
for (final entry in strokes.entries)
entry.key.toString():
entry.value.map((s) => s.toJson()).toList(),
},
'highlights': {
for (final entry in highlights.entries)
entry.key.toString():
entry.value.map((h) => h.toJson()).toList(),
},
if (texts.isNotEmpty)
'texts': {
for (final entry in texts.entries)
entry.key.toString():
entry.value.map((t) => t.toJson()).toList(),
},
'bookmarks': bookmarks.map((b) => b.toJson()).toList(),
'scratchLinks': scratchLinks.map((s) => s.toJson()).toList(),
if (legacyAnnotations.isNotEmpty)
'legacyAnnotations': {
for (final entry in legacyAnnotations.entries)
entry.key.toString(): entry.value,
},
if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText,
if (pageText != null && pageText!.isNotEmpty) 'pageText': pageText,
if (legacyId != null) 'legacyId': legacyId,
if (background != null) 'background': background,
};
factory BadnoteSidecar.fromJson(Map<String, dynamic> json) {
Map<int, List<T>> decodePageMap<T>(
Object? raw,
T Function(Map<String, dynamic>) item,
) {
final out = <int, List<T>>{};
if (raw is Map) {
raw.forEach((key, value) {
final pageIndex = int.tryParse(key.toString());
if (pageIndex == null || value is! List) return;
out[pageIndex] = value
.map((e) => item(e as Map<String, dynamic>))
.toList();
});
}
return out;
}
return BadnoteSidecar(
version: (json['badnoteSidecarVersion'] as num?)?.toInt() ??
kBadnoteSidecarVersion,
sourceFile: json['sourceFile'] as String?,
docType: json['docType'] as String?,
title: json['title'] as String?,
pageCount: (json['pageCount'] as num?)?.toInt(),
rotation: (json['rotation'] as num?)?.toInt() ?? 0,
createdAt: json['createdAt'] == null
? null
: DateTime.tryParse(json['createdAt'] as String),
updatedAt: json['updatedAt'] == null
? null
: DateTime.tryParse(json['updatedAt'] as String),
strokes: decodePageMap(json['strokes'], EditorStroke.fromJson),
highlights: decodePageMap(json['highlights'], SidecarHighlight.fromJson),
texts: decodePageMap(json['texts'], SidecarText.fromJson),
bookmarks: ((json['bookmarks'] as List<dynamic>?) ?? const [])
.map((e) => Bookmark.fromJson(e as Map<String, dynamic>))
.toList(),
scratchLinks: ((json['scratchLinks'] as List<dynamic>?) ?? const [])
.map((e) => SidecarScratchLink.fromJson(e as Map<String, dynamic>))
.toList(),
legacyAnnotations: () {
final raw = json['legacyAnnotations'];
final out = <int, String>{};
if (raw is Map) {
raw.forEach((key, value) {
final page = int.tryParse(key.toString());
if (page != null && value is String) out[page] = value;
});
}
return out;
}(),
ocrText: json['ocrText'] as String?,
pageText: json['pageText'] as String?,
legacyId: json['legacyId'] as String?,
background: json['background'] as String?,
);
}
}
bool _listEq<T>(List<T> a, List<T> b) {
if (identical(a, b)) return true;
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}