From 587c0c00499b711d1958040bfd0ef69145d5bdfe Mon Sep 17 00:00:00 2001
From: Jeremie Pardou-Piquemal <571533+jrmi@users.noreply.github.com>
Date: Sat, 13 Jun 2020 16:40:18 +0200
Subject: [PATCH] Add action pane to handle all user interactions
---
package-lock.json | 5 ++
package.json | 1 +
src/App.css | 3 -
src/components/ActionPane.js | 90 +++++++++++++++++++++
src/components/Board.js | 55 ++++---------
src/components/CursorPane.js | 36 +++++++++
src/components/GameLoader.js | 13 +--
src/components/Item.js | 152 ++++++++++++++++++++---------------
src/components/Items.js | 8 +-
src/components/Selector.js | 26 ++----
src/components/UserConfig.js | 4 +-
src/utils/index.js | 29 +++++++
12 files changed, 287 insertions(+), 135 deletions(-)
create mode 100644 src/components/ActionPane.js
create mode 100644 src/components/CursorPane.js
create mode 100644 src/utils/index.js
diff --git a/package-lock.json b/package-lock.json
index 32aedb0..64843c1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8508,6 +8508,11 @@
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
},
+ "lodash.findlast": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/lodash.findlast/-/lodash.findlast-4.6.0.tgz",
+ "integrity": "sha1-6ou3jPLn54BPyK630ZU+B/4x+8g="
+ },
"lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
diff --git a/package.json b/package.json
index a3eda12..e3fa8e4 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"client2client.io": "^1.2.1",
"cors": "^2.8.5",
"lodash.debounce": "^4.0.8",
+ "lodash.findlast": "^4.6.0",
"memoizee": "^0.4.14",
"nanoid": "^3.1.9",
"randomcolor": "^0.5.4",
diff --git a/src/App.css b/src/App.css
index 9ec8e7f..0c8b99f 100644
--- a/src/App.css
+++ b/src/App.css
@@ -1,6 +1,3 @@
-.App {
-}
-
.board {
display: fixed;
top: 0;
diff --git a/src/components/ActionPane.js b/src/components/ActionPane.js
new file mode 100644
index 0000000..c2e45d6
--- /dev/null
+++ b/src/components/ActionPane.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import { atom, useRecoilValue } from 'recoil';
+import { PanZoomRotateState } from '../components/PanZoomRotate';
+import { selectedItemsAtom } from '../components/Selector';
+import { ItemListAtom } from '../components/Items';
+import { useRecoilState } from 'recoil';
+import findlast from 'lodash.findlast';
+import { insideClass, isPointInsideRect, isPointInsideItem } from '../utils';
+
+const ActionPane = ({ children }) => {
+ const panZoomRotate = useRecoilValue(PanZoomRotateState);
+ const [itemList, setItemList] = useRecoilState(ItemListAtom);
+ const [selectedItems, setSelectedItems] = useRecoilState(selectedItemsAtom);
+ const wrapperRef = React.useRef(null);
+ const actionRef = React.useRef({});
+
+ const onMouseDown = (e) => {
+ if (e.button === 0) {
+ const { top, left } = e.currentTarget.getBoundingClientRect();
+ const point = {
+ x: (e.clientX - left) / panZoomRotate.scale,
+ y: (e.clientY - top) / panZoomRotate.scale,
+ };
+ const foundItem = findlast(itemList, (item) => {
+ return !item.locked && isPointInsideItem(point, item);
+ });
+ if (foundItem) {
+ actionRef.current.moving = true;
+ actionRef.current.prevX = point.x;
+ actionRef.current.prevY = point.y;
+ actionRef.current.item = foundItem;
+ actionRef.current.moving = true;
+ wrapperRef.current.style.cursor = 'move';
+ e.stopPropagation();
+ }
+ }
+ };
+
+ const moveItem = React.useCallback(
+ (itemId, posDelta) => {
+ setItemList((prevList) => {
+ return prevList.map((item) => {
+ if (item.id === itemId) {
+ const x = item.x + posDelta.x;
+ const y = item.y + posDelta.y;
+ return { ...item, x, y };
+ }
+ return item;
+ });
+ });
+ },
+ [setItemList]
+ );
+
+ const onMouseMouve = (e) => {
+ if (actionRef.current.moving === true) {
+ const { top, left } = e.currentTarget.getBoundingClientRect();
+ const currentX = (e.clientX - left) / panZoomRotate.scale;
+ const currentY = (e.clientY - top) / panZoomRotate.scale;
+ moveItem(actionRef.current.item.id, {
+ x: currentX - actionRef.current.prevX,
+ y: currentY - actionRef.current.prevY,
+ });
+ actionRef.current.prevX = currentX;
+ actionRef.current.prevY = currentY;
+ e.preventDefault();
+ }
+ };
+
+ const onMouseUp = (e) => {
+ if (actionRef.current.moving === true) {
+ actionRef.current = { moving: false };
+ wrapperRef.current.style.cursor = 'auto';
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default ActionPane;
diff --git a/src/components/Board.js b/src/components/Board.js
index 516c04f..4b86af7 100644
--- a/src/components/Board.js
+++ b/src/components/Board.js
@@ -1,52 +1,31 @@
import React from 'react';
-import Cursors from '../components/Cursors';
import Items from './Items';
-import { useC2C } from '../hooks/useC2C';
-import { PanZoomRotateState } from '../components/PanZoomRotate';
-import { useRecoilValue } from 'recoil';
import Selector from '../components/Selector';
+import ActionPane from './ActionPane';
+import CursorPane from './CursorPane';
export const Board = ({ user, users, config }) => {
- const [c2c, joined, isMaster] = useC2C();
- const panZoomRotate = useRecoilValue(PanZoomRotateState);
-
- const onMouseMove = (e) => {
- const { top, left } = e.currentTarget.getBoundingClientRect();
- c2c.publish('cursorMove', {
- userId: user.id,
- pos: {
- x: (e.clientX - left) / panZoomRotate.scale,
- y: (e.clientY - top) / panZoomRotate.scale,
- },
- });
- };
-
- const onLeave = (e) => {
- c2c.publish('cursorOff', {
- userId: user.id,
- });
- };
-
if (!config.size) {
return Please select a gameā¦
;
}
return (
-
-
-
-
+
+
+
+
+
+
+
);
};
diff --git a/src/components/CursorPane.js b/src/components/CursorPane.js
new file mode 100644
index 0000000..f886510
--- /dev/null
+++ b/src/components/CursorPane.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import Cursors from '../components/Cursors';
+import { useC2C } from '../hooks/useC2C';
+import { PanZoomRotateState } from '../components/PanZoomRotate';
+import { useRecoilValue } from 'recoil';
+
+export const Board = ({ children, user, users }) => {
+ const [c2c] = useC2C();
+ const panZoomRotate = useRecoilValue(PanZoomRotateState);
+
+ const onMouseMove = (e) => {
+ const { top, left } = e.currentTarget.getBoundingClientRect();
+ c2c.publish('cursorMove', {
+ userId: user.id,
+ pos: {
+ x: (e.clientX - left) / panZoomRotate.scale,
+ y: (e.clientY - top) / panZoomRotate.scale,
+ },
+ });
+ };
+
+ const onLeave = (e) => {
+ c2c.publish('cursorOff', {
+ userId: user.id,
+ });
+ };
+
+ return (
+
+ {children}
+
+
+ );
+};
+
+export default Board;
diff --git a/src/components/GameLoader.js b/src/components/GameLoader.js
index 17b97c5..076f5e4 100644
--- a/src/components/GameLoader.js
+++ b/src/components/GameLoader.js
@@ -30,11 +30,14 @@ export const GameLoader = ({
React.useEffect(() => {
if (joined) {
if (!isMaster) {
- c2c.call('getGame').then((game) => {
- console.log('get this item list', game);
- setItemList(game.items);
- setBoardConfig(game.board);
- });
+ c2c.call('getGame').then(
+ (game) => {
+ console.log('get this item list', game);
+ setItemList(game.items);
+ setBoardConfig(game.board);
+ },
+ () => {}
+ );
}
}
}, [c2c, isMaster, joined, setItemList, setBoardConfig]);
diff --git a/src/components/Item.js b/src/components/Item.js
index 2720bb1..5efcc1e 100644
--- a/src/components/Item.js
+++ b/src/components/Item.js
@@ -4,6 +4,7 @@ import { useC2C } from '../hooks/useC2C';
import { PanZoomRotateState } from '../components/PanZoomRotate';
import { useRecoilValue } from 'recoil';
import { selectedItemsAtom } from './Selector';
+import { nanoid } from 'nanoid';
const Rect = ({ width, height, color }) => {
return (
@@ -54,16 +55,26 @@ const Image = ({
if (flipped && backContent) {
return (
-
+
+
+
);
}
return (
-
+
+
+
);
};
@@ -81,82 +92,60 @@ const getComponent = (type) => {
};
const Item = ({ setState, state }) => {
- const [c2c] = useC2C();
const selectedItems = useRecoilValue(selectedItemsAtom);
const itemRef = React.useRef(null);
- const itemStateRef = React.useRef({
- ...state,
- });
- itemStateRef.current = { ...state };
-
- const panZoomRotate = useRecoilValue(PanZoomRotateState);
-
- // Use this for each state update.
- const updateState = React.useCallback(
- (newState) => {
- itemStateRef.current = {
- ...itemStateRef.current,
- ...newState,
- };
- setState({
- ...itemStateRef.current,
- });
-
- c2c.publish(`itemStateUpdate.${state.id}`, {
- ...itemStateRef.current,
- });
- },
- [c2c, setState, state]
- );
-
- const onDrag = (e, data) => {
- const { deltaX, deltaY } = data;
- updateState({
- x: itemStateRef.current.x + deltaX / panZoomRotate.scale,
- y: itemStateRef.current.y + deltaY / panZoomRotate.scale,
- });
- };
-
- React.useEffect(() => {
- const { width, height } = itemRef.current.getBoundingClientRect();
- if (state.actualWidth !== width && state.actualHeight !== height) {
- setState({
- ...state,
- actualWidth: width,
- actualHeight: height,
- });
- }
- }, [setState, state]);
-
- React.useEffect(() => {
- const unsub = c2c.subscribe(
- `itemStateUpdate.${state.id}`,
- (newItemState) => {
- setState(newItemState);
- }
- );
- return unsub;
- }, [c2c, state.id, setState]);
const Component = getComponent(state.type);
const style = {};
if (selectedItems.includes(state.id)) {
- style.border = '2px dashed #000000A0';
+ style.border = '2px dashed #ff0000A0';
+ style.padding = '2px';
+ } else {
+ style.padding = '4px';
}
const rotation = state.rotation || 0;
+ const updateState = React.useCallback(
+ (modif) => {
+ setState({ ...state, ...modif });
+ },
+ [setState, state]
+ );
+
+ // Update actual size when update
+ React.useEffect(() => {
+ const currentElem = itemRef.current;
+ const callback = (entries) => {
+ entries.map((entry) => {
+ if (entry.contentBoxSize) {
+ const { inlineSize: width, blockSize: height } = entry.contentBoxSize;
+ if (state.actualWidth !== width || state.actualHeight !== height) {
+ setState({
+ ...state,
+ actualWidth: width,
+ actualHeight: height,
+ });
+ }
+ }
+ });
+ };
+ const observer = new ResizeObserver(callback);
+ observer.observe(currentElem);
+ return () => {
+ observer.unobserve(currentElem);
+ };
+ }, [setState, state]);
+
const content = (
{
);
if (!state.locked) {
- return {content};
+ return content;
}
return (
@@ -183,4 +172,35 @@ const Item = ({ setState, state }) => {
);
};
-export default Item;
+const SyncedItem = ({ setState, state }) => {
+ const [c2c] = useC2C();
+ const versionsRef = React.useRef([]);
+
+ React.useEffect(() => {
+ if (versionsRef.current.includes(state.version)) {
+ versionsRef.current = versionsRef.current.filter((v) => {
+ return v !== state.version;
+ });
+ } else {
+ c2c.publish(`itemStateUpdate.${state.id}`, {
+ ...state,
+ });
+ }
+ }, [c2c, setState, state]);
+
+ React.useEffect(() => {
+ const unsub = c2c.subscribe(
+ `itemStateUpdate.${state.id}`,
+ (newItemState) => {
+ const nextVersion = nanoid();
+ versionsRef.current.push(nextVersion);
+ setState({ ...newItemState, version: nextVersion });
+ }
+ );
+ return unsub;
+ }, [c2c, setState, state]);
+
+ return ;
+};
+
+export default SyncedItem;
diff --git a/src/components/Items.js b/src/components/Items.js
index 31fb622..e9dad43 100644
--- a/src/components/Items.js
+++ b/src/components/Items.js
@@ -1,6 +1,7 @@
import React from 'react';
import { useRecoilState, atom, selector, useRecoilValue } from 'recoil';
import Item from '../components/Item';
+import { selectedItemsAtom } from './Selector';
export const ItemListAtom = atom({
key: 'itemList',
@@ -9,13 +10,14 @@ export const ItemListAtom = atom({
const Items = ({}) => {
const [itemList, setItemList] = useRecoilState(ItemListAtom);
+ const selectedItems = useRecoilValue(selectedItemsAtom);
- const setItemState = React.useCallback(
+ const updateItemState = React.useCallback(
(newState) => {
setItemList((prevList) => {
return prevList.map((item) => {
if (item.id === newState.id) {
- return newState;
+ return { ...item, ...newState, id: item.id };
}
return item;
});
@@ -25,7 +27,7 @@ const Items = ({}) => {
);
return itemList.map((item) => (
-
+
));
};
diff --git a/src/components/Selector.js b/src/components/Selector.js
index 346b100..3a4023b 100644
--- a/src/components/Selector.js
+++ b/src/components/Selector.js
@@ -3,31 +3,13 @@ import { atom, useRecoilValue } from 'recoil';
import { PanZoomRotateState } from '../components/PanZoomRotate';
import { ItemListAtom } from '../components/Items';
import { useRecoilState } from 'recoil';
+import { insideClass, isPointInsideRect, isPointInsideItem } from '../utils';
export const selectedItemsAtom = atom({
key: 'selectedItems',
default: [],
});
-/**
- * Check if element or parent has className.
- * @param {DOMElement} element
- * @param {string} className
- */
-const insideClass = (element, className) => {
- if (element.className === className) return true;
- return element.parentNode && insideClass(element.parentNode, className);
-};
-
-const isPointInsideRect = (point, rect) => {
- return (
- point.x > rect.left &&
- point.x < rect.left + rect.width &&
- point.y > rect.top &&
- point.y < rect.top + rect.height
- );
-};
-
const findSelected = (items, rect) => {
return items.filter((item) => {
return (
@@ -61,6 +43,11 @@ const Selector = ({ children }) => {
stateRef.current.startY = displayY;
setSelector({ ...stateRef.current });
wrapperRef.current.style.cursor = 'crosshair';
+ } else {
+ if (selected.length) {
+ /* Should remove selection if clic another item */
+ /*setSelected([])*/
+ }
}
};
@@ -85,6 +72,7 @@ const Selector = ({ children }) => {
stateRef.current.height = -currentY + stateRef.current.startY;
}
setSelector({ ...stateRef.current });
+ e.preventDefault();
}
};
diff --git a/src/components/UserConfig.js b/src/components/UserConfig.js
index fa1cbb1..0b4bd7d 100644
--- a/src/components/UserConfig.js
+++ b/src/components/UserConfig.js
@@ -2,9 +2,11 @@ import React from 'react';
import { BlockPicker } from 'react-color';
const UserConfig = ({ user, setUser, editable }) => {
+ const [name, setName] = React.useState(user.name);
const [showPicker, setShowPicker] = React.useState(false);
const handleChange = (e) => {
+ setName(e.target.value);
setUser({ ...user, name: e.target.value });
};
@@ -55,7 +57,7 @@ const UserConfig = ({ user, setUser, editable }) => {
backgroundColor: '#CCC',
width: '7em',
}}
- value={user.name}
+ value={name}
onChange={handleChange}
/>
)}
diff --git a/src/utils/index.js b/src/utils/index.js
new file mode 100644
index 0000000..d2b75dc
--- /dev/null
+++ b/src/utils/index.js
@@ -0,0 +1,29 @@
+import { isCompositeComponentWithType } from 'react-dom/test-utils';
+
+/**
+ * Check if element or parent has className.
+ * @param {DOMElement} element
+ * @param {string} className
+ */
+export const insideClass = (element, className) => {
+ if (element.className === className) return true;
+ return element.parentNode && insideClass(element.parentNode, className);
+};
+
+export const isPointInsideRect = (point, rect) => {
+ return (
+ point.x > rect.left &&
+ point.x < rect.left + rect.width &&
+ point.y > rect.top &&
+ point.y < rect.top + rect.height
+ );
+};
+
+export const isPointInsideItem = (point, item) => {
+ return isPointInsideRect(point, {
+ left: item.x,
+ top: item.y,
+ width: item.actualWidth,
+ height: item.actualHeight,
+ });
+};