import React, {FC, useEffect, useLayoutEffect, useMemo, useRef, useState} from "react"; import {findDataForNode, TestableCompositeData, useNodesStore} from './store'; import {useShallow} from 'zustand/react/shallow'; import {Context, ContextProps} from "./Context"; import {testableTypeLabel} from "./testables"; import classNames from "classnames"; import {useLazyTestables} from "./hooks"; import {ColorOptionWithCustomTint} from "./Color"; import {atom, useAtomValue, useSetAtom} from "jotai"; import {HoveredTestableCompositeIDAtom, OverlayOpenCompositeAtom, ProUpgradePopupWindowIsOpen} from "./atoms"; import {TestableCompositeRemoveButton} from "./TestableCompositeRemoveButton"; import {PlaceholderContext} from "./PlaceholderContext"; import {AnimatePresence, motion} from 'motion/react'; import {TestableGroupSeparator} from "./TestableGroupSeparator"; import {useSuggestedScopeForTestableId} from "./getSuggestedScopeForTestable"; import {__, hasProInstalled} from "../globals"; import {FloatingMarker} from "./FloatingMarker"; import {polygonContains} from "d3-polygon"; import {EditHintTextStrip} from "./EditHintTextStrip"; import {RightContextButtons} from "./RightContextButtons"; import {getColorForComponentType} from "./Colors"; export const InsideOverlayContext = React.createContext(false); export type TestableCompositeProps = Pick & Partial> & Partial & Partial> & { isRoot?: boolean, hardHighlight?: boolean, removable?: boolean, confirmRemoval?: boolean, floatButtons?: boolean, forceExpanded?: boolean, } interface NodeData { data: TestableCompositeData; children: TestableCompositeData['id'][]; } const BORDER_PROXIMITY_PADDING = 40; export const TestableComposite: FC = React.memo(({ id, color, buttonsDisplay, buttonsAreEnabled, showRightContextButtonsLabel, hardHighlight, isRoot = false, removable = true, confirmRemoval, floatButtons, forceExpanded, supportsMultipleComponents, unsupportedComponents }) => { const {data, children}: NodeData = useNodesStore(useShallow(findDataForNode(id))) // this is the id of the testable composite that might be used inside callbacks where they define the id of the current iterable item const originalID = id const setHoveredTestableCompositeID = useSetAtom(HoveredTestableCompositeIDAtom) const testableCompositeRef = useRef(null) const testableCompositeBorderRef = useRef(null) const [canShowPlaceholder, setCanShowPlaceholder] = useState(false) const [isWithinBorderProximity, setIsWithinBorderProximity] = useState(false) const testables = useLazyTestables() const testablesRef = useRef(testables) testablesRef.current = testables const myRootId = testables.getRootNode(id)?.id ?? id const isGroupHoveredAtom = useMemo(() => atom(get => { const hoveredId = get(HoveredTestableCompositeIDAtom) if (!hoveredId) return false const hoveredRootId = testablesRef.current.getRootNode(hoveredId)?.id ?? hoveredId return hoveredRootId === myRootId }), [myRootId]) const isGroupHovered = useAtomValue(isGroupHoveredAtom) const isExactHoveredAtom = useMemo(() => atom(get => get(HoveredTestableCompositeIDAtom) === id), [id]) const isExactHovered = useAtomValue(isExactHoveredAtom) const isInsideOverlay = React.useContext(InsideOverlayContext) const isOverlayOpenAtom = useMemo(() => atom(get => { const overlay = get(OverlayOpenCompositeAtom) return !!overlay && overlay.rootId === myRootId }), [myRootId]) const isOverlayOpen = useAtomValue(isOverlayOpenAtom) const setOverlayState = useSetAtom(OverlayOpenCompositeAtom) const [frozenDims, setFrozenDims] = useState<{width: number, height: number} | null>(null) useLayoutEffect(() => { if (isOverlayOpen && !forceExpanded && !isInsideOverlay && testableCompositeRef.current) { const rect = testableCompositeRef.current.getBoundingClientRect() setFrozenDims({width: rect.width, height: rect.height}) } else if (!isOverlayOpen) { setFrozenDims(null) } }, [isOverlayOpen, forceExpanded, isInsideOverlay]) const setIsProUpgradePopupOpen = useSetAtom(ProUpgradePopupWindowIsOpen) // hard highlighted means a darker, full color // soft highlighted means a lighter, color used for general hovering const [isHardHighlighted, setIsHardHighlighted] = useState(false) const contextsParentRef = useRef(null); const suggestedScope = useSuggestedScopeForTestableId(originalID) const onRightButtonsClick = !hasProInstalled ? (_type: string) => setIsProUpgradePopupOpen({ isOpen: true, title: __('Create complex product combinations with Pro'), }) : undefined useEffect(() => { if (children.length <= 1) { setIsWithinBorderProximity(false) return; } const onMouseMove = (event: MouseEvent) => { const element = testableCompositeRef.current; if (!element) { setIsWithinBorderProximity(false) return; } const rect = element.getBoundingClientRect() const boxApproximation: [number, number][] = [ [rect.left - BORDER_PROXIMITY_PADDING, rect.top - BORDER_PROXIMITY_PADDING], [rect.right + BORDER_PROXIMITY_PADDING, rect.top - BORDER_PROXIMITY_PADDING], [rect.right + BORDER_PROXIMITY_PADDING, rect.bottom + BORDER_PROXIMITY_PADDING], [rect.left - BORDER_PROXIMITY_PADDING, rect.bottom + BORDER_PROXIMITY_PADDING], ]; const isInsideApproximation = polygonContains(boxApproximation, [event.clientX, event.clientY]) setIsWithinBorderProximity(currentState => currentState === isInsideApproximation ? currentState : isInsideApproximation) }; const onWindowMouseLeave = () => { setIsWithinBorderProximity(false) }; window.addEventListener('mousemove', onMouseMove, {passive: true}); window.addEventListener('mouseleave', onWindowMouseLeave); return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseleave', onWindowMouseLeave); } }, [children.length]); useEffect(() => { if (forceExpanded) return; const element = testableCompositeRef.current; if (!element) { return } const onNativeClick = (event: MouseEvent) => { if (event.button !== 0) { return; } const target = event.target as Element | null if (target?.closest('button, a, input, textarea, select, [role="button"]')) { return; } //event.stopPropagation(); if (children.length > 1 && isRoot && !isInsideOverlay) { const rect = element.getBoundingClientRect(); setOverlayState({id, rootId: myRootId, rect}); } else { setHoveredTestableCompositeID(id) } }; element.addEventListener('click', onNativeClick); return () => { element.removeEventListener('click', onNativeClick); } }, [id, children.length, isRoot, myRootId, isInsideOverlay, forceExpanded, setHoveredTestableCompositeID, setOverlayState]); useEffect(() => { if (!isExactHovered || forceExpanded) { return } const onDocumentClick = (event: MouseEvent) => { const element = testableCompositeRef.current; const target = event.target as Node | null; if (!element || !target) { setHoveredTestableCompositeID(null) return; } if (!element.contains(target)) { setHoveredTestableCompositeID(null) } }; document.addEventListener('click', onDocumentClick); return () => { document.removeEventListener('click', onDocumentClick); } }, [isExactHovered, forceExpanded, setHoveredTestableCompositeID]); if (!data) { return } //else re-draw each child as a testable composite const isDefaultGroup = children.length === 1; const normalizedType = `${data.type ?? ''}`.toLowerCase() const isAndType = normalizedType === 'and' const isAnyOrType = normalizedType.includes('or') const isRootSingleContextGroup = isRoot && isDefaultGroup let isHovered = (forceExpanded || isInsideOverlay) ? true : (!isInsideOverlay && isOverlayOpen ? false : false /*isGroupHovered*/); const resolvedColor = color || getColorForComponentType( data.mode === 'test' ? 'condition' : data.mode, {conditionPalette: data.conditionColorPalette} ) // Compute effective value: respect explicit false from external callers, // otherwise derive from own hover state (each child reads its own atom). const effectiveButtonsAreEnabled = buttonsAreEnabled ?? (isRootSingleContextGroup || isHovered) const childButtonsAreEnabled = buttonsAreEnabled === false ? false : ((buttonsAreEnabled === true || isRootSingleContextGroup) ? true : undefined) //if context, draw a context if (normalizedType === 'context') { return ; } const shouldBeHardHighlighted = hardHighlight || isHardHighlighted; const shouldShowStyledContainer = !isDefaultGroup; const shouldShowGroupRightContextButtons = effectiveButtonsAreEnabled && !isDefaultGroup && !frozenDims && (isInsideOverlay || isRoot || isExactHovered) const styledContainerStyles = shouldShowStyledContainer ? { paddingTop: 16, paddingBottom: 16, paddingLeft: 14, paddingRight: 14, borderRadius: 24, } : undefined // Snappier spring for placeholder reveal/dismiss const revealSpring = {type: 'spring', stiffness: 420, damping: 28, mass: 0.6} const separator = {testableTypeLabel(data.type)} ; return {isRoot && shouldShowStyledContainer && !frozenDims && !isInsideOverlay && } {removable && !isDefaultGroup && ( setIsHardHighlighted(true)} onRequestToUnHighlight={() => setIsHardHighlighted(false)} /> )} {shouldShowGroupRightContextButtons && }
{isRoot && !isInsideOverlay && suggestedScope === 'bogo' && ()}
{children.map((childId, index) => { const isLast = index + 1 === children.length; return
{!isLast && separator} {!isDefaultGroup && isLast && ( {isHovered && {separator}
}
)}
})}
}) const getTheHighestWidthExcludingPlaceholder = (element: HTMLElement | null) => { if (element) { let highestWidth = 0; for (let i = 0; i < element.children.length; i++) { const child = element.children[i]; if (!child.className.includes('cp-placeholder')) { const currentWidth = child.getBoundingClientRect().width; if (highestWidth < currentWidth) { highestWidth = currentWidth } } } return highestWidth } }