Tools for the Atmosphere tools.slices.network
quickslice atproto html
at main 1651 lines 44 kB view raw view rendered
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, "&amp;") 230 .replace(/</g, "&lt;") 231 .replace(/>/g, "&gt;") 232 .replace(/"/g, "&quot;"); 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?