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, + }); +};