From 16a7bbb25bf53f143c058a1c021436b5d6cd5374 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Tue, 27 Oct 2026 19:24:47 -0400 Subject: [PATCH 1/2] Copy and paste items between strategies on the same map Ctrl+C copies the selection, Ctrl+X cuts it, and Ctrl+V pastes it where it was, as one undo step, into any strategy on the same map (a paste on another map says so). The clipboard lives in the app, never on the system clipboard. Pasted into the strategy it came from, an image shows the same picture. Pasted into another strategy, local or cloud, each picture is saved there anew and uploads as a dropped image does. Copy reads the pictures' bytes when it is made, so the source need not be open to paste. Pasted items keep their copy root, so pasted pages still animate between each other. --- lib/collab/page_made_here.dart | 14 +- lib/const/line_provider.dart | 21 +- lib/const/page_copy_id.dart | 12 + lib/providers/canvas_clipboard_provider.dart | 513 +++++++++++++++++++ lib/providers/canvas_selection_provider.dart | 7 +- lib/providers/drawing_provider.dart | 20 + lib/providers/image_provider.dart | 17 + lib/providers/strategy_image_source.dart | 7 + lib/widgets/canvas_selection_layer.dart | 95 +++- test/canvas_clipboard_test.dart | 312 +++++++++++ 10 files changed, 1001 insertions(+), 17 deletions(-) create mode 100644 lib/providers/canvas_clipboard_provider.dart create mode 100644 test/canvas_clipboard_test.dart 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..c001b312 --- /dev/null +++ b/lib/providers/canvas_clipboard_provider.dart @@ -0,0 +1,513 @@ +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. + 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 only in the strategy it came from, and only while it is still + // there: once its last use is cut, its file is cleaned up and its cloud + // copy swept, and the paste puts it back from the bytes it carried. + final newPictureIds = {}; + final keep = clipboard.strategyId == strategyId; + final renew = [ + for (final MapEntry(key: id, value: extension) in pictureIds.entries) + if (!keep || _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 strategy on screen. + /// One still uploading is on its way, so it is not 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..4dc62c97 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,96 @@ 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 hadn't loaded when you copied it, " + 'so nothing was pasted. Copy it again once it shows.', + 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..fc21bc12 --- /dev/null +++ b/test/canvas_clipboard_test.dart @@ -0,0 +1,312 @@ +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/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 _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)); + }); +} From 3545276db72012f6596412bc25bcbb54c3be0ee5 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Tue, 27 Oct 2026 20:37:55 -0400 Subject: [PATCH 2/2] Paste a cut cloud image back without needing its bytes Cutting an image and pasting it in the same cloud strategy refused with "hadn't loaded" when the copy could not download the picture (offline, or a browser origin the storage does not allow): the paste looked for the picture on the page on screen, where a cut one is no longer listed. In the cloud the picture is still there (a cut image's tombstone keeps it for 30 days), so the strategy it came from always keeps its picture; only a local strategy, which cleans up unused files when it reopens, checks for the file. The refusal now says the picture could not be read, not that it hadn't loaded. --- lib/providers/canvas_clipboard_provider.dart | 19 ++-- lib/widgets/canvas_selection_layer.dart | 5 +- test/canvas_clipboard_test.dart | 102 +++++++++++++++++++ 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/lib/providers/canvas_clipboard_provider.dart b/lib/providers/canvas_clipboard_provider.dart index c001b312..c0a2a991 100644 --- a/lib/providers/canvas_clipboard_provider.dart +++ b/lib/providers/canvas_clipboard_provider.dart @@ -89,7 +89,8 @@ enum PasteRefusal { /// What was copied belongs on another map. otherMap, - /// A picture in it could not be read when it was copied. + /// 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. @@ -197,15 +198,17 @@ class CanvasClipboardNotifier extends Notifier { 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 only in the strategy it came from, and only while it is still - // there: once its last use is cut, its file is cleaned up and its cloud - // copy swept, and the paste puts it back from the bytes it carried. + // 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 || _gone(id, extension)) id, + if (!keep || (!isCloud && _gone(id, extension))) id, ]; if (renew.isNotEmpty) { final pictures = await clipboard.pictures; @@ -314,8 +317,8 @@ class CanvasClipboardNotifier extends Notifier { bool get _canEdit => ref.read(currentStrategyCapabilitiesProvider).canEditPages; - /// Whether picture [id] can no longer be shown in the strategy on screen. - /// One still uploading is on its way, so it is not gone. + /// 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), diff --git a/lib/widgets/canvas_selection_layer.dart b/lib/widgets/canvas_selection_layer.dart index 4dc62c97..4a85c378 100644 --- a/lib/widgets/canvas_selection_layer.dart +++ b/lib/widgets/canvas_selection_layer.dart @@ -239,8 +239,9 @@ class _CanvasSelectionLayerState extends ConsumerState : '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 hadn't loaded when you copied it, " - 'so nothing was pasted. Copy it again once it shows.', + "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.', diff --git a/test/canvas_clipboard_test.dart b/test/canvas_clipboard_test.dart index fc21bc12..cbc80e40 100644 --- a/test/canvas_clipboard_test.dart +++ b/test/canvas_clipboard_test.dart @@ -21,6 +21,11 @@ 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'; @@ -28,6 +33,69 @@ import 'package:icarus/strategy/strategy_page_models.dart'; /// 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() { @@ -309,4 +377,38 @@ void main() { 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', + ); + }); }