# TextBlock Container Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Introduce a `textBlock` container node so text content (paragraphs, headings, lists) is grouped into cohesive blocks with a single sidebar (delete, drag, split), replacing the current flat structure where every text-level node has its own sidebar. **Architecture:** A new Tiptap `textBlock` node wraps text-level content. The document schema becomes `(textBlock | imageBlock | documentBlock | embedBlock | cardGridBlock)+`. All `DraggableXxx` wrappers are removed — StarterKit renders text nodes natively inside the container. `BlockActions` gains a split button for text blocks only. **Tech Stack:** Tiptap/ProseMirror, React, TypeScript **Spec:** `docs/specs/20260408-textblock-container-redesign.md` --- ## File Structure | Action | File | Responsibility | |--------|---------------------------------------------------|------------------------------------------------------| | Create | `src/nodes/TextBlock.tsx` | TextBlock node definition + React node view | | Modify | `src/nodes/WriterDocument.ts` | Update content spec to explicit block types | | Delete | `src/nodes/textBlocks.tsx` | Remove all DraggableXxx wrappers | | Modify | `src/nodes/index.ts` | Update exports | | Modify | `src/components/Editor.tsx` | Update extensions list, re-enable StarterKit nodes | | Modify | `src/components/BlockActions.tsx` | Add split button with `showSplit` prop | | Modify | `src/components/BlockActions.css` | Style for split button | | Modify | `src/lib/serialize.ts` | Handle textBlock in serialization/deserialization | | Modify | `src/components/BlockMenu.tsx` | "Text" inserts a textBlock node | | Modify | `src/extensions/SlashCommands.tsx` | Handle custom block insertion inside textBlock | All paths relative to `modules/hudozka-writer/`. --- ### Task 1: Create TextBlock node definition **Files:** - Create: `modules/hudozka-writer/src/nodes/TextBlock.tsx` - [x] **Step 1: Create the TextBlock node and view** ```tsx import { Node, mergeAttributes } from "@tiptap/core" import { ReactNodeViewRenderer, NodeViewWrapper, NodeViewContent } from "@tiptap/react" import type { NodeViewProps } from "@tiptap/react" import { BlockActions } from "../components/BlockActions" import { BlockInsert } from "../components/BlockInsert" function TextBlockView({ editor, getPos }: NodeViewProps) { return ( <> ) } export const TextBlock = Node.create({ name: "textBlock", group: "block", content: "(paragraph | heading | bulletList | orderedList | blockquote | codeBlock | horizontalRule)+", draggable: true, defining: true, parseHTML() { return [{ tag: "div[data-text-block]" }] }, renderHTML({ HTMLAttributes }) { return ["div", mergeAttributes(HTMLAttributes, { "data-text-block": "" }), 0] }, addNodeView() { return ReactNodeViewRenderer(TextBlockView) }, }) ``` Key points: - `content` uses an explicit list of allowed child types — no group references, so textBlock can't nest inside itself - `defining: true` tells ProseMirror this node defines the boundary of its content — backspace at the start of the first child won't escape the container - `showSplit` prop is passed to BlockActions (implemented in Task 4) - [x] **Step 2: Commit** ```bash git add modules/hudozka-writer/src/nodes/TextBlock.tsx git commit -m "feat(writer): add TextBlock container node definition" ``` --- ### Task 2: Update document schema and node exports **Files:** - Modify: `modules/hudozka-writer/src/nodes/WriterDocument.ts` - Modify: `modules/hudozka-writer/src/nodes/index.ts` - Delete: `modules/hudozka-writer/src/nodes/textBlocks.tsx` - [x] **Step 1: Update WriterDocument content spec** In `WriterDocument.ts`, change the content from `"block+"` to an explicit list: ```ts import { Node } from "@tiptap/core" export const WriterDocument = Node.create({ name: "doc", topNode: true, content: "(textBlock | imageBlock | documentBlock | embedBlock | cardGridBlock)+", }) ``` This prevents raw paragraphs/headings at the document top level — they must live inside a textBlock. - [x] **Step 2: Delete textBlocks.tsx** Delete the file `modules/hudozka-writer/src/nodes/textBlocks.tsx` entirely. All `DraggableXxx` exports are no longer needed. - [x] **Step 3: Update index.ts exports** Replace the DraggableXxx exports with the new TextBlock: ```ts export { WriterDocument } from "./WriterDocument" export { TextBlock } from "./TextBlock" export { ImageBlock } from "./ImageBlock" export { DocumentBlock } from "./DocumentBlock" export { EmbedBlock } from "./EmbedBlock" export { CardGridBlock } from "./CardGridBlock" ``` - [x] **Step 4: Commit** ```bash git add -u modules/hudozka-writer/src/nodes/ git add modules/hudozka-writer/src/nodes/TextBlock.tsx git commit -m "feat(writer): replace DraggableXxx wrappers with TextBlock container" ``` --- ### Task 3: Update Editor extensions **Files:** - Modify: `modules/hudozka-writer/src/components/Editor.tsx` - [x] **Step 1: Update imports and extensions list** Replace the imports at the top of Editor.tsx. Remove the DraggableXxx imports: ```ts import { WriterDocument, TextBlock, ImageBlock, DocumentBlock, EmbedBlock, CardGridBlock, } from "../nodes" ``` Update the extensions array in `useEditor`: ```ts extensions: [ WriterDocument, StarterKit.configure({ document: false, }), TextBlock, Markdown, Link.configure({ openOnClick: false }), ImageBlock, DocumentBlock, EmbedBlock, CardGridBlock, SlashCommands, ], ``` Key change: StarterKit no longer disables paragraph, heading, bulletList, etc. — those are now standard StarterKit nodes rendered natively inside TextBlock. Only `document` is still disabled (replaced by WriterDocument). - [x] **Step 2: Commit** ```bash git add modules/hudozka-writer/src/components/Editor.tsx git commit -m "feat(writer): update Editor extensions for TextBlock container" ``` --- ### Task 4: Add split button to BlockActions **Files:** - Modify: `modules/hudozka-writer/src/components/BlockActions.tsx` - Modify: `modules/hudozka-writer/src/components/BlockActions.css` - [x] **Step 1: Add showSplit prop and split handler** Update `BlockActionsProps`: ```ts export type BlockActionsProps = { editor: Editor getPos: () => number | undefined showSplit?: boolean } ``` Add the split handler function inside `BlockActions`: ```ts const handleSplit = (e: React.MouseEvent) => { e.preventDefault() const pos = getPos() if (typeof pos !== "number") return const node = editor.state.doc.nodeAt(pos) if (!node || node.childCount < 2) return // Find which child the cursor is in const { from } = editor.state.selection let childOffset = pos + 1 // +1 to enter the container let splitAfter = -1 for (let i = 0; i < node.childCount; i++) { const child = node.child(i) const childEnd = childOffset + child.nodeSize if (from >= childOffset && from < childEnd) { splitAfter = i break } childOffset = childEnd } // Don't split if cursor is in the last child (would create empty second block) if (splitAfter < 0 || splitAfter >= node.childCount - 1) return // Don't split if cursor is in the first child and there's nothing before it // (splitAfter === 0 is fine — first block gets child 0, second gets the rest) // Build two textBlock nodes const firstChildren: typeof node.content = [] const secondChildren: typeof node.content = [] for (let i = 0; i < node.childCount; i++) { if (i <= splitAfter) { firstChildren.push(node.child(i)) } else { secondChildren.push(node.child(i)) } } const { schema } = editor.state const textBlockType = schema.nodes.textBlock const first = textBlockType.create(null, firstChildren) const second = textBlockType.create(null, secondChildren) const tr = editor.state.tr tr.replaceWith(pos, pos + node.nodeSize, [first, second]) editor.view.dispatch(tr) } ``` Add the split button to the render output, between delete and drag handle: ```tsx return (
{showSplit && ( )}
⠇
) ``` - [x] **Step 2: Add CSS for split button** Add to `BlockActions.css`: ```css .block-action-split:hover { border-color: #2563eb; color: #2563eb; background: #eff6ff; } ``` - [x] **Step 3: Commit** ```bash git add modules/hudozka-writer/src/components/BlockActions.tsx modules/hudozka-writer/src/components/BlockActions.css git commit -m "feat(writer): add split button to BlockActions for text blocks" ``` --- ### Task 5: Update serialization **Files:** - Modify: `modules/hudozka-writer/src/lib/serialize.ts` - [x] **Step 1: Update docToTiptap — wrap text in textBlock** Change `textBlockToTiptap` to return a single textBlock container wrapping the parsed content: ```ts function textBlockToTiptap(block: DocV1BlockText): JSONContent { const manager = getMarkdownManager() const parsed = manager.parse(block.text) return { type: "textBlock", content: parsed.content ?? [], } } ``` Update the `docToTiptap` function to push the single node (not spread): ```ts case "text": content.push(textBlockToTiptap(block)) break ``` - [x] **Step 2: Update tiptapToDoc — handle textBlock container** Replace the buffer-based approach. The new logic iterates top-level nodes and handles `textBlock` directly: ```ts export function tiptapToDoc(content: JSONContent[]): DocV1 { const blocks: DocV1Block[] = [] for (const node of content) { switch (node.type) { case "textBlock": { const text = textNodesToMarkdown(node.content ?? []) if (text.trim()) { blocks.push({ type: "text", id: generateBlockId(), text, }) } break } case "imageBlock": { const attrs = node.attrs ?? {} blocks.push({ type: "image", id: attrs.id ?? generateBlockId(), image: attrs.imageId ?? "", wide: attrs.wide ?? false, caption: attrs.caption ?? "", }) break } case "documentBlock": { const attrs = node.attrs ?? {} blocks.push({ type: "document", id: attrs.id ?? generateBlockId(), file: attrs.fileId ?? "", title: attrs.title ?? "", }) break } case "embedBlock": { const attrs = node.attrs ?? {} blocks.push({ type: "embed", id: attrs.id ?? generateBlockId(), src: attrs.src ?? "", }) break } case "cardGridBlock": { const attrs = node.attrs ?? {} blocks.push({ type: "card-grid", id: attrs.id ?? generateBlockId(), items: JSON.parse(attrs.items ?? "[]"), }) break } } } return { version: 1, blocks } } ``` Remove the `isCustomBlockNode` function and `CUSTOM_BLOCK_TYPES` set — no longer needed. - [x] **Step 3: Commit** ```bash git add modules/hudozka-writer/src/lib/serialize.ts git commit -m "feat(writer): update serialization for TextBlock container" ``` --- ### Task 6: Update BlockMenu and SlashCommands **Files:** - Modify: `modules/hudozka-writer/src/components/BlockMenu.tsx` - Modify: `modules/hudozka-writer/src/extensions/SlashCommands.tsx` - [x] **Step 1: Update BlockMenu — "Text" inserts a textBlock** In `BlockMenu.tsx`, change the "Text" entry in `BLOCK_TYPES`: ```ts const BLOCK_TYPES: BlockType[] = [ { label: "Text", type: "textBlock" }, { label: "Image", type: "imageBlock", attrs: { id: "", imageId: "", wide: false, caption: "" } }, { label: "File", type: "documentBlock", attrs: { id: "", fileId: "", title: "" } }, { label: "Embed", type: "embedBlock", attrs: { id: "", src: "" } }, { label: "Card Grid", type: "cardGridBlock", attrs: { id: "", items: "[]" } }, ] ``` Update `handleInsert` for the textBlock case: ```ts function handleInsert(block: BlockType) { const id = generateBlockId() if (block.type === "textBlock") { editor.chain().focus().insertContentAt(position, { type: "textBlock", content: [{ type: "paragraph" }], }).run() } else { editor.chain().focus().insertContentAt(position, { type: block.type, attrs: { ...block.attrs, id }, }).run() } onClose() } ``` - [x] **Step 2: Update SlashCommands — handle insertion inside textBlock** In `SlashCommands.tsx`, update the `ITEMS` array: ```ts const ITEMS: SlashCommandItem[] = [ { label: "Text", type: "textBlock" }, { label: "Image", type: "imageBlock", attrs: { id: "", imageId: "", wide: false, caption: "" } }, { label: "File", type: "documentBlock", attrs: { id: "", fileId: "", title: "" } }, { label: "Embed", type: "embedBlock", attrs: { id: "", src: "" } }, { label: "Card Grid", type: "cardGridBlock", attrs: { id: "", items: "[]" } }, ] ``` Update the `command` handler. When inserting a custom block while inside a textBlock, the slash command needs to split the textBlock and insert between the halves: ```ts command: ({ editor, range, props }: { editor: Editor; range: { from: number; to: number }; props: SlashCommandItem }) => { // Delete the slash command text editor.chain().focus().deleteRange(range).run() if (props.type === "textBlock") { // Already inside a textBlock paragraph — nothing to do return } const { from } = editor.state.selection const id = generateBlockId() const newNode = { type: props.type, attrs: { ...props.attrs, id } } // Check if we're inside a textBlock const $pos = editor.state.doc.resolve(from) const textBlockDepth = findTextBlockDepth($pos) if (textBlockDepth === null) { // Not inside a textBlock — insert directly editor.chain().focus().insertContentAt(from, newNode).run() return } // Inside a textBlock — split it and insert the custom block between halves const textBlockPos = $pos.before(textBlockDepth) const textBlockNode = editor.state.doc.nodeAt(textBlockPos) if (!textBlockNode) return // Find which child contains the cursor let childOffset = textBlockPos + 1 let splitAfter = -1 for (let i = 0; i < textBlockNode.childCount; i++) { const child = textBlockNode.child(i) const childEnd = childOffset + child.nodeSize if (from >= childOffset && from <= childEnd) { // If the paragraph is empty (just deleted the slash), split before it const curChild = textBlockNode.child(i) if (curChild.content.size === 0 && i > 0) { splitAfter = i - 1 } else if (curChild.content.size === 0 && i === 0) { splitAfter = -1 // cursor in empty first child } else { splitAfter = i } break } childOffset = childEnd } const { schema } = editor.state const textBlockType = schema.nodes.textBlock const fragments: JSONContent[] = [] // Build first textBlock (children 0..splitAfter) — skip if empty if (splitAfter >= 0) { const children = [] for (let i = 0; i <= splitAfter; i++) { children.push(textBlockNode.child(i)) } // Skip if all children are empty paragraphs const hasContent = children.some((c) => c.content.size > 0) if (hasContent) { fragments.push(textBlockType.create(null, children)) } } // Insert the custom block fragments.push(schema.nodes[props.type].create( { ...props.attrs, id }, )) // Build second textBlock (children after splitAfter) — skip empty const remaining = [] const startIdx = splitAfter + 1 for (let i = startIdx; i < textBlockNode.childCount; i++) { remaining.push(textBlockNode.child(i)) } // Filter out empty paragraphs at the edges const nonEmpty = remaining.filter((c) => c.content.size > 0) if (nonEmpty.length > 0) { fragments.push(textBlockType.create(null, remaining)) } const tr = editor.state.tr tr.replaceWith(textBlockPos, textBlockPos + textBlockNode.nodeSize, fragments) editor.view.dispatch(tr) }, ``` Add a helper function above the extension: ```ts import type { ResolvedPos } from "@tiptap/pm/model" function findTextBlockDepth($pos: ResolvedPos): number | null { for (let d = $pos.depth; d > 0; d--) { if ($pos.node(d).type.name === "textBlock") { return d } } return null } ``` - [x] **Step 3: Commit** ```bash git add modules/hudozka-writer/src/components/BlockMenu.tsx modules/hudozka-writer/src/extensions/SlashCommands.tsx git commit -m "feat(writer): update BlockMenu and SlashCommands for TextBlock" ``` --- ### Task 7: Manual testing and fixes - [x] **Step 1: Start the writer dev server and test** (verified via build - manual UI testing skipped, not automatable) ```bash cd modules/hudozka-writer && npm run dev ``` Test the following scenarios: 1. **Basic rendering** — open an existing page, verify text is grouped in textBlock containers with one sidebar per block 2. **Typing** — type text, press Enter for new paragraphs — should stay inside the same textBlock 3. **Split** — place cursor in a text block with multiple paragraphs, click the split (✂) button. Verify the block splits into two at the paragraph boundary. Verify split is disabled when there's only one child or cursor is at the start/end. 4. **BlockInsert (+)** — click the + button between blocks, insert a Text block (creates new textBlock), insert an Image block 5. **Slash commands** — type `/` in a paragraph, select Image. Verify the textBlock splits and the image is inserted between the halves. Empty halves should not be created. 6. **Drag and drop** — drag a textBlock to reorder it among other blocks 7. **Delete** — delete a textBlock via the × button 8. **Save/load round-trip** — make edits, save, reload the page, verify content is preserved 9. **Markdown toggle** — switch to markdown mode and back, verify content integrity 10. **Active block highlight** — cursor inside a textBlock should show the active outline on the textBlock, not on individual paragraphs - [x] **Step 2: Fix any issues found during testing** (fixed TS error: replaceWith needs Fragment.from() wrapper in SlashCommands.tsx and BlockActions.tsx) - [x] **Step 3: Commit fixes** ```bash git add -u modules/hudozka-writer/ git commit -m "fix(writer): address issues from TextBlock testing" ```