Tools for the Atmosphere tools.slices.network
quickslice atproto html
at main 4041 lines 133 kB view raw
1<!DOCTYPE html> 2<html lang="en"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <title>Docs - tools.slices.network</title> 7 <script src="https://cdn.jsdelivr.net/gh/bigmoves/quickslice@main/quickslice-client-js/dist/quickslice-client.min.js"></script> 8 <script src="https://cdn.jsdelivr.net/gh/bigmoves/elements@v0.2.1/dist/elements.min.js"></script> 9 <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script> 10 <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github.min.css" media="(prefers-color-scheme: light)"> 11 <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css" media="(prefers-color-scheme: dark)"> 12 <link rel="preconnect" href="https://fonts.googleapis.com"> 13 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 14 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet"> 15 <style> 16 :root { 17 --bg: #0a0a0a; 18 --bg-secondary: #141414; 19 --bg-hover: #1a1a1a; 20 --text: #e5e5e5; 21 --text-muted: #737373; 22 --border: #262626; 23 --accent: #3b82f6; 24 --accent-hover: #2563eb; 25 --danger: #ef4444; 26 --success: #22c55e; 27 --font-body: "Lora", Georgia, serif; 28 --font-ui: "Inter", -apple-system, sans-serif; 29 --font-mono: "SF Mono", Monaco, Consolas, monospace; 30 --font-size-ui: 0.875rem; 31 --font-size-code: 0.875rem; 32 --font-size-h1: 1.75rem; 33 --font-size-h2: 1.5rem; 34 --font-size-h3: 1.25rem; 35 } 36 37 @media (prefers-color-scheme: light) { 38 :root { 39 --bg: #ffffff; 40 --bg-secondary: #f5f5f5; 41 --bg-hover: #e5e5e5; 42 --text: #171717; 43 --text-muted: #737373; 44 --border: #e5e5e5; 45 } 46 } 47 48 * { 49 margin: 0; 50 padding: 0; 51 box-sizing: border-box; 52 } 53 54 body { 55 font-family: var(--font-body); 56 background: var(--bg); 57 color: var(--text); 58 font-size: 1.125rem; 59 line-height: 1.7; 60 min-height: 100vh; 61 } 62 63 .container { 64 max-width: 650px; 65 margin: 0 auto; 66 padding: 2rem 1.5rem; 67 } 68 69 header { 70 font-family: var(--font-ui); 71 font-size: var(--font-size-ui); 72 display: flex; 73 justify-content: space-between; 74 align-items: center; 75 margin-bottom: 2rem; 76 padding-bottom: 1rem; 77 border-bottom: 1px solid var(--border); 78 } 79 80 h1, h2, h3, h4, h5, h6 { 81 font-family: var(--font-ui); 82 } 83 84 h1 { 85 font-size: 1.5rem; 86 font-weight: 600; 87 } 88 89 .header-actions { 90 display: flex; 91 gap: 0.5rem; 92 align-items: center; 93 } 94 95 button { 96 font-family: var(--font-ui); 97 background: var(--accent); 98 color: white; 99 border: none; 100 padding: 0.5rem 1rem; 101 border-radius: 6px; 102 cursor: pointer; 103 font-size: 0.875rem; 104 font-weight: 500; 105 } 106 107 button:hover { 108 background: var(--accent-hover); 109 } 110 111 button.secondary { 112 background: var(--bg-secondary); 113 color: var(--text); 114 border: 1px solid var(--border); 115 } 116 117 button.secondary:hover { 118 background: var(--bg-hover); 119 } 120 121 button.danger { 122 background: var(--danger); 123 } 124 125 .doc-list { 126 display: flex; 127 flex-direction: column; 128 gap: 0.5rem; 129 } 130 131 .doc-list { 132 font-family: var(--font-ui); 133 font-size: var(--font-size-ui); 134 } 135 136 .doc-item { 137 display: flex; 138 justify-content: space-between; 139 align-items: center; 140 padding: 1rem; 141 background: var(--bg-secondary); 142 border-radius: 8px; 143 cursor: pointer; 144 transition: background 0.15s; 145 } 146 147 .doc-item:hover { 148 background: var(--bg-hover); 149 } 150 151 .doc-title { 152 font-weight: 500; 153 } 154 155 .doc-meta { 156 font-size: 0.875rem; 157 color: var(--text-muted); 158 } 159 160 .doc-slug { 161 font-family: var(--font-mono); 162 } 163 164 /* Facet styles */ 165 .facet-link { 166 color: var(--accent); 167 text-decoration: underline; 168 } 169 170 .facet-link:hover { 171 color: var(--accent-hover); 172 } 173 174 .facet-bold { 175 font-weight: 600; 176 } 177 178 .facet-italic { 179 font-style: italic; 180 } 181 182 .facet-code { 183 font-family: var(--font-mono); 184 background: var(--bg-hover); 185 padding: 0.125rem 0.375rem; 186 border-radius: 4px; 187 font-size: 0.9em; 188 } 189 190 .facet-codeblock { 191 font-family: var(--font-mono); 192 background: var(--bg-secondary); 193 padding: 1rem; 194 border-radius: 6px; 195 overflow-x: auto; 196 } 197 198 .facet-codeblock code { 199 background: transparent; 200 padding: 0; 201 font-size: var(--font-size-code); 202 line-height: 1.5; 203 } 204 205 .facet-quote { 206 border-left: 3px solid var(--border); 207 padding-left: 1rem; 208 margin: 0.5rem 0; 209 color: var(--text-muted); 210 font-style: italic; 211 } 212 213 /* Block editor styles */ 214 .block-editor { 215 min-height: 300px; 216 padding: 0; 217 } 218 219 .block-editor .block { 220 padding: 0.25rem 0; 221 margin: 0.25rem 0; 222 outline: none; 223 min-height: 1.5em; 224 } 225 226 .block-editor .block.paragraph { 227 /* default styling */ 228 } 229 230 .block-editor .block.heading-1 { 231 font-family: var(--font-ui); 232 font-size: var(--font-size-h1); 233 font-weight: 600; 234 } 235 236 .block-editor .block.heading-2 { 237 font-family: var(--font-ui); 238 font-size: var(--font-size-h2); 239 font-weight: 600; 240 } 241 242 .block-editor .block.heading-3 { 243 font-family: var(--font-ui); 244 font-size: var(--font-size-h3); 245 font-weight: 600; 246 } 247 248 .block-editor .block.codeBlock { 249 position: relative; 250 background: var(--bg-secondary); 251 border-radius: 6px; 252 padding: 0; 253 } 254 255 .code-lang-select { 256 position: absolute; 257 top: 0.5rem; 258 right: 0.5rem; 259 background: var(--bg); 260 border: 1px solid var(--border); 261 border-radius: 4px; 262 color: var(--text-muted); 263 font-size: 0.75rem; 264 padding: 0.125rem 0.25rem; 265 cursor: pointer; 266 opacity: 0; 267 transition: opacity 0.15s; 268 } 269 270 .block-editor .block.codeBlock:hover .code-lang-select, 271 .code-lang-select:focus { 272 opacity: 1; 273 } 274 275 .code-content { 276 display: block; 277 font-family: var(--font-mono); 278 font-size: var(--font-size-code); 279 padding: 1rem 1.25rem; 280 white-space: pre; 281 outline: none; 282 min-height: 1.5em; 283 } 284 285 .block-editor .block.quote { 286 border-left: 3px solid var(--border); 287 padding-left: 1rem; 288 color: var(--text-muted); 289 font-style: italic; 290 } 291 292 .block-editor .block.tangledEmbed { 293 padding: 0; 294 min-height: auto; 295 } 296 297 .block-editor .block.tangledEmbed[data-editing="true"] { 298 padding: 0.5rem 1rem; 299 background: var(--bg-secondary); 300 border-radius: 6px; 301 font-family: var(--font-mono); 302 font-size: 0.875rem; 303 } 304 305 .block-editor .block.tangledEmbed[data-editing="true"]:empty::before { 306 content: attr(data-placeholder); 307 color: var(--text-muted); 308 } 309 310 .block-editor .block.tangledEmbed qs-tangled-repo-card { 311 pointer-events: none; 312 } 313 314 .block-editor .block.tangledEmbed:hover qs-tangled-repo-card { 315 opacity: 0.95; 316 } 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 393 /* Focus style for non-editable blocks (embeds) */ 394 .block-editor .block[tabindex="0"]:focus { 395 outline: 2px solid var(--accent); 396 outline-offset: 2px; 397 border-radius: 8px; 398 } 399 400 .block-editor .block[data-placeholder]:empty:focus::before { 401 content: attr(data-placeholder); 402 color: var(--text-muted); 403 pointer-events: none; 404 } 405 406 .block-editor .block.title { 407 font-family: var(--font-ui); 408 font-size: 2.25rem; 409 font-weight: 700; 410 border: none; 411 padding: 0; 412 margin-bottom: 1rem; 413 min-height: 1em; 414 } 415 416 .block-editor .block.title:empty::before { 417 content: attr(data-placeholder); 418 color: var(--text-muted); 419 } 420 421 .block-editor .block.title:focus { 422 outline: none; 423 } 424 425 .save-status { 426 font-size: 0.875rem; 427 color: var(--text-muted); 428 transition: opacity 0.3s; 429 } 430 431 .save-status.saved { 432 animation: fadeOut 2s forwards; 433 animation-delay: 1s; 434 } 435 436 .save-status.error { 437 color: var(--danger); 438 } 439 440 @keyframes fadeOut { 441 to { opacity: 0; } 442 } 443 444 .user-menu-container { 445 position: relative; 446 } 447 448 .user-menu-trigger { 449 display: flex; 450 align-items: center; 451 gap: 0.5rem; 452 background: none; 453 border: 1px solid var(--border); 454 border-radius: 4px; 455 padding: 0.25rem 0.5rem; 456 cursor: pointer; 457 color: var(--text); 458 } 459 460 .user-menu-trigger:hover { 461 background: var(--bg-secondary); 462 } 463 464 .user-menu-trigger .user-avatar { 465 margin: 0; 466 } 467 468 .user-menu { 469 position: absolute; 470 top: 100%; 471 right: 0; 472 background: var(--bg); 473 border: 1px solid var(--border); 474 border-radius: 4px; 475 box-shadow: 0 2px 8px rgba(0,0,0,0.1); 476 min-width: 150px; 477 z-index: 100; 478 margin-top: 0.25rem; 479 font-family: var(--font-ui); 480 font-size: var(--font-size-ui); 481 } 482 483 .user-menu button { 484 display: block; 485 width: 100%; 486 text-align: left; 487 padding: 0.5rem 1rem; 488 border: none; 489 background: none; 490 cursor: pointer; 491 color: var(--text); 492 } 493 494 .user-menu button:hover { 495 background: var(--bg-secondary); 496 } 497 498 .doc-menu-container { 499 position: relative; 500 margin-left: auto; 501 } 502 503 .doc-menu-trigger { 504 background: none; 505 border: 1px solid var(--border); 506 border-radius: 4px; 507 padding: 0.125rem 0.375rem; 508 cursor: pointer; 509 font-size: 0.75rem; 510 letter-spacing: 1px; 511 color: var(--text); 512 } 513 514 .doc-menu-trigger:hover { 515 background: var(--bg-secondary); 516 } 517 518 .doc-menu { 519 position: absolute; 520 top: 100%; 521 right: 0; 522 background: var(--bg); 523 border: 1px solid var(--border); 524 border-radius: 4px; 525 box-shadow: 0 2px 8px rgba(0,0,0,0.1); 526 min-width: 150px; 527 z-index: 100; 528 margin-top: 0.25rem; 529 font-family: var(--font-ui); 530 font-size: var(--font-size-ui); 531 } 532 533 .doc-menu button { 534 display: block; 535 width: 100%; 536 text-align: left; 537 padding: 0.5rem 1rem; 538 border: none; 539 background: none; 540 cursor: pointer; 541 color: var(--text); 542 } 543 544 .doc-menu button:hover { 545 background: var(--bg-secondary); 546 } 547 548 .doc-menu button.danger { 549 color: var(--danger); 550 } 551 552 .slash-menu { 553 position: absolute; 554 background: var(--bg-secondary); 555 border: 1px solid var(--border); 556 border-radius: 6px; 557 padding: 0.5rem 0; 558 min-width: 200px; 559 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 560 z-index: 100; 561 font-family: var(--font-ui); 562 font-size: var(--font-size-ui); 563 } 564 565 .slash-menu.hidden { 566 display: none; 567 } 568 569 .slash-menu-item { 570 padding: 0.5rem 1rem; 571 cursor: pointer; 572 display: flex; 573 align-items: center; 574 gap: 0.5rem; 575 } 576 577 .slash-menu-item:hover, 578 .slash-menu-item.selected { 579 background: var(--bg-hover); 580 } 581 582 .slash-menu-item .icon { 583 width: 20px; 584 text-align: center; 585 color: var(--text-muted); 586 } 587 588 /* Form styles */ 589 .form-group { 590 margin-bottom: 1rem; 591 } 592 593 label { 594 display: block; 595 font-family: var(--font-ui); 596 font-size: var(--font-size-ui); 597 font-weight: 500; 598 margin-bottom: 0.25rem; 599 } 600 601 input, 602 textarea { 603 width: 100%; 604 padding: 0.5rem; 605 background: var(--bg-secondary); 606 border: 1px solid var(--border); 607 border-radius: 6px; 608 color: var(--text); 609 font-family: inherit; 610 font-size: 1rem; 611 } 612 613 input:focus, 614 textarea:focus { 615 outline: none; 616 border-color: var(--accent); 617 } 618 619 textarea { 620 min-height: 300px; 621 resize: vertical; 622 font-family: var(--font-mono); 623 } 624 625 .form-actions { 626 display: flex; 627 gap: 0.5rem; 628 justify-content: flex-end; 629 } 630 631 /* View mode */ 632 .doc-view { 633 padding: 1rem 0; 634 } 635 636 .doc-view h2 { 637 font-size: 2rem; 638 margin-bottom: 0.5rem; 639 } 640 641 .doc-view .meta { 642 font-family: var(--font-ui); 643 display: flex; 644 align-items: center; 645 gap: 0.5rem; 646 color: var(--text-muted); 647 font-size: 0.875rem; 648 margin-bottom: 1.5rem; 649 padding-bottom: 1rem; 650 border-bottom: 1px solid var(--border); 651 } 652 653 .doc-view .body { 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; 681 } 682 683 .doc-view .body qs-tangled-repo-card { 684 display: block; 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; 745 } 746 747 .empty-state { 748 text-align: center; 749 padding: 3rem; 750 color: var(--text-muted); 751 font-family: var(--font-ui); 752 font-size: var(--font-size-ui); 753 } 754 755 .user-info { 756 display: inline-flex; 757 align-items: center; 758 gap: 0.5rem; 759 font-family: var(--font-ui); 760 font-size: var(--font-size-ui); 761 } 762 763 .user-avatar { 764 width: 24px; 765 height: 24px; 766 border-radius: 50%; 767 } 768 769 .user-avatar-sm { 770 width: 18px; 771 height: 18px; 772 vertical-align: middle; 773 margin-right: 0.25rem; 774 } 775 776 /* Loading/error states */ 777 .loading, 778 .error { 779 text-align: center; 780 padding: 2rem; 781 color: var(--text-muted); 782 } 783 784 .error { 785 color: var(--danger); 786 } 787 788 .hidden { 789 display: none; 790 } 791 792 /* Login form */ 793 .dialog-overlay { 794 position: fixed; 795 inset: 0; 796 background: rgba(0, 0, 0, 0.5); 797 display: flex; 798 align-items: center; 799 justify-content: center; 800 z-index: 1000; 801 } 802 803 .dialog { 804 background: var(--bg); 805 border: 1px solid var(--border); 806 border-radius: 8px; 807 box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); 808 max-width: 400px; 809 width: 90%; 810 overflow: visible; 811 } 812 813 .dialog-header { 814 display: flex; 815 align-items: center; 816 justify-content: space-between; 817 padding: 1rem 1.5rem; 818 border-bottom: 1px solid var(--border); 819 } 820 821 .dialog-header h2 { 822 margin: 0; 823 font-size: 1.125rem; 824 } 825 826 .dialog-close { 827 background: none; 828 border: none; 829 font-size: 1.5rem; 830 cursor: pointer; 831 color: var(--text-muted); 832 padding: 0; 833 line-height: 1; 834 } 835 836 .dialog-close:hover { 837 color: var(--text); 838 } 839 840 .dialog-body { 841 padding: 1.5rem; 842 } 843 844 /* Info button */ 845 .btn-info { 846 width: 24px; 847 height: 24px; 848 padding: 0; 849 border-radius: 50%; 850 font-size: 0.875rem; 851 font-weight: 600; 852 background: var(--bg-hover); 853 color: var(--text-muted); 854 border: 1px solid var(--border); 855 cursor: pointer; 856 display: inline-flex; 857 align-items: center; 858 justify-content: center; 859 } 860 861 .btn-info:hover { 862 background: var(--border); 863 color: var(--text); 864 } 865 866 /* Info modal */ 867 .dialog.dialog-wide { 868 max-width: 480px; 869 } 870 871 .info-section { 872 font-family: var(--font-ui); 873 font-size: var(--font-size-ui); 874 margin-bottom: 1.25rem; 875 } 876 877 .info-section:last-child { 878 margin-bottom: 0; 879 } 880 881 .info-section h3 { 882 font-size: var(--font-size-ui); 883 font-weight: 600; 884 margin-bottom: 0.25rem; 885 } 886 887 .info-section p { 888 color: var(--text-muted); 889 line-height: 1.5; 890 } 891 892 .info-section a { 893 color: var(--accent); 894 } 895 896 .info-section code { 897 font-family: var(--font-mono); 898 background: var(--bg-hover); 899 padding: 0.125rem 0.375rem; 900 border-radius: 0.25rem; 901 font-size: 0.8125rem; 902 } 903 904 .lexicon-list { 905 margin-top: 0.5rem; 906 display: flex; 907 flex-direction: column; 908 gap: 0.375rem; 909 } 910 911 .lexicon-list code { 912 font-weight: 600; 913 } 914 915 .lexicon-list .desc { 916 color: var(--text-muted); 917 font-size: 0.75rem; 918 } 919 920 .login-form { 921 font-family: var(--font-ui); 922 font-size: var(--font-size-ui); 923 } 924 925 .login-form h2 { 926 margin-bottom: 1rem; 927 } 928 929 /* qs-actor-autocomplete styling */ 930 qs-actor-autocomplete { 931 --qs-input-bg: var(--bg-secondary); 932 --qs-input-border: var(--border); 933 --qs-input-border-focus: var(--accent); 934 --qs-input-text: var(--text); 935 --qs-input-placeholder: var(--text-muted); 936 --qs-dropdown-bg: var(--bg-secondary); 937 --qs-dropdown-border: var(--border); 938 --qs-item-hover-bg: var(--bg-hover); 939 --qs-item-text: var(--text); 940 --qs-item-secondary-text: var(--text-muted); 941 display: block; 942 margin-bottom: 1rem; 943 } 944 </style> 945 </head> 946 <body> 947 <div class="container"> 948 <div id="app"> 949 <div class="loading">Loading...</div> 950 </div> 951 </div> 952 953 <script type="module"> 954 import { parseFacets, renderFacetedText, facetsToDom, domToFacets, BlockTypes } from "/richtext.js"; 955 956 // Parse facets from API response (may be JSON strings or objects) 957 function parseFacetsFromApi(facets) { 958 if (!facets || !Array.isArray(facets)) return []; 959 return facets.map(f => { 960 if (typeof f === 'string') { 961 try { return JSON.parse(f); } catch { return null; } 962 } 963 return f; 964 }).filter(Boolean); 965 } 966 967 const SERVER_URL = "https://quickslice-production-cc52.up.railway.app"; 968 const CLIENT_ID = "client_k6mx8qqN2Xj6afF2WTikxA"; 969 const TANGLED_QUICKSLICE_INSTANCE = "https://quickslice-production-ddc3.up.railway.app"; 970 971 // State 972 const state = { 973 view: "loading", // loading, login, list, view, edit, create 974 documents: [], 975 currentDoc: null, 976 viewer: null, 977 error: null, 978 client: null, 979 }; 980 981 // Editor state (only used during editing) 982 const editorState = { 983 blocks: [], // Array of { id, type, element } during editing 984 blockMap: new WeakMap(), // element → blockData for O(1) lookups 985 slashMenuOpen: false, 986 slashMenuIndex: 0, 987 // Auto-save fields 988 saveTimeout: null, 989 saveStatus: "saved", // "saved" | "saving" | "error" | "dirty" 990 saveVersion: 0, // Increments on each edit, used to detect race conditions 991 slugOverride: null, // Manual slug override, null = auto-derive 992 isNewDoc: false, // True if doc hasn't been saved yet 993 lastSavedAt: null, 994 }; 995 996 // O(1) lookup for block data by element 997 function getBlockData(element) { 998 return editorState.blockMap.get(element); 999 } 1000 1001 function getCaretOffset(element) { 1002 const selection = window.getSelection(); 1003 if (!selection.rangeCount) return 0; 1004 const range = selection.getRangeAt(0); 1005 const preRange = range.cloneRange(); 1006 preRange.selectNodeContents(element); 1007 preRange.setEnd(range.startContainer, range.startOffset); 1008 return preRange.toString().length; 1009 } 1010 1011 function setCaretOffset(element, offset) { 1012 const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); 1013 let currentOffset = 0; 1014 let node; 1015 while ((node = walker.nextNode())) { 1016 const len = node.textContent.length; 1017 if (currentOffset + len >= offset) { 1018 const range = document.createRange(); 1019 range.setStart(node, offset - currentOffset); 1020 range.collapse(true); 1021 const sel = window.getSelection(); 1022 sel.removeAllRanges(); 1023 sel.addRange(range); 1024 return; 1025 } 1026 currentOffset += len; 1027 } 1028 } 1029 1030 const COMMON_LANGUAGES = [ 1031 'javascript', 'typescript', 'python', 'go', 'rust', 'java', 1032 'cpp', 'c', 'csharp', 'ruby', 'php', 'swift', 'kotlin', 1033 'bash', 'shell', 'json', 'html', 'css', 'sql', 'graphql' 1034 ]; 1035 1036 const CODE_HIGHLIGHT_DEBOUNCE_MS = 800; 1037 1038 // Clear syntax highlighting while preserving cursor position 1039 function clearCodeHighlightingPreserveCursor(codeEl) { 1040 const sel = window.getSelection(); 1041 let cursorOffset = 0; 1042 1043 // Save cursor offset before rebuild 1044 if (sel && sel.rangeCount) { 1045 try { 1046 const range = sel.getRangeAt(0); 1047 const preRange = document.createRange(); 1048 preRange.selectNodeContents(codeEl); 1049 preRange.setEnd(range.startContainer, range.startOffset); 1050 cursorOffset = preRange.toString().length; 1051 } catch (e) { 1052 // Selection might be outside element, ignore 1053 } 1054 } 1055 1056 const code = codeEl.textContent; 1057 codeEl.textContent = code; // This clears formatting but keeps text 1058 1059 // Restore cursor to saved offset 1060 const textNode = codeEl.firstChild; 1061 if (textNode && textNode.nodeType === Node.TEXT_NODE && sel) { 1062 try { 1063 const newRange = document.createRange(); 1064 const offset = Math.min(cursorOffset, textNode.length); 1065 newRange.setStart(textNode, offset); 1066 newRange.collapse(true); 1067 sel.removeAllRanges(); 1068 sel.addRange(newRange); 1069 } catch (e) { 1070 // Cursor restoration failed, ignore 1071 } 1072 } 1073 } 1074 1075 function applyCodeHighlighting(element, lang = "") { 1076 const code = element.textContent; 1077 if (!code.trim()) return; 1078 1079 const caretOffset = getCaretOffset(element); 1080 1081 try { 1082 let result; 1083 if (lang && hljs.getLanguage(lang)) { 1084 result = hljs.highlight(code, { language: lang }); 1085 } else { 1086 // Use subset of common languages for better detection 1087 result = hljs.highlightAuto(code, COMMON_LANGUAGES); 1088 } 1089 element.innerHTML = result.value; 1090 } catch (e) { 1091 // Fallback to plain text on error 1092 return; 1093 } 1094 1095 setCaretOffset(element, caretOffset); 1096 } 1097 1098 function generateBlockId() { 1099 return 'block-' + Math.random().toString(36).substr(2, 9); 1100 } 1101 1102 function initBlockEditor(blocks = [], title = "") { 1103 const editor = document.getElementById("block-editor"); 1104 if (!editor) return; 1105 1106 editorState.blocks = []; 1107 editorState.blockMap = new WeakMap(); 1108 editorState.slashMenuOpen = false; 1109 editor.innerHTML = ""; 1110 1111 // Always add title block first 1112 addBlock("title", title, null, false); 1113 1114 // Add content blocks (skip if first block is a heading that matches title) 1115 let blocksToAdd = blocks; 1116 if (blocks.length > 0 && BlockTypes.isHeading(blocks[0].__typename) && blocks[0].text === title) { 1117 blocksToAdd = blocks.slice(1); 1118 } 1119 1120 if (blocksToAdd.length === 0) { 1121 // Add empty paragraph for new docs 1122 addBlock("paragraph", "", null, false); 1123 } else { 1124 for (const block of blocksToAdd) { 1125 const type = block.__typename || ""; 1126 const facets = parseFacetsFromApi(block.facets); 1127 if (BlockTypes.isParagraph(type)) { 1128 addBlock("paragraph", block.text, facets); 1129 } else if (BlockTypes.isHeading(type)) { 1130 addBlock("heading", block.text, facets, false, block.level); 1131 } else if (BlockTypes.isCodeBlock(type)) { 1132 addBlock("codeBlock", block.code, null, false, null, block.lang); 1133 } else if (BlockTypes.isQuote(type)) { 1134 addBlock("quote", block.text, facets); 1135 } else if (type.endsWith("TangledEmbed")) { 1136 addTangledEmbedBlock(block.handle, block.repo); 1137 } else if (BlockTypes.isImageEmbed(type)) { 1138 addImageBlock(block.image, block.alt || ""); 1139 } 1140 } 1141 } 1142 1143 // Focus title if empty, otherwise first content block 1144 const titleBlock = editorState.blocks[0]; 1145 if (!titleBlock.element.textContent) { 1146 titleBlock.element.focus(); 1147 } else if (editorState.blocks.length > 1) { 1148 editorState.blocks[1].element.focus(); 1149 } 1150 1151 // Setup paste handler for images 1152 setupEditorPasteHandler(); 1153 } 1154 1155 function addBlock(type, text = "", facets = null, focus = false, level = 1, lang = "") { 1156 const editor = document.getElementById("block-editor"); 1157 const id = generateBlockId(); 1158 const abortController = new AbortController(); 1159 const signal = abortController.signal; 1160 1161 const div = document.createElement("div"); 1162 div.id = id; 1163 1164 if (type === "title") { 1165 div.className = "block title"; 1166 div.dataset.type = "title"; 1167 div.contentEditable = "true"; 1168 div.dataset.placeholder = "Untitled"; 1169 if (text) { 1170 div.textContent = text; 1171 } 1172 div.addEventListener("keydown", handleBlockKeydown, { signal }); 1173 div.addEventListener("input", handleBlockInput, { signal }); 1174 div.addEventListener("paste", (e) => handleBlockPaste(e, div), { signal }); 1175 div.addEventListener("dblclick", handleBlockDblClick, { signal }); 1176 editor.appendChild(div); 1177 const blockData = { id, type: "title", element: div, abortController }; 1178 editorState.blocks.push(blockData); 1179 editorState.blockMap.set(div, blockData); 1180 if (focus) { 1181 div.focus(); 1182 } 1183 return div; 1184 } 1185 1186 div.className = `block ${type}${type === "heading" ? `-${level}` : ""}`; 1187 div.dataset.type = type; 1188 if (type === "heading") div.dataset.level = level; 1189 if (type === "codeBlock") div.dataset.lang = lang; 1190 1191 if (type === "codeBlock") { 1192 // Code blocks: select outside contentEditable, code inside 1193 const select = document.createElement("select"); 1194 select.className = "code-lang-select"; 1195 select.innerHTML = ` 1196 <option value="">auto</option> 1197 ${COMMON_LANGUAGES.map(l => `<option value="${esc(l)}"${l === lang ? ' selected' : ''}>${esc(l)}</option>`).join('')} 1198 `; 1199 1200 const codeEl = document.createElement("code"); 1201 codeEl.className = "code-content"; 1202 codeEl.contentEditable = "true"; 1203 codeEl.spellcheck = false; 1204 1205 if (text) { 1206 codeEl.textContent = text; 1207 } 1208 1209 div.appendChild(select); 1210 div.appendChild(codeEl); 1211 1212 select.addEventListener("change", (e) => { 1213 const blockData = getBlockData(div); 1214 if (blockData) { 1215 blockData.lang = e.target.value; 1216 div.dataset.lang = e.target.value; 1217 applyCodeHighlighting(codeEl, e.target.value); 1218 triggerAutoSave(); 1219 } 1220 }, { signal }); 1221 1222 // Apply initial highlighting if there's text 1223 if (text) { 1224 applyCodeHighlighting(codeEl, lang); 1225 } 1226 1227 // Debounced highlighting on input - timer stored in blockData for cleanup 1228 codeEl.addEventListener("input", () => { 1229 const bd = getBlockData(div); 1230 if (bd?.highlightTimer) clearTimeout(bd.highlightTimer); 1231 1232 // Clear highlighting immediately when typing starts 1233 if (codeEl.querySelector("span[class*='hljs-']")) { 1234 clearCodeHighlightingPreserveCursor(codeEl); 1235 } 1236 1237 // Re-apply highlighting after debounce 1238 if (bd) { 1239 bd.highlightTimer = setTimeout(() => { 1240 if (document.activeElement === codeEl) { 1241 applyCodeHighlighting(codeEl, div.dataset.lang || ""); 1242 } 1243 }, CODE_HIGHLIGHT_DEBOUNCE_MS); 1244 } 1245 }, { signal }); 1246 1247 // Store reference to code element for focus handling 1248 div._codeContent = codeEl; 1249 } else { 1250 div.contentEditable = "true"; 1251 if (text && facets) { 1252 div.innerHTML = facetsToDom(text, facets); 1253 } else if (text) { 1254 div.textContent = text; 1255 } 1256 } 1257 1258 // Placeholder for empty paragraphs 1259 if (type === "paragraph") { 1260 div.dataset.placeholder = "Type '/' for commands..."; 1261 } 1262 1263 // Event listeners - for code blocks, attach to codeEl; otherwise to div 1264 const eventTarget = (type === "codeBlock" && div._codeContent) ? div._codeContent : div; 1265 eventTarget.addEventListener("keydown", handleBlockKeydown, { signal }); 1266 eventTarget.addEventListener("input", handleBlockInput, { signal }); 1267 eventTarget.addEventListener("paste", (e) => handleBlockPaste(e, div), { signal }); 1268 div.addEventListener("dblclick", handleBlockDblClick, { signal }); 1269 1270 editor.appendChild(div); 1271 const blockData = { id, type, element: div, level, lang, abortController }; 1272 editorState.blocks.push(blockData); 1273 editorState.blockMap.set(div, blockData); 1274 1275 if (focus) { 1276 div.focus(); 1277 } 1278 1279 return div; 1280 } 1281 1282 function addTangledEmbedBlock(handle, repo) { 1283 const editor = document.getElementById("block-editor"); 1284 const id = generateBlockId(); 1285 const abortController = new AbortController(); 1286 const signal = abortController.signal; 1287 1288 const div = document.createElement("div"); 1289 div.id = id; 1290 div.className = "block tangledEmbed"; 1291 div.dataset.type = "tangledEmbed"; 1292 div.dataset.editing = "false"; 1293 div.dataset.handle = handle; 1294 div.dataset.repo = repo; 1295 div.contentEditable = "false"; 1296 div.tabIndex = 0; // Make focusable when not editing 1297 div.innerHTML = `<qs-tangled-repo-card handle="${handle}" repo="${repo}" instance="${TANGLED_QUICKSLICE_INSTANCE}"></qs-tangled-repo-card>`; 1298 1299 div.addEventListener("keydown", handleBlockKeydown, { signal }); 1300 div.addEventListener("input", handleBlockInput, { signal }); 1301 div.addEventListener("paste", (e) => handleBlockPaste(e, div), { signal }); 1302 div.addEventListener("dblclick", handleBlockDblClick, { signal }); 1303 div.addEventListener("click", () => div.focus(), { signal }); // Ensure focus on click 1304 1305 editor.appendChild(div); 1306 const blockData = { id, type: "tangledEmbed", element: div, abortController }; 1307 editorState.blocks.push(blockData); 1308 editorState.blockMap.set(div, blockData); 1309 1310 return div; 1311 } 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 1656 function handleBlockKeydown(e) { 1657 // Get the block element - if event is from code-content, find parent block 1658 let block = e.currentTarget; 1659 if (block.classList.contains("code-content")) { 1660 block = block.parentElement; 1661 } 1662 const blockData = getBlockData(block); 1663 if (!blockData) return; 1664 1665 // Special handling for title block 1666 if (blockData.type === "title") { 1667 if (e.key === "Enter") { 1668 e.preventDefault(); 1669 // Move focus to first content block or create one 1670 if (editorState.blocks.length > 1) { 1671 editorState.blocks[1].element.focus(); 1672 } else { 1673 insertBlockAfter(0, "paragraph"); 1674 } 1675 return; 1676 } 1677 if (e.key === "Backspace" && block.textContent === "") { 1678 e.preventDefault(); // Don't delete title block 1679 return; 1680 } 1681 if (e.key === "ArrowDown") { 1682 e.preventDefault(); 1683 if (editorState.blocks.length > 1) { 1684 editorState.blocks[1].element.focus(); 1685 } 1686 return; 1687 } 1688 // Allow normal typing, but no slash commands in title 1689 return; 1690 } 1691 1692 // Handle non-editable blocks (embeds) - generic keyboard navigation 1693 // Note: codeBlocks have contentEditable=false on outer, but editable inner element 1694 if (block.contentEditable === "false" && blockData.type !== "codeBlock") { 1695 const index = editorState.blocks.indexOf(blockData); 1696 1697 if (e.key === "Enter") { 1698 e.preventDefault(); 1699 const newBlock = insertBlockAfter(index, "paragraph"); 1700 newBlock.focus(); 1701 return; 1702 } 1703 if (e.key === "ArrowUp") { 1704 e.preventDefault(); 1705 focusPreviousBlock(blockData); 1706 return; 1707 } 1708 if (e.key === "ArrowDown") { 1709 e.preventDefault(); 1710 focusNextBlock(blockData); 1711 return; 1712 } 1713 if (e.key === "Backspace" || e.key === "Delete") { 1714 e.preventDefault(); 1715 deleteBlock(index); 1716 return; 1717 } 1718 // Letter key enters edit mode (type-specific) 1719 if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) { 1720 e.preventDefault(); 1721 enterEmbedEditMode(block, blockData); 1722 return; 1723 } 1724 return; 1725 } 1726 1727 // Handle slash menu navigation if open 1728 if (editorState.slashMenuOpen) { 1729 if (e.key === "ArrowDown") { 1730 e.preventDefault(); 1731 navigateSlashMenu(1); 1732 return; 1733 } else if (e.key === "ArrowUp") { 1734 e.preventDefault(); 1735 navigateSlashMenu(-1); 1736 return; 1737 } else if (e.key === "Enter") { 1738 e.preventDefault(); 1739 selectSlashMenuItem(); 1740 return; 1741 } else if (e.key === "Escape") { 1742 e.preventDefault(); 1743 closeSlashMenu(); 1744 return; 1745 } 1746 } 1747 1748 // Enter: create new paragraph (or newline in code blocks) 1749 if (e.key === "Enter") { 1750 // Handle Enter in tangledEmbed editing mode 1751 if (blockData.type === "tangledEmbed" && block.dataset.editing === "true") { 1752 e.preventDefault(); 1753 if (renderTangledEmbed(block)) { 1754 // Success - insert new paragraph after 1755 const newBlock = insertBlockAfter(editorState.blocks.indexOf(blockData), "paragraph"); 1756 newBlock.focus(); 1757 } 1758 return; 1759 } 1760 1761 if (blockData.type === "codeBlock") { 1762 if (e.metaKey || e.ctrlKey) { 1763 // Cmd/Ctrl+Enter: exit code block, create new paragraph 1764 e.preventDefault(); 1765 const index = editorState.blocks.indexOf(blockData); 1766 insertBlockAfter(index, "paragraph"); 1767 return; 1768 } 1769 // Regular Enter: insert newline manually to preserve it 1770 e.preventDefault(); 1771 const selection = window.getSelection(); 1772 const range = selection.getRangeAt(0); 1773 range.deleteContents(); 1774 const newline = document.createTextNode("\n"); 1775 range.insertNode(newline); 1776 range.setStartAfter(newline); 1777 range.collapse(true); 1778 selection.removeAllRanges(); 1779 selection.addRange(range); 1780 // Trigger input event for highlighting on code-content 1781 const codeEl = block._codeContent || block.querySelector(".code-content"); 1782 if (codeEl) codeEl.dispatchEvent(new Event("input", { bubbles: true })); 1783 return; 1784 } 1785 if (!e.shiftKey) { 1786 e.preventDefault(); 1787 const index = editorState.blocks.indexOf(blockData); 1788 splitBlockAtCursor(block, blockData, index); 1789 } 1790 } 1791 1792 // Backspace at start of empty block: delete block 1793 if (e.key === "Backspace") { 1794 const selection = window.getSelection(); 1795 const isAtStart = selection.anchorOffset === 0 && selection.isCollapsed; 1796 // For code blocks, check the code-content element 1797 const textContent = blockData.type === "codeBlock" 1798 ? (block._codeContent || block.querySelector(".code-content"))?.textContent || "" 1799 : block.textContent; 1800 const isEmpty = textContent === ""; 1801 1802 if (isEmpty && editorState.blocks.length > 1) { 1803 e.preventDefault(); 1804 const index = editorState.blocks.indexOf(blockData); 1805 deleteBlock(index); 1806 } else if (isAtStart && editorState.blocks.indexOf(blockData) > 0 && blockData.type !== "codeBlock") { 1807 // Merge with previous block if same type (not for code blocks) 1808 e.preventDefault(); 1809 const index = editorState.blocks.indexOf(blockData); 1810 mergeWithPrevious(index); 1811 } 1812 // For code blocks with content, let default backspace behavior work 1813 } 1814 1815 // Keyboard shortcuts for formatting 1816 if ((e.metaKey || e.ctrlKey) && blockData.type !== "codeBlock") { 1817 if (e.key === "b") { 1818 e.preventDefault(); 1819 wrapSelectionWithTag("strong"); 1820 } else if (e.key === "i") { 1821 e.preventDefault(); 1822 wrapSelectionWithTag("em"); 1823 } else if (e.key === "e") { 1824 e.preventDefault(); 1825 wrapSelectionWithTag("code"); 1826 } else if (e.key === "k") { 1827 e.preventDefault(); 1828 insertLink(); 1829 } 1830 } 1831 1832 // Arrow keys for block navigation 1833 if (e.key === "ArrowUp" || e.key === "ArrowDown") { 1834 const selection = window.getSelection(); 1835 if (!selection.rangeCount) return; 1836 const range = selection.getRangeAt(0); 1837 1838 // For code blocks, use the code-content element; otherwise use block 1839 const contentEl = blockData.type === "codeBlock" 1840 ? (block._codeContent || block.querySelector(".code-content")) 1841 : block; 1842 1843 if (!contentEl) return; 1844 1845 const textLength = contentEl.textContent.length; 1846 1847 // Get cursor offset from start of content element 1848 let cursorOffset = 0; 1849 const treeWalker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT); 1850 let node; 1851 while ((node = treeWalker.nextNode())) { 1852 if (node === range.startContainer) { 1853 cursorOffset += range.startOffset; 1854 break; 1855 } 1856 cursorOffset += node.textContent.length; 1857 } 1858 1859 const atStart = cursorOffset === 0; 1860 const atEnd = cursorOffset >= textLength; 1861 1862 if (e.key === "ArrowUp" && atStart) { 1863 e.preventDefault(); 1864 focusPreviousBlock(blockData); 1865 } else if (e.key === "ArrowDown" && atEnd) { 1866 e.preventDefault(); 1867 focusNextBlock(blockData); 1868 } 1869 } 1870 } 1871 1872 function insertBlockAfter(index, type, level = 1) { 1873 const editor = document.getElementById("block-editor"); 1874 const newBlock = document.createElement("div"); 1875 const id = generateBlockId(); 1876 const abortController = new AbortController(); 1877 const signal = abortController.signal; 1878 1879 newBlock.id = id; 1880 newBlock.className = `block ${type}${type === "heading" ? `-${level}` : ""}`; 1881 newBlock.dataset.type = type; 1882 newBlock.contentEditable = "true"; 1883 if (type === "paragraph") { 1884 newBlock.dataset.placeholder = "Type '/' for commands..."; 1885 } 1886 if (type === "heading") newBlock.dataset.level = level; 1887 1888 newBlock.addEventListener("keydown", handleBlockKeydown, { signal }); 1889 newBlock.addEventListener("input", handleBlockInput, { signal }); 1890 newBlock.addEventListener("paste", handleBlockPaste, { signal }); 1891 newBlock.addEventListener("dblclick", handleBlockDblClick, { signal }); 1892 1893 const nextBlock = editorState.blocks[index + 1]; 1894 if (nextBlock) { 1895 editor.insertBefore(newBlock, nextBlock.element); 1896 } else { 1897 editor.appendChild(newBlock); 1898 } 1899 1900 const blockData = { id, type, element: newBlock, level, abortController }; 1901 editorState.blocks.splice(index + 1, 0, blockData); 1902 editorState.blockMap.set(newBlock, blockData); 1903 newBlock.focus(); 1904 triggerAutoSave(); 1905 return newBlock; 1906 } 1907 1908 function deleteBlock(index) { 1909 if (index < 0 || index >= editorState.blocks.length) return; 1910 if (editorState.blocks[index].type === "title") return; // Never delete title 1911 1912 const block = editorState.blocks[index]; 1913 // Abort all event listeners for this block 1914 if (block.abortController) { 1915 block.abortController.abort(); 1916 } 1917 editorState.blockMap.delete(block.element); 1918 block.element.remove(); 1919 editorState.blocks.splice(index, 1); 1920 1921 // Focus previous or next block 1922 const focusIndex = Math.max(0, index - 1); 1923 if (editorState.blocks[focusIndex]) { 1924 editorState.blocks[focusIndex].element.focus(); 1925 } 1926 triggerAutoSave(); 1927 } 1928 1929 function splitBlockAtCursor(block, blockData, index) { 1930 // Code blocks handle Enter differently (newlines, not split) 1931 if (blockData.type === "codeBlock") { 1932 return; 1933 } 1934 1935 const selection = window.getSelection(); 1936 if (!selection.rangeCount) { 1937 // No cursor, just create empty paragraph 1938 const newBlock = insertBlockAfter(index, "paragraph"); 1939 newBlock.focus(); 1940 return; 1941 } 1942 1943 const range = selection.getRangeAt(0); 1944 1945 // Create a range from cursor to end of block 1946 const rangeToEnd = document.createRange(); 1947 rangeToEnd.selectNodeContents(block); // Select entire block first 1948 rangeToEnd.setStart(range.startContainer, range.startOffset); // Then set start to cursor 1949 1950 // Extract content after cursor (removes it from current block) 1951 const extractedContent = rangeToEnd.extractContents(); 1952 1953 // Create new block - paragraphs stay paragraphs, others become paragraphs 1954 const newType = "paragraph"; 1955 const newBlock = insertBlockAfter(index, newType); 1956 1957 // Move extracted content to new block (if any) 1958 if (extractedContent.childNodes.length > 0) { 1959 newBlock.appendChild(extractedContent); 1960 } 1961 1962 // Focus new block at start 1963 newBlock.focus(); 1964 const newRange = document.createRange(); 1965 if (newBlock.firstChild) { 1966 newRange.setStart(newBlock.firstChild, 0); 1967 } else { 1968 newRange.setStart(newBlock, 0); 1969 } 1970 newRange.collapse(true); 1971 selection.removeAllRanges(); 1972 selection.addRange(newRange); 1973 1974 triggerAutoSave(); 1975 } 1976 1977 function mergeWithPrevious(index) { 1978 if (index === 0) return; 1979 1980 const current = editorState.blocks[index]; 1981 const previous = editorState.blocks[index - 1]; 1982 1983 // Only merge text blocks 1984 if (current.type === "codeBlock" || previous.type === "codeBlock") return; 1985 1986 const prevLength = previous.element.textContent.length; 1987 previous.element.innerHTML += current.element.innerHTML; 1988 current.element.remove(); 1989 editorState.blocks.splice(index, 1); 1990 1991 // Set cursor at merge point 1992 previous.element.focus(); 1993 const range = document.createRange(); 1994 const sel = window.getSelection(); 1995 const textNode = findTextNodeAtOffset(previous.element, prevLength); 1996 if (textNode) { 1997 range.setStart(textNode.node, textNode.offset); 1998 range.collapse(true); 1999 sel.removeAllRanges(); 2000 sel.addRange(range); 2001 } 2002 } 2003 2004 function findTextNodeAtOffset(element, targetOffset) { 2005 let offset = 0; 2006 const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); 2007 let node; 2008 while ((node = walker.nextNode())) { 2009 const len = node.textContent.length; 2010 if (offset + len >= targetOffset) { 2011 return { node, offset: targetOffset - offset }; 2012 } 2013 offset += len; 2014 } 2015 return null; 2016 } 2017 2018 function focusPreviousBlock(current) { 2019 const index = editorState.blocks.indexOf(current); 2020 if (index > 0) { 2021 const prev = editorState.blocks[index - 1]; 2022 // For code blocks, focus the code-content element 2023 const focusTarget = prev.type === "codeBlock" 2024 ? prev.element._codeContent || prev.element.querySelector(".code-content") 2025 : prev.element; 2026 2027 if (focusTarget) { 2028 focusTarget.focus(); 2029 if (focusTarget.contentEditable === "true") { 2030 const range = document.createRange(); 2031 range.selectNodeContents(focusTarget); 2032 range.collapse(false); // End of block 2033 const sel = window.getSelection(); 2034 sel.removeAllRanges(); 2035 sel.addRange(range); 2036 } 2037 } 2038 } 2039 } 2040 2041 function focusNextBlock(current) { 2042 const index = editorState.blocks.indexOf(current); 2043 if (index < editorState.blocks.length - 1) { 2044 const next = editorState.blocks[index + 1]; 2045 // For code blocks, focus the code-content element 2046 const focusTarget = next.type === "codeBlock" 2047 ? next.element._codeContent || next.element.querySelector(".code-content") 2048 : next.element; 2049 2050 if (focusTarget) { 2051 focusTarget.focus(); 2052 if (focusTarget.contentEditable === "true") { 2053 const range = document.createRange(); 2054 range.selectNodeContents(focusTarget); 2055 range.collapse(true); // Start of block 2056 const sel = window.getSelection(); 2057 sel.removeAllRanges(); 2058 sel.addRange(range); 2059 } 2060 } 2061 } 2062 } 2063 2064 // Slash commands 2065 const SLASH_COMMANDS = [ 2066 { id: "paragraph", label: "Paragraph", icon: "P", description: "Plain text" }, 2067 { id: "heading1", label: "Heading 1", icon: "H1", description: "Large heading" }, 2068 { id: "heading2", label: "Heading 2", icon: "H2", description: "Medium heading" }, 2069 { id: "heading3", label: "Heading 3", icon: "H3", description: "Small heading" }, 2070 { id: "code", label: "Code Block", icon: "</>", description: "Code snippet" }, 2071 { id: "quote", label: "Quote", icon: '"', description: "Blockquote" }, 2072 { id: "image", label: "Image", icon: "🖼", description: "Upload an image" }, 2073 { id: "tangled", label: "Tangled Repo", icon: "🐑", description: "Embed a repo card" }, 2074 ]; 2075 2076 function handleBlockInput(e) { 2077 // Get the block element - if event is from code-content, find parent block 2078 let block = e.currentTarget; 2079 if (block.classList.contains("code-content")) { 2080 block = block.parentElement; 2081 } 2082 const blockData = getBlockData(block); 2083 2084 // Trigger auto-save on any content change 2085 triggerAutoSave(); 2086 2087 // Skip special handling for code blocks (they have their own input handler) 2088 if (blockData?.type === "codeBlock") return; 2089 2090 const text = block.textContent; 2091 2092 // Check for slash command trigger 2093 if (text === "/") { 2094 openSlashMenu(block); 2095 return; 2096 } 2097 2098 // Filter slash menu if open 2099 if (editorState.slashMenuOpen && text.startsWith("/")) { 2100 const filter = text.slice(1).toLowerCase(); 2101 updateSlashMenuFilter(filter); 2102 return; 2103 } 2104 2105 // Close slash menu if text doesn't start with / 2106 if (editorState.slashMenuOpen && !text.startsWith("/")) { 2107 closeSlashMenu(); 2108 } 2109 2110 // Check for triple backtick to enter code block mode 2111 if (text === "```" || text.startsWith("```\n") || text.startsWith("```")) { 2112 const blockData = getBlockData(block); 2113 if (blockData && blockData.type !== "codeBlock") { 2114 // Extract any language hint after ``` 2115 const match = text.match(/^```(\w*)/); 2116 const lang = match?.[1] || ""; 2117 2118 // Clear the backticks BEFORE converting (so select isn't wiped) 2119 block.textContent = ""; 2120 convertBlock(blockData, "codeBlock"); 2121 blockData.lang = lang; 2122 block.dataset.lang = lang; 2123 2124 // Select the language in the dropdown if specified 2125 if (lang) { 2126 const select = block.querySelector(".code-lang-select"); 2127 if (select) select.value = lang; 2128 } 2129 2130 // Focus the code-content element 2131 const codeEl = block._codeContent || block; 2132 codeEl.focus(); 2133 return; 2134 } 2135 } 2136 2137 // Check for markdown auto-conversion 2138 checkMarkdownConversion(block); 2139 } 2140 2141 function openSlashMenu(block) { 2142 const menu = document.getElementById("slash-menu"); 2143 2144 // Get cursor position 2145 const selection = window.getSelection(); 2146 let top, left; 2147 2148 if (selection.rangeCount > 0) { 2149 const range = selection.getRangeAt(0); 2150 const rect = range.getBoundingClientRect(); 2151 top = rect.bottom + window.scrollY + 5; 2152 left = rect.left + window.scrollX; 2153 } else { 2154 // Fallback to block position 2155 const blockRect = block.getBoundingClientRect(); 2156 top = blockRect.bottom + window.scrollY + 5; 2157 left = blockRect.left + window.scrollX; 2158 } 2159 2160 menu.style.top = `${top}px`; 2161 menu.style.left = `${left}px`; 2162 2163 editorState.slashMenuOpen = true; 2164 editorState.slashMenuIndex = 0; 2165 editorState.slashMenuBlock = block; 2166 editorState.slashMenuFilter = ""; 2167 2168 renderSlashMenu(SLASH_COMMANDS); 2169 menu.classList.remove("hidden"); 2170 } 2171 2172 function closeSlashMenu() { 2173 const menu = document.getElementById("slash-menu"); 2174 menu.classList.add("hidden"); 2175 editorState.slashMenuOpen = false; 2176 editorState.slashMenuBlock = null; 2177 } 2178 2179 function renderSlashMenu(commands) { 2180 const menu = document.getElementById("slash-menu"); 2181 menu.innerHTML = commands 2182 .map( 2183 (cmd, i) => ` 2184 <div class="slash-menu-item${i === editorState.slashMenuIndex ? " selected" : ""}" 2185 data-command="${cmd.id}" 2186 onclick="DocApp.executeSlashCommand('${cmd.id}')"> 2187 <span class="icon">${cmd.icon}</span> 2188 <span>${cmd.label}</span> 2189 </div> 2190 ` 2191 ) 2192 .join(""); 2193 } 2194 2195 function updateSlashMenuFilter(filter) { 2196 const filtered = SLASH_COMMANDS.filter( 2197 cmd => 2198 cmd.label.toLowerCase().includes(filter) || 2199 cmd.description.toLowerCase().includes(filter) 2200 ); 2201 editorState.slashMenuIndex = 0; 2202 renderSlashMenu(filtered); 2203 2204 if (filtered.length === 0) { 2205 closeSlashMenu(); 2206 } 2207 } 2208 2209 function navigateSlashMenu(direction) { 2210 const menu = document.getElementById("slash-menu"); 2211 const items = menu.querySelectorAll(".slash-menu-item"); 2212 editorState.slashMenuIndex = Math.max( 2213 0, 2214 Math.min(items.length - 1, editorState.slashMenuIndex + direction) 2215 ); 2216 items.forEach((item, i) => { 2217 item.classList.toggle("selected", i === editorState.slashMenuIndex); 2218 }); 2219 } 2220 2221 function selectSlashMenuItem() { 2222 const menu = document.getElementById("slash-menu"); 2223 const items = menu.querySelectorAll(".slash-menu-item"); 2224 const selected = items[editorState.slashMenuIndex]; 2225 if (selected) { 2226 executeSlashCommand(selected.dataset.command); 2227 } 2228 } 2229 2230 function executeSlashCommand(commandId) { 2231 const block = editorState.slashMenuBlock; 2232 if (!block) return; 2233 2234 const blockData = getBlockData(block); 2235 if (!blockData) return; 2236 2237 closeSlashMenu(); 2238 2239 // Clear the slash text 2240 block.textContent = ""; 2241 2242 // Convert block to new type 2243 if (commandId === "paragraph") { 2244 convertBlock(blockData, "paragraph"); 2245 } else if (commandId.startsWith("heading")) { 2246 const level = parseInt(commandId.replace("heading", "")); 2247 convertBlock(blockData, "heading", level); 2248 } else if (commandId === "code") { 2249 convertBlock(blockData, "codeBlock"); 2250 } else if (commandId === "quote") { 2251 convertBlock(blockData, "quote"); 2252 } else if (commandId === "tangled") { 2253 convertBlock(blockData, "tangledEmbed"); 2254 block.dataset.editing = "true"; 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; 2285 } 2286 2287 // Focus the appropriate element (code-content for code blocks) 2288 const focusTarget = block._codeContent || block; 2289 focusTarget.focus(); 2290 } 2291 2292 function convertBlock(blockData, newType, level = 1) { 2293 const block = blockData.element; 2294 const content = block.innerHTML; 2295 2296 // Clean up code block structure if converting away from codeBlock 2297 if (blockData.type === "codeBlock" && newType !== "codeBlock") { 2298 if (blockData.highlightTimer) { 2299 clearTimeout(blockData.highlightTimer); 2300 blockData.highlightTimer = null; 2301 } 2302 2303 // Extract text content from code-content before removing 2304 const codeEl = block.querySelector(".code-content"); 2305 const textContent = codeEl ? codeEl.textContent : ""; 2306 2307 // Remove code block structure 2308 const langSelect = block.querySelector(".code-lang-select"); 2309 if (langSelect) langSelect.remove(); 2310 if (codeEl) codeEl.remove(); 2311 2312 // Set the text content directly on the block 2313 block.textContent = textContent; 2314 2315 // Clean up reference and reset contentEditable 2316 delete block._codeContent; 2317 block.contentEditable = "true"; 2318 } 2319 2320 block.className = `block ${newType}${newType === "heading" ? `-${level}` : ""}`; 2321 block.dataset.type = newType; 2322 2323 if (newType === "paragraph") { 2324 block.dataset.placeholder = "Type '/' for commands..."; 2325 delete block.dataset.level; 2326 } else if (newType === "heading") { 2327 block.dataset.level = level; 2328 delete block.dataset.placeholder; 2329 } else if (newType === "codeBlock") { 2330 const textContent = block.textContent; // Strip HTML 2331 block.textContent = ""; 2332 block.contentEditable = "false"; // Outer block not editable 2333 delete block.dataset.placeholder; 2334 2335 const signal = blockData.abortController?.signal; 2336 2337 // Add language selector (outside contentEditable) 2338 const select = document.createElement("select"); 2339 select.className = "code-lang-select"; 2340 select.innerHTML = ` 2341 <option value="">auto</option> 2342 ${COMMON_LANGUAGES.map(l => `<option value="${l}">${l}</option>`).join('')} 2343 `; 2344 2345 // Add code content element (this is contentEditable) 2346 const codeEl = document.createElement("code"); 2347 codeEl.className = "code-content"; 2348 codeEl.contentEditable = "true"; 2349 codeEl.spellcheck = false; 2350 2351 if (textContent) { 2352 codeEl.textContent = textContent; 2353 } 2354 2355 block.appendChild(select); 2356 block.appendChild(codeEl); 2357 2358 select.addEventListener("change", (e) => { 2359 blockData.lang = e.target.value; 2360 block.dataset.lang = e.target.value; 2361 applyCodeHighlighting(codeEl, e.target.value); 2362 triggerAutoSave(); 2363 }, { signal }); 2364 2365 // Set up debounced highlighting (store timer for cleanup) 2366 blockData.highlightTimer = null; 2367 codeEl.addEventListener("input", () => { 2368 if (blockData.highlightTimer) clearTimeout(blockData.highlightTimer); 2369 2370 // Clear highlighting immediately when typing 2371 if (codeEl.querySelector("span[class*='hljs-']")) { 2372 clearCodeHighlightingPreserveCursor(codeEl); 2373 } 2374 2375 // Re-apply highlighting after debounce 2376 blockData.highlightTimer = setTimeout(() => { 2377 if (document.activeElement === codeEl) { 2378 applyCodeHighlighting(codeEl, block.dataset.lang || ""); 2379 } 2380 }, CODE_HIGHLIGHT_DEBOUNCE_MS); 2381 }, { signal }); 2382 2383 // Add keydown and paste handlers to codeEl 2384 codeEl.addEventListener("keydown", handleBlockKeydown, { signal }); 2385 codeEl.addEventListener("paste", (e) => handleBlockPaste(e, block), { signal }); 2386 2387 // Store reference to code element for focus handling 2388 block._codeContent = codeEl; 2389 } else if (newType === "quote") { 2390 delete block.dataset.placeholder; 2391 } else if (newType === "tangledEmbed") { 2392 block.dataset.editing = "true"; 2393 block.dataset.placeholder = "handle/repo"; 2394 delete block.dataset.level; 2395 } 2396 2397 blockData.type = newType; 2398 blockData.level = level; 2399 triggerAutoSave(); 2400 } 2401 2402 function renderTangledEmbed(block) { 2403 const text = block.textContent.trim(); 2404 const match = text.match(/^([^\/]+)\/(.+)$/); 2405 2406 if (!match) { 2407 // Invalid format, keep editing 2408 return false; 2409 } 2410 2411 const [, handle, repo] = match; 2412 2413 block.contentEditable = "false"; 2414 block.tabIndex = 0; // Make focusable when not editing 2415 block.dataset.editing = "false"; 2416 block.dataset.handle = handle; 2417 block.dataset.repo = repo; 2418 block.innerHTML = `<qs-tangled-repo-card handle="${handle}" repo="${repo}" instance="${TANGLED_QUICKSLICE_INSTANCE}"></qs-tangled-repo-card>`; 2419 2420 return true; 2421 } 2422 2423 // Generic edit mode dispatcher for embed blocks 2424 function enterEmbedEditMode(block, blockData) { 2425 switch (blockData.type) { 2426 case "tangledEmbed": 2427 editTangledEmbed(block); 2428 break; 2429 // Add cases for future embed types here: 2430 // case "imageEmbed": 2431 // editImageEmbed(block); 2432 // break; 2433 default: 2434 // No edit mode for this embed type 2435 break; 2436 } 2437 } 2438 2439 function editTangledEmbed(block) { 2440 const handle = block.dataset.handle || ""; 2441 const repo = block.dataset.repo || ""; 2442 2443 block.contentEditable = "true"; 2444 block.removeAttribute("tabindex"); // Remove when editing (contentEditable handles focus) 2445 block.dataset.editing = "true"; 2446 block.textContent = handle && repo ? `${handle}/${repo}` : ""; 2447 block.focus(); 2448 2449 // Move cursor to end 2450 const range = document.createRange(); 2451 range.selectNodeContents(block); 2452 range.collapse(false); 2453 const sel = window.getSelection(); 2454 sel.removeAllRanges(); 2455 sel.addRange(range); 2456 } 2457 2458 function handleBlockDblClick(e) { 2459 const block = e.target.closest(".block"); 2460 if (!block) return; 2461 2462 const blockData = getBlockData(block); 2463 if (!blockData) return; 2464 2465 // Double-click to edit non-editable blocks (embeds) 2466 if (block.contentEditable === "false") { 2467 e.preventDefault(); 2468 enterEmbedEditMode(block, blockData); 2469 } 2470 } 2471 2472 // Make global for onclick 2473 // executeSlashCommand is exposed via DocApp namespace 2474 2475 // Markdown auto-conversion 2476 function checkMarkdownConversion(block) { 2477 const blockData = getBlockData(block); 2478 if (!blockData || blockData.type === "codeBlock") return; 2479 2480 const selection = window.getSelection(); 2481 if (!selection.isCollapsed) return; 2482 2483 const range = selection.getRangeAt(0); 2484 const textNode = range.startContainer; 2485 if (textNode.nodeType !== Node.TEXT_NODE) return; 2486 2487 const text = textNode.textContent; 2488 const cursor = range.startOffset; 2489 2490 // Check for inline code: `text` 2491 if (text[cursor - 1] === "`") { 2492 const before = text.slice(0, cursor - 1); 2493 const openTick = before.lastIndexOf("`"); 2494 if (openTick !== -1 && openTick < cursor - 2) { 2495 const codeText = before.slice(openTick + 1); 2496 // Replace with <code> tag 2497 const beforeCode = text.slice(0, openTick); 2498 const afterCode = text.slice(cursor); 2499 2500 const parent = textNode.parentNode; 2501 const frag = document.createDocumentFragment(); 2502 2503 if (beforeCode) frag.appendChild(document.createTextNode(beforeCode)); 2504 2505 const codeEl = document.createElement("code"); 2506 codeEl.textContent = codeText; 2507 frag.appendChild(codeEl); 2508 2509 if (afterCode) frag.appendChild(document.createTextNode(afterCode)); 2510 2511 parent.replaceChild(frag, textNode); 2512 2513 // Position cursor after code 2514 const newRange = document.createRange(); 2515 newRange.setStartAfter(codeEl); 2516 newRange.collapse(true); 2517 selection.removeAllRanges(); 2518 selection.addRange(newRange); 2519 return; 2520 } 2521 } 2522 2523 // Check for bold: **text** 2524 if (text.slice(cursor - 2, cursor) === "**") { 2525 const before = text.slice(0, cursor - 2); 2526 const openBold = before.lastIndexOf("**"); 2527 if (openBold !== -1 && openBold < cursor - 4) { 2528 const boldText = before.slice(openBold + 2); 2529 applyInlineConversion(textNode, openBold, cursor, boldText, "strong"); 2530 return; 2531 } 2532 } 2533 2534 // Check for italic: *text* (but not **) 2535 if (text[cursor - 1] === "*" && text[cursor - 2] !== "*") { 2536 const before = text.slice(0, cursor - 1); 2537 // Find opening * that's not part of ** 2538 let openItalic = -1; 2539 for (let i = before.length - 1; i >= 0; i--) { 2540 if (before[i] === "*" && before[i - 1] !== "*" && before[i + 1] !== "*") { 2541 openItalic = i; 2542 break; 2543 } 2544 } 2545 if (openItalic !== -1 && openItalic < cursor - 2) { 2546 const italicText = before.slice(openItalic + 1); 2547 applyInlineConversion(textNode, openItalic, cursor, italicText, "em"); 2548 return; 2549 } 2550 } 2551 2552 // Check for URL: auto-link when space typed after URL 2553 const lastChar = text[cursor - 1]; 2554 const isSpace = lastChar === " " || lastChar === "\n" || lastChar?.charCodeAt(0) === 32 || lastChar?.charCodeAt(0) === 160; 2555 if (isSpace) { 2556 const before = text.slice(0, cursor - 1); 2557 // Find URL ending at cursor-1 2558 const urlMatch = before.match(/(https?:\/\/[^\s<>\[\]()]+)$/); 2559 if (urlMatch) { 2560 const url = urlMatch[1]; 2561 const urlStart = before.length - url.length; 2562 2563 // Check if already inside an <a> or <code> tag 2564 let node = textNode.parentNode; 2565 while (node && node !== block) { 2566 if (node.tagName === "A" || node.tagName === "CODE") return; // Already linked or in code 2567 node = node.parentNode; 2568 } 2569 2570 const beforeUrl = text.slice(0, urlStart); 2571 const afterUrl = text.slice(cursor - 1); // includes the space 2572 2573 const parent = textNode.parentNode; 2574 const frag = document.createDocumentFragment(); 2575 2576 if (beforeUrl) frag.appendChild(document.createTextNode(beforeUrl)); 2577 2578 const link = document.createElement("a"); 2579 link.href = url; 2580 link.className = "facet-link"; 2581 link.textContent = url; 2582 frag.appendChild(link); 2583 2584 if (afterUrl) frag.appendChild(document.createTextNode(afterUrl)); 2585 2586 parent.replaceChild(frag, textNode); 2587 2588 // Position cursor after the space (after the link) 2589 const afterNode = link.nextSibling; 2590 if (afterNode) { 2591 const newRange = document.createRange(); 2592 newRange.setStart(afterNode, 1); // After the space 2593 newRange.collapse(true); 2594 selection.removeAllRanges(); 2595 selection.addRange(newRange); 2596 } 2597 return; 2598 } 2599 } 2600 } 2601 2602 function applyInlineConversion(textNode, start, end, content, tagName) { 2603 const text = textNode.textContent; 2604 const beforeText = text.slice(0, start); 2605 const afterText = text.slice(end); 2606 2607 const parent = textNode.parentNode; 2608 const frag = document.createDocumentFragment(); 2609 2610 if (beforeText) frag.appendChild(document.createTextNode(beforeText)); 2611 2612 const el = document.createElement(tagName); 2613 el.textContent = content; 2614 frag.appendChild(el); 2615 2616 if (afterText) frag.appendChild(document.createTextNode(afterText)); 2617 2618 parent.replaceChild(frag, textNode); 2619 2620 // Position cursor after element 2621 const selection = window.getSelection(); 2622 const newRange = document.createRange(); 2623 newRange.setStartAfter(el); 2624 newRange.collapse(true); 2625 selection.removeAllRanges(); 2626 selection.addRange(newRange); 2627 } 2628 2629 // Formatting helpers for toolbar/keyboard shortcuts 2630 function wrapSelectionWithTag(tagName) { 2631 const selection = window.getSelection(); 2632 if (!selection.rangeCount || selection.isCollapsed) return false; 2633 2634 const range = selection.getRangeAt(0); 2635 const selectedText = range.toString(); 2636 if (!selectedText) return false; 2637 2638 // Check if we're in a block editor element 2639 const block = range.commonAncestorContainer.closest?.(".block") || 2640 range.commonAncestorContainer.parentElement?.closest?.(".block"); 2641 if (!block) return false; 2642 2643 // Check if already wrapped in this tag 2644 const parentEl = range.commonAncestorContainer.parentElement; 2645 if (parentEl && parentEl.tagName.toLowerCase() === tagName.toLowerCase()) { 2646 // Unwrap: replace tag with its text content 2647 const text = document.createTextNode(parentEl.textContent); 2648 parentEl.parentNode.replaceChild(text, parentEl); 2649 const newRange = document.createRange(); 2650 newRange.selectNodeContents(text); 2651 selection.removeAllRanges(); 2652 selection.addRange(newRange); 2653 return true; 2654 } 2655 2656 // Wrap selection in tag 2657 const wrapper = document.createElement(tagName); 2658 try { 2659 range.surroundContents(wrapper); 2660 } catch (e) { 2661 // surroundContents fails if selection crosses element boundaries 2662 // Fall back to extracting and wrapping 2663 const fragment = range.extractContents(); 2664 wrapper.appendChild(fragment); 2665 range.insertNode(wrapper); 2666 } 2667 2668 // Select the wrapped content 2669 const newRange = document.createRange(); 2670 newRange.selectNodeContents(wrapper); 2671 selection.removeAllRanges(); 2672 selection.addRange(newRange); 2673 return true; 2674 } 2675 2676 function insertLink() { 2677 const selection = window.getSelection(); 2678 if (!selection.rangeCount) return; 2679 2680 const range = selection.getRangeAt(0); 2681 const selectedText = range.toString(); 2682 2683 // Check if we're in a block editor element 2684 const block = range.commonAncestorContainer.closest?.(".block") || 2685 range.commonAncestorContainer.parentElement?.closest?.(".block"); 2686 if (!block) return; 2687 2688 const url = prompt("Enter URL:", "https://"); 2689 if (!url || url === "https://") return; 2690 2691 const link = document.createElement("a"); 2692 link.href = url; 2693 link.className = "facet-link"; 2694 2695 if (selectedText) { 2696 link.textContent = selectedText; 2697 range.deleteContents(); 2698 } else { 2699 link.textContent = url; 2700 } 2701 2702 range.insertNode(link); 2703 2704 // Position cursor after link 2705 const newRange = document.createRange(); 2706 newRange.setStartAfter(link); 2707 newRange.collapse(true); 2708 selection.removeAllRanges(); 2709 selection.addRange(newRange); 2710 } 2711 2712 function handleBlockPaste(event, block) { 2713 event.preventDefault(); 2714 2715 const text = event.clipboardData.getData("text/plain"); 2716 if (!text) return; 2717 2718 const blockData = getBlockData(block); 2719 if (!blockData) return; 2720 2721 // For code blocks, just insert as-is (preserve newlines) 2722 if (blockData.type === "codeBlock") { 2723 const codeEl = block._codeContent || block.querySelector(".code-content"); 2724 const selection = window.getSelection(); 2725 if (!selection.rangeCount) return; 2726 const range = selection.getRangeAt(0); 2727 range.deleteContents(); 2728 const textNode = document.createTextNode(text); 2729 range.insertNode(textNode); 2730 const newRange = document.createRange(); 2731 newRange.setStartAfter(textNode); 2732 newRange.collapse(true); 2733 selection.removeAllRanges(); 2734 selection.addRange(newRange); 2735 // Dispatch on code-content to trigger highlighting 2736 if (codeEl) codeEl.dispatchEvent(new Event("input", { bubbles: true })); 2737 return; 2738 } 2739 2740 // Split by double newlines (paragraph breaks) 2741 const paragraphs = text.split(/\n\n+/).map(p => p.trim()).filter(p => p); 2742 2743 if (paragraphs.length === 0) return; 2744 2745 const selection = window.getSelection(); 2746 if (!selection.rangeCount) return; 2747 const range = selection.getRangeAt(0); 2748 range.deleteContents(); 2749 2750 // Insert first paragraph into current block 2751 const firstText = paragraphs[0].replace(/\n/g, ' '); // Single newlines become spaces 2752 const textNode = document.createTextNode(firstText); 2753 range.insertNode(textNode); 2754 2755 // Create new blocks for remaining paragraphs 2756 let currentIndex = editorState.blocks.indexOf(blockData); 2757 for (let i = 1; i < paragraphs.length; i++) { 2758 const paraText = paragraphs[i].replace(/\n/g, ' '); 2759 currentIndex++; 2760 const newBlock = insertBlockAfter(currentIndex - 1, "paragraph"); 2761 if (newBlock) { 2762 newBlock.textContent = paraText; 2763 } 2764 } 2765 2766 // Position cursor at end of last block 2767 const lastBlock = editorState.blocks[currentIndex]; 2768 if (lastBlock) { 2769 lastBlock.element.focus(); 2770 const newRange = document.createRange(); 2771 newRange.selectNodeContents(lastBlock.element); 2772 newRange.collapse(false); 2773 selection.removeAllRanges(); 2774 selection.addRange(newRange); 2775 } 2776 2777 triggerAutoSave(); 2778 } 2779 2780 // GraphQL helpers 2781 async function gqlQuery(query, variables = {}) { 2782 const res = await fetch(`${SERVER_URL}/graphql`, { 2783 method: "POST", 2784 headers: { "Content-Type": "application/json" }, 2785 body: JSON.stringify({ query, variables }), 2786 }); 2787 const json = await res.json(); 2788 if (json.errors) { 2789 throw new Error(json.errors[0].message); 2790 } 2791 return json.data; 2792 } 2793 2794 async function gqlMutation(query, variables = {}) { 2795 if (!state.client) { 2796 throw new Error("Not authenticated"); 2797 } 2798 return await state.client.mutate(query, variables); 2799 } 2800 2801 // OAuth helpers 2802 async function initClient() { 2803 if (!state.client) { 2804 state.client = await QuicksliceClient.createQuicksliceClient({ 2805 server: SERVER_URL, 2806 clientId: CLIENT_ID, 2807 scope: "atproto repo:network.slices.tools.document blob:image/*", 2808 }); 2809 } 2810 return state.client; 2811 } 2812 2813 function isCallbackRoute() { 2814 return window.location.pathname === "/docs/callback"; 2815 } 2816 2817 async function handleOAuthCallback() { 2818 if (!isCallbackRoute()) return false; 2819 2820 const params = new URLSearchParams(window.location.search); 2821 2822 // Start OAuth flow from callback route 2823 if (params.get("start") === "1") { 2824 window.history.replaceState({}, document.title, "/docs/callback"); 2825 await startOAuthFromCallback(); 2826 return true; 2827 } 2828 2829 // Handle OAuth error 2830 const urlError = params.get("error"); 2831 if (urlError) { 2832 state.error = params.get("error_description") || urlError; 2833 window.location.href = "/docs"; 2834 return true; 2835 } 2836 2837 // No code - redirect to docs 2838 if (!params.has("code")) { 2839 window.location.href = "/docs"; 2840 return true; 2841 } 2842 2843 // Exchange code for tokens 2844 try { 2845 await initClient(); 2846 await state.client.handleRedirectCallback(); 2847 window.history.replaceState({}, document.title, "/docs/callback"); 2848 const returnUrl = sessionStorage.getItem("oauth_return_url") || "/docs"; 2849 sessionStorage.removeItem("oauth_return_url"); 2850 window.location.href = returnUrl; 2851 return true; 2852 } catch (err) { 2853 console.error("OAuth callback error:", err); 2854 state.error = err.message || "Authentication failed"; 2855 window.location.href = "/docs"; 2856 return true; 2857 } 2858 } 2859 2860 async function handleLogin(event) { 2861 event.preventDefault(); 2862 const handle = document.getElementById("handle").value.trim(); 2863 2864 if (!handle) { 2865 state.error = "Please enter your handle"; 2866 showLoginDialog(); 2867 return; 2868 } 2869 2870 sessionStorage.setItem("oauth_return_url", window.location.href); 2871 sessionStorage.setItem("oauth_handle", handle); 2872 window.location.href = "/docs/callback?start=1"; 2873 } 2874 2875 async function startOAuthFromCallback() { 2876 const handle = sessionStorage.getItem("oauth_handle"); 2877 sessionStorage.removeItem("oauth_handle"); 2878 2879 if (!handle) { 2880 window.location.href = "/docs"; 2881 return; 2882 } 2883 2884 try { 2885 await initClient(); 2886 await state.client.loginWithRedirect({ handle }); 2887 } catch (err) { 2888 console.error("Login error:", err); 2889 state.error = "Login failed: " + err.message; 2890 window.location.href = "/docs"; 2891 } 2892 } 2893 2894 function handleLogout() { 2895 if (state.client) { 2896 state.client.logout(); 2897 } 2898 window.location.reload(); 2899 } 2900 2901 function toggleUserMenu() { 2902 const menu = document.getElementById("user-menu"); 2903 if (menu) menu.classList.toggle("hidden"); 2904 } 2905 2906 // Escape HTML 2907 function esc(str) { 2908 if (!str) return ""; 2909 return str 2910 .replace(/&/g, "&amp;") 2911 .replace(/</g, "&lt;") 2912 .replace(/>/g, "&gt;") 2913 .replace(/"/g, "&quot;"); 2914 } 2915 2916 // Slugify text for URL-friendly slugs 2917 function slugify(text) { 2918 return text 2919 .toLowerCase() 2920 .trim() 2921 .replace(/[^\w\s-]/g, '') // Remove special chars 2922 .replace(/\s+/g, '-') // Spaces to hyphens 2923 .replace(/-+/g, '-') // Collapse multiple hyphens 2924 .substring(0, 100); // Limit length 2925 } 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 3029 // Queries 3030 const DOCUMENTS_QUERY = ` 3031 query GetDocuments($handle: String, $first: Int!, $after: String) { 3032 networkSlicesToolsDocument( 3033 where: { actorHandle: { eq: $handle } } 3034 sortBy: [{ field: createdAt, direction: DESC }] 3035 first: $first 3036 after: $after 3037 ) { 3038 edges { 3039 node { 3040 uri 3041 actorHandle 3042 title 3043 slug 3044 blocks { 3045 __typename 3046 ... on NetworkSlicesToolsDocumentParagraph { 3047 text 3048 facets 3049 } 3050 ... on NetworkSlicesToolsDocumentHeading { 3051 level 3052 text 3053 facets 3054 } 3055 ... on NetworkSlicesToolsDocumentCodeBlock { 3056 code 3057 lang 3058 } 3059 ... on NetworkSlicesToolsDocumentQuote { 3060 text 3061 facets 3062 } 3063 ... on NetworkSlicesToolsDocumentTangledEmbed { 3064 handle 3065 repo 3066 } 3067 ... on NetworkSlicesToolsDocumentImageEmbed { 3068 image { url ref mimeType size } 3069 alt 3070 } 3071 } 3072 createdAt 3073 updatedAt 3074 appBskyActorProfileByDid { 3075 displayName 3076 avatar { url(preset: "avatar") } 3077 } 3078 } 3079 } 3080 pageInfo { hasNextPage endCursor } 3081 } 3082 } 3083 `; 3084 3085 const ALL_DOCUMENTS_QUERY = ` 3086 query GetAllDocuments($first: Int!, $after: String) { 3087 networkSlicesToolsDocument( 3088 sortBy: [{ field: createdAt, direction: DESC }] 3089 first: $first 3090 after: $after 3091 ) { 3092 edges { 3093 node { 3094 uri 3095 actorHandle 3096 title 3097 slug 3098 blocks { 3099 __typename 3100 ... on NetworkSlicesToolsDocumentParagraph { 3101 text 3102 facets 3103 } 3104 ... on NetworkSlicesToolsDocumentHeading { 3105 level 3106 text 3107 facets 3108 } 3109 ... on NetworkSlicesToolsDocumentCodeBlock { 3110 code 3111 lang 3112 } 3113 ... on NetworkSlicesToolsDocumentQuote { 3114 text 3115 facets 3116 } 3117 ... on NetworkSlicesToolsDocumentTangledEmbed { 3118 handle 3119 repo 3120 } 3121 ... on NetworkSlicesToolsDocumentImageEmbed { 3122 image { url ref mimeType size } 3123 alt 3124 } 3125 } 3126 createdAt 3127 updatedAt 3128 appBskyActorProfileByDid { 3129 displayName 3130 avatar { url(preset: "avatar") } 3131 } 3132 } 3133 } 3134 pageInfo { hasNextPage endCursor } 3135 } 3136 } 3137 `; 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 3149 const CREATE_DOCUMENT_MUTATION = ` 3150 mutation CreateDocument($input: NetworkSlicesToolsDocumentInput!) { 3151 createNetworkSlicesToolsDocument(input: $input) { 3152 uri 3153 } 3154 } 3155 `; 3156 3157 const UPDATE_DOCUMENT_MUTATION = ` 3158 mutation UpdateDocument($rkey: String!, $input: NetworkSlicesToolsDocumentInput!) { 3159 updateNetworkSlicesToolsDocument(rkey: $rkey, input: $input) { 3160 uri 3161 } 3162 } 3163 `; 3164 3165 const DELETE_DOCUMENT_MUTATION = ` 3166 mutation DeleteDocument($rkey: String!) { 3167 deleteNetworkSlicesToolsDocument(rkey: $rkey) { 3168 uri 3169 } 3170 } 3171 `; 3172 3173 const DOCUMENT_BY_SLUG_QUERY = ` 3174 query GetDocumentBySlug($handle: String!, $slug: String!) { 3175 networkSlicesToolsDocument( 3176 where: { 3177 actorHandle: { eq: $handle } 3178 slug: { eq: $slug } 3179 } 3180 first: 1 3181 ) { 3182 edges { 3183 node { 3184 uri 3185 actorHandle 3186 title 3187 slug 3188 blocks { 3189 __typename 3190 ... on NetworkSlicesToolsDocumentParagraph { 3191 text 3192 facets 3193 } 3194 ... on NetworkSlicesToolsDocumentHeading { 3195 level 3196 text 3197 facets 3198 } 3199 ... on NetworkSlicesToolsDocumentCodeBlock { 3200 code 3201 lang 3202 } 3203 ... on NetworkSlicesToolsDocumentQuote { 3204 text 3205 facets 3206 } 3207 ... on NetworkSlicesToolsDocumentTangledEmbed { 3208 handle 3209 repo 3210 } 3211 ... on NetworkSlicesToolsDocumentImageEmbed { 3212 image { url ref mimeType size } 3213 alt 3214 } 3215 } 3216 createdAt 3217 updatedAt 3218 appBskyActorProfileByDid { 3219 displayName 3220 avatar { url(preset: "avatar") } 3221 } 3222 } 3223 } 3224 } 3225 } 3226 `; 3227 3228 // URL routing helpers 3229 function parseDocUrl() { 3230 const path = window.location.pathname; 3231 // Match /docs/{handle}/{slug} or /docs.html with defined path after 3232 const match = path.match(/\/docs(?:\.html)?\/([^\/]+)\/([^\/]+)\/?$/); 3233 if (match) { 3234 return { handle: match[1], slug: match[2] }; 3235 } 3236 return null; 3237 } 3238 3239 function updateUrl(handle, slug) { 3240 const basePath = window.location.pathname.includes('.html') ? '/docs.html' : '/docs'; 3241 const newPath = handle && slug ? `${basePath}/${handle}/${slug}` : basePath; 3242 window.history.pushState({}, '', newPath); 3243 } 3244 3245 // Helper to extract rkey from URI 3246 function extractRkey(uri) { 3247 const parts = uri.split("/"); 3248 return parts[parts.length - 1]; 3249 } 3250 3251 // Data fetching 3252 async function loadDocuments(handle = null) { 3253 state.loading = true; 3254 state.error = null; 3255 render(); 3256 3257 try { 3258 const query = handle ? DOCUMENTS_QUERY : ALL_DOCUMENTS_QUERY; 3259 const variables = handle 3260 ? { handle, first: 50 } 3261 : { first: 50 }; 3262 const data = await gqlQuery(query, variables); 3263 const key = "networkSlicesToolsDocument"; 3264 state.documents = data[key]?.edges?.map((e) => e.node) || []; 3265 } catch (err) { 3266 state.error = err.message; 3267 } 3268 3269 state.loading = false; 3270 render(); 3271 } 3272 3273 async function createDocument(title, slug, blocks) { 3274 const input = { 3275 title, 3276 slug, 3277 blocks: blocks.map(block => serializeBlock(block)), 3278 createdAt: new Date().toISOString(), 3279 }; 3280 3281 await gqlMutation(CREATE_DOCUMENT_MUTATION, { input }); 3282 3283 // Fetch the newly created document to get its URI 3284 const data = await gqlQuery(DOCUMENT_BY_SLUG_QUERY, { 3285 handle: state.viewer.handle, 3286 slug, 3287 }); 3288 const newDoc = data.networkSlicesToolsDocument?.edges?.[0]?.node; 3289 if (newDoc) { 3290 state.currentDoc = newDoc; 3291 updateUrl(newDoc.actorHandle, newDoc.slug); 3292 } 3293 } 3294 3295 function serializeBlock(block) { 3296 const base = { $type: `network.slices.tools.document#${block.type}` }; 3297 3298 if (block.type === "paragraph") { 3299 return { ...base, text: block.text, facets: block.facets || [] }; 3300 } else if (block.type === "heading") { 3301 return { ...base, level: block.level, text: block.text, facets: block.facets || [] }; 3302 } else if (block.type === "codeBlock") { 3303 return { ...base, code: block.code, lang: block.lang || undefined }; 3304 } else if (block.type === "quote") { 3305 return { ...base, text: block.text, facets: block.facets || [] }; 3306 } else if (block.type === "tangledEmbed") { 3307 return { ...base, handle: block.handle, repo: block.repo }; 3308 } else if (block.type === "imageEmbed") { 3309 return { ...base, image: block.image, alt: block.alt || "" }; 3310 } 3311 return base; 3312 } 3313 3314 async function updateDocument(uri, title, slug, blocks) { 3315 const input = { 3316 title, 3317 slug, 3318 blocks: blocks.map(block => serializeBlock(block)), 3319 createdAt: state.currentDoc.createdAt, 3320 updatedAt: new Date().toISOString(), 3321 }; 3322 3323 const oldSlug = state.currentDoc.slug; 3324 3325 await gqlMutation(UPDATE_DOCUMENT_MUTATION, { 3326 rkey: extractRkey(uri), 3327 input, 3328 }); 3329 3330 // Update local state (don't reload from server to avoid re-rendering editor) 3331 state.currentDoc.title = title; 3332 state.currentDoc.slug = slug; 3333 3334 // Update URL if slug changed 3335 if (oldSlug !== slug) { 3336 updateUrl(state.currentDoc.actorHandle, slug); 3337 } 3338 } 3339 3340 async function deleteDocument(uri) { 3341 if (!confirm("Delete this document?")) return; 3342 3343 await gqlMutation(DELETE_DOCUMENT_MUTATION, { 3344 rkey: extractRkey(uri), 3345 }); 3346 await loadDocuments(state.viewer?.handle); 3347 state.view = "list"; 3348 state.currentDoc = null; 3349 render(); 3350 } 3351 3352 function formatTime(iso) { 3353 if (!iso) return ""; 3354 const d = new Date(iso); 3355 const now = new Date(); 3356 const diff = now - d; 3357 3358 if (diff < 60000) return "just now"; 3359 if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; 3360 if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; 3361 if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`; 3362 3363 return d.toLocaleDateString(); 3364 } 3365 3366 function renderSaveStatus() { 3367 const status = editorState.saveStatus; 3368 const classes = `save-status ${status}`; 3369 3370 let text = ""; 3371 switch (status) { 3372 case "saving": text = "Saving..."; break; 3373 case "saved": text = "Saved"; break; 3374 case "error": text = "Error saving"; break; 3375 case "dirty": text = ""; break; // Don't show anything when dirty 3376 } 3377 3378 return `<div class="${classes}">${text}</div>`; 3379 } 3380 3381 function updateSaveStatusUI() { 3382 const el = document.querySelector(".save-status"); 3383 if (el) { 3384 el.className = `save-status ${editorState.saveStatus}`; 3385 switch (editorState.saveStatus) { 3386 case "saving": el.textContent = "Saving..."; break; 3387 case "saved": el.textContent = "Saved"; break; 3388 case "error": el.textContent = "Error saving"; break; 3389 default: el.textContent = ""; 3390 } 3391 } 3392 } 3393 3394 function triggerAutoSave() { 3395 // Clear any pending save 3396 if (editorState.saveTimeout) { 3397 clearTimeout(editorState.saveTimeout); 3398 } 3399 3400 editorState.saveVersion++; 3401 editorState.saveStatus = "dirty"; 3402 updateSaveStatusUI(); 3403 3404 // Debounce: save 1.5 seconds after last change 3405 editorState.saveTimeout = setTimeout(() => { 3406 performAutoSave(); 3407 }, 1500); 3408 } 3409 3410 async function performAutoSave() { 3411 if (!state.currentDoc && !editorState.isNewDoc) return; 3412 if (!state.viewer) return; 3413 3414 const titleBlock = editorState.blocks.find(b => b.type === "title"); 3415 const title = titleBlock?.element.textContent.trim() || "Untitled"; 3416 3417 // Don't save if title is empty/untitled and no content 3418 if (title === "Untitled" || title === "") { 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 }); 3425 if (!hasContent) return; 3426 } 3427 3428 const slug = editorState.slugOverride || slugify(title) || "untitled"; 3429 const contentBlocks = extractBlocksFromEditor(); 3430 3431 // Capture version at start of save 3432 const versionAtStart = editorState.saveVersion; 3433 3434 editorState.saveStatus = "saving"; 3435 updateSaveStatusUI(); 3436 3437 try { 3438 if (editorState.isNewDoc) { 3439 await createDocument(title, slug, contentBlocks); 3440 editorState.isNewDoc = false; 3441 } else { 3442 await updateDocument(state.currentDoc.uri, title, slug, contentBlocks); 3443 } 3444 3445 // Only mark as saved if no new edits occurred during save 3446 if (editorState.saveVersion === versionAtStart) { 3447 editorState.saveStatus = "saved"; 3448 editorState.lastSavedAt = Date.now(); 3449 } 3450 // If version changed, status is already "dirty" from triggerAutoSave 3451 } catch (err) { 3452 console.error("Auto-save failed:", err); 3453 editorState.saveStatus = "error"; 3454 } 3455 3456 updateSaveStatusUI(); 3457 } 3458 3459 function renderDocMenu() { 3460 const isOwner = state.viewer?.handle === state.currentDoc?.actorHandle; 3461 if (!isOwner && !editorState.isNewDoc) return ""; 3462 3463 return ` 3464 <div class="doc-menu-container"> 3465 <button class="doc-menu-trigger" onclick="DocApp.toggleDocMenu()">•••</button> 3466 <div id="doc-menu" class="doc-menu hidden"> 3467 <button onclick="DocApp.showSlugEditor()">Edit slug</button> 3468 <button onclick="DocApp.deleteCurrentDocument()" class="danger">Delete</button> 3469 </div> 3470 </div> 3471 `; 3472 } 3473 3474 function toggleDocMenu() { 3475 const menu = document.getElementById("doc-menu"); 3476 menu.classList.toggle("hidden"); 3477 } 3478 3479 function showSlugEditor() { 3480 toggleDocMenu(); 3481 const currentSlug = editorState.slugOverride || slugify(getTitleText()) || "untitled"; 3482 const newSlug = prompt("Edit slug:", currentSlug); 3483 if (newSlug !== null && newSlug !== currentSlug) { 3484 editorState.slugOverride = slugify(newSlug); 3485 triggerAutoSave(); 3486 } 3487 } 3488 3489 function getTitleText() { 3490 const titleBlock = editorState.blocks.find(b => b.type === "title"); 3491 return titleBlock?.element.textContent.trim() || ""; 3492 } 3493 3494 function deleteCurrentDocument() { 3495 if (state.currentDoc) { 3496 deleteDocument(state.currentDoc.uri); 3497 } 3498 } 3499 3500 function render() { 3501 const app = document.getElementById("app"); 3502 3503 if (state.view === "loading") { 3504 app.innerHTML = '<div class="loading">Loading...</div>'; 3505 return; 3506 } 3507 3508 if (state.view === "login") { 3509 state.view = "list"; 3510 render(); 3511 showLoginDialog(); 3512 return; 3513 } 3514 3515 let html = ""; 3516 3517 // Header (only shown when logged in or viewing public docs) 3518 html += ` 3519 <header> 3520 <h1>Docs</h1> <button class="btn-info" onclick="DocApp.openInfoModal()" title="How it works">?</button> 3521 <div class="header-actions"> 3522 ${ 3523 state.viewer 3524 ? ` 3525 <div class="user-menu-container"> 3526 <button class="user-menu-trigger" onclick="DocApp.toggleUserMenu()"> 3527 ${state.viewer.appBskyActorProfileByDid?.avatar ? `<img src="${esc(state.viewer.appBskyActorProfileByDid.avatar.url)}" class="user-avatar" />` : ""} 3528 @${esc(state.viewer.handle)} 3529 </button> 3530 <div id="user-menu" class="user-menu hidden"> 3531 <button onclick="DocApp.showNewDocument(); DocApp.toggleUserMenu();">+ New Document</button> 3532 <button onclick="DocApp.handleLogout()">Logout</button> 3533 </div> 3534 </div> 3535 ` 3536 : ` 3537 <button onclick="DocApp.showLogin()">Login</button> 3538 ` 3539 } 3540 </div> 3541 </header> 3542 `; 3543 3544 // Error display 3545 if (state.error) { 3546 html += `<div class="error">Error: ${esc(state.error)}</div>`; 3547 } 3548 3549 // Content based on view 3550 if (state.view === "list") { 3551 html += renderList(); 3552 } else if (state.view === "doc") { 3553 html += renderDocument(); 3554 } 3555 3556 app.innerHTML = html; 3557 3558 // Initialize block editor if in doc mode and can edit 3559 if (state.view === "doc") { 3560 const isOwner = state.viewer?.handle === state.currentDoc?.actorHandle; 3561 const canEdit = isOwner || editorState.isNewDoc; 3562 if (canEdit) { 3563 setTimeout(() => { 3564 initBlockEditor(state.currentDoc?.blocks || [], state.currentDoc?.title || ""); 3565 }, 0); 3566 } else { 3567 // Apply syntax highlighting to read-only code blocks 3568 document.querySelectorAll('pre code').forEach((el) => { 3569 hljs.highlightElement(el); 3570 }); 3571 } 3572 } 3573 } 3574 3575 function showLoginDialog() { 3576 // Remove existing dialog if any 3577 const existing = document.getElementById("login-dialog"); 3578 if (existing) existing.remove(); 3579 3580 const dialog = document.createElement("div"); 3581 dialog.id = "login-dialog"; 3582 dialog.className = "dialog-overlay"; 3583 dialog.innerHTML = ` 3584 <div class="dialog"> 3585 <div class="dialog-header"> 3586 <h2>Login to Docs</h2> 3587 <button class="dialog-close" onclick="DocApp.closeLoginDialog()">&times;</button> 3588 </div> 3589 <div class="dialog-body"> 3590 <div class="login-form"> 3591 ${state.error ? `<div class="error" style="margin-bottom: 1rem;">${esc(state.error)}</div>` : ""} 3592 <form onsubmit="DocApp.handleLogin(event)"> 3593 <div class="form-group"> 3594 <label for="handle-autocomplete">AT Protocol Handle</label> 3595 <qs-actor-autocomplete id="handle-autocomplete" placeholder="you.bsky.social"></qs-actor-autocomplete> 3596 <input type="hidden" id="handle" /> 3597 </div> 3598 <button type="submit">Continue</button> 3599 </form> 3600 </div> 3601 </div> 3602 </div> 3603 `; 3604 3605 // Close on overlay click 3606 dialog.addEventListener("click", (e) => { 3607 if (e.target === dialog) closeLoginDialog(); 3608 }); 3609 3610 document.body.appendChild(dialog); 3611 3612 // Set up actor autocomplete event listener 3613 const autocomplete = document.getElementById("handle-autocomplete"); 3614 if (autocomplete) { 3615 autocomplete.addEventListener("qs-select", (e) => { 3616 document.getElementById("handle").value = e.detail.actor.handle; 3617 }); 3618 } 3619 } 3620 3621 function closeLoginDialog() { 3622 const dialog = document.getElementById("login-dialog"); 3623 if (dialog) dialog.remove(); 3624 state.error = null; 3625 } 3626 3627 function showLogin() { 3628 state.error = null; 3629 showLoginDialog(); 3630 } 3631 3632 function renderList() { 3633 if (state.documents.length === 0) { 3634 return ` 3635 <div class="empty-state"> 3636 <p>No documents yet.</p> 3637 ${state.viewer ? "<p>Create your first document!</p>" : "<p>Login to create documents.</p>"} 3638 </div> 3639 `; 3640 } 3641 3642 let html = '<div class="doc-list">'; 3643 3644 for (const doc of state.documents) { 3645 const profile = doc.appBskyActorProfileByDid; 3646 html += ` 3647 <div class="doc-item" onclick="DocApp.showDocumentByUri('${esc(doc.uri)}')"> 3648 <div> 3649 <div class="doc-title">${esc(doc.title)}</div> 3650 <div class="doc-meta"> 3651 ${profile?.avatar ? `<img src="${esc(profile.avatar.url)}" class="user-avatar user-avatar-sm" />` : ""} 3652 @${esc(doc.actorHandle)} 3653 · ${formatTime(doc.updatedAt || doc.createdAt)} 3654 </div> 3655 </div> 3656 </div> 3657 `; 3658 } 3659 3660 html += "</div>"; 3661 return html; 3662 } 3663 3664 function renderDocument() { 3665 const doc = state.currentDoc; 3666 const isOwner = state.viewer?.handle === doc?.actorHandle; 3667 const canEdit = isOwner || editorState.isNewDoc; 3668 3669 if (canEdit) { 3670 // Editable view with block editor 3671 return ` 3672 <div class="editor-container"> 3673 <div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;"> 3674 <button class="secondary" onclick="DocApp.showList()">← Back</button> 3675 ${renderDocMenu()} 3676 </div> 3677 <div id="block-editor" class="block-editor"></div> 3678 <div id="slash-menu" class="slash-menu hidden"></div> 3679 </div> 3680 `; 3681 } else { 3682 // Read-only view 3683 if (!doc) return '<div class="error">Document not found</div>'; 3684 const profile = doc.appBskyActorProfileByDid; 3685 3686 return ` 3687 <div class="doc-view"> 3688 <div style="margin-bottom: 1rem;"> 3689 <button class="secondary" onclick="DocApp.showList()">← Back</button> 3690 </div> 3691 <h2>${esc(doc.title)}</h2> 3692 <div class="meta"> 3693 <span class="user-info"> 3694 ${profile?.avatar ? `<img src="${esc(profile.avatar.url)}" class="user-avatar" />` : ""} 3695 @${esc(doc.actorHandle)} 3696 </span> 3697 · ${formatTime(doc.updatedAt || doc.createdAt)} 3698 · <span class="doc-slug">/${esc(doc.slug)}</span> 3699 </div> 3700 <div class="body">${renderBlocks(doc.blocks || [])}</div> 3701 </div> 3702 `; 3703 } 3704 } 3705 3706 function renderBlocks(blocks) { 3707 return blocks.map(block => { 3708 const type = block.__typename || ""; 3709 const facets = parseFacetsFromApi(block.facets); 3710 3711 if (BlockTypes.isParagraph(type)) { 3712 return `<p>${renderFacetedText(block.text, facets, { escapeHtml: esc })}</p>`; 3713 } else if (BlockTypes.isHeading(type)) { 3714 const tag = `h${block.level + 1}`; // h2, h3, h4 (h1 is doc title) 3715 return `<${tag}>${renderFacetedText(block.text, facets, { escapeHtml: esc })}</${tag}>`; 3716 } else if (BlockTypes.isCodeBlock(type)) { 3717 const langClass = block.lang ? `language-${esc(block.lang)}` : ""; 3718 return `<pre class="facet-codeblock"><code class="${langClass}">${esc(block.code)}</code></pre>`; 3719 } else if (BlockTypes.isQuote(type)) { 3720 return `<blockquote class="facet-quote">${renderFacetedText(block.text, facets, { escapeHtml: esc })}</blockquote>`; 3721 } else if (type.endsWith("TangledEmbed")) { 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>`; 3734 } 3735 return ""; 3736 }).join(""); 3737 } 3738 3739 // Navigation 3740 async function showList() { 3741 state.view = "list"; 3742 state.currentDoc = null; 3743 editorState.isNewDoc = false; 3744 editorState.slugOverride = null; 3745 updateUrl(null, null); 3746 // Reload documents to get fresh data 3747 await loadDocuments(state.viewer?.handle); 3748 } 3749 3750 function showDocument(doc) { 3751 state.view = "doc"; 3752 state.currentDoc = doc; 3753 editorState.isNewDoc = false; 3754 editorState.slugOverride = null; 3755 editorState.saveStatus = "saved"; 3756 updateUrl(doc.actorHandle, doc.slug); 3757 render(); 3758 } 3759 3760 function showNewDocument() { 3761 if (!state.viewer) { 3762 alert("Please login first"); 3763 return; 3764 } 3765 state.view = "doc"; 3766 state.currentDoc = null; 3767 editorState.isNewDoc = true; 3768 editorState.slugOverride = null; 3769 editorState.saveStatus = "dirty"; 3770 updateUrl(state.viewer.handle, "new"); 3771 render(); 3772 } 3773 3774 function showDocumentByUri(uri) { 3775 const doc = state.documents.find(d => d.uri === uri); 3776 if (doc) showDocument(doc); 3777 } 3778 3779 function extractBlocksFromEditor() { 3780 const blocks = []; 3781 3782 for (const editorBlock of editorState.blocks) { 3783 const { type, element, level, lang } = editorBlock; 3784 3785 // Skip title block - it's handled separately 3786 if (type === "title") { 3787 continue; 3788 } 3789 3790 if (type === "codeBlock") { 3791 // Code blocks - get text from code-content element 3792 const codeEl = element.querySelector(".code-content"); 3793 const code = codeEl ? codeEl.textContent : ""; 3794 blocks.push({ type: "codeBlock", code, lang: lang || undefined }); 3795 } else if (type === "tangledEmbed") { 3796 // Tangled embed - get handle and repo from dataset 3797 const handle = element.dataset.handle || ""; 3798 const repo = element.dataset.repo || ""; 3799 if (handle && repo) { 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 } 3813 } 3814 } else { 3815 // Paragraph, heading, quote - use domToFacets 3816 const { text, facets } = domToFacets(element); 3817 if (type === "heading") { 3818 blocks.push({ type: "heading", level: level || 1, text, facets }); 3819 } else if (type === "quote") { 3820 blocks.push({ type: "quote", text, facets }); 3821 } else { 3822 blocks.push({ type: "paragraph", text, facets }); 3823 } 3824 } 3825 } 3826 3827 return blocks; 3828 } 3829 3830 // Initialize 3831 async function init() { 3832 // Warn before leaving with unsaved changes 3833 window.addEventListener("beforeunload", (e) => { 3834 if (editorState.saveStatus === "dirty" || editorState.saveStatus === "saving") { 3835 e.preventDefault(); 3836 e.returnValue = ""; 3837 } 3838 }); 3839 3840 // Close dropdown menus on click outside 3841 document.addEventListener("click", (e) => { 3842 const docMenu = document.getElementById("doc-menu"); 3843 const docTrigger = e.target.closest(".doc-menu-trigger"); 3844 if (docMenu && !docMenu.classList.contains("hidden") && !docTrigger) { 3845 docMenu.classList.add("hidden"); 3846 } 3847 3848 const userMenu = document.getElementById("user-menu"); 3849 const userTrigger = e.target.closest(".user-menu-trigger"); 3850 if (userMenu && !userMenu.classList.contains("hidden") && !userTrigger) { 3851 userMenu.classList.add("hidden"); 3852 } 3853 }); 3854 3855 try { 3856 // Handle OAuth callback if on callback route 3857 const isCallback = await handleOAuthCallback(); 3858 if (isCallback) return; 3859 3860 // Try to initialize client and check auth 3861 await initClient(); 3862 const isLoggedIn = state.client && (await state.client.isAuthenticated()); 3863 3864 if (isLoggedIn) { 3865 // Fetch viewer info (must use client.query for auth) 3866 try { 3867 const data = await state.client.query(` 3868 query { 3869 viewer { 3870 did 3871 handle 3872 appBskyActorProfileByDid { 3873 displayName 3874 avatar { url(preset: "avatar") } 3875 } 3876 } 3877 } 3878 `); 3879 state.viewer = data?.viewer; 3880 } catch (err) { 3881 console.error("Failed to fetch viewer:", err); 3882 } 3883 } 3884 3885 // Check if URL points to a specific document 3886 const docUrl = parseDocUrl(); 3887 if (docUrl) { 3888 // Handle /docs/{handle}/new URL for new documents 3889 if (docUrl.slug === "new") { 3890 if (state.viewer && state.viewer.handle === docUrl.handle) { 3891 showNewDocument(); 3892 return; 3893 } else { 3894 // Not owner or not logged in, redirect to list 3895 await loadDocuments(); 3896 state.view = "list"; 3897 render(); 3898 return; 3899 } 3900 } 3901 3902 try { 3903 const data = await gqlQuery(DOCUMENT_BY_SLUG_QUERY, { 3904 handle: docUrl.handle, 3905 slug: docUrl.slug, 3906 }); 3907 const doc = data.networkSlicesToolsDocument?.edges?.[0]?.node; 3908 if (doc) { 3909 state.currentDoc = doc; 3910 state.documents = [doc]; 3911 state.view = "doc"; 3912 render(); 3913 return; 3914 } 3915 } catch (err) { 3916 console.error("Failed to load document:", err); 3917 } 3918 } 3919 3920 // Load documents and show list 3921 await loadDocuments(); 3922 state.view = "list"; 3923 render(); 3924 } catch (err) { 3925 console.error("Init error:", err); 3926 state.error = err.message; 3927 state.view = "list"; 3928 render(); 3929 } 3930 } 3931 3932 function openInfoModal() { 3933 const existing = document.getElementById("info-dialog"); 3934 if (existing) existing.remove(); 3935 3936 const dialog = document.createElement("div"); 3937 dialog.id = "info-dialog"; 3938 dialog.className = "dialog-overlay"; 3939 dialog.innerHTML = ` 3940 <div class="dialog dialog-wide"> 3941 <div class="dialog-header"> 3942 <h2>How Docs Works</h2> 3943 <button class="dialog-close" onclick="DocApp.closeInfoModal()">&times;</button> 3944 </div> 3945 <div class="dialog-body"> 3946 <div class="info-section"> 3947 <h3>Creating Documents</h3> 3948 <p>Anyone with an <a href="https://internethandle.org/" target="_blank">internet handle</a> can create documents. Each document gets a shareable URL based on your handle and a custom slug.</p> 3949 </div> 3950 3951 <div class="info-section"> 3952 <h3>Rich Content</h3> 3953 <p>Documents support headings, paragraphs, code blocks with syntax highlighting, blockquotes, and embeds (limited). Use <strong>bold</strong>, <em>italic</em>, <code>code</code>, and links.</p> 3954 </div> 3955 3956 <div class="info-section"> 3957 <h3>Ownership</h3> 3958 <p>Only you can edit or delete your own documents. Your content lives in your personal data repository, giving you full ownership.</p> 3959 </div> 3960 3961 <div class="info-section"> 3962 <h3>Built on ATmosphere</h3> 3963 <p>Docs is built on the <a href="https://atproto.com/" target="_blank">AT Protocol</a>. Your documents are stored in your personal data repository, giving you ownership of your data.</p> 3964 </div> 3965 3966 <div class="info-section"> 3967 <h3>Lexicons</h3> 3968 <p>Docs uses the following <a href="https://tangled.sh/slices.network/tools/tree/main/lexicons" target="_blank">lexicon schemas</a>:</p> 3969 <div class="lexicon-list"> 3970 <div> 3971 <code>network.slices.tools.document</code> 3972 <div class="desc">Documents with title, slug, and rich content blocks</div> 3973 </div> 3974 <div> 3975 <code>network.slices.tools.richtext.facet</code> 3976 <div class="desc">Inline formatting: bold, italic, links, and code</div> 3977 </div> 3978 </div> 3979 </div> 3980 </div> 3981 </div> 3982 `; 3983 dialog.addEventListener("click", (e) => { 3984 if (e.target === dialog) closeInfoModal(); 3985 }); 3986 document.body.appendChild(dialog); 3987 } 3988 3989 function closeInfoModal() { 3990 const dialog = document.getElementById("info-dialog"); 3991 if (dialog) dialog.remove(); 3992 } 3993 3994 // Make functions global for onclick handlers 3995 // Expose functions via single namespace to reduce global pollution 3996 window.DocApp = { 3997 handleLogin, 3998 handleLogout, 3999 showLogin, 4000 showList, 4001 showDocument, 4002 showDocumentByUri, 4003 showNewDocument, 4004 deleteDocument, 4005 toggleDocMenu, 4006 toggleUserMenu, 4007 showSlugEditor, 4008 deleteCurrentDocument, 4009 executeSlashCommand, 4010 closeLoginDialog, 4011 openInfoModal, 4012 closeInfoModal, 4013 }; 4014 4015 // Handle browser back/forward 4016 window.addEventListener("popstate", async () => { 4017 const docUrl = parseDocUrl(); 4018 if (docUrl) { 4019 // Handle /new URL 4020 if (docUrl.slug === "new" && state.viewer?.handle === docUrl.handle) { 4021 showNewDocument(); 4022 return; 4023 } 4024 4025 const data = await gqlQuery(DOCUMENT_BY_SLUG_QUERY, { 4026 handle: docUrl.handle, 4027 slug: docUrl.slug, 4028 }); 4029 const doc = data.networkSlicesToolsDocument?.edges?.[0]?.node; 4030 if (doc) { 4031 showDocument(doc); 4032 return; 4033 } 4034 } 4035 showList(); 4036 }); 4037 4038 init(); 4039 </script> 4040 </body> 4041</html>