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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,37 @@ Pass [`customEmojis`](#props) to add your workspace's custom emoji: image entrie

Defense-in-depth: blocks are validated against [slack-block-kit-validator](https://github.com/TightknitAI/slack-block-kit-validator) before send. Issues are surfaced in the issues sheet with line numbers — users can fix them inline before posting.

## Undo & redo

Every edit to the draft is undoable — adding, deleting, duplicating,
reordering, dragging in and out of containers, editing a block's fields, and
the toolbar's **Clear** and **View JSON → apply**. The toolbar shows **Undo**
and **Redo** buttons (disabled when there's nothing to step to), and the
keyboard shortcuts work whenever focus is inside the builder:

| Shortcut | Action |
|---|---|
| `Ctrl`/`Cmd` + `Z` | Undo |
| `Ctrl`/`Cmd` + `Shift` + `Z`, or `Ctrl` + `Y` | Redo |

Notes:

- **Typing coalesces.** A run of edits to the *same* field collapses into a
single undo step, so one `Ctrl+Z` rewinds the whole edit rather than one
character at a time. Editing a different block, or any structural change,
starts a new step.
- **Native text undo is preserved.** While a text input, textarea, or the
inline rich-text editor is focused, `Ctrl/Cmd+Z` performs the browser's
own per-character undo — the builder only takes over when focus is on the
canvas or toolbar. The shortcut is scoped to the builder, so it never
hijacks the host app's own `Ctrl/Cmd+Z`.
- **Loading is a fresh baseline.** [Loading an existing
message](#loading-an-existing-message-opt-in) (or "open as new") starts a
new document with an empty history — undo won't step back across the load
into the previous draft. **Clear**, by contrast, is undoable.
- Undo and redo are ordinary draft changes, so they flow through `onChange`
(and re-run validation) like any other edit.

## Styling

Ships a compiled stylesheet at `@tightknitai/block-kitchen/styles.css`. The styles use CSS custom properties (`--background`, `--primary`, `--border`, etc.) for theming. Consumers must provide values for these vars — the standard shadcn/ui token set works as-is.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tightknitai/slack-block-kit-validator": "^0.1.9",
"@tightknitai/slack-block-kit-validator": "^0.1.11",
"@tiptap/extension-link": "^3.23.4",
"@tiptap/react": "^3.23.4",
"@tiptap/starter-kit": "^3.23.4",
Expand Down
18 changes: 9 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 72 additions & 9 deletions src/components/block-kitchen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
} from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { GripVertical } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { type KeyboardEvent, useCallback, useMemo, useState } from 'react';
import { parseContainerBodyId } from '../lib/container-blocks';
import { makeEmojiHook } from '../lib/custom-emoji-hook';
import { buildVariantById, defaultPalette, type PaletteSection } from '../lib/default-blocks';
Expand Down Expand Up @@ -217,8 +217,22 @@ export function BlockKitchen(props: BlockKitchenProps) {
'ignoring `primaryAction`.'
);

const { blocks, addBlock, addChild, updateBlock, removeBlock, duplicateBlock, reorderBlock, moveBlock, replaceAll } =
useBlockKitchenState({ initialBlocks: seededBlocks, onChange });
const {
blocks,
addBlock,
addChild,
updateBlock,
removeBlock,
duplicateBlock,
reorderBlock,
moveBlock,
replaceAll,
resetAll,
undo,
redo,
canUndo,
canRedo
} = useBlockKitchenState({ initialBlocks: seededBlocks, onChange });

// Lookups for resolving a drop target: which ids are container children,
// and which container each child belongs to. Recomputed when the tree
Expand Down Expand Up @@ -405,6 +419,38 @@ export function BlockKitchen(props: BlockKitchenProps) {
setActivePaletteVariantId(null);
}, []);

// Undo/redo keyboard shortcuts, scoped to the builder: this is a React
// `onKeyDown` on the root, so it only fires while focus is inside the
// builder and never hijacks the host app's own Cmd+Z. Text-editing targets
// (inputs, textareas, the inline rich-text contentEditable) keep their
// native per-character undo — we bail before preventing default. Popover
// and dialog editors render in body-level portals outside this subtree, so
// their fields are unaffected regardless.
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey) {
return;
}
const key = event.key.toLowerCase();
const isUndo = key === 'z' && !event.shiftKey;
const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
if (!isUndo && !isRedo) {
return;
}
const target = event.target as HTMLElement | null;
if (target && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))) {
return;
}
event.preventDefault();
if (isRedo) {
if (canRedo) redo();
} else if (canUndo) {
undo();
}
},
[canRedo, canUndo, redo, undo]
);

const activePaletteVariant = activePaletteVariantId ? variantById.get(activePaletteVariantId) : null;

