feat(f6): one-notebook-per-PDF logical PageMap (insert-blank/reorder) pure core
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
The SpeedyNote-style page binding the user asked for: a notebook is an ordered list of logical pages, each a SOURCE page (renders PDF page N, vector preserved) or a BLANK inserted page. Crucially, inserting/reordering logical pages does NOT renumber the PDF underlay — each page carries its source index. Copy-on-write edits (insertBlankAt/After, removeAt, move) return a new immutable PageMap; out-of-range edits throw RangeError; value equality + unmodifiable page list. Pure model (no DB/widget) so it's fully unit-tested; the notebook_pages table + viewport wire it later. flutter analyze lib/editor clean; 156/156 tests (+11). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
121
lib/editor/notebook/page_map.dart
Normal file
121
lib/editor/notebook/page_map.dart
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// lib/editor/notebook/page_map.dart
|
||||||
|
//
|
||||||
|
// The one-notebook-per-PDF logical page model (F6). A notebook is an ordered
|
||||||
|
// list of LOGICAL pages; each is either a SOURCE page (shows PDF page N, vector
|
||||||
|
// preserved) or a BLANK page inserted between/after PDF pages (SpeedyNote-style
|
||||||
|
// binding the user asked for). Ink hosts attach to logical pages, so inserting
|
||||||
|
// or reordering pages must NOT renumber the PDF underlay — the source index is
|
||||||
|
// carried on each page.
|
||||||
|
//
|
||||||
|
// Pure, immutable value type (every edit returns a NEW PageMap) so the model is
|
||||||
|
// fully unit-tested; the `notebook_pages` table + the viewport wire it later.
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
enum NotebookPageKind { source, blank }
|
||||||
|
|
||||||
|
/// One logical page. [sourcePageIndex] is the 0-based PDF page it renders, or
|
||||||
|
/// null for a [NotebookPageKind.blank] inserted page.
|
||||||
|
@immutable
|
||||||
|
class NotebookPage {
|
||||||
|
const NotebookPage.source(this.sourcePageIndex)
|
||||||
|
: kind = NotebookPageKind.source;
|
||||||
|
|
||||||
|
const NotebookPage.blank()
|
||||||
|
: kind = NotebookPageKind.blank,
|
||||||
|
sourcePageIndex = null;
|
||||||
|
|
||||||
|
final NotebookPageKind kind;
|
||||||
|
final int? sourcePageIndex;
|
||||||
|
|
||||||
|
bool get isBlank => kind == NotebookPageKind.blank;
|
||||||
|
bool get isSource => kind == NotebookPageKind.source;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is NotebookPage &&
|
||||||
|
other.kind == kind &&
|
||||||
|
other.sourcePageIndex == sourcePageIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(kind, sourcePageIndex);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
isBlank ? 'NotebookPage.blank' : 'NotebookPage.source($sourcePageIndex)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordered logical page list with copy-on-write edits.
|
||||||
|
@immutable
|
||||||
|
class PageMap {
|
||||||
|
const PageMap(this.pages);
|
||||||
|
|
||||||
|
/// Identity map: one logical SOURCE page per PDF page, in order.
|
||||||
|
factory PageMap.fromSource(int sourcePageCount) {
|
||||||
|
assert(sourcePageCount >= 0);
|
||||||
|
return PageMap(
|
||||||
|
List<NotebookPage>.unmodifiable(
|
||||||
|
List<NotebookPage>.generate(
|
||||||
|
sourcePageCount,
|
||||||
|
(i) => NotebookPage.source(i),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<NotebookPage> pages;
|
||||||
|
|
||||||
|
int get length => pages.length;
|
||||||
|
bool get isEmpty => pages.isEmpty;
|
||||||
|
|
||||||
|
NotebookPage operator [](int index) => pages[index];
|
||||||
|
|
||||||
|
/// Source PDF page rendered at logical [index], or null when it's a blank.
|
||||||
|
int? sourceIndexAt(int index) => pages[index].sourcePageIndex;
|
||||||
|
|
||||||
|
int get blankCount => pages.where((p) => p.isBlank).length;
|
||||||
|
int get sourceCount => pages.where((p) => p.isSource).length;
|
||||||
|
|
||||||
|
/// Insert a blank page at logical [index] (0..length). Throws [RangeError]
|
||||||
|
/// for an out-of-range index.
|
||||||
|
PageMap insertBlankAt(int index) {
|
||||||
|
RangeError.checkValueInInterval(index, 0, length, 'index');
|
||||||
|
final next = List<NotebookPage>.of(pages)
|
||||||
|
..insert(index, const NotebookPage.blank());
|
||||||
|
return PageMap(List<NotebookPage>.unmodifiable(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a blank page immediately after logical [index] (-1 prepends).
|
||||||
|
PageMap insertBlankAfter(int index) {
|
||||||
|
RangeError.checkValueInInterval(index, -1, length - 1, 'index');
|
||||||
|
return insertBlankAt(index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the logical page at [index].
|
||||||
|
PageMap removeAt(int index) {
|
||||||
|
RangeError.checkValidIndex(index, pages, 'index');
|
||||||
|
final next = List<NotebookPage>.of(pages)..removeAt(index);
|
||||||
|
return PageMap(List<NotebookPage>.unmodifiable(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move the page at [from] to position [to] (drag-reorder).
|
||||||
|
PageMap move(int from, int to) {
|
||||||
|
RangeError.checkValidIndex(from, pages, 'from');
|
||||||
|
RangeError.checkValueInInterval(to, 0, length - 1, 'to');
|
||||||
|
if (from == to) return this;
|
||||||
|
final next = List<NotebookPage>.of(pages);
|
||||||
|
final page = next.removeAt(from);
|
||||||
|
next.insert(to, page);
|
||||||
|
return PageMap(List<NotebookPage>.unmodifiable(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is PageMap && listEquals(other.pages, pages);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hashAll(pages);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'PageMap($pages)';
|
||||||
|
}
|
||||||
89
test/page_map_test.dart
Normal file
89
test/page_map_test.dart
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// Tests for the one-notebook-per-PDF logical page model (F6).
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/notebook/page_map.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('fromSource is the identity map (one source page per PDF page)', () {
|
||||||
|
final m = PageMap.fromSource(3);
|
||||||
|
expect(m.length, 3);
|
||||||
|
expect(m.sourceCount, 3);
|
||||||
|
expect(m.blankCount, 0);
|
||||||
|
expect([m.sourceIndexAt(0), m.sourceIndexAt(1), m.sourceIndexAt(2)],
|
||||||
|
[0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fromSource(0) is empty', () {
|
||||||
|
final m = PageMap.fromSource(0);
|
||||||
|
expect(m.isEmpty, isTrue);
|
||||||
|
expect(m.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('insertBlankAt inserts a blank and shifts logical order, NOT source idx',
|
||||||
|
() {
|
||||||
|
final m = PageMap.fromSource(2).insertBlankAt(1);
|
||||||
|
expect(m.length, 3);
|
||||||
|
expect(m[0], const NotebookPage.source(0));
|
||||||
|
expect(m[1].isBlank, isTrue);
|
||||||
|
expect(m[2], const NotebookPage.source(1)); // source index UNCHANGED
|
||||||
|
expect(m.sourceIndexAt(1), isNull);
|
||||||
|
expect(m.blankCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('insertBlankAfter(-1) prepends; after(last) appends', () {
|
||||||
|
final m = PageMap.fromSource(2);
|
||||||
|
expect(m.insertBlankAfter(-1)[0].isBlank, isTrue);
|
||||||
|
final appended = m.insertBlankAfter(1);
|
||||||
|
expect(appended[2].isBlank, isTrue);
|
||||||
|
expect(appended.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('insertBlankAt at length appends (boundary)', () {
|
||||||
|
final m = PageMap.fromSource(2).insertBlankAt(2);
|
||||||
|
expect(m.length, 3);
|
||||||
|
expect(m[2].isBlank, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeAt drops the logical page', () {
|
||||||
|
final m = PageMap.fromSource(3).removeAt(1);
|
||||||
|
expect(m.length, 2);
|
||||||
|
expect([m.sourceIndexAt(0), m.sourceIndexAt(1)], [0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('move reorders (drag-reorder thumbnails)', () {
|
||||||
|
final m = PageMap.fromSource(3).move(0, 2); // [1,2,0]
|
||||||
|
expect([m.sourceIndexAt(0), m.sourceIndexAt(1), m.sourceIndexAt(2)],
|
||||||
|
[1, 2, 0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('move to same index is a no-op (returns identical instance)', () {
|
||||||
|
final m = PageMap.fromSource(3);
|
||||||
|
expect(identical(m.move(1, 1), m), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('out-of-range edits throw RangeError', () {
|
||||||
|
final m = PageMap.fromSource(2);
|
||||||
|
expect(() => m.insertBlankAt(3), throwsRangeError);
|
||||||
|
expect(() => m.removeAt(2), throwsRangeError);
|
||||||
|
expect(() => m.move(0, 5), throwsRangeError);
|
||||||
|
expect(() => m.insertBlankAfter(2), throwsRangeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('value equality + immutability of the page list', () {
|
||||||
|
expect(PageMap.fromSource(2), PageMap.fromSource(2));
|
||||||
|
expect(PageMap.fromSource(2).insertBlankAt(0) == PageMap.fromSource(2),
|
||||||
|
isFalse);
|
||||||
|
final m = PageMap.fromSource(1);
|
||||||
|
expect(() => m.pages.add(const NotebookPage.blank()),
|
||||||
|
throwsUnsupportedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NotebookPage equality distinguishes kind + source index', () {
|
||||||
|
expect(const NotebookPage.source(1), const NotebookPage.source(1));
|
||||||
|
expect(const NotebookPage.source(1) == const NotebookPage.source(2),
|
||||||
|
isFalse);
|
||||||
|
expect(const NotebookPage.blank() == const NotebookPage.source(0), isFalse);
|
||||||
|
expect(const NotebookPage.blank(), const NotebookPage.blank());
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user