feat(f7): infinite-board model (便利贴 cards) + derived 双链 backlinks
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
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) <noreply@anthropic.com>
This commit is contained in:
115
lib/editor/board/board.dart
Normal file
115
lib/editor/board/board.dart
Normal file
@@ -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<BoardCard> cards) : cards = List<BoardCard>.unmodifiable(cards);
|
||||
|
||||
static final Board empty = Board(const []);
|
||||
|
||||
final List<BoardCard> 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<BoardCard> 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<String> 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);
|
||||
}
|
||||
69
test/board_test.dart
Normal file
69
test/board_test.dart
Normal file
@@ -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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user