const blockPayloads = blocks.map((b) => b.block);
Expand All @@ -420,8 +466,19 @@ export function BlockKitchen(props: BlockKitchenProps) {
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="bk-root flex h-full w-full flex-col overflow-hidden rounded-md border bg-background text-foreground">
{/* The builder shell doubles as the keydown scope for undo/redo
shortcuts: the handler only augments already-focusable children
(toolbar buttons, block rows, fields) and never acts as a
control itself, so it needs no role or tabindex. */}
<div
className="bk-root flex h-full w-full flex-col overflow-hidden rounded-md border bg-background text-foreground"
onKeyDown={handleKeyDown}
>
<Toolbar
canUndo={canUndo}
canRedo={canRedo}
onUndo={undo}
onRedo={redo}
onClear={() => replaceAll([])}
onOpenJson={() => setJsonOpen(true)}
onOpenIssues={() => setIssuesOpen(true)}
Expand Down Expand Up @@ -476,9 +533,11 @@ export function BlockKitchen(props: BlockKitchenProps) {
if (sendEnabled) {
// Edit-centric exit (send mode): discard the loaded
// message's draft and reopen the loader so the user
// starts fresh or picks another message.
// starts fresh or picks another message. Reset (not
// replace) so undo can't resurrect the abandoned draft
// after the banner is gone.
setEditTarget(null);
replaceAll([]);
resetAll([]);
setLoadOpen(true);
return;
}
Expand Down Expand Up @@ -613,16 +672,20 @@ export function BlockKitchen(props: BlockKitchenProps) {
loadRecentMessages={loading.loadRecentMessages}
loadChannels={recentChannelSource}
onLoaded={(result) => {
replaceAll(result.blocks);
// A newly loaded message is a fresh document: reset history
// so undo starts from the loaded blocks and can't step back
// across the load into the previous draft.
resetAll(result.blocks);
setEditTarget(toLoadedMessage(result));
setLoadOpen(false);
}}
onOpenAsNew={(loadedBlocks) => {
// Fallback for a not-editable verdict: drop edit mode and
// hydrate the draft (when the host supplied blocks) so the
// user can repost it as a brand-new message.
// user can repost it as a brand-new message. Fresh document,
// so reset history here too.
if (loadedBlocks) {
replaceAll(loadedBlocks);
resetAll(loadedBlocks);
}
setEditTarget(null);
setLoadOpen(false);
Expand Down
28 changes: 28 additions & 0 deletions src/components/toolbar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ const meta = {
component: Toolbar,
parameters: { layout: 'fullscreen', a11y: { test: 'error' } },
args: {
onUndo: fn(),
onRedo: fn(),
canUndo: false,
canRedo: false,
onClear: fn(),
onOpenJson: fn(),
onOpenIssues: fn(),
Expand Down Expand Up @@ -67,6 +71,30 @@ export const CustomSendLabel: Story = {
}
};

// Undo/redo are disabled when there's no history to walk. This is the
// builder's initial state — nothing to undo, nothing to redo.
export const HistoryEmpty: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const undo = await canvas.findByRole('button', { name: 'Undo' });
const redo = await canvas.findByRole('button', { name: 'Redo' });
await expect(undo).toBeDisabled();
await expect(redo).toBeDisabled();
}
};

// With history available, both controls are live and invoke their handlers.
export const HistoryAvailable: Story = {
args: { canUndo: true, canRedo: true },
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Undo' }));
await expect(args.onUndo).toHaveBeenCalledOnce();
await userEvent.click(await canvas.findByRole('button', { name: 'Redo' }));
await expect(args.onRedo).toHaveBeenCalledOnce();
}
};

export const DocsLinkHidden: Story = {
args: { docsLink: false }
};
Expand Down
46 changes: 46 additions & 0 deletions src/components/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import {
Moon,
Pencil,
Plus,
Redo2,
Send,
Sun,
Trash2,
Undo2,
X
} from 'lucide-react';
import type { ComponentType, KeyboardEvent } from 'react';
Expand Down Expand Up @@ -66,6 +68,10 @@ const SEND_MENU_ITEM =
* Top toolbar with the preview theme picker, View JSON escape hatch, and
* the Send action.
* @param props - toolbar props
* @param props.canUndo - whether the Undo button is enabled
* @param props.canRedo - whether the Redo button is enabled
* @param props.onUndo - steps the draft back one history entry
* @param props.onRedo - steps the draft forward one history entry
* @param props.onClear - resets the draft to empty (disabled when already empty)
* @param props.onOpenJson - opens the JSON drawer
* @param props.onOpenIssues - opens the issues sheet
Expand Down Expand Up @@ -93,6 +99,10 @@ const SEND_MENU_ITEM =
* @returns the rendered toolbar
*/
export function Toolbar({
canUndo = false,
canRedo = false,
onUndo,
onRedo,
onClear,
onOpenJson,
onOpenIssues,
Expand Down Expand Up @@ -120,6 +130,14 @@ export function Toolbar({
loadButtonLabel = 'Find message',
updateButtonLabel = 'Review & update'
}: {
/** Whether an undo step is available (enables the Undo button). */
canUndo?: boolean;
/** Whether a redo step is available (enables the Redo button). */
canRedo?: boolean;
/** Steps the draft back one history entry. Undo button is hidden if omitted. */
onUndo?: () => void;
/** Steps the draft forward one history entry. Redo button is hidden if omitted. */
onRedo?: () => void;
onClear: () => void;
onOpenJson: () => void;
onOpenIssues: () => void;
Expand Down Expand Up @@ -269,6 +287,34 @@ export function Toolbar({
) : null}
</div>
<div className="flex items-center gap-1 sm:gap-2">
{onUndo && onRedo ? (
<div className="flex items-center">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onUndo}
disabled={!canUndo}
aria-label="Undo"
title="Undo (Ctrl+Z)"
className="px-2"
>
<Undo2 className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRedo}
disabled={!canRedo}
aria-label="Redo"
title="Redo (Ctrl+Shift+Z)"
className="px-2"
>
<Redo2 className="h-3.5 w-3.5" />
</Button>
</div>
) : null}
{errorCount > 0 ? (
<Button
type="button"
Expand Down
Loading
Loading