Skip to content

Commit

Permalink
Add resize handle
Browse files Browse the repository at this point in the history
  • Loading branch information
jrmi committed Apr 4, 2022
1 parent 3c5109d commit a2407ae
Show file tree
Hide file tree
Showing 10 changed files with 258 additions and 72 deletions.
6 changes: 4 additions & 2 deletions src/board/Board.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const Board = ({
itemTemplates = {},
boardSize = DEFAULT_BOARD_MAX_SIZE,
children,
showResizeHandle = false,
}) => {
const setConfiguration = useSetRecoilState(ConfigurationAtom);
const { updateItemExtent } = useDim();
Expand All @@ -37,8 +38,9 @@ export const Board = ({
...prev,
itemTemplates,
boardSize,
showResizeHandle,
}));
}, [itemTemplates, setConfiguration, boardSize]);
}, [itemTemplates, setConfiguration, boardSize, showResizeHandle]);

React.useEffect(() => {
updateItemExtent();
Expand All @@ -59,7 +61,7 @@ export const Board = ({
style={boardStyle}
className="board"
>
<ItemList itemTemplates={itemTemplates} />
<ItemList />
{children}
</div>
</CursorPane>
Expand Down
175 changes: 163 additions & 12 deletions src/board/Items/Item.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import React, { memo } from "react";

import styled from "@emotion/styled";
import ResizeHandler from "./ResizeHandler";
import lockIcon from "../../images/lock.svg";

const ItemWrapper = styled.div`
display: inline-block;
transition: transform 150ms;
user-select: none;
padding: 2px;
box-sizing: border-box;
transform: rotate(${({ rotation }) => rotation}deg);
& .corner {
Expand Down Expand Up @@ -55,8 +57,36 @@ const ItemWrapper = styled.div`
&.locked:hover::after {
opacity: 0.3;
}
& .resize {
position: absolute;
width: 10px;
height: 10px;
border: 2px solid #db5034;
background-color: #db5034;
cursor: move;
&.resize-width {
cursor: ew-resize;
right: -6px;
top: calc(50% - 5px);
}
&.resize-height {
cursor: ns-resize;
bottom: -6px;
left: calc(50% - 5px);
}
&.resize-ratio {
cursor: nwse-resize;
bottom: -6px;
right: -6px;
}
}
`;

const identity = (x) => x;

const DefaultErrorComponent = ({ onReload }) => (
<div
style={{
Expand Down Expand Up @@ -112,25 +142,73 @@ const removeClass = (e) => {
e.target.className = "";
};

const defaultResize = ({
width,
height,
actualWidth,
actualHeight,
prevState,
keepRatio,
}) => {
let { width: currentWidth, height: currentHeight } = prevState;

// Parse text values if any
[currentWidth, currentHeight] = [
parseFloat(currentWidth),
parseFloat(currentHeight),
];
if (!currentWidth || Number.isNaN(Number(currentWidth))) {
currentWidth = actualWidth;
}
if (!currentHeight || Number.isNaN(Number(currentHeight))) {
currentHeight = actualHeight;
}

if (keepRatio) {
const ratio = currentWidth / currentHeight;
return {
...prevState,
width: (currentWidth + width).toFixed(1),
height: (currentHeight + height / ratio).toFixed(1),
};
}

return {
...prevState,
width: (currentWidth + width).toFixed(1),
height: (currentHeight + height).toFixed(1),
};
};

const defaultResizeDirection = {
w: true,
h: true,
b: true,
};

const Item = ({
setState,
state: { type, rotation = 0, id, locked, layer, ...rest } = {},
animate = "hvr-pop",
isSelected,
itemMap,
unlocked,
showResizeHandle = false,
}) => {
const animateRef = React.useRef(null);
const itemWrapperRef = React.useRef(null);

const Component = itemMap[type].component || (() => null);
const {
component: Component = () => null,
resizeDirections = defaultResizeDirection,
resize = defaultResize,
} = itemMap[type];

const updateState = React.useCallback(
(callbackOrItem, sync = true) => setState(id, callbackOrItem, sync),
[setState, id]
);

React.useEffect(() => {
animateRef.current.className = animate;
itemWrapperRef.current.className = animate;
}, [animate]);

let className = `item ${id}`;
Expand All @@ -141,18 +219,56 @@ const Item = ({
className += " selected";
}

const onResize = React.useCallback(
({ width = 0, height = 0, keepRatio }) => {
updateState((prev) => {
const { offsetWidth, offsetHeight } = itemWrapperRef.current;
return resize({
prevState: prev,
width,
height,
actualHeight: offsetHeight,
actualWidth: offsetWidth,
keepRatio,
});
});
},
[resize, updateState]
);

const onResizeWidth = React.useCallback(
({ width }) => {
onResize({ width });
},
[onResize]
);

const onResizeHeight = React.useCallback(
({ height }) => {
onResize({ height });
},
[onResize]
);

const onResizeRatio = React.useCallback(
({ width }) => {
onResize({ height: width, width, keepRatio: true });
},
[onResize]
);

return (
<ItemWrapper
rotation={rotation}
locked={locked && !unlocked}
locked={locked}
selected={isSelected}
layer={layer}
data-id={id}
className={className}
>
<div
style={{ display: "flex" }}
ref={animateRef}
ref={itemWrapperRef}
onAnimationEnd={removeClass}
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
Expand All @@ -169,6 +285,30 @@ const Item = ({
<div className="corner top-right" />
<div className="corner bottom-left" />
<div className="corner bottom-right" />
{isSelected && showResizeHandle && (
<>
{resizeDirections.b && (
<ResizeHandler
className="resize resize-ratio"
onResize={onResizeRatio}
/>
)}

{resizeDirections.h && (
<ResizeHandler
className="resize resize-height"
onResize={onResizeHeight}
/>
)}

{resizeDirections.w && (
<ResizeHandler
className="resize resize-width"
onResize={onResizeWidth}
/>
)}
</>
)}
</div>
</ItemWrapper>
);
Expand All @@ -181,32 +321,38 @@ const MemoizedItem = memo(
state: prevState,
setState: prevSetState,
isSelected: prevIsSelected,
unlocked: prevUnlocked,
showResizeHandle: prevShowResizeHandle,
},
{
state: nextState,
setState: nextSetState,
isSelected: nextIsSelected,
unlocked: nextUnlocked,
showResizeHandle: nextShowResizeHandle,
}
) =>
prevIsSelected === nextIsSelected &&
prevUnlocked === nextUnlocked &&
prevShowResizeHandle === nextShowResizeHandle &&
prevSetState === nextSetState &&
JSON.stringify(prevState) === JSON.stringify(nextState)
);

// Exclude positioning from memoization
const PositionedItem = ({ state = {}, boardSize, currentUser, ...rest }) => {
const stateTrans = rest.itemMap[state.type]?.stateHook || ((st) => st);
if (!rest.itemMap[state.type]) {
// eslint-disable-next-line no-console
console.warn(`Item type ${state.type} not recognized!`);
return null;
}

const { stateHook = identity } = rest.itemMap[state.type];

const {
x = 0,
y = 0,
layer = 0,
moving,
...stateRest
} = stateTrans(state, { currentUser });
} = stateHook(state, { currentUser });

return (
<div
Expand All @@ -219,7 +365,12 @@ const PositionedItem = ({ state = {}, boardSize, currentUser, ...rest }) => {
zIndex: (layer + 4) * 10 + 100 + (moving ? 5 : 0), // Items z-index between 100 and 200
}}
>
<MemoizedItem {...rest} state={stateRest} />
<MemoizedItem
{...rest}
// Helps to prevent render
showResizeHandle={rest.isSelected && rest.showResizeHandle}
state={stateRest}
/>
</div>
);
};
Expand Down
34 changes: 4 additions & 30 deletions src/board/Items/ItemList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,51 +7,25 @@ import { ItemListAtom, ItemMapAtom, SelectedItemsAtom } from "../atoms";
import { ConfigurationAtom } from "..";
import { useUsers } from "../../users";

/** Allow to operate on locked items while u or l key is pressed */
const useUnlock = () => {
const [unlock, setUnlock] = React.useState(false);

React.useEffect(() => {
const onKeyDown = (e) => {
if (e.key === "u" || e.key === "l") {
setUnlock(true);
}
};
const onKeyUp = (e) => {
if (e.key === "u" || e.key === "l") {
setUnlock(false);
}
};
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("keyup", onKeyUp);
};
}, []);

return unlock;
};

const ItemList = ({ itemTemplates }) => {
const ItemList = () => {
const { updateItem } = useItemActions();
const itemList = useRecoilValue(ItemListAtom);
const itemMap = useRecoilValue(ItemMapAtom);
const selectedItems = useRecoilValue(SelectedItemsAtom);
const { boardSize } = useRecoilValue(ConfigurationAtom);
const { boardSize, showResizeHandle, itemTemplates } =
useRecoilValue(ConfigurationAtom);
const { currentUser } = useUsers();
const unlocked = useUnlock();

return itemList.map((itemId) => (
<Item
key={itemId}
state={itemMap[itemId]}
setState={updateItem}
isSelected={selectedItems.includes(itemId)}
unlocked={unlocked}
itemMap={itemTemplates}
boardSize={boardSize}
currentUser={currentUser}
showResizeHandle={showResizeHandle}
/>
));
};
Expand Down
27 changes: 27 additions & 0 deletions src/board/Items/ResizeHandler.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from "react";
import { useRecoilCallback } from "recoil";
import Gesture from "../Gesture";
import { BoardTransformAtom } from "../atoms";

const ResizeHandler = ({ onResize, ...rest }) => {
const onDrag = useRecoilCallback(
({ snapshot }) =>
async ({ deltaX, deltaY, event }) => {
event.stopPropagation();
const { scale } = await snapshot.getPromise(BoardTransformAtom);
onResize({
width: deltaX / scale,
height: deltaY / scale,
});
},
[onResize]
);

return (
<Gesture onDrag={onDrag}>
<div {...rest} />
</Gesture>
);
};

export default ResizeHandler;
Loading

0 comments on commit a2407ae

Please sign in to comment.