Tools for the Atmosphere
tools.slices.network
quickslice
atproto
html
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, "&")
2911 .replace(/</g, "<")
2912 .replace(/>/g, ">")
2913 .replace(/"/g, """);
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()">×</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()">×</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>