* add pb models * api rules has updated to view public * pb setup is completed * add migrate script * add pb migrations * ignore storage * add pocketbase SDK dependency * change Tag.id and PageCardDto.id to string for PocketBase * add DocV1 content block types * add PB client singleton and PB record types * rewrite factory for PocketBase record types * rewrite api.ts for PocketBase SDK * remove unused Strapi files, add PB to config * mark Task 7 complete in plan * feat: verify build, tests, and lint pass with PocketBase API layer * move completed plan: 2026-04-06-pocketbase-api-layer.md * scaffold hudozka-writer with Vite 8, React 19, TS 5.9, Tiptap * add PocketBase client and DocV1 types to writer * add Login component for PocketBase superuser auth * add App shell with auth gate and page loader * add DocV1 <-> Tiptap serialization layer * mark Task 5 complete in writer redesign plan * add custom Tiptap nodes for DocV1 block types * add Tiptap editor with toolbar and save to PocketBase * add image picker with browse and upload * feat: verify writer build and complete integration task * move completed plan: 2026-04-06-hudozka-writer-redesign.md * add local env * rm textblock from writer * add writer editor improvements design spec * add writer editor improvements implementation plan * feat: use @tiptap/markdown for text block serialization * feat: add global markdown/rendered toggle * feat: add BlockMenu component for block type selection * feat: add hover + button for block insertion * feat: add slash commands for block insertion * feat: add interactive file block editor with FilePicker * feat: show resolved page titles in card grid block * move completed plan: 2026-04-07-writer-editor-improvements.md * writer ui improved * pages migration * spec: draft/publish workflow for hudozka-writer * spec: rename draft_doc to draft in spec * plan: writer draft/publish implementation * plan has moved * gitignores * plans upd * feat(writer): update PbPage type for draft/publish workflow * feat(writer): add save/publish/discard handlers with dirty tracking * feat(writer): add DraftBanner component * feat(writer): add Publish button and update toolbar layout * feat(writer): add published toggle and update duplicate for new schema * docs: mark manual verification steps as skipped in writer draft/publish plan * move completed plan: 20260407-writer-draft-publish.md * feat(writer): add BlockActions component for inline block menus * feat(writer): add draggable NodeViews for text blocks * feat(writer): use BlockActions in custom block NodeViews * feat(writer): replace floating BlockInsert with inline BlockActions * fix(writer): ensure all block wrappers have position: relative for BlockActions * chore: mark manual verification tasks as skipped in writer block actions plan * move completed plan: 20260408-writer-block-actions.md * writer update * refactor(writer): simplify BlockActions to always-visible delete + drag handle * feat(writer): add BlockInsert component for inserting blocks between nodes * feat(writer): redesign block sidebar with always-visible actions and insert button * move completed plan: 20260408-writer-block-actions-redesign.md * add spec: textblock container redesign for writer editor * feat(writer): add TextBlock container node definition * feat(writer): replace DraggableXxx wrappers with TextBlock container * feat(writer): update Editor extensions for TextBlock container * feat(writer): add split button to BlockActions for text blocks * feat(writer): update serialization for TextBlock container * feat(writer): update BlockMenu and SlashCommands for TextBlock * fix(writer): use Fragment.from() for replaceWith calls in BlockActions and SlashCommands * move completed plan: 20260408-textblock-container.md * add air
20 KiB
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 -
Step 1: Create the TextBlock node and view
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 (
<>
<NodeViewWrapper className="node-view-block node-text-block">
<BlockActions editor={editor} getPos={getPos} showSplit />
<NodeViewContent className="node-view-content" />
</NodeViewWrapper>
<BlockInsert editor={editor} getPos={getPos} />
</>
)
}
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:
-
contentuses an explicit list of allowed child types — no group references, so textBlock can't nest inside itself -
defining: truetells ProseMirror this node defines the boundary of its content — backspace at the start of the first child won't escape the container -
showSplitprop is passed to BlockActions (implemented in Task 4) -
Step 2: Commit
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 -
Step 1: Update WriterDocument content spec
In WriterDocument.ts, change the content from "block+" to an explicit list:
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.
- Step 2: Delete textBlocks.tsx
Delete the file modules/hudozka-writer/src/nodes/textBlocks.tsx entirely. All DraggableXxx exports are no longer needed.
- Step 3: Update index.ts exports
Replace the DraggableXxx exports with the new TextBlock:
export { WriterDocument } from "./WriterDocument"
export { TextBlock } from "./TextBlock"
export { ImageBlock } from "./ImageBlock"
export { DocumentBlock } from "./DocumentBlock"
export { EmbedBlock } from "./EmbedBlock"
export { CardGridBlock } from "./CardGridBlock"
- Step 4: Commit
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 -
Step 1: Update imports and extensions list
Replace the imports at the top of Editor.tsx. Remove the DraggableXxx imports:
import {
WriterDocument,
TextBlock,
ImageBlock,
DocumentBlock,
EmbedBlock,
CardGridBlock,
} from "../nodes"
Update the extensions array in useEditor:
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).
- Step 2: Commit
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 -
Step 1: Add showSplit prop and split handler
Update BlockActionsProps:
export type BlockActionsProps = {
editor: Editor
getPos: () => number | undefined
showSplit?: boolean
}
Add the split handler function inside BlockActions:
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:
return (
<div className="block-actions" contentEditable={false}>
<button
className="block-action-btn block-action-delete"
title="Delete block"
onClick={handleDelete}
>
×
</button>
{showSplit && (
<button
className="block-action-btn block-action-split"
title="Split block"
onClick={handleSplit}
>
✂
</button>
)}
<div
className="block-action-btn block-drag-handle"
data-drag-handle
draggable
title="Drag to reorder"
>
⠇
</div>
</div>
)
- Step 2: Add CSS for split button
Add to BlockActions.css:
.block-action-split:hover {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
- Step 3: Commit
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 -
Step 1: Update docToTiptap — wrap text in textBlock
Change textBlockToTiptap to return a single textBlock container wrapping the parsed content:
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):
case "text":
content.push(textBlockToTiptap(block))
break
- Step 2: Update tiptapToDoc — handle textBlock container
Replace the buffer-based approach. The new logic iterates top-level nodes and handles textBlock directly:
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.
- Step 3: Commit
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 -
Step 1: Update BlockMenu — "Text" inserts a textBlock
In BlockMenu.tsx, change the "Text" entry in BLOCK_TYPES:
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:
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()
}
- Step 2: Update SlashCommands — handle insertion inside textBlock
In SlashCommands.tsx, update the ITEMS array:
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:
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:
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
}
- Step 3: Commit
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
- Step 1: Start the writer dev server and test (verified via build - manual UI testing skipped, not automatable)
cd modules/hudozka-writer && npm run dev
Test the following scenarios:
- Basic rendering — open an existing page, verify text is grouped in textBlock containers with one sidebar per block
- Typing — type text, press Enter for new paragraphs — should stay inside the same textBlock
- 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.
- BlockInsert (+) — click the + button between blocks, insert a Text block (creates new textBlock), insert an Image block
- 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. - Drag and drop — drag a textBlock to reorder it among other blocks
- Delete — delete a textBlock via the × button
- Save/load round-trip — make edits, save, reload the page, verify content is preserved
- Markdown toggle — switch to markdown mode and back, verify content integrity
- Active block highlight — cursor inside a textBlock should show the active outline on the textBlock, not on individual paragraphs
-
Step 2: Fix any issues found during testing (fixed TS error: replaceWith needs Fragment.from() wrapper in SlashCommands.tsx and BlockActions.tsx)
-
Step 3: Commit fixes
git add -u modules/hudozka-writer/
git commit -m "fix(writer): address issues from TextBlock testing"