Tools for the Atmosphere
tools.slices.network
quickslice
atproto
html
1# Inline Block Editor Implementation Plan
2
3> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
5**Goal:** Replace textarea-based document editing with a modern WYSIWYG block editor using contenteditable, slash commands, and markdown auto-conversion.
6
7**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.
8
9**Tech Stack:** Vanilla JS, contenteditable API, existing richtext.js for facet rendering
10
11---
12
13## Task 1: Update Document Lexicon
14
15**Files:**
16- Modify: `lexicons/network/slices/tools/document.json`
17
18**Step 1: Replace body/bodyFacets with blocks array**
19
20Replace the entire file with:
21
22```json
23{
24 "lexicon": 1,
25 "id": "network.slices.tools.document",
26 "defs": {
27 "main": {
28 "type": "record",
29 "key": "tid",
30 "record": {
31 "type": "object",
32 "required": ["title", "slug", "blocks", "createdAt"],
33 "properties": {
34 "title": {
35 "type": "string",
36 "maxLength": 300,
37 "description": "Document title"
38 },
39 "slug": {
40 "type": "string",
41 "maxLength": 100,
42 "description": "URL-friendly identifier, unique per author"
43 },
44 "blocks": {
45 "type": "array",
46 "description": "Document content as array of blocks",
47 "items": {
48 "type": "union",
49 "refs": ["#paragraph", "#heading", "#codeBlock", "#quote"]
50 }
51 },
52 "createdAt": {
53 "type": "string",
54 "format": "datetime"
55 },
56 "updatedAt": {
57 "type": "string",
58 "format": "datetime"
59 }
60 }
61 }
62 },
63 "paragraph": {
64 "type": "object",
65 "description": "A paragraph block with optional inline formatting",
66 "required": ["text"],
67 "properties": {
68 "text": {
69 "type": "string",
70 "maxLength": 10000
71 },
72 "facets": {
73 "type": "array",
74 "items": {
75 "type": "ref",
76 "ref": "network.slices.tools.richtext.facet"
77 }
78 }
79 }
80 },
81 "heading": {
82 "type": "object",
83 "description": "A heading block (h1-h3) with optional inline formatting",
84 "required": ["level", "text"],
85 "properties": {
86 "level": {
87 "type": "integer",
88 "minimum": 1,
89 "maximum": 3
90 },
91 "text": {
92 "type": "string",
93 "maxLength": 300
94 },
95 "facets": {
96 "type": "array",
97 "items": {
98 "type": "ref",
99 "ref": "network.slices.tools.richtext.facet"
100 }
101 }
102 }
103 },
104 "codeBlock": {
105 "type": "object",
106 "description": "A fenced code block",
107 "required": ["code"],
108 "properties": {
109 "code": {
110 "type": "string",
111 "maxLength": 20000
112 },
113 "lang": {
114 "type": "string",
115 "maxLength": 50
116 }
117 }
118 },
119 "quote": {
120 "type": "object",
121 "description": "A blockquote with optional inline formatting",
122 "required": ["text"],
123 "properties": {
124 "text": {
125 "type": "string",
126 "maxLength": 5000
127 },
128 "facets": {
129 "type": "array",
130 "items": {
131 "type": "ref",
132 "ref": "network.slices.tools.richtext.facet"
133 }
134 }
135 }
136 }
137 }
138}
139```
140
141**Step 2: Verify JSON is valid**
142
143Run: `cat lexicons/network/slices/tools/document.json | python3 -m json.tool > /dev/null && echo "Valid JSON"`
144Expected: `Valid JSON`
145
146**Step 3: Commit**
147
148```bash
149git add lexicons/network/slices/tools/document.json
150git commit -m "feat(lexicon): replace body with blocks array for document"
151```
152
153---
154
155## Task 2: Add DOM-to-Facets Helpers in richtext.js
156
157**Files:**
158- Modify: `richtext.js`
159
160**Step 1: Add facetsToDom function**
161
162Add after the existing `renderFacetedText` function (around line 236):
163
164```javascript
165/**
166 * Convert text + facets to HTML for contenteditable editing.
167 * Returns HTML string with formatting tags.
168 */
169export function facetsToDom(text, facets = []) {
170 if (!text) return "";
171
172 if (!facets || facets.length === 0) {
173 return escapeHtmlForDom(text);
174 }
175
176 const encoder = new TextEncoder();
177 const decoder = new TextDecoder();
178 const bytes = encoder.encode(text);
179
180 // Sort facets by start position
181 const sortedFacets = [...facets].sort(
182 (a, b) => a.index.byteStart - b.index.byteStart
183 );
184
185 let result = "";
186 let lastEnd = 0;
187
188 for (const facet of sortedFacets) {
189 // Add text before this facet
190 if (facet.index.byteStart > lastEnd) {
191 const beforeBytes = bytes.slice(lastEnd, facet.index.byteStart);
192 result += escapeHtmlForDom(decoder.decode(beforeBytes));
193 }
194
195 // Get the faceted text
196 const facetBytes = bytes.slice(facet.index.byteStart, facet.index.byteEnd);
197 const facetText = decoder.decode(facetBytes);
198
199 // Determine facet type and wrap in tag
200 const feature = facet.features[0];
201 const type = feature?.$type || feature?.__typename || "";
202
203 if (type.includes("Link") || type.includes("link")) {
204 result += `<a href="${escapeHtmlForDom(feature.uri)}" class="facet-link">${escapeHtmlForDom(facetText)}</a>`;
205 } else if (type.includes("Bold") || type.includes("bold")) {
206 result += `<strong>${escapeHtmlForDom(facetText)}</strong>`;
207 } else if (type.includes("Italic") || type.includes("italic")) {
208 result += `<em>${escapeHtmlForDom(facetText)}</em>`;
209 } else if (type.includes("Code") || type.includes("code")) {
210 result += `<code>${escapeHtmlForDom(facetText)}</code>`;
211 } else {
212 result += escapeHtmlForDom(facetText);
213 }
214
215 lastEnd = facet.index.byteEnd;
216 }
217
218 // Add remaining text
219 if (lastEnd < bytes.length) {
220 const remainingBytes = bytes.slice(lastEnd);
221 result += escapeHtmlForDom(decoder.decode(remainingBytes));
222 }
223
224 return result;
225}
226
227function escapeHtmlForDom(text) {
228 return text
229 .replace(/&/g, "&")
230 .replace(/</g, "<")
231 .replace(/>/g, ">")
232 .replace(/"/g, """);
233}
234```
235
236**Step 2: Add domToFacets function**
237
238Add after `facetsToDom`:
239
240```javascript
241/**
242 * Extract text and facets from a contenteditable element.
243 * Walks the DOM tree and builds facets from formatting tags.
244 * Returns { text, facets }.
245 */
246export function domToFacets(element) {
247 const encoder = new TextEncoder();
248 let text = "";
249 const facets = [];
250
251 function walk(node, activeFormats = []) {
252 if (node.nodeType === Node.TEXT_NODE) {
253 const content = node.textContent || "";
254 if (content) {
255 const startByte = encoder.encode(text).length;
256 text += content;
257 const endByte = encoder.encode(text).length;
258
259 // Create facets for each active format
260 for (const format of activeFormats) {
261 facets.push({
262 index: { byteStart: startByte, byteEnd: endByte },
263 features: [format],
264 });
265 }
266 }
267 } else if (node.nodeType === Node.ELEMENT_NODE) {
268 const tag = node.tagName.toLowerCase();
269 let newFormat = null;
270
271 if (tag === "strong" || tag === "b") {
272 newFormat = { $type: "network.slices.tools.richtext.facet#bold" };
273 } else if (tag === "em" || tag === "i") {
274 newFormat = { $type: "network.slices.tools.richtext.facet#italic" };
275 } else if (tag === "code") {
276 newFormat = { $type: "network.slices.tools.richtext.facet#code" };
277 } else if (tag === "a") {
278 newFormat = {
279 $type: "network.slices.tools.richtext.facet#link",
280 uri: node.getAttribute("href") || "",
281 };
282 }
283
284 const formats = newFormat ? [...activeFormats, newFormat] : activeFormats;
285
286 for (const child of node.childNodes) {
287 walk(child, formats);
288 }
289 }
290 }
291
292 walk(element);
293
294 // Merge adjacent facets of the same type
295 const mergedFacets = mergeFacets(facets);
296
297 return { text, facets: mergedFacets };
298}
299
300/**
301 * Merge adjacent facets of the same type.
302 */
303function mergeFacets(facets) {
304 if (facets.length === 0) return [];
305
306 // Group by type
307 const byType = new Map();
308 for (const facet of facets) {
309 const type = facet.features[0]?.$type || "";
310 const key = type + (facet.features[0]?.uri || "");
311 if (!byType.has(key)) {
312 byType.set(key, []);
313 }
314 byType.get(key).push(facet);
315 }
316
317 const merged = [];
318 for (const group of byType.values()) {
319 // Sort by start position
320 group.sort((a, b) => a.index.byteStart - b.index.byteStart);
321
322 let current = null;
323 for (const facet of group) {
324 if (!current) {
325 current = { ...facet, index: { ...facet.index } };
326 } else if (facet.index.byteStart <= current.index.byteEnd) {
327 // Merge overlapping or adjacent
328 current.index.byteEnd = Math.max(current.index.byteEnd, facet.index.byteEnd);
329 } else {
330 merged.push(current);
331 current = { ...facet, index: { ...facet.index } };
332 }
333 }
334 if (current) {
335 merged.push(current);
336 }
337 }
338
339 // Sort by start position
340 merged.sort((a, b) => a.index.byteStart - b.index.byteStart);
341
342 return merged;
343}
344```
345
346**Step 3: Test in browser console**
347
348Open docs.html, then in console:
349```javascript
350import('/richtext.js').then(m => {
351 // Test facetsToDom
352 const html = m.facetsToDom("Hello world", [
353 { index: { byteStart: 0, byteEnd: 5 }, features: [{ $type: "network.slices.tools.richtext.facet#bold" }] }
354 ]);
355 console.log(html); // Should be: <strong>Hello</strong> world
356});
357```
358
359**Step 4: Commit**
360
361```bash
362git add richtext.js
363git commit -m "feat(richtext): add facetsToDom and domToFacets for block editor"
364```
365
366---
367
368## Task 3: Update GraphQL Queries in docs.html
369
370**Files:**
371- Modify: `docs.html`
372
373**Step 1: Update DOCUMENTS_QUERY to fetch blocks**
374
375Find the `DOCUMENTS_QUERY` constant (around line 410) and replace with:
376
377```javascript
378const DOCUMENTS_QUERY = `
379 query GetDocuments($handle: String, $first: Int!, $after: String) {
380 networkSlicesToolsDocument(
381 where: { actorHandle: { eq: $handle } }
382 sortBy: [{ field: createdAt, direction: DESC }]
383 first: $first
384 after: $after
385 ) {
386 edges {
387 node {
388 uri
389 actorHandle
390 title
391 slug
392 blocks {
393 __typename
394 ... on NetworkSlicesToolsDocumentParagraph {
395 text
396 facets {
397 index { byteStart byteEnd }
398 features {
399 __typename
400 ... on NetworkSlicesToolsRichtextFacetLink { uri }
401 ... on NetworkSlicesToolsRichtextFacetBold { _ }
402 ... on NetworkSlicesToolsRichtextFacetItalic { _ }
403 ... on NetworkSlicesToolsRichtextFacetCode { _ }
404 }
405 }
406 }
407 ... on NetworkSlicesToolsDocumentHeading {
408 level
409 text
410 facets {
411 index { byteStart byteEnd }
412 features {
413 __typename
414 ... on NetworkSlicesToolsRichtextFacetLink { uri }
415 ... on NetworkSlicesToolsRichtextFacetBold { _ }
416 ... on NetworkSlicesToolsRichtextFacetItalic { _ }
417 ... on NetworkSlicesToolsRichtextFacetCode { _ }
418 }
419 }
420 }
421 ... on NetworkSlicesToolsDocumentCodeBlock {
422 code
423 lang
424 }
425 ... on NetworkSlicesToolsDocumentQuote {
426 text
427 facets {
428 index { byteStart byteEnd }
429 features {
430 __typename
431 ... on NetworkSlicesToolsRichtextFacetLink { uri }
432 ... on NetworkSlicesToolsRichtextFacetBold { _ }
433 ... on NetworkSlicesToolsRichtextFacetItalic { _ }
434 ... on NetworkSlicesToolsRichtextFacetCode { _ }
435 }
436 }
437 }
438 }
439 createdAt
440 updatedAt
441 appBskyActorProfileByDid {
442 displayName
443 avatar { url(preset: "avatar") }
444 }
445 }
446 }
447 pageInfo { hasNextPage endCursor }
448 }
449 }
450`;
451```
452
453**Step 2: Update ALL_DOCUMENTS_QUERY similarly**
454
455Replace `ALL_DOCUMENTS_QUERY` (around line 449) - same blocks fragment, just without the handle filter.
456
457**Step 3: Update DOCUMENT_BY_SLUG_QUERY similarly**
458
459Replace `DOCUMENT_BY_SLUG_QUERY` (around line 511) - same blocks fragment.
460
461**Step 4: Commit**
462
463```bash
464git add docs.html
465git commit -m "feat(docs): update GraphQL queries to fetch blocks"
466```
467
468---
469
470## Task 4: Update Mutations in docs.html
471
472**Files:**
473- Modify: `docs.html`
474
475**Step 1: Update createDocument function**
476
477Find `async function createDocument` (around line 595) and replace with:
478
479```javascript
480async function createDocument(title, slug, blocks) {
481 const input = {
482 title,
483 slug,
484 blocks: blocks.map(block => serializeBlock(block)),
485 createdAt: new Date().toISOString(),
486 };
487
488 await gqlMutation(CREATE_DOCUMENT_MUTATION, { input });
489 await loadDocuments(state.viewer?.handle);
490 state.view = "list";
491 render();
492}
493
494function serializeBlock(block) {
495 const base = { $type: `network.slices.tools.document#${block.type}` };
496
497 if (block.type === "paragraph") {
498 return { ...base, text: block.text, facets: block.facets || [] };
499 } else if (block.type === "heading") {
500 return { ...base, level: block.level, text: block.text, facets: block.facets || [] };
501 } else if (block.type === "codeBlock") {
502 return { ...base, code: block.code, lang: block.lang || undefined };
503 } else if (block.type === "quote") {
504 return { ...base, text: block.text, facets: block.facets || [] };
505 }
506 return base;
507}
508```
509
510**Step 2: Update updateDocument function**
511
512Find `async function updateDocument` (around line 611) and replace with:
513
514```javascript
515async function updateDocument(uri, title, slug, blocks) {
516 const input = {
517 title,
518 slug,
519 blocks: blocks.map(block => serializeBlock(block)),
520 updatedAt: new Date().toISOString(),
521 };
522
523 await gqlMutation(UPDATE_DOCUMENT_MUTATION, {
524 rkey: extractRkey(uri),
525 input,
526 });
527 await loadDocuments(state.viewer?.handle);
528 state.view = "list";
529 render();
530}
531```
532
533**Step 3: Commit**
534
535```bash
536git add docs.html
537git commit -m "feat(docs): update mutations to save blocks"
538```
539
540---
541
542## Task 5: Update View Rendering for Blocks
543
544**Files:**
545- Modify: `docs.html`
546
547**Step 1: Update renderView function**
548
549Find `function renderView()` (around line 778) and replace the body rendering section:
550
551```javascript
552function renderView() {
553 const doc = state.currentDoc;
554 if (!doc) return '<div class="error">Document not found</div>';
555
556 const isOwner = state.viewer?.handle === doc.actorHandle;
557 const profile = doc.appBskyActorProfileByDid;
558
559 return `
560 <div class="doc-view">
561 <div style="margin-bottom: 1rem;">
562 <button class="secondary" onclick="showList()">← Back</button>
563 ${
564 isOwner
565 ? `
566 <button class="secondary" onclick="showEdit('${esc(doc.uri)}')">Edit</button>
567 <button class="danger" onclick="deleteDocument('${esc(doc.uri)}')">Delete</button>
568 `
569 : ""
570 }
571 </div>
572 <h2>${esc(doc.title)}</h2>
573 <div class="meta">
574 <span class="user-info">
575 ${profile?.avatar ? `<img src="${esc(profile.avatar.url)}" class="user-avatar" />` : ""}
576 @${esc(doc.actorHandle)}
577 </span>
578 · ${formatTime(doc.updatedAt || doc.createdAt)}
579 · <span class="doc-slug">/${esc(doc.slug)}</span>
580 </div>
581 <div class="body">${renderBlocks(doc.blocks || [])}</div>
582 </div>
583 `;
584}
585
586function renderBlocks(blocks) {
587 return blocks.map(block => {
588 const type = block.__typename || "";
589
590 if (type.includes("Paragraph")) {
591 return `<p>${renderFacetedText(block.text, block.facets, { escapeHtml: esc })}</p>`;
592 } else if (type.includes("Heading")) {
593 const tag = `h${block.level + 1}`; // h2, h3, h4 (h1 is doc title)
594 return `<${tag}>${renderFacetedText(block.text, block.facets, { escapeHtml: esc })}</${tag}>`;
595 } else if (type.includes("CodeBlock")) {
596 const langClass = block.lang ? ` language-${esc(block.lang)}` : "";
597 return `<pre class="facet-codeblock${langClass}"><code>${esc(block.code)}</code></pre>`;
598 } else if (type.includes("Quote")) {
599 return `<blockquote class="facet-quote">${renderFacetedText(block.text, block.facets, { escapeHtml: esc })}</blockquote>`;
600 }
601 return "";
602 }).join("\n");
603}
604```
605
606**Step 2: Add blockquote CSS**
607
608Find the CSS section and add after `.facet-codeblock` styles (around line 173):
609
610```css
611.facet-quote {
612 border-left: 3px solid var(--border);
613 padding-left: 1rem;
614 margin: 0.5rem 0;
615 color: var(--text-muted);
616 font-style: italic;
617}
618```
619
620**Step 3: Commit**
621
622```bash
623git add docs.html
624git commit -m "feat(docs): render blocks in view mode"
625```
626
627---
628
629## Task 6: Build Block Editor Component
630
631**Files:**
632- Modify: `docs.html`
633
634**Step 1: Add editor state and helpers**
635
636Add after the `state` object declaration (around line 324):
637
638```javascript
639// Editor state (only used during editing)
640const editorState = {
641 blocks: [], // Array of { id, type, element } during editing
642 slashMenuOpen: false,
643 slashMenuIndex: 0,
644};
645
646function generateBlockId() {
647 return 'block-' + Math.random().toString(36).substr(2, 9);
648}
649```
650
651**Step 2: Update renderForm for block editor**
652
653Replace `function renderForm()` (around line 812) with:
654
655```javascript
656function renderForm() {
657 const isEdit = state.view === "edit";
658 const doc = isEdit ? state.currentDoc : null;
659
660 return `
661 <div class="editor-container">
662 <h2>${isEdit ? "Edit Document" : "New Document"}</h2>
663 <form onsubmit="handleSubmit(event)">
664 <div class="form-group">
665 <label for="title">Title</label>
666 <input type="text" id="title" name="title" value="${esc(doc?.title || "")}" required />
667 </div>
668 <div class="form-group">
669 <label for="slug">Slug</label>
670 <input type="text" id="slug" name="slug" value="${esc(doc?.slug || "")}" required
671 pattern="[a-z0-9-]+" title="lowercase letters, numbers, and hyphens only" />
672 </div>
673 <div class="form-group">
674 <label>Content</label>
675 <div id="block-editor" class="block-editor"></div>
676 </div>
677 <div class="form-actions">
678 <button type="button" class="secondary" onclick="showList()">Cancel</button>
679 <button type="submit">${isEdit ? "Save Changes" : "Create Document"}</button>
680 </div>
681 </form>
682 <div id="slash-menu" class="slash-menu hidden"></div>
683 </div>
684 `;
685}
686```
687
688**Step 3: Add block editor CSS**
689
690Add to the CSS section:
691
692```css
693.block-editor {
694 min-height: 300px;
695 border: 1px solid var(--border);
696 border-radius: 6px;
697 padding: 1rem;
698 background: var(--bg-secondary);
699}
700
701.block-editor .block {
702 padding: 0.25rem 0.5rem;
703 margin: 0.25rem 0;
704 border-left: 2px solid transparent;
705 outline: none;
706 min-height: 1.5em;
707}
708
709.block-editor .block:focus {
710 border-left-color: var(--accent);
711}
712
713.block-editor .block.paragraph {
714 /* default styling */
715}
716
717.block-editor .block.heading-1 {
718 font-size: 1.75rem;
719 font-weight: 600;
720}
721
722.block-editor .block.heading-2 {
723 font-size: 1.5rem;
724 font-weight: 600;
725}
726
727.block-editor .block.heading-3 {
728 font-size: 1.25rem;
729 font-weight: 600;
730}
731
732.block-editor .block.code-block {
733 font-family: "SF Mono", Monaco, monospace;
734 background: var(--bg);
735 padding: 0.75rem;
736 border-radius: 4px;
737 white-space: pre;
738}
739
740.block-editor .block.quote {
741 border-left: 3px solid var(--border);
742 padding-left: 1rem;
743 color: var(--text-muted);
744 font-style: italic;
745}
746
747.block-editor .block[data-placeholder]:empty::before {
748 content: attr(data-placeholder);
749 color: var(--text-muted);
750 pointer-events: none;
751}
752
753.slash-menu {
754 position: absolute;
755 background: var(--bg-secondary);
756 border: 1px solid var(--border);
757 border-radius: 6px;
758 padding: 0.5rem 0;
759 min-width: 200px;
760 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
761 z-index: 100;
762}
763
764.slash-menu.hidden {
765 display: none;
766}
767
768.slash-menu-item {
769 padding: 0.5rem 1rem;
770 cursor: pointer;
771 display: flex;
772 align-items: center;
773 gap: 0.5rem;
774}
775
776.slash-menu-item:hover,
777.slash-menu-item.selected {
778 background: var(--bg-hover);
779}
780
781.slash-menu-item .icon {
782 width: 20px;
783 text-align: center;
784 color: var(--text-muted);
785}
786```
787
788**Step 4: Commit**
789
790```bash
791git add docs.html
792git commit -m "feat(docs): add block editor markup and CSS"
793```
794
795---
796
797## Task 7: Implement Block Editor Initialization
798
799**Files:**
800- Modify: `docs.html`
801
802**Step 1: Add initBlockEditor function**
803
804Add after the editor state:
805
806```javascript
807function initBlockEditor(blocks = []) {
808 const editor = document.getElementById("block-editor");
809 if (!editor) return;
810
811 editorState.blocks = [];
812 editor.innerHTML = "";
813
814 // Initialize with existing blocks or empty paragraph
815 if (blocks.length === 0) {
816 addBlock("paragraph", "", null, true);
817 } else {
818 for (const block of blocks) {
819 const type = block.__typename || "";
820 if (type.includes("Paragraph")) {
821 addBlock("paragraph", block.text, block.facets);
822 } else if (type.includes("Heading")) {
823 addBlock("heading", block.text, block.facets, false, block.level);
824 } else if (type.includes("CodeBlock")) {
825 addBlock("codeBlock", block.code, null, false, null, block.lang);
826 } else if (type.includes("Quote")) {
827 addBlock("quote", block.text, block.facets);
828 }
829 }
830 }
831
832 // Focus first block
833 if (editorState.blocks.length > 0) {
834 editorState.blocks[0].element.focus();
835 }
836}
837
838function addBlock(type, text = "", facets = null, focus = false, level = 1, lang = "") {
839 const editor = document.getElementById("block-editor");
840 const id = generateBlockId();
841
842 const div = document.createElement("div");
843 div.id = id;
844 div.className = `block ${type}${type === "heading" ? `-${level}` : ""}`;
845 div.dataset.type = type;
846 if (type === "heading") div.dataset.level = level;
847 if (type === "codeBlock") div.dataset.lang = lang;
848
849 if (type === "codeBlock") {
850 // Code blocks are plain text, no formatting
851 div.contentEditable = "true";
852 div.textContent = text;
853 div.spellcheck = false;
854 } else {
855 div.contentEditable = "true";
856 if (text && facets) {
857 div.innerHTML = facetsToDom(text, facets);
858 } else if (text) {
859 div.textContent = text;
860 }
861 }
862
863 // Placeholder for empty paragraphs
864 if (type === "paragraph") {
865 div.dataset.placeholder = "Type '/' for commands...";
866 }
867
868 // Event listeners
869 div.addEventListener("keydown", handleBlockKeydown);
870 div.addEventListener("input", handleBlockInput);
871 div.addEventListener("paste", handleBlockPaste);
872
873 editor.appendChild(div);
874 editorState.blocks.push({ id, type, element: div, level, lang });
875
876 if (focus) {
877 div.focus();
878 }
879
880 return div;
881}
882```
883
884**Step 2: Add import for facetsToDom at top of script**
885
886Update the import statement (around line 311):
887
888```javascript
889import { parseFacets, renderFacetedText, facetsToDom, domToFacets } from "/richtext.js";
890```
891
892**Step 3: Call initBlockEditor after render in edit/create mode**
893
894Update the `render` function. After `app.innerHTML = html;` add:
895
896```javascript
897// Initialize block editor if in edit/create mode
898if (state.view === "edit" || state.view === "create") {
899 setTimeout(() => {
900 const doc = state.view === "edit" ? state.currentDoc : null;
901 initBlockEditor(doc?.blocks || []);
902 }, 0);
903}
904```
905
906**Step 4: Commit**
907
908```bash
909git add docs.html
910git commit -m "feat(docs): implement block editor initialization"
911```
912
913---
914
915## Task 8: Implement Block Keyboard Handling
916
917**Files:**
918- Modify: `docs.html`
919
920**Step 1: Add handleBlockKeydown function**
921
922```javascript
923function handleBlockKeydown(e) {
924 const block = e.target;
925 const blockData = editorState.blocks.find(b => b.id === block.id);
926 if (!blockData) return;
927
928 // Handle slash menu navigation if open
929 if (editorState.slashMenuOpen) {
930 if (e.key === "ArrowDown") {
931 e.preventDefault();
932 navigateSlashMenu(1);
933 return;
934 } else if (e.key === "ArrowUp") {
935 e.preventDefault();
936 navigateSlashMenu(-1);
937 return;
938 } else if (e.key === "Enter") {
939 e.preventDefault();
940 selectSlashMenuItem();
941 return;
942 } else if (e.key === "Escape") {
943 e.preventDefault();
944 closeSlashMenu();
945 return;
946 }
947 }
948
949 // Enter: create new paragraph
950 if (e.key === "Enter" && !e.shiftKey) {
951 if (blockData.type === "codeBlock") {
952 // Allow newlines in code blocks
953 return;
954 }
955 e.preventDefault();
956 const index = editorState.blocks.indexOf(blockData);
957 insertBlockAfter(index, "paragraph");
958 }
959
960 // Backspace at start of empty block: delete block
961 if (e.key === "Backspace") {
962 const selection = window.getSelection();
963 const isAtStart = selection.anchorOffset === 0 && selection.isCollapsed;
964 const isEmpty = block.textContent === "";
965
966 if (isEmpty && editorState.blocks.length > 1) {
967 e.preventDefault();
968 const index = editorState.blocks.indexOf(blockData);
969 deleteBlock(index);
970 } else if (isAtStart && editorState.blocks.indexOf(blockData) > 0) {
971 // Merge with previous block if same type
972 e.preventDefault();
973 const index = editorState.blocks.indexOf(blockData);
974 mergeWithPrevious(index);
975 }
976 }
977
978 // Keyboard shortcuts for formatting
979 if (e.metaKey || e.ctrlKey) {
980 if (e.key === "b") {
981 e.preventDefault();
982 document.execCommand("bold");
983 } else if (e.key === "i") {
984 e.preventDefault();
985 document.execCommand("italic");
986 } else if (e.key === "e") {
987 e.preventDefault();
988 wrapSelectionWithTag("code");
989 } else if (e.key === "k") {
990 e.preventDefault();
991 insertLink();
992 }
993 }
994
995 // Arrow keys for block navigation
996 if (e.key === "ArrowUp" || e.key === "ArrowDown") {
997 const selection = window.getSelection();
998 const range = selection.getRangeAt(0);
999 const rect = range.getBoundingClientRect();
1000 const blockRect = block.getBoundingClientRect();
1001
1002 const atTop = rect.top <= blockRect.top + 5;
1003 const atBottom = rect.bottom >= blockRect.bottom - 5;
1004
1005 if (e.key === "ArrowUp" && atTop) {
1006 e.preventDefault();
1007 focusPreviousBlock(blockData);
1008 } else if (e.key === "ArrowDown" && atBottom) {
1009 e.preventDefault();
1010 focusNextBlock(blockData);
1011 }
1012 }
1013}
1014```
1015
1016**Step 2: Add block manipulation helpers**
1017
1018```javascript
1019function insertBlockAfter(index, type, level = 1) {
1020 const editor = document.getElementById("block-editor");
1021 const newBlock = document.createElement("div");
1022 const id = generateBlockId();
1023
1024 newBlock.id = id;
1025 newBlock.className = `block ${type}${type === "heading" ? `-${level}` : ""}`;
1026 newBlock.dataset.type = type;
1027 newBlock.contentEditable = "true";
1028 if (type === "paragraph") {
1029 newBlock.dataset.placeholder = "Type '/' for commands...";
1030 }
1031 if (type === "heading") newBlock.dataset.level = level;
1032
1033 newBlock.addEventListener("keydown", handleBlockKeydown);
1034 newBlock.addEventListener("input", handleBlockInput);
1035 newBlock.addEventListener("paste", handleBlockPaste);
1036
1037 const nextBlock = editorState.blocks[index + 1];
1038 if (nextBlock) {
1039 editor.insertBefore(newBlock, nextBlock.element);
1040 } else {
1041 editor.appendChild(newBlock);
1042 }
1043
1044 editorState.blocks.splice(index + 1, 0, { id, type, element: newBlock, level });
1045 newBlock.focus();
1046}
1047
1048function deleteBlock(index) {
1049 const block = editorState.blocks[index];
1050 block.element.remove();
1051 editorState.blocks.splice(index, 1);
1052
1053 // Focus previous or next block
1054 const focusIndex = Math.max(0, index - 1);
1055 if (editorState.blocks[focusIndex]) {
1056 editorState.blocks[focusIndex].element.focus();
1057 }
1058}
1059
1060function mergeWithPrevious(index) {
1061 if (index === 0) return;
1062
1063 const current = editorState.blocks[index];
1064 const previous = editorState.blocks[index - 1];
1065
1066 // Only merge text blocks
1067 if (current.type === "codeBlock" || previous.type === "codeBlock") return;
1068
1069 const prevLength = previous.element.textContent.length;
1070 previous.element.innerHTML += current.element.innerHTML;
1071 current.element.remove();
1072 editorState.blocks.splice(index, 1);
1073
1074 // Set cursor at merge point
1075 previous.element.focus();
1076 const range = document.createRange();
1077 const sel = window.getSelection();
1078 const textNode = findTextNodeAtOffset(previous.element, prevLength);
1079 if (textNode) {
1080 range.setStart(textNode.node, textNode.offset);
1081 range.collapse(true);
1082 sel.removeAllRanges();
1083 sel.addRange(range);
1084 }
1085}
1086
1087function findTextNodeAtOffset(element, targetOffset) {
1088 let offset = 0;
1089 const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
1090 let node;
1091 while ((node = walker.nextNode())) {
1092 const len = node.textContent.length;
1093 if (offset + len >= targetOffset) {
1094 return { node, offset: targetOffset - offset };
1095 }
1096 offset += len;
1097 }
1098 return null;
1099}
1100
1101function focusPreviousBlock(current) {
1102 const index = editorState.blocks.indexOf(current);
1103 if (index > 0) {
1104 const prev = editorState.blocks[index - 1];
1105 prev.element.focus();
1106 // Move cursor to end
1107 const range = document.createRange();
1108 range.selectNodeContents(prev.element);
1109 range.collapse(false);
1110 const sel = window.getSelection();
1111 sel.removeAllRanges();
1112 sel.addRange(range);
1113 }
1114}
1115
1116function focusNextBlock(current) {
1117 const index = editorState.blocks.indexOf(current);
1118 if (index < editorState.blocks.length - 1) {
1119 const next = editorState.blocks[index + 1];
1120 next.element.focus();
1121 // Move cursor to start
1122 const range = document.createRange();
1123 range.selectNodeContents(next.element);
1124 range.collapse(true);
1125 const sel = window.getSelection();
1126 sel.removeAllRanges();
1127 sel.addRange(range);
1128 }
1129}
1130```
1131
1132**Step 3: Commit**
1133
1134```bash
1135git add docs.html
1136git commit -m "feat(docs): implement block keyboard handling"
1137```
1138
1139---
1140
1141## Task 9: Implement Slash Commands
1142
1143**Files:**
1144- Modify: `docs.html`
1145
1146**Step 1: Add slash menu data and functions**
1147
1148```javascript
1149const SLASH_COMMANDS = [
1150 { id: "paragraph", label: "Paragraph", icon: "P", description: "Plain text" },
1151 { id: "heading1", label: "Heading 1", icon: "H1", description: "Large heading" },
1152 { id: "heading2", label: "Heading 2", icon: "H2", description: "Medium heading" },
1153 { id: "heading3", label: "Heading 3", icon: "H3", description: "Small heading" },
1154 { id: "code", label: "Code Block", icon: "</>", description: "Code snippet" },
1155 { id: "quote", label: "Quote", icon: '"', description: "Blockquote" },
1156];
1157
1158function handleBlockInput(e) {
1159 const block = e.target;
1160 const text = block.textContent;
1161
1162 // Check for slash command trigger
1163 if (text === "/") {
1164 openSlashMenu(block);
1165 return;
1166 }
1167
1168 // Filter slash menu if open
1169 if (editorState.slashMenuOpen && text.startsWith("/")) {
1170 const filter = text.slice(1).toLowerCase();
1171 updateSlashMenuFilter(filter);
1172 return;
1173 }
1174
1175 // Close slash menu if text doesn't start with /
1176 if (editorState.slashMenuOpen && !text.startsWith("/")) {
1177 closeSlashMenu();
1178 }
1179
1180 // Check for markdown auto-conversion
1181 checkMarkdownConversion(block);
1182}
1183
1184function openSlashMenu(block) {
1185 const menu = document.getElementById("slash-menu");
1186 const rect = block.getBoundingClientRect();
1187 const containerRect = document.querySelector(".container").getBoundingClientRect();
1188
1189 menu.style.top = `${rect.bottom + window.scrollY + 5}px`;
1190 menu.style.left = `${rect.left - containerRect.left}px`;
1191
1192 editorState.slashMenuOpen = true;
1193 editorState.slashMenuIndex = 0;
1194 editorState.slashMenuBlock = block;
1195 editorState.slashMenuFilter = "";
1196
1197 renderSlashMenu(SLASH_COMMANDS);
1198 menu.classList.remove("hidden");
1199}
1200
1201function closeSlashMenu() {
1202 const menu = document.getElementById("slash-menu");
1203 menu.classList.add("hidden");
1204 editorState.slashMenuOpen = false;
1205 editorState.slashMenuBlock = null;
1206}
1207
1208function renderSlashMenu(commands) {
1209 const menu = document.getElementById("slash-menu");
1210 menu.innerHTML = commands
1211 .map(
1212 (cmd, i) => `
1213 <div class="slash-menu-item${i === editorState.slashMenuIndex ? " selected" : ""}"
1214 data-command="${cmd.id}"
1215 onclick="executeSlashCommand('${cmd.id}')">
1216 <span class="icon">${cmd.icon}</span>
1217 <span>${cmd.label}</span>
1218 </div>
1219 `
1220 )
1221 .join("");
1222}
1223
1224function updateSlashMenuFilter(filter) {
1225 const filtered = SLASH_COMMANDS.filter(
1226 cmd =>
1227 cmd.label.toLowerCase().includes(filter) ||
1228 cmd.description.toLowerCase().includes(filter)
1229 );
1230 editorState.slashMenuIndex = 0;
1231 renderSlashMenu(filtered);
1232
1233 if (filtered.length === 0) {
1234 closeSlashMenu();
1235 }
1236}
1237
1238function navigateSlashMenu(direction) {
1239 const menu = document.getElementById("slash-menu");
1240 const items = menu.querySelectorAll(".slash-menu-item");
1241 editorState.slashMenuIndex = Math.max(
1242 0,
1243 Math.min(items.length - 1, editorState.slashMenuIndex + direction)
1244 );
1245 items.forEach((item, i) => {
1246 item.classList.toggle("selected", i === editorState.slashMenuIndex);
1247 });
1248}
1249
1250function selectSlashMenuItem() {
1251 const menu = document.getElementById("slash-menu");
1252 const items = menu.querySelectorAll(".slash-menu-item");
1253 const selected = items[editorState.slashMenuIndex];
1254 if (selected) {
1255 executeSlashCommand(selected.dataset.command);
1256 }
1257}
1258
1259function executeSlashCommand(commandId) {
1260 const block = editorState.slashMenuBlock;
1261 if (!block) return;
1262
1263 const blockData = editorState.blocks.find(b => b.element === block);
1264 if (!blockData) return;
1265
1266 closeSlashMenu();
1267
1268 // Clear the slash text
1269 block.textContent = "";
1270
1271 // Convert block to new type
1272 if (commandId === "paragraph") {
1273 convertBlock(blockData, "paragraph");
1274 } else if (commandId.startsWith("heading")) {
1275 const level = parseInt(commandId.replace("heading", ""));
1276 convertBlock(blockData, "heading", level);
1277 } else if (commandId === "code") {
1278 convertBlock(blockData, "codeBlock");
1279 } else if (commandId === "quote") {
1280 convertBlock(blockData, "quote");
1281 }
1282
1283 block.focus();
1284}
1285
1286function convertBlock(blockData, newType, level = 1) {
1287 const block = blockData.element;
1288 const content = block.innerHTML;
1289
1290 block.className = `block ${newType}${newType === "heading" ? `-${level}` : ""}`;
1291 block.dataset.type = newType;
1292
1293 if (newType === "paragraph") {
1294 block.dataset.placeholder = "Type '/' for commands...";
1295 delete block.dataset.level;
1296 } else if (newType === "heading") {
1297 block.dataset.level = level;
1298 delete block.dataset.placeholder;
1299 } else if (newType === "codeBlock") {
1300 block.textContent = block.textContent; // Strip HTML
1301 block.spellcheck = false;
1302 delete block.dataset.placeholder;
1303 } else if (newType === "quote") {
1304 delete block.dataset.placeholder;
1305 }
1306
1307 blockData.type = newType;
1308 blockData.level = level;
1309}
1310
1311// Make global for onclick
1312window.executeSlashCommand = executeSlashCommand;
1313```
1314
1315**Step 2: Commit**
1316
1317```bash
1318git add docs.html
1319git commit -m "feat(docs): implement slash commands menu"
1320```
1321
1322---
1323
1324## Task 10: Implement Markdown Auto-Conversion
1325
1326**Files:**
1327- Modify: `docs.html`
1328
1329**Step 1: Add checkMarkdownConversion function**
1330
1331```javascript
1332function checkMarkdownConversion(block) {
1333 const blockData = editorState.blocks.find(b => b.element === block);
1334 if (!blockData || blockData.type === "codeBlock") return;
1335
1336 const selection = window.getSelection();
1337 if (!selection.isCollapsed) return;
1338
1339 const range = selection.getRangeAt(0);
1340 const textNode = range.startContainer;
1341 if (textNode.nodeType !== Node.TEXT_NODE) return;
1342
1343 const text = textNode.textContent;
1344 const cursor = range.startOffset;
1345
1346 // Check for inline code: `text`
1347 if (text[cursor - 1] === "`") {
1348 const before = text.slice(0, cursor - 1);
1349 const openTick = before.lastIndexOf("`");
1350 if (openTick !== -1 && openTick < cursor - 2) {
1351 const codeText = before.slice(openTick + 1);
1352 // Replace with <code> tag
1353 const beforeCode = text.slice(0, openTick);
1354 const afterCode = text.slice(cursor);
1355
1356 const parent = textNode.parentNode;
1357 const frag = document.createDocumentFragment();
1358
1359 if (beforeCode) frag.appendChild(document.createTextNode(beforeCode));
1360
1361 const codeEl = document.createElement("code");
1362 codeEl.textContent = codeText;
1363 frag.appendChild(codeEl);
1364
1365 if (afterCode) frag.appendChild(document.createTextNode(afterCode));
1366
1367 parent.replaceChild(frag, textNode);
1368
1369 // Position cursor after code
1370 const newRange = document.createRange();
1371 newRange.setStartAfter(codeEl);
1372 newRange.collapse(true);
1373 selection.removeAllRanges();
1374 selection.addRange(newRange);
1375 return;
1376 }
1377 }
1378
1379 // Check for bold: **text**
1380 if (text.slice(cursor - 2, cursor) === "**") {
1381 const before = text.slice(0, cursor - 2);
1382 const openBold = before.lastIndexOf("**");
1383 if (openBold !== -1 && openBold < cursor - 4) {
1384 const boldText = before.slice(openBold + 2);
1385 applyInlineConversion(textNode, openBold, cursor, boldText, "strong");
1386 return;
1387 }
1388 }
1389
1390 // Check for italic: *text* (but not **)
1391 if (text[cursor - 1] === "*" && text[cursor - 2] !== "*") {
1392 const before = text.slice(0, cursor - 1);
1393 // Find opening * that's not part of **
1394 let openItalic = -1;
1395 for (let i = before.length - 1; i >= 0; i--) {
1396 if (before[i] === "*" && before[i - 1] !== "*" && before[i + 1] !== "*") {
1397 openItalic = i;
1398 break;
1399 }
1400 }
1401 if (openItalic !== -1 && openItalic < cursor - 2) {
1402 const italicText = before.slice(openItalic + 1);
1403 applyInlineConversion(textNode, openItalic, cursor, italicText, "em");
1404 return;
1405 }
1406 }
1407}
1408
1409function applyInlineConversion(textNode, start, end, content, tagName) {
1410 const text = textNode.textContent;
1411 const beforeText = text.slice(0, start);
1412 const afterText = text.slice(end);
1413
1414 const parent = textNode.parentNode;
1415 const frag = document.createDocumentFragment();
1416
1417 if (beforeText) frag.appendChild(document.createTextNode(beforeText));
1418
1419 const el = document.createElement(tagName);
1420 el.textContent = content;
1421 frag.appendChild(el);
1422
1423 if (afterText) frag.appendChild(document.createTextNode(afterText));
1424
1425 parent.replaceChild(frag, textNode);
1426
1427 // Position cursor after element
1428 const selection = window.getSelection();
1429 const newRange = document.createRange();
1430 newRange.setStartAfter(el);
1431 newRange.collapse(true);
1432 selection.removeAllRanges();
1433 selection.addRange(newRange);
1434}
1435```
1436
1437**Step 2: Commit**
1438
1439```bash
1440git add docs.html
1441git commit -m "feat(docs): implement markdown auto-conversion"
1442```
1443
1444---
1445
1446## Task 11: Implement Form Submission with Blocks
1447
1448**Files:**
1449- Modify: `docs.html`
1450
1451**Step 1: Update handleSubmit to extract blocks**
1452
1453Replace the `handleSubmit` function:
1454
1455```javascript
1456async function handleSubmit(event) {
1457 event.preventDefault();
1458
1459 const form = event.target;
1460 const title = form.title.value.trim();
1461 const slug = form.slug.value.trim().toLowerCase();
1462
1463 // Extract blocks from editor
1464 const blocks = editorState.blocks.map(blockData => {
1465 const el = blockData.element;
1466
1467 if (blockData.type === "codeBlock") {
1468 return {
1469 type: "codeBlock",
1470 code: el.textContent,
1471 lang: el.dataset.lang || "",
1472 };
1473 }
1474
1475 // Extract text and facets from contenteditable
1476 const { text, facets } = domToFacets(el);
1477
1478 if (blockData.type === "heading") {
1479 return {
1480 type: "heading",
1481 level: parseInt(el.dataset.level) || 1,
1482 text,
1483 facets,
1484 };
1485 } else if (blockData.type === "quote") {
1486 return { type: "quote", text, facets };
1487 } else {
1488 return { type: "paragraph", text, facets };
1489 }
1490 }).filter(b => b.text || b.code); // Remove empty blocks
1491
1492 // Ensure at least one block
1493 if (blocks.length === 0) {
1494 blocks.push({ type: "paragraph", text: "", facets: [] });
1495 }
1496
1497 try {
1498 if (state.view === "edit" && state.currentDoc) {
1499 await updateDocument(state.currentDoc.uri, title, slug, blocks);
1500 } else {
1501 await createDocument(title, slug, blocks);
1502 }
1503 } catch (err) {
1504 alert("Error: " + err.message);
1505 }
1506}
1507```
1508
1509**Step 2: Commit**
1510
1511```bash
1512git add docs.html
1513git commit -m "feat(docs): extract blocks from editor on submit"
1514```
1515
1516---
1517
1518## Task 12: Add Formatting Helpers
1519
1520**Files:**
1521- Modify: `docs.html`
1522
1523**Step 1: Add wrapSelectionWithTag and insertLink functions**
1524
1525```javascript
1526function wrapSelectionWithTag(tagName) {
1527 const selection = window.getSelection();
1528 if (selection.isCollapsed) return;
1529
1530 const range = selection.getRangeAt(0);
1531 const selectedText = range.toString();
1532
1533 const el = document.createElement(tagName);
1534 el.textContent = selectedText;
1535
1536 range.deleteContents();
1537 range.insertNode(el);
1538
1539 // Move cursor after element
1540 range.setStartAfter(el);
1541 range.collapse(true);
1542 selection.removeAllRanges();
1543 selection.addRange(range);
1544}
1545
1546function insertLink() {
1547 const selection = window.getSelection();
1548 if (selection.isCollapsed) return;
1549
1550 const url = prompt("Enter URL:");
1551 if (!url) return;
1552
1553 const range = selection.getRangeAt(0);
1554 const selectedText = range.toString();
1555
1556 const a = document.createElement("a");
1557 a.href = url;
1558 a.className = "facet-link";
1559 a.textContent = selectedText;
1560
1561 range.deleteContents();
1562 range.insertNode(a);
1563
1564 range.setStartAfter(a);
1565 range.collapse(true);
1566 selection.removeAllRanges();
1567 selection.addRange(range);
1568}
1569
1570function handleBlockPaste(e) {
1571 e.preventDefault();
1572 const text = e.clipboardData.getData("text/plain");
1573 document.execCommand("insertText", false, text);
1574}
1575```
1576
1577**Step 2: Commit**
1578
1579```bash
1580git add docs.html
1581git commit -m "feat(docs): add formatting helpers and paste handling"
1582```
1583
1584---
1585
1586## Task 13: Manual Testing
1587
1588**Steps:**
1589
15901. **Start local server:**
1591 ```bash
1592 cd /Users/chadmiller/code/tools && python3 -m http.server 8000
1593 ```
1594
15952. **Test block creation:**
1596 - Open http://localhost:8000/docs.html
1597 - Login
1598 - Click "New Document"
1599 - Verify empty paragraph with placeholder appears
1600
16013. **Test slash commands:**
1602 - Type `/` - verify menu appears
1603 - Type `/head` - verify filtering
1604 - Select "Heading 1" - verify block converts
1605
16064. **Test inline formatting:**
1607 - Type `**bold**` - verify converts to bold
1608 - Type `*italic*` - verify converts to italic
1609 - Type `` `code` `` - verify converts to code
1610 - Select text, press Cmd+B - verify bold
1611
16125. **Test block navigation:**
1613 - Press Enter - verify new paragraph
1614 - Press Backspace in empty block - verify deletion
1615 - Use arrow keys at block edges - verify navigation
1616
16176. **Test save and reload:**
1618 - Create document with mixed blocks
1619 - Save
1620 - View document - verify rendering
1621 - Edit document - verify blocks load correctly
1622
1623---
1624
1625## Task 14: Final Commit
1626
1627**Step 1: Review all changes**
1628
1629```bash
1630git status
1631git diff --staged
1632```
1633
1634**Step 2: Final commit if any loose changes**
1635
1636```bash
1637git add -A
1638git commit -m "feat(docs): complete inline block editor implementation"
1639```
1640
1641---
1642
1643**Plan complete and saved to `docs/plans/2025-12-20-inline-block-editor.md`.**
1644
1645Two execution options:
1646
1647**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
1648
1649**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
1650
1651Which approach?