Tools for the Atmosphere tools.slices.network
quickslice atproto html

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, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

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:

  1. Start local server:

    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

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?