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.