diff --git a/src/main/frontend/app/app.css b/src/main/frontend/app/app.css index 850cf13a..da4331a0 100644 --- a/src/main/frontend/app/app.css +++ b/src/main/frontend/app/app.css @@ -63,6 +63,19 @@ --palette-parameters: #1389ff; --palette-monitoring: #00ff84; --palette-transactionalstorages: #fdc300; + + /* Label styling */ + --label-components: #edfeea; + --label-category: #feeaf0; + --label-eip: #eaf0fe; + --label-type: #feeaf0; + --label-deprecated: #a8a8a8; + + --label-border-components: #a5f994; + --label-border-category: #f994b3; + --label-border-eip: #94b3f9; + --label-border-type: #f994b3; + --label-border-deprecated: #444444; } [data-theme='dark'] { @@ -104,6 +117,19 @@ --palette-parameters: #0d4a87; --palette-monitoring: #01924c; --palette-transactionalstorages: #9c7800; + + /* Label styling */ + --label-components: #20930c; + --label-category: #bf063e; + --label-eip: #123ea2; + --label-type: #bf063e; + --label-deprecated: #444444; + + --label-border-components: #89be7e; + --label-border-category: #e37294; + --label-border-eip: #5174c7; + --label-border-type: #e37294; + --label-border-deprecated: #b6b6b6; } } diff --git a/src/main/frontend/app/routes/studio/context/element-hover-card.tsx b/src/main/frontend/app/routes/studio/context/element-hover-card.tsx new file mode 100644 index 00000000..e1e3fcb5 --- /dev/null +++ b/src/main/frontend/app/routes/studio/context/element-hover-card.tsx @@ -0,0 +1,142 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import type { ElementDetails } from '~/types/ff-doc.types' +import { getFirstLabelGroup } from '~/utils/flow-utils' +import ExternalLinkIcon from '../../../../icons/solar/External Link.svg?react' + +interface ElementHoverCardProps { + anchorRect: DOMRect + element: ElementDetails + isLocked: boolean + onComplete?: () => void + onUnlock?: () => void +} + +export default function ElementHoverCard({ + anchorRect, + element, + isLocked, + onComplete, + onUnlock, +}: ElementHoverCardProps) { + const ref = useRef(null) + const [position, setPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0 }) + const [fillWidth, setFillWidth] = useState(0) + const offset = 10 // distance between anchor and tooltip + const [labelGroup, label] = getFirstLabelGroup(element.labels) + const route = element.labels ? [labelGroup, label, element.name].join('/') : element.className + const frankdocUrl = `https://frankdoc.frankframework.org/#/${route}` + + useLayoutEffect(() => { + if (!ref.current || !anchorRect) return + + const tooltip = ref.current + const tooltipHeight = tooltip.offsetHeight + const tooltipWidth = tooltip.offsetWidth + + const viewportHeight = window.innerHeight + const margin = 8 // space from top/bottom of screen + + // Desired centered position + const centeredTop = anchorRect.top + anchorRect.height / 2 - tooltipHeight / 2 + + // Clamp within viewport + const clampedTop = Math.min(Math.max(centeredTop, margin), viewportHeight - tooltipHeight - margin) + + const left = anchorRect.left - tooltipWidth - offset + + setPosition({ + top: clampedTop, + left, + }) + }, [anchorRect]) + + useEffect(() => { + const timeout = setTimeout(() => { + setFillWidth(100) + }, 50) + return () => clearTimeout(timeout) + }, [onComplete]) + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (!isLocked) return + if (!ref.current) return + + if (!ref.current.contains(event.target as Node)) { + onUnlock?.() + } + } + + document.addEventListener('pointerdown', handleClickOutside, true) + + return () => { + document.removeEventListener('pointerdown', handleClickOutside, true) + } + }, [isLocked, onUnlock]) + + return createPortal( +
+
+
{ + if (fillWidth === 100) { + onComplete?.() + } + }} + /> +
+
+

{element.labels.Components}

+

+ + {element.name} + + +

+
+ {element.deprecated && ( + + Deprecated + + )} + {element.labels && + Object.entries(element.labels).map(([key, value]) => ( + + {value} + + ))} +
+ {element.description &&

} +

