Add basic selection menu
This commit is contained in:
parent
648d308fa5
commit
986f1efc9b
|
@ -10,7 +10,6 @@ import DrawingTool from "../tools/DrawingTool";
|
||||||
import FogTool from "../tools/FogTool";
|
import FogTool from "../tools/FogTool";
|
||||||
import MeasureTool from "../tools/MeasureTool";
|
import MeasureTool from "../tools/MeasureTool";
|
||||||
import NetworkedMapPointer from "../../network/NetworkedMapPointer";
|
import NetworkedMapPointer from "../../network/NetworkedMapPointer";
|
||||||
import SelectTool from "../tools/SelectTool";
|
|
||||||
|
|
||||||
import { useSettings } from "../../contexts/SettingsContext";
|
import { useSettings } from "../../contexts/SettingsContext";
|
||||||
import { useUserId } from "../../contexts/UserIdContext";
|
import { useUserId } from "../../contexts/UserIdContext";
|
||||||
|
@ -44,6 +43,7 @@ import {
|
||||||
import useMapTokens from "../../hooks/useMapTokens";
|
import useMapTokens from "../../hooks/useMapTokens";
|
||||||
import useMapNotes from "../../hooks/useMapNotes";
|
import useMapNotes from "../../hooks/useMapNotes";
|
||||||
import { MapActions } from "../../hooks/useMapActions";
|
import { MapActions } from "../../hooks/useMapActions";
|
||||||
|
import useMapSelection from "../../hooks/useMapSelection";
|
||||||
|
|
||||||
type MapProps = {
|
type MapProps = {
|
||||||
map: MapType | null;
|
map: MapType | null;
|
||||||
|
@ -149,6 +149,13 @@ function Map({
|
||||||
!!(map?.owner === userId || mapState?.editFlags.includes("notes"))
|
!!(map?.owner === userId || mapState?.editFlags.includes("notes"))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { selectionTool, selectionMenu } = useMapSelection(
|
||||||
|
map,
|
||||||
|
onSelectionItemsChange,
|
||||||
|
selectedToolId,
|
||||||
|
settings.select
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
<MapInteraction
|
<MapInteraction
|
||||||
|
@ -173,6 +180,7 @@ function Map({
|
||||||
/>
|
/>
|
||||||
{tokenMenu}
|
{tokenMenu}
|
||||||
{noteMenu}
|
{noteMenu}
|
||||||
|
{selectionMenu}
|
||||||
{tokenDragOverlay}
|
{tokenDragOverlay}
|
||||||
{noteDragOverlay}
|
{noteDragOverlay}
|
||||||
</>
|
</>
|
||||||
|
@ -211,11 +219,7 @@ function Map({
|
||||||
session={session}
|
session={session}
|
||||||
/>
|
/>
|
||||||
<MeasureTool map={map} active={selectedToolId === "measure"} />
|
<MeasureTool map={map} active={selectedToolId === "measure"} />
|
||||||
<SelectTool
|
{selectionTool}
|
||||||
active={selectedToolId === "select"}
|
|
||||||
toolSettings={settings.select}
|
|
||||||
onSelectionItemsChange={onSelectionItemsChange}
|
|
||||||
/>
|
|
||||||
</MapInteraction>
|
</MapInteraction>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
165
src/components/selection/SelectionMenu.tsx
Normal file
165
src/components/selection/SelectionMenu.tsx
Normal file
|
@ -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<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModalContent(node: HTMLElement) {
|
||||||
|
if (node) {
|
||||||
|
// Focus input
|
||||||
|
const tokenLabelInput =
|
||||||
|
node.querySelector<HTMLInputElement>("#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 (
|
||||||
|
<MapMenu
|
||||||
|
isOpen={isOpen}
|
||||||
|
onRequestClose={onRequestClose}
|
||||||
|
top={`${menuTop}px`}
|
||||||
|
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" }}>
|
||||||
|
<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>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</MapMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectionMenu;
|
|
@ -31,17 +31,24 @@ import { useGridCellNormalizedSize } from "../../contexts/GridContext";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import Selection from "../konva/Selection";
|
import Selection from "../konva/Selection";
|
||||||
import { SelectionItemsChangeEventHandler } from "../../types/Events";
|
import { SelectionItemsChangeEventHandler } from "../../types/Events";
|
||||||
|
import { getSelectionPoints } from "../../helpers/selection";
|
||||||
|
|
||||||
type MapSelectProps = {
|
type MapSelectProps = {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
toolSettings: SelectToolSettings;
|
toolSettings: SelectToolSettings;
|
||||||
onSelectionItemsChange: SelectionItemsChangeEventHandler;
|
onSelectionItemsChange: SelectionItemsChangeEventHandler;
|
||||||
|
selection: SelectionType | null;
|
||||||
|
onSelectionChange: React.Dispatch<React.SetStateAction<SelectionType | null>>;
|
||||||
|
onSelectionMenuOpen: (open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SelectTool({
|
function SelectTool({
|
||||||
active,
|
active,
|
||||||
toolSettings,
|
toolSettings,
|
||||||
onSelectionItemsChange,
|
onSelectionItemsChange,
|
||||||
|
selection,
|
||||||
|
onSelectionChange,
|
||||||
|
onSelectionMenuOpen,
|
||||||
}: MapSelectProps) {
|
}: MapSelectProps) {
|
||||||
const stageScale = useDebouncedStageScale();
|
const stageScale = useDebouncedStageScale();
|
||||||
const mapWidth = useMapWidth();
|
const mapWidth = useMapWidth();
|
||||||
|
@ -51,7 +58,6 @@ function SelectTool({
|
||||||
const gridCellNormalizedSize = useGridCellNormalizedSize();
|
const gridCellNormalizedSize = useGridCellNormalizedSize();
|
||||||
|
|
||||||
const mapStageRef = useMapStage();
|
const mapStageRef = useMapStage();
|
||||||
const [selection, setSelection] = useState<SelectionType | null>(null);
|
|
||||||
const [isBrushDown, setIsBrushDown] = useState(false);
|
const [isBrushDown, setIsBrushDown] = useState(false);
|
||||||
|
|
||||||
// Use a ref here to prevent case where brush down event
|
// Use a ref here to prevent case where brush down event
|
||||||
|
@ -85,7 +91,7 @@ function SelectTool({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (toolSettings.type === "path") {
|
if (toolSettings.type === "path") {
|
||||||
setSelection({
|
onSelectionChange({
|
||||||
type: "path",
|
type: "path",
|
||||||
items: [],
|
items: [],
|
||||||
data: { points: [brushPosition] },
|
data: { points: [brushPosition] },
|
||||||
|
@ -93,7 +99,7 @@ function SelectTool({
|
||||||
y: 0,
|
y: 0,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setSelection({
|
onSelectionChange({
|
||||||
type: "rectangle",
|
type: "rectangle",
|
||||||
items: [],
|
items: [],
|
||||||
data: getDefaultShapeData("rectangle", brushPosition) as RectData,
|
data: getDefaultShapeData("rectangle", brushPosition) as RectData,
|
||||||
|
@ -111,7 +117,7 @@ function SelectTool({
|
||||||
}
|
}
|
||||||
if (isBrushDown && selection && mapImage) {
|
if (isBrushDown && selection && mapImage) {
|
||||||
if (selection.type === "path") {
|
if (selection.type === "path") {
|
||||||
setSelection((prevSelection) => {
|
onSelectionChange((prevSelection) => {
|
||||||
if (prevSelection?.type !== "path") {
|
if (prevSelection?.type !== "path") {
|
||||||
return prevSelection;
|
return prevSelection;
|
||||||
}
|
}
|
||||||
|
@ -135,7 +141,7 @@ function SelectTool({
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setSelection((prevSelection) => {
|
onSelectionChange((prevSelection) => {
|
||||||
if (prevSelection?.type !== "rectangle") {
|
if (prevSelection?.type !== "rectangle") {
|
||||||
return prevSelection;
|
return prevSelection;
|
||||||
}
|
}
|
||||||
|
@ -163,24 +169,7 @@ function SelectTool({
|
||||||
const tokensGroup = mapStage.findOne<Konva.Group>("#tokens");
|
const tokensGroup = mapStage.findOne<Konva.Group>("#tokens");
|
||||||
const notesGroup = mapStage.findOne<Konva.Group>("#notes");
|
const notesGroup = mapStage.findOne<Konva.Group>("#notes");
|
||||||
if (tokensGroup && notesGroup) {
|
if (tokensGroup && notesGroup) {
|
||||||
let points: Vector2[] = [];
|
const points = getSelectionPoints(selection);
|
||||||
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 intersection = new Intersection(
|
const intersection = new Intersection(
|
||||||
{
|
{
|
||||||
type: "path",
|
type: "path",
|
||||||
|
@ -214,20 +203,21 @@ function SelectTool({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intersectingItems.length > 0) {
|
if (intersectingItems.length > 0) {
|
||||||
setSelection((prevSelection) => {
|
onSelectionChange((prevSelection) => {
|
||||||
if (!prevSelection) {
|
if (!prevSelection) {
|
||||||
return prevSelection;
|
return prevSelection;
|
||||||
}
|
}
|
||||||
return { ...prevSelection, items: intersectingItems };
|
return { ...prevSelection, items: intersectingItems };
|
||||||
});
|
});
|
||||||
|
onSelectionMenuOpen(true);
|
||||||
} else {
|
} else {
|
||||||
setSelection(null);
|
onSelectionChange(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setSelection(null);
|
onSelectionChange(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setSelection(null);
|
onSelectionChange(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsBrushDown(false);
|
setIsBrushDown(false);
|
||||||
|
@ -237,7 +227,7 @@ function SelectTool({
|
||||||
if (preventSelectionRef.current) {
|
if (preventSelectionRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelection(null);
|
onSelectionChange(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
interactionEmitter?.on("dragStart", handleBrushDown);
|
interactionEmitter?.on("dragStart", handleBrushDown);
|
||||||
|
@ -258,7 +248,7 @@ function SelectTool({
|
||||||
{selection && (
|
{selection && (
|
||||||
<Selection
|
<Selection
|
||||||
selection={selection}
|
selection={selection}
|
||||||
onSelectionChange={setSelection}
|
onSelectionChange={onSelectionChange}
|
||||||
onSelectionItemsChange={onSelectionItemsChange}
|
onSelectionItemsChange={onSelectionItemsChange}
|
||||||
onPreventSelectionChange={(prevent: boolean) =>
|
onPreventSelectionChange={(prevent: boolean) =>
|
||||||
(preventSelectionRef.current = prevent)
|
(preventSelectionRef.current = prevent)
|
||||||
|
|
24
src/helpers/selection.ts
Normal file
24
src/helpers/selection.ts
Normal file
|
@ -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;
|
||||||
|
}
|
47
src/hooks/useMapSelection.tsx
Normal file
47
src/hooks/useMapSelection.tsx
Normal file
|
@ -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<boolean>(false);
|
||||||
|
const [selection, setSelection] = useState<Selection | null>(null);
|
||||||
|
|
||||||
|
function handleSelectionMenuOpen(open: boolean) {
|
||||||
|
setIsSelectionMenuOpen(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionTool = (
|
||||||
|
<SelectTool
|
||||||
|
active={selectedToolId === "select"}
|
||||||
|
toolSettings={settings}
|
||||||
|
onSelectionItemsChange={onSelectionItemsChange}
|
||||||
|
selection={selection}
|
||||||
|
onSelectionChange={setSelection}
|
||||||
|
onSelectionMenuOpen={handleSelectionMenuOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectionMenu = (
|
||||||
|
<SelectionMenu
|
||||||
|
isOpen={isSelectionMenuOpen}
|
||||||
|
onRequestClose={() => setIsSelectionMenuOpen(false)}
|
||||||
|
selection={selection}
|
||||||
|
onSelectionItemsChange={onSelectionItemsChange}
|
||||||
|
map={map}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { selectionTool, selectionMenu };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useMapSelection;
|
Loading…
Reference in New Issue
Block a user