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 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 (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<MapInteraction
|
||||
@ -173,6 +180,7 @@ function Map({
|
||||
/>
|
||||
{tokenMenu}
|
||||
{noteMenu}
|
||||
{selectionMenu}
|
||||
{tokenDragOverlay}
|
||||
{noteDragOverlay}
|
||||
</>
|
||||
@ -211,11 +219,7 @@ function Map({
|
||||
session={session}
|
||||
/>
|
||||
<MeasureTool map={map} active={selectedToolId === "measure"} />
|
||||
<SelectTool
|
||||
active={selectedToolId === "select"}
|
||||
toolSettings={settings.select}
|
||||
onSelectionItemsChange={onSelectionItemsChange}
|
||||
/>
|
||||
{selectionTool}
|
||||
</MapInteraction>
|
||||
</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 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<React.SetStateAction<SelectionType | null>>;
|
||||
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<SelectionType | null>(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<Konva.Group>("#tokens");
|
||||
const notesGroup = mapStage.findOne<Konva.Group>("#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 && (
|
||||
<Selection
|
||||
selection={selection}
|
||||
onSelectionChange={setSelection}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onSelectionItemsChange={onSelectionItemsChange}
|
||||
onPreventSelectionChange={(prevent: boolean) =>
|
||||
(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