diff --git a/lib/editor/board/board.dart b/lib/editor/board/board.dart new file mode 100644 index 0000000..8e77d4f --- /dev/null +++ b/lib/editor/board/board.dart @@ -0,0 +1,115 @@ +// lib/editor/board/board.dart +// +// Infinite-board model (F7 — 便利贴 + 双链). A board is a set of positioned +// cards (sticky notes) in board content coordinates; each card has text that may +// contain [[links]] to other cards, so a board derives a LinkGraph for +// backlinks. Cards also host ink (via a StrokeHost keyed by the card id) — the +// same host-agnostic engine as PDF pages. +// +// Pure, immutable value model (no widgets/storage); fully unit-tested. The board +// canvas + persistence wrap it. + +import 'dart:ui' show Offset, Rect, Size; + +import 'package:flutter/foundation.dart'; + +import '../link/link_graph.dart'; + +/// One sticky-note card on the board. +@immutable +class BoardCard { + const BoardCard({ + required this.id, + required this.position, + required this.size, + this.text = '', + }); + + final String id; + + /// Top-left in board content coordinates. + final Offset position; + final Size size; + + /// Card body; may contain `[[other-card]]` links. + final String text; + + Rect get bounds => position & size; + + BoardCard copyWith({Offset? position, Size? size, String? text}) => BoardCard( + id: id, + position: position ?? this.position, + size: size ?? this.size, + text: text ?? this.text, + ); + + @override + bool operator ==(Object other) => + other is BoardCard && + other.id == id && + other.position == position && + other.size == size && + other.text == text; + + @override + int get hashCode => Object.hash(id, position, size, text); +} + +/// An immutable infinite board: an ordered list of cards with copy-on-write +/// edits. Card ids are unique. +@immutable +class Board { + Board(List cards) : cards = List.unmodifiable(cards); + + static final Board empty = Board(const []); + + final List cards; + + int get length => cards.length; + + BoardCard? cardById(String id) { + for (final c in cards) { + if (c.id == id) return c; + } + return null; + } + + /// Add a card (throws if [card].id already exists). + Board add(BoardCard card) { + if (cardById(card.id) != null) { + throw ArgumentError('duplicate card id: ${card.id}'); + } + return Board([...cards, card]); + } + + Board removeById(String id) => + Board([for (final c in cards) if (c.id != id) c]); + + /// Replace card [id] via [update]; no-op if absent. + Board updateCard(String id, BoardCard Function(BoardCard) update) => + Board([for (final c in cards) if (c.id == id) update(c) else c]); + + Board moveCard(String id, Offset position) => + updateCard(id, (c) => c.copyWith(position: position)); + + Board setText(String id, String text) => + updateCard(id, (c) => c.copyWith(text: text)); + + /// Cards whose bounds overlap [viewport] (board broad-phase culling). + List cardsIn(Rect viewport) => + [for (final c in cards) if (c.bounds.overlaps(viewport)) c]; + + /// Derive the 双链 graph from card texts (card id → its [[links]]). + LinkGraph linkGraph() => + LinkGraph.fromTexts({for (final c in cards) c.id: c.text}); + + /// Backlinks to card [id] (ids of cards whose text links to it). + Set backlinksOf(String id) => linkGraph().backlinksOf(id); + + @override + bool operator ==(Object other) => + other is Board && listEquals(other.cards, cards); + + @override + int get hashCode => Object.hashAll(cards); +} diff --git a/test/board_test.dart b/test/board_test.dart new file mode 100644 index 0000000..f9b2860 --- /dev/null +++ b/test/board_test.dart @@ -0,0 +1,69 @@ +// Tests for the infinite-board model (F7 便利贴 + 双链). + +import 'dart:ui' show Offset, Rect, Size; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/board/board.dart'; + +BoardCard _card(String id, double x, double y, {String text = ''}) => BoardCard( + id: id, + position: Offset(x, y), + size: const Size(100, 80), + text: text, + ); + +void main() { + test('add / cardById / removeById', () { + final b = Board.empty.add(_card('a', 0, 0)).add(_card('b', 200, 0)); + expect(b.length, 2); + expect(b.cardById('a')!.position, const Offset(0, 0)); + expect(b.removeById('a').cardById('a'), isNull); + }); + + test('duplicate id throws', () { + final b = Board.empty.add(_card('a', 0, 0)); + expect(() => b.add(_card('a', 5, 5)), throwsArgumentError); + }); + + test('moveCard / setText are copy-on-write', () { + final b0 = Board.empty.add(_card('a', 0, 0)); + final b1 = b0.moveCard('a', const Offset(50, 60)).setText('a', 'hi'); + expect(b0.cardById('a')!.position, const Offset(0, 0)); // original intact + expect(b1.cardById('a')!.position, const Offset(50, 60)); + expect(b1.cardById('a')!.text, 'hi'); + }); + + test('cardsIn culls cards outside the viewport', () { + final b = Board.empty + .add(_card('near', 0, 0)) // bounds 0,0,100,80 + .add(_card('far', 5000, 5000)); + final visible = b.cardsIn(const Rect.fromLTWH(0, 0, 300, 300)); + expect(visible.map((c) => c.id).toSet(), {'near'}); + }); + + test('linkGraph derives backlinks from card [[links]]', () { + final b = Board.empty + .add(_card('a', 0, 0, text: 'see [[b]] and [[c]]')) + .add(_card('b', 200, 0, text: 'see [[c]]')) + .add(_card('c', 400, 0, text: 'leaf')); + expect(b.backlinksOf('c'), {'a', 'b'}); + expect(b.backlinksOf('b'), {'a'}); + expect(b.backlinksOf('a'), isEmpty); + expect(b.linkGraph().linksFrom('a'), {'b', 'c'}); + }); + + test('value equality + immutable card list', () { + final a = Board.empty.add(_card('x', 1, 1)); + final b = Board.empty.add(_card('x', 1, 1)); + expect(a, b); + expect(() => a.cards.add(_card('y', 0, 0)), throwsUnsupportedError); + }); + + test('BoardCard.bounds + copyWith', () { + final c = _card('a', 10, 20); + expect(c.bounds, const Rect.fromLTWH(10, 20, 100, 80)); + expect(c.copyWith(text: 'z').text, 'z'); + expect(c.copyWith().position, const Offset(10, 20)); + }); +}