|
1 | 1 | import type {ReactElement, MouseEvent} from "react"; |
2 | | -import {memo, useCallback} from "react"; |
| 2 | +import {memo, useCallback, useLayoutEffect, useRef} from "react"; |
3 | 3 | import type {Node, NodeProps} from "@xyflow/react"; |
4 | 4 | import {Handle, Position} from "@xyflow/react"; |
5 | 5 | import cc from "classcat"; |
6 | 6 | import type {TreeNode} from "../tree-description"; |
7 | 7 | import {NodeIcon} from "./NodeIcon"; |
8 | 8 | import "./QueryNode.css"; |
9 | 9 | import {useGraphRenderingStore} from "./store"; |
| 10 | +import {animationStartTime, graphAnimationProgress} from "./animation-timing"; |
10 | 11 |
|
11 | 12 | export type QueryGraphNode = Node<TreeNode, "querynode">; |
12 | 13 |
|
13 | 14 | function QueryNode({data, id}: NodeProps<QueryGraphNode>) { |
14 | 15 | const expanded = useGraphRenderingStore((s) => s.expandedNodes[id]); |
15 | 16 | const toggleNode = useGraphRenderingStore((s) => s.toggleExpandedNode); |
| 17 | + const finishNodeAnimation = useGraphRenderingStore((s) => s.finishNodeAnimation); |
| 18 | + const sizeAnimation = useGraphRenderingStore((s) => s.nodeSizeAnimations.get(id)); |
16 | 19 | const subtreeExpanded = useGraphRenderingStore((s) => s.expandedSubtrees[id]); |
17 | 20 | const toggleSubtree = useGraphRenderingStore((s) => s.toggleExpandedSubtree); |
18 | 21 |
|
19 | 22 | const hasProperties = data.properties?.size; |
20 | 23 | const hasSubtree = data.collapsedChildren && data.collapsedChildren.length > 0; |
| 24 | + const graphNodeRef = useRef<HTMLDivElement>(null); |
| 25 | + const bodyWrapperRef = useRef<HTMLDivElement>(null); |
| 26 | + |
| 27 | + const measureTargetDimensions = useCallback((targetExpanded: boolean) => { |
| 28 | + const graphNode = graphNodeRef.current; |
| 29 | + const flowNode = graphNode?.closest<HTMLElement>(".react-flow__node"); |
| 30 | + if (graphNode === null || flowNode === null || flowNode === undefined) { |
| 31 | + return {node: {width: 50, height: 50}, body: {width: 0, height: 0}}; |
| 32 | + } |
| 33 | + |
| 34 | + const clone = flowNode.cloneNode(true) as HTMLElement; |
| 35 | + const clonedGraphNode = clone.querySelector<HTMLElement>(".qg-graph-node"); |
| 36 | + const clonedBodyWrapper = clone.querySelector<HTMLElement>(".qg-graph-node-body-wrapper"); |
| 37 | + clone.style.position = "fixed"; |
| 38 | + clone.style.transform = "none"; |
| 39 | + clone.style.visibility = "hidden"; |
| 40 | + clone.style.pointerEvents = "none"; |
| 41 | + clonedGraphNode?.classList.toggle("qg-expanded", targetExpanded); |
| 42 | + clonedBodyWrapper?.style.removeProperty("width"); |
| 43 | + clonedBodyWrapper?.style.removeProperty("height"); |
| 44 | + clonedBodyWrapper?.style.removeProperty("max-width"); |
| 45 | + clonedBodyWrapper?.style.removeProperty("max-height"); |
| 46 | + flowNode.parentElement?.append(clone); |
| 47 | + const measurements = { |
| 48 | + node: {width: clone.offsetWidth, height: clone.offsetHeight}, |
| 49 | + body: {width: clonedBodyWrapper?.offsetWidth ?? 0, height: clonedBodyWrapper?.offsetHeight ?? 0}, |
| 50 | + }; |
| 51 | + clone.remove(); |
| 52 | + return measurements; |
| 53 | + }, []); |
| 54 | + |
| 55 | + useLayoutEffect(() => { |
| 56 | + const element = bodyWrapperRef.current; |
| 57 | + if (element === null) return; |
| 58 | + if (sizeAnimation === undefined) { |
| 59 | + element.style.removeProperty("width"); |
| 60 | + element.style.removeProperty("height"); |
| 61 | + element.style.removeProperty("max-width"); |
| 62 | + element.style.removeProperty("max-height"); |
| 63 | + return; |
| 64 | + } |
| 65 | + |
| 66 | + element.style.maxWidth = "none"; |
| 67 | + element.style.maxHeight = "none"; |
| 68 | + let animationFrame: number | undefined; |
| 69 | + const step = (now: number) => { |
| 70 | + const progress = graphAnimationProgress(sizeAnimation.startedAt, now); |
| 71 | + const width = sizeAnimation.from.width + (sizeAnimation.to.width - sizeAnimation.from.width) * progress; |
| 72 | + const height = sizeAnimation.from.height + (sizeAnimation.to.height - sizeAnimation.from.height) * progress; |
| 73 | + element.style.width = `${width}px`; |
| 74 | + element.style.height = `${height}px`; |
| 75 | + if (progress < 1) animationFrame = requestAnimationFrame(step); |
| 76 | + else finishNodeAnimation(id); |
| 77 | + }; |
| 78 | + step(sizeAnimation.startedAt); |
| 79 | + return () => { |
| 80 | + if (animationFrame !== undefined) cancelAnimationFrame(animationFrame); |
| 81 | + }; |
| 82 | + }, [finishNodeAnimation, id, sizeAnimation]); |
21 | 83 |
|
22 | 84 | const onClick = useCallback( |
23 | 85 | (e: MouseEvent) => { |
24 | 86 | if (e.shiftKey) { |
25 | | - if (hasSubtree) toggleSubtree(id); |
| 87 | + if (hasSubtree) toggleSubtree(id, animationStartTime()); |
26 | 88 | } else { |
27 | | - if (hasProperties) toggleNode(id); |
| 89 | + if (hasProperties) { |
| 90 | + const target = measureTargetDimensions(!expanded); |
| 91 | + const bodyWrapper = bodyWrapperRef.current; |
| 92 | + const startedAt = animationStartTime(); |
| 93 | + const sizeAnimation = |
| 94 | + startedAt === undefined || bodyWrapper === null |
| 95 | + ? undefined |
| 96 | + : { |
| 97 | + from: {width: bodyWrapper.offsetWidth, height: bodyWrapper.offsetHeight}, |
| 98 | + to: target.body, |
| 99 | + startedAt, |
| 100 | + }; |
| 101 | + toggleNode(id, target.node, sizeAnimation); |
| 102 | + } |
28 | 103 | } |
29 | 104 | e.stopPropagation(); |
30 | 105 | }, |
31 | | - [toggleNode, toggleSubtree, hasProperties, hasSubtree, id], |
| 106 | + [toggleNode, toggleSubtree, hasProperties, hasSubtree, expanded, id, measureTargetDimensions], |
32 | 107 | ); |
33 | 108 | const onSubtreeHandleClick = useCallback( |
34 | 109 | (e: MouseEvent) => { |
35 | | - if (hasSubtree) toggleSubtree(id); |
| 110 | + if (hasSubtree) toggleSubtree(id, animationStartTime()); |
36 | 111 | e.stopPropagation(); |
37 | 112 | }, |
38 | 113 | [toggleSubtree, hasSubtree, id], |
39 | 114 | ); |
40 | | - |
41 | 115 | const children = [] as ReactElement[]; |
42 | 116 | for (const [key, value] of (data.properties || []).entries()) { |
43 | 117 | children.push( |
@@ -75,15 +149,15 @@ function QueryNode({data, id}: NodeProps<QueryGraphNode>) { |
75 | 149 | return ( |
76 | 150 | <> |
77 | 151 | <Handle type="target" position={Position.Top} /> |
78 | | - <div className={nodeClassName} onClick={onClick}> |
| 152 | + <div ref={graphNodeRef} className={nodeClassName} onClick={onClick}> |
79 | 153 | <div className="qg-graph-node-head"> |
80 | 154 | {colorBar(data.barsAbove, "above")} |
81 | 155 | <NodeIcon icon={data.icon} iconColor={data.iconColor} /> |
82 | 156 | <div className="qg-graph-node-label" style={{background: data.nodeColor}}> |
83 | 157 | {data.name} |
84 | 158 | </div> |
85 | 159 | </div> |
86 | | - <div className="qg-graph-node-body-wrapper nowheel"> |
| 160 | + <div ref={bodyWrapperRef} className="qg-graph-node-body-wrapper nowheel"> |
87 | 161 | <div className="qg-graph-node-body">{children}</div> |
88 | 162 | </div> |
89 | 163 | {colorBar(data.barsBelow, "below")} |
|
0 commit comments