feat(pdf): anchored scratch links replace board
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Replace the rejected standalone sticky-card board with the real feature: place a link anchor anywhere on a PDF page, tap it to open split view whose right pane is THAT anchor's own infinite scratchpad (keyed by anchor id) — like a paper sticky-note tab. - ScratchLink model + scratch_links table (id, doc, page, nx, ny). - PDF editor: "place link" tool drops/loads/shows tappable markers; tap opens SplitViewScreen for that anchor; long-press deletes. - SplitViewScreen rebuilt on pdfrx (was syncfusion), right scratchpad keyed by scratchLinkId, new brush palette (was AnnotationToolbar). - Remove board_screen + its test + the home board entry. analyze clean, tests green.
This commit is contained in:
@@ -23,8 +23,11 @@
|
|||||||
import 'package:flutter/gestures.dart' show PointerDeviceKind;
|
import 'package:flutter/gestures.dart' show PointerDeviceKind;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:pdfrx/pdfrx.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../models/scratch_link.dart';
|
||||||
|
import '../../screens/split_view_screen.dart';
|
||||||
import '../../services/database_service.dart';
|
import '../../services/database_service.dart';
|
||||||
import '../engine/brush.dart';
|
import '../engine/brush.dart';
|
||||||
import '../engine/stroke_eraser.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-
|
/// 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
|
/// independent (no slashes, spaces, or non-ASCII characters) and stable across
|
||||||
/// restarts. Collisions are astronomically unlikely for a single-user app.
|
/// 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) {
|
String _documentIdFromPath(String path) {
|
||||||
var hash = 5381;
|
var hash = 5381;
|
||||||
for (final c in path.codeUnits) {
|
for (final c in path.codeUnits) {
|
||||||
@@ -156,6 +163,17 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
/// pen falls through to pdfrx for native text selection.
|
/// pen falls through to pdfrx for native text selection.
|
||||||
bool _selectTextMode = false;
|
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<ScratchLink> _scratchLinks = [];
|
||||||
|
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
Color _color = Colors.black;
|
Color _color = Colors.black;
|
||||||
bool _allowFingerDrawing = false;
|
bool _allowFingerDrawing = false;
|
||||||
|
|
||||||
@@ -176,8 +194,9 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// True when a PEN tool (pen/highlighter/eraser) is active — pen capture is on.
|
/// 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.
|
/// False in select-text mode (pen reaches pdfrx text selection) and in
|
||||||
bool get _penCaptureEnabled => !_selectTextMode;
|
/// place-link mode (a tap drops an anchor via the page overlay).
|
||||||
|
bool get _penCaptureEnabled => !_selectTextMode && !_placeLinkMode;
|
||||||
|
|
||||||
/// True when the eraser tool is active.
|
/// True when the eraser tool is active.
|
||||||
bool get _isEraser => _tool == CanvasTool.eraser && !_selectTextMode;
|
bool get _isEraser => _tool == CanvasTool.eraser && !_selectTextMode;
|
||||||
@@ -221,6 +240,18 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
}
|
}
|
||||||
_saveScheduler = scheduler;
|
_saveScheduler = scheduler;
|
||||||
await _loadPersistedStrokes(repo);
|
await _loadPersistedStrokes(repo);
|
||||||
|
await _loadScratchLinks(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load this document's scratch-link anchors into [_scratchLinks].
|
||||||
|
Future<void> _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].
|
/// Load all persisted strokes for [_documentId] and populate [_strokesByPage].
|
||||||
@@ -618,11 +649,84 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_tool = tool;
|
_tool = tool;
|
||||||
_selectTextMode = false;
|
_selectTextMode = false;
|
||||||
|
_placeLinkMode = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _enableSelectText() {
|
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<void> _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<void> _confirmDeleteScratchLink(ScratchLink link) async {
|
||||||
|
final l = AppLocalizations.of(context);
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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() {
|
void _toggleFingerDrawing() {
|
||||||
@@ -727,6 +831,10 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
// normalized page space scaled to the on-screen page rect.
|
// normalized page space scaled to the on-screen page rect.
|
||||||
pageOverlaysBuilder: (context, pageRectInViewer, page) {
|
pageOverlaysBuilder: (context, pageRectInViewer, page) {
|
||||||
final pageIndex = page.pageNumber - 1;
|
final pageIndex = page.pageNumber - 1;
|
||||||
|
final pageW = pageRectInViewer.width;
|
||||||
|
final pageH = pageRectInViewer.height;
|
||||||
|
final linksOnPage =
|
||||||
|
_scratchLinks.where((l) => l.pageIndex == pageIndex);
|
||||||
return [
|
return [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
@@ -744,6 +852,35 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 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
|
// (2) Viewer-level pen capture. Stylus is captured ONLY when a pen tool
|
||||||
@@ -814,6 +951,14 @@ class _PenEditorScreenState extends State<PenEditorScreen> {
|
|||||||
onPressed: _hasSelection ? _highlightSelection : null,
|
onPressed: _hasSelection ? _highlightSelection : null,
|
||||||
),
|
),
|
||||||
PaletteDivider(cs: cs),
|
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).
|
// Undo / redo (per page).
|
||||||
ToolButton(
|
ToolButton(
|
||||||
icon: Icons.undo,
|
icon: Icons.undo,
|
||||||
@@ -1087,3 +1232,35 @@ class _PageOverlayPainter extends CustomPainter {
|
|||||||
old.pageSize != pageSize ||
|
old.pageSize != pageSize ||
|
||||||
old.thinning != thinning;
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,9 @@
|
|||||||
"nextPage": "Next page",
|
"nextPage": "Next page",
|
||||||
"toolSelectText": "Select text",
|
"toolSelectText": "Select text",
|
||||||
"actionHighlightSelection": "Highlight selection",
|
"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": "Failed to open PDF:\n{error}",
|
||||||
"@failedToOpenPdf": {
|
"@failedToOpenPdf": {
|
||||||
"placeholders": { "error": { "type": "String" } }
|
"placeholders": { "error": { "type": "String" } }
|
||||||
|
|||||||
@@ -422,6 +422,24 @@ abstract class AppLocalizations {
|
|||||||
/// **'Highlight selection'**
|
/// **'Highlight selection'**
|
||||||
String get actionHighlightSelection;
|
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.
|
/// No description provided for @failedToOpenPdf.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -176,6 +176,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get actionHighlightSelection => 'Highlight selection';
|
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
|
@override
|
||||||
String failedToOpenPdf(String error) {
|
String failedToOpenPdf(String error) {
|
||||||
return 'Failed to open PDF:\n$error';
|
return 'Failed to open PDF:\n$error';
|
||||||
|
|||||||
@@ -176,6 +176,15 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get actionHighlightSelection => '高亮所选';
|
String get actionHighlightSelection => '高亮所选';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get toolPlaceScratchLink => '放置便签链接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get scratchLinkDeleteTitle => '删除便签链接?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get scratchLinkDeleteBody => '这会移除锚点及其专属草稿纸。';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToOpenPdf(String error) {
|
String failedToOpenPdf(String error) {
|
||||||
return '打开 PDF 失败:\n$error';
|
return '打开 PDF 失败:\n$error';
|
||||||
|
|||||||
@@ -54,6 +54,9 @@
|
|||||||
"nextPage": "下一页",
|
"nextPage": "下一页",
|
||||||
"toolSelectText": "选择文字",
|
"toolSelectText": "选择文字",
|
||||||
"actionHighlightSelection": "高亮所选",
|
"actionHighlightSelection": "高亮所选",
|
||||||
|
"toolPlaceScratchLink": "放置便签链接",
|
||||||
|
"scratchLinkDeleteTitle": "删除便签链接?",
|
||||||
|
"scratchLinkDeleteBody": "这会移除锚点及其专属草稿纸。",
|
||||||
"failedToOpenPdf": "打开 PDF 失败:\n{error}",
|
"failedToOpenPdf": "打开 PDF 失败:\n{error}",
|
||||||
"pdfNoPages": "PDF 没有任何页面。",
|
"pdfNoPages": "PDF 没有任何页面。",
|
||||||
"pageOfPages": "{current} / {total}",
|
"pageOfPages": "{current} / {total}",
|
||||||
|
|||||||
88
lib/models/scratch_link.dart
Normal file
88
lib/models/scratch_link.dart
Normal file
@@ -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<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'documentId': documentId,
|
||||||
|
'pageIndex': pageIndex,
|
||||||
|
'nx': nx,
|
||||||
|
'ny': ny,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory ScratchLink.fromJson(Map<String, dynamic> 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)';
|
||||||
|
}
|
||||||
@@ -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<BoardScreen> createState() => _BoardScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BoardScreenState extends State<BoardScreen> {
|
|
||||||
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<void> _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<void> _confirmDelete(String id) async {
|
|
||||||
final l = AppLocalizations.of(context);
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
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<String> onTextChanged;
|
|
||||||
final ValueChanged<Offset> onMoved;
|
|
||||||
final VoidCallback onDelete;
|
|
||||||
final ValueChanged<String> 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<String> onTapLink;
|
|
||||||
final Color baseColor;
|
|
||||||
|
|
||||||
static final RegExp _linkPattern = RegExp(r'\[\[([^\]]*)\]\]');
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final cs = Theme.of(context).colorScheme;
|
|
||||||
final spans = <InlineSpan>[];
|
|
||||||
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<String> 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', ' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,10 +11,8 @@ import '../services/pdf_service.dart';
|
|||||||
import '../services/pptx_service.dart';
|
import '../services/pptx_service.dart';
|
||||||
import '../editor/canvas/pen_note_screen.dart';
|
import '../editor/canvas/pen_note_screen.dart';
|
||||||
import '../editor/canvas/pen_slide_screen.dart';
|
import '../editor/canvas/pen_slide_screen.dart';
|
||||||
import 'board_screen.dart';
|
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'split_view_screen.dart';
|
|
||||||
|
|
||||||
// [M1] Relative date helper — no new package dependencies.
|
// [M1] Relative date helper — no new package dependencies.
|
||||||
String _formatDate(DateTime d) {
|
String _formatDate(DateTime d) {
|
||||||
@@ -61,15 +59,6 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
tooltip: l.importPpt,
|
tooltip: l.importPpt,
|
||||||
onPressed: () => _importPptx(context),
|
onPressed: () => _importPptx(context),
|
||||||
),
|
),
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.dashboard_customize),
|
|
||||||
tooltip: l.boardOpen,
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(
|
|
||||||
context,
|
|
||||||
).push(MaterialPageRoute(builder: (_) => const BoardScreen()));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
tooltip: l.search,
|
tooltip: l.search,
|
||||||
@@ -502,22 +491,10 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
'${document.docType.toUpperCase()} · ${document.pageCount} pages · $dateStr',
|
'${document.docType.toUpperCase()} · ${document.pageCount} pages · $dateStr',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
// [H2] Trailing row: split-view (PDF only) + remove
|
// [H2] Trailing remove button. Split view is now reached only by
|
||||||
trailing: Row(
|
// tapping a scratch-link anchor inside the PDF editor, so the
|
||||||
mainAxisSize: MainAxisSize.min,
|
// standalone split-view entry was removed.
|
||||||
children: [
|
trailing: IconButton(
|
||||||
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(
|
icon: Icon(
|
||||||
Icons.delete_outline,
|
Icons.delete_outline,
|
||||||
color: _hovering
|
color: _hovering
|
||||||
@@ -527,8 +504,6 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
tooltip: 'Remove document',
|
tooltip: 'Remove document',
|
||||||
onPressed: () => _confirmDelete(context),
|
onPressed: () => _confirmDelete(context),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
// [L2] Routing bug fix: route by docType
|
// [L2] Routing bug fix: route by docType
|
||||||
onTap: () => _openDocument(context),
|
onTap: () => _openDocument(context),
|
||||||
onLongPress: () => _showDocumentMenu(context),
|
onLongPress: () => _showDocumentMenu(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 {
|
void _showContextMenu(BuildContext context, Offset position) async {
|
||||||
final isPdf = widget.document.docType == 'pdf';
|
|
||||||
final result = await showMenu<String>(
|
final result = await showMenu<String>(
|
||||||
context: context,
|
context: context,
|
||||||
position: RelativeRect.fromLTRB(
|
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(
|
PopupMenuItem(
|
||||||
value: 'remove',
|
value: 'remove',
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -641,15 +593,12 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (result == 'open') {
|
if (result == 'open') {
|
||||||
_openDocument(this.context);
|
_openDocument(this.context);
|
||||||
} else if (result == 'split') {
|
|
||||||
_openSplitView(this.context);
|
|
||||||
} else if (result == 'remove') {
|
} else if (result == 'remove') {
|
||||||
_confirmDelete(this.context);
|
_confirmDelete(this.context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showDocumentMenu(BuildContext context) {
|
void _showDocumentMenu(BuildContext context) {
|
||||||
final isPdf = widget.document.docType == 'pdf';
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
@@ -657,16 +606,6 @@ class _DocumentTileState extends ConsumerState<_DocumentTile> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||||
title: const Text(
|
title: const Text(
|
||||||
|
|||||||
@@ -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:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
import 'package:pdfrx/pdfrx.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
import '../editor/canvas/pen_canvas.dart';
|
import '../editor/canvas/pen_canvas.dart';
|
||||||
|
import '../editor/canvas/pen_palette_widgets.dart';
|
||||||
import '../editor/canvas/pen_stroke.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 '../editor/notebook/ink_stroke_adapter.dart';
|
||||||
import '../models/ink_stroke.dart';
|
import '../models/ink_stroke.dart';
|
||||||
import '../models/pen_tool.dart';
|
|
||||||
import '../models/pressure_curve.dart';
|
|
||||||
import '../services/database_service.dart';
|
import '../services/database_service.dart';
|
||||||
import '../services/undo_manager.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
|
/// Split-view derivation surface for a single scratch-link anchor: left pane =
|
||||||
/// scratchpad for formula derivation. Scratchpad strokes are persisted per
|
/// the reference PDF (at [initialPage]), right pane = the anchor's private
|
||||||
/// document via [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad].
|
/// infinite scratchpad. Scratchpad strokes persist per ANCHOR via
|
||||||
|
/// [DatabaseService.saveScratchpad] / [DatabaseService.loadScratchpad], keyed by
|
||||||
|
/// [scratchLinkId].
|
||||||
class SplitViewScreen extends StatefulWidget {
|
class SplitViewScreen extends StatefulWidget {
|
||||||
final String filePath;
|
final String filePath;
|
||||||
final String documentId;
|
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({
|
const SplitViewScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.filePath,
|
required this.filePath,
|
||||||
required this.documentId,
|
required this.documentId,
|
||||||
|
required this.scratchLinkId,
|
||||||
|
this.initialPage = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -37,63 +60,55 @@ class SplitViewScreen extends StatefulWidget {
|
|||||||
class _SplitViewState extends State<SplitViewScreen> {
|
class _SplitViewState extends State<SplitViewScreen> {
|
||||||
// -- PDF (left pane) --
|
// -- PDF (left pane) --
|
||||||
final PdfViewerController _pdfController = PdfViewerController();
|
final PdfViewerController _pdfController = PdfViewerController();
|
||||||
int _currentPage = 0;
|
int _currentPage = 0; // 0-based
|
||||||
int _pageCount = 0;
|
int _pageCount = 0;
|
||||||
String _fileName = '';
|
|
||||||
|
|
||||||
// -- Split divider --
|
// -- Split divider --
|
||||||
double _leftPaneFraction = 0.5;
|
double _leftPaneFraction = 0.5;
|
||||||
bool _isDraggingDivider = false;
|
bool _isDraggingDivider = false;
|
||||||
|
|
||||||
// -- Scratchpad (right pane) --
|
// -- Scratchpad (right pane) --
|
||||||
// The scratchpad is an infinite WORLD: strokes are stored in absolute world
|
// Infinite WORLD: strokes stored in absolute world pixels ([InkStroke],
|
||||||
// pixels ([InkStroke], unchanged persistence format), and rendered through the
|
// unchanged persistence format), rendered through PenCanvas by normalizing
|
||||||
// performant PenCanvas by normalizing against the CURRENT world size. When the
|
// against the CURRENT world size. World auto-expands without moving ink.
|
||||||
// world auto-expands, the stored world coords don't move — only the
|
|
||||||
// normalization divisor grows — so ink stays put with zero drift.
|
|
||||||
final UndoManager _undoManager = UndoManager();
|
final UndoManager _undoManager = UndoManager();
|
||||||
List<InkStroke> _strokes = [];
|
List<InkStroke> _strokes = [];
|
||||||
double _canvasWidth = 4000;
|
double _canvasWidth = 4000;
|
||||||
double _canvasHeight = 4000;
|
double _canvasHeight = 4000;
|
||||||
static const _uuid = Uuid();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
/// Pan/zoom transform for the scratchpad world (PenCanvas drives this).
|
|
||||||
final TransformationController _scratchTransform = TransformationController();
|
final TransformationController _scratchTransform = TransformationController();
|
||||||
|
|
||||||
/// Set once the initial view has been framed onto existing ink.
|
|
||||||
bool _scratchCentered = false;
|
bool _scratchCentered = false;
|
||||||
|
|
||||||
Size get _worldSize => Size(_canvasWidth, _canvasHeight);
|
Size get _worldSize => Size(_canvasWidth, _canvasHeight);
|
||||||
|
|
||||||
/// Maps the scratchpad toolbar's [PenTool] to the pen-canvas tool. Shapes and
|
// -- Tool state (new Material You brush palette) --
|
||||||
/// text fall back to pen (the pen-first scratchpad is freehand).
|
CanvasTool _tool = CanvasTool.pen;
|
||||||
CanvasTool get _canvasTool => switch (_currentTool) {
|
BrushKind _penBrush = BrushKind.fountainPen;
|
||||||
PenTool.eraser => CanvasTool.eraser,
|
Color _color = Colors.black;
|
||||||
PenTool.highlighter => CanvasTool.highlighter,
|
|
||||||
_ => CanvasTool.pen,
|
|
||||||
};
|
|
||||||
|
|
||||||
// -- Tool state --
|
static const double _penWidthFraction = 0.006;
|
||||||
PenTool _currentTool = PenTool.pen;
|
static const double _highlighterWidthFraction = 0.02;
|
||||||
Color _currentColor = Colors.black;
|
|
||||||
double _currentStrokeWidth = 2.0;
|
static const List<Color> _palette = [
|
||||||
bool _filled = false;
|
Colors.black,
|
||||||
PressureCurveType _pressureCurveType = PressureCurveType.linear;
|
Colors.red,
|
||||||
StabilizationLevel _stabilizationLevel = StabilizationLevel.none;
|
Colors.blue,
|
||||||
|
Colors.green,
|
||||||
|
Colors.orange,
|
||||||
|
];
|
||||||
|
|
||||||
// -- Auto-save debounce --
|
// -- Auto-save debounce --
|
||||||
Timer? _saveTimer;
|
Timer? _saveTimer;
|
||||||
bool _dirty = false;
|
bool _dirty = false;
|
||||||
|
|
||||||
// -- Page link markers (optional feature) --
|
|
||||||
final List<_PageLink> _pageLinks = [];
|
|
||||||
|
|
||||||
static const double _edgeThreshold = 200.0;
|
static const double _edgeThreshold = 200.0;
|
||||||
static const double _expandAmount = 1000.0;
|
static const double _expandAmount = 1000.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_currentPage = widget.initialPage;
|
||||||
_loadScratchpad();
|
_loadScratchpad();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,20 +116,20 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_saveTimer?.cancel();
|
_saveTimer?.cancel();
|
||||||
_saveImmediate();
|
_saveImmediate();
|
||||||
_pdfController.dispose();
|
// PdfViewerController (pdfrx) has no dispose(); it detaches with the viewer.
|
||||||
_scratchTransform.dispose();
|
_scratchTransform.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Persistence --
|
// -- Persistence (keyed by the ANCHOR id, not the documentId) --
|
||||||
|
|
||||||
Future<void> _loadScratchpad() async {
|
Future<void> _loadScratchpad() async {
|
||||||
final db = await DatabaseService.getInstance();
|
final db = await DatabaseService.getInstance();
|
||||||
final strokes = await db.loadScratchpad(widget.documentId);
|
final strokes = await db.loadScratchpad(widget.scratchLinkId);
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
// Keep only freehand strokes so the canvas list stays 1:1 with the
|
// Keep only freehand strokes so the canvas list stays 1:1 with the undo
|
||||||
// undo manager (shapes/text have no pen-canvas representation).
|
// manager (shapes/text have no pen-canvas representation).
|
||||||
final freehand = strokes.where((s) => isFreehandTool(s.tool)).toList();
|
final freehand = strokes.where((s) => isFreehandTool(s.tool)).toList();
|
||||||
_strokes = freehand;
|
_strokes = freehand;
|
||||||
for (final s in freehand) {
|
for (final s in freehand) {
|
||||||
@@ -122,7 +137,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
void _scheduleSave() {
|
void _scheduleSave() {
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
@@ -135,13 +149,11 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
_dirty = false;
|
_dirty = false;
|
||||||
final db = await DatabaseService.getInstance();
|
final db = await DatabaseService.getInstance();
|
||||||
final json = jsonEncode(_strokes.map((s) => s.toJson()).toList());
|
final json = jsonEncode(_strokes.map((s) => s.toJson()).toList());
|
||||||
await db.saveScratchpad(widget.documentId, json);
|
await db.saveScratchpad(widget.scratchLinkId, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Scratchpad stroke callbacks --
|
// -- Scratchpad stroke callbacks --
|
||||||
|
|
||||||
/// PenCanvas committed a stroke (normalized to the current world). Convert it
|
|
||||||
/// to absolute world coords for storage.
|
|
||||||
void _onStrokeComplete(PenStroke pen) {
|
void _onStrokeComplete(PenStroke pen) {
|
||||||
final stroke = inkStrokeFromPen(pen, _worldSize,
|
final stroke = inkStrokeFromPen(pen, _worldSize,
|
||||||
id: _uuid.v4(), createdAt: DateTime.now());
|
id: _uuid.v4(), createdAt: DateTime.now());
|
||||||
@@ -153,8 +165,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
_scheduleSave();
|
_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<PenStroke> replacements) {
|
void _onErase(int index, List<PenStroke> replacements) {
|
||||||
if (index < 0 || index >= _strokes.length) return;
|
if (index < 0 || index >= _strokes.length) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -229,62 +239,32 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
setState(() => _isDraggingDivider = false);
|
setState(() => _isDraggingDivider = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- PDF page navigation --
|
// -- PDF page navigation (left pane) --
|
||||||
|
|
||||||
void _prevPage() {
|
void _prevPage() {
|
||||||
if (_currentPage > 0) {
|
if (_currentPage > 0) _pdfController.goToPage(pageNumber: _currentPage);
|
||||||
_pdfController.previousPage();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _nextPage() {
|
void _nextPage() {
|
||||||
if (_currentPage < _pageCount - 1) {
|
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) {
|
double get _strokeWidth => _tool == CanvasTool.highlighter
|
||||||
// Place a page link marker at the current scratchpad viewport center.
|
? _highlighterWidthFraction
|
||||||
// We approximate the viewport center as (0, 0) since InteractiveViewer
|
: _penWidthFraction;
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- Build --
|
// -- Build --
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: const Text('Scratch link', style: TextStyle(fontSize: 16)),
|
||||||
_fileName.isEmpty ? 'Split View' : _fileName,
|
|
||||||
style: const TextStyle(fontSize: 16),
|
|
||||||
),
|
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -293,7 +273,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
// Left pane page navigation
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.navigate_before),
|
icon: const Icon(Icons.navigate_before),
|
||||||
tooltip: 'Previous page (PDF)',
|
tooltip: 'Previous page (PDF)',
|
||||||
@@ -314,7 +293,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
onPressed: _currentPage < _pageCount - 1 ? _nextPage : null,
|
onPressed: _currentPage < _pageCount - 1 ? _nextPage : null,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// Canvas info
|
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message:
|
message:
|
||||||
'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}',
|
'Scratchpad size: ${_canvasWidth.round()} x ${_canvasHeight.round()}',
|
||||||
@@ -332,7 +310,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
),
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// Label clarifying that the toolbar controls the scratchpad pane.
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 12, top: 4),
|
padding: const EdgeInsets.only(left: 12, top: 4),
|
||||||
child: Align(
|
child: Align(
|
||||||
@@ -345,42 +322,17 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Toolbar (applies to scratchpad only)
|
_buildBrushPalette(cs),
|
||||||
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
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final totalWidth = constraints.maxWidth;
|
final totalWidth = constraints.maxWidth;
|
||||||
final leftWidth = totalWidth * _leftPaneFraction;
|
final leftWidth = totalWidth * _leftPaneFraction;
|
||||||
final rightWidth =
|
final rightWidth = totalWidth - leftWidth - 12;
|
||||||
totalWidth - leftWidth - 12; // 12px divider hit area
|
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// Left pane: PDF reference (read-only)
|
|
||||||
SizedBox(width: leftWidth, child: _buildPdfPane()),
|
SizedBox(width: leftWidth, child: _buildPdfPane()),
|
||||||
// Draggable divider: 12px hit area, 4px visual strip.
|
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onHorizontalDragStart: _onDividerDragStart,
|
onHorizontalDragStart: _onDividerDragStart,
|
||||||
onHorizontalDragUpdate: (d) =>
|
onHorizontalDragUpdate: (d) =>
|
||||||
@@ -401,7 +353,6 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Right pane: Infinite scratchpad
|
|
||||||
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
|
SizedBox(width: rightWidth, child: _buildScratchpadPane()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -413,72 +364,118 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPdfPane() {
|
/// Material You brush palette (shared chrome with the pen-first editors):
|
||||||
return Stack(
|
/// 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: [
|
children: [
|
||||||
GestureDetector(
|
BrushPickerButton(
|
||||||
onLongPress: () {
|
selected: _penBrush,
|
||||||
// Long-press on PDF to create page link marker
|
active: _tool == CanvasTool.pen,
|
||||||
_onPdfLongPress(_currentPage + 1);
|
tooltip: 'Brush',
|
||||||
},
|
labelFor: brushLabelEn,
|
||||||
child: SfPdfViewer.file(
|
onSelected: (b) => setState(() {
|
||||||
File(widget.filePath),
|
_penBrush = b;
|
||||||
controller: _pdfController,
|
_tool = CanvasTool.pen;
|
||||||
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;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
ToolButton(
|
||||||
|
icon: Icons.brush_outlined,
|
||||||
|
selected: _tool == CanvasTool.highlighter,
|
||||||
|
tooltip: 'Highlighter',
|
||||||
|
onPressed: () => setState(() => _tool = CanvasTool.highlighter),
|
||||||
),
|
),
|
||||||
// Page link markers overlay (on PDF pane, showing linked pages)
|
ToolButton(
|
||||||
if (_pageLinks.isNotEmpty)
|
icon: Icons.cleaning_services_outlined,
|
||||||
Positioned(bottom: 8, left: 8, child: _buildPageLinkChips()),
|
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),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPageLinkChips() {
|
Widget _colorDot(Color c, ColorScheme cs) {
|
||||||
return Wrap(
|
final selected =
|
||||||
spacing: 4,
|
_color.toARGB32() == c.toARGB32() && _tool != CanvasTool.eraser;
|
||||||
runSpacing: 4,
|
|
||||||
children: _pageLinks.map((link) {
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _onPageLinkTap(link),
|
onTap: () => setState(() {
|
||||||
onLongPress: () => _deletePageLink(link),
|
_color = c;
|
||||||
child: Chip(
|
if (_tool == CanvasTool.eraser) _tool = CanvasTool.pen;
|
||||||
avatar: const Icon(Icons.link, size: 14, color: Colors.white),
|
}),
|
||||||
label: Text(
|
child: AnimatedContainer(
|
||||||
'p${link.pageNumber}',
|
duration: const Duration(milliseconds: 150),
|
||||||
style: const TextStyle(fontSize: 11, color: Colors.white),
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.blue.shade600,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).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() {
|
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(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
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) {
|
if (!_scratchCentered) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -493,10 +490,11 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
pageSize: _worldSize,
|
pageSize: _worldSize,
|
||||||
strokes: penStrokesFromInk(_strokes, _worldSize),
|
strokes: penStrokesFromInk(_strokes, _worldSize),
|
||||||
transformationController: _scratchTransform,
|
transformationController: _scratchTransform,
|
||||||
tool: _canvasTool,
|
tool: _activeTool,
|
||||||
color: _currentColor,
|
brush: _penBrush,
|
||||||
strokeWidth: _currentStrokeWidth / _canvasWidth,
|
color: _color,
|
||||||
// The world is huge, so allow zooming further out to survey it.
|
strokeWidth: _strokeWidth,
|
||||||
|
eraserRadius: kDefaultEraserRadius,
|
||||||
minScale: 0.1,
|
minScale: 0.1,
|
||||||
maxScale: 8.0,
|
maxScale: 8.0,
|
||||||
onStrokeComplete: _onStrokeComplete,
|
onStrokeComplete: _onStrokeComplete,
|
||||||
@@ -508,9 +506,8 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Position the scratchpad so existing ink is on-screen. Fits the strokes'
|
/// Position the scratchpad so existing ink is on-screen (fit its bbox into
|
||||||
/// world bounding box into [pane] (with padding, scale clamped); for an empty
|
/// [pane], scale clamped); an empty scratchpad shows the top-left at 1:1.
|
||||||
/// scratchpad, shows the top-left working area at 1:1.
|
|
||||||
void _frameScratchpad(Size pane) {
|
void _frameScratchpad(Size pane) {
|
||||||
if (pane.isEmpty) return;
|
if (pane.isEmpty) return;
|
||||||
if (_strokes.isEmpty) {
|
if (_strokes.isEmpty) {
|
||||||
@@ -534,8 +531,9 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
const pad = 80.0;
|
const pad = 80.0;
|
||||||
final boxW = (maxX - minX) + pad * 2;
|
final boxW = (maxX - minX) + pad * 2;
|
||||||
final boxH = (maxY - minY) + pad * 2;
|
final boxH = (maxY - minY) + pad * 2;
|
||||||
final scale =
|
final scale = (pane.width / boxW < pane.height / boxH
|
||||||
(pane.width / boxW < pane.height / boxH ? pane.width / boxW : pane.height / boxH)
|
? pane.width / boxW
|
||||||
|
: pane.height / boxH)
|
||||||
.clamp(0.15, 1.5);
|
.clamp(0.15, 1.5);
|
||||||
final cx = (minX + maxX) / 2;
|
final cx = (minX + maxX) / 2;
|
||||||
final cy = (minY + maxY) / 2;
|
final cy = (minY + maxY) / 2;
|
||||||
@@ -548,11 +546,3 @@ class _SplitViewState extends State<SplitViewScreen> {
|
|||||||
..setTranslationRaw(tx, ty, 0);
|
..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});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import '../models/ink_stroke.dart';
|
|||||||
import '../models/note.dart';
|
import '../models/note.dart';
|
||||||
import '../models/pen_tool.dart';
|
import '../models/pen_tool.dart';
|
||||||
import '../models/pointer_device_kind.dart';
|
import '../models/pointer_device_kind.dart';
|
||||||
|
import '../models/scratch_link.dart';
|
||||||
|
|
||||||
class DatabaseService {
|
class DatabaseService {
|
||||||
static DatabaseService? _instance;
|
static DatabaseService? _instance;
|
||||||
@@ -56,7 +57,7 @@ class DatabaseService {
|
|||||||
|
|
||||||
_database = await openDatabase(
|
_database = await openDatabase(
|
||||||
dbPath,
|
dbPath,
|
||||||
version: 7,
|
version: 8,
|
||||||
onCreate: _onCreate,
|
onCreate: _onCreate,
|
||||||
onUpgrade: _onUpgrade,
|
onUpgrade: _onUpgrade,
|
||||||
);
|
);
|
||||||
@@ -202,6 +203,28 @@ class DatabaseService {
|
|||||||
|
|
||||||
// Sticky-note board cards (v7): F7 双链 + 无限便利贴.
|
// Sticky-note board cards (v7): F7 双链 + 无限便利贴.
|
||||||
await _createBoardCardsTable(db);
|
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<void> _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
|
/// 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 < 5) await _migrateV4toV5(db);
|
||||||
if (oldVersion < 6) await _migrateV5toV6(db);
|
if (oldVersion < 6) await _migrateV5toV6(db);
|
||||||
if (oldVersion < 7) await _migrateV6toV7(db);
|
if (oldVersion < 7) await _migrateV6toV7(db);
|
||||||
|
if (oldVersion < 8) await _migrateV7toV8(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _migrateV7toV8(Database db) async {
|
||||||
|
await db.transaction((txn) async {
|
||||||
|
await _createScratchLinksTable(txn);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _migrateV6toV7(Database db) async {
|
Future<void> _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<void> 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<List<ScratchLink>> 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<void> 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]);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String?> getApplicationDocumentsPath() async => dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _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<void>.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<void>.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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
166
test/scratch_link_test.dart
Normal file
166
test/scratch_link_test.dart
Normal file
@@ -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<String?> 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<String, dynamic>);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user