* 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
628 lines
20 KiB
Markdown
628 lines
20 KiB
Markdown
# 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 (
|
||
<>
|
||
<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:
|
||
- `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 (
|
||
<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>
|
||
)
|
||
```
|
||
|
||
- [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"
|
||
```
|