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
67 changes: 58 additions & 9 deletions src/components/block-kitchen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@ import {
type DragEndEvent,
DragOverlay,
type DragStartEvent,
KeyboardSensor,
PointerSensor,
pointerWithin,
TouchSensor,
useSensor,
useSensors
} from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { GripVertical } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { buildVariantById, defaultPalette } from '../lib/default-blocks';
import { Sheet, SheetContent, SheetDescription, SheetTitle } from '../lib/ui/sheet';
import { TooltipProvider } from '../lib/ui/tooltip';
import { useIsMobile } from '../lib/use-is-mobile';
import { useBlockKitValidation } from '../state/use-block-kit-validation';
import { useBlockKitchenState } from '../state/use-block-kitchen-state';
import type { BlockKitchenProps, PreviewSurface, PreviewTheme } from '../types';
Expand Down Expand Up @@ -69,18 +74,28 @@ export function BlockKitchen(props: BlockKitchenProps) {
const [jsonOpen, setJsonOpen] = useState(false);
const [sendOpen, setSendOpen] = useState(false);
const [issuesOpen, setIssuesOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [openBlockId, setOpenBlockId] = useState<string | null>(null);
const [previewTheme, setPreviewTheme] = useState<PreviewTheme>(defaultPreviewTheme);
const [previewSurface, setPreviewSurface] = useState<PreviewSurface>(allowedSurfaces[0]);
const [activePaletteVariantId, setActivePaletteVariantId] = useState<string | null>(null);

const isMobile = useIsMobile();

// Always validate against the `message` surface: that's where Send posts
// to. If we scoped validation to the preview surface, a user could switch
// to `modal`, drop in modal-only blocks, see `errorCount === 0`, and have
// Send accept a payload Slack will reject.
const validation = useBlockKitValidation(blocks, 'message');

const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
// Touch needs a 150ms press-and-hold to start a drag so scrolling the
// surface doesn't accidentally pick up a block. Pointer keeps the small
// 4px distance threshold so mouse clicks still open the editor cleanly.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);

// Pick the block directly under the cursor when possible so the drop
// target tracks the cursor rather than whichever droppable's geometric
Expand Down Expand Up @@ -151,12 +166,13 @@ export function BlockKitchen(props: BlockKitchenProps) {
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="bk-root flex h-full w-full flex-col rounded-md border bg-background text-foreground">
<div className="bk-root flex h-full w-full flex-col overflow-hidden rounded-md border bg-background text-foreground">
<Toolbar
onClear={() => replaceAll([])}
onOpenJson={() => setJsonOpen(true)}
onOpenIssues={() => setIssuesOpen(true)}
onOpenSend={() => setSendOpen(true)}
onOpenPalette={() => setPaletteOpen(true)}
canSend={blocks.length > 0}
canClear={blocks.length > 0}
errorCount={validation.total}
Expand All @@ -169,13 +185,17 @@ export function BlockKitchen(props: BlockKitchenProps) {
docsLink={docsLink}
/>
<div className="flex min-h-0 flex-1 items-stretch">
<Palette
onAddBlock={(block) => addBlock(block)}
sections={paletteSections}
showSearch={showPaletteSearch}
searchPlaceholder={paletteSearchPlaceholder}
defaultOpenSections={defaultOpenSections}
/>
{/* Desktop: persistent left aside. Mobile: collapsed to the
palette sheet trigger in the toolbar. */}
<div className="hidden min-h-0 md:flex">
<Palette
onAddBlock={(block) => addBlock(block)}
sections={paletteSections}
showSearch={showPaletteSearch}
searchPlaceholder={paletteSearchPlaceholder}
defaultOpenSections={defaultOpenSections}
/>
</div>
<Surface
blocks={blocks}
workspaceName={workspaceName}
Expand All @@ -190,6 +210,7 @@ export function BlockKitchen(props: BlockKitchenProps) {
onDelete={removeBlock}
onReorder={reorderBlock}
isPaletteDrag={activePaletteVariant !== null}
onOpenPalette={() => setPaletteOpen(true)}
/>
</div>
</div>
Expand All @@ -201,6 +222,34 @@ export function BlockKitchen(props: BlockKitchenProps) {
</div>
) : null}
</DragOverlay>
{/* Mobile-only palette sheet. The desktop aside above stays put;
this opens from the bottom on a tap of the toolbar's Blocks
button. Tap-to-add closes the sheet so the user sees the new
row land in the surface. */}
<Sheet open={paletteOpen && isMobile} onOpenChange={setPaletteOpen}>
<SheetContent
side="bottom"
className="bk-portal-content flex h-[85svh] max-h-[85svh] flex-col gap-3 p-0 sm:max-w-none"
>
<div className="flex flex-col gap-1 px-4 pt-5">
<SheetTitle>Add a block</SheetTitle>
<SheetDescription>Tap a block to add it to the bottom of your draft.</SheetDescription>
</div>
<div className="flex min-h-0 flex-1 flex-col">
<Palette
onAddBlock={(block) => {
addBlock(block);
setPaletteOpen(false);
}}
sections={paletteSections}
showSearch={showPaletteSearch}
searchPlaceholder={paletteSearchPlaceholder}
defaultOpenSections={defaultOpenSections}
variant="sheet"
/>
</div>
</SheetContent>
</Sheet>
<JsonDrawer open={jsonOpen} onOpenChange={setJsonOpen} blocks={blockPayloads} onApply={replaceAll} />
<SendDialog
open={sendOpen}
Expand Down
69 changes: 64 additions & 5 deletions src/components/block-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,11 @@ export function BlockRow({
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')}>
<div
ref={setNodeRef}
style={style}
className={cn('group relative mb-2 last:mb-0 hover:z-10 md:mb-0', isDragging && 'opacity-40')}
>
{showDropIndicator ? (
<div
aria-hidden="true"
Expand Down Expand Up @@ -169,7 +173,7 @@ export function BlockRow({
{preview}
</div>
</PopoverTrigger>
<PopoverContent className="w-[32rem]" align="start">
<PopoverContent className="w-[min(32rem,calc(100vw-1.5rem))] sm:w-[32rem]" align="start">
<BlockEditor
block={builderBlock.block}
errors={errors}
Expand All @@ -178,15 +182,19 @@ export function BlockRow({
</PopoverContent>
</Popover>
)}
<span className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 z-10 bg-background px-1.5 text-[11px] text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
<span className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 z-10 hidden bg-background px-1.5 text-[11px] text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 md:block">
{BLOCK_TYPE_LABELS[builderBlock.block.type]}
</span>
{/* Desktop action toolbar. Hover-reveal floating above the block —
crowded but only one is visible at a time so the overlap is
invisible. Hidden on mobile in favor of the inline row below. */}
<div
className={cn(
'absolute -top-3 right-2 z-10 flex items-center gap-0.5 rounded-md border bg-background p-0.5 shadow-sm transition-opacity',
// Reveal on hover, on focus inside the row (so Tab-stops on the
// Hidden on mobile in favor of the inline row below; on desktop
// reveal on hover, on focus inside the row (so Tab-stops on the
// floating buttons themselves keep them visible), and when there
// are validation errors worth surfacing immediately.
'absolute -top-3 right-2 z-10 hidden items-center gap-0.5 rounded-md border bg-background p-0.5 shadow-sm transition-opacity md:flex',
hasErrors ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
)}
>
Expand Down Expand Up @@ -291,6 +299,57 @@ export function BlockRow({
</Tooltip>
</div>
</div>
{/* Mobile-only inline action bar. Lives in the flow under the block
preview so it never overlaps content (the floating desktop bar
gets in its own way when every row shows it). Type label sits on
the left, action triplet on the right. */}
<div className="mt-1 flex items-center justify-between gap-2 rounded-md border border-dashed bg-muted/40 px-2 py-1 md:hidden">
<span className="truncate text-[11px] font-medium text-muted-foreground">
{BLOCK_TYPE_LABELS[builderBlock.block.type]}
</span>
<div className="flex items-center gap-1">
{hasErrors ? (
<button
type="button"
aria-label={`Show ${errors!.length} validation ${errors!.length === 1 ? 'issue' : 'issues'}`}
onClick={() => onOpenChange?.(true)}
className="flex h-9 w-9 items-center justify-center rounded text-destructive hover:bg-destructive/10"
>
<AlertTriangle className="h-4 w-4" />
</button>
) : null}
<button
type="button"
aria-label="Edit block"
onClick={() => {
if (isRichText) {
setInlineEditing(true);
} else {
onOpenChange?.(true);
}
}}
className="flex h-9 w-9 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
aria-label="Duplicate block"
onClick={() => onDuplicate(builderBlock.id)}
className="flex h-9 w-9 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Copy className="h-4 w-4" />
</button>
<button
type="button"
aria-label="Delete block"
onClick={() => onDelete(builderBlock.id)}
className="flex h-9 w-9 items-center justify-center rounded text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/components/issues-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function IssuesSheet({

return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-3 sm:max-w-md">
<SheetContent className="flex w-full max-w-full flex-col gap-3 sm:max-w-md">
<div className="flex flex-col gap-1">
<SheetTitle className="flex items-center gap-2">
{validation.total === 0 ? (
Expand Down
4 changes: 2 additions & 2 deletions src/components/json-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ export function JsonDrawer({

return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-3 sm:max-w-xl">
<div className="flex flex-col gap-1">
<SheetContent className="flex w-full max-w-full flex-col gap-3 sm:max-w-xl">
<div className="flex flex-col gap-1 pr-8">
<SheetTitle>Block Kit JSON</SheetTitle>
<SheetDescription>Edits update the preview as you type. Parse errors show below.</SheetDescription>
</div>
Expand Down
Loading
Loading