diff --git a/lib/editor/notebook/page_map.dart b/lib/editor/notebook/page_map.dart new file mode 100644 index 0000000..f24dd30 --- /dev/null +++ b/lib/editor/notebook/page_map.dart @@ -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.unmodifiable( + List.generate( + sourcePageCount, + (i) => NotebookPage.source(i), + ), + ), + ); + } + + final List 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.of(pages) + ..insert(index, const NotebookPage.blank()); + return PageMap(List.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.of(pages)..removeAt(index); + return PageMap(List.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.of(pages); + final page = next.removeAt(from); + next.insert(to, page); + return PageMap(List.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)'; +} diff --git a/test/page_map_test.dart b/test/page_map_test.dart new file mode 100644 index 0000000..f0feb6d --- /dev/null +++ b/test/page_map_test.dart @@ -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()); + }); +}