diff --git a/lib/collab/page_made_here.dart b/lib/collab/page_made_here.dart index 32c3e95f..9713db10 100644 --- a/lib/collab/page_made_here.dart +++ b/lib/collab/page_made_here.dart @@ -23,7 +23,7 @@ List pageCopyContentOps({ final newLineupId = _sharedCopyIds(); return [ for (final element in elements) - _copiedElement(pageId, element, _copyId(element.publicId)), + _copiedElement(pageId, element, newStorableCopyId(element.publicId)), for (final lineup in lineups) _copiedLineupGroup(pageId, lineup, newLineupId), ]; @@ -67,21 +67,11 @@ RemotePageSnapshot pageMadeHere({ ); } -/// The longest root a copy id keeps, URL-encoded, as the server's -/// (convex/lib/pageCopyId.ts): a longer one would make the op's storage -/// key too long to keep, so that copy gets a plain id. -const _maxKeptRootLength = 80; - -String _copyId(String id) => - Uri.encodeComponent(pageCopyRoot(id)).length > _maxKeptRootLength - ? const Uuid().v4() - : newPageCopyId(id); - /// One copy id per id, shared by every lineup group copied: a spot or /// lineup named twice is the same copy both times. String Function(String id) _sharedCopyIds() { final ids = {}; - return (id) => ids[id] ??= _copyId(id); + return (id) => ids[id] ??= newStorableCopyId(id); } ElementAddOp _copiedElement(String pageId, DrawnRow element, String id) { diff --git a/lib/const/line_provider.dart b/lib/const/line_provider.dart index 4f5bd4cb..1a93bcc6 100644 --- a/lib/const/line_provider.dart +++ b/lib/const/line_provider.dart @@ -377,9 +377,12 @@ class LineUpGraph { /// The lineups [linkIds] and the spots they aim at, ready to be put on /// another page, each under a new id that carries its original's (see /// page_copy_id.dart). Spots they share stay shared in the copy. - LineUpGraph copyOfLinks(Set linkIds) { + LineUpGraph copyOfLinks( + Set linkIds, { + String Function(String id) newId = newPageCopyId, + }) { final newIds = {}; - String renamed(String id) => newIds[id] ??= newPageCopyId(id); + String renamed(String id) => newIds[id] ??= newId(id); final part = linksWithSpots(linkIds); return LineUpGraph( origins: [ @@ -1398,6 +1401,20 @@ class LineUpProvider extends Notifier { } } + /// Adds [copies] (a paste: lineups whose ids are all new) as one undoable + /// addition. + void addCopies(LineUpGraph copies) { + if (!ref.canEditPages || copies.links.isEmpty) return; + _apply(from: LineUpGraph.empty, to: copies); + _record( + LineUpGraphAction( + type: ActionType.addition, + id: copies.links.first.id, + after: copies.deepCopy(), + ), + ); + } + // --- Deletions ----------------------------------------------------------- void deleteLink(String linkId) { diff --git a/lib/const/page_copy_id.dart b/lib/const/page_copy_id.dart index 43f0ddad..043654c3 100644 --- a/lib/const/page_copy_id.dart +++ b/lib/const/page_copy_id.dart @@ -34,3 +34,15 @@ String pageCopyRoot(String id) { /// A fresh id for a copy of [id] on another page. String newPageCopyId(String id) => '${pageCopyRoot(id)}$_copyMark${const Uuid().v4()}'; + +/// The longest root a copy id keeps, URL-encoded, as the server's +/// (convex/lib/pageCopyId.ts): a longer one would make the op's storage +/// key too long to keep, so that copy gets a plain id. +const _maxKeptRootLength = 80; + +/// [newPageCopyId], or a plain id when [id]'s root is too long for the copy +/// to be stored to send (only an item imported with an unusually long id). +String newStorableCopyId(String id) => + Uri.encodeComponent(pageCopyRoot(id)).length > _maxKeptRootLength + ? const Uuid().v4() + : newPageCopyId(id); diff --git a/lib/providers/canvas_clipboard_provider.dart b/lib/providers/canvas_clipboard_provider.dart new file mode 100644 index 00000000..c0a2a991 --- /dev/null +++ b/lib/providers/canvas_clipboard_provider.dart @@ -0,0 +1,516 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/page_copy_id.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/canvas_selection_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/media_bytes_source.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/share_link_provider.dart'; +import 'package:icarus/providers/strategy_image_source.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/utility_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/local_image_file.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:uuid/uuid.dart'; + +/// A picture shown by something copied, carried as its bytes so a paste +/// can place it in another strategy. +typedef CopiedPicture = ({Uint8List bytes, String fileExtension}); + +/// The most picture bytes one copy carries. Past it, the rest are left out +/// and the copy pastes into its own strategy only. +const _maxCopiedPictureBytes = 200 * 1024 * 1024; + +/// What Ctrl+C took: copies of the selected items as they were, from the +/// strategy and map they were on. It lives in this window only, never on +/// the system clipboard, so nothing copied ever has to be read back by +/// another version of Icarus. +class CanvasClipboard { + CanvasClipboard({ + required this.map, + required this.strategyId, + required this.agents, + required this.abilities, + required this.texts, + required this.images, + required this.utilities, + required this.drawings, + required this.lineups, + required this.pictures, + }); + + final MapValue map; + final String? strategyId; + final List agents; + final List abilities; + final List texts; + final List images; + final List utilities; + final List drawings; + final LineUpGraph lineups; + + /// The bytes of every picture these show, by picture id, read after the + /// copy; null for one that could not be read (still uploading, offline, + /// or past the copy's limit). Only a paste into another strategy waits + /// for them. + final Future> pictures; + + int get itemCount => + agents.length + + abilities.length + + texts.length + + images.length + + utilities.length + + drawings.length + + lineups.links.length; +} + +/// Why a paste put nothing on the page, for the editor to say. +enum PasteRefusal { + /// What was copied belongs on another map. + otherMap, + + /// A picture in it could not be read when it was copied (offline, or the + /// browser was not let download it). + picturesMissing, + + /// A picture in it is larger than a cloud strategy takes. + pictureTooLarge, + + /// The page on screen changed while its pictures were saved. + pageChanged, +} + +final canvasClipboardProvider = + NotifierProvider( + CanvasClipboardNotifier.new, +); + +class CanvasClipboardNotifier extends Notifier { + static const _uuid = Uuid(); + + @override + CanvasClipboard? build() { + // What one account copied never pastes into another's strategies. + ref.listen(cloudMediaAccountIdProvider, (previous, next) { + if (previous != next) state = null; + }); + return null; + } + + /// Puts copies of the selected items on the clipboard. [readPictures] + /// fetches the bytes behind the pictures they show (see + /// [readCopiedPictures]). Returns how many items were copied. + int copySelection( + Future> Function(Map) + readPictures, + ) { + final ids = {for (final item in ref.read(canvasSelectionProvider)) item.id}; + if (ids.isEmpty) return 0; + bool picked(PlacedWidget item) => ids.contains(item.id); + final images = [ + for (final image in ref.read(placedImageProvider).images) + if (picked(image)) clonePlacedImage(image), + ]; + final lineups = ref.read(lineUpProvider).graph.linksWithSpots(ids); + final clipboard = CanvasClipboard( + map: ref.read(mapProvider).currentMap, + strategyId: ref.read(strategyProvider).strategyId, + agents: [ + for (final agent in ref.read(agentProvider)) + if (picked(agent)) clonePlacedAgentNode(agent), + ], + abilities: [ + for (final ability in ref.read(abilityProvider)) + if (picked(ability)) clonePlacedAbility(ability), + ], + texts: [ + for (final text in ref.read(textProvider)) + if (picked(text)) clonePlacedText(text), + ], + images: images, + utilities: [ + for (final utility in ref.read(utilityProvider)) + if (picked(utility)) clonePlacedUtility(utility), + ], + drawings: [ + for (final drawing in ref.read(drawingProvider).elements) + if (ids.contains(drawing.id)) cloneDrawingElement(drawing), + ], + lineups: lineups.deepCopy(), + pictures: readPictures({ + for (final image in images) image.pictureId: image.fileExtension ?? '', + for (final link in lineups.links) + for (final image in link.images) image.id: image.fileExtension, + }), + ); + if (clipboard.itemCount == 0) return 0; + state = clipboard; + return clipboard.itemCount; + } + + /// Puts copies of what is on the clipboard on the page on screen, where + /// they were on the page they came from, as one undo step, and selects + /// them. Pasted into the strategy they came from, they show the same + /// pictures while those are still there; every other picture is saved + /// anew from the bytes the copy carried, as an image dropped on the map + /// is. Returns why nothing was pasted, or null. + Future paste() async { + final clipboard = state; + if (clipboard == null || !_canEdit) return null; + if (clipboard.map != ref.read(mapProvider).currentMap) { + return PasteRefusal.otherMap; + } + final strategy = ref.read(strategyProvider); + final strategyId = strategy.strategyId; + if (strategyId == null) return null; + final isCloud = strategy.source == StrategySource.cloud; + // Everything lands on this page or not at all. + final pageId = ref.read(strategyPageSessionProvider).activePageId; + bool stillHere() => + ref.read(strategyProvider).strategyId == strategyId && + ref.read(strategyPageSessionProvider).activePageId == pageId && + _canEdit; + + // Each picture by id, with its file extension. + final pictureIds = { + for (final image in clipboard.images) + image.pictureId: image.fileExtension ?? '', + for (final link in clipboard.lineups.links) + for (final image in link.images) image.id: image.fileExtension, + }; + // A picture's new id, by its id where it was copied. A picture keeps its + // id in the strategy it came from. In the cloud it is still there: a cut + // image's tombstone keeps its picture for 30 days, and the clipboard + // lasts one session. A local strategy cleans up a picture nothing shows + // when it reopens, so there it keeps its id only while its file is + // there, and is otherwise put back from the bytes the copy carried. + final newPictureIds = {}; + final keep = clipboard.strategyId == strategyId; + final renew = [ + for (final MapEntry(key: id, value: extension) in pictureIds.entries) + if (!keep || (!isCloud && _gone(id, extension))) id, + ]; + if (renew.isNotEmpty) { + final pictures = await clipboard.pictures; + // The editor may have moved on while the pictures were read. + if (!stillHere()) return PasteRefusal.pageChanged; + if (renew.any((id) => pictures[id] == null)) { + return PasteRefusal.picturesMissing; + } + final uploads = isCloud || !ref.read(imageFilesOnDeviceProvider); + if (uploads && + renew.any((id) => pictures[id]!.bytes.length > maxCloudImageBytes)) { + return PasteRefusal.pictureTooLarge; + } + for (final id in renew) { + newPictureIds[id] = _uuid.v4(); + } + } + // Made before anything is written, so a copy that can't be made leaves + // nothing behind. + final pasted = _copies(clipboard, newPictureIds); + + if (newPictureIds.isNotEmpty) { + final pictures = await clipboard.pictures; + final images = ref.read(placedImageProvider.notifier); + for (final MapEntry(key: id, value: newId) in newPictureIds.entries) { + final picture = pictures[id]!; + await images.saveSecureImage( + picture.bytes, + newId, + picture.fileExtension, + strategyId: strategyId, + ); + } + if (isCloud) await _stageUploads(clipboard, newPictureIds, strategyId); + if (!stillHere()) { + if (isCloud) { + final queue = ref.read(cloudMediaUploadQueueProvider.notifier); + for (final id in newPictureIds.values) { + await queue.discardStagedUpload(id); + } + } + return PasteRefusal.pageChanged; + } + } + + ref.read(actionProvider.notifier).performTransaction( + groups: const [], + mutation: () { + for (final agent in pasted.agents) { + ref.read(agentProvider.notifier).addAgent(agent); + } + for (final ability in pasted.abilities) { + ref.read(abilityProvider.notifier).addAbility(ability); + } + for (final text in pasted.texts) { + ref.read(textProvider.notifier).addText(text); + } + for (final image in pasted.images) { + ref.read(placedImageProvider.notifier).addImageCopy(image); + } + for (final utility in pasted.utilities) { + ref.read(utilityProvider.notifier).addUtility(utility); + } + ref.read(drawingProvider.notifier).addDrawings(pasted.drawings); + ref.read(lineUpProvider.notifier).addCopies(pasted.lineups); + }, + ); + ref.read(canvasSelectionProvider.notifier).select([ + for (final agent in pasted.agents) + (kind: SelectableKind.agent, id: agent.id), + for (final ability in pasted.abilities) + (kind: SelectableKind.ability, id: ability.id), + for (final text in pasted.texts) (kind: SelectableKind.text, id: text.id), + for (final image in pasted.images) + (kind: SelectableKind.image, id: image.id), + for (final utility in pasted.utilities) + (kind: SelectableKind.utility, id: utility.id), + for (final drawing in pasted.drawings) + (kind: SelectableKind.drawing, id: drawing.id), + for (final link in pasted.lineups.links) + (kind: SelectableKind.lineup, id: link.id), + ]); + + if (isCloud && newPictureIds.isNotEmpty) { + try { + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .commitStagedMediaReferences( + strategyPublicId: strategyId, + assetPublicIds: newPictureIds.values, + ); + } catch (error, stackTrace) { + // The pictures stay staged on this device; the sync menu shows + // them, as it does any image upload that is waiting. + AppErrorReporter.reportError( + 'Pasted pictures could not be marked for upload: $error', + error: error, + stackTrace: stackTrace, + source: 'canvas_clipboard.paste', + ); + } + } + return null; + } + + bool get _canEdit => + ref.read(currentStrategyCapabilitiesProvider).canEditPages; + + /// Whether picture [id] can no longer be shown in the local strategy on + /// screen: its file is gone. + bool _gone(String id, String fileExtension) => readStrategyImageSourceOf( + ref, + (id: id, fileExtension: fileExtension), + ) is ImageFailed; + + /// Stages the upload of every picture saved anew, as placing an image or + /// a lineup screenshot does; [paste] then commits them with the items + /// that show them. + Future _stageUploads( + CanvasClipboard clipboard, + Map newPictureIds, + String strategyId, + ) async { + final queue = ref.read(cloudMediaUploadQueueProvider.notifier); + final lineupPictures = []; + final placedPictures = { + for (final image in clipboard.images) image.pictureId, + }; + for (final link in clipboard.lineups.links) { + for (final image in link.images) { + if (placedPictures.contains(image.id)) continue; + lineupPictures.add(image.copyWith(id: newPictureIds[image.id])); + } + } + for (final image in clipboard.images) { + if (!placedPictures.remove(image.pictureId)) continue; + await queue.enqueuePlacedImageUpload( + strategyPublicId: strategyId, + imagePublicId: newPictureIds[image.pictureId]!, + fileExtension: image.fileExtension, + ); + } + if (lineupPictures.isNotEmpty) { + await queue.enqueueLineupMediaJobs( + strategyPublicId: strategyId, + images: lineupPictures, + ); + } + } + + /// Copies of everything on [clipboard] under ids of their own (see + /// newStorableCopyId: a pasted item keeps its root, so a page transition + /// still pairs it with the item it was copied from). Pictures are renamed by + /// [newPictureIds]: the first image showing a picture takes the picture's + /// id, as an image placed fresh does, and the rest point at it. + ({ + List agents, + List abilities, + List texts, + List images, + List utilities, + List drawings, + LineUpGraph lineups, + }) _copies(CanvasClipboard clipboard, Map newPictureIds) { + final placedPictures = {}; + PlacedImage imageCopy(PlacedImage image) { + final picture = newPictureIds[image.pictureId]; + if (picture == null) { + return image.copyWith( + id: newStorableCopyId(image.id), + assetId: image.pictureId, + ); + } + final first = placedPictures.add(picture); + return PlacedImage( + position: image.position, + id: first ? picture : newStorableCopyId(image.id), + aspectRatio: image.aspectRatio, + scale: image.scale, + fileExtension: image.fileExtension, + sizeVersion: image.sizeVersion, + tagColorValue: image.tagColorValue, + assetId: first ? null : picture, + )..link = image.link; + } + + final lineups = clipboard.lineups.copyOfLinks({ + for (final link in clipboard.lineups.links) link.id, + }, newId: newStorableCopyId); + return ( + agents: [ + for (final agent in clipboard.agents) + switch (agent) { + PlacedAgent() => agent.copyWith(id: newStorableCopyId(agent.id)), + PlacedViewConeAgent() => + agent.copyWith(id: newStorableCopyId(agent.id)), + PlacedCircleAgent() => + agent.copyWith(id: newStorableCopyId(agent.id)), + }, + ], + abilities: [ + for (final ability in clipboard.abilities) + ability.copyWith(id: newStorableCopyId(ability.id)), + ], + texts: [ + for (final text in clipboard.texts) + text.copyWith(id: newStorableCopyId(text.id), text: text.text), + ], + images: [for (final image in clipboard.images) imageCopy(image)], + utilities: [ + for (final utility in clipboard.utilities) + utility.copyWith(id: newStorableCopyId(utility.id)), + ], + drawings: [ + for (final drawing in clipboard.drawings) _drawingCopy(drawing), + ], + lineups: newPictureIds.isEmpty + ? lineups + : LineUpGraph( + origins: lineups.origins, + landings: lineups.landings, + links: [ + for (final link in lineups.links) + link.copyWith( + images: [ + for (final image in link.images) + image.copyWith(id: newPictureIds[image.id]), + ], + ), + ], + ), + ); + } +} + +DrawingElement _drawingCopy(DrawingElement drawing) { + // Through JSON text: the saved form holds objects (its bounding box) + // that reading it back expects as maps. + final json = jsonDecode(jsonEncode(DrawingProvider.elementToJson(drawing))) + as Map; + final copy = DrawingProvider.elementFromJson({ + ...json, + 'id': newStorableCopyId(drawing.id), + }); + if (copy is FreeDrawing) copy.rebuildPath(CoordinateSystem.instance); + return copy; +} + +/// Reads the bytes behind each picture in [pictures] (picture id to file +/// extension) the way the editor shows it: from its file, from bytes still +/// uploading, or downloaded from the cloud. A picture that cannot be read +/// comes back null. +Future> readCopiedPictures( + WidgetRef ref, + Map pictures, +) async { + final strategyId = ref.read(strategyProvider).strategyId; + final sources = { + for (final MapEntry(key: id, value: fileExtension) in pictures.entries) + id: readStrategyImageSource(ref, (id: id, fileExtension: fileExtension)), + }; + final read = {}; + var carried = 0; + for (final MapEntry(key: id, value: source) in sources.entries) { + Uint8List? bytes; + try { + bytes = switch (source) { + LocalImageFile(:final path) => await readLocalImageFile(path) + .fold( + BytesBuilder(copy: false), (bytes, chunk) => bytes..add(chunk)) + .then((bytes) => bytes.takeBytes()), + ImageBytes(:final bytes) => bytes, + RemoteImageUrl(:final url) => await downloadCloudImageBytes( + url, + freshUrl: () { + if (strategyId == null) return Future.value(null); + final linkView = ref.read(shareLinkViewProvider); + return ref + .read(convexStrategyRepositoryProvider) + .getImageAssetUrl( + strategyPublicId: strategyId, + assetPublicId: id, + shareToken: linkView?.strategyPublicId == strategyId + ? linkView!.token + : null, + ); + }, + ), + ImageLoading() || ImageFailed() => null, + }; + } catch (error) { + AppErrorReporter.reportInfo( + 'Copied picture $id could not be read: $error', + source: 'canvas_clipboard.copy', + ); + } + if (bytes != null && carried + bytes.length <= _maxCopiedPictureBytes) { + carried += bytes.length; + read[id] = (bytes: bytes, fileExtension: pictures[id]!); + } else { + read[id] = null; + } + } + return read; +} diff --git a/lib/providers/canvas_selection_provider.dart b/lib/providers/canvas_selection_provider.dart index a9c0d6b5..8ae2426e 100644 --- a/lib/providers/canvas_selection_provider.dart +++ b/lib/providers/canvas_selection_provider.dart @@ -220,7 +220,12 @@ class CanvasSelectionNotifier extends Notifier> { /// Deletes everything selected as one undo step. void deleteSelected() { - final selection = state; + deleteItems(state); + clear(); + } + + /// Deletes [selection] (items of the page on screen) as one undo step. + void deleteItems(Set selection) { if (selection.isEmpty || !ref.read(currentStrategyCapabilitiesProvider).canEditPages) { return; diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index 8728aedf..f5dda692 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -469,6 +469,26 @@ class DrawingProvider extends Notifier { _triggerRepaint(); } + /// Adds finished [drawings] (a paste) on top, each recorded as its own + /// addition (wrap the call in performTransaction for one undo step). + void addDrawings(List drawings) { + if (!ref.canEditPages || drawings.isEmpty) return; + state = state.copyWith(elements: [...state.elements, ...drawings]); + for (final drawing in drawings) { + ref.read(actionProvider.notifier).addAction( + UserAction( + type: ActionType.addition, + id: drawing.id, + group: ActionGroup.drawing, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.drawing(drawing), + ), + ), + ); + } + _triggerRepaint(); + } + /// Deletes the drawings in [ids], each recorded as its own deletion (wrap /// the call in performTransaction for one undo step). void deleteDrawingsById(Set ids) { diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 05f12684..137cbace 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -236,6 +236,23 @@ class PlacedImageProvider extends Notifier { } } + /// Places [image], a copy whose picture is already saved where this + /// strategy keeps its pictures (a paste), as an undoable addition. + void addImageCopy(PlacedImage image) { + if (!ref.canEditPages) return; + ref.read(actionProvider.notifier).addAction( + UserAction( + type: ActionType.addition, + id: image.id, + group: ActionGroup.image, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.image(image), + ), + ), + ); + state = state.copyWith(images: [...state.images, image]); + } + void removeImageAsAction(String id) { if (!ref.canEditPages) return; final index = PlacedWidget.getIndexByID(id, state.images); diff --git a/lib/providers/strategy_image_source.dart b/lib/providers/strategy_image_source.dart index c5695969..ba0eb819 100644 --- a/lib/providers/strategy_image_source.dart +++ b/lib/providers/strategy_image_source.dart @@ -107,6 +107,13 @@ StrategyImageSource readStrategyImageSource( ) => _strategyImageSource(ref.read, image); +/// [readStrategyImageSource] for code that holds a provider's [Ref]. +StrategyImageSource readStrategyImageSourceOf( + Ref ref, + StrategyImageKey image, +) => + _strategyImageSource(ref.read, image); + StrategyImageSource _strategyImageSource( T Function(ProviderListenable provider) watch, StrategyImageKey image, diff --git a/lib/widgets/canvas_selection_layer.dart b/lib/widgets/canvas_selection_layer.dart index d3d2689a..4a85c378 100644 --- a/lib/widgets/canvas_selection_layer.dart +++ b/lib/widgets/canvas_selection_layer.dart @@ -7,15 +7,19 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/shortcut_info.dart'; +import 'package:icarus/providers/canvas_clipboard_provider.dart'; import 'package:icarus/providers/canvas_selection_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/editor_operation_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/screen_zoom_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/widgets/editor_operation_scope.dart'; /// Whether Space is held to move the map: while it is, a drag anywhere on @@ -43,8 +47,8 @@ class _CanvasPanKeyHeld extends Notifier { /// middle-button drag and the scroll wheel move and zoom the map as before. /// /// A press on the canvas gives it the keyboard, so the selection's keys -/// (Escape, Delete, Backspace, select all) and Space reach it, and never -/// fire from a dialog, a menu or the library. +/// (Escape, Delete, Backspace, select all, copy, cut, paste) and Space +/// reach it, and never fire from a dialog, a menu or the library. class CanvasSelectionLayer extends ConsumerStatefulWidget { const CanvasSelectionLayer({ required this.enabled, @@ -175,9 +179,97 @@ class _CanvasSelectionLayerState extends ConsumerState selection.selectAll(); return KeyEventResult.handled; } + bool primaryWith(LogicalKeyboardKey key) => + IcarusKeyBinding(trigger: key, primary: true) + .toActivator() + .accepts(event, HardwareKeyboard.instance); + if (primaryWith(LogicalKeyboardKey.keyC) && hasSelection) { + _copy(); + return KeyEventResult.handled; + } + if (primaryWith(LogicalKeyboardKey.keyX) && hasSelection) { + unawaited(_cut()); + return KeyEventResult.handled; + } + if (primaryWith(LogicalKeyboardKey.keyV) && + ref.read(canvasClipboardProvider) != null) { + unawaited(_paste()); + return KeyEventResult.handled; + } return KeyEventResult.ignored; } + CanvasClipboard? _copy() { + final clipboard = ref.read(canvasClipboardProvider.notifier); + final copied = clipboard.copySelection( + (pictures) => readCopiedPictures(ref, pictures), + ); + return copied == 0 ? null : ref.read(canvasClipboardProvider); + } + + /// Copies the selection, then deletes it once its pictures are read: a + /// deleted picture may not be there to read later. Only what was cut is + /// deleted, and only while its page is still the one on screen. + Future _cut() async { + final cut = ref.read(canvasSelectionProvider); + final page = ref.read(strategyPageSessionProvider).activePageId; + final copied = _copy(); + if (copied == null) return; + await copied.pictures; + if (!mounted || + ref.read(strategyPageSessionProvider).activePageId != page) { + return; + } + ref.read(canvasSelectionProvider.notifier).deleteItems(cut); + } + + bool _pasting = false; + + Future _paste() async { + if (_pasting) return; + _pasting = true; + try { + final refusal = await ref.read(canvasClipboardProvider.notifier).paste(); + if (refusal == null || !mounted) return; + final copiedOn = ref.read(canvasClipboardProvider)?.map; + Settings.showToast( + message: switch (refusal) { + PasteRefusal.otherMap => copiedOn == null + ? 'What you copied is from another map.' + : 'What you copied is from ${Maps.displayName(copiedOn)}. Paste ' + 'it into a strategy on ${Maps.displayName(copiedOn)}.', + PasteRefusal.picturesMissing => + "A picture in what you copied couldn't be read when you copied " + 'it, so nothing was pasted. Check your connection and copy ' + 'it again.', + PasteRefusal.pageChanged => + 'The page changed before the paste finished, so nothing was ' + 'pasted.', + PasteRefusal.pictureTooLarge => + 'A picture in what you copied is over 15 MB, more than a cloud ' + 'strategy takes, so nothing was pasted.', + }, + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } catch (error, stackTrace) { + AppErrorReporter.reportError( + 'Paste failed: $error', + error: error, + stackTrace: stackTrace, + source: 'canvas_selection_layer.paste', + promptUser: false, + ); + if (mounted) { + Settings.showToast( + message: "Couldn't paste what you copied.", + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + } finally { + _pasting = false; + } + } + /// The first time a drag on the map draws a box instead of moving the map, /// says why while the drag is still under the user's hand, and how to /// move the map now. diff --git a/test/canvas_clipboard_test.dart b/test/canvas_clipboard_test.dart new file mode 100644 index 00000000..cbc80e40 --- /dev/null +++ b/test/canvas_clipboard_test.dart @@ -0,0 +1,414 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/bounding_box.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/page_copy_id.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/canvas_clipboard_provider.dart'; +import 'package:icarus/providers/canvas_selection_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/collab/strategy_capabilities.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +/// Where the open strategy keeps its files, for a test that puts a picture +/// there. +String? _storage; + +class _OpenCloudStrategy extends StrategyProvider { + @override + StrategyState build() { + return const StrategyState( + strategyId: 'source-strategy', + strategyName: null, + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ); + } + + @override + void setUnsaved() {} +} + +/// The cloud page on screen, loaded, listing no pictures: the only image +/// showing one was cut. +class _PageAfterCut extends RemoteEditorSnapshotNotifier { + @override + Future build() async { + final at = DateTime.utc(2026); + return RemoteEditorSnapshot( + shell: RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: 'source-strategy', + name: 'Source', + mapData: 'Ascent', + revision: 1, + createdAt: at, + updatedAt: at, + ), + pages: const [], + ), + activePage: RemotePageSnapshot( + page: RemotePage( + publicId: 'page-1', + strategyPublicId: 'source-strategy', + name: 'Page 1', + sortIndex: 0, + isAttack: true, + revision: 1, + createdAt: at, + updatedAt: at, + ), + content: RemotePageContent(revision: 1, createdAt: at, updatedAt: at), + elements: const [], + lineups: const [], + assetsById: const {}, + ), + ); + } + + @override + Future refresh() async {} +} + +class _IdleUploads extends CloudMediaUploadQueueNotifier { + @override + CloudMediaUploadQueueState build() => + const CloudMediaUploadQueueState(jobs: [], isProcessing: false); +} + +class _OpenStrategy extends StrategyProvider { + @override + StrategyState build() { + return StrategyState( + strategyId: 'source-strategy', + strategyName: null, + source: StrategySource.local, + storageDirectory: _storage, + isOpen: true, + ); + } + + void openOther() => state = const StrategyState( + strategyId: 'other-strategy', + strategyName: null, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ); + + @override + void setUnsaved() {} +} + +ProviderContainer _container() { + final container = ProviderContainer( + overrides: [strategyProvider.overrideWith(_OpenStrategy.new)], + ); + addTearDown(container.dispose); + container.listen(canvasSelectionProvider, (_, __) {}); + container.listen(canvasClipboardProvider, (_, __) {}); + return container; +} + +Line _line(String id) => Line( + lineStart: const Offset(100, 100), + lineEnd: const Offset(200, 200), + color: Colors.white, + isDotted: false, + hasArrow: false, + id: id, + ); + +Future> _noPictures(Map ids) => + Future.value({for (final id in ids.keys) id: null}); + +/// Every picture as a few bytes, as a copy carries them. +Future> _somePictures(Map ids) => + Future.value({ + for (final MapEntry(key: id, value: extension) in ids.entries) + id: (bytes: Uint8List.fromList([1, 2, 3]), fileExtension: extension), + }); + +PlacedImage _image() => PlacedImage( + id: 'img', + position: const Offset(300, 300), + aspectRatio: 1, + scale: 100, + fileExtension: '.png', + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => CoordinateSystem(playAreaSize: const Size(1920, 1080))); + + late Directory storage; + setUp(() { + // The strategy's pictures 'img' and 'shot' are on disk, so a paste can + // show them. + storage = Directory.systemTemp.createTempSync('icarus-clipboard-'); + File('${storage.path}/images/img.png') + ..createSync(recursive: true) + ..writeAsBytesSync([1, 2, 3]); + File('${storage.path}/images/shot.png').writeAsBytesSync([1, 2, 3]); + _storage = storage.path; + }); + tearDown(() { + _storage = null; + storage.deleteSync(recursive: true); + }); + + test('a paste puts new copies where the originals are, in one step', + () async { + final container = _container(); + container.read(agentProvider.notifier).fromHive([ + PlacedAgent( + id: 'a', type: AgentType.jett, position: const Offset(40, 50)), + ]); + container.read(drawingProvider.notifier).fromHive([ + _line('d'), + FreeDrawing( + id: 'free', + listOfPoints: [const Offset(600, 600), const Offset(700, 620)], + color: Colors.white, + boundingBox: BoundingBox( + min: const Offset(600, 600), + max: const Offset(700, 620), + ), + isDotted: false, + hasArrow: false, + ), + ]); + container.read(placedImageProvider.notifier).fromHive([ + PlacedImage( + id: 'img', + position: const Offset(300, 300), + aspectRatio: 1, + scale: 100, + fileExtension: '.png', + ), + ]); + container.read(canvasSelectionProvider.notifier).selectAll(); + + final copied = container + .read(canvasClipboardProvider.notifier) + .copySelection(_noPictures); + final refusal = + await container.read(canvasClipboardProvider.notifier).paste(); + + expect(copied, 4); + expect(refusal, isNull); + final agents = container.read(agentProvider); + expect(agents, hasLength(2)); + final pasted = agents.last; + expect(pasted.id, isNot('a')); + // Its root is the original, so a page transition still pairs them. + expect(pageCopyRoot(pasted.id), 'a'); + expect(pasted.position, const Offset(40, 50)); + final drawings = container.read(drawingProvider).elements; + expect(drawings, hasLength(4)); + expect((drawings[2] as Line).lineStart, const Offset(100, 100)); + expect(drawings.last.boundingBox?.max, const Offset(700, 620)); + // Pasted into the strategy it came from, it shows the same picture. + final images = container.read(placedImageProvider).images; + expect(images.last.pictureId, 'img'); + expect(container.read(canvasSelectionProvider), { + (kind: SelectableKind.agent, id: pasted.id), + (kind: SelectableKind.drawing, id: drawings[2].id), + (kind: SelectableKind.drawing, id: drawings.last.id), + (kind: SelectableKind.image, id: images.last.id), + }); + + container.read(actionProvider.notifier).undoAction(); + + expect(container.read(agentProvider).map((agent) => agent.id), ['a']); + expect(container.read(drawingProvider).elements, hasLength(2)); + expect(container.read(placedImageProvider).images, hasLength(1)); + }); + + test('a paste on another map puts nothing down', () async { + final container = _container(); + container.read(agentProvider.notifier).fromHive([ + PlacedAgent( + id: 'a', type: AgentType.jett, position: const Offset(40, 50)), + ]); + container.read(canvasSelectionProvider.notifier).selectAll(); + container.read(canvasClipboardProvider.notifier).copySelection(_noPictures); + + container.read(mapProvider.notifier).updateMap(MapValue.bind); + final refusal = + await container.read(canvasClipboardProvider.notifier).paste(); + + expect(refusal, PasteRefusal.otherMap); + expect(container.read(agentProvider), hasLength(1)); + }); + + test('a picture that could not be read keeps a paste out of another strategy', + () async { + final container = _container(); + container.read(placedImageProvider.notifier).fromHive([ + PlacedImage( + id: 'img', + position: const Offset(300, 300), + aspectRatio: 1, + scale: 100, + fileExtension: '.png', + ), + ]); + container.read(canvasSelectionProvider.notifier).selectAll(); + container.read(canvasClipboardProvider.notifier).copySelection(_noPictures); + + (container.read(strategyProvider.notifier) as _OpenStrategy).openOther(); + container.read(placedImageProvider.notifier).fromHive([]); + final refusal = + await container.read(canvasClipboardProvider.notifier).paste(); + + expect(refusal, PasteRefusal.picturesMissing); + expect(container.read(placedImageProvider).images, isEmpty); + }); + + test('a lineup, a text and an ability paste too', () async { + final container = _container(); + final info = AgentData.agents[AgentType.brimstone]!.abilities[2]; + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 't', position: const Offset(40, 40))..text = 'B main', + ]); + container.read(abilityProvider.notifier).fromHive([ + PlacedAbility(id: 's', data: info, position: const Offset(70, 70)), + ]); + container.read(lineUpProvider.notifier).addCopies(LineUpGraph( + origins: [ + LineUpOrigin( + id: 'lo', + agent: PlacedAgent( + id: 'lo', + type: AgentType.brimstone, + position: const Offset(650, 700), + lineUpID: 'lo', + ), + ), + ], + landings: [ + LineUpLanding( + id: 'll', + ability: PlacedAbility( + id: 'll', + data: info, + position: const Offset(760, 560), + lineUpID: 'll', + ), + ), + ], + links: [ + LineUpLink( + id: 'lk', + originId: 'lo', + landingId: 'll', + images: [SimpleImageData(id: 'shot', fileExtension: '.png')], + ), + ], + )); + container.read(canvasSelectionProvider.notifier).selectAll(); + container.read(canvasClipboardProvider.notifier).copySelection(_noPictures); + + final refusal = + await container.read(canvasClipboardProvider.notifier).paste(); + + expect(refusal, isNull); + expect(container.read(textProvider).last.text, 'B main'); + expect(container.read(abilityProvider), hasLength(2)); + final links = container.read(lineUpProvider).links; + expect(links, hasLength(2)); + expect(links.last.images.single.id, 'shot'); + }); + + test("a picture gone from its strategy comes back from the copy's bytes", + () async { + final container = _container(); + container.read(placedImageProvider.notifier).fromHive([_image()]); + container.read(canvasSelectionProvider.notifier).selectAll(); + container + .read(canvasClipboardProvider.notifier) + .copySelection(_somePictures); + // Its last use was cut and its file cleaned up. + File('${storage.path}/images/img.png').deleteSync(); + + final refusal = + await container.read(canvasClipboardProvider.notifier).paste(); + + expect(refusal, isNull); + final pasted = container.read(placedImageProvider).images.last; + expect(pasted.pictureId, isNot('img')); + expect(pasted.id, pasted.pictureId); + }); + + test('an item with a very long imported id pastes under a plain id', + () async { + final container = _container(); + final long = 'imported-${'x' * 200}'; + container.read(agentProvider.notifier).fromHive([ + PlacedAgent(id: long, type: AgentType.jett, position: Offset.zero), + ]); + container.read(canvasSelectionProvider.notifier).selectAll(); + container.read(canvasClipboardProvider.notifier).copySelection(_noPictures); + + await container.read(canvasClipboardProvider.notifier).paste(); + + final pasted = container.read(agentProvider).last.id; + expect(pasted, isNot(contains('~cp1~'))); + expect(pasted.length, lessThan(50)); + }); + + test( + 'a cut cloud image pastes back in its strategy without its bytes: ' + 'its picture is still there', () async { + final container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(_OpenCloudStrategy.new), + currentStrategyCapabilitiesProvider.overrideWithValue( + StrategyCapabilities.fromCloudRole('owner'), + ), + remoteEditorSnapshotProvider.overrideWith(_PageAfterCut.new), + cloudMediaAccountIdProvider.overrideWithValue('account-a'), + cloudMediaUploadQueueProvider.overrideWith(_IdleUploads.new), + ], + ); + addTearDown(container.dispose); + await container.read(remoteEditorSnapshotProvider.future); + container.listen(canvasSelectionProvider, (_, __) {}); + container.listen(canvasClipboardProvider, (_, __) {}); + container.read(placedImageProvider.notifier).fromHive([_image()]); + container.read(canvasSelectionProvider.notifier).selectAll(); + // The browser was not let download it, so the copy carries no bytes. + container.read(canvasClipboardProvider.notifier).copySelection(_noPictures); + container.read(canvasSelectionProvider.notifier).deleteSelected(); + + final refusal = + await container.read(canvasClipboardProvider.notifier).paste(); + + expect(refusal, isNull); + expect( + container.read(placedImageProvider).images.single.pictureId, + 'img', + ); + }); +}