diff --git a/packages/frontend/src/store/State.ts b/packages/frontend/src/store/State.ts index bc3a828..90d617c 100644 --- a/packages/frontend/src/store/State.ts +++ b/packages/frontend/src/store/State.ts @@ -15,8 +15,18 @@ export interface State { readonly shiftKey: boolean readonly spaceKey: boolean } + readonly resizingNode?: { + readonly id: string + readonly initialWidth: number + readonly startX: number + } readonly mouseUpAction?: DeselectOne | DeselectAllBut - readonly mouseMoveAction?: 'drag' | 'pan' | 'select' | 'select-add' + readonly mouseMoveAction?: + | 'drag' + | 'pan' + | 'select' + | 'select-add' + | 'resize-node' readonly mouseMove: { readonly startX: number readonly startY: number diff --git a/packages/frontend/src/store/actions/onMouseDown.ts b/packages/frontend/src/store/actions/onMouseDown.ts index ded7706..ea1ec46 100644 --- a/packages/frontend/src/store/actions/onMouseDown.ts +++ b/packages/frontend/src/store/actions/onMouseDown.ts @@ -1,3 +1,4 @@ +import { isResizeHandle } from '../../view/ResizeHandle' import { State } from '../State' import { LEFT_MOUSE_BUTTON, MIDDLE_MOUSE_BUTTON } from '../utils/constants' import { toViewCoordinates } from '../utils/coordinates' @@ -8,6 +9,24 @@ export function onMouseDown( event: MouseEvent, container: HTMLElement, ): Partial { + // Resize anchor + if (isResizeHandle(event.target)) { + const { nodeId } = event.target.dataset + const node = state.nodes.find((n) => n.simpleNode.id === nodeId) + + if (node && nodeId) { + return { + resizingNode: { + id: nodeId, + initialWidth: node.box.width, + startX: event.clientX, + }, + mouseMoveAction: 'resize-node', + pressed: { ...state.pressed, leftMouseButton: true }, + } + } + } + if (event.button === LEFT_MOUSE_BUTTON && !state.mouseMoveAction) { if (state.pressed.spaceKey) { const [x, y] = [event.clientX, event.clientY] diff --git a/packages/frontend/src/store/actions/onMouseMove.ts b/packages/frontend/src/store/actions/onMouseMove.ts index adc5a09..3a504ad 100644 --- a/packages/frontend/src/store/actions/onMouseMove.ts +++ b/packages/frontend/src/store/actions/onMouseMove.ts @@ -1,5 +1,5 @@ import { Box, State } from '../State' -import { LEFT_MOUSE_BUTTON } from '../utils/constants' +import { LEFT_MOUSE_BUTTON, NODE_WIDTH } from '../utils/constants' import { toViewCoordinates } from '../utils/coordinates' import { toContainerCoordinates } from '../utils/toContainerCoordinates' import { updateNodePositions } from '../utils/updateNodePositions' @@ -18,6 +18,34 @@ export function onMouseMove( case undefined: { return { ...state, mouseUpAction: undefined } } + case 'resize-node': { + if (!state.resizingNode) { + break + } + + const { scale } = state.transform + + const dx = event.clientX - state.resizingNode.startX + + const newWidth = Math.max( + state.resizingNode.initialWidth + dx / scale, + NODE_WIDTH, + ) + + const nodes = state.nodes.map((node) => + node.simpleNode.id === state.resizingNode?.id + ? { + ...node, + box: { ...node.box, width: newWidth }, + } + : node, + ) + + return updateNodePositions({ + ...state, + nodes, + }) + } case 'pan': { const [x, y] = [event.clientX, event.clientY] return { diff --git a/packages/frontend/src/store/actions/updateNodes.ts b/packages/frontend/src/store/actions/updateNodes.ts index 656fe86..82d38d9 100644 --- a/packages/frontend/src/store/actions/updateNodes.ts +++ b/packages/frontend/src/store/actions/updateNodes.ts @@ -28,7 +28,12 @@ export function updateNodes(state: State, nodes: SimpleNode[]): Partial { .filter((node) => oldNodes.has(node.id)) .map((node) => { const oldNode = oldNodes.get(node.id) - return simpleNodeToNode(node, oldNode?.box.x ?? 0, oldNode?.box.y ?? 0) + return simpleNodeToNode( + node, + oldNode?.box.x ?? 0, + oldNode?.box.y ?? 0, + oldNode?.box.width ?? NODE_WIDTH, + ) }) const addedNodes = nodes @@ -37,7 +42,8 @@ export function updateNodes(state: State, nodes: SimpleNode[]): Partial { const box = getNodeBoxFromStorage(state.projectId, node) const x = box?.x ?? startX + (NODE_WIDTH + NODE_SPACING) * i const y = box?.y ?? 0 - return simpleNodeToNode(node, x, y) + const width = box?.width ?? NODE_WIDTH + return simpleNodeToNode(node, x, y, width) }) return updateNodePositions({ @@ -76,11 +82,16 @@ function getNodeBoxFromStorage(projectId: string, node: SimpleNode) { return location } -function simpleNodeToNode(node: SimpleNode, x: number, y: number): Node { +function simpleNodeToNode( + node: SimpleNode, + x: number, + y: number, + width: number, +): Node { return { simpleNode: node, // height will be updated by updateNodePositions - box: { x, y, width: NODE_WIDTH, height: 0 }, + box: { x, y, width: width, height: 0 }, fields: node.fields.map((field) => ({ name: field.name, connection: toConnection(field.connection), diff --git a/packages/frontend/src/store/utils/constants.ts b/packages/frontend/src/store/utils/constants.ts index af7f543..1c9790b 100644 --- a/packages/frontend/src/store/utils/constants.ts +++ b/packages/frontend/src/store/utils/constants.ts @@ -13,6 +13,8 @@ export const FIELD_HEIGHT = 24 export const NODE_WIDTH = 200 export const NODE_SPACING = 25 +export const RESIZE_HANDLE_SPACING = 15 + export const ZOOM_SENSITIVITY = 0.02 export const MAX_ZOOM = 3 export const MIN_ZOOM = 0.3 diff --git a/packages/frontend/src/view/NodeView.tsx b/packages/frontend/src/view/NodeView.tsx index 2fd5704..0c56912 100644 --- a/packages/frontend/src/view/NodeView.tsx +++ b/packages/frontend/src/view/NodeView.tsx @@ -1,7 +1,10 @@ import classNames from 'classnames' -import { useCallback } from 'react' +import { useCallback, useRef } from 'react' import { Node } from '../store/State' +import { useStore } from '../store/store' +import { NODE_WIDTH, RESIZE_HANDLE_SPACING } from '../store/utils/constants' +import { ResizeHandle } from './ResizeHandle' export interface NodeViewProps { node: Node @@ -12,17 +15,34 @@ export interface NodeViewProps { } export function NodeView(props: NodeViewProps) { + const ref = useRef(null) + + const updateNodeLocations = useStore((state) => state.updateNodeLocations) + const onDiscover = useCallback(() => { props.onDiscover(props.node.simpleNode.id) }, [props.onDiscover, props.node.simpleNode.id]) + const onDoubleClick = useCallback(() => { + if (!ref.current) { + return + } + + const newBox = getLocationByChildWidth(ref.current) + + updateNodeLocations({ + [props.node.simpleNode.id]: newBox, + }) + }, []) + return (
))} +
) } + +/** + * Render children with out parent constraints to compute actual width we should expand into + */ +function getAbsoluteWidth(element: Element) { + // deep clone to have potential descendants + const clone = element.cloneNode(true) as HTMLElement + clone.style.width = 'auto' + clone.style.position = 'absolute' + clone.style.visibility = 'hidden' + clone.style.pointerEvents = 'none' + clone.style.transform = 'translateZ(0)' + + document.body.appendChild(clone) + + const { offsetWidth: width } = clone + + document.body.removeChild(clone) + + return width +} + +function getLocationByChildWidth(element: HTMLElement) { + const AUTO_EXPAND_ADDITIONAL_SPACE = 20 + + const absoluteWidths = Array.from(element.children).map((children) => + getAbsoluteWidth(children), + ) + + const newWidth = Math.max(...absoluteWidths, NODE_WIDTH) + + const newWidthWithOffset = newWidth + AUTO_EXPAND_ADDITIONAL_SPACE + + const newBox = { + x: element.offsetLeft, + y: element.offsetTop, + width: newWidthWithOffset, + } + + return newBox +} diff --git a/packages/frontend/src/view/ResizeHandle.tsx b/packages/frontend/src/view/ResizeHandle.tsx new file mode 100644 index 0000000..603d30d --- /dev/null +++ b/packages/frontend/src/view/ResizeHandle.tsx @@ -0,0 +1,36 @@ +import { MouseEventHandler } from 'react' + +const RESIZE_DATA_HANDLE = 'resize' + +export function ResizeHandle(props: { + nodeId: string + onDoubleClick: MouseEventHandler +}) { + const handleDoubleClick: MouseEventHandler = (event) => { + event.stopPropagation() + props.onDoubleClick(event) + } + + return ( +
+
+
+
+
+ ) +} + +export function isResizeHandle( + target: EventTarget | null, +): target is HTMLElement { + return Boolean( + target instanceof HTMLElement && + target.dataset.nodeOperation === RESIZE_DATA_HANDLE && + target.dataset.nodeId, + ) +}