feat(note): page background templates (rnote-style)
Some checks failed
CI / Windows build (push) Has been cancelled

A blank note can show a page-background template painted behind the
ink, picked from the toolbar and persisted per notebook.

- NoteBackground: blank / dots / ruled / grid / cornell, drawn in
  page space (scales with zoom), subtle grey. Cornell = left margin +
  bottom summary rule over a ruled body.
- Stored as the enum name in the notebook sidecar (back-compat:
  missing/unknown -> blank), saved/loaded via SidecarRepository so it
  restores on reopen.
- Picker added to the note tool palette.

PDF backgrounds skipped (PDFs have their own page content). analyze
clean, 386 tests green.
This commit is contained in:
2026-06-24 23:47:29 +08:00
parent 46589a4c87
commit c800295c12
6 changed files with 303 additions and 2 deletions

View File

@@ -0,0 +1,160 @@
// lib/editor/canvas/note_background.dart
//
// rnote-style page background TEMPLATES for the blank-note editor. A background
// is a repeating PATTERN painted in the note page's local pixel space (the
// `pageWidget` is sized to the page rect inside the InteractiveViewer, so a
// CustomPainter here scales 1:1 with zoom — no extra transform needed).
//
// The choice is per-notebook and persists in the sidecar (stored as the enum
// `name`; missing/unknown → [NoteBackground.blank] for back-compat).
import 'package:flutter/material.dart';
/// The available page-background templates (rnote: blank + dots/lines/grid +
/// the Cornell note layout).
enum NoteBackground {
/// Plain white sheet, no pattern.
blank,
/// A regular grid of small dots (dotted paper).
dots,
/// Evenly spaced horizontal lines (ruled / lined paper).
ruled,
/// Square grid (graph paper).
grid,
/// Cornell layout: a left cue-column line + a bottom summary line over a
/// ruled note-taking body.
cornell,
}
/// Decode a persisted background name (the enum [NoteBackground.name]); unknown
/// or missing values fall back to [NoteBackground.blank] (back-compat).
NoteBackground noteBackgroundFromName(String? name) {
for (final b in NoteBackground.values) {
if (b.name == name) return b;
}
return NoteBackground.blank;
}
/// Localized-ish English display label for the picker menu.
String noteBackgroundLabel(NoteBackground b) {
switch (b) {
case NoteBackground.blank:
return 'Blank';
case NoteBackground.dots:
return 'Dots';
case NoteBackground.ruled:
return 'Ruled lines';
case NoteBackground.grid:
return 'Grid';
case NoteBackground.cornell:
return 'Cornell';
}
}
/// An icon for the picker menu.
IconData noteBackgroundIcon(NoteBackground b) {
switch (b) {
case NoteBackground.blank:
return Icons.crop_portrait;
case NoteBackground.dots:
return Icons.grain;
case NoteBackground.ruled:
return Icons.notes;
case NoteBackground.grid:
return Icons.grid_4x4;
case NoteBackground.cornell:
return Icons.view_quilt_outlined;
}
}
/// Paints a [NoteBackground] template behind the ink, in the page's local pixel
/// space. Spacing is page-relative (a fraction of page width) so the template
/// looks the same on any logical page size, and the lines are a light, subtle
/// grey so they sit behind handwriting.
class NoteBackgroundPainter extends CustomPainter {
const NoteBackgroundPainter(this.background);
final NoteBackground background;
/// Pattern spacing as a fraction of the page WIDTH — a ~28-line page.
static const double _spacingFraction = 1 / 28;
static const Color _lineColor = Color(0x1A000000); // ~10% black, subtle grey.
static const Color _dotColor = Color(0x33000000); // dots a touch darker.
static const Color _accentColor = Color(0x33335C81); // Cornell margin lines.
@override
void paint(Canvas canvas, Size size) {
if (background == NoteBackground.blank) return;
final spacing = size.width * _spacingFraction;
if (spacing <= 0) return;
switch (background) {
case NoteBackground.blank:
break;
case NoteBackground.dots:
_paintDots(canvas, size, spacing);
case NoteBackground.ruled:
_paintRuled(canvas, size, spacing);
case NoteBackground.grid:
_paintGrid(canvas, size, spacing);
case NoteBackground.cornell:
_paintCornell(canvas, size, spacing);
}
}
void _paintDots(Canvas canvas, Size size, double spacing) {
final paint = Paint()
..color = _dotColor
..style = PaintingStyle.fill;
final r = (spacing * 0.06).clamp(0.6, 2.0);
for (double y = spacing; y < size.height; y += spacing) {
for (double x = spacing; x < size.width; x += spacing) {
canvas.drawCircle(Offset(x, y), r, paint);
}
}
}
void _paintRuled(Canvas canvas, Size size, double spacing) {
final paint = Paint()
..color = _lineColor
..strokeWidth = 1.0;
for (double y = spacing; y < size.height; y += spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
void _paintGrid(Canvas canvas, Size size, double spacing) {
final paint = Paint()
..color = _lineColor
..strokeWidth = 1.0;
for (double y = spacing; y < size.height; y += spacing) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
for (double x = spacing; x < size.width; x += spacing) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
}
void _paintCornell(Canvas canvas, Size size, double spacing) {
// Ruled body lines.
_paintRuled(canvas, size, spacing);
final accent = Paint()
..color = _accentColor
..strokeWidth = 1.4;
// Left cue-column vertical line (~25% of width).
final cueX = size.width * 0.25;
// Bottom summary horizontal line (~80% down).
final summaryY = size.height * 0.80;
canvas.drawLine(Offset(cueX, 0), Offset(cueX, summaryY), accent);
canvas.drawLine(Offset(0, summaryY), Offset(size.width, summaryY), accent);
}
@override
bool shouldRepaint(covariant NoteBackgroundPainter oldDelegate) =>
oldDelegate.background != background;
}

View File

@@ -24,6 +24,7 @@ import '../layout/viewport_fit.dart';
import '../notebook/ink_stroke_adapter.dart';
import '../ui/pen_settings_page.dart';
import 'editor_tool.dart';
import 'note_background.dart';
import 'pen_canvas.dart';
import 'pen_palette_widgets.dart';
import 'pen_stroke.dart';
@@ -83,6 +84,10 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
/// The active drawing color (the active brush's remembered color).
Color get _color => _brushColors[_activeColorBrush] ?? Colors.black;
/// The page-background template painted behind the ink (rnote-style). Default
/// blank; persisted per-notebook in the sidecar.
NoteBackground _background = NoteBackground.blank;
bool _allowFingerDrawing = false;
bool _dirty = false;
bool _needsCenter = true;
@@ -151,6 +156,7 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
if (title != null && title.isNotEmpty) {
_titleController.text = title;
}
_background = noteBackgroundFromName(repo.loadedBackground);
});
}
@@ -288,6 +294,7 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
for (final s in _strokes) EditorStroke.fromPenStroke(s),
];
repo.scheduleTitleSave(title);
repo.scheduleBackgroundSave(_background.name);
repo.scheduleStrokeSave(_notePageIndex, editorStrokes);
await repo.flush();
@@ -475,8 +482,10 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
allowFingerDrawing: _allowFingerDrawing,
onStrokeComplete: _commitStroke,
onEraseStroke: _eraseStroke,
// A white sheet with a soft shadow — the note "paper".
pageWidget: Container(
// A white sheet with a soft shadow — the note "paper" — overlaid with
// the selected background template, painted in page-pixel space (so it
// scales with zoom) and BEHIND the ink layers.
pageWidget: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
@@ -487,6 +496,10 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
),
],
),
child: CustomPaint(
painter: NoteBackgroundPainter(_background),
size: Size.infinite,
),
),
);
},
@@ -570,6 +583,55 @@ class _PenNoteScreenState extends ConsumerState<PenNoteScreen> {
PaletteDivider(cs: cs),
for (final c in _palette) _colorDot(c, cs),
PaletteDivider(cs: cs),
// Page-background template picker (rnote-style: blank / dots / ruled
// / grid / cornell). Persists per-notebook in the sidecar.
PopupMenuButton<NoteBackground>(
tooltip: 'Page background',
initialValue: _background,
onSelected: (b) {
setState(() {
_background = b;
_dirty = true;
});
},
itemBuilder: (context) => [
for (final b in NoteBackground.values)
PopupMenuItem<NoteBackground>(
value: b,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(noteBackgroundIcon(b), size: 20),
const SizedBox(width: 10),
Text(noteBackgroundLabel(b)),
if (b == _background) ...[
const SizedBox(width: 8),
Icon(Icons.check, size: 18, color: cs.primary),
],
],
),
),
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
noteBackgroundIcon(_background),
size: 22,
color: cs.onSurfaceVariant,
),
Icon(
Icons.arrow_drop_down,
size: 18,
color: cs.onSurfaceVariant,
),
],
),
),
),
PaletteDivider(cs: cs),
ToolButton(
icon: _allowFingerDrawing ? Icons.touch_app : Icons.do_not_touch,
selected: _allowFingerDrawing,

View File

@@ -145,6 +145,10 @@ class SidecarRepository {
/// The standalone-notebook title loaded from the sidecar, or null.
String? get loadedTitle => _sidecar.title;
/// The page-background template name loaded from the sidecar, or null
/// (missing → blank, decoded by the editor).
String? get loadedBackground => _sidecar.background;
// ── Mutations (synchronous in-memory update + debounced atomic write) ──────
/// Replace the standalone-notebook title and schedule a save. No-op if the
@@ -154,6 +158,13 @@ class SidecarRepository {
_replace(title: title);
}
/// Replace the page-background template (a [NoteBackground] enum name) and
/// schedule a save. No-op if unchanged.
void scheduleBackgroundSave(String background) {
if (_sidecar.background == background) return;
_replace(background: background);
}
/// Replace the handwriting-OCR search text and schedule a save (Phase 6
/// search index). No-op if unchanged.
void scheduleOcrTextSave(String? ocrText) {
@@ -266,6 +277,7 @@ class SidecarRepository {
List<SidecarScratchLink>? scratchLinks,
String? ocrText,
bool clearOcrText = false,
String? background,
}) {
if (_disposed) return;
_sidecar = BadnoteSidecar(
@@ -282,6 +294,7 @@ class SidecarRepository {
bookmarks: _sidecar.bookmarks,
scratchLinks: scratchLinks ?? _sidecar.scratchLinks,
ocrText: clearOcrText ? null : (ocrText ?? _sidecar.ocrText),
background: background ?? _sidecar.background,
);
_timer?.cancel();
_timer = Timer(_debounce, () {