From ad7d93f93b674e32a5272dff1673b5b45f5676a8 Mon Sep 17 00:00:00 2001 From: Dennis Collaris <d.a.c.collaris@uu.nl> Date: Thu, 4 Jul 2024 12:47:29 +0000 Subject: [PATCH] feat(nl): use floating ui for node link tooltips --- apps/web/public/assets/sprite_selected.png | Bin 646 -> 697 bytes .../public/assets/sprite_selected_square.png | Bin 100 -> 96 bytes .../shared/lib/components/tooltip/Tooltip.tsx | 79 ++++++- .../nodelinkvis/components/NLPixi.tsx | 199 +++++++++--------- .../nodelinkvis/components/NLPopup.tsx | 4 +- 5 files changed, 171 insertions(+), 111 deletions(-) diff --git a/apps/web/public/assets/sprite_selected.png b/apps/web/public/assets/sprite_selected.png index bd57dcc236de77693873365fdf8d007f2349d2ba..1717d0ce9b6570151ce5f68bacb12864bc542e00 100644 GIT binary patch literal 697 zcmV;q0!ICbP)<h;3K|Lk000e1NJLTq002w?002w~0{{R3@JXQ=0001cP)t-s0000_ zS!zyLYfe~dO;>79S8GmKYED>bO<8GGRc20CYED>cPFQMASZYpJYED>cPFZSCS7}XH zYEM~ePgrYET4+^OW=>dY|NsBd)Y-4Jylr)aXK;Y__V@Dh^y}>I=;`d|>Fd+g+Qi4r zwYtHTo2GMnid$rLeua`MI{i`r000+sQchC<FZt%Rl6^iO2k-3P+0x9xtd3_<HV=D) z(C`2N0oF-GK~z}7oYe`AgD?yQ(6E<bDN9=$C_9wi|AZrTMk0X_;=Th!w*0@slzcDG z>^KZeGYDfl%RQfc>Fy>bY?z6AXJi*Qf&-CzVH8FRN~xj$lntkBz2_bdXmK9w)Xf4- z){Uw84iGrL@`hI+xhn2zSU_YQ-og6;vLEl@Efc<AdeR-Gb3Ny&=o-YPE2jA=olUao z#e$uxY*Emb73^5MEBO06z6O*SUi+JF6XNXl8}Y!n+t%6S9yUr;C}HC!^w9#rQlL_? zfWUejFIn|?qOT4YH9+vL@l^c=!I}?SoU29+wK$tCA8hGSOTS1*OMzFQrUH2BfA&?G z&`-!;4_qOuNFb!w{X7-{XGDS#IML^?75a!ILQn78Ee9kSwtFUg#E^&&Tf&%c%1=o@ zsOd8gI4u&j!0}kF$OSPSIm=)*BiRhAOepz;WRp<#SW!!2L1Pxeszb64Rv`;u)gV~| ztAOz_!!Phb`)$QOXaInLAcpa0Z4xF>srq$EeXM@77NE99ZQ43cb<pbys>^Y%?Rrq^ zshdZ*#)BGMW~8cNZ^qCXkY{wSP%&|5f>=ew39l2YC-6@dF(t{=H&ZM{<uzqo)`VYR fezp3gZ`IKNWhbEIJ}Luq00000NkvXXu0mjf;+Z>d literal 646 zcmeAS@N?(olHy`uVBq!ia0vp^ZXnFT3?e@WZ39v{0X`wFK-w=N3xUF+?4anZpy+JB zh)l2)kdf^dp6wf%;~k#i>6!cg|9_zPpFe+IynJ){>aF!#_7qljq!%=N`}Y0Q=P$3{ zyt{Du#*yP^x9&PPb@sBHlIG}?(z@13^3zZD0__tj3GxeOSpD|Sk4p(2Qv4SVO=*h= z)?s&Nb9l$Vz_{Jh#W5t}@RTd3r!^}Gumm_3F6!)j$tHSV?f?J&;xt8$$Ibh+&5O32 zGO`L>#(Fw=o{G)6$cfVgW2YJQ$(_5Tv*<@6=R2<({vxk5KeSY|*?RVfKYFdc%aG?l z-o=&T#})pF%2wMO+<06Y@ky5R-AzXwCfllcY*#&Ru1uU|T6HA%yU6;5%ibNlc)j=f zjdPYW3;0(ixIdLs*;N11YUc|<zMfkrkDtH!a>cX)k86JM_r5n>Tiz6Wm3{4WVSV@H zzye3zj1ReIczW}>r{B_-%GvB@GEIzW3%69+@{MO*9R*T9cb@&v+RMchnz81;eB$i3 zgl**y)fcO>Oun`3@l_M1gD<8{RZVkuNcuMQkFWOE&r6rj$-BRjaZ%duusiEJ<HY(G z35DAVeBGgyYa(@G3-9s>{d9%d*Q>c_e_%<OeAXgfpIt%vT6OX6Jl2#`Ywmoz%NUt1 zZ5}+qLG7$eUGzp_DTV}vw!}Y?vs*ZQZe91DHO+Ijm-`$&8{7R~Brjd}aV$*IZq4ZW zVv=xfhxg&{3mljxH+yp4;&W14U|Ug}V88OX;KIeJ?n3>W<Qny?>QA)>l}>r2dfj3F c6faT9^1EVNHYho!03(>e)78&qol`;+0MfrA!vFvP diff --git a/apps/web/public/assets/sprite_selected_square.png b/apps/web/public/assets/sprite_selected_square.png index eb7a57e4f70d60c5096d07f0cd53803454285099..8e6a75992f913fa454c18c4138c8a3883791f98f 100644 GIT binary patch literal 96 zcmeAS@N?(olHy`uVBq!ia0vp^P9V(43?y&PnzR~7u?6^qxcWt8{r~^}OmX@epopla ti(`n!`Q!!M8~*b%Enf5Rz#>})2E{iYa@)L}HGm2jJYD@<);T3K0RXyd9TNZm literal 100 zcmeAS@N?(olHy`uVBq!ia0vp^wjj*N3?x<7d|m~l*aCb)T>t<7?-!Bv;jqp+popZW wi(`n!`Q#t`jP@F71`Q0qHIf4iR|xN9C|rJH`@fU+{6Iwvp00i_>zopr01F8n;Q#;t diff --git a/libs/shared/lib/components/tooltip/Tooltip.tsx b/libs/shared/lib/components/tooltip/Tooltip.tsx index 19ca660cf..3541d2516 100644 --- a/libs/shared/lib/components/tooltip/Tooltip.tsx +++ b/libs/shared/lib/components/tooltip/Tooltip.tsx @@ -5,6 +5,8 @@ import { offset, flip, shift, + hide, + arrow, useHover, useFocus, useDismiss, @@ -12,6 +14,7 @@ import { useInteractions, useMergeRefs, FloatingPortal, + FloatingArrow } from '@floating-ui/react'; import type { Placement } from '@floating-ui/react'; import { FloatingDelayGroup } from '@floating-ui/react'; @@ -21,6 +24,8 @@ interface TooltipOptions { placement?: Placement; open?: boolean; onOpenChange?: (open: boolean) => void; + boundaryElement?: React.RefObject<HTMLElement> | null; + showArrow?: boolean; } export function useTooltip({ @@ -28,6 +33,8 @@ export function useTooltip({ placement = 'top', open: controlledOpen, onOpenChange: setControlledOpen, + boundaryElement = null, + showArrow = false }: TooltipOptions = {}): { open: boolean; setOpen: (open: boolean) => void; @@ -38,8 +45,9 @@ export function useTooltip({ const open = controlledOpen ?? uncontrolledOpen; const setOpen = setControlledOpen ?? setUncontrolledOpen; + const arrowRef = React.useRef<SVGSVGElement | null>(null); - const data = useFloating({ + let config = { placement, open, onOpenChange: setOpen, @@ -49,11 +57,25 @@ export function useTooltip({ flip({ crossAxis: placement.includes('-'), fallbackAxisSideDirection: 'start', - padding: 5, + padding: 5 }), shift({ padding: 5 }), ], - }); + } + + if (boundaryElement != null) { + const boundary = boundaryElement?.current ?? undefined; + config.middleware.find(x => x.name == 'flip')!.options[0].boundary = boundary; + config.middleware.find(x => x.name == 'shift')!.options[0].boundary = boundary; + config.middleware.push(hide({ boundary })); + } + + if (showArrow) { + config.middleware.push(arrow({ element: arrowRef })); + } + + const data = useFloating(config); + (data.refs as any).arrow = arrowRef; const context = data.context; @@ -98,17 +120,48 @@ export function Tooltip({ children, ...options }: { children: React.ReactNode } // This can accept any props as options, e.g. `placement`, // or other positioning options. const tooltip = useTooltip(options); - return <TooltipContext.Provider value={tooltip}>{children}</TooltipContext.Provider>; + + return <TooltipContext.Provider value={tooltip}> + {children} + </TooltipContext.Provider>; } -export const TooltipTrigger = React.forwardRef<HTMLElement, React.HTMLProps<HTMLElement> & { asChild?: boolean }>(function TooltipTrigger( - { children, asChild = false, ...props }, +export const TooltipTrigger = React.forwardRef<HTMLElement, React.HTMLProps<HTMLElement> & { asChild?: boolean, x?: number, y?: number }>(function TooltipTrigger( + { children, asChild = false, x = null, y = null, ...props }, propRef, ) { const context = useTooltipContext(); - const childrenRef = (children as any).ref; + const childrenRef = React.useMemo(() => { + if (children == null) { + return null; + } else { + return (children as any).ref; + } + }, [children]); + const ref = useMergeRefs([context.data.refs.setReference, propRef, childrenRef]); + React.useEffect(() => { + if (x && y && context.data.refs.reference.current != null) { + const element = context.data.refs.reference.current as HTMLElement; + element.style.position = 'absolute'; + const {x: offsetX, y: offsetY} = element.getBoundingClientRect(); + element.getBoundingClientRect = () => { + return { + width: 0, + height: 0, + x: offsetX, + y: offsetY, + top: y + offsetY, + left: x + offsetX, + right: x + offsetX, + bottom: y + offsetY, + } as DOMRect + } + context.data.update(); + } + }, [x, y]); + // `asChild` allows the user to pass any element as the anchor if (asChild && React.isValidElement(children)) { return React.cloneElement( @@ -149,13 +202,21 @@ export const TooltipContent = React.forwardRef< <FloatingPortal> <div ref={ref} - className={`z-50 max-w-64 overflow-hidden rounded bg-light px-2 py-1 shadow text-xs border border-secondary-200 text-dark animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2${className ? ` ${className}` : ''}`} + className={`z-50 max-w-64 rounded bg-light px-2 py-1 shadow text-xs border border-secondary-200 text-dark animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2${className ? ` ${className}` : ''}`} style={{ ...context.data.floatingStyles, ...style, + display: context.data.middlewareData.hide?.referenceHidden ? 'none' : 'block', }} {...context.interactions.getFloatingProps(props)} - /> + > + { props.children } + { context.data.middlewareData.arrow ? <FloatingArrow + ref={(context.data.refs as any).arrow} + context={context.data.context} + style={{fill: 'white'}} + /> : null } + </div> </FloatingPortal> ); }); diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx index 6357b96bd..9d59adebd 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPixi.tsx @@ -1,6 +1,6 @@ import { GraphType, GraphTypeD3, LinkType, LinkTypeD3, NodeType, NodeTypeD3 } from '../types'; import { dataColors, visualizationColors } from 'config'; -import { ReactEventHandler, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { Application, AssetsBundle, @@ -21,6 +21,9 @@ import { CytoscapeLayout, GraphologyLayout, LayoutFactory, Layouts } from '../.. import { MultiGraph } from 'graphology'; import { Viewport } from 'pixi-viewport'; import { NodelinkVisProps } from '../nodelinkvis'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@graphpolaris/shared/lib/components/tooltip'; +import { MovedEvent } from 'pixi-viewport/dist/types'; +import { ConstructionOutlined } from '@mui/icons-material'; type Props = { onClick: (event?: { node: NodeTypeD3; pos: IPointData }) => void; @@ -43,7 +46,7 @@ type LayoutState = 'reset' | 'running' | 'paused'; export const NLPixi = (props: Props) => { const [quickPopup, setQuickPopup] = useState<{ node: NodeType; pos: IPointData } | undefined>(); - const [popups, setPopups] = useState<{ node: NodeType; pos: IPointData }[]>([]); + const [popups, setPopups] = useState<{ node: NodeTypeD3; pos: IPointData }[]>([]); const [assetsLoaded, setAssetsLoaded] = useState(false); const app = useMemo( @@ -68,8 +71,6 @@ export const NLPixi = (props: Props) => { const mouseInCanvas = useRef<boolean>(false); const isSetup = useRef(false); const ml = useML(); - const dragging = useRef<{ node: NodeTypeD3; gfx: Sprite } | null>(null); - const onlyClicked = useRef(false); const searchResults = useSearchResultData(); const graph = useRef<GraphTypeD3>({ nodes: [], links: [] }); @@ -122,61 +123,64 @@ export const NLPixi = (props: Props) => { const imperative = useRef<any>(null); + const mouseClickThreshold = 200; // Time between mouse up and down events that is considered a click, and not a drag. + useImperativeHandle(imperative, () => ({ - onDragStart(node: NodeTypeD3, gfx: Sprite) { - dragging.current = { node, gfx }; - onlyClicked.current = true; - - // todo: graphology does not support fixed nodes - // todo: after vis-settings panel is there, we should to also support the original d3 force to allow interactivity if needed - if (props.layoutAlgorithm === Layouts.FORCEATLAS2WEBWORKER) return; - if (viewport.current) viewport.current.pause = true; + onMouseDown(event: FederatedPointerEvent) { + if (props.configuration.showPopUpOnHover) return; + + (event as any).mouseDownTimeStamp = event.timeStamp; }, - onDragMove(movementX: number, movementY: number) { - if (props.layoutAlgorithm === Layouts.FORCEATLAS2WEBWORKER) return; - if (dragging.current) { - onlyClicked.current = false; - if (quickPopup) setQuickPopup(undefined); - const idx = popups.findIndex((p) => p.node._id === dragging.current?.node._id); - if (idx >= 0) { - const p = popups[idx]; - p.pos.x += movementX / (viewport.current?.scaled || 1); - p.pos.y += movementY / (viewport.current?.scaled || 1); - popups[idx] = p; - setPopups([...popups]); - } + onMouseUpNode(event: FederatedPointerEvent) { + if (props.configuration.showPopUpOnHover) return; + + // If its a short click (not a drag) on the stage but not on a node: clear the selection and remove all popups. + const holdDownTime = event.timeStamp - (event as any).mouseDownTimeStamp; + if (holdDownTime > mouseClickThreshold) { + return; + } + + const sprite = event.target as Sprite; + const node = (sprite as any).node as NodeTypeD3; - if (!dragging.current.node.fx) dragging.current.node.fx = dragging.current.node.x || 0; - if (!dragging.current.node.fy) dragging.current.node.fy = dragging.current.node.y || 0; - dragging.current.node.fx += movementX / (viewport.current?.scaled || 1); - dragging.current.node.fy += movementY / (viewport.current?.scaled || 1); - // force.simulation.alpha(0.1).restart(); + if (event.shiftKey) { + setPopups([...popups, { node: node, pos: toGlobal(node) }]); + } else { + setPopups([{ node: node, pos: toGlobal(node) }]); + for (const popup of popups) { + const sprite = nodeMap.current.get(popup.node._id) as Sprite; + sprite.texture = Assets.get(textureId(false)); + } } + + sprite.texture = Assets.get(textureId(true)); + + props.onClick({ node: node, pos: toGlobal(node) }); + + event.stopPropagation(); }, - onDragEnd() { - if (dragging.current) { - // dragging.current.node.fx = null; - // dragging.current.node.fy = null; - if (viewport.current) viewport.current.pause = false; - if (onlyClicked.current) { - onlyClicked.current = false; - - if (popups.filter((d) => d.node._id === dragging.current?.node._id).length > 0) { - setPopups(popups.filter((p) => p.node._id !== dragging.current?.node._id)); - props.onClick(); - } else { - setPopups([...popups, { node: props.graph.nodes[dragging.current.node._id], pos: toGlobal(dragging.current.node) }]); - props.onClick({ node: dragging.current.node, pos: toGlobal(dragging.current.node) }); - } + onMouseUpStage(event: FederatedPointerEvent) { + if (props.configuration.showPopUpOnHover) return; + + // If its a short click (not a drag) on the stage but not on a node: clear the selection and remove all popups. + const holdDownTime = event.timeStamp - (event as any).mouseDownTimeStamp; + if (holdDownTime < mouseClickThreshold) { + for (const popup of popups) { + const sprite = nodeMap.current.get(popup.node._id) as Sprite; + sprite.texture = Assets.get(textureId(false)); } - this.onHover(dragging.current.node); - dragging.current = null; - } else { + setPopups([]); + props.onClick(); } }, - onHover(node: NodeTypeD3) { + + onHover(event: FederatedPointerEvent) { + if (!props.configuration.showPopUpOnHover) return; + + const sprite = event.target as Sprite; + const node = (sprite as any).node as NodeTypeD3; if ( mouseInCanvas.current && viewport?.current && @@ -188,11 +192,19 @@ export const NLPixi = (props: Props) => { } }, onUnHover() { + if (!props.configuration.showPopUpOnHover) return; + setQuickPopup(undefined); }, - onPan() { - setPopups([]); - props.onClick(); + onMoved(event: MovedEvent) { + if (props.configuration.showPopUpOnHover) return; + + for (const popup of popups) { + if (popup.node.x == null || popup.node.y == null) continue; + popup.pos.x = event.viewport.transform.position.x + popup.node.x * event.viewport.scale.x; + popup.pos.y = event.viewport.transform.position.y + popup.node.y * event.viewport.scale.y; + } + setPopups([...popups]); }, })); @@ -234,21 +246,6 @@ export const NLPixi = (props: Props) => { } else return { x: 0, y: 0 }; } - function onDragStart(event: FederatedPointerEvent, node: NodeTypeD3, gfx: Sprite) { - event.stopPropagation(); - if (imperative.current) imperative.current.onDragStart(node, gfx); - } - - function onDragMove(event: FederatedPointerEvent) { - event.stopPropagation(); - if (imperative.current) imperative.current.onDragMove(event.movementX, event.movementY); - } - - function onDragEnd(event: FederatedPointerEvent) { - event.stopPropagation(); - if (imperative.current) imperative.current.onDragEnd(); - } - const updateNode = (node: NodeTypeD3) => { const gfx = nodeMap.current.get(node._id); if (!gfx) return; @@ -267,20 +264,6 @@ export const NLPixi = (props: Props) => { gfx.position.set(node.x, node.y); - gfx.off('mouseover'); - gfx.off('mousedown'); - gfx.on('mouseover', (e) => { - e.stopPropagation(); - e.preventDefault(); - if (imperative.current) imperative.current.onHover(node); - }); - gfx.on('mouseout', (e) => { - e.stopPropagation(); - e.preventDefault(); - if (imperative.current) imperative.current.onUnHover(); - }); - gfx.on('mousedown', (e) => onDragStart(e, node, gfx)); - // if (!item.position) { // item.position = new Point(node.x, node.y); // } else { @@ -307,23 +290,28 @@ export const NLPixi = (props: Props) => { // Do not draw node if it has no position if (node.x === undefined || node.y === undefined) return; - let gfx: Sprite; + let sprite: Sprite; const texture = Assets.get(textureId()); - gfx = new Sprite(texture); + sprite = new Sprite(texture); - gfx.tint = nodeColor(nodeMeta.type); + sprite.tint = nodeColor(nodeMeta.type); const scale = (Math.max(nodeMeta.radius || 5, 5) / 70) * 2; - gfx.scale.set(scale, scale); - gfx.anchor.set(0.5, 0.5); + sprite.scale.set(scale, scale); + sprite.anchor.set(0.5, 0.5); - nodeMap.current.set(node._id, gfx); - nodeLayer.addChild(gfx); + sprite.eventMode = 'static'; + sprite.on('mousedown', (e) => imperative.current.onMouseDown(e)); + sprite.on('mouseup', (e) => imperative.current.onMouseUpNode(e)); + sprite.on('mouseover', (e) => imperative.current.onHover(e)); + sprite.on('mouseout', (e) => imperative.current.onUnHover(e)); + + nodeMap.current.set(node._id, sprite); + nodeLayer.addChild(sprite); updateNode(node); - gfx.name = 'node_' + node._id; - gfx.eventMode = 'dynamic'; + (sprite as any).node = node; - return gfx; + return sprite; }; // /** UpdateRadius works just like UpdateColors, but also applies radius*/ @@ -512,7 +500,6 @@ export const NLPixi = (props: Props) => { }; const update = (forceClear = false) => { - setPopups([]); if (!props.graph || !ref.current) return; if (props.graph) { @@ -601,14 +588,13 @@ export const NLPixi = (props: Props) => { viewport.current.addChild(linkGfx); viewport.current.addChild(nodeLayer); - viewport.current.on('drag-start', (event) => { - imperative.current.onPan(); + viewport.current.on('moved', (event) => { + imperative.current.onMoved(event); }); app.stage.eventMode = 'dynamic'; - app.stage.on('pointerup', onDragEnd); - app.stage.on('mousemove', onDragMove); - app.stage.on('mouseup', onDragEnd); + app.stage.on('mousedown', (e) => imperative.current.onMouseDown(e)); + app.stage.on('mouseup', (e) => imperative.current.onMouseUpStage(e)); nodeMap.current.clear(); linkGfx.clear(); @@ -639,10 +625,25 @@ export const NLPixi = (props: Props) => { } layoutAlgorithm.current.layout(graphologyGraph, boundingBox); }; + return ( <> - {mouseInCanvas.current && popups.map((popup) => <NLPopup onClose={() => {}} data={popup} key={popup.node._id} />)} - {props.showPopupsOnHover && quickPopup && <NLPopup onClose={() => {}} data={quickPopup} />} + {popups.map((popup) => ( + <Tooltip key={popup.node._id} open={true} boundaryElement={ref} showArrow={true}> + <TooltipTrigger x={popup.pos.x} y={popup.pos.y} /> + <TooltipContent> + <NLPopup onClose={() => {}} data={{node: props.graph.nodes[popup.node._id], pos: popup.pos}} key={popup.node._id} /> + </TooltipContent> + </Tooltip> + ))} + {quickPopup != null && + <Tooltip key={quickPopup.node._id} open={true} boundaryElement={ref} showArrow={true}> + <TooltipTrigger x={quickPopup.pos.x} y={quickPopup.pos.y} /> + <TooltipContent> + <NLPopup onClose={() => {}} data={{node: props.graph.nodes[quickPopup.node._id], pos: quickPopup.pos}} key={quickPopup.node._id} /> + </TooltipContent> + </Tooltip> + } <div className="h-full w-full overflow-hidden" ref={ref} diff --git a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx index 2ff3cd5f6..db7f37d0d 100644 --- a/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx +++ b/libs/shared/lib/vis/visualizations/nodelinkvis/components/NLPopup.tsx @@ -11,9 +11,7 @@ export const NLPopup = (props: NodelinkPopupProps) => { return ( <div - className="absolute card card-bordered z-50 bg-light rounded-none text-[0.9rem] min-w-[10rem] pointer-events-none" - // style={{ top: props.data.pos.y + 10, left: props.data.pos.x + 10 }} - style={{ transform: 'translate(' + (props.data.pos.x + 20) + 'px, ' + (props.data.pos.y + 10) + 'px)' }} + className="text-[0.9rem] min-w-[10rem]" > <div className="card-body p-0"> <span className="px-2.5 pt-2"> -- GitLab