+
, + document.body, + ) +} diff --git a/src/main/frontend/app/routes/studio/context/sorted-elements.tsx b/src/main/frontend/app/routes/studio/context/sorted-elements.tsx index 4327106f..18be7a49 100644 --- a/src/main/frontend/app/routes/studio/context/sorted-elements.tsx +++ b/src/main/frontend/app/routes/studio/context/sorted-elements.tsx @@ -7,6 +7,7 @@ import type { ElementDetails } from '@frankframework/ff-doc' import { getElementTypeFromName } from '../node-translator-module' import DangerIcon from '../../../../icons/solar/Danger Triangle.svg?react' import { DeprecatedListPopover } from './deprecated-list-popover' +import ElementHoverCard from './element-hover-card' interface Properties { type: string @@ -19,6 +20,9 @@ export default function SortedElements({ type, items, onDragStart, searchTerm }: const [isExpanded, setIsExpanded] = useState(false) const gradientEnabled = useSettingsStore((state) => state.studio.gradient) const { setDraggedName } = useNodeContextStore((state) => state) + const [hoveredRect, setHoveredRect] = useState(null) + const [hoveredElement, setHoveredElement] = useState(null) + const [lockedElement, setLockedElement] = useState(null) const toggleExpansion = () => { setIsExpanded(!isExpanded) @@ -38,8 +42,12 @@ export default function SortedElements({ type, items, onDragStart, searchTerm }: onClick={toggleExpansion} className="text-foreground-muted hover:text-foreground-active flex w-full cursor-pointer items-center gap-1 text-left text-sm font-semibold capitalize" > - {shouldExpand ? : } - {type} + {shouldExpand ? ( + + ) : ( + + )} + {type} {shouldExpand && ( @@ -52,8 +60,24 @@ export default function SortedElements({ type, items, onDragStart, searchTerm }: key={value.name} className="border-border m-2 flex cursor-move items-center justify-between rounded border p-4" draggable - onDragStart={onDragStart(value)} + onDragStart={(event) => { + setHoveredRect(null) + setHoveredElement(null) + setLockedElement(null) + onDragStart(value)(event) + }} onDragEnd={() => setDraggedName(null)} + onMouseEnter={(event) => { + setLockedElement(null) // unlock previous element + const rect = event.currentTarget.getBoundingClientRect() + setHoveredRect(rect) + setHoveredElement(value) + }} + onMouseLeave={() => { + if (lockedElement?.name === value.name) return + setHoveredElement(null) + setHoveredRect(null) + }} style={{ background: gradientEnabled ? `radial-gradient( @@ -79,6 +103,20 @@ export default function SortedElements({ type, items, onDragStart, searchTerm }: })}
)} + {(hoveredRect && hoveredElement) || lockedElement ? ( + setLockedElement(hoveredElement)} + onUnlock={() => { + setLockedElement(null) + setHoveredElement(null) + setHoveredRect(null) + }} + /> + ) : null} ) } diff --git a/src/main/frontend/app/utils/flow-utils.ts b/src/main/frontend/app/utils/flow-utils.ts index c8f87abd..5cb42703 100644 --- a/src/main/frontend/app/utils/flow-utils.ts +++ b/src/main/frontend/app/utils/flow-utils.ts @@ -30,6 +30,15 @@ export function cloneWithRemappedIds(value: T, idMap: Map, ge return value } +// Helper function from frank-doc https://github.com/frankframework/frank-doc/blob/master/frank-doc-frontend/src/app/app.service.ts +export function getFirstLabelGroup(filters: Record | undefined): [string, string] { + const defaultLabelGroup: [string, string] = ['-', '-'] + if (!filters) return defaultLabelGroup + const labelGroups = Object.entries(filters) + if (labelGroups.length === 0) return defaultLabelGroup + return labelGroups[0] +} + /** Converts the tagname of a non capitalized element that has a classname attribute to the last part of said classname, e.g.: * * Becomes diff --git a/src/main/frontend/icons/solar/External Link.svg b/src/main/frontend/icons/solar/External Link.svg new file mode 100644 index 00000000..091713aa --- /dev/null +++ b/src/main/frontend/icons/solar/External Link.svg @@ -0,0 +1 @@ + \ No newline at end of file