Added note menu and note text

This commit is contained in:
Mitchell McCaffrey 2020-11-04 15:03:34 +11:00
parent 927c596b04
commit a3ae3471e8
5 changed files with 411 additions and 40 deletions

View File

@ -19,6 +19,7 @@ import TokenMenu from "../token/TokenMenu";
import TokenDragOverlay from "../token/TokenDragOverlay"; import TokenDragOverlay from "../token/TokenDragOverlay";
import { drawActionsToShapes } from "../../helpers/drawing"; import { drawActionsToShapes } from "../../helpers/drawing";
import MapNoteMenu from "./MapNoteMenu";
function Map({ function Map({
map, map,
@ -33,7 +34,7 @@ function Map({
onFogDraw, onFogDraw,
onFogDrawUndo, onFogDrawUndo,
onFogDrawRedo, onFogDrawRedo,
onMapNoteAdd, onMapNoteChange,
allowMapDrawing, allowMapDrawing,
allowFogDrawing, allowFogDrawing,
allowMapChange, allowMapChange,
@ -351,15 +352,35 @@ function Map({
/> />
); );
const [isNoteMenuOpen, setIsNoteMenuOpen] = useState(false);
const [noteMenuOptions, setNoteMenuOptions] = useState({});
function handleNoteMenuOpen(noteId, noteNode) {
setNoteMenuOptions({ noteId, noteNode });
setIsNoteMenuOpen(true);
}
const mapNotes = ( const mapNotes = (
<MapNotes <MapNotes
map={map} map={map}
active={selectedToolId === "note"} active={selectedToolId === "note"}
gridSize={gridSizeNormalized} gridSize={gridSizeNormalized}
selectedToolSettings={settings[selectedToolId]} selectedToolSettings={settings[selectedToolId]}
onNoteAdd={onMapNoteAdd} onNoteAdd={onMapNoteChange}
onNoteChange={onMapNoteChange}
// TODO: Sort by last modified // TODO: Sort by last modified
notes={mapState ? Object.values(mapState.notes) : []} notes={mapState ? Object.values(mapState.notes) : []}
onNoteMenuOpen={handleNoteMenuOpen}
/>
);
const noteMenu = (
<MapNoteMenu
isOpen={isNoteMenuOpen}
onRequestClose={() => setIsNoteMenuOpen(false)}
onNoteChange={onMapNoteChange}
note={mapState && mapState.notes[noteMenuOptions.noteId]}
noteNode={noteMenuOptions.noteNode}
map={map}
/> />
); );
@ -370,6 +391,7 @@ function Map({
<> <>
{mapControls} {mapControls}
{tokenMenu} {tokenMenu}
{noteMenu}
{tokenDragOverlay} {tokenDragOverlay}
<MapLoadingOverlay /> <MapLoadingOverlay />
</> </>

View File

@ -1,29 +1,139 @@
import React, { useContext } from "react"; import React, { useContext, useEffect, useState, useRef } from "react";
import { Group, Rect } from "react-konva"; import { Group, Rect, Text } from "react-konva";
import AuthContext from "../../contexts/AuthContext";
import MapInteractionContext from "../../contexts/MapInteractionContext"; import MapInteractionContext from "../../contexts/MapInteractionContext";
function MapNote({ note, map }) { import * as Vector2 from "../../helpers/vector2";
import colors from "../../helpers/colors";
const snappingThreshold = 1 / 5;
const textPadding = 4;
function MapNote({ note, map, onNoteChange, onNoteMenuOpen, draggable }) {
const { userId } = useContext(AuthContext);
const { mapWidth, mapHeight } = useContext(MapInteractionContext); const { mapWidth, mapHeight } = useContext(MapInteractionContext);
const noteWidth = map && (mapWidth / map.grid.size.x) * note.size; const noteWidth = map && (mapWidth / map.grid.size.x) * note.size;
const noteHeight = map && (mapHeight / map.grid.size.y) * note.size; const noteHeight = map && (mapHeight / map.grid.size.y) * note.size;
function handleClick(event) {
if (draggable) {
const noteNode = event.target;
onNoteMenuOpen && onNoteMenuOpen(note.id, noteNode);
}
}
function handleDragMove(event) {
const noteGroup = 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: noteGroup.x() + noteGroup.width() / 2,
y: noteGroup.y() + noteGroup.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) {
noteGroup.x(gridSnap.x - noteGroup.width() / 2);
noteGroup.y(gridSnap.y - noteGroup.height() / 2);
}
}
}
function handleDragEnd(event) {
const noteGroup = event.target;
onNoteChange &&
onNoteChange({
...note,
x: noteGroup.x() / mapWidth,
y: noteGroup.y() / mapHeight,
lastModifiedBy: userId,
lastModified: Date.now(),
});
}
const [fontSize, setFontSize] = useState(1);
useEffect(() => {
const text = textRef.current;
function findFontSize() {
// Create an array from 4 to 18 scaled to the note size
const sizes = Array.from(
{ length: 14 * note.size },
(_, i) => i + 4 * note.size
);
return sizes.reduce((prev, curr) => {
text.fontSize(curr);
const width = text.getTextWidth() + textPadding * 2;
if (width < noteWidth) {
return curr;
} else {
return prev;
}
});
}
setFontSize(findFontSize());
}, [note, noteWidth]);
const textRef = useRef();
return ( return (
<Group> <Group
onClick={handleClick}
onTap={handleClick}
x={note.x * mapWidth}
y={note.y * mapHeight}
width={noteWidth}
height={noteHeight}
offsetX={noteWidth / 2}
offsetY={noteHeight / 2}
draggable={draggable}
onDragEnd={handleDragEnd}
onDragMove={handleDragMove}
>
<Rect <Rect
x={note.x * mapWidth}
y={note.y * mapHeight}
width={noteWidth} width={noteWidth}
height={noteHeight} height={noteHeight}
offsetX={noteWidth / 2}
offsetY={noteHeight / 2}
fill="white"
shadowColor="rgba(0, 0, 0, 0.16)" shadowColor="rgba(0, 0, 0, 0.16)"
shadowOffset={{ x: 0, y: 3 }} shadowOffset={{ x: 0, y: 3 }}
shadowBlur={6} shadowBlur={6}
cornerRadius={1} cornerRadius={0.25}
fill={colors[note.color]}
/> />
<Text
text={note.text}
fill="black"
align="center"
verticalAlign="middle"
padding={textPadding}
fontSize={fontSize}
wrap="word"
width={noteWidth}
height={noteHeight}
/>
{/* Use an invisible text block to work out text sizing */}
<Text visible={false} ref={textRef} text={note.text} wrap="none" />
</Group> </Group>
); );
} }

View File

@ -0,0 +1,216 @@
import React, { useEffect, useState, useContext } from "react";
import { Box, Input, Slider, Flex, Text, IconButton } from "theme-ui";
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 MapNoteMenu({
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;
note && onNoteChange({ ...note, text: text });
}
function handleColorChange(color) {
if (!note) {
return;
}
onNoteChange({ ...note, color: color });
}
function handleSizeChange(event) {
const newSize = parseInt(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)
);
}
}
return (
<MapMenu
isOpen={isOpen}
onRequestClose={onRequestClose}
top={`${menuTop}px`}
left={`${menuLeft}px`}
onModalContent={handleModalContent}
>
<Box sx={{ width: "156px" }} p={1}>
<Flex
as="form"
onSubmit={(e) => {
e.preventDefault();
onRequestClose();
}}
sx={{ alignItems: "center" }}
>
<Text
as="label"
variant="body2"
sx={{ width: "45%", fontSize: "16px" }}
p={1}
>
Label:
</Text>
<Input
id="changeNoteText"
onChange={handleTextChange}
value={(note && note.text) || ""}
sx={{
padding: "4px",
border: "none",
":focus": {
outline: "none",
},
}}
autoComplete="off"
/>
</Flex>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
justifyContent: "space-between",
}}
>
{colorOptions.map((color) => (
<Box
key={color}
sx={{
width: "16.66%",
paddingTop: "16.66%",
borderRadius: "50%",
transform: "scale(0.75)",
backgroundColor: colors[color],
cursor: "pointer",
}}
onClick={() => handleColorChange(color)}
aria-label={`Note label Color ${color}`}
>
{note && note.color === color && (
<Box
sx={{
width: "100%",
height: "100%",
border: "2px solid white",
position: "absolute",
top: 0,
borderRadius: "50%",
}}
/>
)}
</Box>
))}
</Box>
<Flex sx={{ alignItems: "center" }}>
<Text
as="label"
variant="body2"
sx={{ width: "40%", fontSize: "16px" }}
p={1}
>
Size:
</Text>
<Slider
value={(note && note.size) || 1}
onChange={handleSizeChange}
step={1}
min={1}
max={noteMaxSize}
mr={1}
/>
</Flex>
{/* 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={note && note.visible ? "Hide Note" : "Show Note"}
aria-label={note && note.visible ? "Hide Note" : "Show Note"}
>
{note && note.visible ? <ShowIcon /> : <HideIcon />}
</IconButton>
<IconButton
onClick={handleLockChange}
title={note && note.locked ? "Unlock Note" : "Lock Note"}
aria-label={note && note.locked ? "Unlock Note" : "Lock Note"}
>
{note && note.locked ? <LockIcon /> : <UnlockIcon />}
</IconButton>
</Flex>
)}
</Box>
</MapMenu>
);
}
export default MapNoteMenu;

View File

@ -1,4 +1,4 @@
import React, { useContext, useState, useEffect } from "react"; import React, { useContext, useState, useEffect, useRef } from "react";
import shortid from "shortid"; import shortid from "shortid";
import { Group } from "react-konva"; import { Group } from "react-konva";
@ -19,7 +19,9 @@ function MapNotes({
active, active,
gridSize, gridSize,
onNoteAdd, onNoteAdd,
onNoteChange,
notes, notes,
onNoteMenuOpen,
}) { }) {
const { interactionEmitter } = useContext(MapInteractionContext); const { interactionEmitter } = useContext(MapInteractionContext);
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
@ -27,6 +29,8 @@ function MapNotes({
const [isBrushDown, setIsBrushDown] = useState(false); const [isBrushDown, setIsBrushDown] = useState(false);
const [noteData, setNoteData] = useState(null); const [noteData, setNoteData] = useState(null);
const creatingNoteRef = useRef();
useEffect(() => { useEffect(() => {
if (!active) { if (!active) {
return; return;
@ -46,33 +50,41 @@ function MapNotes({
} }
function handleBrushDown() { function handleBrushDown() {
const brushPosition = getBrushPosition(); if (selectedToolSettings.type === "add") {
setNoteData({ const brushPosition = getBrushPosition();
x: brushPosition.x, setNoteData({
y: brushPosition.y, x: brushPosition.x,
size: defaultNoteSize, y: brushPosition.y,
text: "", size: defaultNoteSize,
id: shortid.generate(), text: "",
lastModified: Date.now(), id: shortid.generate(),
lastModifiedBy: userId, lastModified: Date.now(),
visible: true, lastModifiedBy: userId,
locked: false, visible: true,
}); locked: false,
setIsBrushDown(true); color: "yellow",
});
setIsBrushDown(true);
}
} }
function handleBrushMove() { function handleBrushMove() {
const brushPosition = getBrushPosition(); if (selectedToolSettings.type === "add") {
setNoteData((prev) => ({ const brushPosition = getBrushPosition();
...prev, setNoteData((prev) => ({
x: brushPosition.x, ...prev,
y: brushPosition.y, x: brushPosition.x,
})); y: brushPosition.y,
setIsBrushDown(true); }));
setIsBrushDown(true);
}
} }
function handleBrushUp() { function handleBrushUp() {
onNoteAdd(noteData); if (selectedToolSettings.type === "add") {
onNoteAdd(noteData);
onNoteMenuOpen(noteData.id, creatingNoteRef.current);
}
setNoteData(null); setNoteData(null);
setIsBrushDown(false); setIsBrushDown(false);
} }
@ -91,9 +103,20 @@ function MapNotes({
return ( return (
<Group> <Group>
{notes.map((note) => ( {notes.map((note) => (
<MapNote note={note} map={map} key={note.id} /> <MapNote
note={note}
map={map}
key={note.id}
onNoteMenuOpen={onNoteMenuOpen}
draggable={active && selectedToolSettings.type === "move"}
onNoteChange={onNoteChange}
/>
))} ))}
{isBrushDown && noteData && <MapNote note={noteData} map={map} />} <Group ref={creatingNoteRef}>
{isBrushDown && noteData && (
<MapNote note={noteData} map={map} draggable={false} />
)}
</Group>
</Group> </Group>
); );
} }

View File

@ -167,7 +167,7 @@ function NetworkedMapAndTokens({ session }) {
session.send("mapFogIndex", index); session.send("mapFogIndex", index);
} }
function handleNoteAdd(note) { function handleNoteChange(note) {
setCurrentMapState((prevMapState) => ({ setCurrentMapState((prevMapState) => ({
...prevMapState, ...prevMapState,
notes: { notes: {
@ -175,7 +175,7 @@ function NetworkedMapAndTokens({ session }) {
[note.id]: note, [note.id]: note,
}, },
})); }));
session.send("mapNoteAdd", note); session.send("mapNoteChange", note);
} }
/** /**
@ -406,7 +406,7 @@ function NetworkedMapAndTokens({ session }) {
fogDrawActionIndex: data, fogDrawActionIndex: data,
})); }));
} }
if (id === "mapNoteAdd" && currentMapState) { if (id === "mapNoteChange" && currentMapState) {
setCurrentMapState((prevMapState) => ({ setCurrentMapState((prevMapState) => ({
...prevMapState, ...prevMapState,
notes: { notes: {
@ -480,7 +480,7 @@ function NetworkedMapAndTokens({ session }) {
onFogDraw={handleFogDraw} onFogDraw={handleFogDraw}
onFogDrawUndo={handleFogDrawUndo} onFogDrawUndo={handleFogDrawUndo}
onFogDrawRedo={handleFogDrawRedo} onFogDrawRedo={handleFogDrawRedo}
onMapNoteAdd={handleNoteAdd} onMapNoteChange={handleNoteChange}
allowMapDrawing={canEditMapDrawing} allowMapDrawing={canEditMapDrawing}
allowFogDrawing={canEditFogDrawing} allowFogDrawing={canEditFogDrawing}
allowMapChange={canChangeMap} allowMapChange={canChangeMap}