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:
{
"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
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):
/**
* 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 += `<a href="${escapeHtmlForDom(feature.uri)}" class="facet-link">${escapeHtmlForDom(facetText)}</a>`;
} else if (type.includes("Bold") || type.includes("bold")) {
result += `<strong>${escapeHtmlForDom(facetText)}</strong>`;
} else if (type.includes("Italic") || type.includes("italic")) {
result += `<em>${escapeHtmlForDom(facetText)}</em>`;
} else if (type.includes("Code") || type.includes("code")) {
result += `<code>${escapeHtmlForDom(facetText)}</code>`;
} 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, ">")
.replace(/"/g, """);
}
Step 2: Add domToFacets function
Add after facetsToDom:
/**
* 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:
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: <strong>Hello</strong> world
});
Step 4: Commit
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:
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
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:
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:
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
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:
function renderView() {
const doc = state.currentDoc;
if (!doc) return '<div class="error">Document not found</div>';
const isOwner = state.viewer?.handle === doc.actorHandle;
const profile = doc.appBskyActorProfileByDid;
return `
<div class="doc-view">
<div style="margin-bottom: 1rem;">
<button class="secondary" onclick="showList()">← Back</button>
${
isOwner
? `
<button class="secondary" onclick="showEdit('${esc(doc.uri)}')">Edit</button>
<button class="danger" onclick="deleteDocument('${esc(doc.uri)}')">Delete</button>
`
: ""
}
</div>
<h2>${esc(doc.title)}</h2>
<div class="meta">
<span class="user-info">
${profile?.avatar ? `<img src="${esc(profile.avatar.url)}" class="user-avatar" />` : ""}
@${esc(doc.actorHandle)}
</span>
· ${formatTime(doc.updatedAt || doc.createdAt)}
· <span class="doc-slug">/${esc(doc.slug)}</span>
</div>
<div class="body">${renderBlocks(doc.blocks || [])}</div>
</div>
`;
}
function renderBlocks(blocks) {
return blocks.map(block => {
const type = block.__typename || "";
if (type.includes("Paragraph")) {
return `<p>${renderFacetedText(block.text, block.facets, { escapeHtml: esc })}</p>`;
} 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 `<pre class="facet-codeblock${langClass}"><code>${esc(block.code)}</code></pre>`;
} else if (type.includes("Quote")) {
return `<blockquote class="facet-quote">${renderFacetedText(block.text, block.facets, { escapeHtml: esc })}</blockquote>`;
}
return "";
}).join("\n");
}
Step 2: Add blockquote CSS
Find the CSS section and add after .facet-codeblock styles (around line 173):
.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
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):
// 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:
function renderForm() {
const isEdit = state.view === "edit";
const doc = isEdit ? state.currentDoc : null;
return `
<div class="editor-container">
<h2>${isEdit ? "Edit Document" : "New Document"}</h2>
<form onsubmit="handleSubmit(event)">
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" name="title" value="${esc(doc?.title || "")}" required />
</div>
<div class="form-group">
<label for="slug">Slug</label>
<input type="text" id="slug" name="slug" value="${esc(doc?.slug || "")}" required
pattern="[a-z0-9-]+" title="lowercase letters, numbers, and hyphens only" />
</div>
<div class="form-group">
<label>Content</label>
<div id="block-editor" class="block-editor"></div>
</div>
<div class="form-actions">
<button type="button" class="secondary" onclick="showList()">Cancel</button>
<button type="submit">${isEdit ? "Save Changes" : "Create Document"}</button>
</div>
</form>
<div id="slash-menu" class="slash-menu hidden"></div>
</div>
`;
}
Step 3: Add block editor CSS
Add to the CSS section:
.block-editor {
min-height: 300px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 1rem;
background: var(--bg-secondary);
}
.block-editor .block {
padding: 0.25rem 0.5rem;
margin: 0.25rem 0;
border-left: 2px solid transparent;
outline: none;
min-height: 1.5em;
}
.block-editor .block:focus {
border-left-color: var(--accent);
}
.block-editor .block.paragraph {
/* default styling */
}
.block-editor .block.heading-1 {
font-size: 1.75rem;
font-weight: 600;
}
.block-editor .block.heading-2 {
font-size: 1.5rem;
font-weight: 600;
}
.block-editor .block.heading-3 {
font-size: 1.25rem;
font-weight: 600;
}
.block-editor .block.code-block {
font-family: "SF Mono", Monaco, monospace;
background: var(--bg);
padding: 0.75rem;
border-radius: 4px;
white-space: pre;
}
.block-editor .block.quote {
border-left: 3px solid var(--border);
padding-left: 1rem;
color: var(--text-muted);
font-style: italic;
}
.block-editor .block[data-placeholder]:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
pointer-events: none;
}
.slash-menu {
position: absolute;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0;
min-width: 200px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
}
.slash-menu.hidden {
display: none;
}
.slash-menu-item {
padding: 0.5rem 1rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
}
.slash-menu-item:hover,
.slash-menu-item.selected {
background: var(--bg-hover);
}
.slash-menu-item .icon {
width: 20px;
text-align: center;
color: var(--text-muted);
}
Step 4: Commit
git add docs.html
git commit -m "feat(docs): add block editor markup and CSS"
Task 7: Implement Block Editor Initialization#
Files:
- Modify:
docs.html
Step 1: Add initBlockEditor function
Add after the editor state:
function initBlockEditor(blocks = []) {
const editor = document.getElementById("block-editor");
if (!editor) return;
editorState.blocks = [];
editor.innerHTML = "";
// Initialize with existing blocks or empty paragraph
if (blocks.length === 0) {
addBlock("paragraph", "", null, true);
} else {
for (const block of blocks) {
const type = block.__typename || "";
if (type.includes("Paragraph")) {
addBlock("paragraph", block.text, block.facets);
} else if (type.includes("Heading")) {
addBlock("heading", block.text, block.facets, false, block.level);
} else if (type.includes("CodeBlock")) {
addBlock("codeBlock", block.code, null, false, null, block.lang);
} else if (type.includes("Quote")) {
addBlock("quote", block.text, block.facets);
}
}
}
// Focus first block
if (editorState.blocks.length > 0) {
editorState.blocks[0].element.focus();
}
}
function addBlock(type, text = "", facets = null, focus = false, level = 1, lang = "") {
const editor = document.getElementById("block-editor");
const id = generateBlockId();
const div = document.createElement("div");
div.id = id;
div.className = `block ${type}${type === "heading" ? `-${level}` : ""}`;
div.dataset.type = type;
if (type === "heading") div.dataset.level = level;
if (type === "codeBlock") div.dataset.lang = lang;
if (type === "codeBlock") {
// Code blocks are plain text, no formatting
div.contentEditable = "true";
div.textContent = text;
div.spellcheck = false;
} else {
div.contentEditable = "true";
if (text && facets) {
div.innerHTML = facetsToDom(text, facets);
} else if (text) {
div.textContent = text;
}
}
// Placeholder for empty paragraphs
if (type === "paragraph") {
div.dataset.placeholder = "Type '/' for commands...";
}
// Event listeners
div.addEventListener("keydown", handleBlockKeydown);
div.addEventListener("input", handleBlockInput);
div.addEventListener("paste", handleBlockPaste);
editor.appendChild(div);
editorState.blocks.push({ id, type, element: div, level, lang });
if (focus) {
div.focus();
}
return div;
}
Step 2: Add import for facetsToDom at top of script
Update the import statement (around line 311):
import { parseFacets, renderFacetedText, facetsToDom, domToFacets } from "/richtext.js";
Step 3: Call initBlockEditor after render in edit/create mode
Update the render function. After app.innerHTML = html; add:
// Initialize block editor if in edit/create mode
if (state.view === "edit" || state.view === "create") {
setTimeout(() => {
const doc = state.view === "edit" ? state.currentDoc : null;
initBlockEditor(doc?.blocks || []);
}, 0);
}
Step 4: Commit
git add docs.html
git commit -m "feat(docs): implement block editor initialization"
Task 8: Implement Block Keyboard Handling#
Files:
- Modify:
docs.html
Step 1: Add handleBlockKeydown function
function handleBlockKeydown(e) {
const block = e.target;
const blockData = editorState.blocks.find(b => b.id === block.id);
if (!blockData) return;
// Handle slash menu navigation if open
if (editorState.slashMenuOpen) {
if (e.key === "ArrowDown") {
e.preventDefault();
navigateSlashMenu(1);
return;
} else if (e.key === "ArrowUp") {
e.preventDefault();
navigateSlashMenu(-1);
return;
} else if (e.key === "Enter") {
e.preventDefault();
selectSlashMenuItem();
return;
} else if (e.key === "Escape") {
e.preventDefault();
closeSlashMenu();
return;
}
}
// Enter: create new paragraph
if (e.key === "Enter" && !e.shiftKey) {
if (blockData.type === "codeBlock") {
// Allow newlines in code blocks
return;
}
e.preventDefault();
const index = editorState.blocks.indexOf(blockData);
insertBlockAfter(index, "paragraph");
}
// Backspace at start of empty block: delete block
if (e.key === "Backspace") {
const selection = window.getSelection();
const isAtStart = selection.anchorOffset === 0 && selection.isCollapsed;
const isEmpty = block.textContent === "";
if (isEmpty && editorState.blocks.length > 1) {
e.preventDefault();
const index = editorState.blocks.indexOf(blockData);
deleteBlock(index);
} else if (isAtStart && editorState.blocks.indexOf(blockData) > 0) {
// Merge with previous block if same type
e.preventDefault();
const index = editorState.blocks.indexOf(blockData);
mergeWithPrevious(index);
}
}
// Keyboard shortcuts for formatting
if (e.metaKey || e.ctrlKey) {
if (e.key === "b") {
e.preventDefault();
document.execCommand("bold");
} else if (e.key === "i") {
e.preventDefault();
document.execCommand("italic");
} else if (e.key === "e") {
e.preventDefault();
wrapSelectionWithTag("code");
} else if (e.key === "k") {
e.preventDefault();
insertLink();
}
}
// Arrow keys for block navigation
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const blockRect = block.getBoundingClientRect();
const atTop = rect.top <= blockRect.top + 5;
const atBottom = rect.bottom >= blockRect.bottom - 5;
if (e.key === "ArrowUp" && atTop) {
e.preventDefault();
focusPreviousBlock(blockData);
} else if (e.key === "ArrowDown" && atBottom) {
e.preventDefault();
focusNextBlock(blockData);
}
}
}
Step 2: Add block manipulation helpers
function insertBlockAfter(index, type, level = 1) {
const editor = document.getElementById("block-editor");
const newBlock = document.createElement("div");
const id = generateBlockId();
newBlock.id = id;
newBlock.className = `block ${type}${type === "heading" ? `-${level}` : ""}`;
newBlock.dataset.type = type;
newBlock.contentEditable = "true";
if (type === "paragraph") {
newBlock.dataset.placeholder = "Type '/' for commands...";
}
if (type === "heading") newBlock.dataset.level = level;
newBlock.addEventListener("keydown", handleBlockKeydown);
newBlock.addEventListener("input", handleBlockInput);
newBlock.addEventListener("paste", handleBlockPaste);
const nextBlock = editorState.blocks[index + 1];
if (nextBlock) {
editor.insertBefore(newBlock, nextBlock.element);
} else {
editor.appendChild(newBlock);
}
editorState.blocks.splice(index + 1, 0, { id, type, element: newBlock, level });
newBlock.focus();
}
function deleteBlock(index) {
const block = editorState.blocks[index];
block.element.remove();
editorState.blocks.splice(index, 1);
// Focus previous or next block
const focusIndex = Math.max(0, index - 1);
if (editorState.blocks[focusIndex]) {
editorState.blocks[focusIndex].element.focus();
}
}
function mergeWithPrevious(index) {
if (index === 0) return;
const current = editorState.blocks[index];
const previous = editorState.blocks[index - 1];
// Only merge text blocks
if (current.type === "codeBlock" || previous.type === "codeBlock") return;
const prevLength = previous.element.textContent.length;
previous.element.innerHTML += current.element.innerHTML;
current.element.remove();
editorState.blocks.splice(index, 1);
// Set cursor at merge point
previous.element.focus();
const range = document.createRange();
const sel = window.getSelection();
const textNode = findTextNodeAtOffset(previous.element, prevLength);
if (textNode) {
range.setStart(textNode.node, textNode.offset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
}
function findTextNodeAtOffset(element, targetOffset) {
let offset = 0;
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const len = node.textContent.length;
if (offset + len >= targetOffset) {
return { node, offset: targetOffset - offset };
}
offset += len;
}
return null;
}
function focusPreviousBlock(current) {
const index = editorState.blocks.indexOf(current);
if (index > 0) {
const prev = editorState.blocks[index - 1];
prev.element.focus();
// Move cursor to end
const range = document.createRange();
range.selectNodeContents(prev.element);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
}
function focusNextBlock(current) {
const index = editorState.blocks.indexOf(current);
if (index < editorState.blocks.length - 1) {
const next = editorState.blocks[index + 1];
next.element.focus();
// Move cursor to start
const range = document.createRange();
range.selectNodeContents(next.element);
range.collapse(true);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
}
Step 3: Commit
git add docs.html
git commit -m "feat(docs): implement block keyboard handling"
Task 9: Implement Slash Commands#
Files:
- Modify:
docs.html
Step 1: Add slash menu data and functions
const SLASH_COMMANDS = [
{ id: "paragraph", label: "Paragraph", icon: "P", description: "Plain text" },
{ id: "heading1", label: "Heading 1", icon: "H1", description: "Large heading" },
{ id: "heading2", label: "Heading 2", icon: "H2", description: "Medium heading" },
{ id: "heading3", label: "Heading 3", icon: "H3", description: "Small heading" },
{ id: "code", label: "Code Block", icon: "</>", description: "Code snippet" },
{ id: "quote", label: "Quote", icon: '"', description: "Blockquote" },
];
function handleBlockInput(e) {
const block = e.target;
const text = block.textContent;
// Check for slash command trigger
if (text === "/") {
openSlashMenu(block);
return;
}
// Filter slash menu if open
if (editorState.slashMenuOpen && text.startsWith("/")) {
const filter = text.slice(1).toLowerCase();
updateSlashMenuFilter(filter);
return;
}
// Close slash menu if text doesn't start with /
if (editorState.slashMenuOpen && !text.startsWith("/")) {
closeSlashMenu();
}
// Check for markdown auto-conversion
checkMarkdownConversion(block);
}
function openSlashMenu(block) {
const menu = document.getElementById("slash-menu");
const rect = block.getBoundingClientRect();
const containerRect = document.querySelector(".container").getBoundingClientRect();
menu.style.top = `${rect.bottom + window.scrollY + 5}px`;
menu.style.left = `${rect.left - containerRect.left}px`;
editorState.slashMenuOpen = true;
editorState.slashMenuIndex = 0;
editorState.slashMenuBlock = block;
editorState.slashMenuFilter = "";
renderSlashMenu(SLASH_COMMANDS);
menu.classList.remove("hidden");
}
function closeSlashMenu() {
const menu = document.getElementById("slash-menu");
menu.classList.add("hidden");
editorState.slashMenuOpen = false;
editorState.slashMenuBlock = null;
}
function renderSlashMenu(commands) {
const menu = document.getElementById("slash-menu");
menu.innerHTML = commands
.map(
(cmd, i) => `
<div class="slash-menu-item${i === editorState.slashMenuIndex ? " selected" : ""}"
data-command="${cmd.id}"
onclick="executeSlashCommand('${cmd.id}')">
<span class="icon">${cmd.icon}</span>
<span>${cmd.label}</span>
</div>
`
)
.join("");
}
function updateSlashMenuFilter(filter) {
const filtered = SLASH_COMMANDS.filter(
cmd =>
cmd.label.toLowerCase().includes(filter) ||
cmd.description.toLowerCase().includes(filter)
);
editorState.slashMenuIndex = 0;
renderSlashMenu(filtered);
if (filtered.length === 0) {
closeSlashMenu();
}
}
function navigateSlashMenu(direction) {
const menu = document.getElementById("slash-menu");
const items = menu.querySelectorAll(".slash-menu-item");
editorState.slashMenuIndex = Math.max(
0,
Math.min(items.length - 1, editorState.slashMenuIndex + direction)
);
items.forEach((item, i) => {
item.classList.toggle("selected", i === editorState.slashMenuIndex);
});
}
function selectSlashMenuItem() {
const menu = document.getElementById("slash-menu");
const items = menu.querySelectorAll(".slash-menu-item");
const selected = items[editorState.slashMenuIndex];
if (selected) {
executeSlashCommand(selected.dataset.command);
}
}
function executeSlashCommand(commandId) {
const block = editorState.slashMenuBlock;
if (!block) return;
const blockData = editorState.blocks.find(b => b.element === block);
if (!blockData) return;
closeSlashMenu();
// Clear the slash text
block.textContent = "";
// Convert block to new type
if (commandId === "paragraph") {
convertBlock(blockData, "paragraph");
} else if (commandId.startsWith("heading")) {
const level = parseInt(commandId.replace("heading", ""));
convertBlock(blockData, "heading", level);
} else if (commandId === "code") {
convertBlock(blockData, "codeBlock");
} else if (commandId === "quote") {
convertBlock(blockData, "quote");
}
block.focus();
}
function convertBlock(blockData, newType, level = 1) {
const block = blockData.element;
const content = block.innerHTML;
block.className = `block ${newType}${newType === "heading" ? `-${level}` : ""}`;
block.dataset.type = newType;
if (newType === "paragraph") {
block.dataset.placeholder = "Type '/' for commands...";
delete block.dataset.level;
} else if (newType === "heading") {
block.dataset.level = level;
delete block.dataset.placeholder;
} else if (newType === "codeBlock") {
block.textContent = block.textContent; // Strip HTML
block.spellcheck = false;
delete block.dataset.placeholder;
} else if (newType === "quote") {
delete block.dataset.placeholder;
}
blockData.type = newType;
blockData.level = level;
}
// Make global for onclick
window.executeSlashCommand = executeSlashCommand;
Step 2: Commit
git add docs.html
git commit -m "feat(docs): implement slash commands menu"
Task 10: Implement Markdown Auto-Conversion#
Files:
- Modify:
docs.html
Step 1: Add checkMarkdownConversion function
function checkMarkdownConversion(block) {
const blockData = editorState.blocks.find(b => b.element === block);
if (!blockData || blockData.type === "codeBlock") return;
const selection = window.getSelection();
if (!selection.isCollapsed) return;
const range = selection.getRangeAt(0);
const textNode = range.startContainer;
if (textNode.nodeType !== Node.TEXT_NODE) return;
const text = textNode.textContent;
const cursor = range.startOffset;
// Check for inline code: `text`
if (text[cursor - 1] === "`") {
const before = text.slice(0, cursor - 1);
const openTick = before.lastIndexOf("`");
if (openTick !== -1 && openTick < cursor - 2) {
const codeText = before.slice(openTick + 1);
// Replace with <code> 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
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:
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
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
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
git add docs.html
git commit -m "feat(docs): add formatting helpers and paste handling"
Task 13: Manual Testing#
Steps:
-
Start local server:
cd /Users/chadmiller/code/tools && python3 -m http.server 8000 -
Test block creation:
- Open http://localhost:8000/docs.html
- Login
- Click "New Document"
- Verify empty paragraph with placeholder appears
-
Test slash commands:
- Type
/- verify menu appears - Type
/head- verify filtering - Select "Heading 1" - verify block converts
- Type
-
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
- Type
-
Test block navigation:
- Press Enter - verify new paragraph
- Press Backspace in empty block - verify deletion
- Use arrow keys at block edges - verify navigation
-
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
git status
git diff --staged
Step 2: Final commit if any loose changes
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?