PageSourceSearch

https://qimeta.ai/static/factor_app/js/mental_notes.js

js qimeta.ai collected 2026-10-05 15:46:15 UTC 67,191 bytes, 3,885 lines download raw bytes

1/*
2 * ============================================================================
3 * QiMeta Mental Notes
4 * ============================================================================
5 *
6 * File:
7 *   factor_app/static/factor_app/js/mental_notes.js
8 *
9 * Purpose:
10 *   Allow a reader to:
11 *     1. Select text in a QiMeta article.
12 *     2. Add a Mental Note to that selection.
13 *     3. Save the note in Django.
14 *     4. Highlight the selected article text in the live browser DOM.
15 *     5. Reload previously saved Mental Notes when the article is reopened.
16 *
17 * Important architecture:
18 *
19 *   - The stored article HTML is NEVER modified.
20 *
21 *   - Mental Notes are stored separately in the database.
22 *
23 *   - class="mental-note-highlight" and
24 *     data-mental-note-id="<id>"
25 *     are added only to the LIVE browser DOM.
26 *
27 *   - Existing QiMeta dictionary tooltip LABEL text counts as article text.
28 *
29 *   - Existing QiMeta tooltip DROPDOWN content does NOT count as article text.
30 *
31 *   - V1 does NOT allow Mental Notes to overlap other Mental Notes.
32 *
33 * ============================================================================
34 */
35
36
37/* ============================================================================
38 * 1. QIMETA CONFIGURATION
39 * ============================================================================
40 */
41
42const QIMETA = window.QIMETA || {};
43
44const DOCUMENT_ID = QIMETA.documentId || null;
45
46const SERVER_ORIGIN =
47    QIMETA.serverOrigin ||
48    window.location.origin;
49
50
51/*
52 * These URLs will be supplied by qimeta_show_file.html.
53 *
54 * Example:
55 *
56 * window.QIMETA.mentalNotesListUrl
57 * window.QIMETA.mentalNoteCreateUrl
58 * window.QIMETA.mentalNoteUpdateUrlTemplate
59 * window.QIMETA.mentalNoteDeleteUrlTemplate
60 */
61
62const API = {
63
64    list:
65        QIMETA.mentalNotesListUrl || null,
66
67    create:
68        QIMETA.mentalNoteCreateUrl || null,
69
70    updateTemplate:
71        QIMETA.mentalNoteUpdateUrlTemplate || null,
72
73    deleteTemplate:
74        QIMETA.mentalNoteDeleteUrlTemplate || null,
75};
76
77
78/*
79 * Number of characters before/after selected text that we store.
80 *
81 * These help us relocate a Mental Note if offsets no longer exactly match.
82 */
83
84const PREFIX_SUFFIX_LENGTH = 120;
85
86
87/* ============================================================================
88 * 2. CLIENT STATE
89 * ============================================================================
90 */
91
92const mentalNoteState = {
93
94    /* Map: note ID -> note object */
95    notes: new Map(),
96
97    /*
98     * Supplied by the Mental Notes list endpoint.
99     *
100     * Anonymous readers can view Mental Notes,
101     * but only authenticated readers can create them.
102     */
103    isAuthenticated: false,
104
105    /* Selection waiting to be turned into a Mental Note. */
106    pendingSelection: null,
107
108    /* If null: editor is creating a new note.
109     * If set: editor is editing an existing note.
110     */
111    editingNoteId: null,
112
113    /* Telemetry for how long the Mental Note editor is active. */
114    editorOpenedAtMs: null,
115    editorSessionMode: null,
116    editorSessionNoteId: null,
117
118    actionButton: null,
119
120    editor: null,
121};
122
123
124/* ============================================================================
125 * 3. IDENTIFY ARTICLE ROOT
126 * ============================================================================
127 */
128
129function getArticleRoot() {
130
131    /*
132     * Normal website/article.
133     */
134
135    let root =
136        document.getElementById("qimeta-article-container");
137
138    if (root) {
139        return root;
140    }
141
142
143    /*
144     * pdf2htmlEX PDF.
145     */
146
147    root =
148        document.getElementById("page-container");
149
150    if (root) {
151        return root;
152    }
153
154
155    /*
156     * Fallback.
157     */
158
159    root =
160        document.querySelector(".page-container");
161
162    if (root) {
163        return root;
164    }
165
166
167    return null;
168}
169
170
171/* ============================================================================
172 * 4. DETERMINE WHICH TEXT BELONGS TO THE ARTICLE
173 * ============================================================================
174 */
175
176function isQiMetaUIElement(element) {
177
178    if (!(element instanceof Element)) {
179        return false;
180    }
181
182
183    /*
184     * IMPORTANT:
185     *
186     * Do NOT exclude .qimeta_tooltip_label.
187     *
188     * The visible dictionary term itself is article text.
189     *
190     * But the tooltip DROPDOWN content should not become part of the
191     * canonical article-text stream.
192     */
193
194    return Boolean(
195
196        element.closest(
197
198            [
199                "#qimeta-tooltip-portal",
200                "#qimeta-chat-box",
201                ".qimeta_right_sidebar",
202                ".tooltip-dropdown",
203                ".mental-note-action",
204                ".mental-note-editor",
205
206            ].join(",")
207
208        )
209
210    );
211}
212
213
214/*
215 * Determine whether a text node should count as part of the article.
216 */
217
218function isEligibleTextNode(
219    node,
220    root = getArticleRoot()
221) {
222
223    if (!root) {
224        return false;
225    }
226
227    if (!node) {
228        return false;
229    }
230
231    if (node.nodeType !== Node.TEXT_NODE) {
232        return false;
233    }
234
235    if (!root.contains(node)) {
236        return false;
237    }
238
239    if (!node.nodeValue) {
240        return false;
241    }
242
243
244    const parent = node.parentElement;
245
246    if (!parent) {
247        return false;
248    }
249
250
251    /*
252     * Never include script/style/form content.
253     */
254
255    if (
256        parent.closest(
257            "script, style, noscript, textarea, input, select, option"
258        )
259    ) {
260        return false;
261    }
262
263
264    /*
265     * Do not include QiMeta UI content.
266     */
267
268    if (isQiMetaUIElement(parent)) {
269        return false;
270    }
271
272
273    return true;
274}
275
276
277/* ============================================================================
278 * 5. GET ALL ARTICLE TEXT NODES
279 * ============================================================================
280 */
281
282function getEligibleTextNodes(
283    root = getArticleRoot()
284) {
285
286    if (!root) {
287        return [];
288    }
289
290
291    const walker =
292        document.createTreeWalker(
293
294            root,
295
296            NodeFilter.SHOW_TEXT,
297
298            {
299
300                acceptNode(node) {
301
302                    if (
303                        isEligibleTextNode(
304                            node,
305                            root
306                        )
307                    ) {
308
309                        return NodeFilter.FILTER_ACCEPT;
310                    }
311
312                    return NodeFilter.FILTER_REJECT;
313                }
314
315            }
316
317        );
318
319
320    const nodes = [];
321
322
323    while (walker.nextNode()) {
324
325        nodes.push(
326            walker.currentNode
327        );
328    }
329
330
331    return nodes;
332}
333
334
335/* ============================================================================
336 * 6. CANONICAL ARTICLE TEXT
337 * ============================================================================
338 */
339
340/*
341 * This produces one continuous string representing the article.
342 *
343 * Mental Note offsets refer to this string.
344 */
345
346function getCanonicalArticleText(
347    root = getArticleRoot()
348) {
349
350    const nodes =
351        getEligibleTextNodes(root);
352
353
354    return nodes
355        .map(
356            node =>
356 node.nodeValue
357        )
358        .join("");
359}
360
361
362/* ============================================================================
363 * 7. FIND A TEXT NODE'S GLOBAL ARTICLE OFFSET
364 * ============================================================================
365 */
366
367function getNodeGlobalStart(
368    targetNode,
369    root = getArticleRoot()
370) {
371
372    let offset = 0;
373
374
375    const nodes =
376        getEligibleTextNodes(root);
377
378
379    for (const node of nodes) {
380
381        if (node === targetNode) {
382
383            return offset;
384        }
385
386
387        offset +=
388            node.nodeValue.length;
389    }
390
391
392    return null;
393}
394
395
396/* ============================================================================
397 * 8. CHECK WHETHER SELECTION IS INSIDE ARTICLE
398 * ============================================================================
399 */
400
401function selectionIsInsideArticle(
402    selection,
403    root
404) {
405
406    if (!root) {
407        return false;
408    }
409
410    if (!selection) {
411        return false;
412    }
413
414    if (selection.rangeCount === 0) {
415        return false;
416    }
417
418    if (selection.isCollapsed) {
419        return false;
420    }
421
422
423    const range =
424        selection.getRangeAt(0);
425
426
427    const startElement =
428        range.startContainer.nodeType === Node.ELEMENT_NODE
429            ? range.startContainer
430            : range.startContainer.parentElement;
431
432
433    const endElement =
434        range.endContainer.nodeType === Node.ELEMENT_NODE
435            ? range.endContainer
436            : range.endContainer.parentElement;
437
438
439    if (
440        !startElement ||
441        !endElement
442    ) {
443        return false;
444    }
445
446
447    if (
448        !root.contains(startElement) ||
449        !root.contains(endElement)
450    ) {
451
452        return false;
453    }
454
455
456    if (
457        isQiMetaUIElement(startElement) ||
458        isQiMetaUIElement(endElement)
459    ) {
460
461        return false;
462    }
463
464
465    /*
466     * V1:
467     *
468     * Don't allow Mental Notes to overlap existing Mental Notes.
469     */
470
471    if (
472        startElement.closest(
473            ".mental-note-highlight"
474        ) ||
475        endElement.closest(
476            ".mental-note-highlight"
477        )
478    ) {
479
480        return false;
481    }
482
483
484    return true;
485}
486
487
488/* ============================================================================
489 * 9. CONVERT BROWSER SELECTION INTO STORED ANCHOR
490 * ============================================================================
491 */
492
493function buildAnchorFromSelection(
494    selection
495) {
496
497    const root =
498        getArticleRoot();
499
500
501    if (
502        !selectionIsInsideArticle(
503            selection,
504            root
505        )
506    ) {
507
508        return null;
509    }
510
511
512    const range =
513        selection.getRangeAt(0);
514
515
516    /*
517     * Normal browser text selections end at text nodes.
518     *
519     * Keep V1 conservative.
520     */
521
522    if (
523        range.startContainer.nodeType !== Node.TEXT_NODE ||
524        range.endContainer.nodeType !== Node.TEXT_NODE
525    ) {
526
527        return null;
528    }
529
530
531    if (
532        !isEligibleTextNode(
533            range.startContainer,
534            root
535        ) ||
536        !isEligibleTextNode(
537            range.endContainer,
538            root
539        )
540    ) {
541
542        return null;
543    }
544
545
546    const startNodeGlobal =
547        getNodeGlobalStart(
548            range.startContainer,
549            root
550        );
551
552
553    const endNodeGlobal =
554        getNodeGlobalStart(
555            range.endContainer,
556            root
557        );
558
559
560    if (
561        startNodeGlobal === null ||
562        endNodeGlobal === null
563    ) {
564
565        return null;
566    }
567
568
569    const startOffset =
570        startNodeGlobal +
571        range.startOffset;
572
573
574    const endOffset =
575        endNodeGlobal +
576        range.endOffset;
577
578
579    if (
580        endOffset <= startOffset
581    ) {
582
583        return null;
584    }
585
586
587    const articleText =
588        getCanonicalArticleText(root);
589
590
591    const selectedText =
592        articleText.slice(
593            startOffset,
594            endOffset
595        );
596
597
598    if (
599        !selectedText.trim()
600    ) {
601
602        return null;
603    }
604
605
606    /*
607     * Capture text immediately before the selection.
608     */
609
610    const prefixText =
611        articleText.slice(
612
613            Math.max(
614                0,
615                startOffset - PREFIX_SUFFIX_LENGTH
616            ),
617
618            startOffset
619        );
620
621
622    /*
623     * Capture text immediately after the selection.
624     */
625
626    const suffixText =
627        articleText.slice(
628
629            endOffset,
630
631            Math.min(
632                articleText.length,
633                endOffset + PREFIX_SUFFIX_LENGTH
634            )
635        );
636
637
638    return {
639
640        selected_text:
641            selectedText,
642
643        start_offset:
644            startOffset,
645
646        end_offset:
647            endOffset,
648
649        prefix_text:
650            prefixText,
651
652        suffix_text:
653            suffixText,
654
655        /*
656         * Browser-only.
657         *
658         * Never sent to Django.
659         */
660
661        range:
662            range.cloneRange(),
663    };
664}
665
666
667/* ============================================================================
668 * 10. MENTAL NOTE ACTION BUTTON
669 * ============================================================================
670 */
671
672function ensureActionButton() {
673
674    if (
675        mentalNoteState.actionButton
676    ) {
677
678        return mentalNoteState.actionButton;
679    }
680
681
682    const button =
683        document.createElement("button");
684
685
686    button.type =
687        "button";
688
689
690    button.className =
691        "mental-note-action";
692
693
694    button.textContent =
695        "Mental Note";
696
697
698    /*
699     * Temporary V1 styling.
700     *
701     * We can move this into a CSS file later.
702     */
703
704    Object.assign(
705        button.style,
706        {
707
708            position:
709                "fixed",
710
711            display:
712                "none",
713
714            zIndex:
715                "2147483647",
716
717            padding:
718                "7px 11px",
719
720            borderRadius:
721                "7px",
722
723            border:
724                "1px solid #4ec6c2",
725
726            background:
727                "#000",
728
729            color:
730                "#4ec6c2",
731
732            cursor:
733                "pointer",
734
735            fontFamily:
736                "century-gothic, sans-serif",
737
738            fontSize:
739                "14px",
740        }
741    );
742
743
744    /*
745     * Prevent clicking the action button from destroying
746     * the selection before we use it.
747     */
748
749    button.addEventListener(
750        "mousedown",
751        function(event) {
752
753            event.preventDefault();
754        }
755    );
756
757
758    button.addEventListener(
759        "click",
760        function() {
761
762            if (
763                !mentalNoteState.pendingSelection
764            ) {
765                return;
766            }
767
768
769            /*
770            * Anonymous readers may view Mental Notes,
771            * but must sign in before creating one.
772            */
773            if (
774                !mentalNoteState.isAuthenticated
775            ) {
776
777                hideActionButton();
778
779                showMentalNoteLoginRequired(
780                    mentalNoteState.pendingSelection
781                );
782
783                return;
784            }
785
786
787            hideActionButton();
788
789
790            openEditorForNewNote(
791                mentalNoteState.pendingSelection
792            );
793        }
794    );
795
796
797    document.body.appendChild(
798        button
799    );
800
801
802    mentalNoteState.actionButton =
803        button;
804
805
806    return button;
807}
808
809
810/* ============================================================================
811 * 11. POSITION ACTION BUTTON
812 * ============================================================================
813 */
814
815function showActionButton(
816    range
817) {
818
819    const button =
820        ensureActionButton();
821
822
823    const rect =
824        range.getBoundingClientRect();
825
826
827    if (
828        !rect ||
829        (
830            rect.width === 0 &&
831            rect.height === 0
832        )
833    ) {
834
835        return;
836    }
837
838
839    button.style.display =
840        "block";
841
842
843    const buttonRect =
844        button.getBoundingClientRect();
845
846
847    let left =
848        rect.left +
849        rect.width / 2 -
850        buttonRect.width / 2;
851
852
853    left =
854        Math.max(
855            8,
856            left
857        );
858
859
860    left =
861        Math.min(
862            left,
863            window.innerWidth -
864            buttonRect.width -
865            8
866        );
867
868
869    /*
870     * First try above selection.
871     */
872
873    let top =
874        rect.top -
875        buttonRect.height -
876        8;
877
878
879    /*
880     * If insufficient room,
881     * put underneath selection.
882     */
883
884    if (
885        top < 8
886    ) {
887
888        top =
889            rect.bottom +
890            8;
891    }
892
893
894    button.style.left =
895        `${left}px`;
896
897
898    button.style.top =
899        `${top}px`;
900}
901
902
903function hideActionButton() {
904
905    if (
906        mentalNoteState.actionButton
907    ) {
908
909        mentalNoteState.actionButton.style.display =
910            "none";
911    }
912}
913
914
915/* ============================================================================
916 * 12. CREATE NOTE EDITOR
917 * ============================================================================
918 */
919
920function ensureEditor() {
921
922    if (
923        mentalNoteState.editor
924    ) {
925
926        return mentalNoteState.editor;
927    }
928
929
930    const editor =
931        document.createElement("div");
932
933
934    editor.className =
935        "mental-note-editor";
936
937
938    Object.assign(
939        editor.style,
940        {
941
942            position:
943                "fixed",
944
945            display:
946                "none",
947
948            zIndex:
949                "2147483647",
950
951            width:
952                "min(420px, calc(100vw - 24px))",
953
954            background:
955                "#000",
956
957            color:
958                "#fff",
959
960            border:
961                "1px solid rgba(78,198,194,.65)",
962
963            borderRadius:
964                "10px",
965
966            boxShadow:
967                "0 8px 30px rgba(0,0,0,.35)",
968
969            padding:
970                "14px",
971
972            fontFamily:
973                "century-gothic, sans-serif",
974        }
975    );
976
977
978    editor.innerHTML = `
979
980        <div
981            class="mental-note-header-row"
982            style="
983                display:flex;
984                align-items:baseline;
985                justify-content:space-between;
986                gap:10px;
987                margin-bottom:8px;
988            "
989        >
990            <div
991                class="mental-note-title"
992                style="
993                    color:#4ec6c2;
994                    font-weight:600;
995                    margin:0;
996                "
997            >
998                Mental Note
999            </div>
1000
1001            <div
1002                class="mental-note-owner"
1003                style="
1004                    display:none;
1005                    font-size:11px;
1006                    color:#9fdedb;
1007                    opacity:.9;
1008                    white-space:nowrap;
1009                    text-align:right;
1010                "
1011            ></div>
1012        </div>
1013
1014        <div
1015            class="mental-note-selected-preview"
1016            style="
1017                font-size:12px;
1018                opacity:.75;
1019                margin-bottom:10px;
1020                max-height:60px;
1021                overflow:auto;
1022            "
1023        ></div>
1024
1025
1026        <textarea
1027            class="mental-note-input"
1028            rows="5"
1029            placeholder="Capture your thought..."
1030            style="
1031                box-sizing:border-box;
1032                width:100%;
1033                resize:vertical;
1034                padding:10px;
1035                border-radius:7px;
1036                border:1px solid #555;
1037                background:#111;
1038                color:#fff;
1039
1040                font-family:century-gothic, sans-serif;
1041                font-size:14px;
1042                line-height:1.45;
1043            "
1044        ></textarea>
1045
1046
1047        <div
1048            style="
1049                display:flex;
1050                justify-content:flex-end;
1051                gap:8px;
1052                margin-top:10px;
1053            "
1054        >
1055
1056            <button
1057                type="button"
1058                class="mental-note-delete qimeta-btn qimeta-btn-danger"
1059                style="
1060                    display:none;
1061                    margin-right:auto;
1062                "
1063            >
1064                Delete
1065            </button>
1066
1067            <button
1068                type="button"
1069                class="mental-note-cancel qimeta-btn qimeta-btn-ghost"
1070            >
1071                Cancel
1072            </button>
1073
1074            <button
1075                type="button"
1076                class="mental-note-save qimeta-btn qimeta-btn-primary"
1077            >
1078                Save
1079            </button>
1080
1081        </div>
1082
1083
1084        <div
1085            class="mental-note-error"
1086            style="
1087                display:none;
1088                color:#ffb74d;
1089                font-size:12px;
1090                margin-top:8px;
1091            "
1092        ></div>
1093    `;
1094
1095
1096    const cancelButton =
1097        editor.querySelector(
1098            ".mental-note-cancel"
1099        );
1100
1101
1102    const saveButton =
1103        editor.querySelector(
1104            ".mental-note-save"
1105        );
1106
1107    const deleteButton =
1108        editor.querySelector(
1109            ".mental-note-delete"
1110        );
1111        
1112    cancelButton.addEventListener(
1113        "click",
1114        function() {
1115
1116            closeEditor();
1117        }
1118    );
1119
1120
1121    saveButton.addEventListener(
1122        "click",
1123        function(event) {
1124
1125            event.preventDefault();
1126            event.stopPropagation();
1127
1128
1129            if (
1130                saveButton.dataset.action === "login"
1131            ) {
1132
1133                const loginUrl =
1134                    window.QIMETA?.loginUrl;
1135
1136                if (loginUrl) {
1137                    window.location.href =
1138                        loginUrl;
1139                }
1140
1141                return;
1142            }
1143
1144
1145            if (
1146                mentalNoteState.editingNoteId !== null
1147            ) {
1148
1149                saveExistingMentalNote();
1150            }
1151            else {
1152
1153                savePendingMentalNote();
1154            }
1155        }
1156    );
1157
1158    deleteButton.addEventListener(
1159        "click",
1160        function(event) {
1161
1162            event.preventDefault();
1163
1164            deleteExistingMentalNote();
1165        }
1166    );
1167
1168    document.body.appendChild(
1169        editor
1170    );
1171
1172
1173    mentalNoteState.editor =
1174        editor;
1175
1176
1177    return editor;
1178}
1179
1180
1181/* ============================================================================
1182 * 13. POSITION NOTE EDITOR
1183 * ============================================================================
1184 */
1185
1186function positionEditor(
1187    rangeOrElement
1188) {
1189
1190    const editor =
1191        ensureEditor();
1192
1193
1194    let rect;
1195
1196
1197    if (
1198        typeof rangeOrElement.getBoundingClientRect === "function"
1199    ) {
1200
1201        rect =
1202            rangeOrElement.getBoundingClientRect();
1203    }
1204    else {
1205
1206        return;
1207    }
1208
1209
1210    editor.style.display =
1211        "block";
1212
1213
1214    const editorRect =
1215        editor.getBoundingClientRect();
1216
1217
1218    /*
1219     * Prefer opening underneath the selection.
1220     */
1221
1222    let left =
1223        rect.left;
1224
1225
1226    let top =
1227        rect.bottom +
1228        10;
1229
1230
1231    /*
1232     * Keep editor inside viewport horizontally.
1233     */
1234
1235    if (
1236        left +
1237        editorRect.width >
1238        window.innerWidth -
1239        12
1240    ) {
1241
1242        left =
1243            window.innerWidth -
1244            editorRect.width -
1245            12;
1246    }
1247
1248
1249    left =
1250        Math.max(
1251            12,
1252            left
1253        );
1254
1255
1256    /*
1257     * If there is not enough room below,
1258     * move editor above selection.
1259     */
1260
1261    if (
1262        top +
1263        editorRect.height >
1264        window.innerHeight -
1265        12
1266    ) {
1267
1268        top =
1269            rect.top -
1270            editorRect.height -
1271            10;
1272    }
1273
1274
1275    top =
1276        Math.max(
1277            12,
1278            top
1279        );
1280
1281
1282    editor.style.left =
1283        `${left}px`;
1284
1285
1286    editor.style.top =
1287        `${top}px`;
1288}
1289
1290
1291/* ============================================================================
1292 * 14. OPEN EDITOR FOR NEW NOTE
1293 * ============================================================================
1294 */
1295function showMentalNoteLoginRequired(
1296    rangeOrSelection
1297) {
1298
1299    const editor =
1300        ensureEditor();
1301
1302    const title =
1303        editor.querySelector(
1304            ".mental-note-title"
1305        );
1306
1307    const preview =
1308        editor.querySelector(
1309            ".mental-note-selected-preview"
1310        );
1311
1312    const input =
1313        editor.querySelector(
1314            ".mental-note-input"
1315        );
1316
1317    const saveButton =
1318        editor.querySelector(
1319            ".mental-note-save"
1320        );
1321
1322    const cancelButton =
1323        editor.querySelector(
1324            ".mental-note-cancel"
1325        );
1326
1327    const error =
1328        editor.querySelector(
1329            ".mental-note-error"
1330        );
1331
1332
1333    const owner =
1334        editor.querySelector(
1335            ".mental-note-owner"
1336        );
1337
1338    const deleteButton =
1339        editor.querySelector(
1340            ".mental-note-delete"
1341        );
1342    owner.style.display =
1343        "none";
1344
1345    owner.textContent =
1346        "";
1347
1348    deleteButton.style.display =
1349        "none";
1350
1351    title.textContent =
1352        "Mental Notes";
1353
1354
1355    preview.textContent =
1356        "Sign in to create and save your own Mental Notes.";
1357
1358
1359    input.style.display =
1360        "none";
1361
1362
1363    /*
1364     * Reuse the existing Save button as the Login button.
1365     */
1366    saveButton.style.display =
1367        "";
1368
1369    saveButton.textContent =
1370        "Login";
1371
1372    saveButton.dataset.action =
1373        "login";
1374
1375
1376    cancelButton.textContent =
1377        "Close";
1378
1379
1380    error.style.display =
1381        "block";
1382
1383    error.textContent =
1384        "You can view Mental Notes without signing in, but you must sign in to create one.";
1385
1386
1387    positionEditor(
1388        rangeOrSelection?.range ||
1389        rangeOrSelection
1390    );
1391}
1392
1393function openEditorForNewNote(
1394    anchor
1395) {
1396    mentalNoteState.editingNoteId = null;
1397    beginMentalNoteEditorSession("create");
1398
1399    const editor =
1400        ensureEditor();
1401
1402    const title =
1403        editor.querySelector(
1404            ".mental-note-title"
1405        );
1406
1407    title.textContent =
1408        "Mental Note";
1409
1410    const preview =
1411        editor.querySelector(
1412            ".mental-note-selected-preview"
1413        );
1414
1415
1416    const input =
1417        editor.querySelector(
1418            ".mental-note-input"
1419        );
1420    input.style.display = "";
1421
1422
1423    const saveButton =
1424        editor.querySelector(
1425            ".mental-note-save"
1426        );
1427    saveButton.dataset.action =
1428        "save";
1429
1430    saveButton.textContent =
1431        "Save";
1432
1433    saveButton.style.display =
1434        "";
1435
1436    const cancelButton =
1437        editor.querySelector(
1438            ".mental-note-cancel"
1439        );
1440
1441    const deleteButton =
1442        editor.querySelector(
1443            ".mental-note-delete"
1444        );
1445
1446    deleteButton.style.display =
1447        "none";
1448        
1449    const error =
1450        editor.querySelector(
1451            ".mental-note-error"
1452        );
1453
1454
1455    preview.textContent =
1456        `"${anchor.selected_text.trim()}"`;
1457
1458
1459    input.value =
1460        "";
1461
1462
1463    input.readOnly =
1464        false;
1465
1466
1467    saveButton.style.display =
1468        "";
1469
1470
1471    cancelButton.textContent =
1472        "Cancel";
1473
1474
1475    error.style.display =
1476        "none";
1477
1478
1479    positionEditor(
1480        anchor.range
1481    );
1482
1483
1484    input.focus();
1485
1486    const owner =
1487        editor.querySelector(
1488            ".mental-note-owner"
1489        );
1490
1491    owner.style.display =
1492        "none";
1493
1494    owner.textContent =
1495        "";
1496        
1497}
1498
1499
1500/* ============================================================================
1501 * 15. CLOSE EDITOR
1502 * ============================================================================
1503 */
1504
1505function closeEditor() {
1506    const sessionMode =
1507        mentalNoteState.editorSessionMode;
1508
1509
1510    const sessionNoteId =
1511        mentalNoteState.editorSessionNoteId;
1512
1513
1514    const sessionDurationMs =
1515        getMentalNoteEditorDurationMs();
1516
1517
1518    if (
1519        mentalNoteState.editor
1520    ) {
1521
1522        mentalNoteState.editor.style.display =
1523            "none";
1524    }
1525
1526    if (
1527        sessionMode === "existing" &&
1528        sessionNoteId !== null
1529    ) {
1530
1531        const note =
1532            mentalNoteState.notes.get(
1533                Number(sessionNoteId)
1534            );
1535
1536
1537        if (note) {
1538
1539            recordMentalNoteNavigationEvent(
1540                "mental_note_close",
1541                note,
1542                {
1543                    durationMs:
1544                        sessionDurationMs,
1545                }
1546            );
1547        }
1548    }
1549
1550
1551    clearMentalNoteEditorSession();
1552
1553    mentalNoteState.pendingSelection = null;
1554    mentalNoteState.editingNoteId = null;
1555
1556
1557    hideActionButton();
1558
1559
1560    const selection =
1561        window.getSelection();
1562
1563
1564    if (selection) {
1565
1566        selection.removeAllRanges();
1567    }
1568}
1569
1570
1571/* ============================================================================
1572 * 16. SHOW EDITOR ERROR
1573 * ============================================================================
1574 */
1575
1576function showEditorError(
1577    message
1578) {
1579
1580    const editor =
1581        ensureEditor();
1582
1583
1584    const error =
1585        editor.querySelector(
1586            ".mental-note-error"
1587        );
1588
1589
1590    error.textContent =
1591        message;
1592
1593
1594    error.style.display =
1595        "block";
1596}
1597
1598
1599/* ============================================================================
1600 * 17. CSRF COOKIE
1601 * ============================================================================
1602 */
1603
1604function getCookie(name) {
1605
1606    let cookieValue =
1607        null;
1608
1609
1610    if (
1611        document.cookie &&
1612        document.cookie !== ""
1613    ) {
1614
1615        const cookies =
1616            document.cookie.split(";");
1617
1618
1619        for (
1620            let i = 0;
1621            i < cookies.length;
1622            i++
1623        ) {
1624
1625            const cookie =
1626                cookies[i].trim();
1627
1628
1629            if (
1630                cookie.substring(
1631                    0,
1632                    name.length + 1
1633                ) ===
1634                `${name}=`
1635            ) {
1636
1637                cookieValue =
1638                    decodeURIComponent(
1639                        cookie.substring(
1640                            name.length + 1
1641                        )
1642                    );
1643
1644                break;
1645            }
1646        }
1647    }
1648
1649
1650    return cookieValue;
1651}
1652
1653
1654/* ============================================================================
1655 * 18. CONVERT API URL TO ABSOLUTE URL
1656 * ============================================================================
1657 */
1658
1659function absoluteApiUrl(
1660    path
1661) {
1662
1663    if (!path) {
1664        return null;
1665    }
1666
1667
1668    /*
1669     * Already absolute.
1670     */
1671
1672    if (
1673        /^https?:\/\//i.test(path)
1674    ) {
1675
1676        return path;
1677    }
1678
1679
1680    return new URL(
1681        path,
1682        SERVER_ORIGIN
1683    ).toString();
1684}
1685
1686
1687/* ============================================================================
1688 * 19. GENERIC MENTAL NOTE API CALL
1689 * ============================================================================
1690 */
1691
1692async function apiFetch(
1693    path,
1694    options = {}
1695) {
1696
1697    const url =
1698        absoluteApiUrl(path);
1699
1700
1701    if (!url) {
1702
1703        throw new Error(
1704            "Mental Note API URL is not configured."
1705        );
1706    }
1707
1708
1709    const headers = {
1710
1711        Accept:
1712            "application/json",
1713
1714        ...(options.headers || {}),
1715    };
1716
1717
1718    /*
1719     * POST requests need JSON content type and Django CSRF token.
1720     */
1721
1722    if (
1723        options.method &&
1724        options.method.toUpperCase() !== "GET"
1725    ) {
1726
1727        headers["Content-Type"] =
1728            "application/json";
1729
1730
1731        const csrfToken =
1732            document.querySelector(
1733                '[name="csrfmiddlewaretoken"]'
1734            )?.value ||
1735            getCookie("csrftoken");
1736
1737
1738        if (csrfToken) {
1739
1740            headers["X-CSRFToken"] =
1741                csrfToken;
1742        }
1743    }
1744
1745
1746    const response =
1747        await fetch(
1748            url,
1749            {
1750
1751                credentials:
1752                    "include",
1753
1754                ...options,
1755
1756                headers:
1757                    headers,
1758            }
1759        );
1760
1761
1762    let data =
1763        null;
1764
1765
1766    try {
1767
1768        data =
1769            await response.json();
1770    }
1771    catch (error) {
1772
1773        data =
1774            null;
1775    }
1776
1777
1778    if (
1779        !response.ok ||
1780        !data ||
1781        !data.ok
1782    ) {
1783
1784        let message =
1785            data?.reason ||
1786            `Mental Note request failed (${response.status}).`;
1787
1788
1789        if (
1790            message === "login_required"
1791        ) {
1792
1793            message =
1794                "Please sign in to create or edit Mental Notes.";
1795        }
1796
1797
1798        throw new Error(
1799            message
1800        );
1801    }
1802
1803
1804    return data;
1805}
1806
1807
1808/* ============================================================================
1809 * MENTAL NOTE ANALYTICS
1810 * ============================================================================
1811 */
1812
1813function countWords(text) {
1814
1815    if (!text) {
1816        return 0;
1817    }
1818
1819    return text
1820        .trim()
1821        .split(/\s+/)
1822        .filter(Boolean)
1823        .length;
1824}
1825
1826
1827function beginMentalNoteEditorSession(
1828    mode,
1829    noteId = null
1830) {
1831
1832    mentalNoteState.editorOpenedAtMs =
1833        Date.now();
1834
1835    mentalNoteState.editorSessionMode =
1836        mode;
1837
1838    mentalNoteState.editorSessionNoteId =
1839        noteId;
1840}
1841
1842
1843function getMentalNoteEditorDurationMs() {
1844
1845    if (
1846        mentalNoteState.editorOpenedAtMs === null
1847    ) {
1848        return 0;
1849    }
1850
1851    return Math.max(
1852        0,
1853        Date.now() -
1854        mentalNoteState.editorOpenedAtMs
1855    );
1856}
1857
1858
1859function clearMentalNoteEditorSession() {
1860
1861    mentalNoteState.editorOpenedAtMs =
1862        null;
1863
1864    mentalNoteState.editorSessionMode =
1865        null;
1866
1867    mentalNoteState.editorSessionNoteId =
1868        null;
1869}
1870
1871
1872function getMentalNoteReadingContext() {
1873
1874    const documentElement =
1875        document.documentElement;
1876
1877    const body =
1878        document.body;
1879
1880
1881    const documentHeight =
1882        Math.max(
1883            body?.scrollHeight || 0,
1884            documentElement?.scrollHeight || 0,
1885            body?.offsetHeight || 0,
1886            documentElement?.offsetHeight || 0
1887        );
1888
1889
1890    const viewportHeight =
1891        window.innerHeight ||
1892        documentElement?.clientHeight ||
1893        0;
1894
1895
1896    const scrollY =
1897        window.scrollY ||
1898        documentElement?.scrollTop ||
1899        body?.scrollTop ||
1900        0;
1901
1902
1903    const scrollableHeight =
1904        Math.max(
1905            documentHeight -
1906            viewportHeight,
1907            1
1908        );
1909
1910
1911    const readingProgress =
1912        Math.max(
1913            0,
1914            Math.min(
1915                1,
1916                scrollY /
1917                scrollableHeight
1918            )
1919        );
1920
1921
1922    return {
1923
1924        scrollY,
1925
1926        documentHeight,
1927
1928        viewportHeight,
1929
1930        readingProgress,
1931    };
1932}
1933
1934
1935function recordMentalNoteNavigationEvent(
1936    eventType,
1937    note,
1938    options = {}
1939) {
1940
1941    if (
1942        typeof window.save_and_update_navigation_labels
1943        !== "function"
1944    ) {
1945
1946        console.warn(
1947            "[MentalNotes] NavigationInfo save function not available."
1948        );
1949
1950        return;
1951    }
1952
1953
1954    const context =
1955        getMentalNoteReadingContext();
1956
1957
1958    const durationMs =
1959        Math.max(
1960            0,
1961            Number(
1962                options.durationMs || 0
1963            )
1964        );
1965
1966
1967    const selectedText =
1968        note?.selected_text || "";
1969
1970
1971    const noteText =
1972        note?.note_text || "";
1973
1974
1975    const clickInfo = {
1976
1977        type:
1978            eventType,
1979
1980        mental_note_id:
1981            note?.id ?? null,
1982
1983        mental_note_action:
1984            eventType.replace(
1985                "mental_note_",
1986                ""
1987            ),
1988
1989        DOCUMENT_NAME:
1990            QIMETA.documentName || "",
1991
1992        DOCUMENT_ID:
1993            DOCUMENT_ID,
1994
1995        /*
1996         * Client-timed editor/view duration.
1997         */
1998        duration_mental_note_visible:
1999            Math.round(durationMs),
2000
2001        start_offset:
2002            note?.start_offset ?? null,
2003
2004        end_offset:
2005            note?.end_offset ?? null,
2006
2007        selected_word_count:
2008            countWords(
2009                selectedText
2010            ),
2011
2012        note_word_count:
2013            countWords(
2014                noteText
2015            ),
2016
2017        note_changed:
2018            options.noteChanged ?? null,
2019
2020        reading_progress:
2021            context.readingProgress,
2022
2023        scroll_y_position:
2024            context.scrollY,
2025
2026        document_height:
2027            context.documentHeight,
2028
2029        viewport_height:
2030            context.viewportHeight,
2031
2032        /*
2033         * Keeps the payload compatible with the existing
2034         * NavigationInfo processing shape.
2035         */
2036        // Required by existing NavigationInfo / CurrentTopic pipeline.
2037        terms: [],
2038        x_locs: [],
2039        y_locs: [],
2040        elem_top: null,
2041        elem_bottom: null,
2042        scroll_speeds: [],
2043    };
2044
2045
2046    console.log(
2047        "[MentalNotes] Navigation event:",
2048        clickInfo
2049    );
2050
2051
2052    window.save_and_update_navigation_labels(
2053        null,
2054        clickInfo
2055    );
2056}
2057
2058/* ============================================================================
2059 * 20. SAVE NEW MENTAL NOTE
2060 * ============================================================================
2061 */
2062
2063async function savePendingMentalNote() {
2064
2065    const anchor =
2066        mentalNoteState.pendingSelection;
2067
2068
2069    if (!anchor) {
2070
2071        return;
2072    }
2073
2074
2075    if (!API.create) {
2076
2077        showEditorError(
2078            "Mental Note create URL is not configured."
2079        );
2080
2081        return;
2082    }
2083
2084
2085    const editor =
2086        ensureEditor();
2087
2088
2089    const input =
2090        editor.querySelector(
2091            ".mental-note-input"
2092        );
2093
2094
2095    const saveButton =
2096        editor.querySelector(
2097            ".mental-note-save"
2098        );
2099
2100
2101    const noteText =
2102        input.value.trim();
2103
2104
2105    /*
2106     * V1 requires actual note content.
2107     *
2108     * Later we can support plain highlighting with no note.
2109     */
2110
2111    if (!noteText) {
2112
2113        showEditorError(
2114            "Enter a mental note before saving."
2115        );
2116
2117        return;
2118    }
2119
2120
2121    saveButton.disabled =
2122        true;
2123
2124
2125    try {
2126
2127        const data =
2128            await apiFetch(
2129
2130                API.create,
2131
2132                {
2133
2134                    method:
2135                        "POST",
2136
2137                    body:
2138                        JSON.stringify(
2139                            {
2140
2141                                document_id:
2142                                    DOCUMENT_ID,
2143
2144                                selected_text:
2145                                    anchor.selected_text,
2146
2147                                start_offset:
2148                                    anchor.start_offset,
2149
2150                                end_offset:
2151                                    anchor.end_offset,
2152
2153                                prefix_text:
2154                                    anchor.prefix_text,
2155
2156                                suffix_text:
2157                                    anchor.suffix_text,
2158
2159                                note_type:
2160                                    "TEXT",
2161
2162                                note_text:
2163                                    noteText,
2164                            }
2165                        ),
2166                }
2167            );
2168
2169
2170        const note =
2171            data.mental_note;
2172
2173
2174        mentalNoteState.notes.set(
2175            Number(note.id),
2176            note
2177        );
2178
2179
2180        /*
2181         * Instead of simply wrapping the saved browser range,
2182         * repaint from the stored anchor.
2183         *
2184         * That means new notes use the same rendering path as
2185         * notes restored tomorrow.
2186         */
2187
2188        applyMentalNoteHighlight(
2189            note
2190        );
2191
2192        /*
2193        * Capture active annotation time.
2194        */
2195        const mentalNoteDurationMs =
2196            getMentalNoteEditorDurationMs();
2197
2198
2199        recordMentalNoteNavigationEvent(
2200            "mental_note_create",
2201            note,
2202            {
2203                durationMs:
2204                    mentalNoteDurationMs,
2205            }
2206        );
2207
2208
2209        /*
2210        * Clear telemetry session BEFORE closeEditor so we don't
2211        * accidentally also record mental_note_close.
2212        */
2213        clearMentalNoteEditorSession();
2214
2215
2216        closeEditor();
2217    }
2218    catch (error) {
2219
2220        console.error(
2221            "[MentalNotes] Create failed:",
2222            error
2223        );
2224
2225
2226        showEditorError(
2227            error.message ||
2228            "Could not save Mental Note."
2229        );
2230    }
2231    finally {
2232
2233        saveButton.disabled =
2234            false;
2235    }
2236}
2237
2238
2239/* ============================================================================
2240 * 21. LOAD SAVED MENTAL NOTES
2241 * ============================================================================
2242 */
2243
2244async function loadMentalNotes() {
2245
2246    if (
2247        !DOCUMENT_ID ||
2248        !API.list
2249    ) {
2250
2251        return;
2252    }
2253
2254
2255    const url =
2256        new URL(
2257            absoluteApiUrl(
2258                API.list
2259            )
2260        );
2261
2262
2263    url.searchParams.set(
2264        "document_id",
2265        DOCUMENT_ID
2266    );
2267
2268
2269    try {
2270
2271        const data =
2272            await apiFetch(
2273                url.toString(),
2274                {
2275                    method: "GET",
2276                }
2277            );
2278
2279        mentalNoteState.isAuthenticated =
2280            data.is_authenticated === true;
2281            
2282        const notes =
2283            Array.isArray(
2284                data.mental_notes
2285            )
2286                ? data.mental_notes
2287                : [];
2288
2289
2290        /*
2291         * Apply notes starting from the end of the article.
2292         *
2293         * This minimizes DOM splitting from affecting earlier locations.
2294         */
2295
2296        notes.sort(
2297            function(a, b) {
2298
2299                return (
2300                    Number(b.start_offset) -
2301                    Number(a.start_offset)
2302                );
2303            }
2304        );
2305
2306
2307        for (const note of notes) {
2308
2309            mentalNoteState.notes.set(
2310                Number(note.id),
2311                note
2312            );
2313
2314
2315            applyMentalNoteHighlight(
2316                note
2317            );
2318        }
2319    }
2320    catch (error) {
2321
2322        console.error(
2323            "[MentalNotes] Load failed:",
2324            error
2325        );
2326    }
2327}
2328
2329
2330/* ============================================================================
2331 * 22. VALIDATE STORED OFFSETS
2332 * ============================================================================
2333 */
2334
2335function validateStoredAnchor(
2336    note,
2337    articleText
2338) {
2339
2340    const start =
2341        Number(note.start_offset);
2342
2343
2344    const end =
2345        Number(note.end_offset);
2346
2347
2348    if (
2349        !Number.isInteger(start) ||
2350        !Number.isInteger(end)
2351    ) {
2352
2353        return false;
2354    }
2355
2356
2357    if (
2358        start < 0 ||
2359        end <= start ||
2360        end > articleText.length
2361    ) {
2362
2363        return false;
2364    }
2365
2366
2367    return (
2368        articleText.slice(
2369            start,
2370            end
2371        ) ===
2372        note.selected_text
2373    );
2374}
2375
2376
2377/* ============================================================================
2378 * 23. RESOLVE STORED NOTE ANCHOR
2379 * ============================================================================
2380 */
2381
2382function resolveAnchor(
2383    note,
2384    root = getArticleRoot()
2385) {
2386
2387    const articleText =
2388        getCanonicalArticleText(root);
2389
2390
2391    /*
2392     * Preferred:
2393     * saved offsets still point to same text.
2394     */
2395
2396    if (
2397        validateStoredAnchor(
2398            note,
2399            articleText
2400        )
2401    ) {
2402
2403        return {
2404
2405            start:
2406                Number(note.start_offset),
2407
2408            end:
2409                Number(note.end_offset),
2410        };
2411    }
2412
2413
2414    /*
2415     * Offset failed.
2416     *
2417     * Try finding selected text directly.
2418     */
2419
2420    const selectedText =
2421        note.selected_text || "";
2422
2423
2424    if (!selectedText) {
2425
2426        return null;
2427    }
2428
2429
2430    const first =
2431        articleText.indexOf(
2432            selectedText
2433        );
2434
2435
2436    if (
2437        first === -1
2438    ) {
2439
2440        return null;
2441    }
2442
2443
2444    /*
2445     * If selected text exists only once,
2446     * use that location.
2447     */
2448
2449    const second =
2450        articleText.indexOf(
2451            selectedText,
2452            first + 1
2453        );
2454
2455
2456    if (
2457        second === -1
2458    ) {
2459
2460        return {
2461
2462            start:
2463                first,
2464
2465            end:
2466                first +
2467                selectedText.length,
2468        };
2469    }
2470
2471
2472    /*
2473     * Text appears more than once.
2474     *
2475     * Compare prefix/suffix context.
2476     */
2477
2478    const candidates =
2479        [];
2480
2481
2482    let position =
2483        first;
2484
2485
2486    while (
2487        position !== -1
2488    ) {
2489
2490        let score =
2491            0;
2492
2493
2494        const prefix =
2495            note.prefix_text || "";
2496
2497
2498        const suffix =
2499            note.suffix_text || "";
2500
2501
2502        if (prefix) {
2503
2504            const actualPrefix =
2505                articleText.slice(
2506
2507                    Math.max(
2508                        0,
2509                        position -
2510                        prefix.length
2511                    ),
2512
2513                    position
2514                );
2515
2516
2517            if (
2518                actualPrefix === prefix
2519            ) {
2520
2521                score += 1;
2522            }
2523        }
2524
2525
2526        if (suffix) {
2527
2528            const actualSuffix =
2529                articleText.slice(
2530
2531                    position +
2532                    selectedText.length,
2533
2534                    position +
2535                    selectedText.length +
2536                    suffix.length
2537                );
2538
2539
2540            if (
2541                actualSuffix === suffix
2542            ) {
2543
2544                score += 1;
2545            }
2546        }
2547
2548
2549        candidates.push(
2550            {
2551
2552                start:
2553                    position,
2554
2555                end:
2556                    position +
2557                    selectedText.length,
2558
2559                score:
2560                    score,
2561            }
2562        );
2563
2564
2565        position =
2566            articleText.indexOf(
2567
2568                selectedText,
2569
2570                position + 1
2571            );
2572    }
2573
2574
2575    candidates.sort(
2576        function(a, b) {
2577
2578            return (
2579                b.score -
2580                a.score
2581            );
2582        }
2583    );
2584
2585
2586    if (
2587        candidates.length === 0
2588    ) {
2589
2590        return null;
2591    }
2592
2593
2594    /*
2595     * No context matched.
2596     */
2597
2598    if (
2599        candidates[0].score === 0
2600    ) {
2601
2602        return null;
2603    }
2604
2605
2606    /*
2607     * Ambiguous:
2608     * top two matches have same score.
2609     */
2610
2611    if (
2612        candidates.length > 1 &&
2613        candidates[0].score ===
2614        candidates[1].score
2615    ) {
2616
2617        return null;
2618    }
2619
2620
2621    return candidates[0];
2622}
2623
2624
2625/* ============================================================================
2626 * 24. CREATE HIGHLIGHT SPAN
2627 * ============================================================================
2628 */
2629
2630function createHighlightSpan(
2631    noteId
2632) {
2633
2634    const span =
2635        document.createElement(
2636            "span"
2637        );
2638
2639
2640    span.className =
2641        "mental-note-highlight";
2642
2643
2644    span.dataset.mentalNoteId =
2645        String(noteId);
2646
2647
2648    span.setAttribute(
2649        "data-tooltip-type",
2650        "mental_note"
2651    );
2652
2653
2654    /*
2655     * Temporary styling.
2656     *
2657     * We can move this into qimeta_show_file CSS later.
2658     */
2659
2660    Object.assign(
2661        span.style,
2662        {
2663
2664            backgroundColor:
2665                "rgba(255, 235, 59, 0.35)",
2666
2667            borderBottom:
2668                "1px solid rgba(160, 130, 0, .55)",
2669
2670            cursor:
2671                "pointer",
2672        }
2673    );
2674
2675
2676    return span;
2677}
2678
2679
2680/* ============================================================================
2681 * 25. APPLY MENTAL NOTE HIGHLIGHT TO DOM
2682 * ============================================================================
2683 */
2684
2685function applyMentalNoteHighlight(
2686    note
2687) {
2688
2689    const root =
2690        getArticleRoot();
2691
2692
2693    if (!root) {
2694
2695        return false;
2696    }
2697
2698
2699    /*
2700     * Avoid rendering same note twice.
2701     */
2702
2703    const existing =
2704        root.querySelector(
2705            `[data-mental-note-id="${String(note.id)}"]`
2706        );
2707
2708
2709    if (existing) {
2710
2711        return true;
2712    }
2713
2714
2715    const resolved =
2716        resolveAnchor(
2717            note,
2718            root
2719        );
2720
2721
2722    if (!resolved) {
2723
2724        console.warn(
2725
2726            "[MentalNotes] Could not resolve anchor for Mental Note:",
2727
2728            note.id
2729        );
2730
2731
2732        return false;
2733    }
2734
2735
2736    const nodes =
2737        getEligibleTextNodes(root);
2738
2739
2740    let cursor =
2741        0;
2742
2743
2744    const segments =
2745        [];
2746
2747
2748    /*
2749     * Find every article text node touched by the Mental Note.
2750     */
2751
2752    for (const node of nodes) {
2753
2754        const nodeStart =
2755            cursor;
2756
2757
2758        const nodeEnd =
2759            cursor +
2760            node.nodeValue.length;
2761
2762
2763        cursor =
2764            nodeEnd;
2765
2766
2767        /*
2768         * Node occurs entirely before note.
2769         */
2770
2771        if (
2772            nodeEnd <=
2773            resolved.start
2774        ) {
2775
2776            continue;
2777        }
2778
2779
2780        /*
2781         * Node occurs entirely after note.
2782         */
2783
2784        if (
2785            nodeStart >=
2786            resolved.end
2787        ) {
2788
2789            break;
2790        }
2791
2792
2793        const localStart =
2794            Math.max(
2795
2796                0,
2797
2798                resolved.start -
2799                nodeStart
2800            );
2801
2802
2803        const localEnd =
2804            Math.min(
2805
2806                node.nodeValue.length,
2807
2808                resolved.end -
2809                nodeStart
2810            );
2811
2812
2813        if (
2814            localEnd >
2815            localStart
2816        ) {
2817
2818            segments.push(
2819                {
2820
2821                    node:
2822                        node,
2823
2824                    localStart:
2825                        localStart,
2826
2827                    localEnd:
2828                        localEnd,
2829                }
2830            );
2831        }
2832    }
2833
2834
2835    if (
2836        segments.length === 0
2837    ) {
2838
2839        return false;
2840    }
2841
2842
2843    /*
2844     * Modify DOM backwards.
2845     *
2846     * This is safer because splitting later text nodes cannot invalidate
2847     * references to earlier text nodes.
2848     */
2849
2850    for (
2851        let i = segments.length - 1;
2852        i >= 0;
2853        i--
2854    ) {
2855
2856        const segment =
2857            segments[i];
2858
2859
2860        let node =
2861            segment.node;
2862
2863
2864        if (
2865            !node.isConnected ||
2866            !node.parentNode
2867        ) {
2868
2869            continue;
2870        }
2871
2872
2873        /*
2874         * Example:
2875         *
2876         * original:
2877         *   "Federal Reserve raised interest rates today"
2878         *
2879         * selection:
2880         *   "raised interest rates"
2881         *
2882         * splitText creates:
2883         *
2884         *   "Federal Reserve "
2885         *   "raised interest rates"
2886         *   " today"
2887         */
2888
2889
2890        if (
2891            segment.localEnd <
2892            node.nodeValue.length
2893        ) {
2894
2895            node.splitText(
2896                segment.localEnd
2897            );
2898        }
2899
2900
2901        if (
2902            segment.localStart > 0
2903        ) {
2904
2905            node =
2906                node.splitText(
2907                    segment.localStart
2908                );
2909        }
2910
2911
2912        const span =
2913            createHighlightSpan(
2914                note.id
2915            );
2916
2917
2918        node.parentNode.insertBefore(
2919            span,
2920            node
2921        );
2922
2923
2924        span.appendChild(
2925            node
2926        );
2927    }
2928
2929
2930    return true;
2931}
2932
2933
2934/* ============================================================================
2935 * 26. SHOW SAVED MENTAL NOTE
2936 * ============================================================================
2937 */
2938
2939function showSavedMentalNote(
2940    noteId,
2941    triggerElement
2942) {
2943
2944    const note =
2945        mentalNoteState.notes.get(
2946            Number(noteId)
2947        );
2948
2949
2950    if (!note) {
2951        return;
2952    }
2953
2954    recordMentalNoteNavigationEvent(
2955        "mental_note_open",
2956        note
2957    );
2958
2959
2960    beginMentalNoteEditorSession(
2961        "existing",
2962        Number(noteId)
2963    );
2964
2965    /*
2966     * We are now editing an existing Mental Note.
2967     */
2968    mentalNoteState.editingNoteId =
2969        Number(noteId);
2970
2971
2972    mentalNoteState.pendingSelection =
2973        null;
2974
2975
2976    const editor =
2977        ensureEditor();
2978    const title =
2979        editor.querySelector(
2980            ".mental-note-title"
2981        );
2982
2983    title.textContent =
2984        "Mental Note";
2985
2986    const owner = editor.querySelector(
2987        ".mental-note-owner"
2988    );
2989
2990
2991    const preview =
2992        editor.querySelector(
2993            ".mental-note-selected-preview"
2994        );
2995
2996    
2997    const ownerName =
2998        note.owner?.display_name ||
2999        note.owner?.username ||
3000        "Unknown";
3001
3002
3003    owner.textContent =
3004        `Added by ${ownerName}`;
3005
3006    owner.style.display =
3007        "";
3008
3009    const input =
3010        editor.querySelector(
3011            ".mental-note-input"
3012        );
3013    input.style.display ="";
3014
3015
3016    const saveButton =
3017        editor.querySelector(
3018            ".mental-note-save"
3019        );
3020
3021    saveButton.dataset.action =
3022        "save";
3023
3024    saveButton.textContent =
3025        "Save";
3026
3027    const cancelButton =
3028        editor.querySelector(
3029            ".mental-note-cancel"
3030        );
3031
3032    const deleteButton =
3033        editor.querySelector(
3034            ".mental-note-delete"
3035        );
3036
3037    const error =
3038        editor.querySelector(
3039            ".mental-note-error"
3040        );
3041
3042
3043    preview.textContent =
3044        `"${(note.selected_text || "").trim()}"`;
3045
3046
3047    input.value =
3048        note.note_text || "";
3049
3050
3051    const canEdit =
3052        note.can_edit === true;
3053
3054    const canDelete =
3055        note.can_delete === true;
3056
3057
3058    deleteButton.style.display =
3059        canDelete
3060            ? ""
3061            : "none";
3062            
3063    input.style.display =
3064        "";
3065
3066
3067    input.readOnly =
3068        !canEdit;
3069
3070
3071    if (canEdit) {
3072
3073        saveButton.style.display =
3074            "";
3075
3076        saveButton.textContent =
3077            "Save";
3078
3079        cancelButton.textContent =
3080            "Cancel";
3081    }
3082    else {
3083
3084        /*
3085        * Anonymous readers and other authenticated readers
3086        * can view this Mental Note, but cannot modify it.
3087        */
3088        saveButton.style.display =
3089            "none";
3090
3091        cancelButton.textContent =
3092            "Close";
3093    }
3094
3095    if (canEdit) {
3096
3097        input.style.opacity =
3098            "1";
3099
3100        input.style.cursor =
3101            "text";
3102    }
3103    else {
3104
3105        input.style.opacity =
3106            "0.9";
3107
3108        input.style.cursor =
3109            "default";
3110    }
3111
3112    error.style.display =
3113        "none";
3114
3115
3116    positionEditor(
3117        triggerElement
3118    );
3119
3120
3121    input.focus();
3122
3123
3124}
3125
3126/* ============================================================================
3127 * DELETE EXISTING MENTAL NOTE
3128 * ============================================================================
3129 */
3130async function deleteExistingMentalNote() {
3131
3132    const noteId =
3133        mentalNoteState.editingNoteId;
3134
3135
3136    if (
3137        noteId === null ||
3138        noteId === undefined
3139    ) {
3140        return;
3141    }
3142
3143
3144    const note =
3145        mentalNoteState.notes.get(
3146            Number(noteId)
3147        );
3148
3149
3150    if (
3151        !note ||
3152        note.can_delete !== true
3153    ) {
3154
3155        showEditorError(
3156            "You do not have permission to delete this Mental Note."
3157        );
3158
3159        return;
3160    }
3161
3162
3163    if (!API.deleteTemplate) {
3164
3165        showEditorError(
3166            "Mental Note delete URL is not configured."
3167        );
3168
3169        return;
3170    }
3171
3172
3173    const confirmed =
3174        window.confirm(
3175            "Delete this Mental Note?"
3176        );
3177
3178
3179    if (!confirmed) {
3180        return;
3181    }
3182
3183
3184    const deleteUrl =
3185        API.deleteTemplate.replace(
3186            "999999",
3187            String(noteId)
3188        );
3189
3190
3191    try {
3192
3193        await apiFetch(
3194            deleteUrl,
3195            {
3196                method: "POST",
3197                body: JSON.stringify({}),
3198            }
3199        );
3200
3201
3202        mentalNoteState.notes.delete(
3203            Number(noteId)
3204        );
3205
3206
3207        /*
3208         * Remove the existing article highlight.
3209         */
3210        document
3211            .querySelectorAll(
3212                `.mental-note-highlight[data-mental-note-id="${CSS.escape(String(noteId))}"]`
3213            )
3214            .forEach(
3215                function(element) {
3216
3217                    const parent =
3218                        element.parentNode;
3219
3220                    if (!parent) {
3221                        return;
3222                    }
3223
3224
3225                    while (
3226                        element.firstChild
3227                    ) {
3228
3229                        parent.insertBefore(
3230                            element.firstChild,
3231                            element
3232                        );
3233                    }
3234
3235
3236                    parent.removeChild(
3237                        element
3238                    );
3239
3240                    parent.normalize();
3241                }
3242            );
3243
3244
3245        document.dispatchEvent(
3246            new CustomEvent(
3247                "qimeta:mental-notes-changed",
3248                {
3249                    detail: {
3250                        action: "delete",
3251                        noteId: Number(noteId),
3252                    },
3253                }
3254            )
3255        );
3256
3257
3258        clearMentalNoteEditorSession();
3259
3260        closeEditor();
3261    }
3262    catch (error) {
3263
3264        console.error(
3265            "[MentalNotes] Delete failed:",
3266            error
3267        );
3268
3269        showEditorError(
3270            error.message ||
3271            "Could not delete Mental Note."
3272        );
3273    }
3274}
3275/* ============================================================================
3276 * SAVE EXISTING MENTAL NOTE
3277 * ============================================================================
3278 */
3279
3280async function saveExistingMentalNote() {
3281
3282    const noteId =
3283        mentalNoteState.editingNoteId;
3284
3285
3286    if (
3287        noteId === null ||
3288        noteId === undefined
3289    ) {
3290
3291        return;
3292    }
3293
3294
3295    if (!API.updateTemplate) {
3296
3297        showEditorError(
3298            "Mental Note update URL is not configured."
3299        );
3300
3301        return;
3302    }
3303
3304
3305    const editor =
3306        ensureEditor();
3307
3308
3309    const input =
3310        editor.querySelector(
3311            ".mental-note-input"
3312        );
3313
3314
3315    const saveButton =
3316        editor.querySelector(
3317            ".mental-note-save"
3318        );
3319
3320
3321    const noteText =
3322        input.value.trim();
3323
3324
3325    if (!noteText) {
3326
3327        showEditorError(
3328            "Enter a mental note before saving."
3329        );
3330
3331        return;
3332    }
3333
3334
3335    const currentNote =
3336        mentalNoteState.notes.get(
3337            Number(noteId)
3338        );
3339
3340
3341    if (!currentNote) {
3342
3343        showEditorError(
3344            "Mental Note could not be found."
3345        );
3346
3347        return;
3348    }
3349
3350    if (
3351        currentNote.can_edit !== true
3352    ) {
3353
3354        showEditorError(
3355            "You do not have permission to edit this Mental Note."
3356        );
3357
3358        return;
3359    }
3360
3361    const originalNoteText =
3362        (
3363            currentNote.note_text || ""
3364        ).trim();
3365
3366
3367    const noteChanged =
3368        originalNoteText !==
3369        noteText;
3370
3371    /*
3372     * Django generated the URL using note_id=999999.
3373     *
3374     * Replace that placeholder with the actual MentalNote ID.
3375     */
3376    const updateUrl =
3377        API.updateTemplate.replace(
3378            "999999",
3379            String(noteId)
3380        );
3381
3382
3383    saveButton.disabled =
3384        true;
3385
3386
3387    try {
3388
3389        const data =
3390            await apiFetch(
3391
3392                updateUrl,
3393
3394                {
3395
3396                    method:
3397                        "POST",
3398
3399                    body:
3400                        JSON.stringify(
3401                            {
3402
3403                                note_type:
3404                                    currentNote.note_type || "TEXT",
3405
3406                                note_text:
3407                                    noteText,
3408                            }
3409                        ),
3410                }
3411            );
3412
3413
3414        /*
3415         * The Django update endpoint returns only the fields
3416         * that changed, so merge them into the existing note.
3417         */
3418        const updatedNote = {
3419
3420            ...currentNote,
3421
3422            ...data.mental_note,
3423        };
3424
3425
3426        mentalNoteState.notes.set(
3427            Number(noteId),
3428            updatedNote
3429        );
3430
3431
3432        console.log(
3433            "[MentalNotes] Updated Mental Note:",
3434            noteId
3435        );
3436
3437
3438        const mentalNoteDurationMs =
3439            getMentalNoteEditorDurationMs();
3440
3441
3442        recordMentalNoteNavigationEvent(
3443            "mental_note_update",
3444            updatedNote,
3445            {
3446                durationMs:
3447                    mentalNoteDurationMs,
3448
3449                noteChanged:
3450                    noteChanged,
3451            }
3452        );
3453
3454
3455        clearMentalNoteEditorSession();
3456
3457
3458        closeEditor();
3459    }
3460    catch (error) {
3461
3462        console.error(
3463            "[MentalNotes] Update failed:",
3464            error
3465        );
3466
3467
3468        showEditorError(
3469            error.message ||
3470            "Could not update Mental Note."
3471        );
3472    }
3473    finally {
3474
3475        saveButton.disabled =
3476            false;
3477    }
3478}
3479
3480/* ============================================================================
3481 * 27. RESET EDITOR INTO CREATE MODE
3482 * ============================================================================
3483 */
3484
3485function resetEditorToCreateMode() {
3486
3487    if (
3488        !mentalNoteState.editor
3489    ) {
3490
3491        return;
3492    }
3493
3494
3495    const input =
3496        mentalNoteState.editor.querySelector(
3497            ".mental-note-input"
3498        );
3499
3500
3501    const saveButton =
3502        mentalNoteState.editor.querySelector(
3503            ".mental-note-save"
3504        );
3505
3506
3507    const cancelButton =
3508        mentalNoteState.editor.querySelector(
3509            ".mental-note-cancel"
3510        );
3511
3512    const deleteButton =
3513        mentalNoteState.editor.querySelector(
3514            ".mental-note-delete"
3515        );
3516
3517    deleteButton.style.display =
3518        "none";
3519        
3520    input.readOnly =
3521        false;
3522
3523
3524    saveButton.style.display =
3525        "";
3526
3527
3528    cancelButton.textContent =
3529        "Cancel";
3530}
3531
3532
3533/* ============================================================================
3534 * 28. CAPTURE CURRENT SELECTION
3535 * ============================================================================
3536 */
3537
3538function captureCurrentSelection() {
3539
3540    resetEditorToCreateMode();
3541
3542
3543    const selection =
3544        window.getSelection();
3545
3546
3547    const anchor =
3548        buildAnchorFromSelection(
3549            selection
3550        );
3551
3552
3553    if (!anchor) {
3554
3555        mentalNoteState.pendingSelection =
3556            null;
3557
3558
3559        hideActionButton();
3560
3561
3562        return;
3563    }
3564
3565
3566    mentalNoteState.pendingSelection =
3567        anchor;
3568
3569
3570    showActionButton(
3571        anchor.range
3572    );
3573}
3574
3575
3576/* ============================================================================
3577 * 29. BIND SELECTION AND CLICK EVENTS
3578 * ============================================================================
3579 */
3580
3581function bindSelectionEvents() {
3582
3583    const root =
3584        getArticleRoot();
3585
3586
3587    if (!root) {
3588
3589        return;
3590    }
3591
3592
3593    /*
3594     * Desktop mouse selection.
3595     */
3596
3597    root.addEventListener(
3598        "mouseup",
3599        function() {
3600
3601            window.setTimeout(
3602                captureCurrentSelection,
3603                0
3604            );
3605        }
3606    );
3607
3608
3609    /*
3610     * Useful for touch/tablet selections.
3611     *
3612     * Debounce because selectionchange fires repeatedly
3613     * while selection handles are moving.
3614     */
3615
3616    let selectionTimer =
3617        null;
3618
3619
3620    document.addEventListener(
3621        "selectionchange",
3622        function() {
3623
3624            window.clearTimeout(
3625                selectionTimer
3626            );
3627
3628
3629            selectionTimer =
3630                window.setTimeout(
3631                    function() {
3632
3633                        const selection =
3634                            window.getSelection();
3635
3636
3637                        if (
3638                            !selection ||
3639                            selection.isCollapsed
3640                        ) {
3641
3642                            return;
3643                        }
3644
3645
3646                        captureCurrentSelection();
3647
3648                    },
3649
3650                    150
3651                );
3652        }
3653    );
3654
3655
3656    /*
3657     * Delegated click handler.
3658     *
3659     * One Mental Note may consist of multiple highlight spans if the
3660     * selected passage crossed existing HTML tags.
3661     */
3662
3663    root.addEventListener(
3664        "click",
3665        function(event) {
3666
3667            const target =
3668                event.target;
3669
3670
3671            if (
3672                !(target instanceof Element)
3673            ) {
3674
3675                return;
3676            }
3677
3678
3679            const highlight =
3680                target.closest(
3681                    ".mental-note-highlight"
3682                );
3683
3684
3685            if (!highlight) {
3686
3687                return;
3688            }
3689
3690
3691            event.preventDefault();
3692
3693
3694            /*
3695             * Do not use stopImmediatePropagation().
3696             *
3697             * We don't want to unnecessarily interfere with other
3698             * QiMeta document listeners.
3699             */
3700
3701            event.stopPropagation();
3702
3703
3704            hideActionButton();
3705
3706
3707            mentalNoteState.pendingSelection =
3708                null;
3709
3710
3711            const selection =
3712                window.getSelection();
3713
3714
3715            if (selection) {
3716
3717                selection.removeAllRanges();
3718            }
3719
3720
3721            showSavedMentalNote(
3722
3723                highlight.dataset.mentalNoteId,
3724
3725                highlight
3726            );
3727        }
3728    );
3729
3730
3731    /*
3732     * Clicking elsewhere hides the temporary "Mental Note" action.
3733     */
3734
3735    document.addEventListener(
3736        "mousedown",
3737        function(event) {
3738
3739            const target =
3740                event.target;
3741
3742
3743            if (
3744                target instanceof Element
3745            ) {
3746
3747                if (
3748                    target.closest(
3749                        ".mental-note-action"
3750                    )
3751                ) {
3752
3753                    return;
3754                }
3755
3756
3757                if (
3758                    target.closest(
3759                        ".mental-note-editor"
3760                    )
3761                ) {
3762
3763                    return;
3764                }
3765            }
3766
3767
3768            hideActionButton();
3769        }
3770    );
3771}
3772
3773
3774/* ============================================================================
3775 * 30. INITIALIZE
3776 * ============================================================================
3777 */
3778
3779async function initializeMentalNotes() {
3780
3781    const root =
3782        getArticleRoot();
3783
3784
3785    if (!root) {
3786
3787        console.warn(
3788            "[MentalNotes] Article root not found. Mental Notes disabled."
3789        );
3790
3791        return;
3792    }
3793
3794
3795    if (!DOCUMENT_ID) {
3796
3797        console.warn(
3798            "[MentalNotes] documentId missing. Mental Notes disabled."
3799        );
3800
3801        return;
3802    }
3803
3804
3805    /*
3806     * Selection UI can function even before APIs are configured.
3807     */
3808
3809    bindSelectionEvents();
3810
3811
3812    if (
3813        !API.list ||
3814        !API.create
3815    ) {
3816
3817        console.warn(
3818
3819            "[MentalNotes] Mental Note API URLs are not configured. " +
3820
3821            "Selection UI is active, but loading/saving requires " +
3822
3823            "window.QIMETA.mentalNotesListUrl and " +
3824
3825            "window.QIMETA.mentalNoteCreateUrl."
3826        );
3827
3828
3829        return;
3830    }
3831
3832
3833    await loadMentalNotes();
3834}
3835
3836
3837/* ============================================================================
3838 * 31. START AFTER DOM LOAD
3839 * ============================================================================
3840 */
3841
3842if (
3843    document.readyState ===
3844    "loading"
3845) {
3846
3847    document.addEventListener(
3848
3849        "DOMContentLoaded",
3850
3851        initializeMentalNotes,
3852
3853        {
3854            once: true,
3855        }
3856    );
3857}
3858else {
3859
3860    initializeMentalNotes();
3861}
3862
3863
3864/* ============================================================================
3865 * 32. EXPORTS
3866 * ============================================================================
3867 *
3868 * Mostly useful for debugging/testing.
3869 * ============================================================================
3870 */
3871
3872export {
3873
3874    initializeMentalNotes,
3875
3876    getArticleRoot,
3877
3878    getCanonicalArticleText,
3879
3880    buildAnchorFromSelection,
3881
3882    applyMentalNoteHighlight,
3883
3884    resolveAnchor,
3885};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.