Add basic selection menu

This commit is contained in:
Mitchell McCaffrey 2021-07-22 13:16:44 +10:00
parent 648d308fa5
commit 986f1efc9b
5 changed files with 265 additions and 35 deletions

View File

@ -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>
);

View 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;

View File

@ -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
View 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;
}

View 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;