diff --git a/.env b/.env index 45a32c4..d11dc00 100644 --- a/.env +++ b/.env @@ -2,4 +2,7 @@ REACT_APP_BROKER_URL=http://localhost:9000 REACT_APP_ICE_SERVERS_URL=http://localhost:9000/iceservers REACT_APP_STRIPE_API_KEY=pk_test_8M3NHrF1eI2b84ubF4F8rSTe0095R3f0My REACT_APP_STRIPE_URL=http://localhost:9000 -REACT_APP_VERSION=$npm_package_version \ No newline at end of file +REACT_APP_VERSION=$npm_package_version +REACT_APP_PREVIEW=false +REACT_APP_LOGGING=false +REACT_APP_FATHOM_SITE_ID=VMSHBPKD \ No newline at end of file diff --git a/.env.production b/.env.production index 9477201..f8a939f 100644 --- a/.env.production +++ b/.env.production @@ -1,5 +1,8 @@ -REACT_APP_BROKER_URL=https://connect.owlbear.rodeo -REACT_APP_ICE_SERVERS_URL=https://connect.owlbear.rodeo/iceservers +REACT_APP_BROKER_URL=https://test.owlbear.rodeo +REACT_APP_ICE_SERVERS_URL=https://test.owlbear.rodeo/iceservers REACT_APP_STRIPE_API_KEY=pk_live_MJjzi5djj524Y7h3fL5PNh4e00a852XD51 REACT_APP_STRIPE_URL=https://payment.owlbear.rodeo -REACT_APP_VERSION=$npm_package_version \ No newline at end of file +REACT_APP_VERSION=$npm_package_version +REACT_APP_PREVIEW=true +REACT_APP_LOGGING=true +REACT_APP_FATHOM_SITE_ID=VMSHBPKD \ No newline at end of file diff --git a/package.json b/package.json index 04638aa..76ae3a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "owlbear-rodeo", - "version": "1.6.2", + "version": "1.7.0", "private": true, "dependencies": { "@babylonjs/core": "^4.2.0", @@ -14,6 +14,8 @@ "@testing-library/user-event": "^12.2.2", "ammo.js": "kripken/ammo.js#aab297a4164779c3a9d8dc8d9da26958de3cb778", "case": "^1.6.3", + "comlink": "^4.3.0", + "deep-diff": "^1.0.2", "dexie": "^3.0.3", "err-code": "^2.0.3", "fake-indexeddb": "^3.1.2", @@ -24,6 +26,7 @@ "lodash.set": "^4.3.2", "normalize-wheel": "^1.0.1", "polygon-clipping": "^0.15.1", + "pretty-bytes": "^5.4.1", "raw.macro": "^0.4.2", "react": "^17.0.1", "react-dom": "^17.0.1", @@ -43,7 +46,7 @@ "simple-peer": "feross/simple-peer#694/head", "simplebar-react": "^2.1.0", "simplify-js": "^1.2.4", - "socket.io-client": "^2.3.0", + "socket.io-client": "^3.0.3", "source-map-explorer": "^2.4.2", "theme-ui": "^0.3.1", "use-image": "^1.0.5", @@ -52,7 +55,7 @@ "scripts": { "analyze": "source-map-explorer 'build/static/js/*.js'", "start": "react-scripts start", - "build": "react-scripts build", + "build": "react-scripts --max_old_space_size=4096 build", "test": "react-scripts test", "eject": "react-scripts eject" }, @@ -70,5 +73,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "worker-loader": "^3.0.5" } } diff --git a/public/index.html b/public/index.html index 3639964..f9e20ed 100644 --- a/public/index.html +++ b/public/index.html @@ -28,7 +28,7 @@ - + diff --git a/src/components/DragOverlay.js b/src/components/DragOverlay.js new file mode 100644 index 0000000..a89f02d --- /dev/null +++ b/src/components/DragOverlay.js @@ -0,0 +1,89 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Box, IconButton } from "theme-ui"; + +import RemoveTokenIcon from "../icons/RemoveTokenIcon"; + +function DragOverlay({ dragging, node, onRemove }) { + const [isRemoveHovered, setIsRemoveHovered] = useState(false); + const removeTokenRef = useRef(); + + // Detect token hover on remove icon manually to support touch devices + useEffect(() => { + const map = document.querySelector(".map"); + const mapRect = map.getBoundingClientRect(); + + function detectRemoveHover() { + if (!node || !dragging || !removeTokenRef.current) { + return; + } + const stage = node.getStage(); + if (!stage) { + return; + } + const pointerPosition = stage.getPointerPosition(); + const screenSpacePointerPosition = { + x: pointerPosition.x + mapRect.left, + y: pointerPosition.y + mapRect.top, + }; + const removeIconPosition = removeTokenRef.current.getBoundingClientRect(); + + if ( + screenSpacePointerPosition.x > removeIconPosition.left && + screenSpacePointerPosition.y > removeIconPosition.top && + screenSpacePointerPosition.x < removeIconPosition.right && + screenSpacePointerPosition.y < removeIconPosition.bottom + ) { + if (!isRemoveHovered) { + setIsRemoveHovered(true); + } + } else if (isRemoveHovered) { + setIsRemoveHovered(false); + } + } + + let handler; + if (node && dragging) { + handler = setInterval(detectRemoveHover, 100); + } + + return () => { + if (handler) { + clearInterval(handler); + } + }; + }, [isRemoveHovered, dragging, node]); + + // Detect drag end of token image and remove it if it is over the remove icon + useEffect(() => { + if (!dragging && node && isRemoveHovered) { + onRemove(); + } + }); + + return ( + dragging && ( + + + + + + ) + ); +} + +export default DragOverlay; diff --git a/src/components/ImageDrop.js b/src/components/ImageDrop.js index 8bd71d3..4cbc74b 100644 --- a/src/components/ImageDrop.js +++ b/src/components/ImageDrop.js @@ -15,11 +15,32 @@ function ImageDrop({ onDrop, dropText, children }) { setDragging(false); } - function handleImageDrop(event) { + async function handleImageDrop(event) { event.preventDefault(); event.stopPropagation(); - const files = event.dataTransfer.files; let imageFiles = []; + + // Check if the dropped image is from a URL + const html = event.dataTransfer.getData("text/html"); + if (html) { + try { + const urlMatch = html.match(/src="?([^"\s]+)"?\s*/); + const url = urlMatch[1].replace("&", "&"); // Reverse html encoding of url parameters + let name = ""; + const altMatch = html.match(/alt="?([^"]+)"?\s*/); + if (altMatch && altMatch.length > 1) { + name = altMatch[1]; + } + const response = await fetch(url); + if (response.ok) { + const file = await response.blob(); + file.name = name; + imageFiles.push(file); + } + } catch {} + } + + const files = event.dataTransfer.files; for (let file of files) { if (file.type.startsWith("image")) { imageFiles.push(file); diff --git a/src/components/Markdown.js b/src/components/Markdown.js index b49e564..a9381c5 100644 --- a/src/components/Markdown.js +++ b/src/components/Markdown.js @@ -45,7 +45,7 @@ function Image(props) { } function ListItem(props) { - return ; + return ; } function Code({ children, value }) { @@ -157,4 +157,8 @@ function Markdown({ source, assets }) { ); } +Markdown.defaultProps = { + assets: {}, +}; + export default Markdown; diff --git a/src/components/Slider.js b/src/components/Slider.js new file mode 100644 index 0000000..fdc601b --- /dev/null +++ b/src/components/Slider.js @@ -0,0 +1,69 @@ +import React, { useState } from "react"; +import { Box, Slider as ThemeSlider } from "theme-ui"; + +function Slider({ min, max, value, ml, mr, labelFunc, ...rest }) { + const percentValue = ((value - min) * 100) / (max - min); + + const [labelVisible, setLabelVisible] = useState(false); + + return ( + + {labelVisible && ( + + + + {labelFunc(value)} + + + + )} + setLabelVisible(true)} + onMouseUp={() => setLabelVisible(false)} + onTouchStart={() => setLabelVisible(true)} + onTouchEnd={() => setLabelVisible(false)} + {...rest} + /> + + ); +} + +Slider.defaultProps = { + min: 0, + max: 1, + value: 0, + ml: 0, + mr: 0, + labelFunc: (value) => value, +}; + +export default Slider; diff --git a/src/components/Tile.js b/src/components/Tile.js index 66d5ddc..5e7714d 100644 --- a/src/components/Tile.js +++ b/src/components/Tile.js @@ -10,18 +10,37 @@ function Tile({ onSelect, onEdit, onDoubleClick, - large, + size, canEdit, badges, editTitle, }) { + let width; + let margin; + switch (size) { + case "small": + width = "24%"; + margin = "0.5%"; + break; + case "medium": + width = "32%"; + margin = `${2 / 3}%`; + break; + case "large": + width = "48%"; + margin = "1%"; + break; + default: + width = "32%"; + margin = `${2 / 3}%`; + } return ( { e.stopPropagation(); @@ -126,7 +145,7 @@ Tile.defaultProps = { onSelect: () => {}, onEdit: () => {}, onDoubleClick: () => {}, - large: false, + size: "medium", canEdit: false, badges: [], editTitle: "Edit", diff --git a/src/components/map/Map.js b/src/components/map/Map.js index ba2fb2d..cdb7aca 100644 --- a/src/components/map/Map.js +++ b/src/components/map/Map.js @@ -8,14 +8,16 @@ import MapDrawing from "./MapDrawing"; import MapFog from "./MapFog"; import MapGrid from "./MapGrid"; import MapMeasure from "./MapMeasure"; -import MapLoadingOverlay from "./MapLoadingOverlay"; import NetworkedMapPointer from "../../network/NetworkedMapPointer"; +import MapNotes from "./MapNotes"; import TokenDataContext from "../../contexts/TokenDataContext"; import SettingsContext from "../../contexts/SettingsContext"; import TokenMenu from "../token/TokenMenu"; import TokenDragOverlay from "../token/TokenDragOverlay"; +import NoteMenu from "../note/NoteMenu"; +import NoteDragOverlay from "../note/NoteDragOverlay"; import { drawActionsToShapes } from "../../helpers/drawing"; @@ -32,9 +34,12 @@ function Map({ onFogDraw, onFogDrawUndo, onFogDrawRedo, + onMapNoteChange, + onMapNoteRemove, allowMapDrawing, allowFogDrawing, allowMapChange, + allowNoteEditing, disabledTokens, session, }) { @@ -100,8 +105,8 @@ function Map({ onFogDraw({ type: "add", shapes: [shape] }); } - function handleFogShapeSubtract(shape) { - onFogDraw({ type: "subtract", shapes: [shape] }); + function handleFogShapeCut(shape) { + onFogDraw({ type: "cut", shapes: [shape] }); } function handleFogShapesRemove(shapeIds) { @@ -140,6 +145,9 @@ function Map({ if (!allowMapChange) { disabledControls.push("map"); } + if (!allowNoteEditing) { + disabledControls.push("note"); + } const disabledSettings = { fog: [], drawing: [] }; if (mapShapes.length === 0) { @@ -182,7 +190,7 @@ function Map({ const [isTokenMenuOpen, setIsTokenMenuOpen] = useState(false); const [tokenMenuOptions, setTokenMenuOptions] = useState({}); - const [draggingTokenOptions, setDraggingTokenOptions] = useState(); + const [tokenDraggingOptions, setTokenDraggingOptions] = useState(); function handleTokenMenuOpen(tokenStateId, tokenImage) { setTokenMenuOptions({ tokenStateId, tokenImage }); setIsTokenMenuOpen(true); @@ -202,7 +210,7 @@ function Map({ } // Sort so vehicles render below other tokens - function sortMapTokenStates(a, b, draggingTokenOptions) { + function sortMapTokenStates(a, b, tokenDraggingOptions) { const tokenA = tokensById[a.tokenId]; const tokenB = tokensById[b.tokenId]; if (tokenA && tokenB) { @@ -212,16 +220,16 @@ function Map({ const bWeight = getMapTokenCategoryWeight(tokenB.category); return bWeight - aWeight; } else if ( - draggingTokenOptions && - draggingTokenOptions.dragging && - draggingTokenOptions.tokenState.id === a.id + tokenDraggingOptions && + tokenDraggingOptions.dragging && + tokenDraggingOptions.tokenState.id === a.id ) { // If dragging token a move above return 1; } else if ( - draggingTokenOptions && - draggingTokenOptions.dragging && - draggingTokenOptions.tokenState.id === b.id + tokenDraggingOptions && + tokenDraggingOptions.dragging && + tokenDraggingOptions.tokenState.id === b.id ) { // If dragging token b move above return -1; @@ -241,7 +249,7 @@ function Map({ const mapTokens = map && mapState && ( {Object.values(mapState.tokens) - .sort((a, b) => sortMapTokenStates(a, b, draggingTokenOptions)) + .sort((a, b) => sortMapTokenStates(a, b, tokenDraggingOptions)) .map((tokenState) => ( - setDraggingTokenOptions({ + setTokenDraggingOptions({ dragging: true, tokenState, tokenGroup: e.target, }) } onTokenDragEnd={() => - setDraggingTokenOptions({ - ...draggingTokenOptions, + setTokenDraggingOptions({ + ...tokenDraggingOptions, dragging: false, }) } @@ -287,17 +295,17 @@ function Map({ /> ); - const tokenDragOverlay = draggingTokenOptions && ( + const tokenDragOverlay = tokenDraggingOptions && ( { onMapTokenStateRemove(state); - setDraggingTokenOptions(null); + setTokenDraggingOptions(null); }} onTokenStateChange={onMapTokenStateChange} - tokenState={draggingTokenOptions && draggingTokenOptions.tokenState} - tokenGroup={draggingTokenOptions && draggingTokenOptions.tokenGroup} - dragging={draggingTokenOptions && draggingTokenOptions.dragging} - token={tokensById[draggingTokenOptions.tokenState.tokenId]} + tokenState={tokenDraggingOptions && tokenDraggingOptions.tokenState} + tokenGroup={tokenDraggingOptions && tokenDraggingOptions.tokenGroup} + dragging={!!(tokenDraggingOptions && tokenDraggingOptions.dragging)} + token={tokensById[tokenDraggingOptions.tokenState.tokenId]} mapState={mapState} /> ); @@ -309,7 +317,6 @@ function Map({ onShapeAdd={handleMapShapeAdd} onShapesRemove={handleMapShapesRemove} active={selectedToolId === "drawing"} - toolId="drawing" toolSettings={settings.drawing} gridSize={gridSizeNormalized} /> @@ -320,14 +327,13 @@ function Map({ map={map} shapes={fogShapes} onShapeAdd={handleFogShapeAdd} - onShapeSubtract={handleFogShapeSubtract} + onShapeCut={handleFogShapeCut} onShapesRemove={handleFogShapesRemove} onShapesEdit={handleFogShapesEdit} active={selectedToolId === "fog"} - toolId="fog" toolSettings={settings.fog} gridSize={gridSizeNormalized} - transparent={allowFogDrawing && !settings.fog.preview} + editable={allowFogDrawing && !settings.fog.preview} /> ); @@ -350,15 +356,98 @@ function Map({ /> ); + const [isNoteMenuOpen, setIsNoteMenuOpen] = useState(false); + const [noteMenuOptions, setNoteMenuOptions] = useState({}); + const [noteDraggingOptions, setNoteDraggingOptions] = useState(); + function handleNoteMenuOpen(noteId, noteNode) { + setNoteMenuOptions({ noteId, noteNode }); + setIsNoteMenuOpen(true); + } + + function sortNotes(a, b, noteDraggingOptions) { + if ( + noteDraggingOptions && + noteDraggingOptions.dragging && + noteDraggingOptions.noteId === a.id + ) { + // If dragging token `a` move above + return 1; + } else if ( + noteDraggingOptions && + noteDraggingOptions.dragging && + noteDraggingOptions.noteId === b.id + ) { + // If dragging token `b` move above + return -1; + } else { + // Else sort so last modified is on top + return a.lastModified - b.lastModified; + } + } + + const mapNotes = ( + + sortNotes(a, b, noteDraggingOptions) + ) + : [] + } + onNoteMenuOpen={handleNoteMenuOpen} + draggable={ + allowNoteEditing && + (selectedToolId === "note" || selectedToolId === "pan") + } + onNoteDragStart={(e, noteId) => + setNoteDraggingOptions({ dragging: true, noteId, noteGroup: e.target }) + } + onNoteDragEnd={() => + setNoteDraggingOptions({ ...noteDraggingOptions, dragging: false }) + } + /> + ); + + const noteMenu = ( + setIsNoteMenuOpen(false)} + onNoteChange={onMapNoteChange} + note={mapState && mapState.notes[noteMenuOptions.noteId]} + noteNode={noteMenuOptions.noteNode} + map={map} + /> + ); + + const noteDragOverlay = ( + { + onMapNoteRemove(noteId); + setNoteDraggingOptions(null); + }} + /> + ); + return ( {mapControls} {tokenMenu} + {noteMenu} {tokenDragOverlay} - + {noteDragOverlay} } selectedToolId={selectedToolId} @@ -366,6 +455,7 @@ function Map({ disabledControls={disabledControls} > {mapGrid} + {mapNotes} {mapDrawing} {mapTokens} {mapFog} diff --git a/src/components/map/MapControls.js b/src/components/map/MapControls.js index a912201..fb71b72 100644 --- a/src/components/map/MapControls.js +++ b/src/components/map/MapControls.js @@ -18,6 +18,7 @@ import ExpandMoreIcon from "../../icons/ExpandMoreIcon"; import PointerToolIcon from "../../icons/PointerToolIcon"; import FullScreenIcon from "../../icons/FullScreenIcon"; import FullScreenExitIcon from "../../icons/FullScreenExitIcon"; +import NoteToolIcon from "../../icons/NoteToolIcon"; import useSetting from "../../helpers/useSetting"; @@ -66,8 +67,13 @@ function MapContols({ icon: , title: "Pointer Tool (Q)", }, + note: { + id: "note", + icon: , + title: "Note Tool (N)", + }, }; - const tools = ["pan", "fog", "drawing", "measure", "pointer"]; + const tools = ["pan", "fog", "drawing", "measure", "pointer", "note"]; const sections = [ { diff --git a/src/components/map/MapDrawing.js b/src/components/map/MapDrawing.js index 8bf33c9..66012c3 100644 --- a/src/components/map/MapDrawing.js +++ b/src/components/map/MapDrawing.js @@ -23,7 +23,6 @@ function MapDrawing({ onShapeAdd, onShapesRemove, active, - toolId, toolSettings, gridSize, }) { @@ -35,7 +34,7 @@ function MapDrawing({ const [isBrushDown, setIsBrushDown] = useState(false); const [erasingShapes, setErasingShapes] = useState([]); - const shouldHover = toolSettings.type === "erase"; + const shouldHover = toolSettings.type === "erase" && active; const isBrush = toolSettings.type === "brush" || toolSettings.type === "paint"; const isShape = @@ -55,8 +54,8 @@ function MapDrawing({ return getBrushPositionForTool( map, getRelativePointerPositionNormalized(mapImage), - toolId, - toolSettings, + map.snapToGrid && isShape, + false, gridSize, shapes ); diff --git a/src/components/map/MapEditor.js b/src/components/map/MapEditor.js index d1e046a..d6b22d2 100644 --- a/src/components/map/MapEditor.js +++ b/src/components/map/MapEditor.js @@ -8,6 +8,7 @@ import usePreventOverscroll from "../../helpers/usePreventOverscroll"; import useStageInteraction from "../../helpers/useStageInteraction"; import useImageCenter from "../../helpers/useImageCenter"; import { getMapDefaultInset, getMapMaxZoom } from "../../helpers/map"; +import useResponsiveLayout from "../../helpers/useResponsiveLayout"; import { MapInteractionProvider } from "../../contexts/MapInteractionContext"; import KeyboardContext from "../../contexts/KeyboardContext"; @@ -104,11 +105,14 @@ function MapEditor({ map, onSettingsChange }) { map.grid.inset.topLeft.y !== defaultInset.topLeft.y || map.grid.inset.bottomRight.x !== defaultInset.bottomRight.x || map.grid.inset.bottomRight.y !== defaultInset.bottomRight.y; + + const layout = useResponsiveLayout(); + return ( { - if (!active) { + if (!active || !editable) { return; } @@ -61,8 +69,10 @@ function MapFog({ return getBrushPositionForTool( map, getRelativePointerPositionNormalized(mapImage), - toolId, - toolSettings, + map.snapToGrid && + (toolSettings.type === "polygon" || + toolSettings.type === "rectangle"), + toolSettings.useEdgeSnapping, gridSize, shapes ); @@ -78,8 +88,25 @@ function MapFog({ holes: [], }, strokeWidth: 0.5, - color: toolSettings.useFogSubtract ? "red" : "black", - blend: false, + color: toolSettings.useFogCut ? "red" : "black", + id: shortid.generate(), + visible: true, + }); + } + if (toolSettings.type === "rectangle") { + setDrawingShape({ + type: "fog", + data: { + points: [ + brushPosition, + brushPosition, + brushPosition, + brushPosition, + ], + holes: [], + }, + strokeWidth: 0.5, + color: toolSettings.useFogCut ? "red" : "black", id: shortid.generate(), visible: true, }); @@ -110,15 +137,35 @@ function MapFog({ }; }); } + if (toolSettings.type === "rectangle" && isBrushDown && drawingShape) { + const brushPosition = getBrushPosition(); + setDrawingShape((prevShape) => { + const prevPoints = prevShape.data.points; + return { + ...prevShape, + data: { + ...prevShape.data, + points: [ + prevPoints[0], + { x: brushPosition.x, y: prevPoints[1].y }, + brushPosition, + { x: prevPoints[3].x, y: brushPosition.y }, + ], + }, + }; + }); + } } function handleBrushUp() { - if (toolSettings.type === "brush" && drawingShape) { - const subtract = toolSettings.useFogSubtract; - + if ( + toolSettings.type === "brush" || + (toolSettings.type === "rectangle" && drawingShape) + ) { + const cut = toolSettings.useFogCut; if (drawingShape.data.points.length > 1) { let shapeData = {}; - if (subtract) { + if (cut) { shapeData = { id: drawingShape.id, type: drawingShape.type }; } else { shapeData = { ...drawingShape, color: "black" }; @@ -135,8 +182,8 @@ function MapFog({ ), }, }; - if (subtract) { - onShapeSubtract(shape); + if (cut) { + onShapeCut(shape); } else { onShapeAdd(shape); } @@ -169,8 +216,7 @@ function MapFog({ holes: [], }, strokeWidth: 0.5, - color: toolSettings.useFogSubtract ? "red" : "black", - blend: false, + color: toolSettings.useFogCut ? "red" : "black", id: shortid.generate(), visible: true, }; @@ -216,14 +262,14 @@ function MapFog({ }); const finishDrawingPolygon = useCallback(() => { - const subtract = toolSettings.useFogSubtract; + const cut = toolSettings.useFogCut; const data = { ...drawingShape.data, // Remove the last point as it hasn't been placed yet points: drawingShape.data.points.slice(0, -1), }; - if (subtract) { - onShapeSubtract({ + if (cut) { + onShapeCut({ id: drawingShape.id, type: drawingShape.type, data: data, @@ -233,7 +279,7 @@ function MapFog({ } setDrawingShape(null); - }, [toolSettings, drawingShape, onShapeSubtract, onShapeAdd]); + }, [toolSettings, drawingShape, onShapeCut, onShapeAdd]); // Add keyboard shortcuts function handleKeyDown({ key }) { @@ -243,30 +289,22 @@ function MapFog({ if (key === "Escape" && drawingShape) { setDrawingShape(null); } - if (key === "Alt" && drawingShape) { - updateShapeColor(); - } } - function handleKeyUp({ key }) { - if (key === "Alt" && drawingShape) { - updateShapeColor(); - } - } + useKeyboard(handleKeyDown); - function updateShapeColor() { + // Update shape color when useFogCut changes + useEffect(() => { setDrawingShape((prevShape) => { if (!prevShape) { return; } return { ...prevShape, - color: toolSettings.useFogSubtract ? "black" : "red", + color: toolSettings.useFogCut ? "red" : "black", }; }); - } - - useKeyboard(handleKeyDown, handleKeyUp); + }, [toolSettings.useFogCut]); function eraseHoveredShapes() { // Erase @@ -323,14 +361,16 @@ function MapFog({ mapWidth, mapHeight )} - visible={(active && !toolSettings.preview) || shape.visible} - opacity={transparent ? 0.5 : 1} + opacity={editable ? 0.5 : 1} fillPatternImage={patternImage} fillPriority={active && !shape.visible ? "pattern" : "color"} holes={holes} // Disable collision if the fog is transparent and we're not editing it // This allows tokens to be moved under the fog - hitFunc={transparent && !active ? () => {} : undefined} + hitFunc={editable && !active ? () => {} : undefined} + shadowColor={editable ? "rgba(0, 0, 0, 0)" : "rgba(0, 0, 0, 0.33)"} + shadowOffset={{ x: 0, y: 5 }} + shadowBlur={10} /> ); } @@ -366,9 +406,51 @@ function MapFog({ ); } + const [fogShapes, setFogShapes] = useState(shapes); + useEffect(() => { + function shapeVisible(shape) { + return (active && !toolSettings.preview) || shape.visible; + } + + if (editable) { + setFogShapes(shapes.filter(shapeVisible)); + } else { + setFogShapes(mergeShapes(shapes)); + } + }, [shapes, editable, active, toolSettings]); + + const fogGroupRef = useRef(); + const debouncedStageScale = useDebounce(stageScale, 50); + + useEffect(() => { + const fogGroup = fogGroupRef.current; + + const canvas = fogGroup.getChildren()[0].getCanvas(); + const pixelRatio = canvas.pixelRatio || 1; + + // Constrain fog buffer to the map resolution + const fogRect = fogGroup.getClientRect(); + const maxMapSize = map ? Math.max(map.width, map.height) : 4096; // Default to 4096 + const maxFogSize = + Math.max(fogRect.width, fogRect.height) / debouncedStageScale; + const maxPixelRatio = maxMapSize / maxFogSize; + + fogGroup.cache({ + pixelRatio: Math.min( + Math.max(debouncedStageScale * pixelRatio, 1), + maxPixelRatio + ), + }); + fogGroup.getLayer().draw(); + }, [fogShapes, editable, active, debouncedStageScale, mapWidth, map]); + return ( - {shapes.map(renderShape)} + + {/* Render a blank shape so cache works with no fog shapes */} + + {fogShapes.map(renderShape)} + {drawingShape && renderShape(drawingShape)} {drawingShape && toolSettings && diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js index b750f6d..8f89ecc 100644 --- a/src/components/map/MapInteraction.js +++ b/src/components/map/MapInteraction.js @@ -21,6 +21,7 @@ import KeyboardContext from "../../contexts/KeyboardContext"; function MapInteraction({ map, + mapState, children, controls, selectedToolId, @@ -32,12 +33,17 @@ function MapInteraction({ // Map loaded taking in to account different resolutions const [mapLoaded, setMapLoaded] = useState(false); useEffect(() => { - if (map === null) { + if ( + !map || + !mapState || + (map.type === "file" && !map.file && !map.resolutions) || + mapState.mapId !== map.id + ) { setMapLoaded(false); } else if (mapImageSourceStatus === "loaded") { setMapLoaded(true); } - }, [mapImageSourceStatus, map]); + }, [mapImageSourceStatus, map, mapState]); const [stageWidth, setStageWidth] = useState(1); const [stageHeight, setStageHeight] = useState(1); @@ -51,8 +57,10 @@ function MapInteraction({ const mapImageRef = useRef(); function handleResize(width, height) { - setStageWidth(width); - setStageHeight(height); + if (width > 0 && height > 0) { + setStageWidth(width); + setStageHeight(height); + } } const containerRef = useRef(); @@ -135,6 +143,9 @@ function MapInteraction({ if (event.key === "q" && !disabledControls.includes("pointer")) { onSelectedToolChange("pointer"); } + if (event.key === "n" && !disabledControls.includes("note")) { + onSelectedToolChange("note"); + } } function handleKeyUp(event) { @@ -153,8 +164,12 @@ function MapInteraction({ return "move"; case "fog": case "drawing": + return settings.settings[tool].type === "move" + ? "pointer" + : "crosshair"; case "measure": case "pointer": + case "note": return "crosshair"; default: return "default"; diff --git a/src/components/map/MapLoadingOverlay.js b/src/components/map/MapLoadingOverlay.js index d7d2c9c..6c63620 100644 --- a/src/components/map/MapLoadingOverlay.js +++ b/src/components/map/MapLoadingOverlay.js @@ -38,10 +38,10 @@ function MapLoadingOverlay() { display: "flex", justifyContent: "center", alignItems: "center", - left: "8px", - bottom: "8px", + top: 0, + left: 0, + right: 0, flexDirection: "column", - borderRadius: "28px", zIndex: 2, }} bg="overlay" @@ -50,8 +50,9 @@ function MapLoadingOverlay() { ref={progressBarRef} max={1} value={0} - m={2} - sx={{ width: "32px" }} + m={0} + sx={{ width: "100%", borderRadius: 0, height: "4px" }} + color="primary" /> ) diff --git a/src/components/map/MapMeasure.js b/src/components/map/MapMeasure.js index c3544a7..54ef535 100644 --- a/src/components/map/MapMeasure.js +++ b/src/components/map/MapMeasure.js @@ -21,11 +21,26 @@ function MapMeasure({ map, selectedToolSettings, active, gridSize }) { const [drawingShapeData, setDrawingShapeData] = useState(null); const [isBrushDown, setIsBrushDown] = useState(false); - const toolScale = - active && selectedToolSettings.scale.match(/(\d*)([a-zA-Z]*)/); - const toolMultiplier = - active && !isNaN(parseInt(toolScale[1])) ? parseInt(toolScale[1]) : 1; - const toolUnit = active && toolScale[2]; + function parseToolScale(scale) { + if (typeof scale === "string") { + const match = scale.match(/(\d*)(\.\d*)?([a-zA-Z]*)/); + const integer = parseFloat(match[1]); + const fractional = parseFloat(match[2]); + const unit = match[3] || ""; + if (!isNaN(integer) && !isNaN(fractional)) { + return { + multiplier: integer + fractional, + unit: unit, + digits: match[2].length - 1, + }; + } else if (!isNaN(integer) && isNaN(fractional)) { + return { multiplier: integer, unit: unit, digits: 0 }; + } + } + return { multiplier: 1, unit: "", digits: 0 }; + } + + const measureScale = parseToolScale(active && selectedToolSettings.scale); useEffect(() => { if (!active) { @@ -38,8 +53,8 @@ function MapMeasure({ map, selectedToolSettings, active, gridSize }) { return getBrushPositionForTool( map, getRelativePointerPositionNormalized(mapImage), - "drawing", - { type: "line" }, + map.snapToGrid, + false, gridSize, [] ); @@ -62,9 +77,11 @@ function MapMeasure({ map, selectedToolSettings, active, gridSize }) { brushPosition, gridSize ); + // Round the grid positions to the nearest 0.1 to aviod floating point issues + const precision = { x: 0.1, y: 0.1 }; const length = Vector2.distance( - Vector2.divide(points[0], gridSize), - Vector2.divide(points[1], gridSize), + Vector2.roundTo(Vector2.divide(points[0], gridSize), precision), + Vector2.roundTo(Vector2.divide(points[1], gridSize), precision), selectedToolSettings.type ); setDrawingShapeData({ @@ -125,9 +142,9 @@ function MapMeasure({ map, selectedToolSettings, active, gridSize }) { > { // Close modal if interacting with any other element - function handlePointerDown(event) { + function handleInteraction(event) { const path = event.composedPath(); if ( !path.includes(modalContentNode) && - !(excludeNode && path.includes(excludeNode)) + !(excludeNode && path.includes(excludeNode)) && + !(event.target instanceof HTMLTextAreaElement) ) { onRequestClose(); - document.body.removeEventListener("pointerdown", handlePointerDown); + document.body.removeEventListener("pointerdown", handleInteraction); + document.body.removeEventListener("wheel", handleInteraction); } } if (modalContentNode) { - document.body.addEventListener("pointerdown", handlePointerDown); + document.body.addEventListener("pointerdown", handleInteraction); // Check for wheel event to close modal as well - document.body.addEventListener( - "wheel", - () => { - onRequestClose(); - }, - { once: true } - ); + document.body.addEventListener("wheel", handleInteraction); } return () => { if (modalContentNode) { - document.body.removeEventListener("pointerdown", handlePointerDown); + document.body.removeEventListener("pointerdown", handleInteraction); } }; }, [modalContentNode, excludeNode, onRequestClose]); diff --git a/src/components/map/MapNotes.js b/src/components/map/MapNotes.js new file mode 100644 index 0000000..1f31893 --- /dev/null +++ b/src/components/map/MapNotes.js @@ -0,0 +1,126 @@ +import React, { useContext, useState, useEffect, useRef } from "react"; +import shortid from "shortid"; +import { Group } from "react-konva"; + +import MapInteractionContext from "../../contexts/MapInteractionContext"; +import MapStageContext from "../../contexts/MapStageContext"; +import AuthContext from "../../contexts/AuthContext"; + +import { getBrushPositionForTool } from "../../helpers/drawing"; +import { getRelativePointerPositionNormalized } from "../../helpers/konva"; + +import Note from "../note/Note"; + +const defaultNoteSize = 2; + +function MapNotes({ + map, + active, + gridSize, + onNoteAdd, + onNoteChange, + notes, + onNoteMenuOpen, + draggable, + onNoteDragStart, + onNoteDragEnd, +}) { + const { interactionEmitter } = useContext(MapInteractionContext); + const { userId } = useContext(AuthContext); + const mapStageRef = useContext(MapStageContext); + const [isBrushDown, setIsBrushDown] = useState(false); + const [noteData, setNoteData] = useState(null); + + const creatingNoteRef = useRef(); + + useEffect(() => { + if (!active) { + return; + } + const mapStage = mapStageRef.current; + + function getBrushPosition() { + const mapImage = mapStage.findOne("#mapImage"); + return getBrushPositionForTool( + map, + getRelativePointerPositionNormalized(mapImage), + map.snapToGrid, + false, + gridSize, + [] + ); + } + + function handleBrushDown() { + const brushPosition = getBrushPosition(); + setNoteData({ + x: brushPosition.x, + y: brushPosition.y, + size: defaultNoteSize, + text: "", + id: shortid.generate(), + lastModified: Date.now(), + lastModifiedBy: userId, + visible: true, + locked: false, + color: "yellow", + }); + setIsBrushDown(true); + } + + function handleBrushMove() { + if (noteData) { + const brushPosition = getBrushPosition(); + setNoteData((prev) => ({ + ...prev, + x: brushPosition.x, + y: brushPosition.y, + })); + setIsBrushDown(true); + } + } + + function handleBrushUp() { + if (noteData) { + onNoteAdd(noteData); + onNoteMenuOpen(noteData.id, creatingNoteRef.current); + } + setNoteData(null); + setIsBrushDown(false); + } + + interactionEmitter.on("dragStart", handleBrushDown); + interactionEmitter.on("drag", handleBrushMove); + interactionEmitter.on("dragEnd", handleBrushUp); + + return () => { + interactionEmitter.off("dragStart", handleBrushDown); + interactionEmitter.off("drag", handleBrushMove); + interactionEmitter.off("dragEnd", handleBrushUp); + }; + }); + + return ( + + {notes.map((note) => ( + + ))} + + {isBrushDown && noteData && ( + + )} + + + ); +} + +export default MapNotes; diff --git a/src/components/map/MapSettings.js b/src/components/map/MapSettings.js index 3c6181b..622bd0d 100644 --- a/src/components/map/MapSettings.js +++ b/src/components/map/MapSettings.js @@ -233,6 +233,16 @@ function MapSettings({ /> Tokens + diff --git a/src/components/map/MapTile.js b/src/components/map/MapTile.js index 4f155ce..ae3af7f 100644 --- a/src/components/map/MapTile.js +++ b/src/components/map/MapTile.js @@ -11,7 +11,7 @@ function MapTile({ onMapSelect, onMapEdit, onDone, - large, + size, canEdit, badges, }) { @@ -34,7 +34,7 @@ function MapTile({ onSelect={() => onMapSelect(map)} onEdit={() => onMapEdit(map.id)} onDoubleClick={onDone} - large={large} + size={size} canEdit={canEdit} badges={badges} editTitle="Edit Map" diff --git a/src/components/map/MapTiles.js b/src/components/map/MapTiles.js index aba9e16..296ec1a 100644 --- a/src/components/map/MapTiles.js +++ b/src/components/map/MapTiles.js @@ -1,7 +1,6 @@ import React, { useContext } from "react"; import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui"; import SimpleBar from "simplebar-react"; -import { useMedia } from "react-media"; import Case from "case"; import RemoveMapIcon from "../../icons/RemoveMapIcon"; @@ -14,6 +13,8 @@ import FilterBar from "../FilterBar"; import DatabaseContext from "../../contexts/DatabaseContext"; +import useResponsiveLayout from "../../helpers/useResponsiveLayout"; + function MapTiles({ maps, groups, @@ -32,14 +33,15 @@ function MapTiles({ onMapsGroup, }) { const { databaseStatus } = useContext(DatabaseContext); - const isSmallScreen = useMedia({ query: "(max-width: 500px)" }); + const layout = useResponsiveLayout(); let hasMapState = false; for (let state of selectedMapStates) { if ( Object.values(state.tokens).length > 0 || state.mapDrawActions.length > 0 || - state.fogDrawActions.length > 0 + state.fogDrawActions.length > 0 || + Object.values(state.notes).length > 0 ) { hasMapState = true; break; @@ -60,7 +62,7 @@ function MapTiles({ onMapSelect={onMapSelect} onMapEdit={onMapEdit} onDone={onDone} - large={isSmallScreen} + size={layout.tileSize} canEdit={ isSelected && selectMode === "single" && selectedMaps.length === 1 } @@ -82,15 +84,18 @@ function MapTiles({ onAdd={onMapAdd} addTitle="Add Map" /> - + onMapSelect()} @@ -113,6 +118,7 @@ function MapTiles({ left: 0, right: 0, textAlign: "center", + borderRadius: "2px", }} bg="highlight" p={1} diff --git a/src/components/map/MapToken.js b/src/components/map/MapToken.js index 585807e..995edf0 100644 --- a/src/components/map/MapToken.js +++ b/src/components/map/MapToken.js @@ -7,7 +7,7 @@ import Konva from "konva"; import useDataSource from "../../helpers/useDataSource"; import useDebounce from "../../helpers/useDebounce"; import usePrevious from "../../helpers/usePrevious"; -import * as Vector2 from "../../helpers/vector2"; +import { snapNodeToMap } from "../../helpers/map"; import AuthContext from "../../contexts/AuthContext"; import MapInteractionContext from "../../contexts/MapInteractionContext"; @@ -17,9 +17,6 @@ import TokenLabel from "../token/TokenLabel"; import { tokenSources, unknownSource } from "../../tokens"; -// Enable hit detection on drag to allow for vehicle tokens -Konva.hitOnDragEnabled = true; - const snappingThreshold = 1 / 7; function MapToken({ @@ -58,6 +55,9 @@ function MapToken({ const tokenImage = imageRef.current; if (token && token.category === "vehicle") { + // Enable hit detection for .intersects() function + Konva.hitOnDragEnabled = true; + // Find all other tokens on the map const layer = tokenGroup.getLayer(); const tokens = layer.find(".character"); @@ -86,35 +86,7 @@ function MapToken({ const tokenGroup = event.target; // Snap to corners of grid if (map.snapToGrid) { - const offset = Vector2.multiply(map.grid.inset.topLeft, { - x: mapWidth, - y: mapHeight, - }); - const position = { - x: tokenGroup.x() + tokenGroup.width() / 2, - y: tokenGroup.y() + tokenGroup.height() / 2, - }; - const gridSize = { - x: - (mapWidth * - (map.grid.inset.bottomRight.x - map.grid.inset.topLeft.x)) / - map.grid.size.x, - y: - (mapHeight * - (map.grid.inset.bottomRight.y - map.grid.inset.topLeft.y)) / - map.grid.size.y, - }; - // Transform into offset space, round, then transform back - const gridSnap = Vector2.add( - Vector2.roundTo(Vector2.subtract(position, offset), gridSize), - offset - ); - const gridDistance = Vector2.length(Vector2.subtract(gridSnap, position)); - const minGrid = Vector2.min(gridSize); - if (gridDistance < minGrid * snappingThreshold) { - tokenGroup.x(gridSnap.x - tokenGroup.width() / 2); - tokenGroup.y(gridSnap.y - tokenGroup.height() / 2); - } + snapNodeToMap(map, mapWidth, mapHeight, tokenGroup, snappingThreshold); } } @@ -123,6 +95,8 @@ function MapToken({ const mountChanges = {}; if (token && token.category === "vehicle") { + Konva.hitOnDragEnabled = false; + const parent = tokenGroup.getParent(); const mountedTokens = tokenGroup.find(".character"); for (let mountedToken of mountedTokens) { @@ -209,20 +183,30 @@ function MapToken({ const imageRef = useRef(); useEffect(() => { const image = imageRef.current; - if ( - image && - tokenSourceStatus === "loaded" && - tokenWidth > 0 && - tokenHeight > 0 - ) { + if (!image) { + return; + } + + const canvas = image.getCanvas(); + const pixelRatio = canvas.pixelRatio || 1; + + if (tokenSourceStatus === "loaded" && tokenWidth > 0 && tokenHeight > 0) { + const maxImageSize = token ? Math.max(token.width, token.height) : 512; // Default to 512px + const maxTokenSize = Math.max(tokenWidth, tokenHeight); + // Constrain image buffer to original image size + const maxRatio = maxImageSize / maxTokenSize; + image.cache({ - pixelRatio: debouncedStageScale * window.devicePixelRatio, + pixelRatio: Math.min( + Math.max(debouncedStageScale * pixelRatio, 1), + maxRatio + ), }); image.drawHitFromCache(); // Force redraw image.getLayer().draw(); } - }, [debouncedStageScale, tokenWidth, tokenHeight, tokenSourceStatus]); + }, [debouncedStageScale, tokenWidth, tokenHeight, tokenSourceStatus, token]); // Animate to new token positions if edited by others const tokenX = tokenState.x * mapWidth; diff --git a/src/components/map/controls/EdgeSnappingToggle.js b/src/components/map/controls/EdgeSnappingToggle.js index f04bedf..44f7a10 100644 --- a/src/components/map/controls/EdgeSnappingToggle.js +++ b/src/components/map/controls/EdgeSnappingToggle.js @@ -4,7 +4,11 @@ import { IconButton } from "theme-ui"; import SnappingOnIcon from "../../../icons/SnappingOnIcon"; import SnappingOffIcon from "../../../icons/SnappingOffIcon"; -function EdgeSnappingToggle({ useEdgeSnapping, onEdgeSnappingChange }) { +function EdgeSnappingToggle({ + useEdgeSnapping, + onEdgeSnappingChange, + disabled, +}) { return ( onEdgeSnappingChange(!useEdgeSnapping)} + disabled={disabled} > {useEdgeSnapping ? : } diff --git a/src/components/map/controls/FogCutToggle.js b/src/components/map/controls/FogCutToggle.js new file mode 100644 index 0000000..d401e59 --- /dev/null +++ b/src/components/map/controls/FogCutToggle.js @@ -0,0 +1,22 @@ +import React from "react"; +import { IconButton } from "theme-ui"; + +import CutOnIcon from "../../../icons/FogCutOnIcon"; +import CutOffIcon from "../../../icons/FogCutOffIcon"; + +function FogCutToggle({ useFogCut, onFogCutChange, disabled }) { + return ( + onFogCutChange(!useFogCut)} + disabled={disabled} + > + {useFogCut ? : } + + ); +} + +export default FogCutToggle; diff --git a/src/components/map/controls/FogToolSettings.js b/src/components/map/controls/FogToolSettings.js index 3ee8a28..e9b7d0f 100644 --- a/src/components/map/controls/FogToolSettings.js +++ b/src/components/map/controls/FogToolSettings.js @@ -6,13 +6,13 @@ import RadioIconButton from "../../RadioIconButton"; import EdgeSnappingToggle from "./EdgeSnappingToggle"; import FogPreviewToggle from "./FogPreviewToggle"; +import FogCutToggle from "./FogCutToggle"; import FogBrushIcon from "../../../icons/FogBrushIcon"; import FogPolygonIcon from "../../../icons/FogPolygonIcon"; import FogRemoveIcon from "../../../icons/FogRemoveIcon"; import FogToggleIcon from "../../../icons/FogToggleIcon"; -import FogAddIcon from "../../../icons/FogAddIcon"; -import FogSubtractIcon from "../../../icons/FogSubtractIcon"; +import FogRectangleIcon from "../../../icons/FogRectangleIcon"; import UndoButton from "./UndoButton"; import RedoButton from "./RedoButton"; @@ -31,19 +31,23 @@ function BrushToolSettings({ // Keyboard shortcuts function handleKeyDown({ key, ctrlKey, metaKey, shiftKey }) { if (key === "Alt") { - onSettingChange({ useFogSubtract: !settings.useFogSubtract }); + onSettingChange({ useFogCut: !settings.useFogCut }); } else if (key === "p") { onSettingChange({ type: "polygon" }); } else if (key === "b") { onSettingChange({ type: "brush" }); } else if (key === "t") { onSettingChange({ type: "toggle" }); - } else if (key === "r") { + } else if (key === "e") { onSettingChange({ type: "remove" }); } else if (key === "s") { onSettingChange({ useEdgeSnapping: !settings.useEdgeSnapping }); } else if (key === "f") { onSettingChange({ preview: !settings.preview }); + } else if (key === "c") { + onSettingChange({ useFogCut: !settings.useFogCut }); + } else if (key === "r") { + onSettingChange({ type: "rectangle" }); } else if ( (key === "z" || key === "Z") && (ctrlKey || metaKey) && @@ -63,7 +67,7 @@ function BrushToolSettings({ function handleKeyUp({ key }) { if (key === "Alt") { - onSettingChange({ useFogSubtract: !settings.useFogSubtract }); + onSettingChange({ useFogCut: !settings.useFogCut }); } } @@ -76,27 +80,21 @@ function BrushToolSettings({ title: "Fog Polygon (P)", isSelected: settings.type === "polygon", icon: , + disabled: settings.preview, + }, + { + id: "rectangle", + title: "Fog Rectangle (R)", + isSelected: settings.type === "rectangle", + icon: , + disabled: settings.preview, }, { id: "brush", title: "Fog Brush (B)", isSelected: settings.type === "brush", icon: , - }, - ]; - - const modeTools = [ - { - id: "add", - title: "Add Fog", - isSelected: !settings.useFogSubtract, - icon: , - }, - { - id: "subtract", - title: "Subtract Fog", - isSelected: settings.useFogSubtract, - icon: , + disabled: settings.preview, }, ]; @@ -112,30 +110,30 @@ function BrushToolSettings({ title="Toggle Fog (T)" onClick={() => onSettingChange({ type: "toggle" })} isSelected={settings.type === "toggle"} + disabled={settings.preview} > onSettingChange({ type: "remove" })} isSelected={settings.type === "remove"} + disabled={settings.preview} > - - onSettingChange({ useFogSubtract: tool.id === "subtract" }) - } - collapse={isSmallScreen} + onSettingChange({ useFogCut })} + disabled={settings.preview} /> - onSettingChange({ useEdgeSnapping }) } + disabled={settings.preview} /> , }, + { + id: "alternating", + title: "Alternating Diagonal Distance (A)", + isSelected: settings.type === "alternating", + icon: , + }, { id: "euclidean", title: "Line Distance (L)", diff --git a/src/components/map/controls/ToolSection.js b/src/components/map/controls/ToolSection.js index a6baeb7..25b37a4 100644 --- a/src/components/map/controls/ToolSection.js +++ b/src/components/map/controls/ToolSection.js @@ -36,6 +36,7 @@ function ToolSection({ collapse, tools, onToolClick }) { onClick={() => handleToolClick(tool)} key={tool.id} isSelected={tool.isSelected} + disabled={tool.disabled} > {tool.icon} @@ -90,6 +91,7 @@ function ToolSection({ collapse, tools, onToolClick }) { onClick={() => handleToolClick(tool)} key={tool.id} isSelected={tool.isSelected} + disabled={tool.disabled} > {tool.icon} diff --git a/src/components/note/Note.js b/src/components/note/Note.js new file mode 100644 index 0000000..c52e202 --- /dev/null +++ b/src/components/note/Note.js @@ -0,0 +1,195 @@ +import React, { useContext, useEffect, useState, useRef } from "react"; +import { Rect, Text } from "react-konva"; +import { useSpring, animated } from "react-spring/konva"; + +import AuthContext from "../../contexts/AuthContext"; +import MapInteractionContext from "../../contexts/MapInteractionContext"; + +import { snapNodeToMap } from "../../helpers/map"; +import colors from "../../helpers/colors"; +import usePrevious from "../../helpers/usePrevious"; + +const snappingThreshold = 1 / 5; + +function Note({ + note, + map, + onNoteChange, + onNoteMenuOpen, + draggable, + onNoteDragStart, + onNoteDragEnd, +}) { + const { userId } = useContext(AuthContext); + const { mapWidth, mapHeight, setPreventMapInteraction } = useContext( + MapInteractionContext + ); + + const noteWidth = map && (mapWidth / map.grid.size.x) * note.size; + const noteHeight = noteWidth; + const notePadding = noteWidth / 10; + + function handleDragStart(event) { + onNoteDragStart && onNoteDragStart(event, note.id); + } + + function handleDragMove(event) { + const noteGroup = event.target; + // Snap to corners of grid + if (map.snapToGrid) { + snapNodeToMap(map, mapWidth, mapHeight, noteGroup, snappingThreshold); + } + } + + function handleDragEnd(event) { + const noteGroup = event.target; + onNoteChange && + onNoteChange({ + ...note, + x: noteGroup.x() / mapWidth, + y: noteGroup.y() / mapHeight, + lastModifiedBy: userId, + lastModified: Date.now(), + }); + onNoteDragEnd && onNoteDragEnd(note.id); + setPreventMapInteraction(false); + } + + function handleClick(event) { + if (draggable) { + const noteNode = event.target; + onNoteMenuOpen && onNoteMenuOpen(note.id, noteNode); + } + } + + // Store note pointer down time to check for a click when note is locked + const notePointerDownTimeRef = useRef(); + function handlePointerDown(event) { + if (draggable) { + setPreventMapInteraction(true); + } + if (note.locked && map.owner === userId) { + notePointerDownTimeRef.current = event.evt.timeStamp; + } + } + + function handlePointerUp(event) { + if (draggable) { + setPreventMapInteraction(false); + } + // Check note click when locked and we are the map owner + // We can't use onClick because that doesn't check pointer distance + if (note.locked && map.owner === userId) { + // If down and up time is small trigger a click + const delta = event.evt.timeStamp - notePointerDownTimeRef.current; + if (delta < 300) { + const noteNode = event.target; + onNoteMenuOpen(note.id, noteNode); + } + } + } + + const [fontSize, setFontSize] = useState(1); + useEffect(() => { + const text = textRef.current; + + if (!text) { + return; + } + + function findFontSize() { + // Create an array from 1 / 10 of the note height to the full note height + const sizes = Array.from( + { length: Math.ceil(noteHeight - notePadding * 2) }, + (_, i) => i + Math.ceil(noteHeight / 10) + ); + + if (sizes.length > 0) { + const size = sizes.reduce((prev, curr) => { + text.fontSize(curr); + const width = text.getTextWidth() + notePadding * 2; + const height = text.height() + notePadding * 2; + if (width < noteWidth && height < noteHeight) { + return curr; + } else { + return prev; + } + }); + + setFontSize(size); + } + } + + findFontSize(); + }, [note, note.text, noteWidth, noteHeight, notePadding]); + + const textRef = useRef(); + + // Animate to new note positions if edited by others + const noteX = note.x * mapWidth; + const noteY = note.y * mapHeight; + const previousWidth = usePrevious(mapWidth); + const previousHeight = usePrevious(mapHeight); + const resized = mapWidth !== previousWidth || mapHeight !== previousHeight; + const skipAnimation = note.lastModifiedBy === userId || resized; + const props = useSpring({ + x: noteX, + y: noteY, + immediate: skipAnimation, + }); + + // When a note is hidden if you aren't the map owner hide it completely + if (map && !note.visible && map.owner !== userId) { + return null; + } + + return ( + + + + {/* Use an invisible text block to work out text sizing */} + + + ); +} + +export default Note; diff --git a/src/components/note/NoteDragOverlay.js b/src/components/note/NoteDragOverlay.js new file mode 100644 index 0000000..9df0713 --- /dev/null +++ b/src/components/note/NoteDragOverlay.js @@ -0,0 +1,19 @@ +import React from "react"; + +import DragOverlay from "../DragOverlay"; + +function NoteDragOverlay({ onNoteRemove, noteId, noteGroup, dragging }) { + function handleNoteRemove() { + onNoteRemove(noteId); + } + + return ( + + ); +} + +export default NoteDragOverlay; diff --git a/src/components/note/NoteMenu.js b/src/components/note/NoteMenu.js new file mode 100644 index 0000000..cc0379e --- /dev/null +++ b/src/components/note/NoteMenu.js @@ -0,0 +1,219 @@ +import React, { useEffect, useState, useContext } from "react"; +import { Box, Flex, Text, IconButton, Textarea } from "theme-ui"; + +import Slider from "../Slider"; + +import MapMenu from "../map/MapMenu"; + +import colors, { colorOptions } from "../../helpers/colors"; + +import usePrevious from "../../helpers/usePrevious"; + +import LockIcon from "../../icons/TokenLockIcon"; +import UnlockIcon from "../../icons/TokenUnlockIcon"; +import ShowIcon from "../../icons/TokenShowIcon"; +import HideIcon from "../../icons/TokenHideIcon"; + +import AuthContext from "../../contexts/AuthContext"; + +const defaultNoteMaxSize = 6; + +function NoteMenu({ + isOpen, + onRequestClose, + note, + noteNode, + onNoteChange, + map, +}) { + const { userId } = useContext(AuthContext); + + const wasOpen = usePrevious(isOpen); + + const [noteMaxSize, setNoteMaxSize] = useState(defaultNoteMaxSize); + const [menuLeft, setMenuLeft] = useState(0); + const [menuTop, setMenuTop] = useState(0); + useEffect(() => { + if (isOpen && !wasOpen && note) { + setNoteMaxSize(Math.max(note.size, defaultNoteMaxSize)); + // Update menu position + if (noteNode) { + const nodeRect = noteNode.getClientRect(); + const mapElement = document.querySelector(".map"); + const mapRect = mapElement.getBoundingClientRect(); + + // Center X for the menu which is 156px wide + setMenuLeft(mapRect.left + nodeRect.x + nodeRect.width / 2 - 156 / 2); + // Y 12px from the bottom + setMenuTop(mapRect.top + nodeRect.y + nodeRect.height + 12); + } + } + }, [isOpen, note, wasOpen, noteNode]); + + function handleTextChange(event) { + const text = event.target.value.substring(0, 144); + note && onNoteChange({ ...note, text: text }); + } + + function handleColorChange(color) { + if (!note) { + return; + } + onNoteChange({ ...note, color: color }); + } + + function handleSizeChange(event) { + const newSize = parseFloat(event.target.value); + note && onNoteChange({ ...note, size: newSize }); + } + + function handleVisibleChange() { + note && onNoteChange({ ...note, visible: !note.visible }); + } + + function handleLockChange() { + note && onNoteChange({ ...note, locked: !note.locked }); + } + + function handleModalContent(node) { + if (node) { + // Focus input + const tokenLabelInput = node.querySelector("#changeNoteText"); + tokenLabelInput.focus(); + tokenLabelInput.select(); + + // Ensure menu is in bounds + const nodeRect = node.getBoundingClientRect(); + const mapElement = document.querySelector(".map"); + 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) + ); + } + } + + function handleTextKeyPress(e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + onRequestClose(); + } + } + + return ( + + + { + e.preventDefault(); + onRequestClose(); + }} + sx={{ alignItems: "center" }} + > +