feat(ui): dnd layer reordering (wip)

This commit is contained in:
psychedelicious
2024-10-30 18:14:09 +10:00
parent 06283cffed
commit 54abd8d4d1
13 changed files with 482 additions and 48 deletions

View File

@@ -6,7 +6,7 @@ import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHook
import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent';
import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { selectEntityCountActive } from 'features/controlLayers/store/selectors';
import type { Dnd } from 'features/dnd/dnd';
import { Dnd } from 'features/dnd/dnd';
import { DndDropOverlay } from 'features/dnd/DndDropOverlay';
import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent';
import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer';
@@ -195,11 +195,6 @@ const PanelTabs = memo(() => {
}
};
const canMonitor = () => {
// Only monitor if we are not already on the gallery tab
return selectActiveTabCanvasRightPanel(store.getState()) !== 'gallery';
};
const onDragStart = () => {
// Set the state to pending when a drag starts
setGalleryTabDndState('potential');
@@ -212,7 +207,13 @@ const PanelTabs = memo(() => {
onDragLeave,
}),
monitorForElements({
canMonitor,
canMonitor: ({ source }) => {
if (!Dnd.Source.singleImage.typeGuard(source.data) || !Dnd.Source.multipleImage.typeGuard(source.data)) {
return false;
}
// Only monitor if we are not already on the gallery tab
return selectActiveTabCanvasRightPanel(store.getState()) !== 'gallery';
},
onDragStart,
}),
dropTargetForExternal({
@@ -221,7 +222,7 @@ const PanelTabs = memo(() => {
onDragLeave,
}),
monitorForExternal({
canMonitor,
canMonitor: () => selectActiveTabCanvasRightPanel(store.getState()) !== 'gallery',
onDragStart,
})
);

View File

@@ -3,12 +3,12 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
import { ControlLayer } from 'features/controlLayers/components/ControlLayer/ControlLayer';
import { mapId } from 'features/controlLayers/konva/util';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { memo } from 'react';
const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => {
return canvas.controlLayers.entities.map(mapId).reverse();
const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => {
return canvas.controlLayers.entities.map(getEntityIdentifier).toReversed();
});
const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => {
@@ -17,17 +17,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte
export const ControlLayerEntityList = memo(() => {
const isSelected = useAppSelector(selectIsSelected);
const layerIds = useAppSelector(selectEntityIds);
const entityIdentifiers = useAppSelector(selectEntityIdentifiers);
if (layerIds.length === 0) {
if (entityIdentifiers.length === 0) {
return null;
}
if (layerIds.length > 0) {
if (entityIdentifiers.length > 0) {
return (
<CanvasEntityGroupList type="control_layer" isSelected={isSelected}>
{layerIds.map((id) => (
<ControlLayer key={id} id={id} />
<CanvasEntityGroupList type="control_layer" isSelected={isSelected} entityIdentifiers={entityIdentifiers}>
{entityIdentifiers.map((entityIdentifier) => (
<ControlLayer key={entityIdentifier.id} id={entityIdentifier.id} />
))}
</CanvasEntityGroupList>
);

View File

@@ -3,12 +3,12 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
import { InpaintMask } from 'features/controlLayers/components/InpaintMask/InpaintMask';
import { mapId } from 'features/controlLayers/konva/util';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { memo } from 'react';
const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => {
return canvas.inpaintMasks.entities.map(mapId).reverse();
const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => {
return canvas.inpaintMasks.entities.map(getEntityIdentifier).toReversed();
});
const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => {
@@ -17,17 +17,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte
export const InpaintMaskList = memo(() => {
const isSelected = useAppSelector(selectIsSelected);
const entityIds = useAppSelector(selectEntityIds);
const entityIdentifiers = useAppSelector(selectEntityIdentifiers);
if (entityIds.length === 0) {
if (entityIdentifiers.length === 0) {
return null;
}
if (entityIds.length > 0) {
if (entityIdentifiers.length > 0) {
return (
<CanvasEntityGroupList type="inpaint_mask" isSelected={isSelected}>
{entityIds.map((id) => (
<InpaintMask key={id} id={id} />
<CanvasEntityGroupList type="inpaint_mask" isSelected={isSelected} entityIdentifiers={entityIdentifiers}>
{entityIdentifiers.map((entityIdentifier) => (
<InpaintMask key={entityIdentifier.id} id={entityIdentifier.id} />
))}
</CanvasEntityGroupList>
);

View File

@@ -3,12 +3,12 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
import { RasterLayer } from 'features/controlLayers/components/RasterLayer/RasterLayer';
import { mapId } from 'features/controlLayers/konva/util';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { memo } from 'react';
const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => {
return canvas.rasterLayers.entities.map(mapId).reverse();
const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => {
return canvas.rasterLayers.entities.map(getEntityIdentifier).toReversed();
});
const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => {
return selectedEntityIdentifier?.type === 'raster_layer';
@@ -16,17 +16,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte
export const RasterLayerEntityList = memo(() => {
const isSelected = useAppSelector(selectIsSelected);
const layerIds = useAppSelector(selectEntityIds);
const entityIdentifiers = useAppSelector(selectEntityIdentifiers);
if (layerIds.length === 0) {
if (entityIdentifiers.length === 0) {
return null;
}
if (layerIds.length > 0) {
if (entityIdentifiers.length > 0) {
return (
<CanvasEntityGroupList type="raster_layer" isSelected={isSelected}>
{layerIds.map((id) => (
<RasterLayer key={id} id={id} />
<CanvasEntityGroupList type="raster_layer" isSelected={isSelected} entityIdentifiers={entityIdentifiers}>
{entityIdentifiers.map((entityIdentifier) => (
<RasterLayer key={entityIdentifier.id} id={entityIdentifier.id} />
))}
</CanvasEntityGroupList>
);

View File

@@ -3,12 +3,12 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityGroupList } from 'features/controlLayers/components/common/CanvasEntityGroupList';
import { RegionalGuidance } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidance';
import { mapId } from 'features/controlLayers/konva/util';
import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors';
import { getEntityIdentifier } from 'features/controlLayers/store/types';
import { memo } from 'react';
const selectEntityIds = createMemoizedSelector(selectCanvasSlice, (canvas) => {
return canvas.regionalGuidance.entities.map(mapId).reverse();
const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => {
return canvas.regionalGuidance.entities.map(getEntityIdentifier).toReversed();
});
const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => {
return selectedEntityIdentifier?.type === 'regional_guidance';
@@ -16,17 +16,17 @@ const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selecte
export const RegionalGuidanceEntityList = memo(() => {
const isSelected = useAppSelector(selectIsSelected);
const rgIds = useAppSelector(selectEntityIds);
const entityIdentifiers = useAppSelector(selectEntityIdentifiers);
if (rgIds.length === 0) {
if (entityIdentifiers.length === 0) {
return null;
}
if (rgIds.length > 0) {
if (entityIdentifiers.length > 0) {
return (
<CanvasEntityGroupList type="regional_guidance" isSelected={isSelected}>
{rgIds.map((id) => (
<RegionalGuidance key={id} id={id} />
<CanvasEntityGroupList type="regional_guidance" isSelected={isSelected} entityIdentifiers={entityIdentifiers}>
{entityIdentifiers.map((entityIdentifier) => (
<RegionalGuidance key={entityIdentifier.id} id={entityIdentifier.id} />
))}
</CanvasEntityGroupList>
);

View File

@@ -1,11 +1,37 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
// import { pointerOutsideOfPreview } from '@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview';
// import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import { Flex } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useEntityIsSelected } from 'features/controlLayers/hooks/useEntityIsSelected';
import { useEntitySelectionColor } from 'features/controlLayers/hooks/useEntitySelectionColor';
import { entitySelected } from 'features/controlLayers/store/canvasSlice';
import { Dnd } from 'features/dnd/dnd';
import DropIndicator from 'features/dnd/DndDropIndicator';
import type { PropsWithChildren } from 'react';
import { memo, useCallback } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
type DndState =
| {
type: 'idle';
}
| {
type: 'preview';
container: HTMLElement;
}
| {
type: 'is-dragging';
}
| {
type: 'is-dragging-over';
closestEdge: Edge | null;
};
const idle: DndState = { type: 'idle' };
export const CanvasEntityContainer = memo((props: PropsWithChildren) => {
const dispatch = useAppDispatch();
@@ -18,9 +44,95 @@ export const CanvasEntityContainer = memo((props: PropsWithChildren) => {
}
dispatch(entitySelected({ entityIdentifier }));
}, [dispatch, entityIdentifier, isSelected]);
const ref = useRef<HTMLDivElement>(null);
const [dndState, setDndState] = useState<DndState>(idle);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
return combine(
draggable({
element,
getInitialData() {
return Dnd.Source.singleCanvasEntity.getData({ entityIdentifier });
},
// onGenerateDragPreview({ nativeSetDragImage }) {
// setCustomNativeDragPreview({
// nativeSetDragImage,
// getOffset: pointerOutsideOfPreview({
// x: '16px',
// y: '8px',
// }),
// render({ container }) {
// setState({ type: 'preview', container });
// },
// });
// },
onDragStart() {
setDndState({ type: 'is-dragging' });
},
onDrop() {
setDndState(idle);
},
}),
dropTargetForElements({
element,
canDrop({ source }) {
// not allowing dropping on yourself
if (source.element === element) {
return false;
}
// only allowing tasks to be dropped on me
if (!Dnd.Source.singleCanvasEntity.typeGuard(source.data)) {
return false;
}
if (source.data.payload.entityIdentifier.type !== entityIdentifier.type) {
return false;
}
return true;
},
getData({ input }) {
const data = Dnd.Source.singleCanvasEntity.getData({ entityIdentifier });
return attachClosestEdge(data, {
element,
input,
allowedEdges: ['top', 'bottom'],
});
},
getIsSticky() {
return true;
},
onDragEnter({ self }) {
const closestEdge = extractClosestEdge(self.data);
setDndState({ type: 'is-dragging-over', closestEdge });
},
onDrag({ self }) {
const closestEdge = extractClosestEdge(self.data);
// Only need to update react state if nothing has changed.
// Prevents re-rendering.
setDndState((current) => {
if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
return current;
}
return { type: 'is-dragging-over', closestEdge };
});
},
onDragLeave() {
setDndState(idle);
},
onDrop() {
setDndState(idle);
},
})
);
}, [entityIdentifier]);
return (
<Flex
ref={ref}
position="relative"
flexDir="column"
w="full"
@@ -31,6 +143,9 @@ export const CanvasEntityContainer = memo((props: PropsWithChildren) => {
borderRadius="base"
>
{props.children}
{dndState.type === 'is-dragging-over' && dndState.closestEdge ? (
<DropIndicator edge={dndState.closestEdge} gap="8px" />
) : null}
</Flex>
);
});

View File

@@ -1,5 +1,9 @@
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge';
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Button, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover';
import { useBoolean } from 'common/hooks/useBoolean';
import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScrollStyles';
@@ -8,24 +12,97 @@ import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/component
import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle';
import { useEntityTypeInformationalPopover } from 'features/controlLayers/hooks/useEntityTypeInformationalPopover';
import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle';
import { type CanvasEntityIdentifier, isRenderableEntityType } from 'features/controlLayers/store/types';
import { entitiesReordered } from 'features/controlLayers/store/canvasSlice';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { isRenderableEntityType } from 'features/controlLayers/store/types';
import { Dnd } from 'features/dnd/dnd';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
import { memo, useEffect } from 'react';
import { flushSync } from 'react-dom';
import { PiCaretDownBold } from 'react-icons/pi';
type Props = PropsWithChildren<{
isSelected: boolean;
type: CanvasEntityIdentifier['type'];
entityIdentifiers: CanvasEntityIdentifier[];
}>;
const _hover: SystemStyleObject = {
opacity: 1,
};
export const CanvasEntityGroupList = memo(({ isSelected, type, children }: Props) => {
export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityIdentifiers }: Props) => {
const title = useEntityTypeTitle(type);
const informationalPopoverFeature = useEntityTypeInformationalPopover(type);
const collapse = useBoolean(true);
const dispatch = useAppDispatch();
useEffect(() => {
return monitorForElements({
canMonitor({ source }) {
if (!Dnd.Source.singleCanvasEntity.typeGuard(source.data)) {
return false;
}
if (source.data.payload.entityIdentifier.type !== type) {
return false;
}
return true;
},
onDrop({ location, source }) {
const target = location.current.dropTargets[0];
if (!target) {
return;
}
const sourceData = source.data;
const targetData = target.data;
if (
!Dnd.Source.singleCanvasEntity.typeGuard(sourceData) ||
!Dnd.Source.singleCanvasEntity.typeGuard(targetData)
) {
return;
}
const indexOfSource = entityIdentifiers.findIndex(
(entityIdentifier) => entityIdentifier.id === sourceData.payload.entityIdentifier.id
);
const indexOfTarget = entityIdentifiers.findIndex(
(entityIdentifier) => entityIdentifier.id === targetData.payload.entityIdentifier.id
);
if (indexOfTarget < 0 || indexOfSource < 0) {
return;
}
const closestEdgeOfTarget = extractClosestEdge(targetData);
// Using `flushSync` so we can query the DOM straight after this line
flushSync(() => {
dispatch(
entitiesReordered({
type,
entityIdentifiers: reorderWithEdge({
list: entityIdentifiers,
startIndex: indexOfSource,
indexOfTarget,
closestEdgeOfTarget,
axis: 'vertical',
}),
})
);
});
// // Being simple and just querying for the task after the drop.
// // We could use react context to register the element in a lookup,
// // and then we could retrieve that element after the drop and use
// // `triggerPostMoveFlash`. But this gets the job done.
// const element = document.querySelector(`[data-task-id="${sourceData.taskId}"]`);
// if (element instanceof HTMLElement) {
// triggerPostMoveFlash(element);
// }
},
});
}, [dispatch, entityIdentifiers, type]);
return (
<Flex flexDir="column" w="full">

View File

@@ -14,6 +14,8 @@ import {
selectRegionalGuidanceReferenceImage,
} from 'features/controlLayers/store/selectors';
import type {
CanvasEntityStateFromType,
CanvasEntityType,
CanvasInpaintMaskState,
CanvasMetadata,
FillStyle,
@@ -1345,6 +1347,46 @@ export const canvasSlice = createSlice({
}
moveToStart(selectAllEntitiesOfType(state, entity.type), entity);
},
entitiesReordered: <T extends CanvasEntityType>(
state: CanvasState,
action: PayloadAction<{ type: T; entityIdentifiers: CanvasEntityIdentifier<T>[] }>
) => {
const { type, entityIdentifiers } = action.payload;
switch (type) {
case 'raster_layer': {
state.rasterLayers.entities = reorderEntities(
state.rasterLayers.entities,
entityIdentifiers as CanvasEntityIdentifier<'raster_layer'>[]
);
break;
}
case 'control_layer':
state.controlLayers.entities = reorderEntities(
state.controlLayers.entities,
entityIdentifiers as CanvasEntityIdentifier<'control_layer'>[]
);
break;
case 'inpaint_mask':
state.inpaintMasks.entities = reorderEntities(
state.inpaintMasks.entities,
entityIdentifiers as CanvasEntityIdentifier<'inpaint_mask'>[]
);
break;
case 'regional_guidance':
state.regionalGuidance.entities = reorderEntities(
state.regionalGuidance.entities,
entityIdentifiers as CanvasEntityIdentifier<'regional_guidance'>[]
);
break;
case 'reference_image':
state.referenceImages.entities = reorderEntities(
state.referenceImages.entities,
entityIdentifiers as CanvasEntityIdentifier<'reference_image'>[]
);
break;
}
},
entityOpacityChanged: (state, action: PayloadAction<EntityIdentifierPayload<{ opacity: number }>>) => {
const { entityIdentifier, opacity } = action.payload;
const entity = selectEntity(state, entityIdentifier);
@@ -1471,6 +1513,7 @@ export const {
entityArrangedBackwardOne,
entityArrangedToBack,
entityOpacityChanged,
entitiesReordered,
// allEntitiesDeleted, // currently unused
allEntitiesOfTypeIsHiddenToggled,
// bbox
@@ -1604,3 +1647,17 @@ function actionsThrottlingFilter(action: UnknownAction) {
}, THROTTLE_MS);
return true;
}
const reorderEntities = <T extends CanvasEntityType>(
entities: CanvasEntityStateFromType<T>[],
sortedEntityIdentifiers: CanvasEntityIdentifier<T>[]
) => {
const sortedEntities: CanvasEntityStateFromType<T>[] = [];
for (const { id } of sortedEntityIdentifiers.toReversed()) {
const entity = entities.find((entity) => entity.id === id);
if (entity) {
sortedEntities.push(entity);
}
}
return sortedEntities;
};

View File

@@ -466,6 +466,8 @@ export type EntityRasterizedPayload = EntityIdentifierPayload<{
export type GenerationMode = 'txt2img' | 'img2img' | 'inpaint' | 'outpaint';
export type CanvasEntityStateFromType<T extends CanvasEntityType> = Extract<CanvasEntityState, { type: T }>;
export function isRenderableEntityType(
entityType: CanvasEntityState['type']
): entityType is CanvasRenderableEntityState['type'] {

View File

@@ -0,0 +1,166 @@
/**
* Spacing tokens don't make a lot of sense for this specific use case,
* so disabling the linting rule.
*/
import type { Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/types';
import { Box, type SystemStyleObject } from '@invoke-ai/ui-library';
import type { CSSProperties } from 'react';
/**
* Design decisions for the drop indicator's main line
*/
export const line = {
borderRadius: 0,
thickness: 2,
backgroundColor: 'base.300',
};
export type DropIndicatorProps = {
/**
* The `edge` to draw a drop indicator on.
*
* `edge` is required as for the best possible performance
* outcome you should only render this component when it needs to do something
*
* @example {closestEdge && <DropIndicator edge={closestEdge} />}
*/
edge: Edge;
/**
* `gap` allows you to position the drop indicator further away from the drop target.
* `gap` should be the distance between your drop targets
* a drop indicator will be rendered halfway between the drop targets
* (the drop indicator will be offset by half of the `gap`)
*
* `gap` should be a valid CSS length.
* @example "8px"
* @example "var(--gap)"
*/
gap?: string;
};
const terminalSize = 8;
const lineStyles: SystemStyleObject = {
display: 'block',
position: 'absolute',
zIndex: 1,
// Blocking pointer events to prevent the line from triggering drag events
// Dragging over the line should count as dragging over the element behind it
pointerEvents: 'none',
background: line.backgroundColor,
// Terminal
'::before': {
content: '""',
width: terminalSize,
height: terminalSize,
boxSizing: 'border-box',
position: 'absolute',
border: `${line.thickness}px solid ${line.backgroundColor}`,
borderRadius: '50%',
},
};
/**
* By default, the edge of the terminal will be aligned to the edge of the line.
*
* Offsetting the terminal by half its size aligns the middle of the terminal
* with the edge of the line.
*
* We must offset by half the line width in the opposite direction so that the
* middle of the terminal aligns with the middle of the line.
*
* That is,
*
* offset = - (terminalSize / 2) + (line.thickness / 2)
*
* which simplifies to the following value.
*/
const offsetToAlignTerminalWithLine = (line.thickness - terminalSize) / 2;
/**
* We inset the line by half the terminal size,
* so that the terminal only half sticks out past the item.
*/
const lineOffset = terminalSize / 2;
type Orientation = 'horizontal' | 'vertical';
const orientationStyles: Record<Orientation, SystemStyleObject> = {
horizontal: {
height: line.thickness,
left: lineOffset,
right: 0,
'::before': {
// Horizontal indicators have the terminal on the left
left: -terminalSize,
},
},
vertical: {
width: line.thickness,
top: lineOffset,
bottom: 0,
'::before': {
// Vertical indicators have the terminal at the top
top: -terminalSize,
},
},
};
const edgeToOrientationMap: Record<Edge, Orientation> = {
top: 'horizontal',
bottom: 'horizontal',
left: 'vertical',
right: 'vertical',
};
const edgeStyles: Record<Edge, SystemStyleObject> = {
top: {
top: 'var(--local-line-offset)',
'::before': {
top: offsetToAlignTerminalWithLine,
},
},
right: {
right: 'var(--local-line-offset)',
'::before': {
right: offsetToAlignTerminalWithLine,
},
},
bottom: {
bottom: 'var(--local-line-offset)',
'::before': {
bottom: offsetToAlignTerminalWithLine,
},
},
left: {
left: 'var(--local-line-offset)',
'::before': {
left: offsetToAlignTerminalWithLine,
},
},
};
/**
* __Drop indicator__
*
* A drop indicator is used to communicate the intended resting place of the draggable item. The orientation of the drop indicator should always match the direction of the content flow.
*/
export function DropIndicator({ edge, gap = '0px' }: DropIndicatorProps) {
/**
* To clearly communicate the resting place of a draggable item during a drag operation,
* the drop indicator should be positioned half way between draggable items.
*/
const lineOffset = `calc(-0.5 * (${gap} + ${line.thickness}px))`;
const orientation = edgeToOrientationMap[edge];
return (
<Box
sx={{ ...lineStyles, ...orientationStyles[orientation], ...edgeStyles[edge] }}
style={{ '--local-line-offset': lineOffset } as CSSProperties}
/>
);
}
// This default export is intended for usage with React.lazy
export default DropIndicator;

View File

@@ -166,10 +166,15 @@ const singleImage = buildDndSourceApi<{ imageDTO: ImageDTO }>('SingleImage');
* Dnd source API for multiple image source.
*/
const multipleImage = buildDndSourceApi<{ imageDTOs: ImageDTO[]; boardId: BoardId }>('MultipleImage');
/**
* Dnd source API for a single canvas entity.
*/
const singleCanvasEntity = buildDndSourceApi<{ entityIdentifier: CanvasEntityIdentifier }>('SingleCanvasEntity');
const DndSource = {
singleImage,
multipleImage,
singleCanvasEntity,
} as const;
type SourceDataTypeMap = {