# Inline Block Editor Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace textarea-based document editing with a modern WYSIWYG block editor using contenteditable, slash commands, and markdown auto-conversion.
**Architecture:** Documents change from flat `body` + `bodyFacets` to a `blocks` array. Each block (paragraph, heading, codeBlock, quote) is a contenteditable div. Inline formatting uses existing facets (bold, italic, code, link). Slash commands insert new blocks. Markdown syntax auto-converts as you type.
**Tech Stack:** Vanilla JS, contenteditable API, existing richtext.js for facet rendering
---
## Task 1: Update Document Lexicon
**Files:**
- Modify: `lexicons/network/slices/tools/document.json`
**Step 1: Replace body/bodyFacets with blocks array**
Replace the entire file with:
```json
{
"lexicon": 1,
"id": "network.slices.tools.document",
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"required": ["title", "slug", "blocks", "createdAt"],
"properties": {
"title": {
"type": "string",
"maxLength": 300,
"description": "Document title"
},
"slug": {
"type": "string",
"maxLength": 100,
"description": "URL-friendly identifier, unique per author"
},
"blocks": {
"type": "array",
"description": "Document content as array of blocks",
"items": {
"type": "union",
"refs": ["#paragraph", "#heading", "#codeBlock", "#quote"]
}
},
"createdAt": {
"type": "string",
"format": "datetime"
},
"updatedAt": {
"type": "string",
"format": "datetime"
}
}
}
},
"paragraph": {
"type": "object",
"description": "A paragraph block with optional inline formatting",
"required": ["text"],
"properties": {
"text": {
"type": "string",
"maxLength": 10000
},
"facets": {
"type": "array",
"items": {
"type": "ref",
"ref": "network.slices.tools.richtext.facet"
}
}
}
},
"heading": {
"type": "object",
"description": "A heading block (h1-h3) with optional inline formatting",
"required": ["level", "text"],
"properties": {
"level": {
"type": "integer",
"minimum": 1,
"maximum": 3
},
"text": {
"type": "string",
"maxLength": 300
},
"facets": {
"type": "array",
"items": {
"type": "ref",
"ref": "network.slices.tools.richtext.facet"
}
}
}
},
"codeBlock": {
"type": "object",
"description": "A fenced code block",
"required": ["code"],
"properties": {
"code": {
"type": "string",
"maxLength": 20000
},
"lang": {
"type": "string",
"maxLength": 50
}
}
},
"quote": {
"type": "object",
"description": "A blockquote with optional inline formatting",
"required": ["text"],
"properties": {
"text": {
"type": "string",
"maxLength": 5000
},
"facets": {
"type": "array",
"items": {
"type": "ref",
"ref": "network.slices.tools.richtext.facet"
}
}
}
}
}
}
```
**Step 2: Verify JSON is valid**
Run: `cat lexicons/network/slices/tools/document.json | python3 -m json.tool > /dev/null && echo "Valid JSON"`
Expected: `Valid JSON`
**Step 3: Commit**
```bash
git add lexicons/network/slices/tools/document.json
git commit -m "feat(lexicon): replace body with blocks array for document"
```
---
## Task 2: Add DOM-to-Facets Helpers in richtext.js
**Files:**
- Modify: `richtext.js`
**Step 1: Add facetsToDom function**
Add after the existing `renderFacetedText` function (around line 236):
```javascript
/**
* Convert text + facets to HTML for contenteditable editing.
* Returns HTML string with formatting tags.
*/
export function facetsToDom(text, facets = []) {
if (!text) return "";
if (!facets || facets.length === 0) {
return escapeHtmlForDom(text);
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const bytes = encoder.encode(text);
// Sort facets by start position
const sortedFacets = [...facets].sort(
(a, b) => a.index.byteStart - b.index.byteStart
);
let result = "";
let lastEnd = 0;
for (const facet of sortedFacets) {
// Add text before this facet
if (facet.index.byteStart > lastEnd) {
const beforeBytes = bytes.slice(lastEnd, facet.index.byteStart);
result += escapeHtmlForDom(decoder.decode(beforeBytes));
}
// Get the faceted text
const facetBytes = bytes.slice(facet.index.byteStart, facet.index.byteEnd);
const facetText = decoder.decode(facetBytes);
// Determine facet type and wrap in tag
const feature = facet.features[0];
const type = feature?.$type || feature?.__typename || "";
if (type.includes("Link") || type.includes("link")) {
result += `${escapeHtmlForDom(facetText)}`;
} else if (type.includes("Bold") || type.includes("bold")) {
result += `${escapeHtmlForDom(facetText)}`;
} else if (type.includes("Italic") || type.includes("italic")) {
result += `${escapeHtmlForDom(facetText)}`;
} else if (type.includes("Code") || type.includes("code")) {
result += `${escapeHtmlForDom(facetText)}`;
} else {
result += escapeHtmlForDom(facetText);
}
lastEnd = facet.index.byteEnd;
}
// Add remaining text
if (lastEnd < bytes.length) {
const remainingBytes = bytes.slice(lastEnd);
result += escapeHtmlForDom(decoder.decode(remainingBytes));
}
return result;
}
function escapeHtmlForDom(text) {
return text
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
```
**Step 2: Add domToFacets function**
Add after `facetsToDom`:
```javascript
/**
* Extract text and facets from a contenteditable element.
* Walks the DOM tree and builds facets from formatting tags.
* Returns { text, facets }.
*/
export function domToFacets(element) {
const encoder = new TextEncoder();
let text = "";
const facets = [];
function walk(node, activeFormats = []) {
if (node.nodeType === Node.TEXT_NODE) {
const content = node.textContent || "";
if (content) {
const startByte = encoder.encode(text).length;
text += content;
const endByte = encoder.encode(text).length;
// Create facets for each active format
for (const format of activeFormats) {
facets.push({
index: { byteStart: startByte, byteEnd: endByte },
features: [format],
});
}
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const tag = node.tagName.toLowerCase();
let newFormat = null;
if (tag === "strong" || tag === "b") {
newFormat = { $type: "network.slices.tools.richtext.facet#bold" };
} else if (tag === "em" || tag === "i") {
newFormat = { $type: "network.slices.tools.richtext.facet#italic" };
} else if (tag === "code") {
newFormat = { $type: "network.slices.tools.richtext.facet#code" };
} else if (tag === "a") {
newFormat = {
$type: "network.slices.tools.richtext.facet#link",
uri: node.getAttribute("href") || "",
};
}
const formats = newFormat ? [...activeFormats, newFormat] : activeFormats;
for (const child of node.childNodes) {
walk(child, formats);
}
}
}
walk(element);
// Merge adjacent facets of the same type
const mergedFacets = mergeFacets(facets);
return { text, facets: mergedFacets };
}
/**
* Merge adjacent facets of the same type.
*/
function mergeFacets(facets) {
if (facets.length === 0) return [];
// Group by type
const byType = new Map();
for (const facet of facets) {
const type = facet.features[0]?.$type || "";
const key = type + (facet.features[0]?.uri || "");
if (!byType.has(key)) {
byType.set(key, []);
}
byType.get(key).push(facet);
}
const merged = [];
for (const group of byType.values()) {
// Sort by start position
group.sort((a, b) => a.index.byteStart - b.index.byteStart);
let current = null;
for (const facet of group) {
if (!current) {
current = { ...facet, index: { ...facet.index } };
} else if (facet.index.byteStart <= current.index.byteEnd) {
// Merge overlapping or adjacent
current.index.byteEnd = Math.max(current.index.byteEnd, facet.index.byteEnd);
} else {
merged.push(current);
current = { ...facet, index: { ...facet.index } };
}
}
if (current) {
merged.push(current);
}
}
// Sort by start position
merged.sort((a, b) => a.index.byteStart - b.index.byteStart);
return merged;
}
```
**Step 3: Test in browser console**
Open docs.html, then in console:
```javascript
import('/richtext.js').then(m => {
// Test facetsToDom
const html = m.facetsToDom("Hello world", [
{ index: { byteStart: 0, byteEnd: 5 }, features: [{ $type: "network.slices.tools.richtext.facet#bold" }] }
]);
console.log(html); // Should be: Hello world
});
```
**Step 4: Commit**
```bash
git add richtext.js
git commit -m "feat(richtext): add facetsToDom and domToFacets for block editor"
```
---
## Task 3: Update GraphQL Queries in docs.html
**Files:**
- Modify: `docs.html`
**Step 1: Update DOCUMENTS_QUERY to fetch blocks**
Find the `DOCUMENTS_QUERY` constant (around line 410) and replace with:
```javascript
const DOCUMENTS_QUERY = `
query GetDocuments($handle: String, $first: Int!, $after: String) {
networkSlicesToolsDocument(
where: { actorHandle: { eq: $handle } }
sortBy: [{ field: createdAt, direction: DESC }]
first: $first
after: $after
) {
edges {
node {
uri
actorHandle
title
slug
blocks {
__typename
... on NetworkSlicesToolsDocumentParagraph {
text
facets {
index { byteStart byteEnd }
features {
__typename
... on NetworkSlicesToolsRichtextFacetLink { uri }
... on NetworkSlicesToolsRichtextFacetBold { _ }
... on NetworkSlicesToolsRichtextFacetItalic { _ }
... on NetworkSlicesToolsRichtextFacetCode { _ }
}
}
}
... on NetworkSlicesToolsDocumentHeading {
level
text
facets {
index { byteStart byteEnd }
features {
__typename
... on NetworkSlicesToolsRichtextFacetLink { uri }
... on NetworkSlicesToolsRichtextFacetBold { _ }
... on NetworkSlicesToolsRichtextFacetItalic { _ }
... on NetworkSlicesToolsRichtextFacetCode { _ }
}
}
}
... on NetworkSlicesToolsDocumentCodeBlock {
code
lang
}
... on NetworkSlicesToolsDocumentQuote {
text
facets {
index { byteStart byteEnd }
features {
__typename
... on NetworkSlicesToolsRichtextFacetLink { uri }
... on NetworkSlicesToolsRichtextFacetBold { _ }
... on NetworkSlicesToolsRichtextFacetItalic { _ }
... on NetworkSlicesToolsRichtextFacetCode { _ }
}
}
}
}
createdAt
updatedAt
appBskyActorProfileByDid {
displayName
avatar { url(preset: "avatar") }
}
}
}
pageInfo { hasNextPage endCursor }
}
}
`;
```
**Step 2: Update ALL_DOCUMENTS_QUERY similarly**
Replace `ALL_DOCUMENTS_QUERY` (around line 449) - same blocks fragment, just without the handle filter.
**Step 3: Update DOCUMENT_BY_SLUG_QUERY similarly**
Replace `DOCUMENT_BY_SLUG_QUERY` (around line 511) - same blocks fragment.
**Step 4: Commit**
```bash
git add docs.html
git commit -m "feat(docs): update GraphQL queries to fetch blocks"
```
---
## Task 4: Update Mutations in docs.html
**Files:**
- Modify: `docs.html`
**Step 1: Update createDocument function**
Find `async function createDocument` (around line 595) and replace with:
```javascript
async function createDocument(title, slug, blocks) {
const input = {
title,
slug,
blocks: blocks.map(block => serializeBlock(block)),
createdAt: new Date().toISOString(),
};
await gqlMutation(CREATE_DOCUMENT_MUTATION, { input });
await loadDocuments(state.viewer?.handle);
state.view = "list";
render();
}
function serializeBlock(block) {
const base = { $type: `network.slices.tools.document#${block.type}` };
if (block.type === "paragraph") {
return { ...base, text: block.text, facets: block.facets || [] };
} else if (block.type === "heading") {
return { ...base, level: block.level, text: block.text, facets: block.facets || [] };
} else if (block.type === "codeBlock") {
return { ...base, code: block.code, lang: block.lang || undefined };
} else if (block.type === "quote") {
return { ...base, text: block.text, facets: block.facets || [] };
}
return base;
}
```
**Step 2: Update updateDocument function**
Find `async function updateDocument` (around line 611) and replace with:
```javascript
async function updateDocument(uri, title, slug, blocks) {
const input = {
title,
slug,
blocks: blocks.map(block => serializeBlock(block)),
updatedAt: new Date().toISOString(),
};
await gqlMutation(UPDATE_DOCUMENT_MUTATION, {
rkey: extractRkey(uri),
input,
});
await loadDocuments(state.viewer?.handle);
state.view = "list";
render();
}
```
**Step 3: Commit**
```bash
git add docs.html
git commit -m "feat(docs): update mutations to save blocks"
```
---
## Task 5: Update View Rendering for Blocks
**Files:**
- Modify: `docs.html`
**Step 1: Update renderView function**
Find `function renderView()` (around line 778) and replace the body rendering section:
```javascript
function renderView() {
const doc = state.currentDoc;
if (!doc) return '
${renderFacetedText(block.text, block.facets, { escapeHtml: esc })}
`; } else if (type.includes("Heading")) { const tag = `h${block.level + 1}`; // h2, h3, h4 (h1 is doc title) return `<${tag}>${renderFacetedText(block.text, block.facets, { escapeHtml: esc })}${tag}>`; } else if (type.includes("CodeBlock")) { const langClass = block.lang ? ` language-${esc(block.lang)}` : ""; return `${esc(block.code)}`;
} else if (type.includes("Quote")) {
return `${renderFacetedText(block.text, block.facets, { escapeHtml: esc })}`; } return ""; }).join("\n"); } ``` **Step 2: Add blockquote CSS** Find the CSS section and add after `.facet-codeblock` styles (around line 173): ```css .facet-quote { border-left: 3px solid var(--border); padding-left: 1rem; margin: 0.5rem 0; color: var(--text-muted); font-style: italic; } ``` **Step 3: Commit** ```bash git add docs.html git commit -m "feat(docs): render blocks in view mode" ``` --- ## Task 6: Build Block Editor Component **Files:** - Modify: `docs.html` **Step 1: Add editor state and helpers** Add after the `state` object declaration (around line 324): ```javascript // Editor state (only used during editing) const editorState = { blocks: [], // Array of { id, type, element } during editing slashMenuOpen: false, slashMenuIndex: 0, }; function generateBlockId() { return 'block-' + Math.random().toString(36).substr(2, 9); } ``` **Step 2: Update renderForm for block editor** Replace `function renderForm()` (around line 812) with: ```javascript function renderForm() { const isEdit = state.view === "edit"; const doc = isEdit ? state.currentDoc : null; return `
tag
const beforeCode = text.slice(0, openTick);
const afterCode = text.slice(cursor);
const parent = textNode.parentNode;
const frag = document.createDocumentFragment();
if (beforeCode) frag.appendChild(document.createTextNode(beforeCode));
const codeEl = document.createElement("code");
codeEl.textContent = codeText;
frag.appendChild(codeEl);
if (afterCode) frag.appendChild(document.createTextNode(afterCode));
parent.replaceChild(frag, textNode);
// Position cursor after code
const newRange = document.createRange();
newRange.setStartAfter(codeEl);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
return;
}
}
// Check for bold: **text**
if (text.slice(cursor - 2, cursor) === "**") {
const before = text.slice(0, cursor - 2);
const openBold = before.lastIndexOf("**");
if (openBold !== -1 && openBold < cursor - 4) {
const boldText = before.slice(openBold + 2);
applyInlineConversion(textNode, openBold, cursor, boldText, "strong");
return;
}
}
// Check for italic: *text* (but not **)
if (text[cursor - 1] === "*" && text[cursor - 2] !== "*") {
const before = text.slice(0, cursor - 1);
// Find opening * that's not part of **
let openItalic = -1;
for (let i = before.length - 1; i >= 0; i--) {
if (before[i] === "*" && before[i - 1] !== "*" && before[i + 1] !== "*") {
openItalic = i;
break;
}
}
if (openItalic !== -1 && openItalic < cursor - 2) {
const italicText = before.slice(openItalic + 1);
applyInlineConversion(textNode, openItalic, cursor, italicText, "em");
return;
}
}
}
function applyInlineConversion(textNode, start, end, content, tagName) {
const text = textNode.textContent;
const beforeText = text.slice(0, start);
const afterText = text.slice(end);
const parent = textNode.parentNode;
const frag = document.createDocumentFragment();
if (beforeText) frag.appendChild(document.createTextNode(beforeText));
const el = document.createElement(tagName);
el.textContent = content;
frag.appendChild(el);
if (afterText) frag.appendChild(document.createTextNode(afterText));
parent.replaceChild(frag, textNode);
// Position cursor after element
const selection = window.getSelection();
const newRange = document.createRange();
newRange.setStartAfter(el);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}
```
**Step 2: Commit**
```bash
git add docs.html
git commit -m "feat(docs): implement markdown auto-conversion"
```
---
## Task 11: Implement Form Submission with Blocks
**Files:**
- Modify: `docs.html`
**Step 1: Update handleSubmit to extract blocks**
Replace the `handleSubmit` function:
```javascript
async function handleSubmit(event) {
event.preventDefault();
const form = event.target;
const title = form.title.value.trim();
const slug = form.slug.value.trim().toLowerCase();
// Extract blocks from editor
const blocks = editorState.blocks.map(blockData => {
const el = blockData.element;
if (blockData.type === "codeBlock") {
return {
type: "codeBlock",
code: el.textContent,
lang: el.dataset.lang || "",
};
}
// Extract text and facets from contenteditable
const { text, facets } = domToFacets(el);
if (blockData.type === "heading") {
return {
type: "heading",
level: parseInt(el.dataset.level) || 1,
text,
facets,
};
} else if (blockData.type === "quote") {
return { type: "quote", text, facets };
} else {
return { type: "paragraph", text, facets };
}
}).filter(b => b.text || b.code); // Remove empty blocks
// Ensure at least one block
if (blocks.length === 0) {
blocks.push({ type: "paragraph", text: "", facets: [] });
}
try {
if (state.view === "edit" && state.currentDoc) {
await updateDocument(state.currentDoc.uri, title, slug, blocks);
} else {
await createDocument(title, slug, blocks);
}
} catch (err) {
alert("Error: " + err.message);
}
}
```
**Step 2: Commit**
```bash
git add docs.html
git commit -m "feat(docs): extract blocks from editor on submit"
```
---
## Task 12: Add Formatting Helpers
**Files:**
- Modify: `docs.html`
**Step 1: Add wrapSelectionWithTag and insertLink functions**
```javascript
function wrapSelectionWithTag(tagName) {
const selection = window.getSelection();
if (selection.isCollapsed) return;
const range = selection.getRangeAt(0);
const selectedText = range.toString();
const el = document.createElement(tagName);
el.textContent = selectedText;
range.deleteContents();
range.insertNode(el);
// Move cursor after element
range.setStartAfter(el);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
function insertLink() {
const selection = window.getSelection();
if (selection.isCollapsed) return;
const url = prompt("Enter URL:");
if (!url) return;
const range = selection.getRangeAt(0);
const selectedText = range.toString();
const a = document.createElement("a");
a.href = url;
a.className = "facet-link";
a.textContent = selectedText;
range.deleteContents();
range.insertNode(a);
range.setStartAfter(a);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
function handleBlockPaste(e) {
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
document.execCommand("insertText", false, text);
}
```
**Step 2: Commit**
```bash
git add docs.html
git commit -m "feat(docs): add formatting helpers and paste handling"
```
---
## Task 13: Manual Testing
**Steps:**
1. **Start local server:**
```bash
cd /Users/chadmiller/code/tools && python3 -m http.server 8000
```
2. **Test block creation:**
- Open http://localhost:8000/docs.html
- Login
- Click "New Document"
- Verify empty paragraph with placeholder appears
3. **Test slash commands:**
- Type `/` - verify menu appears
- Type `/head` - verify filtering
- Select "Heading 1" - verify block converts
4. **Test inline formatting:**
- Type `**bold**` - verify converts to bold
- Type `*italic*` - verify converts to italic
- Type `` `code` `` - verify converts to code
- Select text, press Cmd+B - verify bold
5. **Test block navigation:**
- Press Enter - verify new paragraph
- Press Backspace in empty block - verify deletion
- Use arrow keys at block edges - verify navigation
6. **Test save and reload:**
- Create document with mixed blocks
- Save
- View document - verify rendering
- Edit document - verify blocks load correctly
---
## Task 14: Final Commit
**Step 1: Review all changes**
```bash
git status
git diff --staged
```
**Step 2: Final commit if any loose changes**
```bash
git add -A
git commit -m "feat(docs): complete inline block editor implementation"
```
---
**Plan complete and saved to `docs/plans/2025-12-20-inline-block-editor.md`.**
Two execution options:
**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
Which approach?