From 5546bf525e7357df64ad0f3c5f783c112fbc2a10 Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Fri, 23 Jul 2021 15:41:07 +1000 Subject: [PATCH] Add selection copy and paste --- src/components/map/Map.tsx | 4 + src/components/selection/SelectionMenu.tsx | 320 +++++++++++++++++---- src/hooks/useMapSelection.tsx | 6 + src/icons/CopyIcon.tsx | 16 ++ src/icons/PasteIcon.tsx | 16 ++ src/network/NetworkedMapAndTokens.tsx | 13 + src/types/Events.ts | 4 + 7 files changed, 321 insertions(+), 58 deletions(-) create mode 100644 src/icons/CopyIcon.tsx create mode 100644 src/icons/PasteIcon.tsx diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index 3ef2fd1..dcc5157 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -39,6 +39,7 @@ import { NoteCreateEventHander, SelectionItemsChangeEventHandler, SelectionItemsRemoveEventHandler, + SelectionItemsCreateEventHandler, } from "../../types/Events"; import useMapTokens from "../../hooks/useMapTokens"; @@ -54,6 +55,7 @@ type MapProps = { onMapTokenStateRemove: TokenStateRemoveHandler; onSelectionItemsChange: SelectionItemsChangeEventHandler; onSelectionItemsRemove: SelectionItemsRemoveEventHandler; + onSelectionItemsCreate: SelectionItemsCreateEventHandler; onMapChange: MapChangeEventHandler; onMapReset: MapResetEventHandler; onMapDraw: (action: Action) => void; @@ -75,6 +77,7 @@ function Map({ onMapTokenStateRemove, onSelectionItemsChange, onSelectionItemsRemove, + onSelectionItemsCreate, onMapChange, onMapReset, onMapDraw, @@ -158,6 +161,7 @@ function Map({ mapState, onSelectionItemsChange, onSelectionItemsRemove, + onSelectionItemsCreate, selectedToolId, settings.select ); diff --git a/src/components/selection/SelectionMenu.tsx b/src/components/selection/SelectionMenu.tsx index ba1d614..9128f42 100644 --- a/src/components/selection/SelectionMenu.tsx +++ b/src/components/selection/SelectionMenu.tsx @@ -1,5 +1,7 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Box, Flex, IconButton } from "theme-ui"; +import { useToasts } from "react-toast-notifications"; +import { v4 as uuid } from "uuid"; import MapMenu from "../map/MapMenu"; @@ -9,39 +11,55 @@ import LockIcon from "../../icons/TokenLockIcon"; import UnlockIcon from "../../icons/TokenUnlockIcon"; import ShowIcon from "../../icons/TokenShowIcon"; import HideIcon from "../../icons/TokenHideIcon"; +import CopyIcon from "../../icons/CopyIcon"; +import PasteIcon from "../../icons/PasteIcon"; import { useUserId } from "../../contexts/UserIdContext"; import { SelectionItemsChangeEventHandler, RequestCloseEventHandler, + SelectionItemsCreateEventHandler, } from "../../types/Events"; import { Map } from "../../types/Map"; -import { Selection } from "../../types/Select"; -import { TokenState } from "../../types/TokenState"; -import { Note } from "../../types/Note"; +import { Selection, SelectionItem } from "../../types/Select"; +import { TokenState, TokenStates } from "../../types/TokenState"; +import { Note, Notes } from "../../types/Note"; import { getSelectionPoints } from "../../helpers/selection"; import Vector2 from "../../helpers/Vector2"; import { useMapStage } from "../../contexts/MapStageContext"; import { MapState } from "../../types/MapState"; +import { isMapState } from "../../validators/MapState"; +import { isSelection } from "../../validators/Selection"; +import { getRelativePointerPosition } from "../../helpers/konva"; type SelectionMenuProps = { isOpen: boolean; + active: boolean; onRequestClose: RequestCloseEventHandler; + onRequestOpen: () => void; selection: Selection | null; + onSelectionChange: React.Dispatch>; onSelectionItemsChange: SelectionItemsChangeEventHandler; + onSelectionItemsCreate: SelectionItemsCreateEventHandler; map: Map | null; mapState: MapState | null; }; function SelectionMenu({ isOpen, + active, onRequestClose, + onRequestOpen, selection, + onSelectionChange, onSelectionItemsChange, + onSelectionItemsCreate, map, mapState, }: SelectionMenuProps) { + const { addToast } = useToasts(); + const userId = useUserId(); const wasOpen = usePrevious(isOpen); @@ -50,35 +68,102 @@ function SelectionMenu({ const [menuLeft, setMenuLeft] = useState(0); const [menuTop, setMenuTop] = useState(0); + + const selectionMenuWidth = selection === null ? 48 : 156; + useEffect(() => { const mapStage = mapStageRef.current; - if (isOpen && !wasOpen && selection && mapStage) { - const points = getSelectionPoints(selection); - const bounds = Vector2.getBoundingBox(points); - - let menuPosition = new Vector2( - bounds.center.x + selection.x, - bounds.max.y + selection.y - ); + if (isOpen && !wasOpen && mapStage) { const mapImage = mapStage.findOne("#mapImage"); if (!mapImage) { return; } - menuPosition = Vector2.multiply(menuPosition, { - x: mapImage.width(), - y: mapImage.height(), - }); + let menuPosition = { x: 0, y: 0 }; + if (selection) { + const points = getSelectionPoints(selection); + const bounds = Vector2.getBoundingBox(points); + + menuPosition = new Vector2( + bounds.center.x + selection.x, + bounds.max.y + selection.y + ); + menuPosition = Vector2.multiply(menuPosition, { + x: mapImage.width(), + y: mapImage.height(), + }); + } else { + const position = getRelativePointerPosition(mapImage); + if (position) { + menuPosition = position; + } + } const transform = mapImage.getAbsoluteTransform().copy(); const absolutePosition = transform.point(menuPosition); const mapElement = document.querySelector(".map"); if (mapElement) { const mapRect = mapElement.getBoundingClientRect(); - setMenuLeft(mapRect.left + absolutePosition.x - 156 / 2); + setMenuLeft(mapRect.left + absolutePosition.x - selectionMenuWidth / 2); setMenuTop(mapRect.top + absolutePosition.y + 12); } } - }, [isOpen, selection, wasOpen, mapStageRef]); + }, [isOpen, selection, wasOpen, mapStageRef, selectionMenuWidth]); + + // Open paste menu if clicking without a selection + // Ensure that paste menu is closed when clicking and that the click hasn't moved far + const openOnDownRef = useRef(false); + const downPositionRef = useRef({ x: 0, y: 0 }); + useEffect(() => { + if (!active) { + return; + } + function handlePointerDown(event: PointerEvent) { + openOnDownRef.current = isOpen || !!selection; + downPositionRef.current = { x: event.clientX, y: event.clientY }; + } + function handleMapElementClick(event: MouseEvent) { + const deltaPosition = Vector2.distance( + { x: event.clientX, y: event.clientY }, + downPositionRef.current + ); + if ( + !openOnDownRef.current && + !selection && + deltaPosition < 10 && + event.target instanceof HTMLCanvasElement + ) { + onRequestOpen(); + } + } + const mapElement = document.querySelector(".map"); + mapElement?.addEventListener("pointerdown", handlePointerDown); + mapElement?.addEventListener("click", handleMapElementClick); + + return () => { + mapElement?.removeEventListener("pointerdown", handlePointerDown); + mapElement?.removeEventListener("click", handleMapElementClick); + }; + }, [isOpen, active, selection, onRequestOpen]); + + function handleModalContent(node: HTMLElement) { + if (node) { + // Ensure menu is in bounds + const nodeRect = node.getBoundingClientRect(); + const mapElement = document.querySelector(".map"); + if (mapElement) { + const mapRect = mapElement.getBoundingClientRect(); + setMenuLeft((prevLeft) => + Math.min( + mapRect.right - nodeRect.width, + Math.max(mapRect.left, prevLeft) + ) + ); + setMenuTop((prevTop) => + Math.min(mapRect.bottom - nodeRect.height, prevTop) + ); + } + } + } function updateSelectedItems(change: Partial | Partial) { if (selection) { @@ -107,6 +192,7 @@ function SelectionMenu({ setItemsLocked(!itemsLocked); } + // Update lock and visible state depending on selected items useEffect(() => { if (isOpen && selection && mapState) { let allVisible = true; @@ -135,32 +221,129 @@ function SelectionMenu({ } }, [mapState, selection, isOpen]); - function handleModalContent(node: HTMLElement) { - if (node) { - // Focus input - const tokenLabelInput = - node.querySelector("#changeNoteText"); - if (tokenLabelInput) { - tokenLabelInput.focus(); - tokenLabelInput.select(); - } + function addSuccessToast( + message: string, + tokens?: TokenStates, + notes?: Notes + ) { + const numTokens = tokens ? Object.keys(tokens).length : 0; + const numNotes = notes ? Object.keys(notes).length : 0; + const tokenText = `${numTokens} token${numTokens > 1 ? "s" : ""}`; + const noteText = `${numNotes} note${numNotes > 1 ? "s" : ""}`; + if (numTokens > 0 && numNotes > 0) { + addToast(`${message} ${tokenText} and ${noteText}`); + } else if (numTokens > 0) { + addToast(`${message} ${tokenText}`); + } else if (numNotes > 0) { + addToast(`${message} ${noteText}`); + } + } - // Ensure menu is in bounds - const nodeRect = node.getBoundingClientRect(); - const mapElement = document.querySelector(".map"); - if (mapElement) { - const mapRect = mapElement.getBoundingClientRect(); - setMenuLeft((prevLeft) => - Math.min( - mapRect.right - nodeRect.width, - Math.max(mapRect.left, prevLeft) - ) - ); - setMenuTop((prevTop) => - Math.min(mapRect.bottom - nodeRect.height, prevTop) - ); + async function handleCopy() { + let version = process.env.REACT_APP_VERSION; + if (!version || !selection || !mapState) { + return; + } + let clipboard: { version: string; data: MapState; selection: Selection } = { + version, + selection, + data: { + tokens: {}, + notes: {}, + drawShapes: {}, + editFlags: [], + fogShapes: {}, + mapId: mapState.mapId, + }, + }; + for (let item of selection.items) { + if (item.type === "token" && clipboard.data.tokens) { + clipboard.data.tokens[item.id] = mapState.tokens[item.id]; + } else if (item.type === "note" && clipboard.data.notes) { + clipboard.data.notes[item.id] = mapState.notes[item.id]; } } + await navigator.clipboard.writeText(JSON.stringify(clipboard)); + addSuccessToast("Copied", clipboard.data.tokens, clipboard.data.notes); + } + + async function handlePaste() { + let version = process.env.REACT_APP_VERSION; + + if (!version || !mapState) { + return; + } + try { + const clipboardText = await navigator.clipboard.readText(); + const clipboard = JSON.parse(clipboardText); + if ( + clipboard.version === version && + isMapState(clipboard.data) && + isSelection(clipboard.selection) + ) { + // Find new selection position + const mapStage = mapStageRef.current; + const mapImage = mapStage?.findOne("#mapImage"); + if (!mapImage) { + return; + } + const position = getRelativePointerPosition(mapImage); + if (!position) { + return; + } + const normalizedPosition = Vector2.divide(position, { + x: mapImage.width(), + y: mapImage.height(), + }); + + const clipboardSelection = clipboard.selection as Selection; + const points = getSelectionPoints(clipboardSelection); + const center = Vector2.centroid(points); + + let selectionDelta = Vector2.subtract(normalizedPosition, center); + + const clipboardState = clipboard.data as MapState; + let tokenStates: TokenState[] = []; + let notes: Note[] = []; + let selectionItems: SelectionItem[] = []; + for (let token of Object.values(clipboardState.tokens)) { + const newId = uuid(); + tokenStates.push({ + ...token, + x: token.x + selectionDelta.x, + y: token.y + selectionDelta.y, + id: newId, + }); + selectionItems.push({ id: newId, type: "token" }); + } + for (let note of Object.values(clipboardState.notes)) { + const newId = uuid(); + notes.push({ + ...note, + x: note.x + selectionDelta.x, + y: note.y + selectionDelta.y, + id: newId, + }); + selectionItems.push({ id: newId, type: "note" }); + } + onSelectionItemsCreate(tokenStates, notes); + + onSelectionChange({ + ...clipboardSelection, + items: selectionItems, + x: clipboardSelection.x + selectionDelta.x, + y: clipboardSelection.y + selectionDelta.y, + }); + + onRequestClose(); + + addSuccessToast("Pasted", clipboard.data.tokens, clipboard.data.notes); + } else { + addToast("Invalid data"); + } + } catch { + addToast("Unable to paste"); + } } return ( @@ -171,26 +354,47 @@ function SelectionMenu({ left={`${menuLeft}px`} onModalContent={handleModalContent} > - - {/* Only show hide and lock token actions to map owners */} - {map && map.owner === userId && ( - + + + {selection ? ( + <> + {/* Only show hide and lock token actions to map owners */} + {map && map.owner === userId && ( + <> + + {itemsVisible ? : } + + + {itemsLocked ? : } + + + )} + + + + + ) : ( - {itemsVisible ? : } + - - {itemsLocked ? : } - - - )} + )} + ); diff --git a/src/hooks/useMapSelection.tsx b/src/hooks/useMapSelection.tsx index 4d04e80..6e7de10 100644 --- a/src/hooks/useMapSelection.tsx +++ b/src/hooks/useMapSelection.tsx @@ -4,6 +4,7 @@ import SelectionMenu from "../components/selection/SelectionMenu"; import SelectTool from "../components/tools/SelectTool"; import { SelectionItemsChangeEventHandler, + SelectionItemsCreateEventHandler, SelectionItemsRemoveEventHandler, } from "../types/Events"; import { Map, MapToolId } from "../types/Map"; @@ -16,6 +17,7 @@ function useMapSelection( mapState: MapState | null, onSelectionItemsChange: SelectionItemsChangeEventHandler, onSelectionItemsRemove: SelectionItemsRemoveEventHandler, + onSelectionItemsCreate: SelectionItemsCreateEventHandler, selectedToolId: MapToolId, settings: SelectToolSettings ) { @@ -69,9 +71,13 @@ function useMapSelection( const selectionMenu = ( setIsSelectionMenuOpen(false)} + onRequestOpen={() => setIsSelectionMenuOpen(true)} selection={selection} + onSelectionChange={setSelection} onSelectionItemsChange={onSelectionItemsChange} + onSelectionItemsCreate={onSelectionItemsCreate} map={map} mapState={mapState} /> diff --git a/src/icons/CopyIcon.tsx b/src/icons/CopyIcon.tsx new file mode 100644 index 0000000..43ab7e3 --- /dev/null +++ b/src/icons/CopyIcon.tsx @@ -0,0 +1,16 @@ +function CopyIcon() { + return ( + + + + + ); +} + +export default CopyIcon; diff --git a/src/icons/PasteIcon.tsx b/src/icons/PasteIcon.tsx new file mode 100644 index 0000000..4922858 --- /dev/null +++ b/src/icons/PasteIcon.tsx @@ -0,0 +1,16 @@ +function PasteIcon() { + return ( + + + + + ); +} + +export default PasteIcon; diff --git a/src/network/NetworkedMapAndTokens.tsx b/src/network/NetworkedMapAndTokens.tsx index 9657c9c..b52fa35 100644 --- a/src/network/NetworkedMapAndTokens.tsx +++ b/src/network/NetworkedMapAndTokens.tsx @@ -342,6 +342,18 @@ function NetworkedMapAndTokens({ session }: { session: Session }) { ]); } + function handleSelectionItemsCreate( + tokenStates: TokenState[], + notes: Note[] + ) { + const tokenAction = new AddStatesAction(tokenStates); + const noteAction = new AddStatesAction(notes); + addActions([ + { type: "tokens", action: tokenAction }, + { type: "notes", action: noteAction }, + ]); + } + useEffect(() => { async function handlePeerData({ id, data, reply }: PeerDataEvent) { if (id === "assetRequest") { @@ -407,6 +419,7 @@ function NetworkedMapAndTokens({ session }: { session: Session }) { onMapTokenStateRemove={handleMapTokenStateRemove} onSelectionItemsChange={handleSelectionItemsChange} onSelectionItemsRemove={handleSelectionItemsRemove} + onSelectionItemsCreate={handleSelectionItemsCreate} onMapChange={handleMapChange} onMapReset={handleMapReset} onMapDraw={handleMapDraw} diff --git a/src/types/Events.ts b/src/types/Events.ts index 8066d66..ebc598d 100644 --- a/src/types/Events.ts +++ b/src/types/Events.ts @@ -67,3 +67,7 @@ export type SelectionItemsRemoveEventHandler = ( tokenStateIds: string[], noteIds: string[] ) => void; +export type SelectionItemsCreateEventHandler = ( + tokenStates: TokenState[], + notes: Note[] +) => void;