# 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 '
Document not found
'; const isOwner = state.viewer?.handle === doc.actorHandle; const profile = doc.appBskyActorProfileByDid; return `
${ isOwner ? ` ` : "" }

${esc(doc.title)}

· ${formatTime(doc.updatedAt || doc.createdAt)} · /${esc(doc.slug)}
${renderBlocks(doc.blocks || [])}
`; } function renderBlocks(blocks) { return blocks.map(block => { const type = block.__typename || ""; if (type.includes("Paragraph")) { 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 })}`; } 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 `

${isEdit ? "Edit Document" : "New Document"}

`; } ``` **Step 3: Add block editor CSS** Add to the CSS section: ```css .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** ```bash 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: ```javascript 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): ```javascript 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: ```javascript // 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** ```bash 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** ```javascript 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** ```javascript 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** ```bash 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** ```javascript 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) => `
${cmd.icon} ${cmd.label}
` ) .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** ```bash 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** ```javascript 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 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?