feat(note): page background templates (rnote-style)
Some checks failed
CI / Windows build (push) Has been cancelled
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:
160
lib/editor/canvas/note_background.dart
Normal file
160
lib/editor/canvas/note_background.dart
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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, () {
|
||||
|
||||
@@ -224,6 +224,7 @@ class BadnoteSidecar {
|
||||
Map<int, String>? legacyAnnotations,
|
||||
this.ocrText,
|
||||
this.legacyId,
|
||||
this.background,
|
||||
}) : strokes = strokes ?? <int, List<EditorStroke>>{},
|
||||
highlights = highlights ?? <int, List<SidecarHighlight>>{},
|
||||
bookmarks = bookmarks ?? <Bookmark>[],
|
||||
@@ -278,6 +279,12 @@ class BadnoteSidecar {
|
||||
/// 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,
|
||||
@@ -306,6 +313,7 @@ class BadnoteSidecar {
|
||||
},
|
||||
if (ocrText != null && ocrText!.isNotEmpty) 'ocrText': ocrText,
|
||||
if (legacyId != null) 'legacyId': legacyId,
|
||||
if (background != null) 'background': background,
|
||||
};
|
||||
|
||||
factory BadnoteSidecar.fromJson(Map<String, dynamic> json) {
|
||||
@@ -361,6 +369,7 @@ class BadnoteSidecar {
|
||||
}(),
|
||||
ocrText: json['ocrText'] as String?,
|
||||
legacyId: json['legacyId'] as String?,
|
||||
background: json['background'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,22 @@ void main() {
|
||||
expect(reparsed.strokes[0]!.single.id, 's0');
|
||||
});
|
||||
|
||||
test('background name round-trips through the sidecar', () {
|
||||
final original = BadnoteSidecar(docType: 'notebook', background: 'cornell');
|
||||
final reparsed = _roundTrip(original);
|
||||
expect(reparsed.background, 'cornell');
|
||||
// Omitted when null (no key bloat for blank/legacy sidecars).
|
||||
expect(BadnoteSidecar().toJson().containsKey('background'), isFalse);
|
||||
});
|
||||
|
||||
test('missing background decodes to null (back-compat → blank)', () {
|
||||
final json =
|
||||
jsonDecode(jsonEncode(BadnoteSidecar().toJson())) as Map<String, dynamic>;
|
||||
expect(json.containsKey('background'), isFalse);
|
||||
final reparsed = BadnoteSidecar.fromJson(json);
|
||||
expect(reparsed.background, isNull);
|
||||
});
|
||||
|
||||
test('missing scratchpad defaults to 4000x4000 empty pad', () {
|
||||
final json = SidecarScratchLink(
|
||||
link: const ScratchLink(
|
||||
|
||||
41
test/note_background_test.dart
Normal file
41
test/note_background_test.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
// test/note_background_test.dart
|
||||
//
|
||||
// Covers the NoteBackground template enum decode (back-compat: missing/unknown →
|
||||
// blank) and the painter's shouldRepaint reacting to a background change.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:badnote/editor/canvas/note_background.dart';
|
||||
|
||||
void main() {
|
||||
group('noteBackgroundFromName', () {
|
||||
test('decodes each enum name round-trip', () {
|
||||
for (final b in NoteBackground.values) {
|
||||
expect(noteBackgroundFromName(b.name), b);
|
||||
}
|
||||
});
|
||||
|
||||
test('null → blank (back-compat for legacy sidecars)', () {
|
||||
expect(noteBackgroundFromName(null), NoteBackground.blank);
|
||||
});
|
||||
|
||||
test('unknown name → blank (forward-compat)', () {
|
||||
expect(noteBackgroundFromName('isometric'), NoteBackground.blank);
|
||||
expect(noteBackgroundFromName(''), NoteBackground.blank);
|
||||
});
|
||||
});
|
||||
|
||||
group('NoteBackgroundPainter.shouldRepaint', () {
|
||||
test('repaints when the background changes', () {
|
||||
const a = NoteBackgroundPainter(NoteBackground.blank);
|
||||
const b = NoteBackgroundPainter(NoteBackground.dots);
|
||||
expect(a.shouldRepaint(b), isTrue);
|
||||
});
|
||||
|
||||
test('does not repaint when the background is identical', () {
|
||||
const a = NoteBackgroundPainter(NoteBackground.grid);
|
||||
const b = NoteBackgroundPainter(NoteBackground.grid);
|
||||
expect(a.shouldRepaint(b), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user