Files
grungnet/src/components/selection/SelectionMenu.tsx
T

404 lines
13 KiB
TypeScript

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";
import usePrevious from "../../hooks/usePrevious";
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, 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);
const mapStageRef = useMapStage();
const [menuLeft, setMenuLeft] = useState(0);
const [menuTop, setMenuTop] = useState(0);
const selectionMenuWidth = selection === null ? 48 : 156;
useEffect(() => {
const mapStage = mapStageRef.current;
if (isOpen && !wasOpen && mapStage) {
const mapImage = mapStage.findOne("#mapImage");
if (!mapImage) {
return;
}
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 - selectionMenuWidth / 2);
setMenuTop(mapRect.top + absolutePosition.y + 12);
}
}
}, [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) {
const tokenChanges: Record<string, Partial<TokenState>> = {};
const noteChanges: Record<string, Partial<Note>> = {};
for (let item of selection.items) {
if (item.type === "token") {
tokenChanges[item.id] = change;
} else {
noteChanges[item.id] = change;
}
}
onSelectionItemsChange(tokenChanges, noteChanges);
}
}
const [itemsVisible, setItemsVisible] = useState(false);
function handleVisibleChange() {
updateSelectedItems({ visible: !itemsVisible });
setItemsVisible(!itemsVisible);
}
const [itemsLocked, setItemsLocked] = useState(false);
function handleLockChange() {
updateSelectedItems({ locked: !itemsLocked });
setItemsLocked(!itemsLocked);
}
// Update lock and visible state depending on selected items
useEffect(() => {
if (isOpen && selection && mapState) {
let allVisible = true;
let allLocked = true;
for (let item of selection.items) {
if (item.type === "token") {
const tokenState = mapState.tokens[item.id];
if (!tokenState.visible) {
allVisible = false;
}
if (!tokenState.locked) {
allLocked = false;
}
} else {
const note = mapState.notes[item.id];
if (!note.visible) {
allVisible = false;
}
if (!note.locked) {
allLocked = false;
}
}
}
setItemsVisible(allVisible);
setItemsLocked(allLocked);
}
}, [mapState, selection, isOpen]);
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}`);
}
}
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 (
<MapMenu
isOpen={isOpen}
onRequestClose={onRequestClose}
top={`${menuTop}px`}
left={`${menuLeft}px`}
onModalContent={handleModalContent}
>
<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
title="Paste Items"
aria-label="Paste Items"
onClick={handlePaste}
>
<PasteIcon />
</IconButton>
)}
</Flex>
</Box>
</MapMenu>
);
}
export default SelectionMenu;