diff --git a/src/design/messaging-api/api-types.ts b/src/design/messaging-api/api-types.ts index 2cd83434..8264a057 100644 --- a/src/design/messaging-api/api-types.ts +++ b/src/design/messaging-api/api-types.ts @@ -84,8 +84,6 @@ export interface ClientEventNameMapping extends IsomorphicEventNameMapping { ClientWindowDragDropped: Domain.ClientWindowDragDroppedEvent; ComponentPropertiesChanged: Domain.ComponentPropertiesChangedEvent; MediaChangedEvent: Domain.MediaChangedEvent; - ClientWindowBoundsHoverOver: Domain.ClientWindowBoundsHoverOverEvent; - ClientWindowBoundsHoverOut: Domain.ClientWindowBoundsHoverOutEvent; ComponentFocused: Domain.ComponentFocusedEvent; } @@ -139,7 +137,7 @@ export type EventHandler< Partial & TMapping[TEvent]> > : Readonly & TMapping[TEvent]> -) => void; +) => unknown; /** * An emitter that will perform the underlying communication with the client or host. @@ -730,25 +728,6 @@ export interface HostApi extends IsomorphicApi { event: EventPayload> ): void; - /** - * Notifies the host that the client window bounds have been hovered over. - * - * @param event - The client window bounds hover over event - * @stability development - */ - notifyClientWindowBoundsHoverOver( - event: EventPayload - ): void; - - /** - * Notifies the host that the client window bounds have been hovered out. - * - * @param event - The client window bounds hover out event - * @stability development - */ - notifyClientWindowBoundsHoverOut( - event: EventPayload - ): void; /** * Notifies the host that a component has been focused. * diff --git a/src/design/messaging-api/client.ts b/src/design/messaging-api/client.ts index d45c3124..1f075025 100644 --- a/src/design/messaging-api/client.ts +++ b/src/design/messaging-api/client.ts @@ -85,7 +85,7 @@ export function createClientApi({ messenger.connect(); subscriptions.push( - messenger.on('ClientAcknowledged', event => { + messenger.on('ClientAcknowledged', async event => { if (event.meta.hostId === messenger.getRemoteId()) { // We've already been acknowledged by the host in this case. return; @@ -95,13 +95,15 @@ export function createClientApi({ messenger.setRemoteId(event.meta.hostId as string); clearConnectionTimeout(); - prepareClient() - .then(() => { - isReady = true; - messenger.emit('ClientReady', {clientId: id}); - onHostConnected?.(hostConfig as ClientAcknowledgedEvent); - }) - .catch(error => onError?.(error)); + try { + await prepareClient(); + + isReady = true; + messenger.emit('ClientReady', {clientId: id}); + onHostConnected?.(hostConfig); + } catch (error) { + onError?.(error as Error); + } }) ); diff --git a/src/design/messaging-api/domain-types.ts b/src/design/messaging-api/domain-types.ts index 012831cf..199a282d 100644 --- a/src/design/messaging-api/domain-types.ts +++ b/src/design/messaging-api/domain-types.ts @@ -295,28 +295,6 @@ export interface MediaChangedEvent extends WithBaseEvent { eventType: 'MediaChanged'; } -/** - * Emits when the user's cursor hovers over the bounds of the client window. - * Used to indicate that the pointer has entered a sensitive boundary area. - * @target client - */ -export interface ClientWindowBoundsHoverOverEvent extends WithBaseEvent { - eventType: 'ClientWindowBoundsHoverOver'; - /** - * The distance in pixels from the window edge where the hover occurred. - */ - delta: number; -} - -/** - * Emits when the user's cursor leaves the bounds of the client window. - * Used to indicate that the pointer has exited a sensitive boundary area. - * @target client - */ -export interface ClientWindowBoundsHoverOutEvent extends WithBaseEvent { - eventType: 'ClientWindowBoundsHoverOut'; -} - /// //////////////////////////////////////////////////////////////////////////// // Isomorphic Events - Events that are subscribed to on both client and host side. // /// //////////////////////////////////////////////////////////////////////////// diff --git a/src/design/messaging-api/host.ts b/src/design/messaging-api/host.ts index 4157c1f2..a71d74d0 100644 --- a/src/design/messaging-api/host.ts +++ b/src/design/messaging-api/host.ts @@ -61,12 +61,6 @@ export function createHostApi({ notifyWindowScrollChanged: messenger.toEmitter('WindowScrollChanged'), notifyPageSettingsChanged: messenger.toEmitter('PageSettingsChanged'), notifyMediaChanged: () => messenger.emit('MediaChangedEvent', {}), - notifyClientWindowBoundsHoverOver: messenger.toEmitter( - 'ClientWindowBoundsHoverOver' - ), - notifyClientWindowBoundsHoverOut: messenger.toEmitter( - 'ClientWindowBoundsHoverOut' - ), notifyError: messenger.toEmitter('Error'), focusComponent: messenger.toEmitter('ComponentFocused'), connect: ({ @@ -99,7 +93,7 @@ export function createHostApi({ ); subscriptions.push( - messenger.on('ClientInitialized', event => { + messenger.on('ClientInitialized', async event => { const remoteId = messenger.getRemoteId(); // If the same client tries reconnecting, we should allow it. @@ -107,21 +101,21 @@ export function createHostApi({ if ((remoteId && event.meta.clientId === remoteId) || !remoteId) { messenger.setRemoteId(event.meta.clientId as string); - configFactory() - .then(config => { - messenger.emit('ClientAcknowledged', config); + try { + const config = await configFactory(); + + messenger.emit('ClientAcknowledged', config); + + const {clientId} = await messenger.toPromise('ClientReady'); - return messenger.toPromise('ClientReady'); - }) - .then(({clientId}) => { - if (clientId !== messenger.getRemoteId()) { - throw new Error('Client id mismatch'); - } + if (clientId !== messenger.getRemoteId()) { + throw new Error('Client id mismatch'); + } - return clientId; - }) - .then(clientId => onClientConnected?.(clientId)) - .catch(error => onError?.(error)); + onClientConnected?.(clientId); + } catch (error) { + onError?.(error as Error); + } } }) ); diff --git a/src/design/react/components/DesignComponent.tsx b/src/design/react/components/DesignComponent.tsx index 9b3a988e..c6695f8f 100644 --- a/src/design/react/components/DesignComponent.tsx +++ b/src/design/react/components/DesignComponent.tsx @@ -18,6 +18,7 @@ import { ComponentContextType, useComponentContext, } from '../context/ComponentContext'; +import {useComponentDiscovery} from '../hooks/useComponentDiscovery'; export function DesignComponent( props: ComponentDecoratorProps @@ -29,15 +30,19 @@ export function DesignComponent( const dragRef = useRef(null); const {regionId, regionDirection} = useRegionContext() ?? {}; const {componentId: parentComponentId} = useComponentContext() ?? {}; + const {nodeToTargetMap} = useDesignState(); const { selectedComponentId, hoveredComponentId, setSelectedComponent, setHoveredComponent, - dragState: {isDragging}, + dragState: {isDragging, sourceComponentId: draggingSourceComponentId}, } = useDesignState(); + const isDraggingComponent = + isDragging && draggingSourceComponentId === componentId; + useFocusedComponentHandler(componentId, dragRef); useNodeToTargetStore({ type: 'component', @@ -48,14 +53,31 @@ export function DesignComponent( componentId, }); + const discoverComponents = useComponentDiscovery({ + nodeToTargetMap, + }); + const handleMouseEnter = useDesignCallback( () => setHoveredComponent(componentId), [setHoveredComponent, componentId] ); const handleMouseLeave = useDesignCallback( - () => setHoveredComponent(null), - [setHoveredComponent] + (event: React.MouseEvent) => { + // If we hover off a component, we could still be hovering over a parent component + // that contains that child. In this instance, the mouse enter doesn't fire and that parent + // would not be highlighted. Everytime we leave a component, we check + // if we are hovering over a component at those coordinates. If we are, + // we set the hovered component to that component. + const components = discoverComponents({ + x: event.clientX, + y: event.clientY, + filter: entry => entry.type === 'component', + }); + + setHoveredComponent(components[0]?.componentId ?? null); + }, + [setHoveredComponent, nodeToTargetMap] ); const handleClick = useDesignCallback( @@ -81,8 +103,15 @@ export function DesignComponent( ); // Makes the component a drop target. - const handleDragOver = (event: React.DragEvent) => - event.preventDefault(); + const handleDragOver = React.useCallback( + (event: React.DragEvent) => { + // If we are moving a component, don't let it be droppable on itself. + if (draggingSourceComponentId !== componentId) { + event.preventDefault(); + } + }, + [draggingSourceComponentId, componentId] + ); return ( /* eslint-disable jsx-a11y/click-events-have-key-events */ @@ -90,24 +119,24 @@ export function DesignComponent(
- {showFrame && ( - - )} - - {children} - +
+ + + {children} + +
); } diff --git a/src/design/react/components/DesignFrame.tsx b/src/design/react/components/DesignFrame.tsx index 14625651..d0241116 100644 --- a/src/design/react/components/DesignFrame.tsx +++ b/src/design/react/components/DesignFrame.tsx @@ -13,17 +13,20 @@ import {useLabels} from '../hooks/useLabels'; export const DesignFrame = ({ componentId, + children, name, parentId, regionId, + showFrame = false, showToolbox = true, -}: { +}: React.PropsWithChildren<{ componentId?: string; name: string; parentId?: string; regionId?: string; showToolbox?: boolean; -}): JSX.Element => { + showFrame?: boolean; +}>): JSX.Element => { const componentType = useComponentType(componentId ?? ''); const {deleteComponent, startComponentMove} = useDesignState(); const labels = useLabels(); @@ -46,10 +49,20 @@ export const DesignFrame = ({ } }, []); + const classes = `pd-design__frame ${ + showFrame ? 'pd-design__frame--visible' : '' + }`.trim(); + // TODO: For the frame label, when there is not enough space above the component to display it, we // need to display it inside the container instead. return ( -
+
+ {showFrame && ( + <> +
+
+ + )}
{componentType?.image && ( @@ -70,6 +83,7 @@ export const DesignFrame = ({ />
)} + {children}
); }; @@ -79,4 +93,5 @@ DesignFrame.defaultProps = { componentId: undefined, showToolbox: true, regionId: undefined, + showFrame: false, }; diff --git a/src/design/react/components/DesignRegion.tsx b/src/design/react/components/DesignRegion.tsx index 79301ea1..4b45a264 100644 --- a/src/design/react/components/DesignRegion.tsx +++ b/src/design/react/components/DesignRegion.tsx @@ -33,7 +33,11 @@ export function DesignRegion( componentTypeInclusions, componentTypeExclusions, }); + const { + dragState: {currentDropTarget}, + } = useDesignState(); const labels = useLabels(); + const showFrame = Boolean(id && currentDropTarget?.regionId === id); const {componentId: parentComponentId} = useComponentContext() ?? {}; const {dragState} = useDesignState(); @@ -70,16 +74,21 @@ export function DesignRegion( ); return ( -
+
- - {children} - + showFrame={showFrame} + showToolbox={false}> + + {children} + +
); } diff --git a/src/design/react/context/DesignContext.tsx b/src/design/react/context/DesignContext.tsx index 6173fe02..66c62dbe 100644 --- a/src/design/react/context/DesignContext.tsx +++ b/src/design/react/context/DesignContext.tsx @@ -13,9 +13,9 @@ import { ClientAcknowledgedEvent, EventPayload, } from '../../messaging-api'; -import {isDesignModeActive} from '../../modeDetection'; import {DesignStateProvider} from './DesignStateContext'; import {DesignApp} from '../components/DesignApp'; +import {usePageDesignerMode} from './PageDesignerProvider'; const noop = () => { /* noop */ @@ -65,7 +65,7 @@ export const DesignProvider = ({ clientConnectionInterval?: number; clientLogger?: IsomorphicConfiguration['logger']; }>): JSX.Element => { - const isDesignMode = isDesignModeActive(); + const {isDesignMode} = usePageDesignerMode(); const [isConnected, setIsConnected] = React.useState(false); const [pageDesignerConfig, setPageDesignerConfig] = React.useState(null); diff --git a/src/design/react/context/PageDesignerProvider.tsx b/src/design/react/context/PageDesignerProvider.tsx index 66798502..11d003b1 100644 --- a/src/design/react/context/PageDesignerProvider.tsx +++ b/src/design/react/context/PageDesignerProvider.tsx @@ -46,6 +46,7 @@ type PageDesignerProviderProps = { clientLogger?: IsomorphicConfiguration['logger']; clientConnectionTimeout?: number; clientConnectionInterval?: number; + mode?: 'design' | 'preview'; }; export const PageDesignerProvider = ({ @@ -55,13 +56,14 @@ export const PageDesignerProvider = ({ clientLogger, clientConnectionTimeout, clientConnectionInterval, + mode, }: PageDesignerProviderProps): JSX.Element => { const contextValue = useMemo( () => ({ - isDesignMode: isDesignModeActive(), - isPreviewMode: isPreviewModeActive(), + isDesignMode: mode === 'design' || isDesignModeActive(), + isPreviewMode: mode === 'preview' || isPreviewModeActive(), }), - [] + [mode] ); const {isDesignMode, isPreviewMode} = contextValue; @@ -112,6 +114,7 @@ export const PageDesignerProvider = ({ PageDesignerProvider.defaultProps = { clientConnectionTimeout: 60_000, clientConnectionInterval: 1_000, + mode: undefined, clientLogger: () => { // noop }, diff --git a/src/design/react/hooks/useComponentDecoratorClasses.ts b/src/design/react/hooks/useComponentDecoratorClasses.ts index 2d534c91..88547307 100644 --- a/src/design/react/hooks/useComponentDecoratorClasses.ts +++ b/src/design/react/hooks/useComponentDecoratorClasses.ts @@ -17,7 +17,7 @@ export function useComponentDecoratorClasses({ const isSelected = selectedComponentId === componentId; const isHovered = !dragState.isDragging && hoveredComponentId === componentId; - const showFrame = isSelected || isHovered; + const showFrame = (isSelected || isHovered) && !dragState.isDragging; const isMoving = dragState.isDragging && dragState.sourceComponentId === componentId; const isDropTarget = diff --git a/src/design/react/hooks/useComponentDiscovery.ts b/src/design/react/hooks/useComponentDiscovery.ts new file mode 100644 index 00000000..2cee91bd --- /dev/null +++ b/src/design/react/hooks/useComponentDiscovery.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {useCallback} from 'react'; +import {NodeToTargetMapEntry} from '../context/DesignStateContext'; + +export type ComponentDiscoveryResult = NodeToTargetMapEntry & {node: Element}; + +/** + * Returns a utility for discovering components and regions at a given + * x, y coordinates. + * @param nodeToTargetMap - The map of nodes to target entries. + */ +export function useComponentDiscovery({ + nodeToTargetMap, +}: { + nodeToTargetMap: WeakMap; +}): (query: { + x: number; + y: number; + filter?: (entry: NodeToTargetMapEntry) => boolean; +}) => ComponentDiscoveryResult[] { + return useCallback( + ({x, y, filter = () => true}) => { + const nodeStack = document.elementsFromPoint(x, y); + const results: ComponentDiscoveryResult[] = []; + + for (let i = 0; i < nodeStack.length; i += 1) { + const node = nodeStack[i]; + const entry = nodeToTargetMap.get(node); + + if (entry && filter(entry)) { + results.push({...entry, node}); + } + } + + return results; + }, + [nodeToTargetMap] + ); +} diff --git a/src/design/react/hooks/useDragInteraction.ts b/src/design/react/hooks/useDragInteraction.ts index 14184b69..957a80fd 100644 --- a/src/design/react/hooks/useDragInteraction.ts +++ b/src/design/react/hooks/useDragInteraction.ts @@ -4,11 +4,28 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import {useCallback, useEffect} from 'react'; +/* eslint-disable max-lines */ +import {useCallback, useEffect, useRef} from 'react'; import {useInteraction} from './useInteraction'; import type {NodeToTargetMapEntry} from '../context/DesignStateContext'; +import { + ComponentDiscoveryResult, + useComponentDiscovery, +} from './useComponentDiscovery'; import {isComponentTypeAllowedInRegion} from '../utils/regionUtils'; +// The height of the scroll buffer on the top and bottom of the window +// as a percentage of the window height. +const SCROLL_BUFFER_HEIGHT_PERCENTAGE = 15; +const SCROLL_BUFFER_MIN_HEIGHT_IN_PIXELS = 50; +// The interval at which the window will scroll within the buffer. +// More often means a smoother experience. +const SCROLL_INTERVAL_IN_MS = 1000 / 60; // 60fps +// The multiplier applied to the scroll factor. +// The scroll factor is a value between 0 and 1 that determines how much to scroll. +// This value will be the maximum amount of pixels that will be scrolled in a single frame. +const SCROLL_BASE_AMOUNT_IN_PIXELS = 50; + export interface DropTarget extends NodeToTargetMapEntry { beforeComponentId?: string; afterComponentId?: string; @@ -29,6 +46,7 @@ export interface DragInteraction { sourceComponentId?: string; sourceRegionId?: string; rectCache: WeakMap; + scrollDirection: 0 | 1 | -1; }; commitCurrentDropTarget: () => void; startComponentMove: (componentId: string, regionId: string) => void; @@ -65,39 +83,71 @@ function getInsertionType({ return y < midY ? 'before' : 'after'; } +// Determines whether a source component is being dropped on itself. +function isOnSelfDropTarget({ + sourceComponentId, + beforeComponentId, + afterComponentId, + insertType, + componentId, +}: { + sourceComponentId: string | undefined; + beforeComponentId: string | undefined; + afterComponentId: string | undefined; + insertType: 'before' | 'after'; + componentId: string; +}) { + const isOnSource = sourceComponentId && componentId === sourceComponentId; + const isOnSameRegionBefore = + sourceComponentId && + insertType === 'before' && + beforeComponentId === sourceComponentId; + const isOnSameRegionAfter = + sourceComponentId && + insertType === 'after' && + afterComponentId === sourceComponentId; + + return isOnSource || isOnSameRegionBefore || isOnSameRegionAfter; +} + export function useDragInteraction({ nodeToTargetMap, }: { nodeToTargetMap: WeakMap; }): DragInteraction { + const discoverComponents = useComponentDiscovery({ + nodeToTargetMap, + }); const getNearestComponentAndRegion = useCallback( ( x: number, y: number ): { - component: (NodeToTargetMapEntry & {node: Element}) | null; - region: (NodeToTargetMapEntry & {node: Element}) | null; + component: ComponentDiscoveryResult | null; + region: ComponentDiscoveryResult | null; } => { - const nodeStack = document.elementsFromPoint(x, y); - let component: (NodeToTargetMapEntry & {node: Element}) | null = null; - let region: (NodeToTargetMapEntry & {node: Element}) | null = null; - - for (let i = 0; i < nodeStack.length; i += 1) { - const node = nodeStack[i]; - const entry = nodeToTargetMap.get(node); - - if (entry?.type === 'component') { - component = {...entry, node}; - } else if (entry?.type === 'region') { - region = {...entry, node}; - // Once we find a region we need to exit. - break; + const stack = discoverComponents({x, y}); + let component: ComponentDiscoveryResult | null = null; + let region: ComponentDiscoveryResult | null = null; + + for (let i = 0; i < stack.length; i += 1) { + const entry = stack[i]; + + // We need a region id and direction for this to be a target. + if (entry.regionId && entry.regionDirection) { + if (entry.type === 'component') { + component = entry; + } else if (entry.type === 'region') { + region = entry; + // Once we find a region we need to exit. + break; + } } } return {component, region}; }, - [nodeToTargetMap] + [discoverComponents] ); const getInsertionComponentIds = ( @@ -113,12 +163,19 @@ export function useDragInteraction({ }; const getCurrentDropTarget = useCallback( - ( - x: number, - y: number, - rectCache: WeakMap, - componentType?: string - ): DropTarget | null => { + ({ + x, + y, + rectCache, + componentType, + sourceComponentId, + }: { + x: number; + y: number; + rectCache: WeakMap; + componentType?: string; + sourceComponentId?: string; + }): DropTarget | null => { const {component, region} = getNearestComponentAndRegion(x, y); if (region) { @@ -147,6 +204,20 @@ export function useDragInteraction({ ? getInsertionComponentIds(component.componentId, region) : []; + // If we are dropping onto another component but would be dropping in the same insert + // position, then don't allow it as a drop target. + if ( + isOnSelfDropTarget({ + sourceComponentId, + beforeComponentId, + afterComponentId, + insertType, + componentId: component?.componentId ?? '', + }) + ) { + return null; + } + // If we find a component before a region, it means we are dropping over a component. // If no component is found before a region, it means we are dropping over an empty region. return { @@ -170,6 +241,44 @@ export function useDragInteraction({ [nodeToTargetMap] ); + const computeScrollFactor = ({ + y, + windowHeight, + }: { + y: number; + windowHeight: number; + }) => { + const bufferHeight = Math.max( + windowHeight * (SCROLL_BUFFER_HEIGHT_PERCENTAGE / 100), + SCROLL_BUFFER_MIN_HEIGHT_IN_PIXELS + ); + const bottomBufferStart = windowHeight - bufferHeight; + + if (y > bottomBufferStart) { + return (y - bottomBufferStart) / bufferHeight; + } + + if (y < bufferHeight) { + return (y - bufferHeight) / bufferHeight; + } + + return 0; + }; + + const computeScrollDirection = (factor: number): 0 | 1 | -1 => { + if (factor > 0) { + return 1; + } + + if (factor < 0) { + return -1; + } + + return 0; + }; + + const scrollFactorRef = useRef(0); + const { state: dragState, commitCurrentDropTarget, @@ -192,6 +301,8 @@ export function useDragInteraction({ eventHandlers: { ComponentDragStarted: { handler: (event, setState) => { + scrollFactorRef.current = 0; + setState(prevState => ({ ...prevState, componentType: event.componentType, @@ -202,12 +313,15 @@ export function useDragInteraction({ isDragging: true, currentDropTarget: null, pendingTargetCommit: false, + scrollDirection: 0, rectCache: new WeakMap(), })); }, }, ClientWindowDragExited: { handler: (_, setState) => { + scrollFactorRef.current = 0; + setState(prevState => ({ ...prevState, componentType: '', @@ -215,23 +329,31 @@ export function useDragInteraction({ y: 0, isDragging: false, currentDropTarget: null, + scrollDirection: 0, pendingTargetCommit: false, })); }, }, ClientWindowDragMoved: { handler: (event, setState) => { + scrollFactorRef.current = computeScrollFactor({ + y: event.y, + windowHeight: window.innerHeight, + }); + setState(prevState => ({ ...prevState, x: event.x, y: event.y, isDragging: true, - currentDropTarget: getCurrentDropTarget( - event.x, - event.y, - prevState.rectCache, - prevState.componentType - ), + scrollDirection: computeScrollDirection(scrollFactorRef.current), + currentDropTarget: getCurrentDropTarget({ + x: event.x, + y: event.y, + rectCache: dragState.rectCache, + componentType: prevState.componentType, + sourceComponentId: dragState.sourceComponentId, + }), })); }, }, @@ -247,24 +369,34 @@ export function useDragInteraction({ }, actions: (state, setState, clientApi) => ({ cancelDrag: () => { + scrollFactorRef.current = 0; + setState(prevState => ({ ...prevState, x: 0, y: 0, + scrollDirection: 0, isDragging: false, })); }, updateComponentMove: ({x, y}: {x: number; y: number}) => { + scrollFactorRef.current = computeScrollFactor({ + y, + windowHeight: window.innerHeight, + }); + setState(prevState => ({ ...prevState, x, y, - currentDropTarget: getCurrentDropTarget( + scrollDirection: computeScrollDirection(scrollFactorRef.current), + currentDropTarget: getCurrentDropTarget({ x, y, - state.rectCache, - state.componentType - ), + rectCache: state.rectCache, + componentType: state.componentType, + sourceComponentId: state.sourceComponentId, + }), })); }, dropComponent: () => { @@ -275,6 +407,8 @@ export function useDragInteraction({ })); }, startComponentMove: (componentId: string, regionId: string) => { + scrollFactorRef.current = 0; + setState(prevState => ({ ...prevState, x: 0, @@ -282,24 +416,31 @@ export function useDragInteraction({ sourceComponentId: componentId, sourceRegionId: regionId, isDragging: true, + scrollDirection: 0, rectCache: new WeakMap(), })); }, commitCurrentDropTarget: () => { + // Don't do anything if we don't have a drop target. if (state.currentDropTarget) { // If we have a source component id, then we are moving a component to a different region. if (state.sourceComponentId) { - clientApi?.moveComponentToRegion({ - componentId: state.sourceComponentId, - sourceRegionId: state.sourceRegionId ?? '', - insertType: state.currentDropTarget.insertType, - insertComponentId: state.currentDropTarget.insertComponentId, - beforeComponentId: state.currentDropTarget.beforeComponentId, - afterComponentId: state.currentDropTarget.afterComponentId, - targetRegionId: state.currentDropTarget.regionId, - targetComponentId: state.currentDropTarget.parentId ?? '', - }); - // If we have a component type, then we are adding a new component to a region. + // If we aren't dropping on the same component we are moving. + if ( + state.currentDropTarget.componentId !== state.sourceComponentId + ) { + clientApi?.moveComponentToRegion({ + componentId: state.sourceComponentId, + sourceRegionId: state.sourceRegionId ?? '', + insertType: state.currentDropTarget.insertType, + insertComponentId: state.currentDropTarget.insertComponentId, + beforeComponentId: state.currentDropTarget.beforeComponentId, + afterComponentId: state.currentDropTarget.afterComponentId, + targetRegionId: state.currentDropTarget.regionId, + targetComponentId: state.currentDropTarget.parentId ?? '', + }); + } + // If we don't have a source component id, then we are adding a new component to a region. } else if (state.componentType) { clientApi?.addComponentToRegion({ insertType: state.currentDropTarget.insertType, @@ -314,11 +455,14 @@ export function useDragInteraction({ } } + scrollFactorRef.current = 0; + setState(prevState => ({ ...prevState, x: 0, y: 0, componentType: '', + scrollDirection: 0, sourceComponentId: undefined, sourceRegionId: undefined, currentDropTarget: null, @@ -328,12 +472,31 @@ export function useDragInteraction({ }), }); + // Commits the current drop target if we are pending a target commit. useEffect(() => { if (dragState.pendingTargetCommit) { commitCurrentDropTarget(); } }, [dragState.pendingTargetCommit]); + // Starts scrolling the window when the drag state scroll factor is not 0. + useEffect(() => { + if (dragState.scrollDirection !== 0) { + const interval = setInterval(() => { + window.scrollBy( + 0, + scrollFactorRef.current * SCROLL_BASE_AMOUNT_IN_PIXELS + ); + }, SCROLL_INTERVAL_IN_MS); + + return () => clearInterval(interval); + } + + return () => { + // noop + }; + }, [dragState.scrollDirection, scrollFactorRef]); + return { dragState, commitCurrentDropTarget, diff --git a/src/design/react/hooks/useHoverInteraction.ts b/src/design/react/hooks/useHoverInteraction.ts index 16207772..88815811 100644 --- a/src/design/react/hooks/useHoverInteraction.ts +++ b/src/design/react/hooks/useHoverInteraction.ts @@ -30,16 +30,18 @@ export function useHoverInteraction(): HoverInteraction { }, actions: (state, setState, clientApi) => ({ setHoveredComponent: (componentId: string | null) => { - setState(componentId); - - if (componentId) { - clientApi?.hoverInToComponent({componentId}); - } else if (state) { + if (state && componentId !== state) { // Use the current hovered component for hover out clientApi?.hoverOutOfComponent({ componentId: state, }); } + + if (componentId && componentId !== state) { + clientApi?.hoverInToComponent({componentId}); + } + + setState(componentId); }, }), }); diff --git a/src/design/react/hooks/useNodeToTargetStore.ts b/src/design/react/hooks/useNodeToTargetStore.ts index af40e0ab..b40bad8c 100644 --- a/src/design/react/hooks/useNodeToTargetStore.ts +++ b/src/design/react/hooks/useNodeToTargetStore.ts @@ -15,17 +15,16 @@ export function useNodeToTargetStore({ regionDirection, nodeRef, type, - componentIds = [], - componentTypeInclusions = [], - componentTypeExclusions = [], + componentIds, + componentTypeInclusions, + componentTypeExclusions, }: Partial & { nodeRef: React.RefObject; }): void { const {nodeToTargetMap} = useDesignState(); React.useEffect(() => { - // We need a region id and direction for this to be a target. - if (nodeRef.current && regionId && regionDirection) { + if (nodeRef.current) { nodeToTargetMap.set(nodeRef.current, { parentId, componentId, @@ -43,6 +42,7 @@ export function useNodeToTargetStore({ componentId, regionId, type, + componentIds, nodeToTargetMap, componentTypeInclusions, componentTypeExclusions, diff --git a/src/design/react/hooks/useRegionDecoratorClasses.ts b/src/design/react/hooks/useRegionDecoratorClasses.ts index 7f2bc64d..3f9c3820 100644 --- a/src/design/react/hooks/useRegionDecoratorClasses.ts +++ b/src/design/react/hooks/useRegionDecoratorClasses.ts @@ -39,7 +39,7 @@ export function useRegionDecoratorClasses({ return [ 'pd-design__decorator', 'pd-design__region', - shouldShowHover && 'pd-design__region--hovered', + shouldShowHover && 'pd-design__region--hovered pd-design__frame--visible', ] .filter(Boolean) .join(' '); diff --git a/src/design/styles/base.css b/src/design/styles/base.css index c55ae362..67aba753 100644 --- a/src/design/styles/base.css +++ b/src/design/styles/base.css @@ -9,15 +9,15 @@ .pd-design__decorator { /* Temporary color for drop targets */ --pd-design-drop-target-color: #008827; + --pd-design-outline-width: 1px; --pd-design-selected-color: #005fb2; - --pd-design-decorator-z-index: 100; + --pd-design-drop-target-width: 2px; } .pd-design__component, +.pd-design__region, .pd-design__fragment { - outline: 1px solid transparent; position: relative; - z-index: var(--pd-design-decorator-z-index); } .pd-design__component { @@ -29,14 +29,41 @@ } /* Shared state styles */ -.pd-design__frame--visible { - outline: 1px solid var(--pd-design-color); +.pd-design__frame.pd-design__frame--visible > .pd-design__frame--x::before, +.pd-design__frame.pd-design__frame--visible > .pd-design__frame--x::after, +.pd-design__frame.pd-design__frame--visible > .pd-design__frame--y::before, +.pd-design__frame.pd-design__frame--visible > .pd-design__frame--y::after { + background-color: var(--pd-design-color); + content: ''; + display: block; + position: absolute; + z-index: 1; } -.pd-design__decorator--selected { - --pd-design-color: var(--pd-design-selected-color); +.pd-design__frame.pd-design__frame--visible > .pd-design__frame--y::before { + height: var(--pd-design-outline-width); + left: 0; + top: 0; + width: 100%; +} - outline-width: 2px; +.pd-design__frame.pd-design__frame--visible > .pd-design__frame--y::after { + bottom: 0; + height: var(--pd-design-outline-width); + left: 0; + width: 100%; +} + +.pd-design__frame.pd-design__frame--visible > .pd-design__frame--x::before { + height: 100%; + left: 0; + width: var(--pd-design-outline-width); +} + +.pd-design__frame.pd-design__frame--visible > .pd-design__frame--x::after { + height: 100%; + right: 0; + width: var(--pd-design-outline-width); } .pd-design__frame__label { @@ -45,32 +72,17 @@ border-top-left-radius: 4px; border-top-right-radius: 4px; color: white; - display: inline-flex; font-family: 'Salesforce Sans', sans-serif; font-size: 12px; font-weight: 500; height: 32px; left: 50%; letter-spacing: 0.5px; - opacity: 0; padding: 0 12px; position: absolute; top: -32px; transform: translateX(-50%); - visibility: hidden; - white-space: nowrap; -} - -.pd-design__frame--visible .pd-design__frame__label, -.pd-design__region--hovered .pd-design__frame__label { - opacity: 1; - visibility: visible; -} - -.pd-design__frame__name { - margin-right: 8px; - overflow: hidden; - text-overflow: ellipsis; + display: none; white-space: nowrap; } @@ -81,9 +93,29 @@ position: absolute; right: 0; top: 0; + visibility: hidden; z-index: 1000; } +.pd-design__frame.pd-design__frame--visible > .pd-design__frame__toolbox { + visibility: visible; +} + +.pd-design__frame.pd-design__frame--visible > .pd-design__frame__label { + display: inline-flex; +} + +.pd-design__decorator--selected { + --pd-design-color: var(--pd-design-selected-color); + --pd-design-outline-width: 2px; +} + +.pd-design__frame__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .pd-design__frame__toolbox-button { align-items: center; background: rgba(0, 0, 0, 0); @@ -114,26 +146,47 @@ min-height: 50px; min-width: 50px; - position: relative; } .pd-design__region--hovered { - outline-color: var(--pd-design-color); - outline-style: solid; + --pd-design-outline-width: 1px; +} + +.pd-design__component__drop-target { + background-color: var(--pd-design-drop-target-color); + /* border-color: var(--pd-design-drop-target-color); */ + /* border-style: solid; */ + /* border-width: 0; */ + height: 0; + left: 0; + position: absolute; + top: 0; + width: 0; + z-index: 1; } -.pd-design__drop-target__y-before { - box-shadow: 0 -2px var(--pd-design-drop-target-color); +.pd-design__drop-target__y-before > .pd-design__component__drop-target { + height: var(--pd-design-drop-target-width); + top: calc(-5px - calc(var(--pd-design-drop-target-width) / 2)); + width: 100%; } -.pd-design__drop-target__y-after { - box-shadow: 0 2px var(--pd-design-drop-target-color); +.pd-design__drop-target__y-after > .pd-design__component__drop-target { + bottom: calc(5px - calc(var(--pd-design-drop-target-width) / 2)); + height: var(--pd-design-drop-target-width); + top: unset; + width: 100%; } -.pd-design__drop-target__x-before { - box-shadow: -2px 0 var(--pd-design-drop-target-color); +.pd-design__drop-target__x-before > .pd-design__component__drop-target { + height: 100%; + left: calc(-5px - calc(var(--pd-design-drop-target-width) / 2)); + width: var(--pd-design-drop-target-width); } -.pd-design__drop-target__x-after { - box-shadow: 2px 0 var(--pd-design-drop-target-color); +.pd-design__drop-target__x-after > .pd-design__component__drop-target { + height: 100%; + left: unset; + right: calc(5px - calc(var(--pd-design-drop-target-width) / 2)); + width: var(--pd-design-drop-target-width); }