diff --git a/package.json b/package.json index 6131a98..7b6f04e 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "socket.io-msgpack-parser": "^3.0.1", "source-map-explorer": "^2.5.2", "theme-ui": "^0.10.0", + "tiny-typed-emitter": "^2.1.0", "use-image": "^1.0.8", "uuid": "^8.3.2", "webrtc-adapter": "^8.1.0" diff --git a/src/components/konva/Note.tsx b/src/components/konva/Note.tsx index e961854..96718a5 100644 --- a/src/components/konva/Note.tsx +++ b/src/components/konva/Note.tsx @@ -102,6 +102,9 @@ function Note({ } function handleClick(event: Konva.KonvaEventObject) { + if (event.evt.button !== 0) { + return; + } if (draggable) { const noteNode = event.target; onNoteMenuOpen && onNoteMenuOpen(note.id, noteNode, true); @@ -111,6 +114,9 @@ function Note({ // Store note pointer down time to check for a click when note is locked const notePointerDownTimeRef = useRef(0); function handlePointerDown(event: Konva.KonvaEventObject) { + if (event.evt.button !== 0) { + return; + } if (draggable) { setPreventMapInteraction(true); } @@ -120,6 +126,9 @@ function Note({ } function handlePointerUp(event: Konva.KonvaEventObject) { + if (event.evt.button !== 0) { + return; + } if (draggable) { setPreventMapInteraction(false); } diff --git a/src/components/konva/Token.tsx b/src/components/konva/Token.tsx index 5695a03..0de3667 100644 --- a/src/components/konva/Token.tsx +++ b/src/components/konva/Token.tsx @@ -197,7 +197,10 @@ function Token({ setAttachmentOverCharacter(false); } - function handleClick() { + function handleClick(event: Konva.KonvaEventObject) { + if (event.evt.button !== 0) { + return; + } if (selectable && draggable && transformRootRef.current) { onTokenMenuOpen(tokenState.id, transformRootRef.current, true); } @@ -207,6 +210,9 @@ function Token({ // Store token pointer down time to check for a click when token is locked const tokenPointerDownTimeRef = useRef(0); function handlePointerDown(event: Konva.KonvaEventObject) { + if (event.evt.button !== 0) { + return; + } if (draggable) { setPreventMapInteraction(true); } @@ -216,6 +222,9 @@ function Token({ } function handlePointerUp(event: Konva.KonvaEventObject) { + if (event.evt.button !== 0) { + return; + } if (draggable) { setPreventMapInteraction(false); } diff --git a/src/components/map/MapInteraction.tsx b/src/components/map/MapInteraction.tsx index 031a761..001b1f4 100644 --- a/src/components/map/MapInteraction.tsx +++ b/src/components/map/MapInteraction.tsx @@ -3,17 +3,20 @@ import { Box } from "theme-ui"; import ReactResizeDetector from "react-resize-detector"; import { Stage, Layer, Image, Group } from "react-konva"; import Konva from "konva"; -import { EventEmitter } from "events"; import useMapImage from "../../hooks/useMapImage"; import usePreventOverscroll from "../../hooks/usePreventOverscroll"; import useStageInteraction from "../../hooks/useStageInteraction"; import useImageCenter from "../../hooks/useImageCenter"; +import usePreventContextMenu from "../../hooks/usePreventContextMenu"; import { getGridMaxZoom } from "../../helpers/grid"; import KonvaBridge from "../../helpers/KonvaBridge"; -import { MapInteractionProvider } from "../../contexts/MapInteractionContext"; +import { + MapInteractionEmitter, + MapInteractionProvider, +} from "../../contexts/MapInteractionContext"; import { useMapStage } from "../../contexts/MapStageContext"; import { GridProvider } from "../../contexts/GridContext"; import { useKeyboard } from "../../contexts/KeyboardContext"; @@ -72,6 +75,7 @@ function MapInteraction({ const containerRef = useRef(null); usePreventOverscroll(containerRef); + usePreventContextMenu(containerRef); const [mapWidth, mapHeight] = useImageCenter( map, @@ -85,8 +89,9 @@ function MapInteraction({ ); const previousSelectedToolRef = useRef(selectedToolId); + const [currentMouseButtons, setCurentMouseButtons] = useState(0); - const [interactionEmitter] = useState(new EventEmitter()); + const [interactionEmitter] = useState(new MapInteractionEmitter()); useStageInteraction( mapStageRef, @@ -106,13 +111,17 @@ function MapInteraction({ onPinchEnd: () => { onSelectedToolChange(previousSelectedToolRef.current); }, - onDrag: ({ first, last }) => { + onDrag: (props) => { + const { first, last, buttons } = props; + if (buttons !== currentMouseButtons) { + setCurentMouseButtons(buttons); + } if (first) { - interactionEmitter.emit("dragStart"); + interactionEmitter.emit("dragStart", props); } else if (last) { - interactionEmitter.emit("dragEnd"); + interactionEmitter.emit("dragEnd", props); } else { - interactionEmitter.emit("drag"); + interactionEmitter.emit("drag", props); } }, } @@ -143,6 +152,11 @@ function MapInteraction({ useKeyboard(handleKeyDown, handleKeyUp); function getCursorForTool(tool: MapToolId) { + if (currentMouseButtons === 2) { + return "crosshair"; + } else if (currentMouseButtons > 2) { + return "move"; + } switch (tool) { case "move": return "move"; diff --git a/src/components/tools/DrawingTool.tsx b/src/components/tools/DrawingTool.tsx index f155da4..fa529cd 100644 --- a/src/components/tools/DrawingTool.tsx +++ b/src/components/tools/DrawingTool.tsx @@ -7,6 +7,8 @@ import { useMapWidth, useMapHeight, useInteractionEmitter, + leftMouseButton, + MapDragEvent, } from "../../contexts/MapInteractionContext"; import { useMapStage } from "../../contexts/MapStageContext"; import { @@ -103,7 +105,10 @@ function DrawingTool({ }); } - function handleBrushDown() { + function handleBrushDown(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } const brushPosition = getBrushPosition(); if (!brushPosition) { return; @@ -135,7 +140,10 @@ function DrawingTool({ setIsBrushDown(true); } - function handleBrushMove() { + function handleBrushMove(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } const brushPosition = getBrushPosition(); if (!brushPosition) { return; @@ -186,7 +194,10 @@ function DrawingTool({ } } - function handleBrushUp() { + function handleBrushUp(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } if (isBrush && drawing && drawing.type === "path") { if (drawing.data.points.length > 1) { onShapeAdd(drawing); diff --git a/src/components/tools/FogTool.tsx b/src/components/tools/FogTool.tsx index 16a760a..b0b48bc 100644 --- a/src/components/tools/FogTool.tsx +++ b/src/components/tools/FogTool.tsx @@ -3,6 +3,7 @@ import shortid from "shortid"; import { Group, Line } from "react-konva"; import useImage from "use-image"; import Color from "color"; +import Konva from "konva"; import diagonalPattern from "../../images/DiagonalPattern.png"; @@ -11,6 +12,8 @@ import { useMapWidth, useMapHeight, useInteractionEmitter, + MapDragEvent, + leftMouseButton, } from "../../contexts/MapInteractionContext"; import { useMapStage } from "../../contexts/MapStageContext"; import { @@ -160,7 +163,10 @@ function FogTool({ }); } - function handleBrushDown() { + function handleBrushDown(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } if (toolSettings.type === "brush") { const brushPosition = getBrushPosition(); if (!brushPosition) { @@ -203,7 +209,10 @@ function FogTool({ setIsBrushDown(true); } - function handleBrushMove() { + function handleBrushMove(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } if (toolSettings.type === "brush" && isBrushDown && drawingShape) { const brushPosition = getBrushPosition(); if (!brushPosition) { @@ -258,7 +267,10 @@ function FogTool({ } } - function handleBrushUp() { + function handleBrushUp(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } if ( (toolSettings.type === "brush" || toolSettings.type === "rectangle") && drawingShape @@ -318,7 +330,13 @@ function FogTool({ setIsBrushDown(false); } - function handlePointerClick() { + function handlePointerClick( + event: Konva.KonvaEventObject + ) { + // Left click only + if (event.evt instanceof MouseEvent && event.evt.button !== 0) { + return; + } if (toolSettings.type === "polygon") { const brushPosition = getBrushPosition(); if (brushPosition) { diff --git a/src/components/tools/MeasureTool.tsx b/src/components/tools/MeasureTool.tsx index 7e352b3..d57f851 100644 --- a/src/components/tools/MeasureTool.tsx +++ b/src/components/tools/MeasureTool.tsx @@ -1,7 +1,11 @@ import { useState, useEffect } from "react"; import { Group } from "react-konva"; -import { useInteractionEmitter } from "../../contexts/MapInteractionContext"; +import { + useInteractionEmitter, + MapDragEvent, + leftMouseButton, +} from "../../contexts/MapInteractionContext"; import { useMapStage } from "../../contexts/MapStageContext"; import { useGrid, @@ -40,8 +44,9 @@ function MeasureTool({ map, active }: MapMeasureProps) { const gridOffset = useGridOffset(); const mapStageRef = useMapStage(); - const [drawingShapeData, setDrawingShapeData] = - useState(null); + const [drawingShapeData, setDrawingShapeData] = useState( + null + ); const [isBrushDown, setIsBrushDown] = useState(false); const gridScale = parseGridScale(active ? grid.measurement.scale : null); @@ -75,7 +80,10 @@ function MeasureTool({ map, active }: MapMeasureProps) { }); } - function handleBrushDown() { + function handleBrushDown(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } const brushPosition = getBrushPosition(); if (!brushPosition) { return; @@ -89,7 +97,10 @@ function MeasureTool({ map, active }: MapMeasureProps) { setIsBrushDown(true); } - function handleBrushMove() { + function handleBrushMove(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } const brushPosition = getBrushPosition(); if (isBrushDown && drawingShapeData && brushPosition && mapImage) { const { points } = getUpdatedShapeData( @@ -123,7 +134,10 @@ function MeasureTool({ map, active }: MapMeasureProps) { } } - function handleBrushUp() { + function handleBrushUp(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } setDrawingShapeData(null); setIsBrushDown(false); } diff --git a/src/components/tools/NoteTool.tsx b/src/components/tools/NoteTool.tsx index 98195d8..e7ea19d 100644 --- a/src/components/tools/NoteTool.tsx +++ b/src/components/tools/NoteTool.tsx @@ -3,7 +3,11 @@ import shortid from "shortid"; import { Group } from "react-konva"; import Konva from "konva"; -import { useInteractionEmitter } from "../../contexts/MapInteractionContext"; +import { + useInteractionEmitter, + MapDragEvent, + leftMouseButton, +} from "../../contexts/MapInteractionContext"; import { useMapStage } from "../../contexts/MapStageContext"; import { useUserId } from "../../contexts/UserIdContext"; @@ -72,7 +76,10 @@ function NoteTool({ }); } - function handleBrushDown() { + function handleBrushDown(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } const brushPosition = getBrushPosition(); if (!brushPosition || !userId) { return; @@ -94,7 +101,10 @@ function NoteTool({ setIsBrushDown(true); } - function handleBrushMove() { + function handleBrushMove(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } if (noteData) { const brushPosition = getBrushPosition(); if (!brushPosition) { @@ -114,7 +124,10 @@ function NoteTool({ } } - function handleBrushUp() { + function handleBrushUp(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } if (noteData && creatingNoteRef.current) { onNoteCreate([noteData]); onNoteMenuOpen(noteData.id, creatingNoteRef.current, true); diff --git a/src/components/tools/PointerTool.tsx b/src/components/tools/PointerTool.tsx index de30bf0..63a3cb2 100644 --- a/src/components/tools/PointerTool.tsx +++ b/src/components/tools/PointerTool.tsx @@ -1,10 +1,13 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { Group } from "react-konva"; import { useMapWidth, useMapHeight, useInteractionEmitter, + MapDragEvent, + leftMouseButton, + rightMouseButton, } from "../../contexts/MapInteractionContext"; import { useMapStage } from "../../contexts/MapStageContext"; import { useGridStrokeWidth } from "../../contexts/GridContext"; @@ -41,11 +44,9 @@ function PointerTool({ const gridStrokeWidth = useGridStrokeWidth(); const mapStageRef = useMapStage(); - useEffect(() => { - if (!active) { - return; - } + const brushDownRef = useRef(false); + useEffect(() => { const mapStage = mapStageRef.current; function getBrushPosition() { @@ -56,19 +57,27 @@ function PointerTool({ return getRelativePointerPositionNormalized(mapImage); } - function handleBrushDown() { - const brushPosition = getBrushPosition(); - brushPosition && onPointerDown?.(brushPosition); + function handleBrushDown(props: MapDragEvent) { + if ((leftMouseButton(props) && active) || rightMouseButton(props)) { + const brushPosition = getBrushPosition(); + brushPosition && onPointerDown?.(brushPosition); + brushDownRef.current = true; + } } function handleBrushMove() { - const brushPosition = getBrushPosition(); - brushPosition && visible && onPointerMove?.(brushPosition); + if (brushDownRef.current) { + const brushPosition = getBrushPosition(); + brushPosition && visible && onPointerMove?.(brushPosition); + } } function handleBrushUp() { - const brushPosition = getBrushPosition(); - brushPosition && onPointerUp?.(brushPosition); + if (brushDownRef.current) { + const brushPosition = getBrushPosition(); + brushPosition && onPointerUp?.(brushPosition); + brushDownRef.current = false; + } } interactionEmitter?.on("dragStart", handleBrushDown); diff --git a/src/components/tools/SelectTool.tsx b/src/components/tools/SelectTool.tsx index 2a29ca8..2c9be79 100644 --- a/src/components/tools/SelectTool.tsx +++ b/src/components/tools/SelectTool.tsx @@ -7,6 +7,8 @@ import { useMapWidth, useMapHeight, useInteractionEmitter, + MapDragEvent, + leftMouseButton, } from "../../contexts/MapInteractionContext"; import { useMapStage } from "../../contexts/MapStageContext"; @@ -96,7 +98,10 @@ function SelectTool({ }); } - function handleBrushDown() { + function handleBrushDown(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } const brushPosition = getBrushPosition(); if (!brushPosition || preventSelectionRef.current) { return; @@ -121,7 +126,10 @@ function SelectTool({ setIsBrushDown(true); } - function handleBrushMove() { + function handleBrushMove(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } const brushPosition = getBrushPosition(); if (!brushPosition || preventSelectionRef.current) { return; @@ -172,7 +180,10 @@ function SelectTool({ } } - function handleBrushUp() { + function handleBrushUp(props: MapDragEvent) { + if (!leftMouseButton(props)) { + return; + } if (preventSelectionRef.current) { return; } diff --git a/src/contexts/MapInteractionContext.tsx b/src/contexts/MapInteractionContext.tsx index 7362c86..21dd1d6 100644 --- a/src/contexts/MapInteractionContext.tsx +++ b/src/contexts/MapInteractionContext.tsx @@ -1,6 +1,21 @@ import React, { useContext } from "react"; -import { EventEmitter } from "stream"; +import { FullGestureState } from "react-use-gesture/dist/types"; import useDebounce from "../hooks/useDebounce"; +import { TypedEmitter } from "tiny-typed-emitter"; + +export type MapDragEvent = Omit, "event"> & { + event: React.PointerEvent | PointerEvent; +}; + +export type MapDragEventHandler = (props: MapDragEvent) => void; + +export interface MapInteractionEvents { + dragStart: MapDragEventHandler; + drag: MapDragEventHandler; + dragEnd: MapDragEventHandler; +} + +export class MapInteractionEmitter extends TypedEmitter {} type MapInteraction = { stageScale: number; @@ -9,29 +24,33 @@ type MapInteraction = { setPreventMapInteraction: React.Dispatch>; mapWidth: number; mapHeight: number; - interactionEmitter: EventEmitter | null; + interactionEmitter: MapInteractionEmitter | null; }; -export const StageScaleContext = - React.createContext(undefined); -export const DebouncedStageScaleContext = - React.createContext(undefined); -export const StageWidthContext = - React.createContext(undefined); -export const StageHeightContext = - React.createContext(undefined); -export const SetPreventMapInteractionContext = - React.createContext( - undefined - ); -export const MapWidthContext = - React.createContext(undefined); -export const MapHeightContext = - React.createContext(undefined); -export const InteractionEmitterContext = - React.createContext( - undefined - ); +export const StageScaleContext = React.createContext< + MapInteraction["stageScale"] | undefined +>(undefined); +export const DebouncedStageScaleContext = React.createContext< + MapInteraction["stageScale"] | undefined +>(undefined); +export const StageWidthContext = React.createContext< + MapInteraction["stageWidth"] | undefined +>(undefined); +export const StageHeightContext = React.createContext< + MapInteraction["stageHeight"] | undefined +>(undefined); +export const SetPreventMapInteractionContext = React.createContext< + MapInteraction["setPreventMapInteraction"] | undefined +>(undefined); +export const MapWidthContext = React.createContext< + MapInteraction["mapWidth"] | undefined +>(undefined); +export const MapHeightContext = React.createContext< + MapInteraction["mapHeight"] | undefined +>(undefined); +export const InteractionEmitterContext = React.createContext< + MapInteraction["interactionEmitter"] | undefined +>(undefined); export function MapInteractionProvider({ value, @@ -152,3 +171,15 @@ export function useDebouncedStageScale() { } return context; } + +export function leftMouseButton(event: MapDragEvent) { + return event.buttons <= 1; +} + +export function middleMouseButton(event: MapDragEvent) { + return event.buttons === 4; +} + +export function rightMouseButton(event: MapDragEvent) { + return event.buttons === 2; +} diff --git a/src/hooks/usePreventContextMenu.ts b/src/hooks/usePreventContextMenu.ts new file mode 100644 index 0000000..7073836 --- /dev/null +++ b/src/hooks/usePreventContextMenu.ts @@ -0,0 +1,25 @@ +import React, { useEffect } from "react"; + +function usePreventContextMenu(elementRef: React.RefObject) { + useEffect(() => { + // Stop conext menu i.e. right click dialog + function preventContextMenu(event: MouseEvent) { + event.preventDefault(); + return false; + } + const element = elementRef.current; + if (element) { + element.addEventListener("contextmenu", preventContextMenu, { + passive: false, + }); + } + + return () => { + if (element) { + element.removeEventListener("contextmenu", preventContextMenu); + } + }; + }, [elementRef]); +} + +export default usePreventContextMenu; diff --git a/src/hooks/useStageInteraction.ts b/src/hooks/useStageInteraction.ts index 88ad511..b005bfe 100644 --- a/src/hooks/useStageInteraction.ts +++ b/src/hooks/useStageInteraction.ts @@ -67,6 +67,7 @@ function useStageInteraction( return; } const { event, last } = props; + // Prevent double zoom on wheel end if (!last) { const { pixelY } = normalizeWheel(event); @@ -178,7 +179,7 @@ function useStageInteraction( gesture.onDragStart && gesture.onDragStart(props); }, onDrag: (props) => { - const { delta, pinching } = props; + const { delta, pinching, buttons } = props; const stage = stageRef.current; if ( preventInteraction || @@ -191,7 +192,8 @@ function useStageInteraction( const [dx, dy] = delta; const stageTranslate = stageTranslateRef.current; - if (tool === "move") { + // Move with move tool and left click or any mouse button but right click + if ((tool === "move" && buttons < 2) || buttons > 2) { const newTranslate = { x: stageTranslate.x + dx, y: stageTranslate.y + dy, diff --git a/yarn.lock b/yarn.lock index 411290c..d178a1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13181,6 +13181,11 @@ tiny-invariant@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== +tiny-typed-emitter@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz#b3b027fdd389ff81a152c8e847ee2f5be9fad7b5" + integrity sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA== + tiny-warning@^1.0.0, tiny-warning@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"