diff --git a/demo/vite.config.ts b/demo/vite.config.ts index 3c0689b..d6dd42a 100644 --- a/demo/vite.config.ts +++ b/demo/vite.config.ts @@ -8,7 +8,8 @@ export default defineConfig({ resolve: { alias: { '@tightknitai/block-kit-builder': resolve(__dirname, '../src/index.ts') - } + }, + dedupe: ['react', 'react-dom'] }, server: { port: 5173, diff --git a/src/components/block-kit-builder.tsx b/src/components/block-kit-builder.tsx index 68ad6f9..abe2e0b 100644 --- a/src/components/block-kit-builder.tsx +++ b/src/components/block-kit-builder.tsx @@ -1,10 +1,12 @@ import { + type CollisionDetection, closestCenter, DndContext, type DragEndEvent, DragOverlay, type DragStartEvent, PointerSensor, + pointerWithin, useSensor, useSensors } from '@dnd-kit/core'; @@ -70,6 +72,22 @@ export function BlockKitBuilder(props: BlockKitBuilderProps) { const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + // Pick the block directly under the cursor when possible so the drop + // target tracks the cursor rather than whichever droppable's geometric + // center is nearest. The surface (a tall droppable) used to win against + // small block rows under closestCenter, which made it look like every + // palette drop appended to the end. Fall back to closestCenter so the + // bottom of the surface still resolves to a valid target when the + // cursor sits past the last block. + const collisionDetection = useCallback((args) => { + const pointerHits = pointerWithin(args); + if (pointerHits.length > 0) { + const blockHit = pointerHits.find((c) => c.id !== SURFACE_DROPPABLE_ID); + return blockHit ? [blockHit] : pointerHits; + } + return closestCenter(args); + }, []); + const handleDragStart = useCallback((event: DragStartEvent) => { const variantId = parsePaletteDragId(event.active.id); setActivePaletteVariantId(variantId); @@ -117,7 +135,7 @@ export function BlockKitBuilder(props: BlockKitBuilderProps) { diff --git a/src/components/block-row.tsx b/src/components/block-row.tsx index 80b28e4..e2fc503 100644 --- a/src/components/block-row.tsx +++ b/src/components/block-row.tsx @@ -56,7 +56,8 @@ export function BlockRow({ onOpenChange, onUpdate, onDuplicate, - onDelete + onDelete, + isPaletteDrag = false }: { builderBlock: BuilderBlock; previewHooks?: PreviewHooks; @@ -70,8 +71,12 @@ export function BlockRow({ onUpdate: (id: string, block: SupportedBlock) => void; onDuplicate: (id: string) => void; onDelete: (id: string) => void; + /** True while a palette item is being dragged (vs. reordering an existing block). */ + isPaletteDrag?: boolean; }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: builderBlock.id }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({ + id: builderBlock.id + }); const style = { transform: CSS.Transform.toString(transform), @@ -81,11 +86,22 @@ export function BlockRow({ const hasErrors = !!errors && errors.length > 0; const isRichText = builderBlock.block.type === 'rich_text'; const [inlineEditing, setInlineEditing] = useState(false); + // Show the insertion bar only for palette drags; sortable reorders + // already get strong feedback from verticalListSortingStrategy. + const showDropIndicator = isPaletteDrag && isOver; const preview = ; return (
+ {showDropIndicator ? ( + + ) : null} {isRichText && inlineEditing ? ( void; onDuplicate: (id: string) => void; onDelete: (id: string) => void; + /** True while a palette item is being dragged (vs. reordering an existing block). */ + isPaletteDrag?: boolean; }) { const { setNodeRef, isOver } = useDroppable({ id: SURFACE_DROPPABLE_ID }); const isDark = previewTheme === 'dark'; + // Show the end-of-list insertion bar when the cursor is past the last + // block while dragging a palette item. For reorder we rely on + // verticalListSortingStrategy's row shift instead. + const showEndDropZone = isPaletteDrag && isOver && blocks.length > 0; + const isModal = previewSurface === 'modal'; const blocksList = (
{blocks.length === 0 ? ( - + ) : ( b.id)} strategy={verticalListSortingStrategy}> {blocks.map((block) => ( @@ -89,8 +101,10 @@ export function Surface({ onUpdate={onUpdate} onDuplicate={onDuplicate} onDelete={onDelete} + isPaletteDrag={isPaletteDrag} /> ))} + {showEndDropZone ? : null} )}
@@ -313,31 +327,61 @@ function AppHomeFrame({ * Placeholder shown inside the preview surface when the draft has no * blocks. Uses Slack-style explicit colors keyed off `isDark` so the * empty state visually matches the rest of the preview chrome and stays - * consistent regardless of the host app's light/dark theme. + * consistent regardless of the host app's light/dark theme. While a + * palette item is being dragged the copy switches to a "Drop here" cue + * and the icon container brightens to confirm the empty surface itself + * is the drop target. * @param props - empty state props * @param props.isDark - whether the dark Slack canvas is active + * @param props.isPaletteDrag - whether a palette block is currently being dragged + * @param props.isOver - whether the cursor is currently over the surface * @returns the rendered placeholder */ -function EmptyState({ isDark }: { isDark: boolean }) { +function EmptyState({ isDark, isPaletteDrag, isOver }: { isDark: boolean; isPaletteDrag: boolean; isOver: boolean }) { + const active = isPaletteDrag && isOver; return (
-

Start adding blocks!

-

Drag a block from the left, or click one to add it here.

+

+ {active ? 'Drop to add block' : 'Start adding blocks!'} +

+

+ {active ? 'Release to insert it here.' : 'Drag a block from the left, or click one to add it here.'} +

); } + +/** + * Insertion bar shown at the cursor's drop target while a palette item + * is being dragged. A 2px primary-colored line spanning the row width + * with a small filled caret on the leading edge so the user can see + * exactly where the new block will land. + * @returns the rendered drop indicator + */ +function DropIndicator() { + return ( + + ); +}