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;