|
| 1 | +import { isNumber } from '@vueuse/core' |
| 2 | +import type { GraphNode, HandleConnectable, NodeComponent } from '~/types' |
| 3 | + |
| 4 | +interface Props { |
| 5 | + id: string |
| 6 | + draggable: boolean |
| 7 | + selectable: boolean |
| 8 | + connectable: HandleConnectable |
| 9 | + focusable: boolean |
| 10 | + type: NodeComponent | Function | Object | false |
| 11 | + name: string |
| 12 | + node: GraphNode |
| 13 | + resizeObserver: ResizeObserver |
| 14 | +} |
| 15 | + |
| 16 | +const NodeWrapper = defineComponent({ |
| 17 | + name: 'Node', |
| 18 | + compatConfig: { MODE: 3 }, |
| 19 | + props: ['name', 'type', 'id', 'draggable', 'selectable', 'focusable', 'connectable', 'node', 'resizeObserver'], |
| 20 | + setup(props: Props) { |
| 21 | + provide(NodeId, props.id) |
| 22 | + |
| 23 | + const { |
| 24 | + id: vueFlowId, |
| 25 | + edges, |
| 26 | + noPanClassName, |
| 27 | + selectNodesOnDrag, |
| 28 | + nodesSelectionActive, |
| 29 | + multiSelectionActive, |
| 30 | + emits, |
| 31 | + findNode, |
| 32 | + removeSelectedNodes, |
| 33 | + addSelectedNodes, |
| 34 | + updateNodeDimensions, |
| 35 | + onUpdateNodeInternals, |
| 36 | + getIntersectingNodes, |
| 37 | + getNodeTypes, |
| 38 | + nodeExtent, |
| 39 | + elevateNodesOnSelect, |
| 40 | + disableKeyboardA11y, |
| 41 | + ariaLiveMessage, |
| 42 | + snapToGrid, |
| 43 | + snapGrid, |
| 44 | + } = $(useVueFlow()) |
| 45 | + |
| 46 | + const updateNodePositions = useUpdateNodePositions() |
| 47 | + |
| 48 | + const node = $(useVModel(props, 'node')) |
| 49 | + |
| 50 | + const parentNode = $computed(() => (node.parentNode ? findNode(node.parentNode) : undefined)) |
| 51 | + |
| 52 | + const connectedEdges = $computed(() => getConnectedEdges([node], edges)) |
| 53 | + |
| 54 | + const nodeElement = ref<HTMLDivElement>() |
| 55 | + |
| 56 | + provide(NodeRef, nodeElement) |
| 57 | + |
| 58 | + const { emit, on } = useNodeHooks(node, emits) |
| 59 | + |
| 60 | + const dragging = useDrag({ |
| 61 | + id: props.id, |
| 62 | + el: nodeElement, |
| 63 | + disabled: computed(() => !props.draggable), |
| 64 | + selectable: computed(() => props.selectable), |
| 65 | + onStart(event, node, nodes) { |
| 66 | + emit.dragStart({ event, node, nodes, intersections: getIntersectingNodes(node) }) |
| 67 | + }, |
| 68 | + onDrag(event, node, nodes) { |
| 69 | + emit.drag({ event, node, nodes, intersections: getIntersectingNodes(node) }) |
| 70 | + }, |
| 71 | + onStop(event, node, nodes) { |
| 72 | + emit.dragStop({ event, node, nodes, intersections: getIntersectingNodes(node) }) |
| 73 | + }, |
| 74 | + }) |
| 75 | + |
| 76 | + const getClass = computed(() => (node.class instanceof Function ? node.class(node) : node.class)) |
| 77 | + |
| 78 | + const getStyle = computed(() => { |
| 79 | + const styles = (node.style instanceof Function ? node.style(node) : node.style) || {} |
| 80 | + |
| 81 | + const width = node.width instanceof Function ? node.width(node) : node.width |
| 82 | + const height = node.height instanceof Function ? node.height(node) : node.height |
| 83 | + |
| 84 | + if (width) styles.width = typeof width === 'string' ? width : `${width}px` |
| 85 | + |
| 86 | + if (height) styles.height = typeof height === 'string' ? height : `${height}px` |
| 87 | + |
| 88 | + return styles |
| 89 | + }) |
| 90 | + |
| 91 | + const zIndex = computed(() => Number(node.zIndex ?? getStyle.value.zIndex ?? 0)) |
| 92 | + |
| 93 | + onUpdateNodeInternals((updateIds) => { |
| 94 | + if (updateIds.includes(props.id)) { |
| 95 | + updateInternals() |
| 96 | + } |
| 97 | + }) |
| 98 | + |
| 99 | + onMounted(() => { |
| 100 | + props.resizeObserver.observe(nodeElement.value as HTMLDivElement) |
| 101 | + }) |
| 102 | + |
| 103 | + onBeforeUnmount(() => { |
| 104 | + props.resizeObserver.unobserve(nodeElement.value as HTMLDivElement) |
| 105 | + }) |
| 106 | + |
| 107 | + watch( |
| 108 | + [() => node.type, () => node.sourcePosition, () => node.targetPosition], |
| 109 | + () => { |
| 110 | + updateNodeDimensions([{ id: props.id, nodeElement: nodeElement.value as HTMLDivElement, forceUpdate: true }]) |
| 111 | + }, |
| 112 | + { flush: 'pre' }, |
| 113 | + ) |
| 114 | + |
| 115 | + /** this watcher only updates XYZPosition (when dragging a parent etc) */ |
| 116 | + watch( |
| 117 | + [ |
| 118 | + () => node.position.x, |
| 119 | + () => node.position.y, |
| 120 | + () => parentNode?.computedPosition.x, |
| 121 | + () => parentNode?.computedPosition.y, |
| 122 | + () => parentNode?.computedPosition.z, |
| 123 | + () => node.selected, |
| 124 | + () => node.dimensions.height, |
| 125 | + () => node.dimensions.width, |
| 126 | + () => parentNode?.dimensions.height, |
| 127 | + () => parentNode?.dimensions.width, |
| 128 | + zIndex, |
| 129 | + ], |
| 130 | + ([newX, newY, parentX, parentY, parentZ]) => { |
| 131 | + const xyzPos = { |
| 132 | + x: newX, |
| 133 | + y: newY, |
| 134 | + z: zIndex.value + (elevateNodesOnSelect ? (node.selected ? 1000 : 0) : 0), |
| 135 | + } |
| 136 | + |
| 137 | + if (isNumber(parentX) && isNumber(parentY)) { |
| 138 | + node.computedPosition = getXYZPos({ x: parentX, y: parentY, z: parentZ! }, xyzPos) |
| 139 | + } else { |
| 140 | + node.computedPosition = xyzPos |
| 141 | + } |
| 142 | + }, |
| 143 | + { flush: 'pre', immediate: true }, |
| 144 | + ) |
| 145 | + |
| 146 | + watch([() => node.extent, () => nodeExtent], ([nodeExtent, globalExtent], [oldNodeExtent, oldGlobalExtent]) => { |
| 147 | + // update position if extent has actually changed |
| 148 | + if (nodeExtent !== oldNodeExtent || globalExtent !== oldGlobalExtent) { |
| 149 | + clampPosition() |
| 150 | + } |
| 151 | + }) |
| 152 | + |
| 153 | + // clamp initial position to nodes' extent |
| 154 | + // if extent is parent, we need dimensions to properly clamp the position |
| 155 | + if ( |
| 156 | + node.extent === 'parent' || |
| 157 | + (typeof node.extent === 'object' && 'range' in node.extent && node.extent.range === 'parent') |
| 158 | + ) { |
| 159 | + until(() => node.initialized) |
| 160 | + .toBe(true) |
| 161 | + .then(clampPosition) |
| 162 | + } |
| 163 | + // if extent is not parent, we can clamp it immediately |
| 164 | + else { |
| 165 | + clampPosition() |
| 166 | + } |
| 167 | + |
| 168 | + return () => |
| 169 | + h( |
| 170 | + 'div', |
| 171 | + { |
| 172 | + 'ref': nodeElement, |
| 173 | + 'data-id': node.id, |
| 174 | + 'class': [ |
| 175 | + 'vue-flow__node', |
| 176 | + `vue-flow__node-${props.type === false ? 'default' : props.name}`, |
| 177 | + { |
| 178 | + [noPanClassName]: props.draggable, |
| 179 | + dragging, |
| 180 | + selected: node.selected, |
| 181 | + selectable: props.selectable, |
| 182 | + }, |
| 183 | + getClass, |
| 184 | + ], |
| 185 | + 'style': { |
| 186 | + zIndex: node.computedPosition.z ?? zIndex, |
| 187 | + transform: `translate(${node.computedPosition.x}px,${node.computedPosition.y}px)`, |
| 188 | + pointerEvents: props.selectable || props.draggable ? 'all' : 'none', |
| 189 | + visibility: node.initialized ? 'visible' : 'hidden', |
| 190 | + ...getStyle, |
| 191 | + }, |
| 192 | + 'tabIndex': props.focusable ? 0 : undefined, |
| 193 | + 'role': props.focusable ? 'button' : undefined, |
| 194 | + 'aria-describedby': disableKeyboardA11y ? undefined : `${ARIA_NODE_DESC_KEY}-${vueFlowId}`, |
| 195 | + 'aria-label': node.ariaLabel, |
| 196 | + 'onMouseenter': onMouseEnter, |
| 197 | + 'onMousemove': onMouseMove, |
| 198 | + 'onMouseleave': onMouseLeave, |
| 199 | + 'onContextmenu': onContextMenu, |
| 200 | + 'onClick': onSelectNode, |
| 201 | + 'onDblclick': onDoubleClick, |
| 202 | + 'onKeydown': onKeyDown, |
| 203 | + }, |
| 204 | + [ |
| 205 | + h(props.type === false ? getNodeTypes.default : props.type, { |
| 206 | + id: node.id, |
| 207 | + type: node.type, |
| 208 | + data: node.data, |
| 209 | + events: { ...node.events, ...on }, |
| 210 | + selected: !!node.selected, |
| 211 | + resizing: !!node.resizing, |
| 212 | + dragging, |
| 213 | + connectable: props.connectable, |
| 214 | + position: node.position, |
| 215 | + dimensions: node.dimensions, |
| 216 | + isValidTargetPos: node.isValidTargetPos, |
| 217 | + isValidSourcePos: node.isValidSourcePos, |
| 218 | + parentNode: node.parentNode, |
| 219 | + zIndex: node.computedPosition.z, |
| 220 | + targetPosition: node.targetPosition, |
| 221 | + sourcePosition: node.sourcePosition, |
| 222 | + label: node.label, |
| 223 | + dragHandle: node.dragHandle, |
| 224 | + onUpdateNodeInternals: updateInternals, |
| 225 | + }), |
| 226 | + ], |
| 227 | + ) |
| 228 | + |
| 229 | + /** this re-calculates the current position, necessary for clamping by a node's extent */ |
| 230 | + function clampPosition() { |
| 231 | + const nextPos = node.computedPosition |
| 232 | + |
| 233 | + if (snapToGrid) { |
| 234 | + nextPos.x = snapGrid[0] * Math.round(nextPos.x / snapGrid[0]) |
| 235 | + nextPos.y = snapGrid[1] * Math.round(nextPos.y / snapGrid[1]) |
| 236 | + } |
| 237 | + |
| 238 | + const { computedPosition, position } = calcNextPosition(node, nextPos, nodeExtent, parentNode) |
| 239 | + |
| 240 | + // only overwrite positions if there are changes when clamping |
| 241 | + if (node.computedPosition.x !== computedPosition.x || node.computedPosition.y !== computedPosition.y) { |
| 242 | + node.computedPosition = { ...node.computedPosition, ...computedPosition } |
| 243 | + } |
| 244 | + |
| 245 | + if (node.position.x !== position.x || node.position.y !== position.y) { |
| 246 | + node.position = position |
| 247 | + } |
| 248 | + } |
| 249 | + |
| 250 | + function updateInternals() { |
| 251 | + if (nodeElement.value) updateNodeDimensions([{ id: props.id, nodeElement: nodeElement.value, forceUpdate: true }]) |
| 252 | + } |
| 253 | + |
| 254 | + function onMouseEnter(event: MouseEvent) { |
| 255 | + if (!dragging?.value) { |
| 256 | + emit.mouseEnter({ event, node, connectedEdges }) |
| 257 | + } |
| 258 | + } |
| 259 | + |
| 260 | + function onMouseMove(event: MouseEvent) { |
| 261 | + if (!dragging?.value) { |
| 262 | + emit.mouseMove({ event, node, connectedEdges }) |
| 263 | + } |
| 264 | + } |
| 265 | + |
| 266 | + function onMouseLeave(event: MouseEvent) { |
| 267 | + if (!dragging?.value) { |
| 268 | + emit.mouseLeave({ event, node, connectedEdges }) |
| 269 | + } |
| 270 | + } |
| 271 | + |
| 272 | + function onContextMenu(event: MouseEvent) { |
| 273 | + return emit.contextMenu({ event, node, connectedEdges }) |
| 274 | + } |
| 275 | + |
| 276 | + function onDoubleClick(event: MouseEvent) { |
| 277 | + return emit.doubleClick({ event, node, connectedEdges }) |
| 278 | + } |
| 279 | + |
| 280 | + function onSelectNode(event: MouseEvent) { |
| 281 | + if (props.selectable && (!selectNodesOnDrag || !props.draggable)) { |
| 282 | + handleNodeClick( |
| 283 | + node, |
| 284 | + multiSelectionActive, |
| 285 | + addSelectedNodes, |
| 286 | + removeSelectedNodes, |
| 287 | + $$(nodesSelectionActive), |
| 288 | + false, |
| 289 | + nodeElement.value!, |
| 290 | + ) |
| 291 | + } |
| 292 | + |
| 293 | + emit.click({ event, node, connectedEdges }) |
| 294 | + } |
| 295 | + |
| 296 | + function onKeyDown(event: KeyboardEvent) { |
| 297 | + if (isInputDOMNode(event)) return |
| 298 | + |
| 299 | + if (elementSelectionKeys.includes(event.key) && props.selectable) { |
| 300 | + const unselect = event.key === 'Escape' |
| 301 | + |
| 302 | + if (unselect) { |
| 303 | + nodeElement.value?.blur() |
| 304 | + } |
| 305 | + |
| 306 | + handleNodeClick( |
| 307 | + node, |
| 308 | + multiSelectionActive, |
| 309 | + addSelectedNodes, |
| 310 | + removeSelectedNodes, |
| 311 | + $$(nodesSelectionActive), |
| 312 | + unselect, |
| 313 | + nodeElement.value!, |
| 314 | + ) |
| 315 | + } else if (!disableKeyboardA11y && props.draggable && node.selected && arrowKeyDiffs[event.key]) { |
| 316 | + $$(ariaLiveMessage).value = `Moved selected node ${event.key |
| 317 | + .replace('Arrow', '') |
| 318 | + .toLowerCase()}. New position, x: ${~~node.position.x}, y: ${~~node.position.y}` |
| 319 | + |
| 320 | + updateNodePositions( |
| 321 | + { |
| 322 | + x: arrowKeyDiffs[event.key].x, |
| 323 | + y: arrowKeyDiffs[event.key].y, |
| 324 | + }, |
| 325 | + event.shiftKey, |
| 326 | + ) |
| 327 | + } |
| 328 | + } |
| 329 | + }, |
| 330 | +}) |
| 331 | + |
| 332 | +export default NodeWrapper |
0 commit comments