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

feat(docs): add image embed blocks with upload, resize, and alt text

Add imageEmbed block type to the docs editor with full support for:

- /image slash command to create image placeholder blocks
- Upload via file picker, drag-drop, or paste
- Auto-resize images to fit under 1MB using Canvas API
- Alt text input always visible below image (like code block lang selector)
- ALT pill with popover in read-only view mode
- Proper save/load via GraphQL with blob upload mutation

Technical changes:
- Add imageEmbed to document lexicon with blob image and alt text fields
- Add image resize utilities (readFileAsDataURL, resizeImage, etc.)
- Add blob:image/* to OAuth scope for image uploads
- Consolidate heading sizes into CSS variables for view/edit consistency
- Fix view mode spacing to match edit mode

Keyboard navigation:
- Click image to focus block, click alt input to edit
- Enter/ArrowDown on image creates new paragraph after
- Escape in alt input focuses block for navigation

+1750 -10
+702 -9
docs.html
··· 29 29 --font-mono: "SF Mono", Monaco, Consolas, monospace; 30 30 --font-size-ui: 0.875rem; 31 31 --font-size-code: 0.875rem; 32 + --font-size-h1: 1.75rem; 33 + --font-size-h2: 1.5rem; 34 + --font-size-h3: 1.25rem; 32 35 } 33 36 34 37 @media (prefers-color-scheme: light) { ··· 226 229 227 230 .block-editor .block.heading-1 { 228 231 font-family: var(--font-ui); 229 - font-size: 1.75rem; 232 + font-size: var(--font-size-h1); 230 233 font-weight: 600; 231 234 } 232 235 233 236 .block-editor .block.heading-2 { 234 237 font-family: var(--font-ui); 235 - font-size: 1.5rem; 238 + font-size: var(--font-size-h2); 236 239 font-weight: 600; 237 240 } 238 241 239 242 .block-editor .block.heading-3 { 240 243 font-family: var(--font-ui); 241 - font-size: 1.25rem; 244 + font-size: var(--font-size-h3); 242 245 font-weight: 600; 243 246 } 244 247 ··· 312 315 opacity: 0.95; 313 316 } 314 317 318 + .block-editor .block.imageEmbed { 319 + padding: 0; 320 + min-height: auto; 321 + } 322 + 323 + .block-editor .block.imageEmbed.placeholder { 324 + border: 2px dashed var(--border); 325 + border-radius: 8px; 326 + padding: 2rem; 327 + text-align: center; 328 + color: var(--text-muted); 329 + cursor: pointer; 330 + font-family: var(--font-ui); 331 + font-size: 0.875rem; 332 + transition: border-color 0.15s, background 0.15s; 333 + } 334 + 335 + .block-editor .block.imageEmbed.placeholder:hover, 336 + .block-editor .block.imageEmbed.placeholder.dragover { 337 + border-color: var(--accent); 338 + background: var(--bg-secondary); 339 + } 340 + 341 + .block-editor .block.imageEmbed.placeholder .upload-icon { 342 + font-size: 2rem; 343 + margin-bottom: 0.5rem; 344 + display: block; 345 + } 346 + 347 + .block-editor .block.imageEmbed.loading { 348 + padding: 2rem; 349 + text-align: center; 350 + color: var(--text-muted); 351 + font-family: var(--font-ui); 352 + font-size: 0.875rem; 353 + } 354 + 355 + .block-editor .block.imageEmbed img { 356 + max-width: 100%; 357 + height: auto; 358 + border-radius: 6px; 359 + display: block; 360 + cursor: pointer; 361 + } 362 + 363 + .block-editor .block.imageEmbed .alt-editor { 364 + margin-top: 0.5rem; 365 + } 366 + 367 + .block-editor .block.imageEmbed .alt-editor input { 368 + width: 100%; 369 + padding: 0.375rem 0.5rem; 370 + font-family: var(--font-ui); 371 + font-size: 0.8125rem; 372 + background: var(--bg-secondary); 373 + border: 1px solid var(--border); 374 + border-radius: 4px; 375 + color: var(--text); 376 + } 377 + 378 + .block-editor .block.imageEmbed .alt-editor input:focus { 379 + outline: none; 380 + border-color: var(--accent); 381 + } 382 + 383 + .block-editor .block.imageEmbed .alt-editor input::placeholder { 384 + color: var(--text-muted); 385 + } 386 + 387 + .block-editor .block.imageEmbed .error-message { 388 + color: var(--danger); 389 + font-size: 0.875rem; 390 + margin-top: 0.5rem; 391 + } 392 + 315 393 /* Focus style for non-editable blocks (embeds) */ 316 394 .block-editor .block[tabindex="0"]:focus { 317 395 outline: 2px solid var(--accent); ··· 573 651 } 574 652 575 653 .doc-view .body { 576 - white-space: pre-wrap; 654 + } 655 + 656 + .doc-view .body p, 657 + .doc-view .body h2, 658 + .doc-view .body h3, 659 + .doc-view .body h4, 660 + .doc-view .body blockquote, 661 + .doc-view .body pre { 662 + margin: 1rem 0; 663 + } 664 + 665 + .doc-view .body h2 { 666 + font-family: var(--font-ui); 667 + font-size: var(--font-size-h1); 668 + font-weight: 600; 669 + } 670 + 671 + .doc-view .body h3 { 672 + font-family: var(--font-ui); 673 + font-size: var(--font-size-h2); 674 + font-weight: 600; 675 + } 676 + 677 + .doc-view .body h4 { 678 + font-family: var(--font-ui); 679 + font-size: var(--font-size-h3); 680 + font-weight: 600; 577 681 } 578 682 579 683 .doc-view .body qs-tangled-repo-card { 580 684 display: block; 581 685 margin: 1rem 0; 686 + } 687 + 688 + .doc-view .body .image-embed { 689 + margin: 1rem 0; 690 + display: table; 691 + } 692 + 693 + .doc-view .body .image-embed .image-wrapper { 694 + position: relative; 695 + display: table; 696 + } 697 + 698 + .doc-view .body .image-embed img { 699 + max-width: 100%; 700 + height: auto; 701 + border-radius: 6px; 702 + display: block; 703 + } 704 + 705 + .doc-view .body .image-embed .alt-pill { 706 + position: absolute; 707 + bottom: 8px; 708 + right: 8px; 709 + background: rgba(0, 0, 0, 0.7); 710 + color: white; 711 + font-family: var(--font-ui); 712 + font-size: 0.625rem; 713 + font-weight: 600; 714 + padding: 2px 6px; 715 + border-radius: 4px; 716 + cursor: pointer; 717 + text-transform: uppercase; 718 + letter-spacing: 0.5px; 719 + transition: background 0.15s; 720 + } 721 + 722 + .doc-view .body .image-embed .alt-pill:hover { 723 + background: rgba(0, 0, 0, 0.85); 724 + } 725 + 726 + .doc-view .body .image-embed .alt-popover { 727 + display: none; 728 + position: absolute; 729 + bottom: 36px; 730 + right: 8px; 731 + background: var(--bg-secondary); 732 + border: 1px solid var(--border); 733 + border-radius: 6px; 734 + padding: 0.5rem 0.75rem; 735 + font-family: var(--font-ui); 736 + font-size: 0.875rem; 737 + color: var(--text); 738 + max-width: 280px; 739 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 740 + z-index: 10; 741 + } 742 + 743 + .doc-view .body .image-embed .alt-popover.visible { 744 + display: block; 582 745 } 583 746 584 747 .empty-state { ··· 971 1134 addBlock("quote", block.text, facets); 972 1135 } else if (type.endsWith("TangledEmbed")) { 973 1136 addTangledEmbedBlock(block.handle, block.repo); 1137 + } else if (BlockTypes.isImageEmbed(type)) { 1138 + addImageBlock(block.image, block.alt || ""); 974 1139 } 975 1140 } 976 1141 } ··· 982 1147 } else if (editorState.blocks.length > 1) { 983 1148 editorState.blocks[1].element.focus(); 984 1149 } 1150 + 1151 + // Setup paste handler for images 1152 + setupEditorPasteHandler(); 985 1153 } 986 1154 987 1155 function addBlock(type, text = "", facets = null, focus = false, level = 1, lang = "") { ··· 1142 1310 return div; 1143 1311 } 1144 1312 1313 + function addImageBlock(imageData = null, alt = "", focus = false) { 1314 + // imageData can be: 1315 + // - { url: "..." } from GraphQL query (loading existing) 1316 + // - { blobRef: {...} } from upload (newly uploaded, need to construct URL) 1317 + // - null for placeholder 1318 + const editor = document.getElementById("block-editor"); 1319 + const id = generateBlockId(); 1320 + 1321 + const div = document.createElement("div"); 1322 + div.id = id; 1323 + div.className = "block imageEmbed" + (imageData ? "" : " placeholder"); 1324 + div.dataset.type = "imageEmbed"; 1325 + div.contentEditable = "false"; 1326 + div.tabIndex = 0; 1327 + 1328 + if (imageData) { 1329 + // Display mode with image 1330 + let imgUrl; 1331 + if (imageData.url) { 1332 + // From GraphQL query - has url, ref, mimeType, size 1333 + imgUrl = imageData.url; 1334 + // Store as blobRef for re-saving 1335 + const blobRef = { 1336 + $type: "blob", 1337 + ref: { $link: imageData.ref }, 1338 + mimeType: imageData.mimeType, 1339 + size: imageData.size, 1340 + }; 1341 + div.dataset.blobRef = JSON.stringify(blobRef); 1342 + } else if (imageData.blobRef) { 1343 + // From upload - store blobRef for saving 1344 + div.dataset.blobRef = JSON.stringify(imageData.blobRef); 1345 + imgUrl = imageData.dataUrl || `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${state.viewer.did}&cid=${imageData.blobRef.ref.$link}`; 1346 + } 1347 + div.dataset.alt = alt; 1348 + div.innerHTML = ` 1349 + <img src="${imgUrl}" alt="${esc(alt)}" title="${esc(alt || "Add alt text")}" /> 1350 + <div class="alt-editor"> 1351 + <input type="text" value="${esc(alt)}" placeholder="Alt text for accessibility" /> 1352 + </div> 1353 + `; 1354 + setupImageBlockEvents(div); 1355 + } else { 1356 + // Placeholder mode 1357 + div.innerHTML = ` 1358 + <span class="upload-icon">🖼</span> 1359 + <span>Click to upload or drop image here</span> 1360 + <input type="file" accept="image/*" style="display: none;" /> 1361 + `; 1362 + setupImagePlaceholderEvents(div); 1363 + } 1364 + 1365 + editor.appendChild(div); 1366 + 1367 + const blockData = { id, type: "imageEmbed", element: div }; 1368 + editorState.blocks.push(blockData); 1369 + editorState.blockMap.set(div, blockData); 1370 + 1371 + if (focus) { 1372 + div.focus(); 1373 + } 1374 + 1375 + return div; 1376 + } 1377 + 1378 + async function uploadImage(file, block) { 1379 + const blockData = getBlockData(block); 1380 + if (!blockData) return; 1381 + 1382 + // Show loading state 1383 + block.className = "block imageEmbed loading"; 1384 + block.innerHTML = "Processing image..."; 1385 + 1386 + try { 1387 + // Read and resize image 1388 + const dataUrl = await readFileAsDataURL(file); 1389 + const resized = await resizeImage(dataUrl, { 1390 + width: 2000, 1391 + height: 2000, 1392 + maxSize: 900000, 1393 + mode: "contain", 1394 + }); 1395 + 1396 + block.innerHTML = "Uploading..."; 1397 + 1398 + // Upload via GraphQL mutation 1399 + const base64Data = resized.dataUrl.split(",")[1]; 1400 + const uploadResult = await gqlMutation(UPLOAD_BLOB_MUTATION, { 1401 + data: base64Data, 1402 + mimeType: "image/jpeg", 1403 + }); 1404 + 1405 + if (!uploadResult.uploadBlob) { 1406 + throw new Error("Upload failed"); 1407 + } 1408 + 1409 + const blobRef = { 1410 + $type: "blob", 1411 + ref: { $link: uploadResult.uploadBlob.ref }, 1412 + mimeType: uploadResult.uploadBlob.mimeType, 1413 + size: uploadResult.uploadBlob.size, 1414 + }; 1415 + 1416 + // Update block to display mode 1417 + block.className = "block imageEmbed"; 1418 + block.dataset.blobRef = JSON.stringify(blobRef); 1419 + block.dataset.alt = ""; 1420 + 1421 + // Use the resized dataUrl for immediate preview 1422 + // The proper URL will come from GraphQL after save/reload 1423 + const imgUrl = resized.dataUrl; 1424 + block.innerHTML = ` 1425 + <img src="${imgUrl}" alt="" title="Add alt text" /> 1426 + <div class="alt-editor"> 1427 + <input type="text" value="" placeholder="Alt text for accessibility" /> 1428 + </div> 1429 + `; 1430 + setupImageBlockEvents(block); 1431 + triggerAutoSave(); 1432 + 1433 + } catch (err) { 1434 + console.error("Image upload failed:", err); 1435 + block.className = "block imageEmbed placeholder"; 1436 + block.innerHTML = ` 1437 + <span class="upload-icon">🖼</span> 1438 + <span>Click to upload or drop image here</span> 1439 + <div class="error-message">Upload failed: ${esc(err.message)}</div> 1440 + <input type="file" accept="image/*" style="display: none;" /> 1441 + `; 1442 + setupImagePlaceholderEvents(block); 1443 + } 1444 + } 1445 + 1446 + function setupImagePlaceholderEvents(block) { 1447 + const fileInput = block.querySelector('input[type="file"]'); 1448 + 1449 + // Click to open file picker (only if still a placeholder) 1450 + block.addEventListener("click", (e) => { 1451 + if (!block.classList.contains("placeholder")) return; 1452 + if (e.target === fileInput) return; 1453 + fileInput?.click(); 1454 + }); 1455 + 1456 + // File selected 1457 + fileInput?.addEventListener("change", (e) => { 1458 + const file = e.target.files?.[0]; 1459 + if (file && file.type.startsWith("image/")) { 1460 + uploadImage(file, block); 1461 + } 1462 + }); 1463 + 1464 + // Drag and drop 1465 + block.addEventListener("dragover", (e) => { 1466 + e.preventDefault(); 1467 + block.classList.add("dragover"); 1468 + }); 1469 + 1470 + block.addEventListener("dragleave", (e) => { 1471 + e.preventDefault(); 1472 + block.classList.remove("dragover"); 1473 + }); 1474 + 1475 + block.addEventListener("drop", (e) => { 1476 + e.preventDefault(); 1477 + block.classList.remove("dragover"); 1478 + const file = e.dataTransfer?.files?.[0]; 1479 + if (file && file.type.startsWith("image/")) { 1480 + uploadImage(file, block); 1481 + } 1482 + }); 1483 + 1484 + // Keyboard handling 1485 + block.addEventListener("keydown", (e) => { 1486 + handleImageBlockKeydown(e, block); 1487 + }); 1488 + } 1489 + 1490 + function setupImageBlockEvents(block) { 1491 + const img = block.querySelector("img"); 1492 + const altInput = block.querySelector(".alt-editor input"); 1493 + 1494 + // Click block or image to focus block (but not if clicking alt input) 1495 + block.addEventListener("click", (e) => { 1496 + if (e.target === altInput) return; 1497 + block.focus(); 1498 + }); 1499 + 1500 + // Alt input events 1501 + altInput?.addEventListener("input", (e) => { 1502 + const alt = e.target.value; 1503 + block.dataset.alt = alt; 1504 + if (img) { 1505 + img.alt = alt; 1506 + img.title = alt || "Add alt text"; 1507 + } 1508 + triggerAutoSave(); 1509 + }); 1510 + 1511 + altInput?.addEventListener("keydown", (e) => { 1512 + if (e.key === "Escape") { 1513 + e.preventDefault(); 1514 + block.focus(); 1515 + } else if (e.key === "Enter") { 1516 + e.preventDefault(); 1517 + // Move to next block or create new one 1518 + const blockData = getBlockData(block); 1519 + const blockIndex = editorState.blocks.indexOf(blockData); 1520 + if (blockIndex < editorState.blocks.length - 1) { 1521 + const nextBlock = editorState.blocks[blockIndex + 1]; 1522 + const focusTarget = nextBlock.type === "codeBlock" 1523 + ? nextBlock.element._codeContent || nextBlock.element 1524 + : nextBlock.element; 1525 + focusTarget.focus(); 1526 + } else { 1527 + const newBlock = insertBlockAfter(blockIndex, "paragraph"); 1528 + newBlock.focus(); 1529 + } 1530 + } 1531 + }); 1532 + 1533 + // Block keyboard handling 1534 + block.addEventListener("keydown", (e) => { 1535 + // Don't handle if alt input is focused 1536 + if (document.activeElement === altInput) return; 1537 + handleImageBlockKeydown(e, block); 1538 + }); 1539 + } 1540 + 1541 + function handleImageBlockKeydown(e, block) { 1542 + const blockData = getBlockData(block); 1543 + if (!blockData) return; 1544 + 1545 + const blockIndex = editorState.blocks.indexOf(blockData); 1546 + 1547 + if (e.key === "Enter") { 1548 + e.preventDefault(); 1549 + const newBlock = insertBlockAfter(blockIndex, "paragraph"); 1550 + newBlock.focus(); 1551 + } else if (e.key === "Backspace" || e.key === "Delete") { 1552 + e.preventDefault(); 1553 + // Focus previous block before deleting 1554 + if (blockIndex > 0) { 1555 + const prevBlock = editorState.blocks[blockIndex - 1]; 1556 + const focusTarget = prevBlock.type === "codeBlock" 1557 + ? prevBlock.element._codeContent || prevBlock.element 1558 + : prevBlock.element; 1559 + focusTarget.focus(); 1560 + } 1561 + deleteBlock(blockIndex); 1562 + } else if (e.key === "ArrowUp") { 1563 + e.preventDefault(); 1564 + if (blockIndex > 0) { 1565 + const prevBlock = editorState.blocks[blockIndex - 1]; 1566 + const focusTarget = prevBlock.type === "codeBlock" 1567 + ? prevBlock.element._codeContent || prevBlock.element 1568 + : prevBlock.element; 1569 + focusTarget.focus(); 1570 + } 1571 + } else if (e.key === "ArrowDown") { 1572 + e.preventDefault(); 1573 + if (blockIndex < editorState.blocks.length - 1) { 1574 + const nextBlock = editorState.blocks[blockIndex + 1]; 1575 + const focusTarget = nextBlock.type === "codeBlock" 1576 + ? nextBlock.element._codeContent || nextBlock.element 1577 + : nextBlock.element; 1578 + focusTarget.focus(); 1579 + } else { 1580 + // Last block - create new paragraph 1581 + const newBlock = insertBlockAfter(blockIndex, "paragraph"); 1582 + newBlock.focus(); 1583 + } 1584 + } 1585 + } 1586 + 1587 + function setupEditorPasteHandler() { 1588 + const editor = document.getElementById("block-editor"); 1589 + if (!editor) return; 1590 + 1591 + editor.addEventListener("paste", async (e) => { 1592 + const items = e.clipboardData?.items; 1593 + if (!items) return; 1594 + 1595 + for (const item of items) { 1596 + if (item.type.startsWith("image/")) { 1597 + e.preventDefault(); 1598 + const file = item.getAsFile(); 1599 + if (!file) continue; 1600 + 1601 + // Get current focused block or create new one 1602 + const focusedBlock = document.activeElement?.closest(".block"); 1603 + const focusedBlockData = focusedBlock ? getBlockData(focusedBlock) : null; 1604 + 1605 + let imageBlock; 1606 + if (focusedBlockData && focusedBlockData.type === "imageEmbed" && focusedBlock.classList.contains("placeholder")) { 1607 + // Paste into existing placeholder 1608 + imageBlock = focusedBlock; 1609 + } else { 1610 + // Insert new image block after current 1611 + const blockIndex = focusedBlockData 1612 + ? editorState.blocks.indexOf(focusedBlockData) 1613 + : editorState.blocks.length - 1; 1614 + 1615 + imageBlock = insertImageBlockAfter(blockIndex); 1616 + } 1617 + 1618 + uploadImage(file, imageBlock); 1619 + break; 1620 + } 1621 + } 1622 + }); 1623 + } 1624 + 1625 + function insertImageBlockAfter(index) { 1626 + const editor = document.getElementById("block-editor"); 1627 + const id = generateBlockId(); 1628 + 1629 + const div = document.createElement("div"); 1630 + div.id = id; 1631 + div.className = "block imageEmbed placeholder"; 1632 + div.dataset.type = "imageEmbed"; 1633 + div.contentEditable = "false"; 1634 + div.tabIndex = 0; 1635 + div.innerHTML = ` 1636 + <span class="upload-icon">🖼</span> 1637 + <span>Click to upload or drop image here</span> 1638 + <input type="file" accept="image/*" style="display: none;" /> 1639 + `; 1640 + 1641 + const afterBlock = editorState.blocks[index]?.element; 1642 + if (afterBlock?.nextSibling) { 1643 + editor.insertBefore(div, afterBlock.nextSibling); 1644 + } else { 1645 + editor.appendChild(div); 1646 + } 1647 + 1648 + const blockData = { id, type: "imageEmbed", element: div }; 1649 + editorState.blocks.splice(index + 1, 0, blockData); 1650 + editorState.blockMap.set(div, blockData); 1651 + 1652 + setupImagePlaceholderEvents(div); 1653 + return div; 1654 + } 1655 + 1145 1656 function handleBlockKeydown(e) { 1146 1657 // Get the block element - if event is from code-content, find parent block 1147 1658 let block = e.currentTarget; ··· 1558 2069 { id: "heading3", label: "Heading 3", icon: "H3", description: "Small heading" }, 1559 2070 { id: "code", label: "Code Block", icon: "</>", description: "Code snippet" }, 1560 2071 { id: "quote", label: "Quote", icon: '"', description: "Blockquote" }, 2072 + { id: "image", label: "Image", icon: "🖼", description: "Upload an image" }, 1561 2073 { id: "tangled", label: "Tangled Repo", icon: "🐑", description: "Embed a repo card" }, 1562 2074 ]; 1563 2075 ··· 1741 2253 convertBlock(blockData, "tangledEmbed"); 1742 2254 block.dataset.editing = "true"; 1743 2255 block.dataset.placeholder = "handle/repo"; 2256 + } else if (commandId === "image") { 2257 + // Convert to image block 2258 + const blockIndex = editorState.blocks.indexOf(blockData); 2259 + const newImageBlock = document.createElement("div"); 2260 + newImageBlock.id = blockData.id; 2261 + newImageBlock.className = "block imageEmbed placeholder"; 2262 + newImageBlock.dataset.type = "imageEmbed"; 2263 + newImageBlock.contentEditable = "false"; 2264 + newImageBlock.tabIndex = 0; 2265 + newImageBlock.innerHTML = ` 2266 + <span class="upload-icon">🖼</span> 2267 + <span>Click to upload or drop image here</span> 2268 + <input type="file" accept="image/*" style="display: none;" /> 2269 + `; 2270 + 2271 + block.replaceWith(newImageBlock); 2272 + blockData.element = newImageBlock; 2273 + blockData.type = "imageEmbed"; 2274 + editorState.blockMap.delete(block); 2275 + editorState.blockMap.set(newImageBlock, blockData); 2276 + 2277 + setupImagePlaceholderEvents(newImageBlock); 2278 + newImageBlock.focus(); 2279 + 2280 + // Auto-open file picker 2281 + setTimeout(() => { 2282 + newImageBlock.querySelector('input[type="file"]')?.click(); 2283 + }, 100); 2284 + return; 1744 2285 } 1745 2286 1746 2287 // Focus the appropriate element (code-content for code blocks) ··· 2263 2804 state.client = await QuicksliceClient.createQuicksliceClient({ 2264 2805 server: SERVER_URL, 2265 2806 clientId: CLIENT_ID, 2266 - scope: "atproto repo:network.slices.tools.document", 2807 + scope: "atproto repo:network.slices.tools.document blob:image/*", 2267 2808 }); 2268 2809 } 2269 2810 return state.client; ··· 2383 2924 .substring(0, 100); // Limit length 2384 2925 } 2385 2926 2927 + // Image resize utilities 2928 + function readFileAsDataURL(file) { 2929 + return new Promise((resolve, reject) => { 2930 + const reader = new FileReader(); 2931 + reader.onload = () => resolve(reader.result); 2932 + reader.onerror = reject; 2933 + reader.readAsDataURL(file); 2934 + }); 2935 + } 2936 + 2937 + function getDataUrlSize(dataUrl) { 2938 + const base64 = dataUrl.split(",")[1]; 2939 + return Math.ceil((base64.length * 3) / 4); 2940 + } 2941 + 2942 + function createResizedImage(dataUrl, options) { 2943 + return new Promise((resolve, reject) => { 2944 + const img = new Image(); 2945 + img.onload = () => { 2946 + let scale; 2947 + if (options.mode === "cover") { 2948 + scale = Math.max(options.width / img.width, options.height / img.height); 2949 + } else if (options.mode === "contain") { 2950 + scale = Math.min(options.width / img.width, options.height / img.height); 2951 + } else { 2952 + scale = 1; 2953 + } 2954 + 2955 + // Don't upscale 2956 + scale = Math.min(scale, 1); 2957 + 2958 + const w = Math.round(img.width * scale); 2959 + const h = Math.round(img.height * scale); 2960 + 2961 + const canvas = document.createElement("canvas"); 2962 + canvas.width = w; 2963 + canvas.height = h; 2964 + 2965 + const ctx = canvas.getContext("2d"); 2966 + if (!ctx) return reject(new Error("Failed to get canvas context")); 2967 + 2968 + ctx.fillStyle = "#fff"; 2969 + ctx.fillRect(0, 0, w, h); 2970 + ctx.imageSmoothingEnabled = true; 2971 + ctx.imageSmoothingQuality = "high"; 2972 + ctx.drawImage(img, 0, 0, w, h); 2973 + 2974 + resolve({ 2975 + dataUrl: canvas.toDataURL("image/jpeg", options.quality), 2976 + width: w, 2977 + height: h, 2978 + }); 2979 + }; 2980 + img.onerror = (e) => reject(e); 2981 + img.src = dataUrl; 2982 + }); 2983 + } 2984 + 2985 + async function resizeImage(dataUrl, opts) { 2986 + // Binary search for optimal quality 2987 + let bestResult = null; 2988 + let minQuality = 0; 2989 + let maxQuality = 101; 2990 + 2991 + while (maxQuality - minQuality > 1) { 2992 + const quality = Math.round((minQuality + maxQuality) / 2); 2993 + const result = await createResizedImage(dataUrl, { 2994 + width: opts.width, 2995 + height: opts.height, 2996 + quality: quality / 100, 2997 + mode: opts.mode, 2998 + }); 2999 + 3000 + const size = getDataUrlSize(result.dataUrl); 3001 + 3002 + if (size < opts.maxSize) { 3003 + minQuality = quality; 3004 + bestResult = result; 3005 + } else { 3006 + maxQuality = quality; 3007 + } 3008 + } 3009 + 3010 + if (!bestResult) { 3011 + throw new Error("Failed to compress image within size limit"); 3012 + } 3013 + 3014 + return bestResult; 3015 + } 3016 + 3017 + function dataURLtoBlob(dataUrl) { 3018 + const arr = dataUrl.split(","); 3019 + const mime = arr[0].match(/:(.*?);/)[1]; 3020 + const bstr = atob(arr[1]); 3021 + let n = bstr.length; 3022 + const u8arr = new Uint8Array(n); 3023 + while (n--) { 3024 + u8arr[n] = bstr.charCodeAt(n); 3025 + } 3026 + return new Blob([u8arr], { type: mime }); 3027 + } 3028 + 2386 3029 // Queries 2387 3030 const DOCUMENTS_QUERY = ` 2388 3031 query GetDocuments($handle: String, $first: Int!, $after: String) { ··· 2421 3064 handle 2422 3065 repo 2423 3066 } 3067 + ... on NetworkSlicesToolsDocumentImageEmbed { 3068 + image { url ref mimeType size } 3069 + alt 3070 + } 2424 3071 } 2425 3072 createdAt 2426 3073 updatedAt ··· 2471 3118 handle 2472 3119 repo 2473 3120 } 3121 + ... on NetworkSlicesToolsDocumentImageEmbed { 3122 + image { url ref mimeType size } 3123 + alt 3124 + } 2474 3125 } 2475 3126 createdAt 2476 3127 updatedAt ··· 2485 3136 } 2486 3137 `; 2487 3138 3139 + const UPLOAD_BLOB_MUTATION = ` 3140 + mutation UploadBlob($data: String!, $mimeType: String!) { 3141 + uploadBlob(data: $data, mimeType: $mimeType) { 3142 + ref 3143 + mimeType 3144 + size 3145 + } 3146 + } 3147 + `; 3148 + 2488 3149 const CREATE_DOCUMENT_MUTATION = ` 2489 3150 mutation CreateDocument($input: NetworkSlicesToolsDocumentInput!) { 2490 3151 createNetworkSlicesToolsDocument(input: $input) { ··· 2546 3207 ... on NetworkSlicesToolsDocumentTangledEmbed { 2547 3208 handle 2548 3209 repo 3210 + } 3211 + ... on NetworkSlicesToolsDocumentImageEmbed { 3212 + image { url ref mimeType size } 3213 + alt 2549 3214 } 2550 3215 } 2551 3216 createdAt ··· 2640 3305 return { ...base, text: block.text, facets: block.facets || [] }; 2641 3306 } else if (block.type === "tangledEmbed") { 2642 3307 return { ...base, handle: block.handle, repo: block.repo }; 3308 + } else if (block.type === "imageEmbed") { 3309 + return { ...base, image: block.image, alt: block.alt || "" }; 2643 3310 } 2644 3311 return base; 2645 3312 } ··· 2749 3416 2750 3417 // Don't save if title is empty/untitled and no content 2751 3418 if (title === "Untitled" || title === "") { 2752 - const hasContent = editorState.blocks.some(b => 2753 - b.type !== "title" && b.element.textContent.trim() 2754 - ); 3419 + const hasContent = editorState.blocks.some(b => { 3420 + if (b.type === "title") return false; 3421 + if (b.type === "imageEmbed") return !!b.element.dataset.blobRef; 3422 + if (b.type === "tangledEmbed") return !!(b.element.dataset.handle && b.element.dataset.repo); 3423 + return b.element.textContent.trim(); 3424 + }); 2755 3425 if (!hasContent) return; 2756 3426 } 2757 3427 ··· 3050 3720 return `<blockquote class="facet-quote">${renderFacetedText(block.text, facets, { escapeHtml: esc })}</blockquote>`; 3051 3721 } else if (type.endsWith("TangledEmbed")) { 3052 3722 return `<qs-tangled-repo-card handle="${esc(block.handle)}" repo="${esc(block.repo)}" instance="${TANGLED_QUICKSLICE_INSTANCE}"></qs-tangled-repo-card>`; 3723 + } else if (BlockTypes.isImageEmbed(type)) { 3724 + const imgUrl = block.image?.url || ""; 3725 + const alt = block.alt || ""; 3726 + const altId = `alt-${Math.random().toString(36).slice(2, 9)}`; 3727 + return `<figure class="image-embed"> 3728 + <div class="image-wrapper"> 3729 + <img src="${esc(imgUrl)}" alt="${esc(alt)}" /> 3730 + ${alt ? `<span class="alt-pill" onclick="document.getElementById('${altId}').classList.toggle('visible')">ALT</span> 3731 + <div id="${altId}" class="alt-popover">${esc(alt)}</div>` : ""} 3732 + </div> 3733 + </figure>`; 3053 3734 } 3054 3735 return ""; 3055 - }).join("\n"); 3736 + }).join(""); 3056 3737 } 3057 3738 3058 3739 // Navigation ··· 3117 3798 const repo = element.dataset.repo || ""; 3118 3799 if (handle && repo) { 3119 3800 blocks.push({ type: "tangledEmbed", handle, repo }); 3801 + } 3802 + } else if (type === "imageEmbed") { 3803 + // Image embed - get blob ref and alt from dataset 3804 + const blobRefStr = element.dataset.blobRef; 3805 + const alt = element.dataset.alt || ""; 3806 + if (blobRefStr) { 3807 + try { 3808 + const image = JSON.parse(blobRefStr); 3809 + blocks.push({ type: "imageEmbed", image, alt }); 3810 + } catch (e) { 3811 + console.error("Failed to parse image blob ref:", e); 3812 + } 3120 3813 } 3121 3814 } else { 3122 3815 // Paragraph, heading, quote - use domToFacets
+1028
docs/plans/2025-12-23-image-embeds.md
··· 1 + # Image Embeds Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add image embed blocks to the docs editor with upload, paste, drag-drop support, auto-resize to fit under 1MB, and click-to-edit alt text. 6 + 7 + **Architecture:** Add `imageEmbed` block type to document lexicon. In the editor, `/image` slash command creates a placeholder block. Users can click to upload or drag-drop images. Images are resized client-side using canvas (binary search for optimal JPEG quality), then uploaded as blobs to the user's PDS. Alt text is editable by clicking the image. 8 + 9 + **Tech Stack:** Vanilla JS, Canvas API for resize, ATProto blob upload, existing block editor infrastructure 10 + 11 + --- 12 + 13 + ### Task 1: Update Document Lexicon 14 + 15 + **Files:** 16 + - Modify: `lexicons/network/slices/tools/document.json` 17 + 18 + **Step 1: Add imageEmbed to union refs** 19 + 20 + Find the `blocks` array definition and add `#imageEmbed` to the refs: 21 + 22 + ```json 23 + "blocks": { 24 + "type": "array", 25 + "description": "Document content as array of blocks", 26 + "items": { 27 + "type": "union", 28 + "refs": ["#paragraph", "#heading", "#codeBlock", "#quote", "#tangledEmbed", "#imageEmbed"] 29 + } 30 + } 31 + ``` 32 + 33 + **Step 2: Add imageEmbed definition** 34 + 35 + Add after the `tangledEmbed` definition: 36 + 37 + ```json 38 + "imageEmbed": { 39 + "type": "object", 40 + "description": "An embedded image with alt text", 41 + "required": ["image"], 42 + "properties": { 43 + "image": { 44 + "type": "blob", 45 + "accept": ["image/*"], 46 + "maxSize": 1000000 47 + }, 48 + "alt": { 49 + "type": "string", 50 + "maxLength": 1000, 51 + "description": "Alt text for accessibility" 52 + } 53 + } 54 + } 55 + ``` 56 + 57 + **Step 3: Commit** 58 + 59 + ```bash 60 + git add lexicons/network/slices/tools/document.json 61 + git commit -m "feat(lexicon): add imageEmbed block type to document" 62 + ``` 63 + 64 + --- 65 + 66 + ### Task 2: Add Image Resize Utilities 67 + 68 + **Files:** 69 + - Modify: `docs.html` (add after existing utility functions, around line 800) 70 + 71 + **Step 1: Add image processing utilities** 72 + 73 + Copy these functions from bugs.html and add them to docs.html: 74 + 75 + ```javascript 76 + // Image resize utilities 77 + function readFileAsDataURL(file) { 78 + return new Promise((resolve, reject) => { 79 + const reader = new FileReader(); 80 + reader.onload = () => resolve(reader.result); 81 + reader.onerror = reject; 82 + reader.readAsDataURL(file); 83 + }); 84 + } 85 + 86 + function getDataUrlSize(dataUrl) { 87 + const base64 = dataUrl.split(",")[1]; 88 + return Math.ceil((base64.length * 3) / 4); 89 + } 90 + 91 + function createResizedImage(dataUrl, options) { 92 + return new Promise((resolve, reject) => { 93 + const img = new Image(); 94 + img.onload = () => { 95 + let scale; 96 + if (options.mode === "cover") { 97 + scale = Math.max(options.width / img.width, options.height / img.height); 98 + } else if (options.mode === "contain") { 99 + scale = Math.min(options.width / img.width, options.height / img.height); 100 + } else { 101 + scale = 1; 102 + } 103 + 104 + // Don't upscale 105 + scale = Math.min(scale, 1); 106 + 107 + const w = Math.round(img.width * scale); 108 + const h = Math.round(img.height * scale); 109 + 110 + const canvas = document.createElement("canvas"); 111 + canvas.width = w; 112 + canvas.height = h; 113 + 114 + const ctx = canvas.getContext("2d"); 115 + if (!ctx) return reject(new Error("Failed to get canvas context")); 116 + 117 + ctx.fillStyle = "#fff"; 118 + ctx.fillRect(0, 0, w, h); 119 + ctx.imageSmoothingEnabled = true; 120 + ctx.imageSmoothingQuality = "high"; 121 + ctx.drawImage(img, 0, 0, w, h); 122 + 123 + resolve({ 124 + dataUrl: canvas.toDataURL("image/jpeg", options.quality), 125 + width: w, 126 + height: h, 127 + }); 128 + }; 129 + img.onerror = (e) => reject(e); 130 + img.src = dataUrl; 131 + }); 132 + } 133 + 134 + async function resizeImage(dataUrl, opts) { 135 + // Binary search for optimal quality 136 + let bestResult = null; 137 + let minQuality = 0; 138 + let maxQuality = 101; 139 + 140 + while (maxQuality - minQuality > 1) { 141 + const quality = Math.round((minQuality + maxQuality) / 2); 142 + const result = await createResizedImage(dataUrl, { 143 + width: opts.width, 144 + height: opts.height, 145 + quality: quality / 100, 146 + mode: opts.mode, 147 + }); 148 + 149 + const size = getDataUrlSize(result.dataUrl); 150 + 151 + if (size < opts.maxSize) { 152 + minQuality = quality; 153 + bestResult = result; 154 + } else { 155 + maxQuality = quality; 156 + } 157 + } 158 + 159 + if (!bestResult) { 160 + throw new Error("Failed to compress image within size limit"); 161 + } 162 + 163 + return bestResult; 164 + } 165 + 166 + function dataURLtoBlob(dataUrl) { 167 + const arr = dataUrl.split(","); 168 + const mime = arr[0].match(/:(.*?);/)[1]; 169 + const bstr = atob(arr[1]); 170 + let n = bstr.length; 171 + const u8arr = new Uint8Array(n); 172 + while (n--) { 173 + u8arr[n] = bstr.charCodeAt(n); 174 + } 175 + return new Blob([u8arr], { type: mime }); 176 + } 177 + ``` 178 + 179 + **Step 2: Commit** 180 + 181 + ```bash 182 + git add docs.html 183 + git commit -m "feat(docs): add image resize utilities" 184 + ``` 185 + 186 + --- 187 + 188 + ### Task 3: Add Image Block CSS 189 + 190 + **Files:** 191 + - Modify: `docs.html` (add to CSS section, after tangledEmbed styles around line 310) 192 + 193 + **Step 1: Add imageEmbed styles** 194 + 195 + ```css 196 + .block-editor .block.imageEmbed { 197 + padding: 0; 198 + min-height: auto; 199 + } 200 + 201 + .block-editor .block.imageEmbed.placeholder { 202 + border: 2px dashed var(--border); 203 + border-radius: 8px; 204 + padding: 2rem; 205 + text-align: center; 206 + color: var(--text-muted); 207 + cursor: pointer; 208 + font-family: var(--font-ui); 209 + font-size: 0.875rem; 210 + transition: border-color 0.15s, background 0.15s; 211 + } 212 + 213 + .block-editor .block.imageEmbed.placeholder:hover, 214 + .block-editor .block.imageEmbed.placeholder.dragover { 215 + border-color: var(--accent); 216 + background: var(--bg-secondary); 217 + } 218 + 219 + .block-editor .block.imageEmbed.placeholder .upload-icon { 220 + font-size: 2rem; 221 + margin-bottom: 0.5rem; 222 + display: block; 223 + } 224 + 225 + .block-editor .block.imageEmbed.loading { 226 + padding: 2rem; 227 + text-align: center; 228 + color: var(--text-muted); 229 + font-family: var(--font-ui); 230 + font-size: 0.875rem; 231 + } 232 + 233 + .block-editor .block.imageEmbed img { 234 + max-width: 100%; 235 + height: auto; 236 + border-radius: 6px; 237 + display: block; 238 + cursor: pointer; 239 + } 240 + 241 + .block-editor .block.imageEmbed .alt-editor { 242 + margin-top: 0.5rem; 243 + display: none; 244 + } 245 + 246 + .block-editor .block.imageEmbed .alt-editor.visible { 247 + display: block; 248 + } 249 + 250 + .block-editor .block.imageEmbed .alt-editor input { 251 + width: 100%; 252 + padding: 0.5rem; 253 + font-family: var(--font-ui); 254 + font-size: 0.875rem; 255 + background: var(--bg-secondary); 256 + border: 1px solid var(--border); 257 + border-radius: 4px; 258 + color: var(--text); 259 + } 260 + 261 + .block-editor .block.imageEmbed .alt-editor input:focus { 262 + outline: none; 263 + border-color: var(--accent); 264 + } 265 + 266 + .block-editor .block.imageEmbed .alt-editor input::placeholder { 267 + color: var(--text-muted); 268 + } 269 + 270 + .block-editor .block.imageEmbed .error-message { 271 + color: var(--danger); 272 + font-size: 0.875rem; 273 + margin-top: 0.5rem; 274 + } 275 + ``` 276 + 277 + **Step 2: Commit** 278 + 279 + ```bash 280 + git add docs.html 281 + git commit -m "feat(docs): add imageEmbed block CSS" 282 + ``` 283 + 284 + --- 285 + 286 + ### Task 4: Add Image Slash Command 287 + 288 + **Files:** 289 + - Modify: `docs.html` (SLASH_COMMANDS array, around line 1554) 290 + 291 + **Step 1: Add image command to array** 292 + 293 + Find `SLASH_COMMANDS` and add the image option: 294 + 295 + ```javascript 296 + const SLASH_COMMANDS = [ 297 + { id: "paragraph", label: "Paragraph", icon: "P", description: "Plain text" }, 298 + { id: "heading1", label: "Heading 1", icon: "H1", description: "Large heading" }, 299 + { id: "heading2", label: "Heading 2", icon: "H2", description: "Medium heading" }, 300 + { id: "heading3", label: "Heading 3", icon: "H3", description: "Small heading" }, 301 + { id: "code", label: "Code Block", icon: "</>", description: "Code snippet" }, 302 + { id: "quote", label: "Quote", icon: '"', description: "Blockquote" }, 303 + { id: "image", label: "Image", icon: "🖼", description: "Upload an image" }, 304 + { id: "tangled", label: "Tangled Repo", icon: "🐑", description: "Embed a repo card" }, 305 + ]; 306 + ``` 307 + 308 + **Step 2: Commit** 309 + 310 + ```bash 311 + git add docs.html 312 + git commit -m "feat(docs): add image slash command" 313 + ``` 314 + 315 + --- 316 + 317 + ### Task 5: Add BlockTypes Helper 318 + 319 + **Files:** 320 + - Modify: `richtext.js` (add to BlockTypes object) 321 + 322 + **Step 1: Add isImageEmbed check** 323 + 324 + Find the `BlockTypes` export and add: 325 + 326 + ```javascript 327 + export const BlockTypes = { 328 + isParagraph: (t) => t?.includes("paragraph") || t?.includes("Paragraph"), 329 + isHeading: (t) => t?.includes("heading") || t?.includes("Heading"), 330 + isCodeBlock: (t) => t?.includes("codeBlock") || t?.includes("CodeBlock"), 331 + isQuote: (t) => t?.includes("quote") || t?.includes("Quote"), 332 + isTangledEmbed: (t) => t?.includes("tangledEmbed") || t?.includes("TangledEmbed"), 333 + isImageEmbed: (t) => t?.includes("imageEmbed") || t?.includes("ImageEmbed"), 334 + }; 335 + ``` 336 + 337 + **Step 2: Commit** 338 + 339 + ```bash 340 + git add richtext.js 341 + git commit -m "feat(richtext): add isImageEmbed to BlockTypes" 342 + ``` 343 + 344 + --- 345 + 346 + ### Task 6: Create addImageBlock Function 347 + 348 + **Files:** 349 + - Modify: `docs.html` (add after addBlock function, around line 1140) 350 + 351 + **Step 1: Add the addImageBlock function** 352 + 353 + ```javascript 354 + function addImageBlock(blobRef = null, alt = "", focus = false) { 355 + const editor = document.getElementById("block-editor"); 356 + const id = generateBlockId(); 357 + 358 + const div = document.createElement("div"); 359 + div.id = id; 360 + div.className = "block imageEmbed" + (blobRef ? "" : " placeholder"); 361 + div.dataset.type = "imageEmbed"; 362 + div.contentEditable = "false"; 363 + div.tabIndex = 0; 364 + 365 + if (blobRef) { 366 + // Display mode with image 367 + div.dataset.blobRef = JSON.stringify(blobRef); 368 + div.dataset.alt = alt; 369 + const imgUrl = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${state.viewer.did}&cid=${blobRef.ref.$link}`; 370 + div.innerHTML = ` 371 + <img src="${imgUrl}" alt="${esc(alt)}" title="${esc(alt || "Click to add alt text")}" /> 372 + <div class="alt-editor"> 373 + <input type="text" value="${esc(alt)}" placeholder="Add alt text for accessibility" /> 374 + </div> 375 + `; 376 + setupImageBlockEvents(div); 377 + } else { 378 + // Placeholder mode 379 + div.innerHTML = ` 380 + <span class="upload-icon">🖼</span> 381 + <span>Click to upload or drop image here</span> 382 + <input type="file" accept="image/*" style="display: none;" /> 383 + `; 384 + setupImagePlaceholderEvents(div); 385 + } 386 + 387 + editor.appendChild(div); 388 + 389 + const blockData = { id, type: "imageEmbed", element: div }; 390 + editorState.blocks.push(blockData); 391 + editorState.blockMap.set(div, blockData); 392 + 393 + if (focus) { 394 + div.focus(); 395 + } 396 + 397 + return div; 398 + } 399 + ``` 400 + 401 + **Step 2: Commit** 402 + 403 + ```bash 404 + git add docs.html 405 + git commit -m "feat(docs): add addImageBlock function" 406 + ``` 407 + 408 + --- 409 + 410 + ### Task 7: Add Image Upload Function 411 + 412 + **Files:** 413 + - Modify: `docs.html` (add after addImageBlock) 414 + 415 + **Step 1: Add uploadImage function** 416 + 417 + ```javascript 418 + async function uploadImage(file, block) { 419 + const blockData = getBlockData(block); 420 + if (!blockData) return; 421 + 422 + // Show loading state 423 + block.className = "block imageEmbed loading"; 424 + block.innerHTML = "Processing image..."; 425 + 426 + try { 427 + // Read and resize image 428 + const dataUrl = await readFileAsDataURL(file); 429 + const resized = await resizeImage(dataUrl, { 430 + width: 2000, 431 + height: 2000, 432 + maxSize: 900000, 433 + mode: "contain", 434 + }); 435 + 436 + block.innerHTML = "Uploading..."; 437 + 438 + // Convert to blob and upload 439 + const blob = dataURLtoBlob(resized.dataUrl); 440 + const response = await fetch("https://bsky.social/xrpc/com.atproto.repo.uploadBlob", { 441 + method: "POST", 442 + headers: { 443 + "Content-Type": blob.type, 444 + "Authorization": `Bearer ${state.session.accessJwt}`, 445 + }, 446 + body: blob, 447 + }); 448 + 449 + if (!response.ok) { 450 + throw new Error(`Upload failed: ${response.status}`); 451 + } 452 + 453 + const result = await response.json(); 454 + const blobRef = result.blob; 455 + 456 + // Update block to display mode 457 + block.className = "block imageEmbed"; 458 + block.dataset.blobRef = JSON.stringify(blobRef); 459 + block.dataset.alt = ""; 460 + 461 + const imgUrl = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${state.viewer.did}&cid=${blobRef.ref.$link}`; 462 + block.innerHTML = ` 463 + <img src="${imgUrl}" alt="" title="Click to add alt text" /> 464 + <div class="alt-editor"> 465 + <input type="text" value="" placeholder="Add alt text for accessibility" /> 466 + </div> 467 + `; 468 + setupImageBlockEvents(block); 469 + triggerAutoSave(); 470 + 471 + } catch (err) { 472 + console.error("Image upload failed:", err); 473 + block.className = "block imageEmbed placeholder"; 474 + block.innerHTML = ` 475 + <span class="upload-icon">🖼</span> 476 + <span>Click to upload or drop image here</span> 477 + <div class="error-message">Upload failed: ${esc(err.message)}</div> 478 + <input type="file" accept="image/*" style="display: none;" /> 479 + `; 480 + setupImagePlaceholderEvents(block); 481 + } 482 + } 483 + ``` 484 + 485 + **Step 2: Commit** 486 + 487 + ```bash 488 + git add docs.html 489 + git commit -m "feat(docs): add uploadImage function" 490 + ``` 491 + 492 + --- 493 + 494 + ### Task 8: Add Placeholder Event Handlers 495 + 496 + **Files:** 497 + - Modify: `docs.html` (add after uploadImage) 498 + 499 + **Step 1: Add setupImagePlaceholderEvents function** 500 + 501 + ```javascript 502 + function setupImagePlaceholderEvents(block) { 503 + const fileInput = block.querySelector('input[type="file"]'); 504 + 505 + // Click to open file picker 506 + block.addEventListener("click", (e) => { 507 + if (e.target === fileInput) return; 508 + fileInput?.click(); 509 + }); 510 + 511 + // File selected 512 + fileInput?.addEventListener("change", (e) => { 513 + const file = e.target.files?.[0]; 514 + if (file && file.type.startsWith("image/")) { 515 + uploadImage(file, block); 516 + } 517 + }); 518 + 519 + // Drag and drop 520 + block.addEventListener("dragover", (e) => { 521 + e.preventDefault(); 522 + block.classList.add("dragover"); 523 + }); 524 + 525 + block.addEventListener("dragleave", (e) => { 526 + e.preventDefault(); 527 + block.classList.remove("dragover"); 528 + }); 529 + 530 + block.addEventListener("drop", (e) => { 531 + e.preventDefault(); 532 + block.classList.remove("dragover"); 533 + const file = e.dataTransfer?.files?.[0]; 534 + if (file && file.type.startsWith("image/")) { 535 + uploadImage(file, block); 536 + } 537 + }); 538 + 539 + // Keyboard handling 540 + block.addEventListener("keydown", (e) => { 541 + handleImageBlockKeydown(e, block); 542 + }); 543 + } 544 + ``` 545 + 546 + **Step 2: Commit** 547 + 548 + ```bash 549 + git add docs.html 550 + git commit -m "feat(docs): add image placeholder event handlers" 551 + ``` 552 + 553 + --- 554 + 555 + ### Task 9: Add Image Display Event Handlers 556 + 557 + **Files:** 558 + - Modify: `docs.html` (add after setupImagePlaceholderEvents) 559 + 560 + **Step 1: Add setupImageBlockEvents function** 561 + 562 + ```javascript 563 + function setupImageBlockEvents(block) { 564 + const img = block.querySelector("img"); 565 + const altEditor = block.querySelector(".alt-editor"); 566 + const altInput = altEditor?.querySelector("input"); 567 + 568 + // Click image to show alt editor 569 + img?.addEventListener("click", (e) => { 570 + e.stopPropagation(); 571 + altEditor?.classList.add("visible"); 572 + altInput?.focus(); 573 + }); 574 + 575 + // Alt input events 576 + altInput?.addEventListener("input", (e) => { 577 + const alt = e.target.value; 578 + block.dataset.alt = alt; 579 + img.alt = alt; 580 + img.title = alt || "Click to add alt text"; 581 + triggerAutoSave(); 582 + }); 583 + 584 + altInput?.addEventListener("blur", () => { 585 + // Hide if empty 586 + if (!altInput.value.trim()) { 587 + altEditor?.classList.remove("visible"); 588 + } 589 + }); 590 + 591 + altInput?.addEventListener("keydown", (e) => { 592 + if (e.key === "Escape") { 593 + e.preventDefault(); 594 + altEditor?.classList.remove("visible"); 595 + block.focus(); 596 + } else if (e.key === "Enter") { 597 + e.preventDefault(); 598 + altEditor?.classList.remove("visible"); 599 + block.focus(); 600 + } 601 + }); 602 + 603 + // Block keyboard handling 604 + block.addEventListener("keydown", (e) => { 605 + // Don't handle if alt input is focused 606 + if (document.activeElement === altInput) return; 607 + handleImageBlockKeydown(e, block); 608 + }); 609 + } 610 + ``` 611 + 612 + **Step 2: Commit** 613 + 614 + ```bash 615 + git add docs.html 616 + git commit -m "feat(docs): add image display event handlers" 617 + ``` 618 + 619 + --- 620 + 621 + ### Task 10: Add Image Block Keyboard Handler 622 + 623 + **Files:** 624 + - Modify: `docs.html` (add after setupImageBlockEvents) 625 + 626 + **Step 1: Add handleImageBlockKeydown function** 627 + 628 + ```javascript 629 + function handleImageBlockKeydown(e, block) { 630 + const blockData = getBlockData(block); 631 + if (!blockData) return; 632 + 633 + const blockIndex = editorState.blocks.indexOf(blockData); 634 + 635 + if (e.key === "Enter") { 636 + e.preventDefault(); 637 + const newBlock = insertBlockAfter(blockIndex, "paragraph"); 638 + newBlock.focus(); 639 + } else if (e.key === "Backspace" || e.key === "Delete") { 640 + e.preventDefault(); 641 + // Focus previous block before deleting 642 + if (blockIndex > 0) { 643 + const prevBlock = editorState.blocks[blockIndex - 1]; 644 + const focusTarget = prevBlock.type === "codeBlock" 645 + ? prevBlock.element._codeContent || prevBlock.element 646 + : prevBlock.element; 647 + focusTarget.focus(); 648 + } 649 + deleteBlock(blockIndex); 650 + } else if (e.key === "ArrowUp") { 651 + e.preventDefault(); 652 + if (blockIndex > 0) { 653 + const prevBlock = editorState.blocks[blockIndex - 1]; 654 + const focusTarget = prevBlock.type === "codeBlock" 655 + ? prevBlock.element._codeContent || prevBlock.element 656 + : prevBlock.element; 657 + focusTarget.focus(); 658 + } 659 + } else if (e.key === "ArrowDown") { 660 + e.preventDefault(); 661 + if (blockIndex < editorState.blocks.length - 1) { 662 + const nextBlock = editorState.blocks[blockIndex + 1]; 663 + const focusTarget = nextBlock.type === "codeBlock" 664 + ? nextBlock.element._codeContent || nextBlock.element 665 + : nextBlock.element; 666 + focusTarget.focus(); 667 + } 668 + } 669 + } 670 + ``` 671 + 672 + **Step 2: Commit** 673 + 674 + ```bash 675 + git add docs.html 676 + git commit -m "feat(docs): add image block keyboard handler" 677 + ``` 678 + 679 + --- 680 + 681 + ### Task 11: Handle Image Slash Command Execution 682 + 683 + **Files:** 684 + - Modify: `docs.html` (executeSlashCommand function, around line 1717) 685 + 686 + **Step 1: Add image case to executeSlashCommand** 687 + 688 + Find the `executeSlashCommand` function and add handling for "image": 689 + 690 + ```javascript 691 + function executeSlashCommand(commandId) { 692 + const block = editorState.slashMenuBlock; 693 + if (!block) return; 694 + 695 + const blockData = getBlockData(block); 696 + if (!blockData) return; 697 + 698 + closeSlashMenu(); 699 + 700 + // Clear the slash text 701 + block.textContent = ""; 702 + 703 + const blockIndex = editorState.blocks.indexOf(blockData); 704 + 705 + if (commandId === "image") { 706 + // Convert to image block 707 + const newImageBlock = document.createElement("div"); 708 + newImageBlock.id = blockData.id; 709 + newImageBlock.className = "block imageEmbed placeholder"; 710 + newImageBlock.dataset.type = "imageEmbed"; 711 + newImageBlock.contentEditable = "false"; 712 + newImageBlock.tabIndex = 0; 713 + newImageBlock.innerHTML = ` 714 + <span class="upload-icon">🖼</span> 715 + <span>Click to upload or drop image here</span> 716 + <input type="file" accept="image/*" style="display: none;" /> 717 + `; 718 + 719 + block.replaceWith(newImageBlock); 720 + blockData.element = newImageBlock; 721 + blockData.type = "imageEmbed"; 722 + editorState.blockMap.delete(block); 723 + editorState.blockMap.set(newImageBlock, blockData); 724 + 725 + setupImagePlaceholderEvents(newImageBlock); 726 + newImageBlock.focus(); 727 + 728 + // Auto-open file picker 729 + setTimeout(() => { 730 + newImageBlock.querySelector('input[type="file"]')?.click(); 731 + }, 100); 732 + return; 733 + } 734 + 735 + // ... rest of existing command handling 736 + ``` 737 + 738 + **Step 2: Commit** 739 + 740 + ```bash 741 + git add docs.html 742 + git commit -m "feat(docs): handle image slash command execution" 743 + ``` 744 + 745 + --- 746 + 747 + ### Task 12: Add Paste Image Handler 748 + 749 + **Files:** 750 + - Modify: `docs.html` (add paste listener in initBlockEditor or after) 751 + 752 + **Step 1: Add paste event listener** 753 + 754 + Find where block editor events are set up and add: 755 + 756 + ```javascript 757 + function setupEditorPasteHandler() { 758 + const editor = document.getElementById("block-editor"); 759 + if (!editor) return; 760 + 761 + editor.addEventListener("paste", async (e) => { 762 + const items = e.clipboardData?.items; 763 + if (!items) return; 764 + 765 + for (const item of items) { 766 + if (item.type.startsWith("image/")) { 767 + e.preventDefault(); 768 + const file = item.getAsFile(); 769 + if (!file) continue; 770 + 771 + // Get current focused block or create new one 772 + const focusedBlock = document.activeElement?.closest(".block"); 773 + const focusedBlockData = focusedBlock ? getBlockData(focusedBlock) : null; 774 + 775 + let imageBlock; 776 + if (focusedBlockData && focusedBlockData.type === "imageEmbed" && focusedBlock.classList.contains("placeholder")) { 777 + // Paste into existing placeholder 778 + imageBlock = focusedBlock; 779 + } else { 780 + // Insert new image block after current 781 + const blockIndex = focusedBlockData 782 + ? editorState.blocks.indexOf(focusedBlockData) 783 + : editorState.blocks.length - 1; 784 + 785 + imageBlock = insertImageBlockAfter(blockIndex); 786 + } 787 + 788 + uploadImage(file, imageBlock); 789 + break; 790 + } 791 + } 792 + }); 793 + } 794 + 795 + function insertImageBlockAfter(index) { 796 + const editor = document.getElementById("block-editor"); 797 + const id = generateBlockId(); 798 + 799 + const div = document.createElement("div"); 800 + div.id = id; 801 + div.className = "block imageEmbed placeholder"; 802 + div.dataset.type = "imageEmbed"; 803 + div.contentEditable = "false"; 804 + div.tabIndex = 0; 805 + div.innerHTML = ` 806 + <span class="upload-icon">🖼</span> 807 + <span>Click to upload or drop image here</span> 808 + <input type="file" accept="image/*" style="display: none;" /> 809 + `; 810 + 811 + const afterBlock = editorState.blocks[index]?.element; 812 + if (afterBlock?.nextSibling) { 813 + editor.insertBefore(div, afterBlock.nextSibling); 814 + } else { 815 + editor.appendChild(div); 816 + } 817 + 818 + const blockData = { id, type: "imageEmbed", element: div }; 819 + editorState.blocks.splice(index + 1, 0, blockData); 820 + editorState.blockMap.set(div, blockData); 821 + 822 + setupImagePlaceholderEvents(div); 823 + return div; 824 + } 825 + ``` 826 + 827 + **Step 2: Call setupEditorPasteHandler in initBlockEditor** 828 + 829 + At the end of `initBlockEditor`, add: 830 + 831 + ```javascript 832 + setupEditorPasteHandler(); 833 + ``` 834 + 835 + **Step 3: Commit** 836 + 837 + ```bash 838 + git add docs.html 839 + git commit -m "feat(docs): add paste image handler" 840 + ``` 841 + 842 + --- 843 + 844 + ### Task 13: Update extractBlocksFromEditor 845 + 846 + **Files:** 847 + - Modify: `docs.html` (extractBlocksFromEditor function, around line 3100) 848 + 849 + **Step 1: Add imageEmbed handling** 850 + 851 + Find `extractBlocksFromEditor` and add handling for imageEmbed: 852 + 853 + ```javascript 854 + } else if (type === "imageEmbed") { 855 + // Image embed - get blob ref and alt from dataset 856 + const blobRefStr = element.dataset.blobRef; 857 + const alt = element.dataset.alt || ""; 858 + if (blobRefStr) { 859 + try { 860 + const image = JSON.parse(blobRefStr); 861 + blocks.push({ type: "imageEmbed", image, alt }); 862 + } catch (e) { 863 + console.error("Failed to parse image blob ref:", e); 864 + } 865 + } 866 + } 867 + ``` 868 + 869 + **Step 2: Commit** 870 + 871 + ```bash 872 + git add docs.html 873 + git commit -m "feat(docs): handle imageEmbed in extractBlocksFromEditor" 874 + ``` 875 + 876 + --- 877 + 878 + ### Task 14: Update initBlockEditor to Load Images 879 + 880 + **Files:** 881 + - Modify: `docs.html` (initBlockEditor function, around line 960) 882 + 883 + **Step 1: Add imageEmbed loading** 884 + 885 + Find the block loading loop in `initBlockEditor` and add: 886 + 887 + ```javascript 888 + } else if (BlockTypes.isImageEmbed(type)) { 889 + addImageBlock(block.image, block.alt || ""); 890 + } 891 + ``` 892 + 893 + **Step 2: Commit** 894 + 895 + ```bash 896 + git add docs.html 897 + git commit -m "feat(docs): load imageEmbed blocks in initBlockEditor" 898 + ``` 899 + 900 + --- 901 + 902 + ### Task 15: Update serializeBlock for Images 903 + 904 + **Files:** 905 + - Modify: `docs.html` (serializeBlock function, around line 2630) 906 + 907 + **Step 1: Add imageEmbed serialization** 908 + 909 + Find `serializeBlock` and add: 910 + 911 + ```javascript 912 + } else if (block.type === "imageEmbed") { 913 + return { ...base, image: block.image, alt: block.alt || "" }; 914 + } 915 + ``` 916 + 917 + **Step 2: Commit** 918 + 919 + ```bash 920 + git add docs.html 921 + git commit -m "feat(docs): serialize imageEmbed blocks" 922 + ``` 923 + 924 + --- 925 + 926 + ### Task 16: Update Read-Only Rendering 927 + 928 + **Files:** 929 + - Modify: `docs.html` (renderBlocks function, around line 3040) 930 + 931 + **Step 1: Add imageEmbed rendering in read-only mode** 932 + 933 + Find the block rendering in `renderDocument` for read-only view and add: 934 + 935 + ```javascript 936 + } else if (BlockTypes.isImageEmbed(type)) { 937 + const imgUrl = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${doc.actorDid}&cid=${block.image.ref.$link}`; 938 + return `<figure class="image-embed"> 939 + <img src="${esc(imgUrl)}" alt="${esc(block.alt || "")}" /> 940 + ${block.alt ? `<figcaption>${esc(block.alt)}</figcaption>` : ""} 941 + </figure>`; 942 + } 943 + ``` 944 + 945 + **Step 2: Add figure CSS for read-only view** 946 + 947 + ```css 948 + .document-content .image-embed { 949 + margin: 1.5rem 0; 950 + } 951 + 952 + .document-content .image-embed img { 953 + max-width: 100%; 954 + height: auto; 955 + border-radius: 6px; 956 + } 957 + 958 + .document-content .image-embed figcaption { 959 + font-family: var(--font-ui); 960 + font-size: 0.875rem; 961 + color: var(--text-muted); 962 + margin-top: 0.5rem; 963 + text-align: center; 964 + } 965 + ``` 966 + 967 + **Step 3: Commit** 968 + 969 + ```bash 970 + git add docs.html 971 + git commit -m "feat(docs): render imageEmbed in read-only view" 972 + ``` 973 + 974 + --- 975 + 976 + ### Task 17: Manual Testing 977 + 978 + **Steps:** 979 + 1. Run local server: `npx serve -s .` 980 + 2. Open http://localhost:3000/docs 981 + 3. Login and create new doc 982 + 983 + **Test cases:** 984 + - [ ] Type `/image` - should open file picker 985 + - [ ] Cancel file picker - should show placeholder 986 + - [ ] Click placeholder - opens file picker 987 + - [ ] Select image - uploads and displays 988 + - [ ] Drag image onto placeholder - uploads 989 + - [ ] Paste image (Cmd+V) - inserts and uploads 990 + - [ ] Click displayed image - shows alt text input 991 + - [ ] Type alt text, blur - saves, hides if empty 992 + - [ ] Press Escape in alt input - hides, focuses block 993 + - [ ] Arrow keys on image block - navigate to adjacent blocks 994 + - [ ] Backspace on image block - deletes block 995 + - [ ] Enter on image block - inserts paragraph after 996 + - [ ] Save and reload - image persists 997 + - [ ] View as logged-out user - image displays read-only with alt 998 + 999 + **Step 1: Commit any fixes** 1000 + 1001 + ```bash 1002 + git add docs.html 1003 + git commit -m "fix(docs): testing fixes for image embeds" 1004 + ``` 1005 + 1006 + --- 1007 + 1008 + ### Task 18: Final Cleanup 1009 + 1010 + **Step 1: Review for dead code** 1011 + 1012 + Check for any unused functions or duplicate code. 1013 + 1014 + **Step 2: Final commit** 1015 + 1016 + ```bash 1017 + git add -A 1018 + git commit -m "feat(docs): complete image embed support 1019 + 1020 + - Add imageEmbed block type to document lexicon 1021 + - Support upload via file picker, drag-drop, and paste 1022 + - Auto-resize images to fit under 1MB using canvas 1023 + - Click-to-edit alt text for accessibility 1024 + - Natural image sizing capped at content width 1025 + - Read-only view with figcaption for alt text" 1026 + ``` 1027 + 1028 + ---
+18 -1
lexicons/network/slices/tools/document.json
··· 24 24 "description": "Document content as array of blocks", 25 25 "items": { 26 26 "type": "union", 27 - "refs": ["#paragraph", "#heading", "#codeBlock", "#quote", "#tangledEmbed"] 27 + "refs": ["#paragraph", "#heading", "#codeBlock", "#quote", "#tangledEmbed", "#imageEmbed"] 28 28 } 29 29 }, 30 30 "createdAt": { ··· 126 126 "type": "string", 127 127 "maxLength": 300, 128 128 "description": "The repository name" 129 + } 130 + } 131 + }, 132 + "imageEmbed": { 133 + "type": "object", 134 + "description": "An embedded image with alt text", 135 + "required": ["image"], 136 + "properties": { 137 + "image": { 138 + "type": "blob", 139 + "accept": ["image/*"], 140 + "maxSize": 1000000 141 + }, 142 + "alt": { 143 + "type": "string", 144 + "maxLength": 1000, 145 + "description": "Alt text for accessibility" 129 146 } 130 147 } 131 148 }
+2
richtext.js
··· 23 23 isHeading: (type) => isFacetType(type, 'heading'), 24 24 isCodeBlock: (type) => isFacetType(type, 'codeblock'), 25 25 isQuote: (type) => isFacetType(type, 'quote'), 26 + isTangledEmbed: (type) => isFacetType(type, 'tangledembed'), 27 + isImageEmbed: (type) => isFacetType(type, 'imageembed'), 26 28 }; 27 29 28 30 /**