diff --git a/packages/webapp/src/features/graph/graphs/CirclesGraph.ts b/packages/webapp/src/features/graph/graphs/CirclesGraph.ts index c968c8fc..864c0a1f 100644 --- a/packages/webapp/src/features/graph/graphs/CirclesGraph.ts +++ b/packages/webapp/src/features/graph/graphs/CirclesGraph.ts @@ -192,12 +192,12 @@ export abstract class CirclesGraph extends Graph { } updateData(circles: CircleFullFragment[]) { + const firstDraw = !this.inputData super.updateData(circles) const data = this.prepareData(circles) // Pack data with d3.pack const root = this.packData(data) - const firstDraw = !this.d3Root.select('.circle').node() // Get all nodes under root and rescale them const nodesMap = root.descendants() @@ -219,8 +219,7 @@ export abstract class CirclesGraph extends Graph { if (firstDraw) { setTimeout( () => this.zoomTo(root.x, root.y, this.focusCircleScale(root)), - 0, - true + 0 ) } diff --git a/packages/webapp/src/features/graph/graphs/Graph.ts b/packages/webapp/src/features/graph/graphs/Graph.ts index f80b2677..3dd4eb11 100644 --- a/packages/webapp/src/features/graph/graphs/Graph.ts +++ b/packages/webapp/src/features/graph/graphs/Graph.ts @@ -74,7 +74,12 @@ export abstract class Graph< // Zoom this.zoomBehaviour = d3 .zoom() - .filter(() => !this.zoomDisabled) // Listen also to mouse wheel + .filter( + (event) => + !this.zoomDisabled && + // Control/Command key is pressed + !(event.ctrlKey || event.metaKey) + ) .scaleExtent(settings.zoom.scaleExtent as [number, number]) .on('zoom', (event) => { if (this.unmounted) return diff --git a/packages/webapp/src/features/graph/hooks/useMounted.tsx b/packages/webapp/src/features/graph/hooks/useMounted.tsx new file mode 100644 index 00000000..553e04d6 --- /dev/null +++ b/packages/webapp/src/features/graph/hooks/useMounted.tsx @@ -0,0 +1,11 @@ +import { useEffect, useState } from 'react' + +export default function useMounted() { + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + return mounted +} diff --git a/packages/webapp/src/features/graph/renderers/html/Panzoom.tsx b/packages/webapp/src/features/graph/renderers/html/Panzoom.tsx index 7b15eb2e..8b7b6a49 100644 --- a/packages/webapp/src/features/graph/renderers/html/Panzoom.tsx +++ b/packages/webapp/src/features/graph/renderers/html/Panzoom.tsx @@ -15,6 +15,7 @@ export function Panzoom({ graph, children }: Props) { () + const dragNodes = useRef([]) + const dragTargets = useRef([]) + const dragTarget = useRef() + + const handleMouseDown = (event: React.MouseEvent) => { + const canDrag = + // Disable when mousewheel is pressed + event.button !== 1 && + // Control/Command key is pressed + (event.ctrlKey || event.metaKey) && + // Disable when events are not provided + graph.params.events.onCircleMove && + graph.params.events.onMemberMove && + // Disable for invited circles (links) + node.data.id.indexOf('_') === -1 + + // Can't drag, click + if (!canDrag) return + + // Register mouse position + dragOrigin.current = { x: event.clientX, y: event.clientY } + + // Register nodes to drag + const descendants = node.descendants() + dragNodes.current = [node, ...descendants] + .map((d) => { + const element = getNodeElement(d) + if (!element) return + return { + node: d, + element, + } + }) + .filter(truthy) + + // Register targets + dragTargets.current = graph.nodes + .filter( + (d) => !descendants.includes(d) && d.data.type === NodeType.Circle + ) + .map((d) => { + const element = getNodeElement(d) + if (!element) return + return { + node: d, + element, + } + }) + .filter(truthy) + + // Add classes + dragNodes.current[0].element.classList.add('drag-node') + dragNodes.current.forEach((d) => { + d.element.classList.add('dragging') + }) + + const handleMouseUp = () => { + document.removeEventListener('mouseup', handleMouseUp) + document.removeEventListener('mousemove', handleMouseMove) + + // Remove classes + dragNodes.current[0]?.element.classList.remove('drag-node') + dragNodes.current.forEach((d) => { + d.element.classList.remove('dragging') + }) + dragTarget.current?.element.classList.remove('drag-target') + + // Reset dragged circles + const actionMoved = false + if (dragNodes.current && !actionMoved) { + dragNodes.current.forEach((d) => { + setTimeout(() => { + d.element.style.transform = '' + }, 0) + }) + } + + // Reset refs + dragOrigin.current = undefined + dragNodes.current = [] + dragTargets.current = [] + dragTarget.current = undefined + } + + const handleMouseMove = (event: MouseEvent) => { + if (!dragOrigin.current || !dragTargets.current) return + + const { k } = graph.zoomTransform + const dX = (event.clientX - dragOrigin.current.x) / k + const dY = (event.clientY - dragOrigin.current.y) / k + + dragNodes.current.forEach((d) => { + d.element.style.transform = `translate(${d.node.x - d.node.r + dX}px, ${ + d.node.y - d.node.r + dY + }px)` + }) + + const target = getTargetNodeData(dragTargets.current, event, graph) + + if (target !== dragTarget.current) { + dragTarget.current?.element.classList.remove('drag-target') + if (target) { + dragTarget.current = target + target.element.classList.add('drag-target') + } + } + } + + document.addEventListener('mouseup', handleMouseUp) + document.addEventListener('mousemove', handleMouseMove) + } + + return { + handleMouseDown, + } +} + +function getNodeElement(node: NodeData) { + return document.getElementById(`node-${node.data.id}`) as HTMLDivElement +} + +function getTargetNodeData( + targetNodes: DragNode[], + event: MouseEvent, + graph: Graph +): DragNode | null { + const position = getDragEventPosition(event, graph) + + // Get circles under the mouse + const currentTargets = targetNodes.filter(({ node }) => + isPointInsideCircle(position.x, position.y, node.x, node.y, node.r) + ) + + // Get last descendants under the mouse + return ( + currentTargets.reduce<{ max: number; dragNode?: DragNode }>( + (acc, dragNode) => + !dragNode || dragNode.node.depth > acc.max + ? { max: dragNode.node.depth, dragNode } + : acc, + { max: 0 } + ).dragNode || null + ) +} + +function getDragEventPosition(event: MouseEvent, graph: Graph) { + const { x, y, k } = graph.zoomTransform + const graphRect = (graph.element as HTMLDivElement).getBoundingClientRect() + + return { + x: (event.clientX - graphRect.left - x) / k, + y: (event.clientY - graphRect.top - y) / k, + } +} diff --git a/packages/webapp/src/features/graph/renderers/html/nodes/CircleElement.tsx b/packages/webapp/src/features/graph/renderers/html/nodes/CircleElement.tsx index 5b4dbd12..43be52f1 100644 --- a/packages/webapp/src/features/graph/renderers/html/nodes/CircleElement.tsx +++ b/packages/webapp/src/features/graph/renderers/html/nodes/CircleElement.tsx @@ -19,6 +19,7 @@ export default function CircleElement({ graph, node, selected }: Props) { return ( { - setMounted(true) - }, []) - // Text size const textRef = useRef(null) const [size, setSize] = useState(undefined) diff --git a/packages/webapp/src/features/graph/renderers/html/nodes/MemberElement.tsx b/packages/webapp/src/features/graph/renderers/html/nodes/MemberElement.tsx index 61406d3a..28e08d33 100644 --- a/packages/webapp/src/features/graph/renderers/html/nodes/MemberElement.tsx +++ b/packages/webapp/src/features/graph/renderers/html/nodes/MemberElement.tsx @@ -21,6 +21,7 @@ export default function MemberElement({ graph, node, selected }: Props) { return ( { - setMounted(true) - }, []) + // Drag & drop + const { handleMouseDown } = useDragNode(graph, node) return ( {children} diff --git a/packages/webapp/src/features/graph/renderers/html/animations.tsx b/packages/webapp/src/features/graph/renderers/html/utils/animations.tsx similarity index 100% rename from packages/webapp/src/features/graph/renderers/html/animations.tsx rename to packages/webapp/src/features/graph/renderers/html/utils/animations.tsx diff --git a/packages/webapp/src/features/graph/renderers/html/colors.tsx b/packages/webapp/src/features/graph/renderers/html/utils/colors.tsx similarity index 100% rename from packages/webapp/src/features/graph/renderers/html/colors.tsx rename to packages/webapp/src/features/graph/renderers/html/utils/colors.tsx diff --git a/packages/webapp/src/features/graph/renderers/svg/elements/MouseCircleElement.tsx b/packages/webapp/src/features/graph/renderers/svg/elements/MouseCircleElement.tsx index 9968507b..e4f40185 100644 --- a/packages/webapp/src/features/graph/renderers/svg/elements/MouseCircleElement.tsx +++ b/packages/webapp/src/features/graph/renderers/svg/elements/MouseCircleElement.tsx @@ -74,8 +74,7 @@ export class MouseCircleElement extends AbstractCircleElement { events.onCircleMove && events.onMemberMove && // Disable for invited circles (links) - (d.data.parentId === d.parent?.data.id || - d.data.parentId === d.parent?.parent?.data.id) + d.data.id.indexOf('_') === -1 ) }) .on('start', function (event, dragNode) {