Add action pane to handle all user interactions

This commit is contained in:
Jeremie Pardou-Piquemal 2020-06-13 16:40:18 +02:00
parent 0131c1176a
commit 587c0c0049
12 changed files with 287 additions and 135 deletions

5
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -1,6 +1,3 @@
.App {
}
.board {
display: fixed;
top: 0;

View file

@ -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 (
<div
onMouseDown={onMouseDown}
onMouseMove={onMouseMouve}
onMouseUp={onMouseUp}
style={{}}
ref={wrapperRef}
>
{children}
</div>
);
};
export default ActionPane;

View file

@ -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 <p>Please select a game</p>;
}
return (
<Selector>
<div
onMouseMove={onMouseMove}
onMouseLeave={onLeave}
className='content'
style={{
background:
'repeating-linear-gradient(45deg, #606dbc60, #606dbc60 10px, #46529860 10px, #46529860 20px)',
width: `${config.size}px`,
height: `${config.size}px`,
}}
>
<Items />
<Cursors users={users} />
</div>
<ActionPane>
<CursorPane user={user} users={users}>
<div
className='content'
style={{
background:
'repeating-linear-gradient(45deg, #606dbc60, #606dbc60 10px, #46529860 10px, #46529860 20px)',
width: `${config.size}px`,
height: `${config.size}px`,
}}
>
<Items />
</div>
</CursorPane>
</ActionPane>
</Selector>
);
};

View file

@ -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 (
<div onMouseMove={onMouseMove} onMouseLeave={onLeave}>
{children}
<Cursors users={users} />
</div>
);
};
export default Board;

View file

@ -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]);

View file

@ -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 (
<img
src={backContent}
draggable={false}
{...size}
onDoubleClick={onDblClick}
/>
<div onDoubleClick={onDblClick}>
<img
src={backContent}
draggable={false}
{...size}
style={{ userSelect: 'none', pointerEvents: 'none' }}
/>
</div>
);
}
return (
<img src={content} draggable={false} {...size} onDoubleClick={onDblClick} />
<div onDoubleClick={onDblClick}>
<img
src={content}
draggable={false}
{...size}
style={{ userSelect: 'none', pointerEvents: 'none' }}
onDoubleClick={onDblClick}
/>
</div>
);
};
@ -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 = (
<div
style={{
position: 'absolute',
left: state.x,
top: state.y,
//border: '2px dashed #000000A0',
display: 'inline-block',
boxSizing: 'content-box',
//padding: '2px',
transform: `rotate(${rotation}deg)`,
...style,
}}
@ -168,7 +157,7 @@ const Item = ({ setState, state }) => {
);
if (!state.locked) {
return <DraggableCore onDrag={onDrag}>{content}</DraggableCore>;
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 <Item state={state} setState={setState} />;
};
export default SyncedItem;

View file

@ -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) => (
<Item key={item.id} state={item} setState={setItemState} />
<Item key={item.id} state={item} setState={updateItemState} />
));
};

View file

@ -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();
}
};

View file

@ -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}
/>
)}

29
src/utils/index.js Normal file
View file

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