Add action pane to handle all user interactions
This commit is contained in:
parent
0131c1176a
commit
587c0c0049
12 changed files with 287 additions and 135 deletions
5
package-lock.json
generated
5
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
.App {
|
||||
}
|
||||
|
||||
.board {
|
||||
display: fixed;
|
||||
top: 0;
|
||||
|
|
90
src/components/ActionPane.js
Normal file
90
src/components/ActionPane.js
Normal 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;
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
36
src/components/CursorPane.js
Normal file
36
src/components/CursorPane.js
Normal 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;
|
|
@ -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]);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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} />
|
||||
));
|
||||
};
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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
29
src/utils/index.js
Normal 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,
|
||||
});
|
||||
};
|
Loading…
Reference in a new issue