Skip to content

feat: undo/redo for block editor #164

@oobagi

Description

@oobagi

Problem

The block editor has no undo/redo. Every destructive action — backspace merging
blocks, Ctrl+K cutting a block, accidentally deleting a heading — is permanent
until the user manually reconstructs it. For a developer audience with Ctrl+Z
muscle memory, this suppresses confident editing and makes the tool feel fragile.

Context

The editor (internal/editor/editor.go) uses a block-based content model where
m.blocks []block.Block holds the document and m.textareas []textarea.Model
provides one textarea per block. The Block struct is simple (4 fields: Type,
Content, Language, Checked), making deep copies cheap.

There are ~10 structural mutation points that need undo tracking:

  • insertBlockBefore / insertBlockAfter (line ~300-337)
  • deleteBlock (line ~342)
  • mergeBlockUp (line ~369)
  • swapBlocks (line ~420)
  • handleEnter (line ~444) — splits blocks
  • handleBackspace (line ~583) — merges/deletes/converts blocks
  • cutBlock (line ~652)
  • applyPaletteSelection (line ~688) — block type change
  • Checklist toggle via Ctrl+X (line ~944)

Content is synced from textareas to blocks in focusBlock() (line ~250-262)
before changing focus, which provides a natural batching boundary for
character-level edits.

Ctrl+Z and Ctrl+Y are both unbound. Key events are matched via msg.String()
(e.g., "ctrl+z"), not enum constants.

The forked textarea (fork/bubbles/textarea/textarea.go) has SetCursor(col)
and Line() but no SetRow() method. Cursor restoration on undo needs a new
SetRow method added to the fork.

newTextareaForBlock() (line ~144-163) already exists and rebuilds a textarea
from a block — this is the restore path for undo/redo.

Approach: full-snapshot undo

Deep copy []block.Block + active index + cursor position on each structural
mutation. Restore by replacing blocks, rebuilding textareas via
newTextareaForBlock, and refocusing. Block struct is small (~4 fields), so
memory is negligible even at 100-depth cap (~1MB worst case for a large doc).

Character-level edits are batched by focus-change boundaries: focusBlock()
pushes an undo snapshot only when the active block's content changed since the
last snapshot. This means typing "hello world" then moving to another block
creates one undo entry for all the typing — matching VS Code / Notion behavior.

Tasks

  • Add editorState snapshot struct and undoStack type with push/pop/clear
    and max-depth cap (100) in internal/editor/undo.go
  • Add deepCopyBlocks() helper for []block.Block
  • Add SetRow(row int) to forked textarea for cursor row restoration
  • Add undoStack, redoStack, and undoDirty fields to editor Model
  • Add pushUndo() — captures state, pushes to undo stack, clears redo stack
  • Add restoreState() — sets blocks, rebuilds textareas, focuses active, restores cursor
  • Add performUndo() / performRedo() — swap between stacks and restore
  • Wire ctrl+zperformUndo() and ctrl+yperformRedo() in Update
  • Add pushUndo() call before each structural mutation (10+ sites)
  • Add focus-change batching: pushUndo() in focusBlock() when undoDirty is true
  • Set undoDirty = true on textarea keystroke forwarding (line ~1030)
  • Show "Nothing to undo" / "Nothing to redo" status via existing statusGen pattern
  • Add table-driven tests in internal/editor/undo_test.go
  • Update help overlay with Ctrl+Z / Ctrl+Y bindings

Test plan

  • Undo after Enter (block split) — block count decreases, content rejoins
  • Undo after Backspace merge — blocks split back apart
  • Undo after Ctrl+K (cut) — block reappears with content
  • Undo after Alt+Up/Down (swap) — blocks return to original order
  • Undo after Ctrl+X (checklist toggle) — checked state reverts
  • Undo after palette block type change — type reverts
  • Redo after undo — state re-applies
  • New edit after undo clears redo stack
  • Ctrl+Z with empty stack shows "Nothing to undo" status
  • Max depth: 101st mutation drops oldest snapshot
  • Character batching: type in block, move to another, Ctrl+Z undoes all typing as one step
  • Cursor position restored to pre-mutation location after undo
  • Existing tests still pass (no regression)

Scope

Type: feature
Size: medium

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions