From 986f1efc9b91b05d6fdce12282804489c81437d6 Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Thu, 22 Jul 2021 13:16:44 +1000 Subject: [PATCH] Add basic selection menu --- src/components/map/Map.tsx | 16 +- src/components/selection/SelectionMenu.tsx | 165 +++++++++++++++++++++ src/components/tools/SelectTool.tsx | 48 +++--- src/helpers/selection.ts | 24 +++ src/hooks/useMapSelection.tsx | 47 ++++++ 5 files changed, 265 insertions(+), 35 deletions(-) create mode 100644 src/components/selection/SelectionMenu.tsx create mode 100644 src/helpers/selection.ts create mode 100644 src/hooks/useMapSelection.tsx diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index b6b83c4..0637016 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -10,7 +10,6 @@ import DrawingTool from "../tools/DrawingTool"; import FogTool from "../tools/FogTool"; import MeasureTool from "../tools/MeasureTool"; import NetworkedMapPointer from "../../network/NetworkedMapPointer"; -import SelectTool from "../tools/SelectTool"; import { useSettings } from "../../contexts/SettingsContext"; import { useUserId } from "../../contexts/UserIdContext"; @@ -44,6 +43,7 @@ import { import useMapTokens from "../../hooks/useMapTokens"; import useMapNotes from "../../hooks/useMapNotes"; import { MapActions } from "../../hooks/useMapActions"; +import useMapSelection from "../../hooks/useMapSelection"; type MapProps = { map: MapType | null; @@ -149,6 +149,13 @@ function Map({ !!(map?.owner === userId || mapState?.editFlags.includes("notes")) ); + const { selectionTool, selectionMenu } = useMapSelection( + map, + onSelectionItemsChange, + selectedToolId, + settings.select + ); + return ( {tokenMenu} {noteMenu} + {selectionMenu} {tokenDragOverlay} {noteDragOverlay} @@ -211,11 +219,7 @@ function Map({ session={session} /> - + {selectionTool} ); diff --git a/src/components/selection/SelectionMenu.tsx b/src/components/selection/SelectionMenu.tsx new file mode 100644 index 0000000..c96504b --- /dev/null +++ b/src/components/selection/SelectionMenu.tsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from "react"; +import { Box, Flex, IconButton } from "theme-ui"; + +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 { useUserId } from "../../contexts/UserIdContext"; + +import { + SelectionItemsChangeEventHandler, + RequestCloseEventHandler, +} from "../../types/Events"; +import { Map } from "../../types/Map"; +import { Selection } from "../../types/Select"; +import { TokenState } from "../../types/TokenState"; +import { Note } from "../../types/Note"; +import { getSelectionPoints } from "../../helpers/selection"; +import Vector2 from "../../helpers/Vector2"; +import { useMapStage } from "../../contexts/MapStageContext"; + +type SelectionMenuProps = { + isOpen: boolean; + onRequestClose: RequestCloseEventHandler; + selection: Selection | null; + onSelectionItemsChange: SelectionItemsChangeEventHandler; + map: Map | null; +}; + +function SelectionMenu({ + isOpen, + onRequestClose, + selection, + onSelectionItemsChange, + map, +}: SelectionMenuProps) { + const userId = useUserId(); + + const wasOpen = usePrevious(isOpen); + + const mapStageRef = useMapStage(); + + const [menuLeft, setMenuLeft] = useState(0); + const [menuTop, setMenuTop] = useState(0); + 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, bounds.max.y); + const mapImage = mapStage.findOne("#mapImage"); + if (!mapImage) { + return; + } + menuPosition = Vector2.multiply(menuPosition, { + x: mapImage.width(), + y: mapImage.height(), + }); + + 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); + setMenuTop(mapRect.top + absolutePosition.y + 12); + } + } + }, [isOpen, selection, wasOpen, mapStageRef]); + + function updateSelectedItems(change: Partial | Partial) { + if (selection) { + const tokenChanges: Record> = {}; + const noteChanges: Record> = {}; + 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); + } + + function handleModalContent(node: HTMLElement) { + if (node) { + // Focus input + const tokenLabelInput = + node.querySelector("#changeNoteText"); + if (tokenLabelInput) { + tokenLabelInput.focus(); + tokenLabelInput.select(); + } + + // 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) + ); + } + } + } + + return ( + + + {/* Only show hide and lock token actions to map owners */} + {map && map.owner === userId && ( + + + {itemsVisible ? : } + + + {itemsLocked ? : } + + + )} + + + ); +} + +export default SelectionMenu; diff --git a/src/components/tools/SelectTool.tsx b/src/components/tools/SelectTool.tsx index 0ac5317..1d3ffda 100644 --- a/src/components/tools/SelectTool.tsx +++ b/src/components/tools/SelectTool.tsx @@ -31,17 +31,24 @@ import { useGridCellNormalizedSize } from "../../contexts/GridContext"; import Konva from "konva"; import Selection from "../konva/Selection"; import { SelectionItemsChangeEventHandler } from "../../types/Events"; +import { getSelectionPoints } from "../../helpers/selection"; type MapSelectProps = { active: boolean; toolSettings: SelectToolSettings; onSelectionItemsChange: SelectionItemsChangeEventHandler; + selection: SelectionType | null; + onSelectionChange: React.Dispatch>; + onSelectionMenuOpen: (open: boolean) => void; }; function SelectTool({ active, toolSettings, onSelectionItemsChange, + selection, + onSelectionChange, + onSelectionMenuOpen, }: MapSelectProps) { const stageScale = useDebouncedStageScale(); const mapWidth = useMapWidth(); @@ -51,7 +58,6 @@ function SelectTool({ const gridCellNormalizedSize = useGridCellNormalizedSize(); const mapStageRef = useMapStage(); - const [selection, setSelection] = useState(null); const [isBrushDown, setIsBrushDown] = useState(false); // Use a ref here to prevent case where brush down event @@ -85,7 +91,7 @@ function SelectTool({ return; } if (toolSettings.type === "path") { - setSelection({ + onSelectionChange({ type: "path", items: [], data: { points: [brushPosition] }, @@ -93,7 +99,7 @@ function SelectTool({ y: 0, }); } else { - setSelection({ + onSelectionChange({ type: "rectangle", items: [], data: getDefaultShapeData("rectangle", brushPosition) as RectData, @@ -111,7 +117,7 @@ function SelectTool({ } if (isBrushDown && selection && mapImage) { if (selection.type === "path") { - setSelection((prevSelection) => { + onSelectionChange((prevSelection) => { if (prevSelection?.type !== "path") { return prevSelection; } @@ -135,7 +141,7 @@ function SelectTool({ }; }); } else { - setSelection((prevSelection) => { + onSelectionChange((prevSelection) => { if (prevSelection?.type !== "rectangle") { return prevSelection; } @@ -163,24 +169,7 @@ function SelectTool({ const tokensGroup = mapStage.findOne("#tokens"); const notesGroup = mapStage.findOne("#notes"); if (tokensGroup && notesGroup) { - let points: Vector2[] = []; - if (selection.type === "path") { - points = selection.data.points; - } else { - points.push({ x: selection.data.x, y: selection.data.y }); - points.push({ - x: selection.data.x + selection.data.width, - y: selection.data.y, - }); - points.push({ - x: selection.data.x + selection.data.width, - y: selection.data.y + selection.data.height, - }); - points.push({ - x: selection.data.x, - y: selection.data.y + selection.data.height, - }); - } + const points = getSelectionPoints(selection); const intersection = new Intersection( { type: "path", @@ -214,20 +203,21 @@ function SelectTool({ } if (intersectingItems.length > 0) { - setSelection((prevSelection) => { + onSelectionChange((prevSelection) => { if (!prevSelection) { return prevSelection; } return { ...prevSelection, items: intersectingItems }; }); + onSelectionMenuOpen(true); } else { - setSelection(null); + onSelectionChange(null); } } else { - setSelection(null); + onSelectionChange(null); } } else { - setSelection(null); + onSelectionChange(null); } setIsBrushDown(false); @@ -237,7 +227,7 @@ function SelectTool({ if (preventSelectionRef.current) { return; } - setSelection(null); + onSelectionChange(null); } interactionEmitter?.on("dragStart", handleBrushDown); @@ -258,7 +248,7 @@ function SelectTool({ {selection && ( (preventSelectionRef.current = prevent) diff --git a/src/helpers/selection.ts b/src/helpers/selection.ts new file mode 100644 index 0000000..58b66ab --- /dev/null +++ b/src/helpers/selection.ts @@ -0,0 +1,24 @@ +import { Selection } from "../types/Select"; +import Vector2 from "./Vector2"; + +export function getSelectionPoints(selection: Selection): Vector2[] { + let points: Vector2[] = []; + if (selection.type === "path") { + points = selection.data.points; + } else { + points.push({ x: selection.data.x, y: selection.data.y }); + points.push({ + x: selection.data.x + selection.data.width, + y: selection.data.y, + }); + points.push({ + x: selection.data.x + selection.data.width, + y: selection.data.y + selection.data.height, + }); + points.push({ + x: selection.data.x, + y: selection.data.y + selection.data.height, + }); + } + return points; +} diff --git a/src/hooks/useMapSelection.tsx b/src/hooks/useMapSelection.tsx new file mode 100644 index 0000000..884bc41 --- /dev/null +++ b/src/hooks/useMapSelection.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import SelectionMenu from "../components/selection/SelectionMenu"; +import SelectTool from "../components/tools/SelectTool"; +import { SelectionItemsChangeEventHandler } from "../types/Events"; +import { Map, MapToolId } from "../types/Map"; +import { Selection } from "../types/Select"; +import { SelectToolSettings } from "../types/Select"; + +function useMapSelection( + map: Map | null, + onSelectionItemsChange: SelectionItemsChangeEventHandler, + selectedToolId: MapToolId, + settings: SelectToolSettings +) { + const [isSelectionMenuOpen, setIsSelectionMenuOpen] = + useState(false); + const [selection, setSelection] = useState(null); + + function handleSelectionMenuOpen(open: boolean) { + setIsSelectionMenuOpen(open); + } + + const selectionTool = ( + + ); + + const selectionMenu = ( + setIsSelectionMenuOpen(false)} + selection={selection} + onSelectionItemsChange={onSelectionItemsChange} + map={map} + /> + ); + + return { selectionTool, selectionMenu }; +} + +export default useMapSelection;