From 1847f383b4e29c34683ec77fd5b688e865aab9ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 17:58:40 +0000 Subject: [PATCH 1/5] feat: add undo/redo to the block builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every draft mutation is now recorded for undo/redo — adds, deletes, duplicates, reorders, container moves, per-field edits, and the toolbar's Clear and JSON-apply. The toolbar gains Undo/Redo buttons (disabled when there's nothing to step to) and Ctrl/Cmd+Z / Ctrl/Cmd+Shift+Z / Ctrl+Y shortcuts scoped to the builder. - New generic `useHistoryState` hook (past/present/future) with tag-based coalescing and a bounded history, tested independently. - `useBlockKitchenState` routes all mutators through it. Consecutive edits to the same block coalesce into one undo step so typing doesn't flood the stack; structural changes and `replaceAll` each form their own step. - `replaceAll` (Clear, JSON apply) stays undoable; a new `resetAll` backs loading a message / "open as new" as a fresh baseline, so undo can't cross the load boundary and desync the loaded banner. - Keyboard shortcuts bail on text-editing targets (inputs, textareas, the inline rich-text contentEditable) so native per-character undo is preserved, and never hijack the host app's own Ctrl/Cmd+Z. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UM6nXbE9uPet1gFVohrCyY --- README.md | 31 +++ src/components/block-kitchen.tsx | 81 ++++++- src/components/toolbar.stories.tsx | 28 +++ src/components/toolbar.tsx | 46 ++++ src/state/use-block-kitchen-state.ts | 309 ++++++++++++++++---------- src/state/use-history-state.ts | 134 +++++++++++ test/block-kitchen-undo-redo.test.tsx | 79 +++++++ test/use-block-kitchen-state.test.ts | 121 ++++++++++ test/use-history-state.test.ts | 128 +++++++++++ 9 files changed, 825 insertions(+), 132 deletions(-) create mode 100644 src/state/use-history-state.ts create mode 100644 test/block-kitchen-undo-redo.test.tsx create mode 100644 test/use-history-state.test.ts diff --git a/README.md b/README.md index 8224c1c..f121d21 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/components/block-kitchen.tsx b/src/components/block-kitchen.tsx index b3aa0eb..033edf7 100644 --- a/src/components/block-kitchen.tsx +++ b/src/components/block-kitchen.tsx @@ -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'; @@ -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 @@ -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) => { + 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); @@ -420,8 +466,19 @@ export function BlockKitchen(props: BlockKitchenProps) { onDragEnd={handleDragEnd} onDragCancel={handleDragCancel} > -
+ {/* 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. */} +
replaceAll([])} onOpenJson={() => setJsonOpen(true)} onOpenIssues={() => setIssuesOpen(true)} @@ -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; } @@ -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); diff --git a/src/components/toolbar.stories.tsx b/src/components/toolbar.stories.tsx index b4d4dd2..63921be 100644 --- a/src/components/toolbar.stories.tsx +++ b/src/components/toolbar.stories.tsx @@ -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(), @@ -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 } }; diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index 9a9fcd7..bc8a824 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -12,9 +12,11 @@ import { Moon, Pencil, Plus, + Redo2, Send, Sun, Trash2, + Undo2, X } from 'lucide-react'; import type { ComponentType, KeyboardEvent } from 'react'; @@ -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 @@ -93,6 +99,10 @@ const SEND_MENU_ITEM = * @returns the rendered toolbar */ export function Toolbar({ + canUndo = false, + canRedo = false, + onUndo, + onRedo, onClear, onOpenJson, onOpenIssues, @@ -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; @@ -269,6 +287,34 @@ export function Toolbar({ ) : null}
+ {onUndo && onRedo ? ( +
+ + +
+ ) : null} {errorCount > 0 ? (