diff --git a/packages/core/bundle/core.ts b/packages/core/bundle/core.ts index acb5ff3e76..3244eeb4df 100644 --- a/packages/core/bundle/core.ts +++ b/packages/core/bundle/core.ts @@ -12,7 +12,7 @@ export { AutoField, FieldLabel } from "../components/AutoField"; export * from "../components/Button"; export { Drawer } from "../components/Drawer"; -export { DropZone } from "../components/DropZone"; +export { DropZone, useSlot } from "../components/DropZone"; export * from "../components/IconButton"; export { Puck } from "../components/Puck"; export * from "../components/Render"; diff --git a/packages/core/components/DropZone/index.tsx b/packages/core/components/DropZone/index.tsx index 45b42071a7..7279c25b0e 100644 --- a/packages/core/components/DropZone/index.tsx +++ b/packages/core/components/DropZone/index.tsx @@ -1,8 +1,9 @@ import { - CSSProperties, Ref, forwardRef, memo, + ReactNode, + RefObject, useCallback, useContext, useEffect, @@ -59,11 +60,6 @@ const getClassName = getClassNameFactory("DropZone", styles); export { DropZoneProvider, dropZoneContext } from "./context"; -const getRandomColor = () => - `#${Math.floor(Math.random() * 16777215).toString(16)}`; - -const RENDER_DEBUG = false; - export type DropZoneDndData = { areaId?: string; depth: number; @@ -293,265 +289,306 @@ const DropZoneChild = ({ const DropZoneChildMemo = memo(DropZoneChild); -export const DropZoneEdit = forwardRef( - function DropZoneEditInternal( - { - zone, - allow, - disallow, - style, - className, - minEmptyHeight: userMinEmptyHeight = "128px", - collisionAxis, - as, - }, - userRef - ) { - const ctx = useContext(dropZoneContext); - const appStoreApi = useAppStoreApi(); - - const { - // These all need setting via context - areaId, - depth = 0, - registerLocalZone, - unregisterLocalZone, - } = ctx ?? {}; - - const path = useAppStore( - useShallow((s) => (areaId ? s.state.indexes.nodes[areaId]?.path : null)) - ); +export const useSlot = ({ + zone, + allow, + disallow, + minEmptyHeight: userMinEmptyHeight = "128px", + collisionAxis, +}: Omit): [ + RefObject, + ReactNode +] => { + const ctx = useContext(dropZoneContext); + const appStoreApi = useAppStoreApi(); + + const { + // These all need setting via context + areaId, + depth = 0, + registerLocalZone, + unregisterLocalZone, + } = ctx ?? {}; + + const path = useAppStore( + useShallow((s) => (areaId ? s.state.indexes.nodes[areaId]?.path : null)) + ); - let zoneCompound = rootDroppableId; + let zoneCompound = rootDroppableId; - if (areaId) { - if (zone !== rootDroppableId) { - zoneCompound = `${areaId}:${zone}`; - } + if (areaId) { + if (zone !== rootDroppableId) { + zoneCompound = `${areaId}:${zone}`; } + } - const isRootZone = - zoneCompound === rootDroppableId || - zone === rootDroppableId || - areaId === "root"; + const isRootZone = + zoneCompound === rootDroppableId || + zone === rootDroppableId || + areaId === "root"; - const inNextDeepestArea = useContextStore( - ZoneStoreContext, - (s) => s.nextAreaDepthIndex[areaId || ""] - ); + const inNextDeepestArea = useContextStore( + ZoneStoreContext, + (s) => s.nextAreaDepthIndex[areaId || ""] + ); - const zoneContentIds = useAppStore( - useShallow((s) => { - return s.state.indexes.zones[zoneCompound]?.contentIds; - }) - ); - const zoneType = useAppStore( - useShallow((s) => { - return s.state.indexes.zones[zoneCompound]?.type; - }) - ); + const zoneContentIds = useAppStore( + useShallow((s) => { + return s.state.indexes.zones[zoneCompound]?.contentIds; + }) + ); + const zoneType = useAppStore( + useShallow((s) => { + return s.state.indexes.zones[zoneCompound]?.type; + }) + ); - // Register zone on mount - useEffect(() => { - if (!zoneType || zoneType === "dropzone") { - if (ctx?.registerZone) { - ctx?.registerZone(zoneCompound); - } + // Register zone on mount + useEffect(() => { + if (!zoneType || zoneType === "dropzone") { + if (ctx?.registerZone) { + ctx?.registerZone(zoneCompound); } - }, [zoneType, appStoreApi]); + } + }, [zoneType, appStoreApi]); - useEffect(() => { - if (zoneType === "dropzone") { - if (zoneCompound !== rootDroppableId) { - console.warn( - "DropZones have been deprecated in favor of slot fields and will be removed in a future version of Puck. Please see the migration guide: https://www.puckeditor.com/docs/guides/migrations/dropzones-to-slots" - ); - } + useEffect(() => { + if (zoneType === "dropzone") { + if (zoneCompound !== rootDroppableId) { + console.warn( + "DropZones have been deprecated in favor of slot fields and will be removed in a future version of Puck. Please see the migration guide: https://www.puckeditor.com/docs/guides/migrations/dropzones-to-slots" + ); + } + } + }, [zoneType]); + + const contentIds = useMemo(() => { + return zoneContentIds || []; + }, [zoneContentIds]); + + const acceptsTarget = useCallback( + (componentType: string | null | undefined) => { + if (!componentType) { + return true; } - }, [zoneType]); - const contentIds = useMemo(() => { - return zoneContentIds || []; - }, [zoneContentIds]); + if (disallow) { + const defaultedAllow = allow || []; - const ref = useRef(null); + // remove any explicitly allowed items from disallow + const filteredDisallow = (disallow || []).filter( + (item) => defaultedAllow.indexOf(item) === -1 + ); - const acceptsTarget = useCallback( - (componentType: string | null | undefined) => { - if (!componentType) { - return true; + if (filteredDisallow.indexOf(componentType) !== -1) { + return false; + } + } else if (allow) { + if (allow.indexOf(componentType) === -1) { + return false; } + } - if (disallow) { - const defaultedAllow = allow || []; + return true; + }, + [allow, disallow] + ); - // remove any explicitly allowed items from disallow - const filteredDisallow = (disallow || []).filter( - (item) => defaultedAllow.indexOf(item) === -1 - ); + const targetAccepted = useContextStore(ZoneStoreContext, (s) => { + const draggedComponentType = s.draggedItem?.data.componentType; + return acceptsTarget(draggedComponentType); + }); - if (filteredDisallow.indexOf(componentType) !== -1) { - return false; - } - } else if (allow) { - if (allow.indexOf(componentType) === -1) { - return false; - } - } + const hoveringOverArea = inNextDeepestArea || isRootZone; - return true; - }, - [allow, disallow] - ); + const isEnabled = useContextStore(ZoneStoreContext, (s) => { + let _isEnabled = true; + const isDeepestZone = s.zoneDepthIndex[zoneCompound] ?? false; - const targetAccepted = useContextStore(ZoneStoreContext, (s) => { - const draggedComponentType = s.draggedItem?.data.componentType; - return acceptsTarget(draggedComponentType); - }); + _isEnabled = isDeepestZone; - const hoveringOverArea = inNextDeepestArea || isRootZone; + if (_isEnabled) { + _isEnabled = targetAccepted; + } - const isEnabled = useContextStore(ZoneStoreContext, (s) => { - let _isEnabled = true; - const isDeepestZone = s.zoneDepthIndex[zoneCompound] ?? false; + return _isEnabled; + }); - _isEnabled = isDeepestZone; + useEffect(() => { + if (registerLocalZone) { + registerLocalZone(zoneCompound, targetAccepted || isEnabled); + } - if (_isEnabled) { - _isEnabled = targetAccepted; + return () => { + if (unregisterLocalZone) { + unregisterLocalZone(zoneCompound); } + }; + }, [targetAccepted, isEnabled, zoneCompound]); - return _isEnabled; - }); + const [contentIdsWithPreview, preview] = useContentIdsWithPreview( + contentIds, + zoneCompound + ); - useEffect(() => { - if (registerLocalZone) { - registerLocalZone(zoneCompound, targetAccepted || isEnabled); - } + const isDropEnabled = + isEnabled && + (preview + ? contentIdsWithPreview.length === 1 + : contentIdsWithPreview.length === 0); - return () => { - if (unregisterLocalZone) { - unregisterLocalZone(zoneCompound); - } - }; - }, [targetAccepted, isEnabled, zoneCompound]); + const zoneStore = useContext(ZoneStoreContext); - const [contentIdsWithPreview, preview] = useContentIdsWithPreview( - contentIds, - zoneCompound - ); + useEffect(() => { + const { enabledIndex } = zoneStore.getState(); + zoneStore.setState({ + enabledIndex: { ...enabledIndex, [zoneCompound]: isEnabled }, + }); + }, [isEnabled, zoneStore, zoneCompound]); + + const droppableConfig: UseDroppableInput = { + id: zoneCompound, + collisionPriority: isEnabled ? depth : 0, + disabled: !isDropEnabled, + collisionDetector: pointerIntersection, + type: "dropzone", + data: { + areaId, + depth, + isDroppableTarget: targetAccepted, + path: path || [], + }, + }; - const isDropEnabled = - isEnabled && - (preview - ? contentIdsWithPreview.length === 1 - : contentIdsWithPreview.length === 0); + const ref = useRef(null); - const zoneStore = useContext(ZoneStoreContext); + const { ref: dropRef } = useDroppable(droppableConfig); - useEffect(() => { - const { enabledIndex } = zoneStore.getState(); - zoneStore.setState({ - enabledIndex: { ...enabledIndex, [zoneCompound]: isEnabled }, - }); - }, [isEnabled, zoneStore, zoneCompound]); - - const droppableConfig: UseDroppableInput = { - id: zoneCompound, - collisionPriority: isEnabled ? depth : 0, - disabled: !isDropEnabled, - collisionDetector: pointerIntersection, - type: "dropzone", - data: { - areaId, - depth, - isDroppableTarget: targetAccepted, - path: path || [], - }, - }; + const isAreaSelected = useAppStore( + (s) => s?.selectedItem && areaId === s?.selectedItem.props.id + ); - const { ref: dropRef } = useDroppable(droppableConfig); + const [dragAxis] = useDragAxis(ref, collisionAxis); - const isAreaSelected = useAppStore( - (s) => s?.selectedItem && areaId === s?.selectedItem.props.id - ); + const [minEmptyHeight, isAnimating] = useMinEmptyHeight({ + zoneCompound, + userMinEmptyHeight, + ref, + }); - const [dragAxis] = useDragAxis(ref, collisionAxis); + const _experimentalVirtualization = useAppStore( + (s) => s._experimentalVirtualization + ); + const isRootAreaZone = (areaId ?? rootAreaId) === rootAreaId && depth === 0; + const shouldVirtualizeItems = _experimentalVirtualization && isRootAreaZone; + + useEffect(() => { + if (ref.current) { + dropRef(ref.current); + + const classesToRemove = [ + getClassName(), + getClassName({ isRootZone: true }, { excludeBase: true }), + getClassName({ hoveringOverArea: true }, { excludeBase: true }), + getClassName({ isEnabled: true }, { excludeBase: true }), + getClassName({ isAreaSelected: true }, { excludeBase: true }), + getClassName({ hasChildren: true }, { excludeBase: true }), + getClassName({ isAnimating: true }, { excludeBase: true }), + ].filter((c) => c); + + ref.current.classList.remove(...classesToRemove); + + const classesToAdd = [ + getClassName(), + getClassName({ isRootZone }, { excludeBase: true }), + getClassName({ hoveringOverArea }, { excludeBase: true }), + getClassName({ isEnabled }, { excludeBase: true }), + getClassName({ isAreaSelected }, { excludeBase: true }), + getClassName( + { hasChildren: contentIds.length > 0 }, + { excludeBase: true } + ), + getClassName({ isAnimating }, { excludeBase: true }), + ].filter((c) => c); + + ref.current.classList.add(...classesToAdd); + + ref.current.setAttribute("data-testid", `dropzone:${zoneCompound}`); + ref.current.setAttribute("data-puck-dropzone", zoneCompound); + if (typeof minEmptyHeight !== "undefined") { + ref.current.style.setProperty( + "--min-empty-height", + typeof minEmptyHeight === "number" + ? `${minEmptyHeight}px` + : minEmptyHeight + ); + } + } + }, [ + ref, + isRootZone, + hoveringOverArea, + isEnabled, + isAreaSelected, + contentIds.length, + isAnimating, + zoneCompound, + minEmptyHeight, + ]); + + return [ + ref, + shouldVirtualizeItems ? ( + ( + + )} + /> + ) : ( + contentIdsWithPreview.map((componentId, i) => { + return ( + + ); + }) + ), + ]; +}; - const [minEmptyHeight, isAnimating] = useMinEmptyHeight({ - zoneCompound, - userMinEmptyHeight, - ref, - }); +export const DropZoneEdit = forwardRef( + function DropZoneEditInternal(props, userRef) { + const [ref, children] = useSlot(props); const setRefs = useCallback( (node: any) => { - assignRefs([ref, dropRef, userRef], node); + assignRefs([ref, userRef], node); }, - [dropRef] - ); - - const _experimentalVirtualization = useAppStore( - (s) => s._experimentalVirtualization + [ref, userRef] ); - const El = as ?? "div"; - const isRootAreaZone = (areaId ?? rootAreaId) === rootAreaId && depth === 0; - const shouldVirtualizeItems = _experimentalVirtualization && isRootAreaZone; + const El = props.as ?? "div"; return ( - 0, - isAnimating, - })}${className ? ` ${className}` : ""}`} - ref={setRefs} - data-testid={`dropzone:${zoneCompound}`} - data-puck-dropzone={zoneCompound} - style={ - { - ...style, - "--min-empty-height": minEmptyHeight, - backgroundColor: RENDER_DEBUG - ? getRandomColor() - : style?.backgroundColor, - } as CSSProperties - } - > - {shouldVirtualizeItems ? ( - ( - - )} - /> - ) : ( - contentIdsWithPreview.map((componentId, i) => ( - - )) - )} + + {children} ); } diff --git a/packages/core/components/DropZone/lib/use-min-empty-height.ts b/packages/core/components/DropZone/lib/use-min-empty-height.ts index 9f73edf623..fea874496d 100644 --- a/packages/core/components/DropZone/lib/use-min-empty-height.ts +++ b/packages/core/components/DropZone/lib/use-min-empty-height.ts @@ -14,8 +14,8 @@ export const useMinEmptyHeight = ({ }: { zoneCompound: string; userMinEmptyHeight: CSSProperties["minHeight"] | number; - ref: RefObject; -}) => { + ref: RefObject; +}): [string | number | undefined, boolean] => { const appStore = useAppStoreApi(); const [prevHeight, setPrevHeight] = useState(0); const [isAnimating, setIsAnimating] = useState(false); diff --git a/packages/core/lib/get-class-name-factory.ts b/packages/core/lib/get-class-name-factory.ts index 8148f4473e..d185806799 100644 --- a/packages/core/lib/get-class-name-factory.ts +++ b/packages/core/lib/get-class-name-factory.ts @@ -25,7 +25,7 @@ const getClassNameFactory = styles: Record, config: { baseClass?: string } = { baseClass: "" } ) => - (options: Options = {}) => { + (options: Options = {}, params: { excludeBase?: boolean } = {}) => { if (typeof options === "string") { const descendant = options; @@ -46,7 +46,7 @@ const getClassNameFactory = modifiers[modifier]; } - const c = styles[rootClass]; + const c = !params.excludeBase ? styles[rootClass] : ""; return ( config.baseClass +