diff --git a/lib/editor/canvas/pen_editor_screen.dart b/lib/editor/canvas/pen_editor_screen.dart index 37ed4a7..0b7b176 100644 --- a/lib/editor/canvas/pen_editor_screen.dart +++ b/lib/editor/canvas/pen_editor_screen.dart @@ -23,8 +23,11 @@ import 'package:flutter/gestures.dart' show PointerDeviceKind; import 'package:flutter/material.dart'; import 'package:pdfrx/pdfrx.dart'; +import 'package:uuid/uuid.dart'; import '../../l10n/app_localizations.dart'; +import '../../models/scratch_link.dart'; +import '../../screens/split_view_screen.dart'; import '../../services/database_service.dart'; import '../engine/brush.dart'; import '../engine/stroke_eraser.dart'; @@ -53,6 +56,10 @@ import 'pen_stroke.dart'; /// Produces a fixed-length hex string from the path so the id is filesystem- /// independent (no slashes, spaces, or non-ASCII characters) and stable across /// restarts. Collisions are astronomically unlikely for a single-user app. +/// On-screen size (px) of a scratch-link anchor marker. Fixed in screen space +/// (not scaled with zoom) so the tap target stays comfortably tappable. +const double _kMarkerSize = 36.0; + String _documentIdFromPath(String path) { var hash = 5381; for (final c in path.codeUnits) { @@ -156,6 +163,17 @@ class _PenEditorScreenState extends State { /// pen falls through to pdfrx for native text selection. bool _selectTextMode = false; + /// When true the "place scratch link" tool is active: a tap on a page drops a + /// new anchor (a sticky-note tab) instead of inking. Pen capture is disabled + /// so the tap is handled by the per-page GestureDetector overlay. + bool _placeLinkMode = false; + + /// All scratch-link anchors for this document, loaded on open and updated on + /// add/delete. Rendered as tappable markers in [pageOverlaysBuilder]. + final List _scratchLinks = []; + + static const _uuid = Uuid(); + Color _color = Colors.black; bool _allowFingerDrawing = false; @@ -176,8 +194,9 @@ class _PenEditorScreenState extends State { ]; /// True when a PEN tool (pen/highlighter/eraser) is active — pen capture is on. - /// False in select-text mode so the pen reaches pdfrx text selection. - bool get _penCaptureEnabled => !_selectTextMode; + /// False in select-text mode (pen reaches pdfrx text selection) and in + /// place-link mode (a tap drops an anchor via the page overlay). + bool get _penCaptureEnabled => !_selectTextMode && !_placeLinkMode; /// True when the eraser tool is active. bool get _isEraser => _tool == CanvasTool.eraser && !_selectTextMode; @@ -221,6 +240,18 @@ class _PenEditorScreenState extends State { } _saveScheduler = scheduler; await _loadPersistedStrokes(repo); + await _loadScratchLinks(service); + } + + /// Load this document's scratch-link anchors into [_scratchLinks]. + Future _loadScratchLinks(DatabaseService service) async { + final links = await service.loadScratchLinks(_documentId); + if (!mounted) return; + setState(() { + _scratchLinks + ..clear() + ..addAll(links); + }); } /// Load all persisted strokes for [_documentId] and populate [_strokesByPage]. @@ -618,11 +649,84 @@ class _PenEditorScreenState extends State { setState(() { _tool = tool; _selectTextMode = false; + _placeLinkMode = false; }); } void _enableSelectText() { - setState(() => _selectTextMode = true); + setState(() { + _selectTextMode = true; + _placeLinkMode = false; + }); + } + + /// Toggle "place scratch link" mode. While active, a tap on a page drops a + /// new anchor at the tapped normalized position. + void _togglePlaceLinkMode() { + setState(() { + _placeLinkMode = !_placeLinkMode; + if (_placeLinkMode) _selectTextMode = false; + }); + } + + // ── Scratch-link anchors ───────────────────────────────────────────────────── + + /// Create + persist a new anchor at normalized [normalized] on [pageIndex], + /// then show it. Leaves place-link mode on so several can be dropped in a row. + Future _placeScratchLink(int pageIndex, Offset normalized) async { + final link = ScratchLink( + id: _uuid.v4(), + documentId: _documentId, + pageIndex: pageIndex, + nx: normalized.dx.clamp(0.0, 1.0), + ny: normalized.dy.clamp(0.0, 1.0), + ); + final service = await DatabaseService.getInstance(); + await service.saveScratchLink(link); + if (!mounted) return; + setState(() => _scratchLinks.add(link)); + } + + /// Open the anchor's split view (left = this PDF at the anchor page, right = + /// the anchor's private infinite scratchpad). + void _openScratchLink(ScratchLink link) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SplitViewScreen( + filePath: widget.pdfPath, + documentId: _documentId, + scratchLinkId: link.id, + initialPage: link.pageIndex, + ), + ), + ); + } + + /// Confirm + delete an anchor (and its private scratchpad). + Future _confirmDeleteScratchLink(ScratchLink link) async { + final l = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l.scratchLinkDeleteTitle), + content: Text(l.scratchLinkDeleteBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(l.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(l.delete), + ), + ], + ), + ); + if (confirmed != true) return; + final service = await DatabaseService.getInstance(); + await service.deleteScratchLink(link.id); + if (!mounted) return; + setState(() => _scratchLinks.removeWhere((s) => s.id == link.id)); } void _toggleFingerDrawing() { @@ -727,6 +831,10 @@ class _PenEditorScreenState extends State { // normalized page space scaled to the on-screen page rect. pageOverlaysBuilder: (context, pageRectInViewer, page) { final pageIndex = page.pageNumber - 1; + final pageW = pageRectInViewer.width; + final pageH = pageRectInViewer.height; + final linksOnPage = + _scratchLinks.where((l) => l.pageIndex == pageIndex); return [ Positioned.fill( child: IgnorePointer( @@ -744,6 +852,35 @@ class _PenEditorScreenState extends State { ), ), ), + // Tap-to-place layer: only swallows taps while place-link mode is on. + // Otherwise it's a no-op (IgnorePointer) so ink/scroll fall through. + if (_placeLinkMode) + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapUp: (details) { + final local = details.localPosition; + if (pageW <= 0 || pageH <= 0) return; + final nx = (local.dx / pageW).clamp(0.0, 1.0); + final ny = (local.dy / pageH).clamp(0.0, 1.0); + _placeScratchLink(pageIndex, Offset(nx, ny)); + }, + ), + ), + // Anchor markers (sticky-note tabs): tap → split view, long-press → + // delete. Sized in screen px so the tap target stays usable at any + // zoom; positioned at (nx*pageW, ny*pageH). + for (final link in linksOnPage) + Positioned( + left: link.nx * pageW - _kMarkerSize / 2, + top: link.ny * pageH - _kMarkerSize / 2, + width: _kMarkerSize, + height: _kMarkerSize, + child: _ScratchLinkMarker( + onTap: () => _openScratchLink(link), + onLongPress: () => _confirmDeleteScratchLink(link), + ), + ), ]; }, // (2) Viewer-level pen capture. Stylus is captured ONLY when a pen tool @@ -814,6 +951,14 @@ class _PenEditorScreenState extends State { onPressed: _hasSelection ? _highlightSelection : null, ), PaletteDivider(cs: cs), + // Place scratch link (sticky-note tab). Tap a page to drop an anchor. + ToolButton( + icon: Icons.sticky_note_2_outlined, + selected: _placeLinkMode, + tooltip: l.toolPlaceScratchLink, + onPressed: _togglePlaceLinkMode, + ), + PaletteDivider(cs: cs), // Undo / redo (per page). ToolButton( icon: Icons.undo, @@ -1087,3 +1232,35 @@ class _PageOverlayPainter extends CustomPainter { old.pageSize != pageSize || old.thinning != thinning; } + +/// A small sticky-note "tab" marker glued to a page at a scratch-link anchor. +/// Tap opens the anchor's split view; long-press deletes the anchor. +class _ScratchLinkMarker extends StatelessWidget { + const _ScratchLinkMarker({required this.onTap, required this.onLongPress}); + + final VoidCallback onTap; + final VoidCallback onLongPress; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + onLongPress: onLongPress, + child: Material( + color: cs.tertiaryContainer, + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: cs.outlineVariant), + ), + child: Icon( + Icons.sticky_note_2, + size: 20, + color: cs.onTertiaryContainer, + ), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ff04f66..66748d5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -63,6 +63,9 @@ "nextPage": "Next page", "toolSelectText": "Select text", "actionHighlightSelection": "Highlight selection", + "toolPlaceScratchLink": "Place scratch link", + "scratchLinkDeleteTitle": "Delete scratch link?", + "scratchLinkDeleteBody": "This removes the anchor and its private scratchpad.", "failedToOpenPdf": "Failed to open PDF:\n{error}", "@failedToOpenPdf": { "placeholders": { "error": { "type": "String" } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1fb02d2..df28fee 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -422,6 +422,24 @@ abstract class AppLocalizations { /// **'Highlight selection'** String get actionHighlightSelection; + /// No description provided for @toolPlaceScratchLink. + /// + /// In en, this message translates to: + /// **'Place scratch link'** + String get toolPlaceScratchLink; + + /// No description provided for @scratchLinkDeleteTitle. + /// + /// In en, this message translates to: + /// **'Delete scratch link?'** + String get scratchLinkDeleteTitle; + + /// No description provided for @scratchLinkDeleteBody. + /// + /// In en, this message translates to: + /// **'This removes the anchor and its private scratchpad.'** + String get scratchLinkDeleteBody; + /// No description provided for @failedToOpenPdf. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4265534..30150bf 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -176,6 +176,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get actionHighlightSelection => 'Highlight selection'; + @override + String get toolPlaceScratchLink => 'Place scratch link'; + + @override + String get scratchLinkDeleteTitle => 'Delete scratch link?'; + + @override + String get scratchLinkDeleteBody => + 'This removes the anchor and its private scratchpad.'; + @override String failedToOpenPdf(String error) { return 'Failed to open PDF:\n$error'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ee357e9..a020a0b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -176,6 +176,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get actionHighlightSelection => '高亮所选'; + @override + String get toolPlaceScratchLink => '放置便签链接'; + + @override + String get scratchLinkDeleteTitle => '删除便签链接?'; + + @override + String get scratchLinkDeleteBody => '这会移除锚点及其专属草稿纸。'; + @override String failedToOpenPdf(String error) { return '打开 PDF 失败:\n$error'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 927cea0..ad187ba 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -54,6 +54,9 @@ "nextPage": "下一页", "toolSelectText": "选择文字", "actionHighlightSelection": "高亮所选", + "toolPlaceScratchLink": "放置便签链接", + "scratchLinkDeleteTitle": "删除便签链接?", + "scratchLinkDeleteBody": "这会移除锚点及其专属草稿纸。", "failedToOpenPdf": "打开 PDF 失败:\n{error}", "pdfNoPages": "PDF 没有任何页面。", "pageOfPages": "{current} / {total}", diff --git a/lib/models/scratch_link.dart b/lib/models/scratch_link.dart new file mode 100644 index 0000000..538c57a --- /dev/null +++ b/lib/models/scratch_link.dart @@ -0,0 +1,88 @@ +// lib/models/scratch_link.dart +// +// A PDF-anchored scratch link: a sticky-note "tab" placed at a normalized +// position (nx, ny in [0,1]) on a specific page of a document. Tapping the +// anchor opens a split view whose right pane is an infinite freehand scratchpad +// that BELONGS TO THIS ANCHOR (keyed by [id]). +// +// Plain immutable value class (no freezed codegen) so it compiles without a +// build_runner step. Equality is by value so anchors can be diffed in lists. + +import 'package:flutter/foundation.dart'; + +@immutable +class ScratchLink { + const ScratchLink({ + required this.id, + required this.documentId, + required this.pageIndex, + required this.nx, + required this.ny, + }); + + /// Stable anchor id (uuid). Doubles as the scratchpad storage key so each + /// anchor gets its own private infinite scratchpad. + final String id; + + /// The owning document (the editor's stable document-id for the PDF path). + final String documentId; + + /// 0-based page the anchor sits on. + final int pageIndex; + + /// Normalized horizontal position on the page, in [0, 1]. + final double nx; + + /// Normalized vertical position on the page, in [0, 1]. + final double ny; + + ScratchLink copyWith({ + String? id, + String? documentId, + int? pageIndex, + double? nx, + double? ny, + }) => + ScratchLink( + id: id ?? this.id, + documentId: documentId ?? this.documentId, + pageIndex: pageIndex ?? this.pageIndex, + nx: nx ?? this.nx, + ny: ny ?? this.ny, + ); + + Map toJson() => { + 'id': id, + 'documentId': documentId, + 'pageIndex': pageIndex, + 'nx': nx, + 'ny': ny, + }; + + factory ScratchLink.fromJson(Map json) => ScratchLink( + id: json['id'] as String, + documentId: json['documentId'] as String, + pageIndex: (json['pageIndex'] as num).toInt(), + nx: (json['nx'] as num).toDouble(), + ny: (json['ny'] as num).toDouble(), + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ScratchLink && + runtimeType == other.runtimeType && + id == other.id && + documentId == other.documentId && + pageIndex == other.pageIndex && + nx == other.nx && + ny == other.ny; + + @override + int get hashCode => Object.hash(id, documentId, pageIndex, nx, ny); + + @override + String toString() => + 'ScratchLink(id: $id, documentId: $documentId, pageIndex: $pageIndex, ' + 'nx: $nx, ny: $ny)'; +} diff --git a/lib/screens/board_screen.dart b/lib/screens/board_screen.dart deleted file mode 100644 index 365c4ef..0000000 --- a/lib/screens/board_screen.dart +++ /dev/null @@ -1,650 +0,0 @@ -// lib/screens/board_screen.dart -// -// F7 — 双链 + 无限便利贴. The reachable, persisted UI over the (already -// unit-tested) pure [Board] model. An infinite InteractiveViewer canvas hosts a -// Stack of sticky cards; cards are draggable, their text is editable, and -// `[[link]]` spans render as tappable chips that pan/center the board on the -// matching card (matched by card id — see _navigateToTarget). Backlinks -// ("什么链接到这里 / Linked from") come straight from board.backlinksOf(id). -// -// Persistence: the whole board is saved (cards as rows) via -// DatabaseService.saveBoardCards, debounced 800ms like the ink editors, and -// reloaded on open so it survives a restart. -// -// OUT OF SCOPE (left as TODOs): -// TODO(board-ink): ink/handwriting ON cards (the model mentions a per-card -// StrokeHost). Not wired here. -// TODO(board-multi): multiple-boards management UI. A single default board id -// is used when [boardId] is null; the param is kept for later. -// TODO(board-link-autocomplete): real-time `[[` autocomplete of card ids. - -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import '../editor/board/board.dart'; -import '../l10n/app_localizations.dart'; -import '../services/database_service.dart'; - -/// Default board id used when no [boardId] is supplied. One shared board is in -/// scope for increment 1; the param survives for future multi-board support. -const String kDefaultBoardId = 'default'; - -/// Default size of a freshly added sticky card (board content coordinates). -const Size _kNewCardSize = Size(180, 140); - -/// The logical extent of the (conceptually infinite) board content. The Stack -/// needs a finite size; InteractiveViewer's infinite boundaryMargin lets the -/// user pan far beyond it, and cards are centered around the middle so there is -/// room to grow in every direction. -const double _kCanvasExtent = 100000; -const Offset _kCanvasCenter = Offset(_kCanvasExtent / 2, _kCanvasExtent / 2); - -class BoardScreen extends StatefulWidget { - const BoardScreen({super.key, this.boardId}); - - /// Which board to open. Null → [kDefaultBoardId]. - final String? boardId; - - @override - State createState() => _BoardScreenState(); -} - -class _BoardScreenState extends State { - final TransformationController _transform = TransformationController(); - - /// The viewport size of the InteractiveViewer (used to center on a card). - final GlobalKey _viewportKey = GlobalKey(); - - Board _board = Board.empty; - bool _loading = true; - - /// Currently selected card (drives the backlinks panel + delete affordance). - String? _selectedId; - - /// The card whose text is being edited inline (null = none). - String? _editingId; - - DatabaseService? _db; - Timer? _saveTimer; - - String get _boardId => widget.boardId ?? kDefaultBoardId; - - @override - void initState() { - super.initState(); - // Center the initial viewport on the canvas center so newly added cards - // (placed at viewport center, which starts near _kCanvasCenter) are onscreen. - WidgetsBinding.instance.addPostFrameCallback((_) => _centerInitially()); - _load(); - } - - @override - void dispose() { - // Flush any pending debounced save synchronously-ish before tearing down. - _saveTimer?.cancel(); - if (_dirty) { - // Fire-and-forget: the future completes after dispose but the write still - // lands (DatabaseService is a singleton independent of this widget). - unawaited(_db?.saveBoardCards(_boardId, _board.cards) ?? Future.value()); - } - _transform.dispose(); - super.dispose(); - } - - bool _dirty = false; - - Future _load() async { - final db = await DatabaseService.getInstance(); - final board = await db.loadBoard(_boardId); - if (!mounted) return; - setState(() { - _db = db; - _board = board; - _loading = false; - }); - } - - void _centerInitially() { - final ctx = _viewportKey.currentContext; - if (ctx == null) return; - final size = ctx.size; - if (size == null) return; - // Translate so that _kCanvasCenter sits at the middle of the viewport. - _transform.value = Matrix4.identity() - ..translateByDouble( - size.width / 2 - _kCanvasCenter.dx, - size.height / 2 - _kCanvasCenter.dy, - 0, - 1, - ); - } - - // ── Mutations (each schedules a debounced save) ──────────────────────── - - void _mutate(Board next) { - setState(() => _board = next); - _dirty = true; - _scheduleSave(); - } - - void _scheduleSave() { - _saveTimer?.cancel(); - // Capture the snapshot synchronously (Board is immutable, so _board is a - // stable value) — same discipline as SaveScheduler. - final snapshot = _board; - final boardId = _boardId; - _saveTimer = Timer(const Duration(milliseconds: 800), () async { - await _db?.saveBoardCards(boardId, snapshot.cards); - _dirty = false; - }); - } - - String _newCardId() => 'card-${DateTime.now().microsecondsSinceEpoch}'; - - /// The board-content point currently at the center of the viewport. - Offset _viewportCenterInContent() { - final ctx = _viewportKey.currentContext; - final size = ctx?.size ?? const Size(400, 600); - final viewportCenter = Offset(size.width / 2, size.height / 2); - // Inverse-map the viewport-center screen point into content coordinates. - final inv = Matrix4.inverted(_transform.value); - return MatrixUtils.transformPoint(inv, viewportCenter); - } - - void _addCard() { - final center = _viewportCenterInContent(); - final pos = center - - Offset(_kNewCardSize.width / 2, _kNewCardSize.height / 2); - final l = AppLocalizations.of(context); - final card = BoardCard( - id: _newCardId(), - position: pos, - size: _kNewCardSize, - text: l.boardNewCardText, - ); - _mutate(_board.add(card)); - setState(() { - _selectedId = card.id; - _editingId = card.id; - }); - } - - void _deleteCard(String id) { - _mutate(_board.removeById(id)); - setState(() { - if (_selectedId == id) _selectedId = null; - if (_editingId == id) _editingId = null; - }); - } - - /// Pan/center the board on the card matching [target] (matched by card id). - /// Returns false when no such card exists (a dangling link). - bool _navigateToTarget(String target) { - final card = _board.cardById(target); - if (card == null) return false; - _centerOnCard(card); - setState(() => _selectedId = card.id); - return true; - } - - void _centerOnCard(BoardCard card) { - final ctx = _viewportKey.currentContext; - final size = ctx?.size ?? const Size(400, 600); - final scale = _transform.value.getMaxScaleOnAxis(); - final cardCenter = card.position + - Offset(card.size.width / 2, card.size.height / 2); - // We want: screen = scale * content + translation == viewportCenter. - final tx = size.width / 2 - scale * cardCenter.dx; - final ty = size.height / 2 - scale * cardCenter.dy; - _transform.value = Matrix4.identity() - ..translateByDouble(tx, ty, 0, 1) - ..scaleByDouble(scale, scale, scale, 1); - } - - // ── Build ────────────────────────────────────────────────────────────── - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - final cs = Theme.of(context).colorScheme; - - return Scaffold( - appBar: AppBar(title: Text(l.boardTitle), centerTitle: true), - floatingActionButton: FloatingActionButton( - onPressed: _loading ? null : _addCard, - tooltip: l.boardAddCard, - child: const Icon(Icons.add), - ), - body: _loading - ? const Center(child: CircularProgressIndicator()) - : Stack( - children: [ - InteractiveViewer( - key: _viewportKey, - transformationController: _transform, - boundaryMargin: const EdgeInsets.all(double.infinity), - minScale: 0.2, - maxScale: 4, - // A finite, very large content area for the card Stack. - constrained: false, - child: GestureDetector( - // Tap on empty canvas → deselect / stop editing. - behavior: HitTestBehavior.translucent, - onTap: () => setState(() { - _selectedId = null; - _editingId = null; - }), - child: SizedBox( - width: _kCanvasExtent, - height: _kCanvasExtent, - child: Stack( - children: [ - for (final card in _board.cards) - _BoardCardWidget( - key: ValueKey(card.id), - card: card, - board: _board, - selected: card.id == _selectedId, - editing: card.id == _editingId, - onSelect: () => - setState(() => _selectedId = card.id), - onStartEdit: () => - setState(() => _editingId = card.id), - onTextChanged: (t) => - _mutate(_board.setText(card.id, t)), - onMoved: (pos) => - _mutate(_board.moveCard(card.id, pos)), - onDelete: () => _confirmDelete(card.id), - onTapLink: _onTapLink, - scale: _transform.value.getMaxScaleOnAxis(), - ), - ], - ), - ), - ), - ), - if (_selectedId != null) - _BacklinksPanel( - board: _board, - selectedId: _selectedId!, - onTapCard: (id) { - final c = _board.cardById(id); - if (c != null) _centerOnCard(c); - setState(() => _selectedId = id); - }, - onClose: () => setState(() => _selectedId = null), - background: cs.surfaceContainerHigh, - ), - ], - ), - ); - } - - void _onTapLink(String target) { - final l = AppLocalizations.of(context); - final ok = _navigateToTarget(target); - if (!ok) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.boardDanglingLink(target))), - ); - } - } - - Future _confirmDelete(String id) async { - final l = AppLocalizations.of(context); - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(l.boardDeleteCardTitle), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text(l.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - child: Text(l.delete), - ), - ], - ), - ); - if (confirmed == true) _deleteCard(id); - } -} - -// ─────────────────────────────────────────────────────────────────────────── -// One sticky card. - -class _BoardCardWidget extends StatefulWidget { - const _BoardCardWidget({ - super.key, - required this.card, - required this.board, - required this.selected, - required this.editing, - required this.onSelect, - required this.onStartEdit, - required this.onTextChanged, - required this.onMoved, - required this.onDelete, - required this.onTapLink, - required this.scale, - }); - - final BoardCard card; - final Board board; - final bool selected; - final bool editing; - final VoidCallback onSelect; - final VoidCallback onStartEdit; - final ValueChanged onTextChanged; - final ValueChanged onMoved; - final VoidCallback onDelete; - final ValueChanged onTapLink; - - /// Current board zoom — used to convert screen drag deltas into content space. - final double scale; - - @override - State<_BoardCardWidget> createState() => _BoardCardWidgetState(); -} - -class _BoardCardWidgetState extends State<_BoardCardWidget> { - TextEditingController? _controller; - - @override - void didUpdateWidget(_BoardCardWidget old) { - super.didUpdateWidget(old); - // Keep the live editing controller in sync if the card text changes - // underneath us (e.g. external mutation) while NOT editing. - if (!widget.editing && _controller != null) { - _controller!.dispose(); - _controller = null; - } - } - - @override - void dispose() { - _controller?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final card = widget.card; - - return Positioned( - left: card.position.dx, - top: card.position.dy, - width: card.size.width, - height: card.size.height, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.onSelect, - onLongPress: widget.onDelete, - // Dragging the card body moves it (content delta = screen delta / scale). - onPanStart: (_) => widget.onSelect(), - onPanUpdate: (d) { - final scale = widget.scale == 0 ? 1.0 : widget.scale; - widget.onMoved(card.position + d.delta / scale); - }, - child: Material( - elevation: widget.selected ? 6 : 2, - color: cs.secondaryContainer, - surfaceTintColor: cs.surfaceTint, - borderRadius: BorderRadius.circular(12), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: widget.selected - ? Border.all(color: cs.primary, width: 2) - : null, - ), - padding: const EdgeInsets.all(10), - child: widget.editing - ? _buildEditor(cs) - : _buildBody(context, cs), - ), - ), - ), - ); - } - - Widget _buildEditor(ColorScheme cs) { - _controller ??= TextEditingController(text: widget.card.text); - return TextField( - controller: _controller, - autofocus: true, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - style: TextStyle(color: cs.onSecondaryContainer, fontSize: 14), - decoration: const InputDecoration( - border: InputBorder.none, - isCollapsed: true, - ), - onChanged: widget.onTextChanged, - ); - } - - Widget _buildBody(BuildContext context, ColorScheme cs) { - return GestureDetector( - // A tap that is NOT on a link enters edit mode. - onTap: widget.onStartEdit, - child: SizedBox.expand( - child: SingleChildScrollView( - child: _LinkedText( - text: widget.card.text, - board: widget.board, - onTapLink: widget.onTapLink, - baseColor: cs.onSecondaryContainer, - ), - ), - ), - ); - } -} - -// ─────────────────────────────────────────────────────────────────────────── -// Renders card text with [[link]] spans as tappable inline chips. Existing card -// ids are styled as live links; unknown targets are dangling (dashed/red). - -class _LinkedText extends StatelessWidget { - const _LinkedText({ - required this.text, - required this.board, - required this.onTapLink, - required this.baseColor, - }); - - final String text; - final Board board; - final ValueChanged onTapLink; - final Color baseColor; - - static final RegExp _linkPattern = RegExp(r'\[\[([^\]]*)\]\]'); - - @override - Widget build(BuildContext context) { - final cs = Theme.of(context).colorScheme; - final spans = []; - var last = 0; - for (final m in _linkPattern.allMatches(text)) { - if (m.start > last) { - spans.add(TextSpan(text: text.substring(last, m.start))); - } - final target = (m.group(1) ?? '').trim(); - final exists = target.isNotEmpty && board.cardById(target) != null; - spans.add( - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: _LinkChip( - label: target.isEmpty ? '[[]]' : target, - exists: exists, - onTap: target.isEmpty ? null : () => onTapLink(target), - cs: cs, - ), - ), - ); - last = m.end; - } - if (last < text.length) { - spans.add(TextSpan(text: text.substring(last))); - } - return Text.rich( - TextSpan( - style: TextStyle(color: baseColor, fontSize: 14), - children: spans, - ), - ); - } -} - -class _LinkChip extends StatelessWidget { - const _LinkChip({ - required this.label, - required this.exists, - required this.onTap, - required this.cs, - }); - - final String label; - final bool exists; - final VoidCallback? onTap; - final ColorScheme cs; - - @override - Widget build(BuildContext context) { - final color = exists ? cs.primary : cs.error; - return GestureDetector( - onTap: onTap, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 1), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: color, - width: 1, - // Dangling links get a visibly different (no fill, error) look. - style: BorderStyle.solid, - ), - ), - child: Text( - label, - style: TextStyle( - color: color, - fontSize: 13, - fontWeight: FontWeight.w600, - decoration: - exists ? TextDecoration.none : TextDecoration.lineThrough, - ), - ), - ), - ); - } -} - -// ─────────────────────────────────────────────────────────────────────────── -// "什么链接到这里 / Linked from" panel for the selected card. - -class _BacklinksPanel extends StatelessWidget { - const _BacklinksPanel({ - required this.board, - required this.selectedId, - required this.onTapCard, - required this.onClose, - required this.background, - }); - - final Board board; - final String selectedId; - final ValueChanged onTapCard; - final VoidCallback onClose; - final Color background; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - final backlinks = board.backlinksOf(selectedId).toList()..sort(); - - return Positioned( - right: 12, - top: 12, - bottom: 12, - width: 240, - child: Material( - elevation: 4, - color: background, - borderRadius: BorderRadius.circular(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 8, 4), - child: Row( - children: [ - Expanded( - child: Text( - l.boardBacklinks, - style: Theme.of(context).textTheme.titleSmall, - ), - ), - IconButton( - icon: const Icon(Icons.close), - tooltip: l.close, - onPressed: onClose, - ), - ], - ), - ), - const Divider(height: 1), - Expanded( - child: backlinks.isEmpty - ? Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - l.boardNoBacklinks, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ) - : ListView( - children: [ - for (final id in backlinks) - ListTile( - dense: true, - leading: const Icon(Icons.link, size: 18), - title: Text( - _preview(board, id), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - id, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - onTap: () => onTapCard(id), - ), - ], - ), - ), - ], - ), - ), - ); - } - - String _preview(Board board, String id) { - final c = board.cardById(id); - if (c == null) return id; - final text = c.text.trim(); - return text.isEmpty ? id : text.replaceAll('\n', ' '); - } -} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 6ad2905..b5f9086 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -11,10 +11,8 @@ import '../services/pdf_service.dart'; import '../services/pptx_service.dart'; import '../editor/canvas/pen_note_screen.dart'; import '../editor/canvas/pen_slide_screen.dart'; -import 'board_screen.dart'; import 'search_screen.dart'; import 'settings_screen.dart'; -import 'split_view_screen.dart'; // [M1] Relative date helper — no new package dependencies. String _formatDate(DateTime d) { @@ -61,15 +59,6 @@ class HomeScreen extends ConsumerWidget { tooltip: l.importPpt, onPressed: () => _importPptx(context), ), - IconButton( - icon: const Icon(Icons.dashboard_customize), - tooltip: l.boardOpen, - onPressed: () { - Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const BoardScreen())); - }, - ), IconButton( icon: const Icon(Icons.search), tooltip: l.search, @@ -502,32 +491,18 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> { '${document.docType.toUpperCase()} · ${document.pageCount} pages · $dateStr', style: Theme.of(context).textTheme.bodySmall, ), - // [H2] Trailing row: split-view (PDF only) + remove - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isPdf) - IconButton( - icon: Icon( - Icons.vertical_split, - color: _hovering - ? Theme.of(context).colorScheme.primary - : subtleColor.withValues(alpha: 0.4), - ), - tooltip: 'Open in Split View', - onPressed: () => _openSplitView(context), - ), - IconButton( - icon: Icon( - Icons.delete_outline, - color: _hovering - ? Theme.of(context).colorScheme.error - : subtleColor.withValues(alpha: 0.4), - ), - tooltip: 'Remove document', - onPressed: () => _confirmDelete(context), - ), - ], + // [H2] Trailing remove button. Split view is now reached only by + // tapping a scratch-link anchor inside the PDF editor, so the + // standalone split-view entry was removed. + trailing: IconButton( + icon: Icon( + Icons.delete_outline, + color: _hovering + ? Theme.of(context).colorScheme.error + : subtleColor.withValues(alpha: 0.4), + ), + tooltip: 'Remove document', + onPressed: () => _confirmDelete(context), ), // [L2] Routing bug fix: route by docType onTap: () => _openDocument(context), @@ -577,19 +552,7 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> { } } - void _openSplitView(BuildContext context) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SplitViewScreen( - filePath: widget.document.filePath, - documentId: widget.document.id, - ), - ), - ); - } - void _showContextMenu(BuildContext context, Offset position) async { - final isPdf = widget.document.docType == 'pdf'; final result = await showMenu( context: context, position: RelativeRect.fromLTRB( @@ -609,17 +572,6 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> { ], ), ), - if (isPdf) - PopupMenuItem( - value: 'split', - child: Row( - children: const [ - Icon(Icons.vertical_split), - SizedBox(width: 8), - Text('Open in Split View'), - ], - ), - ), PopupMenuItem( value: 'remove', child: Row( @@ -641,15 +593,12 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> { if (!mounted) return; if (result == 'open') { _openDocument(this.context); - } else if (result == 'split') { - _openSplitView(this.context); } else if (result == 'remove') { _confirmDelete(this.context); } } void _showDocumentMenu(BuildContext context) { - final isPdf = widget.document.docType == 'pdf'; showModalBottomSheet( context: context, builder: (ctx) { @@ -657,16 +606,6 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (isPdf) - ListTile( - leading: const Icon(Icons.vertical_split), - title: const Text('Open in Split View'), - subtitle: const Text('PDF reference + scratchpad'), - onTap: () { - Navigator.of(ctx).pop(); - _openSplitView(context); - }, - ), ListTile( leading: const Icon(Icons.delete_outline, color: Colors.red), title: const Text( diff --git a/lib/screens/split_view_screen.dart b/lib/screens/split_view_screen.dart index cb811b2..167b533 100644 --- a/lib/screens/split_view_screen.dart +++ b/lib/screens/split_view_screen.dart @@ -1,33 +1,56 @@ +// lib/screens/split_view_screen.dart +// +// Anchor-keyed split view: opened by tapping a PDF-anchored scratch link. +// LEFT pane = the source PDF (pdfrx PdfViewer, read-only reference + page +// nav), opened at the anchor's page. +// RIGHT pane = an INFINITE freehand scratchpad that BELONGS TO THE ANCHOR, +// keyed by [scratchLinkId] (not the documentId). Each anchor has +// its own private scratch space, persisted via the existing +// scratchpad storage (InkStroke JSON, format unchanged). +// +// The right pane reuses the pen-first PenCanvas world-coord engine and the +// Material You brush palette shared with pen_note_screen (BrushPickerButton / +// ToolButton / color dots), replacing the old AnnotationToolbar. + import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; +import 'package:pdfrx/pdfrx.dart'; import 'package:uuid/uuid.dart'; import '../editor/canvas/pen_canvas.dart'; +import '../editor/canvas/pen_palette_widgets.dart'; import '../editor/canvas/pen_stroke.dart'; +import '../editor/engine/brush.dart'; +import '../editor/input/pen_config.dart' show kDefaultEraserRadius; import '../editor/notebook/ink_stroke_adapter.dart'; import '../models/ink_stroke.dart'; -import '../models/pen_tool.dart'; -import '../models/pressure_curve.dart'; import '../services/database_service.dart'; import '../services/undo_manager.dart'; -import '../utils/stroke_stabilizer.dart'; -import '../widgets/annotation_toolbar.dart'; -/// Split-view derivation mode: left pane = reference PDF, right pane = infinite -/// scratchpad for formula derivation. Scratchpad strokes are persisted per -/// document via [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad]. +/// Split-view derivation surface for a single scratch-link anchor: left pane = +/// the reference PDF (at [initialPage]), right pane = the anchor's private +/// infinite scratchpad. Scratchpad strokes persist per ANCHOR via +/// [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad], keyed by +/// [scratchLinkId]. class SplitViewScreen extends StatefulWidget { final String filePath; final String documentId; + /// The owning anchor id. Doubles as the scratchpad storage key so this is the + /// anchor's private scratch space. + final String scratchLinkId; + + /// 0-based page the anchor sits on; the left PDF opens here. + final int initialPage; + const SplitViewScreen({ super.key, required this.filePath, required this.documentId, + required this.scratchLinkId, + this.initialPage = 0, }); @override @@ -37,63 +60,55 @@ class SplitViewScreen extends StatefulWidget { class _SplitViewState extends State { // -- PDF (left pane) -- final PdfViewerController _pdfController = PdfViewerController(); - int _currentPage = 0; + int _currentPage = 0; // 0-based int _pageCount = 0; - String _fileName = ''; // -- Split divider -- double _leftPaneFraction = 0.5; bool _isDraggingDivider = false; // -- Scratchpad (right pane) -- - // The scratchpad is an infinite WORLD: strokes are stored in absolute world - // pixels ([InkStroke], unchanged persistence format), and rendered through the - // performant PenCanvas by normalizing against the CURRENT world size. When the - // world auto-expands, the stored world coords don't move — only the - // normalization divisor grows — so ink stays put with zero drift. + // Infinite WORLD: strokes stored in absolute world pixels ([InkStroke], + // unchanged persistence format), rendered through PenCanvas by normalizing + // against the CURRENT world size. World auto-expands without moving ink. final UndoManager _undoManager = UndoManager(); List _strokes = []; double _canvasWidth = 4000; double _canvasHeight = 4000; static const _uuid = Uuid(); - /// Pan/zoom transform for the scratchpad world (PenCanvas drives this). final TransformationController _scratchTransform = TransformationController(); - - /// Set once the initial view has been framed onto existing ink. bool _scratchCentered = false; Size get _worldSize => Size(_canvasWidth, _canvasHeight); - /// Maps the scratchpad toolbar's [PenTool] to the pen-canvas tool. Shapes and - /// text fall back to pen (the pen-first scratchpad is freehand). - CanvasTool get _canvasTool => switch (_currentTool) { - PenTool.eraser => CanvasTool.eraser, - PenTool.highlighter => CanvasTool.highlighter, - _ => CanvasTool.pen, - }; + // -- Tool state (new Material You brush palette) -- + CanvasTool _tool = CanvasTool.pen; + BrushKind _penBrush = BrushKind.fountainPen; + Color _color = Colors.black; - // -- Tool state -- - PenTool _currentTool = PenTool.pen; - Color _currentColor = Colors.black; - double _currentStrokeWidth = 2.0; - bool _filled = false; - PressureCurveType _pressureCurveType = PressureCurveType.linear; - StabilizationLevel _stabilizationLevel = StabilizationLevel.none; + static const double _penWidthFraction = 0.006; + static const double _highlighterWidthFraction = 0.02; + + static const List _palette = [ + Colors.black, + Colors.red, + Colors.blue, + Colors.green, + Colors.orange, + ]; // -- Auto-save debounce -- Timer? _saveTimer; bool _dirty = false; - // -- Page link markers (optional feature) -- - final List<_PageLink> _pageLinks = []; - static const double _edgeThreshold = 200.0; static const double _expandAmount = 1000.0; @override void initState() { super.initState(); + _currentPage = widget.initialPage; _loadScratchpad(); } @@ -101,27 +116,26 @@ class _SplitViewState extends State { void dispose() { _saveTimer?.cancel(); _saveImmediate(); - _pdfController.dispose(); + // PdfViewerController (pdfrx) has no dispose(); it detaches with the viewer. _scratchTransform.dispose(); super.dispose(); } - // -- Persistence -- + // -- Persistence (keyed by the ANCHOR id, not the documentId) -- Future _loadScratchpad() async { final db = await DatabaseService.getInstance(); - final strokes = await db.loadScratchpad(widget.documentId); - if (mounted) { - setState(() { - // Keep only freehand strokes so the canvas list stays 1:1 with the - // undo manager (shapes/text have no pen-canvas representation). - final freehand = strokes.where((s) => isFreehandTool(s.tool)).toList(); - _strokes = freehand; - for (final s in freehand) { - _undoManager.addStroke(s); - } - }); - } + final strokes = await db.loadScratchpad(widget.scratchLinkId); + if (!mounted) return; + setState(() { + // Keep only freehand strokes so the canvas list stays 1:1 with the undo + // manager (shapes/text have no pen-canvas representation). + final freehand = strokes.where((s) => isFreehandTool(s.tool)).toList(); + _strokes = freehand; + for (final s in freehand) { + _undoManager.addStroke(s); + } + }); } void _scheduleSave() { @@ -135,13 +149,11 @@ class _SplitViewState extends State { _dirty = false; final db = await DatabaseService.getInstance(); final json = jsonEncode(_strokes.map((s) => s.toJson()).toList()); - await db.saveScratchpad(widget.documentId, json); + await db.saveScratchpad(widget.scratchLinkId, json); } // -- Scratchpad stroke callbacks -- - /// PenCanvas committed a stroke (normalized to the current world). Convert it - /// to absolute world coords for storage. void _onStrokeComplete(PenStroke pen) { final stroke = inkStrokeFromPen(pen, _worldSize, id: _uuid.v4(), createdAt: DateTime.now()); @@ -153,8 +165,6 @@ class _SplitViewState extends State { _scheduleSave(); } - /// PenCanvas erased through stroke [index] (into [_strokes]); [replacements] - /// are the surviving sub-strokes (normalized) — convert back to world coords. void _onErase(int index, List replacements) { if (index < 0 || index >= _strokes.length) return; setState(() { @@ -229,62 +239,32 @@ class _SplitViewState extends State { setState(() => _isDraggingDivider = false); } - // -- PDF page navigation -- + // -- PDF page navigation (left pane) -- void _prevPage() { - if (_currentPage > 0) { - _pdfController.previousPage(); - } + if (_currentPage > 0) _pdfController.goToPage(pageNumber: _currentPage); } void _nextPage() { if (_currentPage < _pageCount - 1) { - _pdfController.nextPage(); + _pdfController.goToPage(pageNumber: _currentPage + 2); } } - // -- Page link creation (long-press on left pane) -- + CanvasTool get _activeTool => _tool; - void _onPdfLongPress(int pageNumber) { - // Place a page link marker at the current scratchpad viewport center. - // We approximate the viewport center as (0, 0) since InteractiveViewer - // manages its own transform — the user can reposition by panning. - setState(() { - _pageLinks.add( - _PageLink( - pageNumber: pageNumber, - position: const Offset(100, 100), // default top-left area - ), - ); - }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Page link marker added for page $pageNumber')), - ); - } - - void _onPageLinkTap(_PageLink link) { - _pdfController.jumpToPage(link.pageNumber); - setState(() { - _currentPage = link.pageNumber - 1; - }); - } - - void _deletePageLink(_PageLink link) { - setState(() { - _pageLinks.remove(link); - }); - } + double get _strokeWidth => _tool == CanvasTool.highlighter + ? _highlighterWidthFraction + : _penWidthFraction; // -- Build -- @override Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar( - title: Text( - _fileName.isEmpty ? 'Split View' : _fileName, - style: const TextStyle(fontSize: 16), - ), + title: const Text('Scratch link', style: TextStyle(fontSize: 16)), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { @@ -293,7 +273,6 @@ class _SplitViewState extends State { }, ), actions: [ - // Left pane page navigation IconButton( icon: const Icon(Icons.navigate_before), tooltip: 'Previous page (PDF)', @@ -314,7 +293,6 @@ class _SplitViewState extends State { onPressed: _currentPage < _pageCount - 1 ? _nextPage : null, ), const SizedBox(width: 8), - // Canvas info Tooltip( message: 'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}', @@ -332,7 +310,6 @@ class _SplitViewState extends State { ), body: Column( children: [ - // Label clarifying that the toolbar controls the scratchpad pane. Padding( padding: const EdgeInsets.only(left: 12, top: 4), child: Align( @@ -340,47 +317,22 @@ class _SplitViewState extends State { child: Text( 'Scratchpad tools', style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), ), - // Toolbar (applies to scratchpad only) - AnnotationToolbar( - currentTool: _currentTool, - currentColor: _currentColor, - currentStrokeWidth: _currentStrokeWidth, - filled: _filled, - pressureCurveType: _pressureCurveType, - stabilizationLevel: _stabilizationLevel, - canUndo: _undoManager.canUndo, - canRedo: _undoManager.canRedo, - onToolChanged: (tool) => setState(() => _currentTool = tool), - onColorChanged: (color) => setState(() => _currentColor = color), - onStrokeWidthChanged: (w) => - setState(() => _currentStrokeWidth = w), - onFilledChanged: (f) => setState(() => _filled = f), - onPressureCurveChanged: (v) => - setState(() => _pressureCurveType = v), - onStabilizationChanged: (v) => - setState(() => _stabilizationLevel = v), - onUndo: _undo, - onRedo: _redo, - ), - // Split view body + _buildBrushPalette(cs), Expanded( child: LayoutBuilder( builder: (context, constraints) { final totalWidth = constraints.maxWidth; final leftWidth = totalWidth * _leftPaneFraction; - final rightWidth = - totalWidth - leftWidth - 12; // 12px divider hit area + final rightWidth = totalWidth - leftWidth - 12; return Row( children: [ - // Left pane: PDF reference (read-only) SizedBox(width: leftWidth, child: _buildPdfPane()), - // Draggable divider: 12px hit area, 4px visual strip. GestureDetector( onHorizontalDragStart: _onDividerDragStart, onHorizontalDragUpdate: (d) => @@ -401,7 +353,6 @@ class _SplitViewState extends State { ), ), ), - // Right pane: Infinite scratchpad SizedBox(width: rightWidth, child: _buildScratchpadPane()), ], ); @@ -413,72 +364,118 @@ class _SplitViewState extends State { ); } - Widget _buildPdfPane() { - return Stack( - children: [ - GestureDetector( - onLongPress: () { - // Long-press on PDF to create page link marker - _onPdfLongPress(_currentPage + 1); - }, - child: SfPdfViewer.file( - File(widget.filePath), - controller: _pdfController, - canShowScrollHead: true, - canShowScrollStatus: true, - onPageChanged: (PdfPageChangedDetails details) { - setState(() { - _currentPage = details.newPageNumber - 1; - }); - }, - onDocumentLoaded: (PdfDocumentLoadedDetails details) { - setState(() { - _pageCount = details.document.pages.count; - _fileName = widget.filePath.split(Platform.pathSeparator).last; - }); - }, + /// Material You brush palette (shared chrome with the pen-first editors): + /// brush picker + highlighter + eraser, undo/redo, color dots. + Widget _buildBrushPalette(ColorScheme cs) { + return Material( + color: cs.surfaceContainerHigh, + elevation: 3, + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + BrushPickerButton( + selected: _penBrush, + active: _tool == CanvasTool.pen, + tooltip: 'Brush', + labelFor: brushLabelEn, + onSelected: (b) => setState(() { + _penBrush = b; + _tool = CanvasTool.pen; + }), + ), + ToolButton( + icon: Icons.brush_outlined, + selected: _tool == CanvasTool.highlighter, + tooltip: 'Highlighter', + onPressed: () => setState(() => _tool = CanvasTool.highlighter), + ), + ToolButton( + icon: Icons.cleaning_services_outlined, + selected: _tool == CanvasTool.eraser, + tooltip: 'Eraser', + onPressed: () => setState(() => _tool = CanvasTool.eraser), + ), + PaletteDivider(cs: cs), + ToolButton( + icon: Icons.undo, + selected: false, + tooltip: 'Undo', + onPressed: _undoManager.canUndo ? _undo : null, + ), + ToolButton( + icon: Icons.redo, + selected: false, + tooltip: 'Redo', + onPressed: _undoManager.canRedo ? _redo : null, + ), + PaletteDivider(cs: cs), + for (final c in _palette) _colorDot(c, cs), + ], ), ), - // Page link markers overlay (on PDF pane, showing linked pages) - if (_pageLinks.isNotEmpty) - Positioned(bottom: 8, left: 8, child: _buildPageLinkChips()), - ], + ), ); } - Widget _buildPageLinkChips() { - return Wrap( - spacing: 4, - runSpacing: 4, - children: _pageLinks.map((link) { - return GestureDetector( - onTap: () => _onPageLinkTap(link), - onLongPress: () => _deletePageLink(link), - child: Chip( - avatar: const Icon(Icons.link, size: 14, color: Colors.white), - label: Text( - 'p${link.pageNumber}', - style: const TextStyle(fontSize: 11, color: Colors.white), - ), - backgroundColor: Colors.blue.shade600, - padding: EdgeInsets.zero, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, + Widget _colorDot(Color c, ColorScheme cs) { + final selected = + _color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser; + return GestureDetector( + onTap: () => setState(() { + _color = c; + if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen; + }), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.symmetric(horizontal: 3), + width: 24, + height: 24, + decoration: BoxDecoration( + color: c, + shape: BoxShape.circle, + border: Border.all( + color: selected ? cs.onSurface : cs.outlineVariant, + width: selected ? 3 : 1, ), - ); - }).toList(), + ), + ), + ); + } + + Widget _buildPdfPane() { + // Read-only reference PDF on the same engine (pdfrx) as the rest of the + // app, opened at the anchor's page. + return PdfViewer.file( + widget.filePath, + controller: _pdfController, + params: PdfViewerParams( + onViewerReady: (document, controller) { + if (!mounted) return; + setState(() { + _pageCount = document.pages.length; + }); + final target = widget.initialPage.clamp(0, _pageCount - 1); + if (target > 0) { + controller.goToPage(pageNumber: target + 1); + } + }, + onPageChanged: (pageNumber) { + if (pageNumber == null || !mounted) return; + final idx = pageNumber - 1; + if (idx != _currentPage) setState(() => _currentPage = idx); + }, + ), ); } Widget _buildScratchpadPane() { - // Render the world through the performant PenCanvas: strokes normalized - // against the current world size; toolbar width is in world pixels, so the - // pen-canvas fraction is width / worldWidth. return LayoutBuilder( builder: (context, constraints) { - // On first layout, frame the view so existing ink is actually visible - // (otherwise identity shows only the empty top-left corner of the huge - // world). Empty scratchpad falls back to a comfortable 1:1 near origin. if (!_scratchCentered) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -493,10 +490,11 @@ class _SplitViewState extends State { pageSize: _worldSize, strokes: penStrokesFromInk(_strokes, _worldSize), transformationController: _scratchTransform, - tool: _canvasTool, - color: _currentColor, - strokeWidth: _currentStrokeWidth / _canvasWidth, - // The world is huge, so allow zooming further out to survey it. + tool: _activeTool, + brush: _penBrush, + color: _color, + strokeWidth: _strokeWidth, + eraserRadius: kDefaultEraserRadius, minScale: 0.1, maxScale: 8.0, onStrokeComplete: _onStrokeComplete, @@ -508,9 +506,8 @@ class _SplitViewState extends State { ); } - /// Position the scratchpad so existing ink is on-screen. Fits the strokes' - /// world bounding box into [pane] (with padding, scale clamped); for an empty - /// scratchpad, shows the top-left working area at 1:1. + /// Position the scratchpad so existing ink is on-screen (fit its bbox into + /// [pane], scale clamped); an empty scratchpad shows the top-left at 1:1. void _frameScratchpad(Size pane) { if (pane.isEmpty) return; if (_strokes.isEmpty) { @@ -534,9 +531,10 @@ class _SplitViewState extends State { const pad = 80.0; final boxW = (maxX - minX) + pad * 2; final boxH = (maxY - minY) + pad * 2; - final scale = - (pane.width / boxW < pane.height / boxH ? pane.width / boxW : pane.height / boxH) - .clamp(0.15, 1.5); + final scale = (pane.width / boxW < pane.height / boxH + ? pane.width / boxW + : pane.height / boxH) + .clamp(0.15, 1.5); final cx = (minX + maxX) / 2; final cy = (minY + maxY) / 2; final tx = pane.width / 2 - scale * cx; @@ -548,11 +546,3 @@ class _SplitViewState extends State { ..setTranslationRaw(tx, ty, 0); } } - -/// A marker linking a scratchpad position to a specific PDF page. -class _PageLink { - final int pageNumber; - final Offset position; - - const _PageLink({required this.pageNumber, required this.position}); -} diff --git a/lib/services/database_service.dart b/lib/services/database_service.dart index 6d6c769..a1778bc 100644 --- a/lib/services/database_service.dart +++ b/lib/services/database_service.dart @@ -16,6 +16,7 @@ import '../models/ink_stroke.dart'; import '../models/note.dart'; import '../models/pen_tool.dart'; import '../models/pointer_device_kind.dart'; +import '../models/scratch_link.dart'; class DatabaseService { static DatabaseService? _instance; @@ -56,7 +57,7 @@ class DatabaseService { _database = await openDatabase( dbPath, - version: 7, + version: 8, onCreate: _onCreate, onUpgrade: _onUpgrade, ); @@ -202,6 +203,28 @@ class DatabaseService { // Sticky-note board cards (v7): F7 双链 + 无限便利贴. await _createBoardCardsTable(db); + + // PDF-anchored scratch links (v8). + await _createScratchLinksTable(db); + } + + /// PDF-anchored scratch links table (v8). One row per [ScratchLink] anchor. + /// The anchor [id] doubles as the storage key for its private scratchpad + /// (reused from the [scratchpads] table — see [saveScratchpad]). + Future _createScratchLinksTable(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE scratch_links ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL, + page_index INTEGER NOT NULL, + nx REAL NOT NULL, + ny REAL NOT NULL, + created_at TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX idx_scratch_links_doc ON scratch_links(document_id)', + ); } /// Sticky-note board cards table (F7). One row per [BoardCard]; a board is the @@ -232,6 +255,13 @@ class DatabaseService { if (oldVersion < 5) await _migrateV4toV5(db); if (oldVersion < 6) await _migrateV5toV6(db); if (oldVersion < 7) await _migrateV6toV7(db); + if (oldVersion < 8) await _migrateV7toV8(db); + } + + Future _migrateV7toV8(Database db) async { + await db.transaction((txn) async { + await _createScratchLinksTable(txn); + }); } Future _migrateV6toV7(Database db) async { @@ -1003,4 +1033,54 @@ class DatabaseService { ), ]); } + + // ── Scratch links CRUD (PDF-anchored scratchpad tabs) ────────────────── + + /// Insert or replace a [ScratchLink] anchor. The anchor's private scratchpad + /// lives in the [scratchpads] table keyed by [ScratchLink.id] — saved/loaded + /// via [saveScratchpad] / [loadScratchpad]. + Future saveScratchLink(ScratchLink link) async { + await _database.insert( + 'scratch_links', + { + 'id': link.id, + 'document_id': link.documentId, + 'page_index': link.pageIndex, + 'nx': link.nx, + 'ny': link.ny, + 'created_at': DateTime.now().toIso8601String(), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + /// Load all anchors for [documentId], oldest first. + Future> loadScratchLinks(String documentId) async { + final rows = await _database.query( + 'scratch_links', + where: 'document_id = ?', + whereArgs: [documentId], + orderBy: 'created_at ASC', + ); + return rows + .map( + (row) => ScratchLink( + id: row['id'] as String, + documentId: row['document_id'] as String, + pageIndex: row['page_index'] as int, + nx: (row['nx'] as num).toDouble(), + ny: (row['ny'] as num).toDouble(), + ), + ) + .toList(); + } + + /// Delete an anchor and its private scratchpad (the scratchpad row keyed by + /// the anchor id), so a deleted anchor leaves no orphaned ink behind. + Future deleteScratchLink(String id) async { + await _database.transaction((txn) async { + await txn.delete('scratch_links', where: 'id = ?', whereArgs: [id]); + await txn.delete('scratchpads', where: 'document_id = ?', whereArgs: [id]); + }); + } } diff --git a/test/board_screen_test.dart b/test/board_screen_test.dart deleted file mode 100644 index cf61734..0000000 --- a/test/board_screen_test.dart +++ /dev/null @@ -1,150 +0,0 @@ -// test/board_screen_test.dart -// -// Screen-level + persistence guards for F7 (双链 + 无限便利贴): -// 1. BoardScreen pumps, "Add card" inserts a card, typing text with a -// [[link]] renders the card body and the link chip. -// 2. saveBoardCards / loadBoard round-trip a board through DatabaseService -// (cards survive a reload — proves the screen's persistence is real). -// -// DatabaseService.getInstance() needs getApplicationDocumentsDirectory(); we -// mock PathProviderPlatform to a temp dir so the singleton opens a real (ffi) -// sqlite DB on disk. - -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -// ignore: depend_on_referenced_packages -import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; -// ignore: depend_on_referenced_packages -import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; - -import 'package:badnote/editor/board/board.dart'; -import 'package:badnote/l10n/app_localizations.dart'; -import 'package:badnote/screens/board_screen.dart'; -import 'package:badnote/services/database_service.dart'; - -class _FakePathProvider extends PathProviderPlatform - with MockPlatformInterfaceMixin { - _FakePathProvider(this.dir); - final String dir; - @override - Future getApplicationDocumentsPath() async => dir; -} - -Future _pumpBoard(WidgetTester tester) async { - await tester.pumpWidget( - ProviderScope( - child: MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: const BoardScreen(boardId: 'test-board'), - ), - ), - ); - // The board loads from a real (ffi) sqlite file in initState. That I/O only - // completes on the real event loop, so drive it via runAsync, then pump to - // surface the loaded state. (pumpAndSettle is unusable here: the loading - // CircularProgressIndicator animates forever and never settles.) - await tester.runAsync(() async { - // Give the DB open + loadBoard future real wall-clock time to resolve. - await Future.delayed(const Duration(milliseconds: 200)); - }); - await tester.pump(); - await tester.pump(); -} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - - late Directory tmp; - - setUp(() async { - tmp = await Directory.systemTemp.createTemp('board_screen_test_'); - // Fresh DB file per test → clean board state. - final dbFile = File('${tmp.path}/badnote.db'); - if (dbFile.existsSync()) dbFile.deleteSync(); - PathProviderPlatform.instance = _FakePathProvider(tmp.path); - DatabaseService.resetForTest(); - }); - - tearDown(() async { - try { - tmp.deleteSync(recursive: true); - } catch (_) {} - }); - - testWidgets('add a card, type [[link]] text, card + link chip render', - (tester) async { - await _pumpBoard(tester); - - // Empty board: no cards yet. - expect(find.byType(TextField), findsNothing); - - // "Add card" FAB → inserts a card that opens straight into inline edit. - await tester.tap(find.byType(FloatingActionButton)); - await tester.pump(); - await tester.pump(); - expect(find.byType(TextField), findsOneWidget, - reason: 'a new card opens in inline-edit mode'); - - // Type a body containing a [[link]] to a (dangling) target. - await tester.enterText(find.byType(TextField), 'hello [[other]]'); - await tester.pump(); - - // Tap empty canvas (top-left, away from the centered card) to leave edit - // mode and render the body. - await tester.tapAt(const Offset(10, 100)); - await tester.pump(); - await tester.pump(); - - // The non-link text and the link chip both render. - expect(find.textContaining('hello'), findsWidgets); - expect(find.text('other'), findsOneWidget, - reason: 'the [[other]] link renders as a tappable chip'); - - // Tear the screen down so its debounced save timer is cancelled and any - // pending flush lands while the DB is still open (avoids a post-test write - // against a torn-down DB). Disposal runs synchronously on pumpWidget. - await tester.pumpWidget(const SizedBox()); - await tester.runAsync(() async { - await Future.delayed(const Duration(milliseconds: 50)); - }); - }); - - test('persistence round-trips a board through DatabaseService', () async { - final db = await DatabaseService.getInstance(); - - final board = Board.empty - .add(BoardCard( - id: 'a', - position: const Offset(10, 20), - size: const Size(180, 140), - text: 'see [[b]]', - )) - .add(BoardCard( - id: 'b', - position: const Offset(300, 50), - size: const Size(180, 140), - text: 'leaf', - )); - - await db.saveBoardCards('test-board', board.cards); - - final reloaded = await db.loadBoard('test-board'); - expect(reloaded.length, 2); - expect(reloaded.cardById('a')!.position, const Offset(10, 20)); - expect(reloaded.cardById('a')!.text, 'see [[b]]'); - expect(reloaded.cardById('b')!.size, const Size(180, 140)); - // Backlinks survive because the [[b]] link is preserved in text. - expect(reloaded.backlinksOf('b'), {'a'}); - - // A board id without rows loads as empty (default-board fallback path). - expect((await db.loadBoard('nonexistent')).length, 0); - }); -} diff --git a/test/scratch_link_test.dart b/test/scratch_link_test.dart new file mode 100644 index 0000000..16ee181 --- /dev/null +++ b/test/scratch_link_test.dart @@ -0,0 +1,166 @@ +// test/scratch_link_test.dart +// +// Guards the PDF-anchored scratch-link feature: +// 1. ScratchLink toJson/fromJson round-trip (pure model). +// 2. saveScratchLink / loadScratchLinks / deleteScratchLink round-trip a set +// of anchors through DatabaseService (anchors survive a reload). +// 3. A per-anchor scratchpad keyed by the anchor id is private to that anchor +// and is removed when the anchor is deleted (proves the reuse of the +// existing scratchpad storage keyed by anchor id, not document id). +// +// DatabaseService.getInstance() needs getApplicationDocumentsDirectory(); we +// mock PathProviderPlatform to a temp dir so the singleton opens a real (ffi) +// sqlite DB on disk. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +// ignore: depend_on_referenced_packages +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +// ignore: depend_on_referenced_packages +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:badnote/models/ink_point.dart'; +import 'package:badnote/models/ink_stroke.dart'; +import 'package:badnote/models/pen_tool.dart'; +import 'package:badnote/models/scratch_link.dart'; +import 'package:badnote/services/database_service.dart'; + +class _FakePathProvider extends PathProviderPlatform + with MockPlatformInterfaceMixin { + _FakePathProvider(this.dir); + final String dir; + @override + Future getApplicationDocumentsPath() async => dir; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + + group('ScratchLink model', () { + test('toJson/fromJson round-trips', () { + const link = ScratchLink( + id: 'anchor-1', + documentId: 'doc-abc', + pageIndex: 3, + nx: 0.25, + ny: 0.8, + ); + final restored = ScratchLink.fromJson(jsonDecode(jsonEncode(link.toJson())) + as Map); + expect(restored, link); + expect(restored.pageIndex, 3); + expect(restored.nx, 0.25); + expect(restored.ny, 0.8); + }); + }); + + group('ScratchLink persistence', () { + late Directory tmp; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('scratch_link_test_'); + final dbFile = File('${tmp.path}/badnote.db'); + if (dbFile.existsSync()) dbFile.deleteSync(); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); + await DatabaseService.resetForTest(); + }); + + tearDown(() async { + await DatabaseService.resetForTest(); + try { + tmp.deleteSync(recursive: true); + } catch (_) {} + }); + + test('save / load / delete anchors round-trip', () async { + final db = await DatabaseService.getInstance(); + + const a = ScratchLink( + id: 'a', + documentId: 'doc-1', + pageIndex: 0, + nx: 0.1, + ny: 0.2, + ); + const b = ScratchLink( + id: 'b', + documentId: 'doc-1', + pageIndex: 4, + nx: 0.7, + ny: 0.9, + ); + // A different document's anchor must not leak into doc-1's list. + const other = ScratchLink( + id: 'c', + documentId: 'doc-2', + pageIndex: 1, + nx: 0.5, + ny: 0.5, + ); + + await db.saveScratchLink(a); + await db.saveScratchLink(b); + await db.saveScratchLink(other); + + final loaded = await db.loadScratchLinks('doc-1'); + expect(loaded.length, 2); + expect(loaded.map((l) => l.id).toSet(), {'a', 'b'}); + final reloadedA = loaded.firstWhere((l) => l.id == 'a'); + expect(reloadedA.pageIndex, 0); + expect(reloadedA.nx, 0.1); + expect(reloadedA.ny, 0.2); + + // doc-2 keeps its own anchor. + expect((await db.loadScratchLinks('doc-2')).single.id, 'c'); + + await db.deleteScratchLink('a'); + final after = await db.loadScratchLinks('doc-1'); + expect(after.map((l) => l.id), ['b']); + }); + + test('each anchor has its own private scratchpad keyed by anchor id', + () async { + final db = await DatabaseService.getInstance(); + + InkStroke stroke(String id, double x) => InkStroke( + id: id, + points: [ + InkPoint(x: x, y: x, pressure: 0.5, timestamp: 0), + ], + tool: PenTool.pen, + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + + // Two anchors, two different private scratchpads (keyed by anchor id). + final inkA = [stroke('s1', 10)]; + final inkB = [stroke('s2', 20)]; + await db.saveScratchpad( + 'anchor-A', jsonEncode(inkA.map((s) => s.toJson()).toList())); + await db.saveScratchpad( + 'anchor-B', jsonEncode(inkB.map((s) => s.toJson()).toList())); + + final loadedA = await db.loadScratchpad('anchor-A'); + final loadedB = await db.loadScratchpad('anchor-B'); + expect(loadedA.single.points.single.x, 10); + expect(loadedB.single.points.single.x, 20); + + // Deleting the anchor removes its private scratchpad too. + await db.saveScratchLink(const ScratchLink( + id: 'anchor-A', + documentId: 'doc-9', + pageIndex: 0, + nx: 0.0, + ny: 0.0, + )); + await db.deleteScratchLink('anchor-A'); + expect(await db.loadScratchpad('anchor-A'), isEmpty); + // anchor-B is untouched. + expect((await db.loadScratchpad('anchor-B')).single.points.single.x, 20); + }); + }); +}