Add selection copy and paste

This commit is contained in:
Mitchell McCaffrey 2021-07-23 15:41:07 +10:00
parent 1b248c16a5
commit 5546bf525e
7 changed files with 321 additions and 58 deletions

View File

@ -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<DrawingState>) => 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
);

View File

@ -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<React.SetStateAction<Selection | null>>;
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<HTMLElement>(".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<TokenState> | Partial<Note>) {
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<HTMLInputElement>("#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}
>
<Box sx={{ width: "156px", overflow: "hidden" }} p={1}>
{/* Only show hide and lock token actions to map owners */}
{map && map.owner === userId && (
<Flex sx={{ alignItems: "center", justifyContent: "space-around" }}>
<Box sx={{ width: `${selectionMenuWidth}px`, overflow: "hidden" }} p={1}>
<Flex sx={{ alignItems: "center", justifyContent: "space-around" }}>
{selection ? (
<>
{/* Only show hide and lock token actions to map owners */}
{map && map.owner === userId && (
<>
<IconButton
onClick={handleVisibleChange}
title={itemsVisible ? "Hide Items" : "Show Items"}
aria-label={itemsVisible ? "Hide Items" : "Show Items"}
>
{itemsVisible ? <ShowIcon /> : <HideIcon />}
</IconButton>
<IconButton
onClick={handleLockChange}
title={itemsLocked ? "Unlock Items" : "Lock Items"}
aria-label={itemsLocked ? "Unlock Items" : "Lock Items"}
>
{itemsLocked ? <LockIcon /> : <UnlockIcon />}
</IconButton>
</>
)}
<IconButton
title="Copy Items"
aria-label="Copy Items"
onClick={handleCopy}
>
<CopyIcon />
</IconButton>
</>
) : (
<IconButton
onClick={handleVisibleChange}
title={itemsVisible ? "Hide Items" : "Show Items"}
aria-label={itemsVisible ? "Hide Items" : "Show Items"}
title="Paste Items"
aria-label="Paste Items"
onClick={handlePaste}
>
{itemsVisible ? <ShowIcon /> : <HideIcon />}
<PasteIcon />
</IconButton>
<IconButton
onClick={handleLockChange}
title={itemsLocked ? "Unlock Items" : "Lock Items"}
aria-label={itemsLocked ? "Unlock Items" : "Lock Items"}
>
{itemsLocked ? <LockIcon /> : <UnlockIcon />}
</IconButton>
</Flex>
)}
)}
</Flex>
</Box>
</MapMenu>
);

View File

@ -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 = (
<SelectionMenu
isOpen={isSelectionMenuOpen}
active={active}
onRequestClose={() => setIsSelectionMenuOpen(false)}
onRequestOpen={() => setIsSelectionMenuOpen(true)}
selection={selection}
onSelectionChange={setSelection}
onSelectionItemsChange={onSelectionItemsChange}
onSelectionItemsCreate={onSelectionItemsCreate}
map={map}
mapState={mapState}
/>

16
src/icons/CopyIcon.tsx Normal file
View File

@ -0,0 +1,16 @@
function CopyIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M15,20H5V7c0-0.55-0.45-1-1-1h0C3.45,6,3,6.45,3,7v13c0,1.1,0.9,2,2,2h10c0.55,0,1-0.45,1-1v0C16,20.45,15.55,20,15,20z M20,16V4c0-1.1-0.9-2-2-2H9C7.9,2,7,2.9,7,4v12c0,1.1,0.9,2,2,2h9C19.1,18,20,17.1,20,16z M18,16H9V4h9V16z" />
</svg>
);
}
export default CopyIcon;

16
src/icons/PasteIcon.tsx Normal file
View File

@ -0,0 +1,16 @@
function PasteIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm6 18H6c-.55 0-1-.45-1-1V5c0-.55.45-1 1-1h1v1c0 1.1.9 2 2 2h6c1.1 0 2-.9 2-2V4h1c.55 0 1 .45 1 1v14c0 .55-.45 1-1 1z" />
</svg>
);
}
export default PasteIcon;

View File

@ -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}

View File

@ -67,3 +67,7 @@ export type SelectionItemsRemoveEventHandler = (
tokenStateIds: string[],
noteIds: string[]
) => void;
export type SelectionItemsCreateEventHandler = (
tokenStates: TokenState[],
notes: Note[]
) => void;