From 4c8cc731066b80faa0e0a9f65d35a15a66660e42 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 03:38:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(f7):=20infinite-board=20model=20(=E4=BE=BF?= =?UTF-8?q?=E5=88=A9=E8=B4=B4=20cards)=20+=20derived=20=E5=8F=8C=E9=93=BE?= =?UTF-8?q?=20backlinks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Board + BoardCard: positioned sticky-note cards in board coordinates, immutable copy-on-write edits (add/removeById/moveCard/setText, unique ids), cardsIn() broad-phase culling, and linkGraph()/backlinksOf() that derive the 双链 graph from the cards' [[links]] — making link_graph load-bearing. Cards also host ink via a StrokeHost keyed by card id (same host-agnostic engine as PDF pages). Pure model; fully unit-tested (CRUD, immutability, culling, backlinks). flutter analyze lib/editor clean; 215/215 tests (+7). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/editor/board/board.dart | 115 ++++++++++++++++++++++++++++++++++++ test/board_test.dart | 69 ++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 lib/editor/board/board.dart create mode 100644 test/board_test.dart 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)); + }); +}