Skip to content

Commit dd3554b

Browse files
zhawtofclaude
andauthored
feat(dnd): show clear landing position while dragging a block (#19)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent eb53178 commit dd3554b

5 files changed

Lines changed: 102 additions & 15 deletions

File tree

demo/vite.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ export default defineConfig({
88
resolve: {
99
alias: {
1010
'@tightknitai/block-kit-builder': resolve(__dirname, '../src/index.ts')
11-
}
11+
},
12+
dedupe: ['react', 'react-dom']
1213
},
1314
server: {
1415
port: 5173,

src/components/block-kit-builder.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import {
2+
type CollisionDetection,
23
closestCenter,
34
DndContext,
45
type DragEndEvent,
56
DragOverlay,
67
type DragStartEvent,
78
PointerSensor,
9+
pointerWithin,
810
useSensor,
911
useSensors
1012
} from '@dnd-kit/core';
@@ -70,6 +72,22 @@ export function BlockKitBuilder(props: BlockKitBuilderProps) {
7072

7173
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
7274

75+
// Pick the block directly under the cursor when possible so the drop
76+
// target tracks the cursor rather than whichever droppable's geometric
77+
// center is nearest. The surface (a tall droppable) used to win against
78+
// small block rows under closestCenter, which made it look like every
79+
// palette drop appended to the end. Fall back to closestCenter so the
80+
// bottom of the surface still resolves to a valid target when the
81+
// cursor sits past the last block.
82+
const collisionDetection = useCallback<CollisionDetection>((args) => {
83+
const pointerHits = pointerWithin(args);
84+
if (pointerHits.length > 0) {
85+
const blockHit = pointerHits.find((c) => c.id !== SURFACE_DROPPABLE_ID);
86+
return blockHit ? [blockHit] : pointerHits;
87+
}
88+
return closestCenter(args);
89+
}, []);
90+
7391
const handleDragStart = useCallback((event: DragStartEvent) => {
7492
const variantId = parsePaletteDragId(event.active.id);
7593
setActivePaletteVariantId(variantId);
@@ -117,7 +135,7 @@ export function BlockKitBuilder(props: BlockKitBuilderProps) {
117135
<TooltipProvider delayDuration={200}>
118136
<DndContext
119137
sensors={sensors}
120-
collisionDetection={closestCenter}
138+
collisionDetection={collisionDetection}
121139
onDragStart={handleDragStart}
122140
onDragEnd={handleDragEnd}
123141
onDragCancel={handleDragCancel}
@@ -152,6 +170,7 @@ export function BlockKitBuilder(props: BlockKitBuilderProps) {
152170
onUpdate={updateBlock}
153171
onDuplicate={duplicateBlock}
154172
onDelete={removeBlock}
173+
isPaletteDrag={activePaletteVariant !== null}
155174
/>
156175
</div>
157176
</div>

src/components/block-row.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ export function BlockRow({
5656
onOpenChange,
5757
onUpdate,
5858
onDuplicate,
59-
onDelete
59+
onDelete,
60+
isPaletteDrag = false
6061
}: {
6162
builderBlock: BuilderBlock;
6263
previewHooks?: PreviewHooks;
@@ -70,8 +71,12 @@ export function BlockRow({
7071
onUpdate: (id: string, block: SupportedBlock) => void;
7172
onDuplicate: (id: string) => void;
7273
onDelete: (id: string) => void;
74+
/** True while a palette item is being dragged (vs. reordering an existing block). */
75+
isPaletteDrag?: boolean;
7376
}) {
74-
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: builderBlock.id });
77+
const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({
78+
id: builderBlock.id
79+
});
7580

7681
const style = {
7782
transform: CSS.Transform.toString(transform),
@@ -81,11 +86,22 @@ export function BlockRow({
8186
const hasErrors = !!errors && errors.length > 0;
8287
const isRichText = builderBlock.block.type === 'rich_text';
8388
const [inlineEditing, setInlineEditing] = useState(false);
89+
// Show the insertion bar only for palette drags; sortable reorders
90+
// already get strong feedback from verticalListSortingStrategy.
91+
const showDropIndicator = isPaletteDrag && isOver;
8492

8593
const preview = <SlackBlockPreview block={builderBlock.block} hooks={previewHooks} theme={previewTheme} />;
8694

8795
return (
8896
<div ref={setNodeRef} style={style} className={cn('group relative hover:z-10', isDragging && 'opacity-40')}>
97+
{showDropIndicator ? (
98+
<div
99+
aria-hidden="true"
100+
className="-top-1 pointer-events-none absolute right-0 left-0 z-20 h-0.5 rounded-full bg-primary"
101+
>
102+
<span className="-left-1 -top-[3px] absolute h-2 w-2 rounded-full bg-primary shadow-[0_0_0_2px_var(--color-background)]" />
103+
</div>
104+
) : null}
89105
{isRichText && inlineEditing ? (
90106
<RichTextInlineEditor
91107
block={builderBlock.block as RichTextBlock}

src/components/preview/slack-block-preview.stories.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,13 @@ export const Image: Story = {
141141
alt_text: 'Placeholder image',
142142
title: { type: 'plain_text', text: 'Image title', emoji: true }
143143
})
144+
},
145+
parameters: {
146+
// slack-blocks-to-jsx renders an icon-only resize control on image
147+
// blocks without an aria-label. We can't reach into the upstream
148+
// component, so scope the rule disable to this story; every other
149+
// a11y rule still runs and other stories are unaffected.
150+
a11y: { config: { rules: [{ id: 'button-name', enabled: false }] } }
144151
}
145152
};
146153

src/components/surface.tsx

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ export function Surface({
4242
onOpenBlockChange,
4343
onUpdate,
4444
onDuplicate,
45-
onDelete
45+
onDelete,
46+
isPaletteDrag = false
4647
}: {
4748
blocks: BuilderBlock[];
4849
workspaceName?: string;
@@ -58,10 +59,17 @@ export function Surface({
5859
onUpdate: (id: string, block: SupportedBlock) => void;
5960
onDuplicate: (id: string) => void;
6061
onDelete: (id: string) => void;
62+
/** True while a palette item is being dragged (vs. reordering an existing block). */
63+
isPaletteDrag?: boolean;
6164
}) {
6265
const { setNodeRef, isOver } = useDroppable({ id: SURFACE_DROPPABLE_ID });
6366
const isDark = previewTheme === 'dark';
6467

68+
// Show the end-of-list insertion bar when the cursor is past the last
69+
// block while dragging a palette item. For reorder we rely on
70+
// verticalListSortingStrategy's row shift instead.
71+
const showEndDropZone = isPaletteDrag && isOver && blocks.length > 0;
72+
6573
const isModal = previewSurface === 'modal';
6674
const blocksList = (
6775
<div
@@ -70,11 +78,15 @@ export function Surface({
7078
'flex min-h-[240px] flex-col py-2 transition-colors',
7179
isModal ? 'px-5' : 'px-2',
7280
isDark ? 'bg-[#1a1d21]' : 'bg-white',
73-
isOver && (isDark ? 'bg-[#2c3036]' : 'bg-[#f0f4ff]')
81+
isPaletteDrag &&
82+
(isDark
83+
? 'bg-[#22262c] outline-2 outline-primary/40 -outline-offset-2 outline-dashed'
84+
: 'bg-[#f5f8ff] outline-2 outline-primary/40 -outline-offset-2 outline-dashed'),
85+
isPaletteDrag && isOver && (isDark ? 'bg-[#2c3036]' : 'bg-[#eaf0ff]')
7486
)}
7587
>
7688
{blocks.length === 0 ? (
77-
<EmptyState isDark={isDark} />
89+
<EmptyState isDark={isDark} isPaletteDrag={isPaletteDrag} isOver={isOver} />
7890
) : (
7991
<SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
8092
{blocks.map((block) => (
@@ -89,8 +101,10 @@ export function Surface({
89101
onUpdate={onUpdate}
90102
onDuplicate={onDuplicate}
91103
onDelete={onDelete}
104+
isPaletteDrag={isPaletteDrag}
92105
/>
93106
))}
107+
{showEndDropZone ? <DropIndicator /> : null}
94108
</SortableContext>
95109
)}
96110
</div>
@@ -313,31 +327,61 @@ function AppHomeFrame({
313327
* Placeholder shown inside the preview surface when the draft has no
314328
* blocks. Uses Slack-style explicit colors keyed off `isDark` so the
315329
* empty state visually matches the rest of the preview chrome and stays
316-
* consistent regardless of the host app's light/dark theme.
330+
* consistent regardless of the host app's light/dark theme. While a
331+
* palette item is being dragged the copy switches to a "Drop here" cue
332+
* and the icon container brightens to confirm the empty surface itself
333+
* is the drop target.
317334
* @param props - empty state props
318335
* @param props.isDark - whether the dark Slack canvas is active
336+
* @param props.isPaletteDrag - whether a palette block is currently being dragged
337+
* @param props.isOver - whether the cursor is currently over the surface
319338
* @returns the rendered placeholder
320339
*/
321-
function EmptyState({ isDark }: { isDark: boolean }) {
340+
function EmptyState({ isDark, isPaletteDrag, isOver }: { isDark: boolean; isPaletteDrag: boolean; isOver: boolean }) {
341+
const active = isPaletteDrag && isOver;
322342
return (
323343
<div
324344
className={cn(
325-
'flex flex-1 flex-col items-center justify-center gap-3 px-6 py-10 text-center',
326-
isDark ? 'text-white/60' : 'text-[#616061]'
345+
'flex flex-1 flex-col items-center justify-center gap-3 px-6 py-10 text-center transition-colors',
346+
active ? 'text-primary' : isDark ? 'text-white/60' : 'text-[#616061]'
327347
)}
328348
>
329349
<span
330350
className={cn(
331-
'flex h-12 w-12 items-center justify-center rounded-full',
332-
isDark ? 'bg-white/5' : 'bg-[#f3f3f3]'
351+
'flex h-12 w-12 items-center justify-center rounded-full transition-colors',
352+
active ? 'bg-primary/15 text-primary' : isDark ? 'bg-white/5' : 'bg-[#f3f3f3]'
333353
)}
334354
>
335355
<LayoutGrid className="h-6 w-6" />
336356
</span>
337357
<div className="flex flex-col gap-0.5">
338-
<p className={cn('text-sm font-semibold', isDark ? 'text-white' : 'text-[#1d1c1d]')}>Start adding blocks!</p>
339-
<p className="text-xs">Drag a block from the left, or click one to add it here.</p>
358+
<p
359+
className={cn(
360+
'text-sm font-semibold transition-colors',
361+
active ? 'text-primary' : isDark ? 'text-white' : 'text-[#1d1c1d]'
362+
)}
363+
>
364+
{active ? 'Drop to add block' : 'Start adding blocks!'}
365+
</p>
366+
<p className="text-xs">
367+
{active ? 'Release to insert it here.' : 'Drag a block from the left, or click one to add it here.'}
368+
</p>
340369
</div>
341370
</div>
342371
);
343372
}
373+
374+
/**
375+
* Insertion bar shown at the cursor's drop target while a palette item
376+
* is being dragged. A 2px primary-colored line spanning the row width
377+
* with a small filled caret on the leading edge so the user can see
378+
* exactly where the new block will land.
379+
* @returns the rendered drop indicator
380+
*/
381+
function DropIndicator() {
382+
return (
383+
<div aria-hidden="true" className="pointer-events-none relative my-1 h-0.5 w-full rounded-full bg-primary">
384+
<span className="-left-1 -top-[3px] absolute h-2 w-2 rounded-full bg-primary shadow-[0_0_0_2px_var(--color-background)]" />
385+
</div>
386+
);
387+
}

0 commit comments

Comments
 (0)