Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion demo/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion src/components/block-kit-builder.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
type CollisionDetection,
closestCenter,
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
pointerWithin,
useSensor,
useSensors
} from '@dnd-kit/core';
Expand Down Expand Up @@ -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<CollisionDetection>((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);
Expand Down Expand Up @@ -117,7 +135,7 @@ export function BlockKitBuilder(props: BlockKitBuilderProps) {
<TooltipProvider delayDuration={200}>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
collisionDetection={collisionDetection}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
Expand Down Expand Up @@ -152,6 +170,7 @@ export function BlockKitBuilder(props: BlockKitBuilderProps) {
onUpdate={updateBlock}
onDuplicate={duplicateBlock}
onDelete={removeBlock}
isPaletteDrag={activePaletteVariant !== null}
/>
</div>
</div>
Expand Down
20 changes: 18 additions & 2 deletions src/components/block-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ export function BlockRow({
onOpenChange,
onUpdate,
onDuplicate,
onDelete
onDelete,
isPaletteDrag = false
}: {
builderBlock: BuilderBlock;
previewHooks?: PreviewHooks;
Expand All @@ -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),
Expand All @@ -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 = <SlackBlockPreview block={builderBlock.block} hooks={previewHooks} theme={previewTheme} />;

return (
<div ref={setNodeRef} style={style} className={cn('group relative hover:z-10', isDragging && 'opacity-40')}>
{showDropIndicator ? (
<div
aria-hidden="true"
className="-top-1 pointer-events-none absolute right-0 left-0 z-20 h-0.5 rounded-full bg-primary"
>
<span className="-left-1 -top-[3px] absolute h-2 w-2 rounded-full bg-primary shadow-[0_0_0_2px_var(--color-background)]" />
</div>
) : null}
{isRichText && inlineEditing ? (
<RichTextInlineEditor
block={builderBlock.block as RichTextBlock}
Expand Down
7 changes: 7 additions & 0 deletions src/components/preview/slack-block-preview.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ export const Image: Story = {
alt_text: 'Placeholder image',
title: { type: 'plain_text', text: 'Image title', emoji: true }
})
},
parameters: {
// slack-blocks-to-jsx renders an icon-only resize control on image
// blocks without an aria-label. We can't reach into the upstream
// component, so scope the rule disable to this story; every other
// a11y rule still runs and other stories are unaffected.
a11y: { config: { rules: [{ id: 'button-name', enabled: false }] } }
}
};

Expand Down
66 changes: 55 additions & 11 deletions src/components/surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export function Surface({
onOpenBlockChange,
onUpdate,
onDuplicate,
onDelete
onDelete,
isPaletteDrag = false
}: {
blocks: BuilderBlock[];
workspaceName?: string;
Expand All @@ -58,10 +59,17 @@ export function Surface({
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 { 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 = (
<div
Expand All @@ -70,11 +78,15 @@ export function Surface({
'flex min-h-[240px] flex-col py-2 transition-colors',
isModal ? 'px-5' : 'px-2',
isDark ? 'bg-[#1a1d21]' : 'bg-white',
isOver && (isDark ? 'bg-[#2c3036]' : 'bg-[#f0f4ff]')
isPaletteDrag &&
(isDark
? 'bg-[#22262c] outline-2 outline-primary/40 -outline-offset-2 outline-dashed'
: 'bg-[#f5f8ff] outline-2 outline-primary/40 -outline-offset-2 outline-dashed'),
isPaletteDrag && isOver && (isDark ? 'bg-[#2c3036]' : 'bg-[#eaf0ff]')
)}
>
{blocks.length === 0 ? (
<EmptyState isDark={isDark} />
<EmptyState isDark={isDark} isPaletteDrag={isPaletteDrag} isOver={isOver} />
) : (
<SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
{blocks.map((block) => (
Expand All @@ -89,8 +101,10 @@ export function Surface({
onUpdate={onUpdate}
onDuplicate={onDuplicate}
onDelete={onDelete}
isPaletteDrag={isPaletteDrag}
/>
))}
{showEndDropZone ? <DropIndicator /> : null}
</SortableContext>
)}
</div>
Expand Down Expand Up @@ -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 (
<div
className={cn(
'flex flex-1 flex-col items-center justify-center gap-3 px-6 py-10 text-center',
isDark ? 'text-white/60' : 'text-[#616061]'
'flex flex-1 flex-col items-center justify-center gap-3 px-6 py-10 text-center transition-colors',
active ? 'text-primary' : isDark ? 'text-white/60' : 'text-[#616061]'
)}
>
<span
className={cn(
'flex h-12 w-12 items-center justify-center rounded-full',
isDark ? 'bg-white/5' : 'bg-[#f3f3f3]'
'flex h-12 w-12 items-center justify-center rounded-full transition-colors',
active ? 'bg-primary/15 text-primary' : isDark ? 'bg-white/5' : 'bg-[#f3f3f3]'
)}
>
<LayoutGrid className="h-6 w-6" />
</span>
<div className="flex flex-col gap-0.5">
<p className={cn('text-sm font-semibold', isDark ? 'text-white' : 'text-[#1d1c1d]')}>Start adding blocks!</p>
<p className="text-xs">Drag a block from the left, or click one to add it here.</p>
<p
className={cn(
'text-sm font-semibold transition-colors',
active ? 'text-primary' : isDark ? 'text-white' : 'text-[#1d1c1d]'
)}
>
{active ? 'Drop to add block' : 'Start adding blocks!'}
</p>
<p className="text-xs">
{active ? 'Release to insert it here.' : 'Drag a block from the left, or click one to add it here.'}
</p>
</div>
</div>
);
}

/**
* 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 (
<div aria-hidden="true" className="pointer-events-none relative my-1 h-0.5 w-full rounded-full bg-primary">
<span className="-left-1 -top-[3px] absolute h-2 w-2 rounded-full bg-primary shadow-[0_0_0_2px_var(--color-background)]" />
</div>
);
}
Loading