diff --git a/lib/components/canvas/_canvas_painter.dart b/lib/components/canvas/_canvas_painter.dart index 085b0d2b0..7ba634a77 100644 --- a/lib/components/canvas/_canvas_painter.dart +++ b/lib/components/canvas/_canvas_painter.dart @@ -55,7 +55,7 @@ class CanvasPainter extends CustomPainter { canvas.restore(); canvas.saveLayer(canvasRect, highlighterLayerPaint); } - if (currentSelection?.indices.contains(i) ?? false) { + if (currentSelection?.strokeIndices.contains(i) ?? false) { paint.color = primaryColor; } else { paint.color = stroke.strokeProperties.color.withAlpha(255).withInversion(invert); @@ -69,7 +69,7 @@ class CanvasPainter extends CustomPainter { for (int i = 0; i < strokes.length; i++) { final Stroke stroke = strokes[i]; if (stroke.penType == (Highlighter).toString()) continue; - if (currentSelection?.indices.contains(i) ?? false) { + if (currentSelection?.strokeIndices.contains(i) ?? false) { paint.color = primaryColor; } else { paint.color = stroke.strokeProperties.color.withInversion(invert); diff --git a/lib/components/canvas/canvas_image.dart b/lib/components/canvas/canvas_image.dart index a36205bff..4678b8128 100644 --- a/lib/components/canvas/canvas_image.dart +++ b/lib/components/canvas/canvas_image.dart @@ -22,6 +22,7 @@ class CanvasImage extends StatefulWidget { required this.setAsBackground, this.isBackground = false, this.readOnly = false, + this.selected = false, }) : super(key: Key('CanvasImage$filePath/${image.id}')); /// The path to the note that this image is in. @@ -32,6 +33,7 @@ class CanvasImage extends StatefulWidget { final void Function(EditorImage image)? setAsBackground; final bool isBackground; final bool readOnly; + final bool selected; /// When notified, all [CanvasImages] will have their [active] property set to false. static ChangeNotifier activeListener = ChangeNotifier(); @@ -254,6 +256,10 @@ class _CanvasImageState extends State { ), ), ), + if (widget.selected) // tint image if selected + ColoredBox( + color: colorScheme.primary.withOpacity(0.5), + ), if (!widget.readOnly) for (double x = -20; x <= 20; x += 20) for (double y = -20; y <= 20; y += 20) diff --git a/lib/components/canvas/inner_canvas.dart b/lib/components/canvas/inner_canvas.dart index a65e2d536..be9c1d865 100644 --- a/lib/components/canvas/inner_canvas.dart +++ b/lib/components/canvas/inner_canvas.dart @@ -162,14 +162,16 @@ class _InnerCanvasState extends State { child: quillEditor, ), ), - for (final EditorImage editorImage in page.images) + for (int i = 0; i < page.images.length; i++) CanvasImage( filePath: widget.coreInfo.filePath, - image: editorImage, + image: page.images[i], pageSize: Size(widget.width, widget.height), setAsBackground: widget.setAsBackground, readOnly: widget.coreInfo.readOnly || !widget.currentToolIsSelect, + selected: widget.currentSelection?.imageIndices.contains(i) + ?? false, ), ], ), diff --git a/lib/components/canvas/tools/select.dart b/lib/components/canvas/tools/select.dart index 5979a9527..806dc3969 100644 --- a/lib/components/canvas/tools/select.dart +++ b/lib/components/canvas/tools/select.dart @@ -1,5 +1,5 @@ - import 'package:flutter/material.dart'; +import 'package:saber/components/canvas/_editor_image.dart'; import 'package:saber/components/canvas/_stroke.dart'; import 'package:saber/components/canvas/tools/_tool.dart'; @@ -10,7 +10,16 @@ class Select extends Tool { static final Select _currentSelect = Select._(); static Select get currentSelect => _currentSelect; - SelectResult selectResult = SelectResult(-1, const [], Path()); + /// The minimum ratio of points inside a stroke or image + /// for it to be selected. + static const double minPercentInside = 0.7; + + SelectResult selectResult = SelectResult( + pageIndex: -1, + strokeIndices: const [], + imageIndices: const [], + path: Path(), + ); bool doneSelecting = false; @override @@ -23,7 +32,12 @@ class Select extends Tool { void onDragStart(Offset position, int pageIndex) { doneSelecting = false; - selectResult = SelectResult(pageIndex, [], Path()); + selectResult = SelectResult( + pageIndex: pageIndex, + strokeIndices: [], + imageIndices: [], + path: Path(), + ); selectResult.path.moveTo(position.dx, position.dy); onDragUpdate(position); } @@ -32,8 +46,10 @@ class Select extends Tool { selectResult.path.lineTo(position.dx, position.dy); } - /// Returns the indices of any [strokes] that are inside the selection area - void onDragEnd(List strokes) { + /// Adds the indices of any [strokes] that are inside the selection area + /// to [selectResult.indices]. + void onDragEnd(List strokes, List images) { + selectResult.path.close(); doneSelecting = true; @@ -48,8 +64,35 @@ class Select extends Tool { final ratio = pointsInside / stroke.polygon.length; // if more than 70% of the points are inside the path, select the stroke - if (ratio > 0.7) { - selectResult.indices.add(i); + if (ratio > minPercentInside) { + selectResult.strokeIndices.add(i); + } + } + + // test 5x5 grid of points inside each image + for (int i = 0; i < images.length; i++) { + final EditorImage image = images[i]; + + const int gridSize = 5; + final double gridCellWidth = image.dstRect.width / (gridSize - 1); + final double gridCellHeight = image.dstRect.height / (gridSize - 1); + + int pointsInside = 0; + for (int x = 0; x < gridSize; x++) { + for (int y = 0; y < gridSize; y++) { + if (selectResult.path.contains(Offset( + image.dstRect.left + gridCellWidth * x, + image.dstRect.top + gridCellHeight * y, + ))) { + pointsInside++; + } + } + } + + // times 0.8 because the grid is not perfectly accurate + final int minPointsInside = (gridSize * gridSize * minPercentInside * 0.8).floor(); + if (pointsInside >= minPointsInside) { + selectResult.imageIndices.add(i); } } } @@ -57,8 +100,14 @@ class Select extends Tool { class SelectResult { int pageIndex; - final List indices; + final List strokeIndices; + final List imageIndices; Path path; - SelectResult(this.pageIndex, this.indices, this.path); + SelectResult({ + required this.pageIndex, + required this.strokeIndices, + required this.imageIndices, + required this.path, + }); } diff --git a/lib/pages/editor/editor.dart b/lib/pages/editor/editor.dart index a395d9923..d0857911e 100644 --- a/lib/pages/editor/editor.dart +++ b/lib/pages/editor/editor.dart @@ -411,9 +411,12 @@ class EditorState extends State { } else if (currentTool is Select) { Select select = currentTool as Select; if (select.doneSelecting) { - for (int i in select.selectResult.indices) { + for (int i in select.selectResult.strokeIndices) { page.strokes[i].offset += offset; } + for (int i in select.selectResult.imageIndices) { + page.images[i].dstRect = page.images[i].dstRect.shift(offset); + } select.selectResult.path = select.selectResult.path.shift(offset); } else { select.onDragUpdate(position); @@ -446,19 +449,21 @@ class EditorState extends State { if (select.doneSelecting) { history.recordChange(EditorHistoryItem( type: EditorHistoryItemType.move, - strokes: select.selectResult.indices + strokes: select.selectResult.strokeIndices .map((i) => page.strokes[i]) .toList(), - images: [], + images: select.selectResult.imageIndices + .map((i) => page.images[i]) + .toList(), offset: Rect.fromLTRB( moveOffset.dx, moveOffset.dy, - 0, - 0, + moveOffset.dx, + moveOffset.dy, ), )); } else { - select.onDragEnd(page.strokes); + select.onDragEnd(page.strokes, page.images); } } }); diff --git a/test/tools_select_test.dart b/test/tools_select_test.dart index 233113219..bc6415a86 100644 --- a/test/tools_select_test.dart +++ b/test/tools_select_test.dart @@ -1,12 +1,14 @@ +import 'dart:typed_data'; import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; +import 'package:saber/components/canvas/_editor_image.dart'; import 'package:saber/components/canvas/_stroke.dart'; import 'package:saber/components/canvas/tools/select.dart'; import 'package:saber/components/canvas/tools/stroke_properties.dart'; void main() { - test('Test that the select tool selects the right points', () async { + test('Test that the select tool selects the right strokes', () async { Select select = Select.currentSelect; StrokeProperties strokeProperties = StrokeProperties(); Size pageSize = const Size(100, 100); @@ -41,9 +43,64 @@ void main() { ..offset = const Offset(10, 10), ]; - select.onDragEnd(strokes); + select.onDragEnd(strokes, const []); - expect(select.selectResult.indices.length, 1, reason: 'Only one stroke should be selected'); - expect(select.selectResult.indices.first, 0, reason: 'The first stroke should be selected'); + expect(select.selectResult.strokeIndices.length, 1, reason: 'Only one stroke should be selected'); + expect(select.selectResult.strokeIndices.first, 0, reason: 'The first stroke should be selected'); + expect(select.selectResult.imageIndices.length, 0, reason: 'No images should be selected'); }); + + test('Test that the select tool selects the right images', () async { + Select select = Select.currentSelect; + + // Drag gesture in a 10x10 square shape, on page 0 + select.onDragStart(Offset.zero, 0); + select.onDragUpdate(const Offset(0, 10)); + select.onDragUpdate(const Offset(10, 10)); + select.onDragUpdate(const Offset(10, 0)); + + expect(select.selectResult.pageIndex, 0, reason: 'The page index should be 0'); + + List images = [ + // index 0 is inside (100% in the selection) + TestImage( + dstRect: const Rect.fromLTWH(0, 0, 10, 10), + ), + // index 1 is inside (> 70% in the selection) + TestImage( + dstRect: const Rect.fromLTWH(0, 0, 10 / 0.75, 10 / 0.75), + ), + // index 2 is outside (< 70% in the selection) + TestImage( + dstRect: const Rect.fromLTWH(0, 0, 10 / 0.6, 10 / 0.6), + ), + ]; + + select.onDragEnd(const [], images); + + expect(select.selectResult.imageIndices.length, 2, reason: 'Two images should be selected'); + expect(select.selectResult.imageIndices.contains(0), true, reason: 'The first image should be selected'); + expect(select.selectResult.imageIndices.contains(1), true, reason: 'The second image should be selected'); + expect(select.selectResult.strokeIndices.length, 0, reason: 'No strokes should be selected'); + }); +} + +class TestImage extends EditorImage { + TestImage({ + required super.dstRect, + }) : super( + id: -1, + extension: '.png', + bytes: Uint8List(0), + pageIndex: 0, + pageSize: const Size(100, 100), + onMoveImage: null, + onDeleteImage: null, + onMiscChange: null, + ); + + @override + Future getImage({Size? pageSize}) async { + // do nothing + } }