1<!doctype html> 2<html lang="en"> 3 <head> 4 <meta charset="utf-8" /> 5 <meta name="viewport" content="width=device-width,initial-scale=1" /> 6 <meta name="referrer" content="no-referrer" /> 7 <meta 8 http-equiv="Content-Security-Policy" 9 content="default-src 'self'; script-src 'self' https://gc.zgo.at https://cdnjs.cloudflare.com https://cdn.jsdelivr.net 'sha256-gMK4pykGz/gVoh9PKGKrH+Bj1eMsDBrwjVwuvOt+Zys=' 'sha256-p4uUv+kujfJG7TrNl2/wjrWrDiuIENDML8qDEeuxfuM=' 'sha256-DNx9rrL+BfMish9h8ETnC3xGfwx5iYIvQaL/HhqNpcQ=' 'sha256-1wO3OWK0/Ob77IkLXZkvXhxesyzKilfI/c3j/Ru/tCY=' 'sha256-2NI1bfKYlapXvdvjl9Y+SCpn8kLnFRDCBmBjS5ZhbMk=' 'sha256-KtgzXSeWJyWuGF0lImPlTc48a51d2W4ThOb4qFgwZxI=' 'sha256-vEbSE+62dSRCWcJvhgYD//x/1wbTAhQuFUscaMfLr6s=' 'sha256-lHZB5jp1zqqQIt+dZXxT1xCAHbJhNEGNx36zowvQyWU='; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; img-src 'self' blob: data: https://markv.goatcounter.com https://github.com https://raw.githubusercontent.com https://user-images.githubusercontent.com https://private-user-images.githubusercontent.com https://media.githubusercontent.com https://avatars.githubusercontent.com; media-src 'self' blob: data: https://github.com https://raw.githubusercontent.com https://user-images.githubusercontent.com https://private-user-images.githubusercontent.com https://media.githubusercontent.com https://avatars.githubusercontent.com; font-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https://markv.goatcounter.com https://api.groq.com http://127.0.0.1:* https://127.0.0.1:* http://localhost:* https://localhost:*; worker-src 'self' blob:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; manifest-src 'self'" 10 /> 11 <meta 12 name="theme-color" 13 content="#fafaf9" 14 media="(prefers-color-scheme: light)" 15 /> 16 <meta 17 name="theme-color" 18 content="#161616" 19 media="(prefers-color-scheme: dark)" 20 /> 21 <title>Markdown Viewer</title> 22 <link rel="manifest" href="./manifest.webmanifest" /> 23 <link 24 rel="icon" 25 type="image/png" 26 sizes="192x192" 27 href="./assets/icons/icon-192.png" 28 /> 29 <link 30 rel="icon" 31 type="image/png" 32 sizes="512x512" 33 href="./assets/icons/icon-512.png" 34 /> 35 <link rel="apple-touch-icon" href="./assets/icons/icon-192.png" /> 36 <link rel="stylesheet" href="assets/command-palette.css?v=2" /> 37 38
38<script src="assets/analytics.js"></script>
38 39
39<script src="assets/library-core.js?v=16"></script>
39 40
40<script src="assets/visual-blocks.js?v=16"></script>
40 41
41<script src="assets/shortcut-registry.js?v=2"></script>
41 42
42<script src="assets/editor-markdown-actions.js?v=2"></script>
42 43
43<script src="assets/command-palette-core.js?v=2"></script>
43 44
44<script src="assets/command-palette-host.js?v=4"></script>
44 45
45<script src="assets/command-palette.js?v=5"></script>
45 46
46<script src="assets/reader-palettes.js?v=4"></script>
46 47
47<script src="assets/export-core.js?v=2"></script>
47 48
48<script src="assets/export-host.js?v=4"></script>
48 49
49<script src="assets/csv-sheet.js?v=1"></script>
49 50
50<script src="assets/pwa.js"></script>
50 51
51<script> 52 (function () { 53 "use strict"; 54 55 const READING_KEY = "md-viewer-reading"; 56 const defaults = { 57 fontSize: 19, 58 lineHeight: 1.72, 59 measure: 680, 60 }; 61 62 function clamp(value, min, max) { 63 return Math.min(max, Math.max(min, value)); 64 } 65 66 function roundToStep(value, step, min) { 67 const offset = Math.round((value - min) / step) * step + min; 68 return Number(offset.toFixed(2)); 69 } 70 71 try { 72 const raw = window.localStorage.getItem(READING_KEY); 73 if (!raw) return; 74 const parsed = JSON.parse(raw); 75 if (!parsed || typeof parsed !== "object") return; 76 77 const fontSize = roundToStep( 78 clamp(Number(parsed.fontSize) || defaults.fontSize, 16, 22), 79 0.5, 80 16, 81 ); 82 const lineHeight = roundToStep( 83 clamp(Number(parsed.lineHeight) || defaults.lineHeight, 1.45, 1.9), 84 0.02, 85 1.45, 86 ); 87 const measure = roundToStep( 88 clamp(Number(parsed.measure) || defaults.measure, 560, 820), 89 20, 90 560, 91 ); 92 93 const root = document.documentElement; 94 root.style.setProperty("--reading-font-size", fontSize + "px"); 95 root.style.setProperty("--reading-line-height", String(lineHeight)); 96 root.style.setProperty("--reading-measure", measure + "px"); 97 } catch (_error) {} 98 })(); 99 </script>
99 100 101 <style> 102 /* ============================================================ 103 Markdown Viewer â editorial, readability-first. 104 Body text: local serif stack 105 UI chrome: local sans stack 106 Code: system monospace 107 Palette: warm cream (light) / soft charcoal (dark) 108 ============================================================ */ 109 110 :root, 111 :root[data-theme="light"] { 112 color-scheme: light; 113 114 /* Still Water â neutral mist, rice-paper calm. */ 115 --bg-canvas: #fafaf9; 116 --bg-subtle: #f4f3f1; 117 --bg-muted: #ecebe9; 118 --bg-raised: #ffffff; 119 120 /* Soft near-black â warm enough to feel human, neutral enough for calm. */ 121 --fg-default: #222221; 122 --fg-muted: #71706e; 123 --fg-subtle: #a3a09c; 124 125 /* Borders dissolve into the background. */ 126 --border-soft: rgba(0, 0, 0, 0.04); 127 --border-default: rgba(0, 0, 0, 0.07); 128 --border-strong: rgba(0, 0, 0, 0.11); 129 130 /* Sage-eucalyptus accent â quieter than saturated teal. */ 131 --accent-fg: #5f8f80; 132 --accent-hover: #4d7d6e; 133 134 --code-bg: rgba(0, 0, 0, 0.025); 135 --code-inline-bg: rgba(0, 0, 0, 0.035); 136 --code-block-bg: #f5f4f2; 137 138 --blockquote-border: #c7c4be; 139 --blockquote-bg: rgba(0, 0, 0, 0.018); 140 141 --selection: rgba(95, 143, 128, 0.14); 142 --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.025); 143 --shadow-md: 0 8px 30px rgba(0, 0, 0, 0.04); 144 --alert-note: #4f7fa8; 145 --alert-tip: #4f8c74; 146 --alert-important: #6f73c4; 147 --alert-warning: #b6801f; 148 --alert-caution: #b35d5d; 149 --ont-intro: rgba(100, 150, 210, 0.55); 150 --ont-definition: rgba(100, 150, 210, 0.55); 151 --ont-explanation: rgba(170, 135, 55, 0.55); 152 --ont-instruction: rgba(130, 170, 110, 0.55); 153 --ont-argument: rgba(175, 100, 100, 0.5); 154 --ont-narrative: rgba(150, 120, 180, 0.5); 155 --ont-example: rgba(100, 160, 160, 0.5); 156 --ont-unknown: rgba(138, 133, 120, 0.45); 157 --ont-term-underline: rgba(214, 176, 89, 0.5); 158 159 /* ââ Mindmap tokens ââââââââââââââââââââââââââââââââââââââ */ 160 --mm-canvas-bg: var(--bg-canvas); 161 --mm-node-bg: var(--bg-raised); 162 --mm-node-border: var(--border-strong); 163 --mm-node-border-hover: var(--accent-fg); 164 --mm-node-shadow: 165 0 2px 14px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.04); 166 --mm-node-shadow-hover: 167 0 6px 28px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.06); 168 --mm-node-filter: drop-shadow(0 2px 14px rgba(0, 0, 0, 0.05)) 169 drop-shadow(0 1px 3px rgba(0, 0, 0, 0.04)); 170 --mm-node-filter-hover: drop-shadow(0 6px 28px rgba(0, 0, 0, 0.08)) 171 drop-shadow(0 2px 6px rgba(0, 0, 0, 0.06)); 172 --mm-node-bg-selected: color-mix( 173 in srgb, 174 var(--accent-fg) 6%, 175 transparent 176 ); 177 --mm-edge-stroke: var(--fg-muted); 178 --mm-edge-opacity-primary: 0.32; 179 --mm-edge-opacity-secondary: 0.22; 180 --mm-edge-opacity-tertiary: 0.14; 181 --mm-label-serif: var(--font-serif); 182 --mm-badge-sans: var(--font-sans); 183 --mm-root-label-size: calc(var(--reading-font-size, 19px) * 1.18); 184 --mm-primary-label-size: calc(var(--reading-font-size, 19px) * 0.84); 185 --mm-secondary-label-size: calc(var(--reading-font-size, 19px) * 0.74); 186 --mm-badge-size: 10px; 187 --mm-ring-1-radius: 320px; 188 --mm-ring-2-radius: 620px; 189 --mm-ring-3-radius: 920px; 190 --mm-node-padding-x: 20px; 191 --mm-node-padding-y: 14px; 192 --mm-node-gap-min: 28px; 193 194 /* ââ Motion system ââââââââââââââââââââââââââââââââââââââââ 195 Calm & bliss curve â gentle ease-out, never bouncy. 196 Durations scale with affordance: micro â hover/focus,
197 small â toggles/toasts, panel â drawers/dialogs, 198 page â route-level transitions. */ 199 --motion-micro: 140ms; 200 --motion-small: 220ms; 201 --motion-panel: 380ms; 202 --motion-page: 520ms; 203 --ease-out: cubic-bezier(0.22, 1, 0.36, 1); 204 --ease-in: cubic-bezier(0.4, 0, 1, 1); 205 --ease-calm: cubic-bezier(0.25, 0.46, 0.45, 0.94); 206 } 207 208 /* Reduced-motion: honor user preference globally. Kills the 209 translate/scale, leaves opacity in place so state changes 210 still read. Any component-level animation below should 211 likewise respect this. */ 212 @media (prefers-reduced-motion: reduce) { 213 *, 214 *::before, 215 *::after { 216 animation-duration: 0.01ms !important; 217 animation-iteration-count: 1 !important; 218 transition-duration: 0.01ms !important; 219 scroll-behavior: auto !important; 220 } 221 } 222 223 :root[data-theme="dark"] { 224 color-scheme: dark; 225 226 /* Evening Calm â neutral charcoal, no warm/cool bias. */ 227 --bg-canvas: #161616; 228 --bg-subtle: #1d1d1d; 229 --bg-muted: #262626; 230 --bg-raised: #1f1f1f; 231 232 --fg-default: #e4e3e0; 233 --fg-muted: #9a9895; 234 --fg-subtle: #636261; 235 236 --border-soft: rgba(255, 255, 255, 0.05); 237 --border-default: rgba(255, 255, 255, 0.08); 238 --border-strong: rgba(255, 255, 255, 0.13); 239 240 --accent-fg: #88bead; 241 --accent-hover: #a0cfbf; 242 243 --code-bg: rgba(255, 255, 255, 0.04); 244 --code-inline-bg: rgba(255, 255, 255, 0.06); 245 --code-block-bg: #131313; 246 247 --blockquote-border: #5a5850; 248 --blockquote-bg: rgba(255, 255, 255, 0.02); 249 250 --selection: rgba(136, 190, 173, 0.18); 251 --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25); 252 --shadow-md: 0 8px 30px rgba(0, 0, 0, 0.35); 253 --alert-note: #83acd6; 254 --alert-tip: #83c1a7; 255 --alert-important: #9da6ef; 256 --alert-warning: #d6af62; 257 --alert-caution: #d98d8d; 258 --ont-intro: rgba(120, 170, 230, 0.45); 259 --ont-definition: rgba(120, 170, 230, 0.45); 260 --ont-explanation: rgba(190, 155, 75, 0.45); 261 --ont-instruction: rgba(150, 190, 130, 0.45); 262 --ont-argument: rgba(195, 120, 120, 0.4); 263 --ont-narrative: rgba(170, 140, 200, 0.4); 264 --ont-example: rgba(120, 180, 180, 0.4); 265 --ont-unknown: rgba(110, 104, 92, 0.4); 266 --ont-term-underline: rgba(214, 176, 89, 0.45); 267 268 /* ââ Mindmap dark overrides âââââââââââââââââââââââââââââââ */ 269 --mm-node-shadow: 270 0 2px 18px rgba(0, 0, 0, 0.35), 0 1px 3px rgba(0, 0, 0, 0.25); 271 --mm-node-shadow-hover: 272 0 6px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.3); 273 --mm-node-filter: drop-shadow(0 2px 18px rgba(0, 0, 0, 0.35)) 274 drop-shadow(0 1px 3px rgba(0, 0, 0, 0.25)); 275 --mm-node-filter-hover: drop-shadow(0 6px 32px rgba(0, 0, 0, 0.45)) 276 drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3)); 277 --mm-edge-opacity-primary: 0.38; 278 --mm-edge-opacity-secondary: 0.26; 279 --mm-edge-opacity-tertiary: 0.16; 280 --mm-node-bg-selected: rgba(120, 170, 230, 0.08); 281 } 282 283 /* Font stacks */ 284 :root { 285 --font-serif: 286 "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", 287 Georgia, serif; 288 --font-sans: 289 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", 290 "Helvetica Neue", Arial, sans-serif; 291 --font-mono: 292 ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, 293 "Liberation Mono", monospace; 294 --reading-font-size: 19px; 295 --reading-line-height: 1.72; 296 --reading-measure: 680px; 297 } 298 299 * { 300 box-sizing: border-box; 301 } 302 303 .visually-hidden { 304 position: absolute; 305 width: 1px; 306 height: 1px; 307 padding: 0; 308 margin: -1px; 309 overflow: hidden; 310 clip: rect(0, 0, 0, 0); 311 white-space: nowrap; 312 border: 0; 313 } 314 315 html, 316 body { 317 margin: 0; 318 height: 100%; 319 overflow-x: clip; 320 background: var(--bg-canvas); 321 color: var(--fg-default); 322 font-family: var(--font-sans); 323 font-size: 16px; 324 line-height: 1.5; 325 -webkit-font-smoothing: antialiased; 326 -moz-osx-font-smoothing: grayscale; 327 text-rendering: optimizeLegibility; 328 } 329 330 ::selection { 331 background: var(--selection); 332 } 333 334 body.dragging::before { 335 content: ""; 336 position: fixed; 337 inset: 16px; 338 border: 1.5px dashed var(--accent-fg); 339 border-radius: 16px; 340 pointer-events: none; 341 z-index: 100; 342 background: color-mix(in srgb, var(--accent-fg) 4%, transparent); 343 animation: drag-pulse 1s ease-in-out infinite alternate; 344 } 345 @keyframes drag-pulse { 346 from { 347 opacity: 0.6; 348 } 349 to { 350 opacity: 1; 351 } 352 } 353 354 html.empty-preview-state, 355 body.empty-preview-state { 356 overflow: hidden; 357 } 358 359 .app { 360 min-height: 100%; 361 min-height: 100dvh; 362 display: flex; 363 flex-direction: column; 364 } 365 366 /* ---------- Toolbar ---------- */ 367 .toolbar { 368 position: sticky; 369 top: 0; 370 z-index: 10; 371 display: flex; 372 align-items: center; 373 gap: 6px; 374 padding: 10px 20px; 375 background: color-mix(in srgb, var(--bg-canvas) 88%, transparent); 376 -webkit-backdrop-filter: saturate(1.2) blur(16px); 377 backdrop-filter: saturate(1.2) blur(16px); 378 border-bottom: 1px solid var(--border-soft); 379 } 380 body:is(.preview-mode, .edit-mode-active, .mindmap-open) .toolbar, 381 .preview-pane, 382 .editor-pane, 383 .mindmap-viewer, 384 .mermaid-viewer { 385 --bg-canvas: var(--reader-bg-canvas, #fafaf9); 386 --bg-subtle: var(--reader-bg-subtle, #f4f3f1); 387 --bg-muted: var(--reader-bg-subtle, #ecebe9); 388 --bg-raised: var(--reader-bg-raised, #ffffff); 389 --fg-default: var(--reader-fg-default, #222221); 390 --fg-heading: var(--reader-fg-heading, #222221); 391 --fg-muted: var(--reader-fg-muted, #71706e); 392 --fg-subtle: var(--reader-fg-subtle, #a3a09c); 393 --border-soft: var(--reader-border-soft, rgba(0, 0, 0, 0.04)); 394 --border-default: var(--reader-border-default, rgba(0, 0, 0, 0.07)); 395 --border-strong: var(--reader-border-strong, rgba(0, 0, 0, 0.11)); 396 --accent-fg: var(--reader-accent, #5f8f80); 397 --accent-hover: var(--reader-accent-hover, #4d7d6e); 398 --selection: var(--reader-selection, rgba(95, 143, 128, 0.14)); 399 --code-block-bg: var(--reader-code-bg, #f5f4f2); 400 --code-inline-bg: var(--reader-code-inline-bg, rgba(0, 0, 0, 0.035)); 401 --blockquote-bg: var(--reader-blockquote-bg, rgba(0, 0, 0, 0.018)); 402 --blockquote-border: var(--reader-blockquote-border, #c7c4be); 403 --shadow-sm: var(--reader-shadow-sm, 0 1px 3px rgba(0, 0, 0, 0.025)); 404 --alert-note: var(--reader-alert-note, #4f7fa8); 405 --alert-tip: var(--reader-alert-tip, #4f8c74); 406 --alert-important: var(--reader-alert-important, #6f73c4); 407 --alert-warning: var(--reader-alert-warning, #b6801f); 408 --alert-caution: var(--reader-alert-caution, #b35d5d); 409 --mm-canvas-bg: var(--reader-bg-canvas, var(--bg-canvas)); 410 --mm-node-bg: var(--reader-bg-raised, var(--bg-raised)); 411 --mm-node-bg-selected: color-mix( 412 in srgb, 413 var(--reader-accent, var(--accent-fg)) 8%, 414 transparent 415 ); 416 --mm-node-border: var(--reader-border-strong, var(--border-strong)); 417 --mm-node-border-hover: var(--reader-accent, var(--accent-fg)); 418 --mm-edge-stroke: var(--reader-fg-muted, var(--fg-muted)); 419 } 420 421 body:is(.preview-mode, .edit-mode-active, .mindmap-open) .toolbar { 422 background: color-mix( 423 in srgb, 424 var(--reader-bg-canvas) 90%, 425 transparent 426 ); 427 border-bottom-color: var(--reader-border-soft); 428 color: var(--reader-fg-default); 429 transition: 430 background-color var(--motion-small) var(--ease-calm), 431 border-color var(--motion-small) var(--ease-calm), 432 color var(--motion-small) var(--ease-calm); 433 } 434 body:is(.preview-mode, .edit-mode-active, .mindmap-open), 435 body:is(.preview-mode, .edit-mode-active, .mindmap-open) .app, 436 body:is(.preview-mode, .edit-mode-active, .mindmap-open) .main { 437 background: var(--reader-bg-canvas, var(--bg-canvas)); 438 color: var(--reader-fg-default, var(--fg-default)); 439 } 440 441 body.preview-mode #saveBtn { 442 display: none !important; 443 } 444 445 .brand { 446 display: flex; 447 align-items: center; 448 gap: 6px; 449 font-family: var(--font-serif); 450 font-weight: 600; 451 font-size: 14px; 452 font-style: normal; 453 letter-spacing: -0.01em; 454 color: var(--fg-muted); 455 margin-right: 4px; 456 } 457 458 .brand-mark { 459 display: inline-flex; 460 align-items: center; 461 justify-content: center; 462 flex-shrink: 0; 463 width: 18px; 464 height: 18px; 465 color: var(--fg-default); 466 } 467 468 .brand-mark svg { 469 width: 100%; 470 height: 100%; 471 display: block; 472 } 473 474 .brand-name { 475 font-family: var(--font-serif); 476 font-weight: 700; 477 font-size: 0.85rem; 478 letter-spacing: -0.045em; 479 line-height: 1; 480 text-transform: lowercase; 481 max-width: 360px; 482 white-space: nowrap; 483 overflow: hidden; 484 text-overflow: ellipsis; 485 color: var(--fg-default); 486 } 487 .brand-name.is-file { 488 font-weight: 500; 489 letter-spacing: -0.005em; 490 text-transform: none; 491 } 492 .brand-name.is-untitled { 493 font-weight: 500; 494 letter-spacing: -0.005em; 495 text-transform: none; 496 color: var(--fg-subtle); 497 } 498 .brand-dirty { 499 display: inline-block; 500 width: 6px; 501 height: 6px; 502 margin-left: 6px; 503 border-radius: 50%; 504 background: color-mix(in srgb, var(--alert-warning) 80%, transparent); 505 flex-shrink: 0; 506 } 507 .brand-dirty[hidden] { 508 display: none; 509 } 510 @media (max-width: 640px) { 511 .brand-name { 512 max-width: 180px; 513 } 514 } 515 516 /* Command palette shortcut hint â Option C "purist" trigger. 517 Inherits sizing/padding/border from .btn so it visually peers with 518 the surrounding chrome buttons (Settings, etc.). This class only 519 layers on the "kbd hint" character: mono font, slight tracking, 520 muted color, lower opacity at rest. Hidden on mobile (no shortcut). 521 The real #commandPaletteBtn is hidden in the desktop .toolbar but 522 remains live for the mobile overflow sheet (toolbar-mobile.js 523 teleports it via data-mv-overflow-section). */ 524 .palette-shortcut-hint { 525 font-family: var( 526 --font-mono, 527 ui-monospace, 528 "SF Mono", 529 Menlo, 530 Consolas, 531 monospace 532 ); 533 font-size: 12px; 534 font-weight: 500; 535 letter-spacing: 0.02em; 536 color: var(--fg-subtle); 537 opacity: 0.8; 538 transition: 539 opacity 140ms ease, 540 color 140ms ease; 541 } 542 .palette-shortcut-hint:hover, 543 .palette-shortcut-hint:focus-visible { 544 opacity: 1; 545 color: var(--fg-default); 546 } 547 body.command-palette-open .palette-shortcut-hint { 548 visibility: hidden; 549 } 550 @media (max-width: 640px) { 551 .palette-shortcut-hint { 552 display: none; 553 } 554 } 555 .toolbar #commandPaletteBtn { 556 display: none; 557 } 558 559 .toolbar .spacer { 560 flex: 1; 561 min-width: 0; 562 } 563 564 /* ââ Toolbar groups & dividers ââââââââââââââââââââââââââââââ 565 Cluster related controls (Gestalt proximity) so the eye can 566 parse the bar as 5â6 meaningful regions instead of a flat 567 list of 11+ buttons. Dividers give the rhythm. */ 568 .tb-group { 569 display: inline-flex; 570 align-items: center; 571 gap: 4px; 572 flex-shrink: 0; 573 } 574 575 .tb-divider { 576 width: 1px; 577 height: 18px; 578 background: var(--border-default); 579 margin: 0 8px; 580 flex: 0 0 auto; 581 border-radius: 1px; 582 } 583 584 /* ââ Segmented view-mode pill (Edit · Mindmap) ââââââââââââââ
585 Two adjacent toggles joined into one control. Active segment 586 reflects body.edit-mode and body.mindmap-open (synced via 587 a small MutationObserver near the end of this file). */ 588 .tb-segment { 589 display: inline-flex; 590 align-items: center; 591 gap: 2px; 592 padding: 3px; 593 background: var(--bg-subtle); 594 border: 1px solid var(--border-soft); 595 border-radius: 10px; 596 flex-shrink: 0; 597 } 598 .tb-segment .tb-seg-btn { 599 display: inline-flex; 600 align-items: center; 601 gap: 6px; 602 height: 26px; 603 padding: 0 11px; 604 font-family: var(--font-sans); 605 font-size: 12.5px; 606 font-weight: 500; 607 color: var(--fg-muted); 608 background: transparent; 609 border: 1px solid transparent; 610 border-radius: 7px; 611 cursor: pointer; 612 white-space: nowrap; 613 transition: 614 color 140ms ease, 615 background 140ms ease, 616 box-shadow 140ms ease; 617 } 618 .tb-segment .tb-seg-btn:hover:not(:disabled):not([aria-pressed="true"]) { 619 color: var(--fg-default); 620 } 621 .tb-segment .tb-seg-btn[aria-pressed="true"] { 622 color: var(--fg-default); 623 background: var(--bg-raised); 624 box-shadow: 625 0 1px 2px rgba(0, 0, 0, 0.04), 626 0 0 0 1px var(--border-soft); 627 } 628 .tb-segment .tb-seg-btn:focus-visible { 629 outline: none; 630 box-shadow: 0 0 0 3px var(--selection); 631 } 632 .tb-segment .tb-seg-btn:disabled { 633 opacity: 0.4; 634 cursor: not-allowed; 635 } 636 /* Preserve the "unsaved changes" dot on the Edit segment. */ 637 .tb-segment .mode-toggle-btn.has-unsaved::after { 638 content: ""; 639 width: 6px; 640 height: 6px; 641 border-radius: 999px; 642 background: color-mix(in srgb, var(--accent-fg) 74%, white 26%); 643 box-shadow: 0 0 0 3px 644 color-mix(in srgb, var(--accent-fg) 12%, transparent); 645 flex: 0 0 auto; 646 } 647 648 /* ââ Legacy theme icons (sun + moon) âââââââââââââââââââââââ 649 Hidden by default; the state-neutral contrast icon replaces 650 them. Left in the DOM so reverting is a one-line change â 651 just remove this rule (or delete the wrapper span in the 652 themeToggleBtn markup). The JS applyTheme() still targets 653 #themeIconSun / #themeIconMoon, so toggling works instantly 654 on re-enable. */ 655 .theme-icons-legacy { 656 display: none; 657 } 658 659 /* View Transition theme swap â when startViewTransition is 660 supported, the new theme unfolds from the theme button as 661 a radial clip. `--vt-origin-x/y` are set on :root at click 662 time (screen-space pixels). Uses a large radius so the 663 reveal always completes across even ultra-wide monitors. 664 Firefox and older Safari fall back to the default crossfade 665 via the API, no change needed there. */ 666 ::view-transition-old(root), 667 ::view-transition-new(root) { 668 animation: none; 669 mix-blend-mode: normal; 670 } 671 ::view-transition-old(root) { 672 z-index: 1; 673 } 674 ::view-transition-new(root) { 675 z-index: 2; 676 animation: vt-reveal 520ms cubic-bezier(0.22, 1, 0.36, 1); 677 } 678 @keyframes vt-reveal { 679 from { 680 clip-path: circle( 681 0 at var(--vt-origin-x, 50%) var(--vt-origin-y, 50%) 682 ); 683 } 684 to { 685 clip-path: circle( 686 var(--vt-end-radius, 150%) at var(--vt-origin-x, 50%) 687 var(--vt-origin-y, 50%) 688 ); 689 } 690 } 691 692 /* ââ Reading progress gutter âââââââââââââââââââââââââââââ 693 A 2px vertical thread pinned to the left viewport edge that 694 fills from top as the reader scrolls. Ambient presence, no 695 labels, no numbers â just a quiet "how far in" signal. 696 GPU-only: animates scaleY on an inner fill, transform-origin 697 top. Hidden when the user is at the very top (nothing to 698 show yet) and in edit mode (chrome distraction). */ 699 #readingProgressGutter { 700 position: fixed; 701 left: 0; 702 top: 0; 703 bottom: 0; 704 width: 2px; 705 pointer-events: none; 706 z-index: 5; 707 opacity: 0; 708 transition: opacity var(--motion-small) var(--ease-calm); 709 } 710 #readingProgressGutter.is-active { 711 opacity: 1; 712 } 713 #readingProgressGutter::before { 714 content: ""; 715 position: absolute; 716 inset: 0; 717 background: var(--border-soft); 718 } 719 #readingProgressGutter .fill { 720 position: absolute; 721 inset: 0; 722 background: color-mix(in srgb, var(--accent-fg) 55%, transparent); 723 transform-origin: top; 724 transform: scaleY(var(--progress, 0)); 725 will-change: transform; 726 } 727 body.edit-mode-active #readingProgressGutter, 728 body.mindmap-open #readingProgressGutter, 729 body.library-open #readingProgressGutter { 730 opacity: 0 !important; 731 } 732 733 /* Mirror the contrast icon horizontally in dark mode. In light 734 theme the filled half sits on the right; in dark theme it 735 flips to the left, giving a clear visual cue that the theme 736 state actually changed even though the icon itself is 737 state-neutral. `transform-origin: center` keeps the flip 738 optically centered inside the button. */ 739 #themeIconContrast { 740 transition: transform 180ms ease; 741 } 742 :root[data-theme="dark"] #themeIconContrast { 743 transform: scaleX(-1); 744 } 745 746 /* ââ Destructive affordance: Clear ââââââââââââââââââââââââââ 747 Kept in the toolbar (no feature removal) but muted by default 748 and tinted red on hover so the caution reads without shouting. */ 749 #clearBtn { 750 color: var(--fg-subtle); 751 } 752 #clearBtn:hover:not(:disabled) { 753 color: #b94f4f; 754 background: color-mix(in srgb, #b94f4f 8%, transparent); 755 border-color: color-mix(in srgb, #b94f4f 22%, var(--border-default)); 756 } 757 758 /* Responsive: collapse dividers on narrow screens; the 759 .toolbar already sets flex-wrap at â¤768px. */ 760 @media (max-width: 768px) { 761 .tb-divider { 762 display: none; 763 } 764 .tb-group { 765 gap: 3px; 766 } 767 .tb-segment .tb-seg-btn { 768 padding: 0 9px; 769 } 770 } 771 772 /* ââ Mobile overflow ("More") system ââââââââââââââââââââââââââ 773 At â¤640px the toolbar shrinks to a single primary row and a 774 "⯠More" button. Secondary controls are teleported (by JS, 775 see assets/toolbar-mobile.js) into the bottom sheet that 776 opens from this button. CSS below handles the default-hidden 777 state for the More button + sheet on desktop, the sheet's 778 visual presentation, and the â¤640px overrides. */ 779 780 /* Mobile-only "More" trigger. Higher specificity than `.btn` 781 so the default-hidden state isn't overridden by the base 782 button rule (which is defined later in this stylesheet). */ 783 .toolbar .mv-more-btn { 784 display: none; 785 flex-shrink: 0; 786 } 787 788 .mv-more-backdrop { 789 position: fixed; 790 inset: 0; 791 z-index: 9990; 792 background: color-mix(in srgb, #000 38%, transparent); 793 -webkit-backdrop-filter: blur(2px); 794 backdrop-filter: blur(2px); 795 opacity: 0; 796 pointer-events: none; 797 transition: opacity 200ms cubic-bezier(0.22, 1, 0.36, 1); 798 } 799 .mv-more-backdrop.is-open { 800 opacity: 1; 801 pointer-events: auto; 802 } 803 .mv-more-backdrop[hidden] { 804 display: none !important; 805 } 806 807 .mv-more-sheet { 808 position: fixed; 809 left: 0; 810 right: 0; 811 bottom: 0; 812 z-index: 9995; 813 max-height: 86vh; 814 overflow-y: auto; 815 -webkit-overflow-scrolling: touch; 816 background: var(--bg-canvas); 817 border-top: 1px solid var(--border-default); 818 border-top-left-radius: 18px; 819 border-top-right-radius: 18px; 820 box-shadow: 0 -16px 48px rgba(0, 0, 0, 0.18); 821 transform: translateY(100%); 822 transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1); 823 padding: 4px 16px calc(env(safe-area-inset-bottom, 0px) + 20px); 824 } 825 .mv-more-sheet.is-open { 826 transform: translateY(0); 827 } 828 .mv-more-sheet[hidden] { 829 display: none !important; 830 } 831 @media (prefers-reduced-motion: reduce) { 832 .mv-more-backdrop, 833 .mv-more-sheet { 834 transition: none; 835 } 836 } 837 838 /* Drag-handle hint: subtle pill at top of sheet */ 839 .mv-more-sheet::before { 840 content: ""; 841 display: block; 842 width: 38px; 843 height: 4px; 844 margin: 8px auto 4px; 845 border-radius: 999px; 846 background: var(--border-default); 847 opacity: 0.7; 848 } 849 850 .mv-more-sheet__header { 851 position: sticky; 852 top: 0; 853 z-index: 1; 854 display: flex; 855 align-items: center; 856 justify-content: space-between; 857 padding: 8px 2px 12px; 858 background: var(--bg-canvas); 859 border-bottom: 1px solid var(--border-soft); 860 } 861 .mv-more-sheet__title { 862 margin: 0; 863 font-family: var(--font-serif); 864 font-weight: 600; 865 font-size: 1.05rem; 866 letter-spacing: -0.01em; 867 color: var(--fg-default); 868 } 869 .mv-more-sheet__close { 870 flex-shrink: 0; 871 } 872 873 .mv-more-sheet__body { 874 display: flex; 875 flex-direction: column; 876 gap: 18px; 877 padding: 16px 0 8px; 878 } 879 .mv-more-section { 880 display: flex; 881 flex-direction: column; 882 gap: 10px; 883 } 884 .mv-more-section__label { 885 font-family: var(--font-sans); 886 font-size: 11px; 887 font-weight: 600; 888 letter-spacing: 0.08em; 889 text-transform: uppercase; 890 color: var(--fg-subtle); 891 padding: 0 2px; 892 } 893 .mv-more-section__row { 894 display: flex; 895 flex-wrap: wrap; 896 align-items: center; 897 gap: 8px; 898 } 899 /* Inside the sheet, hoist tb-group children directly into the
900 row so we get one consistent flex-wrap layout regardless of 901 which groups happen to teleport in. */ 902 .mv-more-sheet .tb-group { 903 display: contents; 904 } 905 .mv-more-sheet .tb-divider { 906 display: none; 907 } 908 /* Touch-target floor inside the sheet (WCAG 2.5.5). */ 909 .mv-more-sheet .btn, 910 .mv-more-sheet .tb-segment .tb-seg-btn, 911 .mv-more-sheet .auto-refresh { 912 min-height: 44px; 913 min-width: 44px; 914 } 915 916 /* ââ â¤640px: collapse toolbar to primary row + More âââââââââ 917 FOUC prevention: hide secondary items via attribute selector 918 even before toolbar-mobile.js runs. JS then physically moves 919 them into #mvMoreSheetBody (single source of truth â same 920 buttons, same IDs, same event handlers). */ 921 @media (max-width: 640px) { 922 .toolbar { 923 flex-wrap: nowrap; 924 padding: 8px 12px; 925 gap: 4px; 926 } 927 .toolbar .status, 928 .toolbar #wordCount { 929 display: none; 930 } 931 .toolbar .tb-divider { 932 display: none; 933 } 934 /* Whole groups marked for overflow: hidden inside toolbar. */ 935 .toolbar [data-mv-overflow-section] { 936 display: none; 937 } 938 /* Individual secondary buttons inside mixed groups. */ 939 .toolbar #saveBtn, 940 .toolbar .ont-settings { 941 display: none; 942 } 943 /* Primary controls keep WCAG 2.5.5 tap floor + room to breathe. */ 944 .toolbar .btn { 945 min-height: 44px; 946 } 947 .toolbar .mv-more-btn { 948 display: inline-flex; 949 margin-left: auto; 950 } 951 /* While sheet is open, freeze background scroll. */ 952 body.mv-more-open { 953 overflow: hidden; 954 } 955 } 956 957 .btn { 958 display: inline-flex; 959 align-items: center; 960 gap: 6px; 961 height: 32px; 962 padding: 0 11px; 963 font-family: var(--font-sans); 964 font-size: 12.5px; 965 font-weight: 500; 966 color: var(--fg-muted); 967 background: transparent; 968 border: 1px solid var(--border-default); 969 border-radius: 8px; 970 cursor: pointer; 971 white-space: nowrap; 972 position: relative; 973 /* Settle physics â hover lifts 0.5px with a soft shadow bloom; 974 press sinks. Transitions driven off the motion system so 975 the whole UI shares one timing vocabulary. */ 976 transform: translateY(0); 977 box-shadow: 0 0 0 rgba(0, 0, 0, 0); 978 transition: 979 color var(--motion-micro) var(--ease-calm), 980 background var(--motion-micro) var(--ease-calm), 981 border-color var(--motion-micro) var(--ease-calm), 982 transform var(--motion-micro) var(--ease-out), 983 box-shadow var(--motion-micro) var(--ease-out); 984 } 985 .btn-prefix { 986 display: inline-flex; 987 align-items: center; 988 justify-content: center; 989 width: 15px; 990 height: 15px; 991 margin-top: -1px; 992 font-family: var(--font-sans); 993 font-size: 17px; 994 font-weight: 400; 995 line-height: 1; 996 color: color-mix(in srgb, var(--fg-muted) 85%, transparent); 997 } 998 .btn:hover .btn-prefix, 999 .btn:focus-visible .btn-prefix { 1000 color: var(--fg-default); 1001 } 1002 /* Pressed-state underline: a calm alternative to a full 1003 segmented-pill slider. When a toggle button (Edit, Mindmap, 1004 Reading, Library, etc.) enters aria-pressed=true, a thin 1005 accent underline scales in from center. Reads as "active" 1006 without shouting, and the scaleX transition gives a gentle 1007 slide feeling rather than a hard on/off. */ 1008 .btn::after { 1009 content: ""; 1010 position: absolute; 1011 left: 20%; 1012 right: 20%; 1013 bottom: 3px; 1014 height: 1.5px; 1015 border-radius: 2px; 1016 background: var(--accent-fg); 1017 transform: scaleX(0); 1018 transform-origin: center; 1019 opacity: 0; 1020 pointer-events: none; 1021 transition: 1022 transform var(--motion-small) var(--ease-out), 1023 opacity var(--motion-small) var(--ease-out); 1024 } 1025 .btn[aria-pressed="true"]:not(:disabled)::after { 1026 transform: scaleX(1); 1027 opacity: 0.7; 1028 } 1029 .btn:hover:not(:disabled) { 1030 color: var(--fg-default); 1031 background: var(--bg-subtle); 1032 border-color: var(--border-strong); 1033 transform: translateY(-0.5px); 1034 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); 1035 } 1036 .btn:active:not(:disabled) { 1037 background: var(--bg-muted); 1038 transform: translateY(0.5px); 1039 box-shadow: 0 0 0 rgba(0, 0, 0, 0);
1040 transition-duration: 80ms; 1041 } 1042 /* Toggle-state buttons (e.g. Edit, Mindmap): visually "pressed" 1043 when aria-pressed="true". Driven by assets/toolbar-sync.js 1044 from body classes. Discrete-button alternative to a segmented 1045 pill â consistent with the rest of the File / Refresh groups. */ 1046 .btn[aria-pressed="true"]:not(:disabled) { 1047 background: var(--bg-subtle); 1048 border-color: var(--border-strong); 1049 color: var(--fg-default); 1050 } 1051 .btn[aria-pressed="true"]:not(:disabled):hover { 1052 background: var(--bg-muted); 1053 } 1054 .btn:disabled { 1055 opacity: 0.35; 1056 cursor: not-allowed; 1057 } 1058 #shareBtn { 1059 min-width: 96px; 1060 justify-content: center; 1061 } 1062 /* âââ Export: split button + compact menu âââââââââââââââââ 1063 Main label runs the last-used format directly. Caret opens 1064 the small format picker. Default format is remembered in 1065 localStorage via assets/export-host.js. */ 1066 .export-settings { 1067 position: relative; 1068 display: inline-flex; 1069 } 1070 .export-split { 1071 position: relative; 1072 display: inline-flex; 1073 align-items: stretch; 1074 border: 1px solid var(--border-default); 1075 border-radius: 6px; 1076 color: var(--fg-muted); 1077 background: transparent; 1078 transform: translateY(0); 1079 box-shadow: 0 0 0 rgba(0, 0, 0, 0); 1080 transition: 1081 color var(--motion-micro) var(--ease-calm), 1082 border-color 120ms ease, 1083 background-color 120ms ease, 1084 transform var(--motion-micro) var(--ease-out), 1085 box-shadow var(--motion-micro) var(--ease-out); 1086 } 1087 .export-split:hover:not(.is-disabled):not([aria-disabled="true"]) { 1088 color: var(--fg-default); 1089 background: var(--bg-subtle); 1090 border-color: var(--border-strong); 1091 transform: translateY(-0.5px); 1092 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); 1093 } 1094 .export-split.is-disabled, 1095 .export-split[aria-disabled="true"] { 1096 opacity: 0.55; 1097 pointer-events: none; 1098 } 1099 .export-split__main { 1100 display: inline-flex; 1101 align-items: center; 1102 gap: 6px; 1103 padding: 5px 11px; 1104 background: transparent; 1105 border: 0; 1106 border-radius: 6px 0 0 6px; 1107 color: inherit; 1108 font: inherit; 1109 font-size: 12.5px; 1110 font-weight: 500; 1111 line-height: 1.4; 1112 cursor: pointer; 1113 white-space: nowrap; 1114 transition: background-color 120ms ease; 1115 } 1116 .export-split__main:hover:not(:disabled) { 1117 background: transparent; 1118 } 1119 .export-split__main:disabled { 1120 cursor: not-allowed; 1121 } 1122 .export-split__sep { 1123 width: 1px; 1124 background: var(--border-soft); 1125 align-self: stretch; 1126 } 1127 .export-split__caret { 1128 display: inline-flex; 1129 align-items: center; 1130 justify-content: center; 1131 padding: 5px 7px; 1132 background: transparent; 1133 border: 0; 1134 border-radius: 0 6px 6px 0; 1135 color: inherit; 1136 cursor: pointer; 1137 transition: 1138 background-color 120ms ease, 1139 color 120ms ease; 1140 } 1141 .export-split__caret:hover:not(:disabled) { 1142 background: transparent; 1143 color: inherit; 1144 } 1145 .export-split__caret:disabled { 1146 cursor: not-allowed; 1147 } 1148 .export-split__main .icon { 1149 width: 14px; 1150 height: 14px; 1151 flex-shrink: 0; 1152 color: currentColor; 1153 transition: color 120ms ease; 1154 } 1155 .export-split__main:hover:not(:disabled) .icon { 1156 color: currentColor; 1157 } 1158 .export-split__caret svg { 1159 width: 14px; 1160 height: 14px; 1161 flex-shrink: 0; 1162 } 1163 1164 /* Compact format menu anchored to the split-button container */ 1165 .export-menu { 1166 position: absolute; 1167 top: calc(100% + 6px); 1168 right: 0; 1169 z-index: 90; 1170 min-width: 200px; 1171 padding: 4px; 1172 border: 1px solid var(--border-default); 1173 border-radius: 8px; 1174 background: var(--bg-raised); 1175 color: var(--fg-default); 1176 box-shadow: 1177 0 1px 2px rgba(0, 0, 0, 0.04), 1178 0 6px 18px rgba(0, 0, 0, 0.08), 1179 0 24px 64px rgba(0, 0, 0, 0.1); 1180 } 1181 .export-menu[hidden] { 1182 display: none; 1183 } 1184 .export-menu__head { 1185 padding: 6px 10px 4px; 1186 font-size: 9.5px; 1187 font-weight: 700; 1188 text-transform: uppercase; 1189 letter-spacing: 0.14em; 1190 color: var(--fg-subtle); 1191 } 1192 .export-option { 1193 display: flex; 1194 align-items: center; 1195 gap: 10px; 1196 width: 100%; 1197 padding: 7px 10px; 1198 border: 0; 1199 border-radius: 5px; 1200 background: transparent; 1201 color: var(--fg-default); 1202 font: inherit; 1203 font-size: 13.5px; 1204 font-weight: 500; 1205 text-align: left; 1206 cursor: pointer; 1207 transition: background-color 100ms ease; 1208 } 1209 .export-option:hover:not(:disabled), 1210 .export-option:focus-visible { 1211 background: var(--bg-subtle); 1212 outline: none; 1213 } 1214 .export-option:disabled { 1215 opacity: 0.45;
1216 cursor: not-allowed; 1217 } 1218 .export-option__label { 1219 flex: 1; 1220 } 1221 .export-option__ext { 1222 font-family: var(--font-mono, ui-monospace, monospace); 1223 font-size: 10.5px; 1224 font-weight: 500; 1225 letter-spacing: 0.02em; 1226 color: var(--fg-subtle); 1227 } 1228 .export-option.is-active .export-option__ext { 1229 color: var(--accent-fg); 1230 } 1231 .export-option.is-active::after { 1232 content: "â"; 1233 font-size: 11px; 1234 font-weight: 700; 1235 color: var(--accent-fg); 1236 } 1237 /* Legacy classes kept for stray references â hidden when unused */ 1238 .export-option__title { 1239 font-weight: 600; 1240 } 1241 .export-option__meta { 1242 font-size: 12px; 1243 color: var(--fg-muted); 1244 display: none; 1245 } 1246 .btn.is-busy { 1247 color: var(--fg-default); 1248 background: var(--bg-subtle); 1249 border-color: var(--border-strong); 1250 } 1251 .btn.is-busy::before { 1252 content: ""; 1253 width: 11px; 1254 height: 11px; 1255 border-radius: 999px; 1256 border: 1.5px solid currentColor; 1257 border-right-color: transparent; 1258 opacity: 0.68; 1259 animation: btn-spin 720ms linear infinite; 1260 } 1261 .btn:focus-visible, 1262 .chat-fab:focus-visible, 1263 .scroll-top-btn:focus-visible, 1264 .chat-header-btn:focus-visible, 1265 .chat-send-btn:focus-visible, 1266 .ctx-menu-item:focus-visible, 1267 .ont-close:focus-visible, 1268 .ont-section-row:focus-visible, 1269 .selection-action:focus-visible, 1270 .highlight-remove-chip:focus-visible, 1271 .highlight-undo-btn:focus-visible { 1272 outline: none; 1273 box-shadow: 0 0 0 3px var(--selection); 1274 } 1275 1276 /* Unsaved-state cue. Moved to ::before so the ::after pseudo 1277 is free for the pressed-state underline (shared across all 1278 .btn). The dot breathes gently â 4s cycle, opacity+scale â 1279 so the "you have unsaved changes" signal is present without 1280 being anxious. */ 1281 .mode-toggle-btn.has-unsaved::before { 1282 content: ""; 1283 width: 6px; 1284 height: 6px; 1285 border-radius: 999px; 1286 background: color-mix(in srgb, var(--accent-fg) 74%, white 26%); 1287 box-shadow: 0 0 0 3px 1288 color-mix(in srgb, var(--accent-fg) 12%, transparent); 1289 flex: 0 0 auto; 1290 order: 99; /* place it after the label, like the old ::after */ 1291 margin-left: 2px; 1292 animation: unsaved-breathe 4s var(--ease-calm) infinite; 1293 } 1294 @keyframes unsaved-breathe { 1295 0%, 1296 100% { 1297 opacity: 0.7; 1298 transform: scale(1); 1299 } 1300 50% { 1301 opacity: 1; 1302 transform: scale(1.15); 1303 } 1304 } 1305 1306 /* Just-saved morph: when the unsaved state resolves (JS adds 1307 .just-saved for 1.4s), the dot becomes a checkmark glyph. 1308 Same footprint, no layout shift â just a calm confirmation. */ 1309 .mode-toggle-btn.just-saved::before { 1310 content: ""; 1311 width: 10px; 1312 height: 10px; 1313 border-radius: 0; 1314 background: none; 1315 box-shadow: none; 1316 flex: 0 0 auto; 1317 order: 99; 1318 margin-left: 4px; 1319 animation: none; 1320 /* SVG checkmark as a background mask so it picks up accent color */ 1321 -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M13.5 4.5 L6 12 L2.5 8.5' fill='none' stroke='%23000' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'/></svg>") 1322 center / contain no-repeat; 1323 mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M13.5 4.5 L6 12 L2.5 8.5' fill='none' stroke='%23000' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'/></svg>") 1324 center / contain no-repeat; 1325 background: var(--accent-fg); 1326 opacity: 0; 1327 transform: scale(0.6); 1328 animation: just-saved-pop var(--motion-panel) var(--ease-out) forwards; 1329 } 1330 @keyframes just-saved-pop { 1331 0% { 1332 opacity: 0; 1333 transform: scale(0.6); 1334 } 1335 55% { 1336 opacity: 1; 1337 transform: scale(1.08); 1338 } 1339 100% { 1340 opacity: 1; 1341 transform: scale(1); 1342 } 1343 } 1344 1345 .btn-icon { 1346 width: 32px; 1347 padding: 0; 1348 justify-content: center; 1349 } 1350 1351 .btn-primary { 1352 background: var(--fg-default); 1353 color: var(--bg-canvas); 1354 border-color: transparent; 1355 } 1356 .btn-primary:hover:not(:disabled) { 1357 background: var(--fg-muted); 1358 color: var(--bg-canvas); 1359 border-color: transparent; 1360 } 1361 1362 /* ââ PWA update note âââââââââââââââââââââââââââââââââââââââ 1363 Quiet, temporary, and editorial: the update should feel like 1364 a slip tucked onto the desk, not part of the toolbar chrome. */ 1365 .pwa-update-toast { 1366 position: fixed; 1367 top: calc(var(--toolbar-h, 72px) + 18px); 1368 right: 20px; 1369 z-index: 30; 1370 width: min(360px, calc(100vw - 32px)); 1371 padding: 14px 14px 13px; 1372 display: grid; 1373 grid-template-columns: 1fr auto; 1374 gap: 12px 16px; 1375 border-radius: 16px; 1376 border: 1px solid var(--border-default); 1377 background: linear-gradient( 1378 180deg, 1379 color-mix(in srgb, var(--bg-raised) 94%, transparent), 1380 color-mix(in srgb, var(--bg-subtle) 90%, transparent) 1381 ); 1382 box-shadow: 1383 0 18px 42px rgba(0, 0, 0, 0.12), 1384 0 1px 0 rgba(255, 255, 255, 0.08) inset; 1385 -webkit-backdrop-filter: saturate(1.1) blur(16px); 1386 backdrop-filter: saturate(1.1) blur(16px); 1387 opacity: 0; 1388 transform: translateY(-8px) translateX(10px) scale(0.985); 1389 pointer-events: none; 1390 transition: 1391 opacity var(--motion-small) var(--ease-out), 1392 transform var(--motion-small) var(--ease-out); 1393 } 1394 .pwa-update-toast::before { 1395 content: ""; 1396 position: absolute; 1397 left: 14px; 1398 top: 12px; 1399 bottom: 12px; 1400 width: 1px; 1401 border-radius: 999px; 1402 background: linear-gradient( 1403 180deg, 1404 color-mix(in srgb, var(--accent-fg) 78%, white 22%), 1405 color-mix(in srgb, var(--accent-fg) 18%, transparent) 1406 ); 1407 opacity: 0.9; 1408 } 1409 .pwa-update-toast.is-visible { 1410 opacity: 1; 1411 transform: translateY(0) translateX(0) scale(1); 1412 pointer-events: auto; 1413 } 1414 .pwa-update-toast__body { 1415 position: relative; 1416 padding-left: 10px; 1417 min-width: 0; 1418 } 1419 .pwa-update-toast__eyebrow { 1420 margin: 0 0 4px; 1421 color: var(--fg-muted); 1422 font-family: var(--font-serif); 1423 font-size: 11px; 1424 font-weight: 600; 1425 letter-spacing: 0.08em; 1426 text-transform: uppercase; 1427 } 1428 .pwa-update-toast__title { 1429 margin: 0; 1430 color: var(--fg-default); 1431 font-family: var(--font-serif); 1432 font-size: 15px; 1433 font-weight: 600; 1434 letter-spacing: -0.02em; 1435 line-height: 1.22; 1436 } 1437 .pwa-update-toast__copy { 1438 margin: 5px 0 0; 1439 color: var(--fg-muted); 1440 font-size: 12.5px; 1441 line-height: 1.45; 1442 } 1443 .pwa-update-toast__actions { 1444 display: inline-flex; 1445 align-items: flex-start; 1446 gap: 8px; 1447 align-self: center; 1448 } 1449 .pwa-update-toast__actions .btn { 1450 height: 30px; 1451 padding: 0 10px; 1452 font-size: 12px; 1453 }
1454 .pwa-update-toast__dismiss { 1455 min-width: 34px; 1456 padding: 0; 1457 justify-content: center; 1458 } 1459 .pwa-update-toast__dismiss svg { 1460 width: 12px; 1461 height: 12px; 1462 } 1463 .app-toast { 1464 z-index: 35; 1465 } 1466 .app-toast[data-kind="error"]::before { 1467 background: linear-gradient( 1468 180deg, 1469 color-mix(in srgb, var(--alert-caution) 86%, white 14%), 1470 color-mix(in srgb, var(--alert-caution) 22%, transparent) 1471 ); 1472 } 1473 .app-toast[data-kind="error"] .pwa-update-toast__eyebrow { 1474 color: var(--alert-caution); 1475 } 1476 @media (max-width: 768px) { 1477 .pwa-update-toast { 1478 top: auto; 1479 right: 16px; 1480 left: 16px; 1481 bottom: 16px; 1482 width: auto; 1483 grid-template-columns: 1fr; 1484 gap: 12px; 1485 transform: translateY(10px) scale(0.985); 1486 } 1487 .pwa-update-toast__actions { 1488 justify-content: flex-end; 1489 } 1490 } 1491 1492 .auto-refresh { 1493 display: inline-flex; 1494 align-items: center; 1495 gap: 7px; 1496 height: 32px; 1497 padding: 0 11px; 1498 font-family: var(--font-sans); 1499 font-size: 12px; 1500 color: var(--fg-subtle); 1501 background: transparent; 1502 border: 1px solid var(--border-default); 1503 border-radius: 8px; 1504 cursor: pointer; 1505 user-select: none; 1506 white-space: nowrap; 1507 transition: 1508 color 150ms ease, 1509 border-color 150ms ease; 1510 } 1511 .auto-refresh:has(input:disabled) { 1512 opacity: 0.4; 1513 cursor: not-allowed; 1514 } 1515 .auto-refresh input { 1516 accent-color: var(--accent-fg); 1517 margin: 0; 1518 } 1519 1520 .reading-settings { 1521 position: relative; 1522 } 1523 #readingToggleBtn[aria-expanded="true"] { 1524 background: var(--bg-subtle); 1525 border-color: var(--border-strong); 1526 } 1527 1528 .reading-panel { 1529 position: absolute; 1530 top: calc(100% + 6px); 1531 right: 0; 1532 z-index: 50; 1533 width: min(360px, calc(100vw - 32px)); 1534 max-height: none; 1535 padding: 18px; 1536 overflow: visible; 1537 background: var(--bg-raised); 1538 border: 1px solid var(--border-soft); 1539 border-radius: 16px; 1540 box-shadow: 0 12px 40px rgba(0, 0, 0, 0.06); 1541 animation: panel-enter 200ms ease-out; 1542 } 1543 @media (max-height: 760px) { 1544 .reading-panel { 1545 max-height: calc(100dvh - var(--toolbar-h, 72px) - 24px); 1546 overflow-y: auto; 1547 overscroll-behavior: contain; 1548 } 1549 } 1550 :root[data-theme="dark"] .reading-panel { 1551 box-shadow: 0 12px 40px rgba(0, 0, 0, 0.32); 1552 } 1553 @keyframes panel-enter { 1554 from { 1555 opacity: 0; 1556 transform: translateY(-4px); 1557 } 1558 to { 1559 opacity: 1; 1560 transform: translateY(0); 1561 } 1562 } 1563 .reading-panel-header { 1564 display: flex; 1565 align-items: flex-start; 1566 justify-content: space-between; 1567 gap: 12px; 1568 margin-bottom: 16px; 1569 } 1570 .reading-panel-title { 1571 margin: 0; 1572 font-family: var(--font-serif); 1573 font-size: 1.05rem; 1574 font-weight: 700; 1575 letter-spacing: -0.02em; 1576 line-height: 1.15; 1577 } 1578 .reading-panel-help { 1579 margin: 4px 0 0; 1580 max-width: 22ch; 1581 font-size: 12px; 1582 line-height: 1.45; 1583 color: var(--fg-muted); 1584 } 1585 .reading-preset-custom { 1586 align-self: center; 1587 font-size: 12px; 1588 color: var(--fg-subtle); 1589 white-space: nowrap; 1590 } 1591 .reading-panel-section + .reading-panel-section { 1592 margin-top: 16px; 1593 } 1594 .reading-section-label { 1595 display: block; 1596 margin-bottom: 8px; 1597 font-size: 12px; 1598 font-weight: 600; 1599 letter-spacing: 0.01em; 1600 color: var(--fg-muted); 1601 text-transform: uppercase; 1602 } 1603 .reading-preset-group { 1604 display: grid; 1605 grid-template-columns: repeat(3, minmax(0, 1fr)); 1606 gap: 6px; 1607 } 1608 .reading-preset { 1609 justify-content: center; 1610 padding: 0 10px; 1611 } 1612 .reading-preset.is-active { 1613 background: var(--fg-default); 1614 color: var(--bg-canvas); 1615 border-color: transparent; 1616 } 1617 .reading-preset.is-active:hover:not(:disabled) { 1618 background: var(--fg-muted); 1619 color: var(--bg-canvas); 1620 border-color: transparent; 1621 } 1622 .reading-palette-grid { 1623 display: grid; 1624 grid-template-columns: repeat(3, minmax(0, 1fr)); 1625 gap: 8px; 1626 } 1627 .reading-palette { 1628 --swatch-paper: #fafaf9; 1629 --swatch-ink: #222221; 1630 --swatch-accent: #5f8f80; 1631 display: grid; 1632 align-content: space-between; 1633 min-width: 0; 1634 min-height: 62px; 1635 padding: 8px; 1636 color: var(--fg-default); 1637 background: var(--bg-raised); 1638 border: 1px solid var(--border-default); 1639 border-radius: 8px; 1640 cursor: pointer; 1641 text-align: left; 1642 transition: 1643 border-color var(--motion-micro) var(--ease-calm), 1644 background-color var(--motion-micro) var(--ease-calm), 1645 box-shadow var(--motion-micro) var(--ease-calm); 1646 } 1647 .reading-palette:hover { 1648 border-color: var(--border-strong); 1649 background: var(--bg-subtle); 1650 } 1651 .reading-palette:focus-visible { 1652 outline: 2px solid var(--accent-fg); 1653 outline-offset: 2px; 1654 } 1655 .reading-palette[aria-checked="true"] { 1656 border-color: var(--fg-default); 1657 box-shadow: 0 0 0 2px var(--selection); 1658 } 1659 .reading-palette__swatch { 1660 position: relative; 1661 display: block; 1662 height: 22px; 1663 margin-bottom: 7px; 1664 overflow: hidden; 1665 background: var(--swatch-paper); 1666 border: 1px solid var(--border-soft); 1667 border-radius: 6px; 1668 } 1669 .reading-palette__swatch::before, 1670 .reading-palette__swatch::after { 1671 content: ""; 1672 position: absolute; 1673 inset-block: 0; 1674 } 1675 .reading-palette__swatch::before { 1676 left: 0; 1677 width: 38%; 1678 background: var(--swatch-ink); 1679 } 1680 .reading-palette__swatch::after { 1681 right: 0; 1682 width: 22%; 1683 background: var(--swatch-accent); 1684 } 1685 .reading-palette__label { 1686 display: block; 1687 min-width: 0; 1688 overflow: hidden; 1689 font-family: var(--font-sans); 1690 font-size: 11.5px; 1691 font-weight: 650; 1692 line-height: 1.15; 1693 text-overflow: ellipsis; 1694 white-space: nowrap; 1695 } 1696 .reading-palette[data-reader-palette="ivory"] { 1697 --swatch-paper: #f3ebdd;
1698 --swatch-ink: #1f1a13; 1699 --swatch-accent: #416f62; 1700 } 1701 .reading-palette[data-reader-palette="manilla"] { 1702 --swatch-paper: #ebdbbc; 1703 --swatch-ink: #221a14; 1704 --swatch-accent: #7b3e2d; 1705 } 1706 .reading-palette[data-reader-palette="cloud"] { 1707 --swatch-paper: #bfbfba; 1708 --swatch-ink: #191919; 1709 --swatch-accent: #666663; 1710 } 1711 .reading-palette[data-reader-palette="slate"] { 1712 --swatch-paper: #191919; 1713 --swatch-ink: #f2f0ea; 1714 --swatch-accent: #61aaf2; 1715 } 1716 .reading-palette[data-reader-palette="garden"] { 1717 --swatch-paper: #e8f3de; 1718 --swatch-ink: #001002; 1719 --swatch-accent: #9a344a; 1720 } 1721 @media (max-width: 380px) { 1722 .reading-palette-grid { 1723 grid-template-columns: repeat(2, minmax(0, 1fr)); 1724 } 1725 } 1726 .reading-control { 1727 display: grid; 1728 gap: 8px; 1729 } 1730 .reading-control + .reading-control { 1731 margin-top: 12px; 1732 } 1733 .reading-control-head { 1734 display: flex; 1735 align-items: baseline; 1736 justify-content: space-between; 1737 gap: 12px; 1738 } 1739 .reading-control-head label { 1740 font-size: 13px; 1741 font-weight: 600; 1742 color: var(--fg-default); 1743 } 1744 .reading-control-value { 1745 font-size: 12px; 1746 color: var(--fg-muted); 1747 font-variant-numeric: tabular-nums; 1748 } 1749 .reading-range { 1750 --range-progress: 50%; 1751 width: 100%; 1752 height: 4px; 1753 margin: 0; 1754 border: 0; 1755 border-radius: 999px; 1756 outline: none; 1757 cursor: pointer; 1758 appearance: none; 1759 -webkit-appearance: none; 1760 background: linear-gradient( 1761 to right, 1762 var(--accent-fg) 0, 1763 var(--accent-fg) var(--range-progress), 1764 var(--bg-muted) var(--range-progress), 1765 var(--bg-muted) 100% 1766 ); 1767 } 1768 .reading-range::-webkit-slider-runnable-track { 1769 height: 4px; 1770 border-radius: 999px; 1771 background: transparent; 1772 } 1773 .reading-range::-webkit-slider-thumb { 1774 margin-top: -6px; 1775 width: 16px; 1776 height: 16px; 1777 border: 1px solid var(--border-strong); 1778 border-radius: 50%; 1779 background: var(--bg-raised); 1780 box-shadow: var(--shadow-md); 1781 appearance: none; 1782 -webkit-appearance: none; 1783 } 1784 .reading-range::-moz-range-track { 1785 height: 4px; 1786 border: 0; 1787 border-radius: 999px; 1788 background: var(--bg-muted); 1789 } 1790 .reading-range::-moz-range-progress { 1791 height: 4px; 1792 border-radius: 999px; 1793 background: var(--accent-fg); 1794 } 1795 .reading-range::-moz-range-thumb { 1796 width: 16px; 1797 height: 16px; 1798 border: 1px solid var(--border-strong); 1799 border-radius: 50%; 1800 background: var(--bg-raised); 1801 box-shadow: var(--shadow-md); 1802 } 1803 .reading-range:focus-visible { 1804 outline: none; 1805 } 1806 .reading-range:focus-visible::-webkit-slider-thumb { 1807 box-shadow: 1808 0 0 0 3px var(--selection), 1809 var(--shadow-md); 1810 } 1811 .reading-range:focus-visible::-moz-range-thumb { 1812 box-shadow: 1813 0 0 0 3px var(--selection), 1814 var(--shadow-md); 1815 } 1816 .reading-actions { 1817 display: flex; 1818 justify-content: flex-end; 1819 gap: 8px; 1820 margin-top: 18px; 1821 padding-top: 14px; 1822 border-top: 1px solid var(--border-soft); 1823 } 1824 1825 .status { 1826 font-family: var(--font-sans); 1827 font-size: 12px; 1828 color: var(--fg-subtle); 1829 max-width: 260px; 1830 overflow: hidden; 1831 text-overflow: ellipsis; 1832 white-space: nowrap; 1833 font-feature-settings: "tnum"; 1834 } 1835 .word-count { 1836 font-family: var(--font-sans); 1837 font-size: 12px; 1838 color: var(--fg-subtle); 1839 white-space: nowrap; 1840 font-feature-settings: "tnum"; 1841 opacity: 0.7; 1842 } 1843 .word-count::before { 1844 content: "·"; 1845 margin: 0 6px; 1846 opacity: 0.5; 1847 } 1848 .status[data-kind="error"] { 1849 color: #b05050; 1850 max-width: 420px; 1851 overflow: visible; 1852 white-space: normal; 1853 } 1854 .status[data-kind="progress"] { 1855 color: var(--fg-muted); 1856 max-width: 420px; 1857 overflow: visible; 1858 white-space: normal; 1859 } 1860 1861 @keyframes btn-spin { 1862 to { 1863 transform: rotate(360deg); 1864 } 1865 } 1866 @media (prefers-reduced-motion: reduce) { 1867 .btn.is-busy::before { 1868 animation: none; 1869 } 1870 } 1871 1872 /* ---------- Empty state ---------- */ 1873 .empty-state { 1874 display: flex; 1875 flex-direction: column; 1876 align-items: center; 1877 justify-content: center; 1878 min-height: calc(100vh - 200px); 1879 padding: 48px 24px; 1880 text-align: center; 1881 animation: empty-breathe 600ms ease-out both; 1882 } 1883 @keyframes empty-breathe { 1884 from { 1885 opacity: 0; 1886 transform: scale(0.98); 1887 } 1888 to { 1889 opacity: 1; 1890 transform: scale(1); 1891 } 1892 } 1893 .empty-state-mark { 1894 width: 48px; 1895 height: 48px; 1896 margin-bottom: 28px; 1897 color: var(--border-strong); 1898 opacity: 0.6; 1899 } 1900 .empty-state-mark svg { 1901 width: 100%; 1902 height: 100%; 1903 } 1904 .empty-state-title { 1905 font-family: var(--font-serif); 1906 font-size: 1.35rem; 1907 font-weight: 600; 1908 letter-spacing: -0.02em; 1909 color: var(--fg-muted); 1910 margin: 0 0 10px; 1911 } 1912 .empty-state-hint { 1913 font-family: var(--font-sans); 1914 font-size: 0.85rem; 1915 color: var(--fg-subtle); 1916 line-height: 1.6; 1917 max-width: 28ch; 1918 } 1919 .empty-state-keys { 1920 display: flex; 1921 gap: 20px; 1922 margin-top: 32px; 1923 font-family: var(--font-sans); 1924 font-size: 0.78rem; 1925 color: var(--fg-subtle); 1926 } 1927 .empty-state-keys span { 1928 display: flex; 1929 align-items: center; 1930 gap: 6px; 1931 } 1932 .empty-state-keys kbd { 1933 display: inline-block; 1934 padding: 2px 7px; 1935 font-family: var(--font-mono); 1936 font-size: 0.82em; 1937 color: var(--fg-muted); 1938 background: var(--bg-subtle); 1939 border: 1px solid var(--border-default); 1940 border-bottom-width: 2px; 1941 border-radius: 4px; 1942 line-height: 1.4; 1943 } 1944 1945 /* ---------- Main content area ---------- */ 1946 .main { 1947 flex: 1; 1948 min-height: 0; 1949 display: flex; 1950 flex-direction: column; 1951 position: relative; 1952 } 1953 1954 .editor-pane { 1955 display: none; 1956 } 1957 .preview-pane { 1958 display: block; 1959 flex: 1 0 auto; 1960 min-height: calc(100dvh - var(--toolbar-h, 0px)); 1961 background: var(--bg-canvas); 1962 color: var(--fg-default); 1963 transition: 1964 background-color var(--motion-small) var(--ease-calm), 1965 color var(--motion-small) var(--ease-calm); 1966 } 1967 .preview-pane ::selection { 1968 background: var(--selection); 1969 } 1970 .preview-pane .markdown-body, 1971 .preview-pane .reader-rail, 1972 .preview-pane .markdown-body pre, 1973 .preview-pane .markdown-body blockquote, 1974 .preview-pane .reader-connections__panel, 1975 .preview-pane .reader-connections__graph { 1976 transition: 1977 background-color var(--motion-small) var(--ease-calm), 1978 border-color var(--motion-small) var(--ease-calm), 1979 color var(--motion-small) var(--ease-calm); 1980 } 1981 .main.edit-mode .editor-pane { 1982 display: block; 1983 } 1984 .main.edit-mode .preview-pane { 1985 display: none; 1986 } 1987 1988 .editor-pane { 1989 flex: 1; 1990 min-height: calc(100vh - 63px); 1991 background: var(--bg-canvas); 1992 color: var(--fg-default); 1993 transition: 1994 background-color var(--motion-small) var(--ease-calm), 1995 color var(--motion-small) var(--ease-calm); 1996 } 1997 1998 #editor { 1999 display: block; 2000 width: 100%; 2001 min-height: calc(100vh - 63px); 2002 padding: 48px max(24px, calc((100% - 820px) / 2)) 120px; 2003 border: 0; 2004 resize: none; 2005 background: transparent; 2006 color: var(--fg-default); 2007 outline: none; 2008 tab-size: 2; 2009 font-family: var(--font-mono); 2010 font-size: 14px; 2011 line-height: 1.65; 2012 } 2013 #editor::placeholder { 2014 color: var(--fg-subtle); 2015 font-style: italic; 2016 } 2017 #editor:focus-visible { 2018 outline: 2px solid var(--accent-fg); 2019 outline-offset: -2px; 2020 } 2021 2022 /* ââ Floating editor toolbar â Medium-style, selection-driven âââââ */ 2023 .editor-toolbar { 2024 position: absolute; 2025 top: 0; 2026 left: 0; 2027 z-index: 1000; 2028 display: flex; 2029 align-items: center; 2030 height: 44px; 2031 background: #1a1a1a; 2032 color: #fff; 2033 border-radius: 6px; 2034 box-shadow: 2035 0 6px 20px rgba(0, 0, 0, 0.28), 2036 0 2px 4px rgba(0, 0, 0, 0.18); 2037 opacity: 0; 2038 transform: translateY(4px); 2039 transition: 2040 opacity 160ms ease, 2041 transform 160ms ease; 2042 pointer-events: none; 2043 user-select: none; 2044 -webkit-user-select: none; 2045 white-space: nowrap; 2046 max-width: calc(100vw - 16px); 2047 } 2048 .editor-toolbar.visible { 2049 opacity: 1; 2050 transform: translateY(0); 2051 pointer-events: auto; 2052 } 2053 .editor-toolbar .et-buttons { 2054 display: flex; 2055 align-items: center; 2056 height: 100%; 2057 overflow-x: auto; 2058 overflow-y: hidden; 2059 scrollbar-width: none; 2060 } 2061 .editor-toolbar .et-buttons::-webkit-scrollbar {
2062 display: none; 2063 } 2064 .editor-toolbar.et-link-mode .et-buttons { 2065 display: none; 2066 } 2067 .editor-toolbar .et-link { 2068 display: none; 2069 align-items: center; 2070 height: 100%; 2071 padding: 0 6px 0 12px; 2072 gap: 6px; 2073 } 2074 .editor-toolbar.et-link-mode .et-link { 2075 display: flex; 2076 } 2077 .editor-toolbar button { 2078 all: unset; 2079 box-sizing: border-box; 2080 display: inline-flex; 2081 align-items: center; 2082 justify-content: center; 2083 height: 44px; 2084 min-width: 38px; 2085 padding: 0 9px; 2086 color: #fff; 2087 cursor: pointer; 2088 flex: 0 0 auto; 2089 transition: color 140ms ease; 2090 } 2091 .editor-toolbar button:hover, 2092 .editor-toolbar button:focus-visible { 2093 color: #6fcf8c; 2094 } 2095 .editor-toolbar button:focus-visible { 2096 outline: 2px solid #6fcf8c; 2097 outline-offset: -4px; 2098 border-radius: 4px; 2099 } 2100 .editor-toolbar .et-divider { 2101 width: 1px; 2102 height: 24px; 2103 background: #3a3a3a; 2104 margin: 0 2px; 2105 } 2106 .editor-toolbar .et-glyph { 2107 font-family: Georgia, "Source Serif 4", "Times New Roman", serif; 2108 font-feature-settings: "kern" 1; 2109 line-height: 1; 2110 } 2111 .editor-toolbar .et-glyph-b { 2112 font-weight: 700; 2113 font-size: 19px; 2114 } 2115 .editor-toolbar .et-glyph-i { 2116 font-style: italic; 2117 font-weight: 500; 2118 font-size: 20px; 2119 } 2120 .editor-toolbar .et-glyph-t1 { 2121 font-weight: 700; 2122 font-size: 22px; 2123 } 2124 .editor-toolbar .et-glyph-t2 { 2125 font-weight: 700; 2126 font-size: 15px; 2127 } 2128 .editor-toolbar .et-glyph-q { 2129 font-weight: 700; 2130 font-size: 26px; 2131 transform: translateY(4px); 2132 display: inline-block; 2133 } 2134 .editor-toolbar .et-glyph-code { 2135 font-family: var(--font-mono); 2136 font-size: 18px; 2137 font-weight: 700; 2138 } 2139 .editor-toolbar .et-link-input { 2140 all: unset; 2141 box-sizing: border-box; 2142 height: 32px; 2143 min-width: 260px; 2144 padding: 0 10px; 2145 color: #fff; 2146 font-family: var( 2147 --font-mono, 2148 ui-monospace, 2149 SFMono-Regular, 2150 Menlo, 2151 monospace 2152 ); 2153 font-size: 13px; 2154 background: transparent; 2155 } 2156 .editor-toolbar .et-link-input::placeholder { 2157 color: #888; 2158 } 2159 .editor-toolbar .et-link button { 2160 min-width: 32px; 2161 height: 32px; 2162 font-size: 22px; 2163 padding: 0 8px; 2164 } 2165 .editor-toolbar .et-arrow { 2166 position: absolute; 2167 bottom: -6px; 2168 left: 50%; 2169 width: 0; 2170 height: 0; 2171 margin-left: -6px; 2172 border-left: 6px solid transparent; 2173 border-right: 6px solid transparent; 2174 border-top: 6px solid #1a1a1a; 2175 pointer-events: none; 2176 } 2177 .editor-toolbar.et-arrow-up .et-arrow { 2178 top: -6px; 2179 bottom: auto; 2180 border-top: 0; 2181 border-bottom: 6px solid #1a1a1a; 2182 } 2183 @media (prefers-reduced-motion: reduce) { 2184 .editor-toolbar { 2185 transition: opacity 0s; 2186 transform: none; 2187 } 2188 } 2189 2190 .visual-block-caret-mirror { 2191 position: absolute; 2192 visibility: hidden; 2193 top: 0; 2194 left: -99999px; 2195 white-space: pre-wrap; 2196 overflow-wrap: break-word; 2197 word-wrap: break-word; 2198 pointer-events: none; 2199 } 2200 2201 .visual-block-menu { 2202 position: fixed; 2203 z-index: 1001; 2204 box-sizing: border-box; 2205 width: min(390px, calc(100vw - 20px)); 2206 max-height: min(440px, calc(100vh - 32px)); 2207 overflow-y: auto; 2208 overscroll-behavior: contain; 2209 padding: 14px 12px 12px; 2210 border: 1px solid var(--border-soft); 2211 border-radius: 14px; 2212 background: color-mix(in srgb, var(--bg-raised) 98%, var(--bg-subtle)); 2213 color: var(--fg-default); 2214 box-shadow: 2215 0 24px 70px rgba(0, 0, 0, 0.11), 2216 0 3px 16px rgba(0, 0, 0, 0.05); 2217 font-family: var(--font-sans); 2218 opacity: 0; 2219 transform: translateY(3px) scale(0.99); 2220 transform-origin: top left; 2221 transition: 2222 opacity var(--motion-micro) var(--ease-out), 2223 transform var(--motion-micro) var(--ease-out); 2224 } 2225 2226 :root[data-theme="dark"] .visual-block-menu { 2227 background: color-mix(in srgb, var(--bg-raised) 96%, var(--bg-subtle)); 2228 border-color: var(--border-default); 2229 box-shadow: 2230 0 26px 80px rgba(0, 0, 0, 0.52), 2231 0 3px 16px rgba(0, 0, 0, 0.28); 2232 } 2233 2234 .visual-block-menu[hidden] { 2235 display: none; 2236 } 2237 2238 .visual-block-menu.is-open { 2239 opacity: 1; 2240 transform: translateY(0) scale(1); 2241 } 2242 2243 .visual-block-menu__section { 2244 padding: 6px 8px 5px; 2245 color: var(--fg-subtle); 2246 font-size: 16px; 2247 font-weight: 500; 2248 line-height: 1.2; 2249 } 2250 2251 .visual-block-menu__section:not(:first-child) { 2252 margin-top: 7px; 2253 padding-top: 9px; 2254 } 2255 2256 .visual-block-menu__item { 2257 display: grid; 2258 grid-template-columns: 28px minmax(0, 1fr); 2259 align-items: center; 2260 min-height: 38px; 2261 gap: 10px; 2262 padding: 3px 8px; 2263 border-radius: 7px; 2264 border: 1px solid transparent; 2265 cursor: default; 2266 user-select: none; 2267 -webkit-user-select: none; 2268 transition: 2269 background-color var(--motion-micro) var(--ease-out), 2270 border-color var(--motion-micro) var(--ease-out), 2271 transform var(--motion-micro) var(--ease-out); 2272 } 2273 2274 .visual-block-menu__item[aria-selected="true"] { 2275 border-color: transparent; 2276 background: color-mix(in srgb, var(--fg-default) 7%, transparent); 2277 } 2278 2279 .visual-block-menu__item--disclosure .visual-block-menu__title { 2280 color: color-mix(in srgb, var(--fg-default) 88%, var(--fg-muted)); 2281 font-weight: 500; 2282 } 2283 2284 .visual-block-menu__item--disclosure .visual-block-menu__icon { 2285 color: color-mix(in srgb, var(--fg-default) 64%, var(--fg-muted)); 2286 } 2287 2288 .visual-block-menu__item:active { 2289 transform: translateY(1px); 2290 } 2291 2292 .visual-block-menu__icon { 2293 display: inline-flex; 2294 align-items: center; 2295 justify-content: center; 2296 width: 24px; 2297 height: 24px; 2298 color: color-mix(in srgb, var(--fg-muted) 82%, var(--fg-subtle)); 2299 } 2300 2301 .visual-block-menu__icon svg {
2302 width: 20px; 2303 height: 20px; 2304 fill: none; 2305 stroke: currentColor; 2306 stroke-width: 1.7; 2307 stroke-linecap: round; 2308 stroke-linejoin: round; 2309 overflow: visible; 2310 transform: scale(1.08); 2311 transform-origin: center; 2312 } 2313 2314 .visual-block-menu__title { 2315 color: var(--fg-default); 2316 overflow: hidden; 2317 font-size: 17px; 2318 font-weight: 430; 2319 line-height: 1.2; 2320 text-overflow: ellipsis; 2321 white-space: nowrap; 2322 } 2323 2324 .visual-block-menu__description { 2325 display: none; 2326 } 2327 2328 @supports selector(:has(*)) { 2329 .visual-block-menu__item:has(.visual-block-menu__description):not( 2330 :has(.visual-block-menu__icon) 2331 ) { 2332 grid-template-columns: minmax(0, 1fr); 2333 } 2334 } 2335 2336 .visual-block-menu__empty { 2337 padding: 14px 8px 8px; 2338 color: var(--fg-muted); 2339 font-family: var(--font-sans); 2340 font-size: 16px; 2341 line-height: 1.35; 2342 } 2343 2344 @media (max-width: 640px) { 2345 .visual-block-menu { 2346 width: min(340px, calc(100vw - 20px)); 2347 max-height: min(380px, calc(100vh - 28px)); 2348 padding: 12px 10px 10px; 2349 border-radius: 12px; 2350 } 2351 2352 .visual-block-menu__section { 2353 font-size: 15px; 2354 } 2355 2356 .visual-block-menu__item { 2357 grid-template-columns: 26px minmax(0, 1fr); 2358 min-height: 36px; 2359 gap: 9px; 2360 padding-inline: 6px; 2361 } 2362 2363 .visual-block-menu__icon { 2364 width: 22px; 2365 height: 22px; 2366 } 2367 2368 .visual-block-menu__icon svg { 2369 width: 19px; 2370 height: 19px; 2371 } 2372 2373 .visual-block-menu__title { 2374 font-size: 16px; 2375 } 2376 } 2377 2378 @media (max-height: 760px) { 2379 .visual-block-menu { 2380 max-height: min(400px, calc(100vh - 28px)); 2381 padding: 12px 10px 10px; 2382 border-radius: 12px; 2383 } 2384 2385 .visual-block-menu__section { 2386 padding: 5px 7px 4px; 2387 font-size: 15px; 2388 } 2389 2390 .visual-block-menu__section:not(:first-child) { 2391 margin-top: 5px; 2392 padding-top: 7px; 2393 } 2394 2395 .visual-block-menu__item { 2396 min-height: 34px; 2397 padding-block: 2px; 2398 } 2399 2400 .visual-block-menu__title { 2401 font-size: 16px; 2402 } 2403 } 2404 2405 @media (prefers-reduced-motion: reduce) { 2406 .visual-block-menu, 2407 .visual-block-menu__item { 2408 transition: opacity 0s; 2409 transform: none; 2410 } 2411 } 2412 2413 .preview-pane { 2414 padding: 72px max(24px, calc((100% - 740px) / 2)) 128px; 2415 } 2416 .preview-pane.has-reader-rail { 2417 display: grid; 2418 grid-template-columns: minmax(0, var(--reading-measure)) minmax( 2419 220px, 2420 264px 2421 ); 2422 justify-content: center; 2423 align-items: start; 2424 column-gap: clamp(32px, 5vw, 76px); 2425 padding-inline: 32px; 2426 } 2427 .preview-pane.has-reader-rail > * { 2428 min-width: 0; 2429 } 2430 .preview-pane.has-reader-rail .markdown-body { 2431 width: 100%; 2432 max-width: min(var(--reading-measure), 100%); 2433 margin: 0; 2434 } 2435 .preview-pane.is-empty-state { 2436 display: flex; 2437 align-items: center; 2438 justify-content: center; 2439 padding: 0; 2440 overflow: auto; 2441 } 2442 .preview-pane.is-empty-state .empty-state { 2443 min-height: 0; 2444 width: min(100%, 680px); 2445 } 2446 .preview-pane:focus-visible { 2447 outline: 2px solid var(--accent-fg); 2448 outline-offset: -2px; 2449 } 2450 2451 .reader-rail { 2452 --reader-rail-top: calc(var(--toolbar-h, 52px) + 42px); 2453 position: sticky; 2454 top: var(--reader-rail-top); 2455 display: flex; 2456 flex-direction: column; 2457 width: 100%; 2458 max-width: 248px; 2459 max-height: calc(100dvh - var(--reader-rail-top) - 28px); 2460 min-height: 0; 2461 padding-bottom: 28px; 2462 overflow: hidden; 2463 box-sizing: border-box; 2464 font-family: var(--font-sans); 2465 color: var(--fg-default); 2466 } 2467 .reader-rail.has-reader-connections { 2468 max-height: calc(100dvh - var(--reader-rail-top) - 28px); 2469 } 2470 .reader-rail.has-reader-connections > .reader-rail__section { 2471 min-height: 0; 2472 } 2473 .reader-outline-section, 2474 .reader-rail.has-reader-connections > .reader-outline-section { 2475 display: flex; 2476 flex-direction: column; 2477 flex: 1 1 auto; 2478 min-height: 0; 2479 } 2480 .reader-rail.has-reader-connections > .reader-connections { 2481 flex-shrink: 0; 2482 } 2483 .reader-rail[hidden] { 2484 display: none; 2485 } 2486 .reader-rail__section + .reader-rail__section { 2487 margin-top: 22px; 2488 padding-top: 20px; 2489 border-top: 1px solid var(--border-soft); 2490 } 2491 .reader-rail__header, 2492 .reader-connections__header { 2493 display: flex; 2494 align-items: center; 2495 justify-content: space-between; 2496 gap: 12px; 2497 margin-bottom: 8px; 2498 } 2499 .reader-rail__title, 2500 .reader-connections__title { 2501 margin: 0; 2502 color: var(--fg-muted); 2503 font-family: var(--font-sans); 2504 font-size: 10.5px; 2505 font-weight: 650; 2506 letter-spacing: 0.13em; 2507 line-height: 1.2; 2508 text-transform: uppercase; 2509 } 2510 .reader-outline-toggle { 2511 display: none; 2512 } 2513 .reader-outline-section { 2514 min-height: 0; 2515 } 2516 .reader-outline { 2517 display: grid; 2518 gap: 0; 2519 margin: 0; 2520 max-height: min(64dvh, calc(100dvh - var(--reader-rail-top) - 74px)); 2521 min-height: 0; 2522 padding: 0 4px 0 0; 2523 list-style: none; 2524 overflow-x: hidden; 2525 overflow-y: auto; 2526 overscroll-behavior: contain; 2527 scrollbar-gutter: stable; 2528 scrollbar-width: thin; 2529 scrollbar-color: transparent transparent; 2530 } 2531 .reader-rail.has-reader-connections .reader-outline { 2532 max-height: min(42dvh, 360px); 2533 } 2534 .reader-outline__item { 2535 min-width: 0; 2536 } 2537 .reader-outline::-webkit-scrollbar {
2538 width: 5px; 2539 } 2540 .reader-outline::-webkit-scrollbar-track { 2541 background: transparent; 2542 } 2543 .reader-outline::-webkit-scrollbar-thumb { 2544 border-radius: 999px; 2545 background: transparent; 2546 } 2547 .reader-outline:focus-within, 2548 .reader-outline-section:focus-within .reader-outline { 2549 scrollbar-color: color-mix(in srgb, var(--fg-subtle) 34%, transparent) 2550 transparent; 2551 } 2552 .reader-outline:focus-within::-webkit-scrollbar-thumb, 2553 .reader-outline-section:focus-within 2554 .reader-outline::-webkit-scrollbar-thumb { 2555 background: color-mix(in srgb, var(--fg-subtle) 34%, transparent); 2556 } 2557 @media (hover: hover) and (pointer: fine) { 2558 .reader-outline:hover, 2559 .reader-outline-section:hover .reader-outline { 2560 scrollbar-color: color-mix(in srgb, var(--fg-subtle) 34%, transparent) 2561 transparent; 2562 } 2563 .reader-outline:hover::-webkit-scrollbar-thumb, 2564 .reader-outline-section:hover .reader-outline::-webkit-scrollbar-thumb { 2565 background: color-mix(in srgb, var(--fg-subtle) 34%, transparent); 2566 } 2567 } 2568 .reader-outline__link { 2569 display: block; 2570 box-sizing: border-box; 2571 max-width: 100%; 2572 padding: 4.5px 0 4.5px 10px; 2573 border-left: 2px solid transparent; 2574 color: var(--fg-muted); 2575 font-size: 12.5px; 2576 line-height: 1.25; 2577 text-decoration: none; 2578 transition: 2579 border-color var(--motion-micro) var(--ease-calm), 2580 color var(--motion-micro) var(--ease-calm); 2581 } 2582 .reader-outline__text { 2583 display: -webkit-box; 2584 overflow: hidden; 2585 overflow-wrap: anywhere; 2586 -webkit-box-orient: vertical; 2587 -webkit-line-clamp: 2; 2588 } 2589 .reader-outline__link[data-level="3"] { 2590 padding-left: 18px; 2591 color: var(--fg-subtle); 2592 font-size: 12px; 2593 } 2594 .reader-outline__link[data-level="4"] { 2595 padding-left: 26px; 2596 color: var(--fg-subtle); 2597 font-size: 11.5px; 2598 } 2599 .reader-outline__link:hover, 2600 .reader-outline__link:focus-visible, 2601 .reader-outline__link[aria-current="true"] { 2602 border-left-color: var(--accent-fg); 2603 color: var(--fg-default); 2604 } 2605 .reader-outline__link:focus-visible { 2606 outline: 2px solid var(--accent-fg); 2607 outline-offset: 3px; 2608 } 2609 .reader-connections__panel { 2610 border: 1px solid var(--border-soft); 2611 border-radius: 8px; 2612 background: color-mix(in srgb, var(--bg-raised) 92%, transparent); 2613 box-shadow: var(--shadow-sm); 2614 overflow: hidden; 2615 } 2616 :root[data-theme="dark"] .reader-connections__panel { 2617 background: color-mix(in srgb, var(--bg-raised) 88%, transparent); 2618 } 2619 .reader-connections__graph { 2620 display: block; 2621 width: 100%; 2622 aspect-ratio: 1.35; 2623 touch-action: manipulation; 2624 background: var(--bg-subtle); 2625 } 2626 .reader-connections__edge { 2627 stroke: color-mix(in srgb, var(--fg-subtle) 42%, transparent); 2628 stroke-width: 1; 2629 } 2630 .reader-connections__edge[data-direction="inbound"] { 2631 stroke-dasharray: 3 4; 2632 } 2633 .reader-connections__point { 2634 outline: none; 2635 transform-box: fill-box; 2636 transform-origin: center; 2637 transition: 2638 opacity var(--motion-micro) var(--ease-calm), 2639 transform var(--motion-small) var(--ease-calm); 2640 will-change: transform; 2641 } 2642 .reader-connections__point[data-openable="true"] { 2643 cursor: pointer; 2644 } 2645 .reader-connections__node { 2646 fill: color-mix(in srgb, var(--fg-muted) 46%, var(--bg-raised)); 2647 stroke: var(--bg-raised); 2648 stroke-width: 2; 2649 transition: 2650 stroke-width var(--motion-micro) var(--ease-calm), 2651 opacity var(--motion-micro) var(--ease-calm); 2652 } 2653 .reader-connections__node[data-direction="outbound"] { 2654 fill: color-mix(in srgb, var(--accent-fg) 68%, #6b8fb9); 2655 } 2656 .reader-connections__node[data-direction="inbound"] { 2657 fill: color-mix(in srgb, var(--fg-muted) 62%, #8fb38e); 2658 } 2659 .reader-connections__node[data-direction="bidirectional"] { 2660 fill: color-mix(in srgb, var(--accent-fg) 52%, #caa35c); 2661 } 2662 .reader-connections__node[data-related="true"] { 2663 r: 5.5px; 2664 } 2665 .reader-connections__point[data-openable="true"]:hover 2666 .reader-connections__node, 2667 .reader-connections__point[data-openable="true"]:focus-visible 2668 .reader-connections__node { 2669 stroke-width: 3; 2670 } 2671 .reader-connections__point:focus-visible .reader-connections__node { 2672 stroke: var(--accent-fg); 2673 } 2674 .reader-connections__root {
2675 fill: var(--accent-fg); 2676 stroke: var(--bg-raised); 2677 stroke-width: 2.5; 2678 } 2679 .reader-connections__root-label { 2680 fill: var(--fg-muted); 2681 font-family: var(--font-sans); 2682 font-size: 9.5px; 2683 font-weight: 540; 2684 letter-spacing: 0.005em; 2685 paint-order: stroke; 2686 stroke: var(--bg-subtle); 2687 stroke-linejoin: round; 2688 stroke-width: 5px; 2689 transition: opacity var(--motion-micro) var(--ease-calm); 2690 } 2691 .reader-connections__meta { 2692 display: grid; 2693 grid-template-columns: repeat(3, minmax(0, 1fr)); 2694 border-top: 1px solid var(--border-soft); 2695 } 2696 .reader-connections__stat { 2697 display: grid; 2698 gap: 2px; 2699 padding: 8px 7px; 2700 text-align: center; 2701 } 2702 .reader-connections__stat + .reader-connections__stat { 2703 border-left: 1px solid var(--border-soft); 2704 } 2705 .reader-connections__stat strong { 2706 color: var(--fg-default); 2707 font-family: var(--font-sans); 2708 font-size: 12px; 2709 font-weight: 600; 2710 line-height: 1; 2711 font-variant-numeric: tabular-nums; 2712 transition: color var(--motion-micro) var(--ease-calm); 2713 } 2714 .reader-connections__stat span { 2715 color: var(--fg-subtle); 2716 font-size: 10px; 2717 line-height: 1.2; 2718 } 2719 .reader-connections__stat[data-empty="true"] { 2720 opacity: 0.45; 2721 } 2722 .reader-connections__stat[data-empty="true"] strong { 2723 color: var(--fg-subtle); 2724 font-weight: 500; 2725 } 2726 .reader-connections__list { 2727 display: grid; 2728 gap: 0; 2729 margin-top: 9px; 2730 } 2731 .reader-connection { 2732 display: grid; 2733 grid-template-columns: 7px minmax(0, 1fr); 2734 gap: 8px; 2735 width: 100%; 2736 padding: 6px 0; 2737 border: 0; 2738 border-top: 1px solid var(--border-soft); 2739 border-radius: 0; 2740 color: inherit; 2741 background: transparent; 2742 text-align: left; 2743 } 2744 button.reader-connection { 2745 cursor: pointer; 2746 } 2747 button.reader-connection:hover .reader-connection__title, 2748 button.reader-connection:focus-visible .reader-connection__title { 2749 color: var(--accent-fg); 2750 } 2751 button.reader-connection:focus-visible { 2752 outline: 2px solid var(--accent-fg); 2753 outline-offset: 3px; 2754 } 2755 .reader-connection__dot { 2756 width: 6px; 2757 height: 6px; 2758 margin-top: 5px; 2759 border-radius: 50%; 2760 background: color-mix(in srgb, var(--fg-muted) 52%, var(--bg-raised)); 2761 } 2762 .reader-connection__dot[data-direction="outbound"] { 2763 background: color-mix(in srgb, var(--accent-fg) 68%, #6b8fb9); 2764 } 2765 .reader-connection__dot[data-direction="inbound"] { 2766 background: color-mix(in srgb, var(--fg-muted) 62%, #8fb38e); 2767 } 2768 .reader-connection__dot[data-direction="bidirectional"] { 2769 background: color-mix(in srgb, var(--accent-fg) 52%, #caa35c); 2770 } 2771 .reader-connection__title { 2772 display: block; 2773 overflow: hidden; 2774 color: var(--fg-default); 2775 font-size: 12.5px; 2776 font-weight: 540; 2777 line-height: 1.28; 2778 text-overflow: ellipsis; 2779 white-space: nowrap; 2780 transition: color var(--motion-micro) var(--ease-calm); 2781 } 2782 .reader-connection__detail { 2783 display: block;
2784 margin-top: 1px; 2785 overflow: hidden; 2786 color: var(--fg-subtle); 2787 font-size: 11px; 2788 line-height: 1.25; 2789 text-overflow: ellipsis; 2790 white-space: nowrap; 2791 } 2792 .reader-connections__more { 2793 margin: 7px 0 0; 2794 color: var(--fg-subtle); 2795 font-size: 11px; 2796 line-height: 1.4; 2797 } 2798 2799 @media (max-width: 900px) { 2800 .preview-pane.has-reader-rail { 2801 display: block; 2802 padding: 72px max(24px, calc((100% - 740px) / 2)) 128px; 2803 } 2804 .preview-pane.has-reader-rail .markdown-body { 2805 margin: 0 auto; 2806 } 2807 .reader-rail { 2808 position: static; 2809 max-width: min(var(--reading-measure), 100%); 2810 max-height: none; 2811 margin: 48px auto 0; 2812 padding-bottom: 0; 2813 overflow: visible; 2814 } 2815 .reader-rail.has-reader-connections { 2816 max-height: none; 2817 padding-bottom: 0; 2818 } 2819 .reader-outline, 2820 .reader-rail.has-reader-connections .reader-outline { 2821 max-height: none; 2822 padding-right: 0; 2823 overflow: visible; 2824 scrollbar-gutter: auto; 2825 } 2826 } 2827 2828 @media (min-width: 721px) and (max-width: 900px) { 2829 .reader-rail.has-reader-connections { 2830 display: grid; 2831 grid-template-columns: minmax(0, 1fr) minmax(240px, 0.82fr); 2832 align-items: start; 2833 column-gap: clamp(24px, 5vw, 40px); 2834 padding-top: 26px; 2835 border-top: 1px solid var(--border-soft); 2836 } 2837 .reader-rail.has-reader-connections 2838 .reader-rail__section 2839 + .reader-rail__section { 2840 margin-top: 0; 2841 padding-top: 0; 2842 border-top: 0; 2843 } 2844 } 2845 2846 @media (max-width: 720px) { 2847 .preview-pane.has-reader-rail { 2848 padding: 40px 22px 96px; 2849 } 2850 .reader-rail { 2851 margin-top: 38px; 2852 } 2853 .reader-rail__section + .reader-rail__section { 2854 margin-top: 18px; 2855 padding-top: 18px; 2856 } 2857 .reader-outline-section .reader-rail__header { 2858 display: none; 2859 } 2860 .reader-outline-toggle { 2861 display: flex; 2862 align-items: center; 2863 justify-content: space-between; 2864 gap: 14px; 2865 width: 100%; 2866 min-height: 42px; 2867 padding: 0; 2868 border: 0; 2869 border-radius: 0; 2870 color: var(--fg-default); 2871 background: transparent; 2872 font: inherit; 2873 text-align: left; 2874 cursor: pointer; 2875 } 2876 .reader-outline-toggle:focus-visible { 2877 outline: 2px solid var(--accent-fg); 2878 outline-offset: 4px; 2879 } 2880 .reader-outline-toggle__kicker { 2881 display: block; 2882 color: var(--fg-muted); 2883 font-size: 10.5px; 2884 font-weight: 650; 2885 letter-spacing: 0.13em; 2886 line-height: 1.2; 2887 text-transform: uppercase; 2888 } 2889 .reader-outline-toggle__summary { 2890 display: block; 2891 margin-top: 4px; 2892 color: var(--fg-subtle); 2893 font-size: 12px; 2894 line-height: 1.2; 2895 } 2896 .reader-outline-toggle__chevron { 2897 width: 8px; 2898 height: 8px; 2899 margin-right: 2px; 2900 border-right: 1.5px solid currentColor; 2901 border-bottom: 1.5px solid currentColor; 2902 color: var(--fg-muted); 2903 transform: rotate(45deg); 2904 transition: transform var(--motion-small) var(--ease-calm); 2905 } 2906 .reader-outline-section.is-open .reader-outline-toggle__chevron { 2907 transform: rotate(225deg); 2908 } 2909 .reader-outline-section .reader-outline { 2910 display: none; 2911 margin-top: 10px; 2912 padding-top: 10px; 2913 border-top: 1px solid var(--border-soft); 2914 } 2915 .reader-rail.has-reader-connections 2916 .reader-outline-section 2917 .reader-outline { 2918 max-height: min(46dvh, 360px); 2919 overflow-y: auto; 2920 scrollbar-gutter: stable; 2921 } 2922 .reader-outline-section.is-open .reader-outline { 2923 display: grid; 2924 } 2925 .reader-outline__link {
2926 padding-block: 6px; 2927 font-size: 13px; 2928 } 2929 .reader-outline__link[data-level="3"] { 2930 font-size: 12.5px; 2931 } 2932 .reader-outline__link[data-level="4"] { 2933 font-size: 12px; 2934 } 2935 .reader-connections__graph { 2936 height: clamp(150px, 42vw, 210px); 2937 aspect-ratio: auto; 2938 } 2939 .reader-connections__panel { 2940 box-shadow: none; 2941 } 2942 .reader-connections__meta { 2943 border-top: 1px solid var(--border-soft); 2944 } 2945 } 2946 2947 /* ============================================================ 2948 Markdown body â editorial serif typography. 2949 The whole point of this app. 2950 ============================================================ */ 2951 .markdown-body { 2952 max-width: min(var(--reading-measure), 100%); 2953 margin: 0 auto; 2954 font-family: var(--font-serif); 2955 font-size: var(--reading-font-size); 2956 /* Optical size axis â Source Serif 4 has variable optical sizing, 2957 this tells it to render as a reading-weight design. */ 2958 font-optical-sizing: auto; 2959 font-variation-settings: "opsz" 20; 2960 line-height: var(--reading-line-height); 2961 color: var(--fg-default); 2962 word-wrap: break-word; 2963 /* Better text shaping for long reads */ 2964 font-feature-settings: "kern", "liga", "calt", "onum"; 2965 hanging-punctuation: first last; 2966 } 2967 2968 .markdown-body > *:first-child { 2969 margin-top: 0 !important; 2970 } 2971 .markdown-body > *:last-child { 2972 margin-bottom: 0 !important; 2973 } 2974 2975 .markdown-body p { 2976 margin: 0 0 1.15em; 2977 } 2978 2979 /* Drop-cap-adjacent first-paragraph lift, just a touch */ 2980 .markdown-body > p:first-of-type { 2981 font-size: 1.05em; 2982 color: var(--fg-default); 2983 } 2984 2985 /* Headings â Source Serif with tighter leading, negative tracking */ 2986 .markdown-body h1, 2987 .markdown-body h2, 2988 .markdown-body h3, 2989 .markdown-body h4, 2990 .markdown-body h5, 2991 .markdown-body h6 { 2992 font-family: var(--font-serif); 2993 font-weight: 600; 2994 line-height: 1.2; 2995 letter-spacing: -0.018em; 2996 color: var(--fg-heading, var(--fg-default)); 2997 margin: 1.5em 0 0.45em; 2998 font-variation-settings: "opsz" 36; 2999 } 3000 3001 /* ââ Heading anchor links ââââââââââââââââââââââââââââââââ 3002 A subtle `#` link appears to the left of each heading on 3003 hover, enabling deep-linking without visual noise. Uses 3004 absolute positioning so it never shifts the heading text. 3005 Anchor is a real <a> (wired up in JS) so it works with 3006 keyboard, middle-click, and copy-link-address. */ 3007 .preview-pane .markdown-body :is(h1, h2, h3, h4, h5, h6) { 3008 position: relative; 3009 } 3010 .heading-anchor { 3011 position: absolute; 3012 left: -1.15em; 3013 top: 0; 3014 display: inline-flex; 3015 align-items: baseline; 3016 color: var(--fg-subtle); 3017 font-family: var(--font-sans); 3018 font-weight: 400; 3019 font-size: 0.7em; 3020 text-decoration: none; 3021 opacity: 0; 3022 transform: translateX(4px); 3023 transition: 3024 opacity var(--motion-small) var(--ease-calm), 3025 transform var(--motion-small) var(--ease-out), 3026 color var(--motion-micro) var(--ease-calm); 3027 line-height: inherit; 3028 padding: 0 0.25em; 3029 border-radius: 4px; 3030 } 3031 .preview-pane 3032 .markdown-body 3033 :is(h1, h2, h3, h4, h5, h6):hover 3034 .heading-anchor, 3035 .heading-anchor:focus-visible { 3036 opacity: 0.7; 3037 transform: translateX(0); 3038 } 3039 .heading-anchor:hover { 3040 opacity: 1 !important; 3041 color: var(--accent-fg); 3042 } 3043 .heading-anchor:focus-visible { 3044 outline: 2px solid var(--accent-fg); 3045 outline-offset: 2px; 3046 } 3047 /* Small viewports: park the anchor inline at the end of the 3048 heading rather than floating in a missing left margin. */ 3049 @media (max-width: 900px) { 3050 .heading-anchor { 3051 position: static; 3052 display: inline-block; 3053 margin-left: 0.35em; 3054 transform: none; 3055 } 3056 } 3057
3058 /* ââ Code block chrome (language label + copy) âââââââââââ 3059 A floating pill in the top-right of each <pre> that shows 3060 the language and a Copy button. Both hidden by default, 3061 fade/slide in on hover or keyboard focus within the block. 3062 Positioned so it never overlaps the first line of code. */ 3063 .preview-pane .markdown-body pre { 3064 position: relative; 3065 } 3066 .code-chrome { 3067 position: absolute; 3068 top: 8px; 3069 right: 8px; 3070 display: flex; 3071 align-items: center; 3072 gap: 4px; 3073 opacity: 0; 3074 transform: translateX(6px); 3075 pointer-events: none; 3076 transition: 3077 opacity var(--motion-small) var(--ease-calm), 3078 transform var(--motion-small) var(--ease-out); 3079 } 3080 .preview-pane .markdown-body pre:hover .code-chrome, 3081 .preview-pane .markdown-body pre:focus-within .code-chrome { 3082 opacity: 1; 3083 transform: translateX(0); 3084 pointer-events: auto; 3085 } 3086 .code-lang-badge { 3087 display: inline-flex; 3088 align-items: center; 3089 height: 22px; 3090 padding: 0 8px; 3091 font-family: var(--font-sans); 3092 font-size: 10.5px; 3093 font-weight: 500; 3094 color: var(--fg-muted); 3095 background: color-mix(in srgb, var(--bg-raised) 90%, transparent); 3096 border: 1px solid var(--border-soft); 3097 border-radius: 6px; 3098 text-transform: lowercase; 3099 letter-spacing: 0.03em; 3100 backdrop-filter: blur(4px); 3101 -webkit-backdrop-filter: blur(4px); 3102 } 3103 .code-copy-btn { 3104 display: inline-flex; 3105 align-items: center; 3106 justify-content: center; 3107 width: 26px; 3108 height: 22px; 3109 padding: 0; 3110 color: var(--fg-muted); 3111 background: color-mix(in srgb, var(--bg-raised) 90%, transparent); 3112 border: 1px solid var(--border-soft); 3113 border-radius: 6px; 3114 cursor: pointer; 3115 transition: 3116 color var(--motion-micro) var(--ease-calm), 3117 background var(--motion-micro) var(--ease-calm); 3118 backdrop-filter: blur(4px); 3119 -webkit-backdrop-filter: blur(4px); 3120 } 3121 .code-copy-btn:hover { 3122 color: var(--fg-default); 3123 background: color-mix(in srgb, var(--bg-raised) 100%, transparent); 3124 } 3125 .code-copy-btn:focus-visible { 3126 outline: 2px solid var(--accent-fg); 3127 outline-offset: 1px; 3128 } 3129 .code-copy-btn svg { 3130 width: 12px; 3131 height: 12px; 3132 } 3133 .code-copy-btn.is-copied { 3134 color: var(--accent-fg); 3135 } 3136 3137 /* ââ Reading breadcrumb ââââââââââââââââââââââââââââââââââ 3138 Quiet "where am I" signal. Updates as the user scrolls 3139 through sections. Sits directly under the toolbar, fades 3140 in only once the user has scrolled past the first heading. 3141 Hidden in edit/library/mindmap modes (chrome noise). */ 3142 #readingBreadcrumb { 3143 position: fixed; 3144 top: calc(var(--toolbar-h, 52px) + 4px); 3145 left: 50%; 3146 transform: translateX(-50%) translateY(-4px); 3147 z-index: 4; 3148 display: inline-flex; 3149 align-items: center; 3150 gap: 6px; 3151 max-width: min(720px, calc(100vw - 48px)); 3152 padding: 6px 12px; 3153 font-family: var(--font-sans); 3154 font-size: 11.5px; 3155 color: var(--fg-muted); 3156 background: color-mix(in srgb, var(--bg-raised) 88%, transparent); 3157 border: 1px solid var(--border-soft); 3158 border-radius: 999px; 3159 box-shadow: var(--shadow-sm); 3160 backdrop-filter: blur(10px); 3161 -webkit-backdrop-filter: blur(10px); 3162 opacity: 0; 3163 pointer-events: none; 3164 transition: 3165 opacity var(--motion-small) var(--ease-calm), 3166 transform var(--motion-small) var(--ease-out); 3167 overflow: hidden; 3168 text-overflow: ellipsis; 3169 white-space: nowrap; 3170 } 3171 #readingBreadcrumb.is-visible { 3172 opacity: 1; 3173 transform: translateX(-50%) translateY(0); 3174 } 3175 #readingBreadcrumb .sep { 3176 color: var(--fg-subtle); 3177 margin: 0 2px; 3178 } 3179 #readingBreadcrumb .crumb { 3180 color: var(--fg-muted); 3181 } 3182 #readingBreadcrumb .crumb:last-child { 3183 color: var(--fg-default); 3184 } 3185 body.edit-mode-active #readingBreadcrumb, 3186 body.mindmap-open #readingBreadcrumb, 3187 body.library-open #readingBreadcrumb { 3188 opacity: 0 !important; 3189 } 3190 3191 /* ââ Link preview card âââââââââââââââââââââââââââââââââââ 3192 Appears on long hover (~500ms) of an <a> inside the doc. 3193 For internal anchors, surfaces the target heading and 3194 first paragraph. For external, shows the domain + path. 3195 Positioned near the hovered link; JS handles placement 3196 to keep it in-viewport. */ 3197 .link-preview { 3198 position: fixed; 3199 z-index: 50; 3200 max-width: 340px; 3201 padding: 12px 14px; 3202 font-family: var(--font-sans); 3203 font-size: 12.5px; 3204 line-height: 1.45; 3205 color: var(--fg-default); 3206 background: var(--bg-raised); 3207 border: 1px solid var(--border-soft); 3208 border-radius: 10px; 3209 box-shadow: var(--shadow-md); 3210 opacity: 0; 3211 transform: translateY(4px) scale(0.98); 3212 pointer-events: none; 3213 transition: 3214 opacity var(--motion-small) var(--ease-calm), 3215 transform var(--motion-small) var(--ease-out); 3216 } 3217 .link-preview.is-visible { 3218 opacity: 1; 3219 transform: translateY(0) scale(1); 3220 } 3221 .link-preview .lp-title { 3222 font-weight: 600; 3223 color: var(--fg-default); 3224 margin-bottom: 4px; 3225 overflow: hidden; 3226 text-overflow: ellipsis; 3227 white-space: nowrap; 3228 } 3229 .link-preview .lp-excerpt { 3230 color: var(--fg-muted); 3231 font-size: 11.5px; 3232 display: -webkit-box; 3233 -webkit-line-clamp: 3; 3234 -webkit-box-orient: vertical; 3235 overflow: hidden; 3236 } 3237 .link-preview .lp-url { 3238 margin-top: 6px; 3239 font-family: var(--font-mono, ui-monospace, monospace); 3240 font-size: 10.5px; 3241 color: var(--fg-subtle); 3242 overflow: hidden; 3243 text-overflow: ellipsis; 3244 white-space: nowrap; 3245 } 3246 .link-preview .lp-kind { 3247 display: inline-block; 3248 font-size: 9.5px; 3249 text-transform: uppercase; 3250 letter-spacing: 0.08em; 3251 color: var(--fg-subtle); 3252 margin-bottom: 4px; 3253 } 3254 3255 /* Scroll-reveal for headings â only applies when JS wires up 3256 the IntersectionObserver (body class `js-scroll-reveal` is 3257 set from script). If JS fails for any reason, content stays 3258 visible by default. Gate on `.preview-pane` so the editor
3259 view and mindmap aren't affected. */ 3260 html.js-scroll-reveal 3261 .preview-pane 3262 .markdown-body 3263 :is(h1, h2, h3, h4):not([data-revealed]) { 3264 opacity: 0; 3265 transform: translateY(8px); 3266 } 3267 html.js-scroll-reveal .preview-pane .markdown-body :is(h1, h2, h3, h4) { 3268 transition: 3269 opacity var(--motion-page) var(--ease-out), 3270 transform var(--motion-page) var(--ease-out); 3271 } 3272 3273 .markdown-body h1 { 3274 font-size: 2.4em; 3275 font-weight: 700; 3276 letter-spacing: -0.025em; 3277 margin-top: 0; 3278 margin-bottom: 0.35em; 3279 } 3280 .markdown-body h2 { 3281 font-size: 1.7em; 3282 margin-top: 1.7em; 3283 padding-bottom: 0.3em; 3284 border-bottom: 1px solid var(--border-soft); 3285 } 3286 .markdown-body h3 { 3287 font-size: 1.32em; 3288 margin-top: 1.4em; 3289 } 3290 .markdown-body h4 { 3291 font-size: 1.12em; 3292 } 3293 .markdown-body h5 { 3294 font-size: 1em; 3295 text-transform: uppercase; 3296 letter-spacing: 0.04em; 3297 color: var(--fg-muted); 3298 } 3299 .markdown-body h6 { 3300 font-size: 0.9em; 3301 color: var(--fg-muted); 3302 } 3303 3304 .markdown-body h1 + p, 3305 .markdown-body h2 + p, 3306 .markdown-body h3 + p { 3307 margin-top: 0; 3308 } 3309 3310 /* Links â subtle underline offset, ink color */ 3311 .markdown-body a { 3312 color: var(--reader-link, var(--accent-fg)); 3313 text-decoration: underline; 3314 text-decoration-thickness: 1px; 3315 text-decoration-color: color-mix( 3316 in srgb, 3317 currentColor 30%, 3318 transparent 3319 ); 3320 text-underline-offset: 3px; 3321 transition: 3322 text-decoration-color 180ms ease, 3323 color 180ms ease; 3324 } 3325 .markdown-body a:hover { 3326 color: var(--accent-hover); 3327 text-decoration-color: currentColor; 3328 } 3329 3330 .markdown-body strong { 3331 font-weight: 700; 3332 color: var(--fg-default); 3333 } 3334 3335 .markdown-body em { 3336 font-style: italic; 3337 } 3338 3339 .markdown-body hr { 3340 height: 1px; 3341 margin: 2em auto; 3342 width: 64px; 3343 background: var(--border-default); 3344 border: 0; 3345 } 3346 3347 .markdown-body ul, 3348 .markdown-body ol { 3349 margin: 0 0 1.15em; 3350 padding-left: 1.6em; 3351 } 3352 .markdown-body ul ul, 3353 .markdown-body ul ol, 3354 .markdown-body ol ul, 3355 .markdown-body ol ol { 3356 margin: 0.3em 0; 3357 } 3358 3359 .markdown-body li { 3360 margin: 0.3em 0; 3361 } 3362 .markdown-body li::marker { 3363 color: var(--fg-subtle); 3364 } 3365 .markdown-body li > p { 3366 margin: 0.5em 0; 3367 } 3368 3369 /* Blockquote â warm, editorial pull-quote feel */ 3370 .markdown-body blockquote { 3371 margin: 1.5em 0; 3372 padding: 0.4em 1.2em; 3373 color: var(--fg-muted); 3374 font-style: italic; 3375 border-left: 3px solid var(--blockquote-border); 3376 background: var(--blockquote-bg); 3377 border-radius: 0 4px 4px 0; 3378 } 3379 .markdown-body blockquote > :first-child { 3380 margin-top: 0; 3381 } 3382 .markdown-body blockquote > :last-child { 3383 margin-bottom: 0; 3384 } 3385 .markdown-body blockquote p { 3386 font-size: 0.98em; 3387 } 3388 3389 .markdown-body .markdown-alert, 3390 .chat-msg.markdown-rendered .markdown-alert { 3391 --alert-accent: var(--alert-note); 3392 margin: 1.5em 0; 3393 padding: 0.95em 1.1em 1em; 3394 color: var(--fg-default); 3395 background: color-mix( 3396 in srgb, 3397 var(--alert-accent) 7%, 3398 var(--bg-raised) 3399 ); 3400 border: 1px solid 3401 color-mix(in srgb, var(--alert-accent) 18%, var(--border-soft)); 3402 border-left: 4px solid 3403 color-mix(in srgb, var(--alert-accent) 62%, transparent); 3404 border-radius: 10px; 3405 box-shadow: var(--shadow-sm); 3406 } 3407 .markdown-body .markdown-alert[data-alert-kind="note"], 3408 .chat-msg.markdown-rendered .markdown-alert[data-alert-kind="note"] { 3409 --alert-accent: var(--alert-note); 3410 } 3411 .markdown-body .markdown-alert[data-alert-kind="tip"], 3412 .chat-msg.markdown-rendered .markdown-alert[data-alert-kind="tip"] { 3413 --alert-accent: var(--alert-tip); 3414 } 3415 .markdown-body .markdown-alert[data-alert-kind="important"], 3416 .chat-msg.markdown-rendered .markdown-alert[data-alert-kind="important"] { 3417 --alert-accent: var(--alert-important); 3418 } 3419 .markdown-body .markdown-alert[data-alert-kind="warning"], 3420 .chat-msg.markdown-rendered .markdown-alert[data-alert-kind="warning"] { 3421 --alert-accent: var(--alert-warning); 3422 } 3423 .markdown-body .markdown-alert[data-alert-kind="caution"], 3424 .chat-msg.markdown-rendered .markdown-alert[data-alert-kind="caution"] { 3425 --alert-accent: var(--alert-caution); 3426 } 3427 .markdown-body .markdown-alert-title, 3428 .chat-msg.markdown-rendered .markdown-alert-title { 3429 display: flex; 3430 align-items: center; 3431 gap: 0.55rem; 3432 margin: 0 0 0.55em; 3433 font-family: var(--font-sans); 3434 font-size: 0.9rem; 3435 font-weight: 700; 3436 line-height: 1.25; 3437 color: color-mix(in srgb, var(--alert-accent) 74%, var(--fg-default)); 3438 } 3439 .markdown-body .markdown-alert-title svg, 3440 .chat-msg.markdown-rendered .markdown-alert-title svg { 3441 width: 1rem; 3442 height: 1rem; 3443 flex-shrink: 0; 3444 } 3445 .markdown-body .markdown-alert-content, 3446 .chat-msg.markdown-rendered .markdown-alert-content { 3447 color: var(--fg-default); 3448 } 3449 .markdown-body .markdown-alert-content > :first-child, 3450 .chat-msg.markdown-rendered .markdown-alert-content > :first-child { 3451 margin-top: 0; 3452 } 3453 .markdown-body .markdown-alert-content > :last-child, 3454 .chat-msg.markdown-rendered .markdown-alert-content > :last-child { 3455 margin-bottom: 0; 3456 } 3457 /* Inline code â slight tint so it sits in the prose without shouting */ 3458 .markdown-body code, 3459 .markdown-body tt { 3460 padding: 0.15em 0.4em; 3461 margin: 0; 3462 font-family: var(--font-mono); 3463 font-size: 0.85em; 3464 background: var(--code-inline-bg); 3465 border-radius: 4px; 3466 white-space: break-spaces; 3467 color: var(--reader-code-fg, var(--fg-default)); 3468 } 3469 3470 /* Code blocks â standalone cards */ 3471 .markdown-body pre { 3472 margin: 1.5em 0; 3473 padding: 18px 22px; 3474 overflow: auto; 3475 font-family: var(--font-mono); 3476 font-size: 13.5px; 3477 line-height: 1.6; 3478 color: var(--reader-code-fg, var(--fg-default)); 3479 background: var(--code-block-bg); 3480 border: 1px solid var(--border-soft); 3481 border-radius: 8px; 3482 } 3483 .markdown-body pre code, 3484 .markdown-body pre tt { 3485 display: inline; 3486 padding: 0; 3487 margin: 0; 3488 background: transparent; 3489 border: 0; 3490 font-size: inherit; 3491 white-space: pre; 3492 color: inherit; 3493 } 3494 .markdown-body .mermaid-block { 3495 position: relative; 3496 margin: 1.5em 0; 3497 padding: 18px 22px; 3498 overflow: auto; 3499 color: var(--reader-code-fg, var(--fg-default)); 3500 background: var(--code-block-bg); 3501 border: 1px solid var(--border-soft); 3502 border-radius: 8px; 3503 } 3504 .markdown-body .mermaid-block:not(.is-loading):not(.is-error) { 3505 cursor: zoom-in; 3506 } 3507 .markdown-body .mermaid-block.is-loading { 3508 display: flex; 3509 align-items: center; 3510 justify-content: center; 3511 min-height: 160px; 3512 color: var(--fg-muted); 3513 font-family: var(--font-sans); 3514 font-size: 0.95rem; 3515 } 3516 .markdown-body .mermaid-block svg { 3517 display: block; 3518 height: auto; 3519 max-width: 100%; 3520 margin: 0 auto; 3521 } 3522 .markdown-body .mermaid-inline-actions { 3523 position: sticky; 3524 top: 10px; 3525 z-index: 1; 3526 display: flex; 3527 justify-content: flex-end; 3528 margin: 0 0 10px; 3529 pointer-events: none; 3530 } 3531 .markdown-body .mermaid-inline-action { 3532 display: inline-flex; 3533 align-items: center; 3534 gap: 6px; 3535 padding: 7px 10px; 3536 border: 1px solid var(--border-default); 3537 border-radius: 999px; 3538 background: color-mix(in srgb, var(--bg-raised) 88%, transparent); 3539 color: var(--fg-default); 3540 box-shadow: var(--shadow-sm); 3541 opacity: 0; 3542 pointer-events: auto; 3543 transform: translateY(-2px); 3544 transition: 3545 opacity 140ms ease, 3546 transform 140ms ease, 3547 border-color 140ms ease, 3548 background 140ms ease; 3549 } 3550 .markdown-body .mermaid-block:hover .mermaid-inline-action, 3551 .markdown-body .mermaid-block:focus-within .mermaid-inline-action, 3552 .markdown-body .mermaid-inline-action:focus-visible { 3553 opacity: 1; 3554 transform: translateY(0); 3555 } 3556 .markdown-body .mermaid-inline-action:hover { 3557 border-color: var(--border-strong); 3558 background: var(--bg-raised); 3559 } 3560 .markdown-body .mermaid-inline-action svg { 3561 width: 13px; 3562 height: 13px; 3563 margin: 0; 3564 flex: none; 3565 } 3566 .markdown-body .mermaid-block.is-error { 3567 padding: 0; 3568 overflow: hidden; 3569 } 3570 .markdown-body .mermaid-error { 3571 padding: 12px 16px 0; 3572 font-family: var(--font-sans); 3573 font-size: 0.9rem; 3574 color: var(--fg-muted); 3575 } 3576 .markdown-body .mermaid-error-detail { 3577 margin: 12px 0 0; 3578 padding: 12px 16px; 3579 font-size: 12.5px; 3580 line-height: 1.5; 3581 } 3582 .markdown-body .mermaid-block.is-error pre { 3583 margin: 12px 0 0; 3584 border: 0; 3585 border-top: 1px solid var(--border-soft); 3586 border-radius: 0; 3587 } 3588 /* ââ KaTeX math ââââââââââââââââââââââââââââââââââââââââââââââ */ 3589 .markdown-body .katex-display-block { 3590 margin: 1.2em 0; 3591 padding: 14px 18px; 3592 overflow-x: auto; 3593 text-align: center; 3594 background: var(--code-block-bg); 3595 border: 1px solid var(--border-soft); 3596 border-radius: 8px; 3597 } 3598 .markdown-body .katex-inline { 3599 padding: 0 2px; 3600 } 3601 /* ââ Footnotes ââââââââââââââââââââââââââââââââââââââââââââââ */ 3602 .markdown-body .footnote-ref a { 3603 color: var(--accent-fg); 3604 text-decoration: none; 3605 font-weight: 600; 3606 font-size: 0.78em; 3607 vertical-align: super; 3608 line-height: 0; 3609 padding: 0 2px; 3610 } 3611 .markdown-body .footnote-ref a:hover { 3612 text-decoration: underline; 3613 } 3614 .markdown-body .footnotes { 3615 margin-top: 2em; 3616 padding-top: 1em; 3617 font-size: 0.9rem; 3618 color: var(--fg-muted); 3619 } 3620 .markdown-body .footnotes ol { 3621 padding-left: 1.5em; 3622 } 3623 .markdown-body .footnotes li { 3624 margin-bottom: 0.5em; 3625 } 3626 .markdown-body .footnotes li:target { 3627 background: color-mix(in srgb, var(--accent-fg) 8%, transparent); 3628 border-radius: 4px; 3629 padding: 2px 6px; 3630 margin-left: -6px; 3631 } 3632 .markdown-body .footnote-backref { 3633 color: var(--accent-fg); 3634 text-decoration: none; 3635 margin-left: 4px; 3636 font-size: 0.85em; 3637 } 3638 .markdown-body .footnote-backref:hover { 3639 text-decoration: underline; 3640 } 3641 /* Override hljs background to match our code block style */ 3642 .markdown-body pre code.hljs { 3643 color: var(--reader-code-fg, var(--fg-default)); 3644 background: transparent; 3645 padding: 0; 3646 } 3647 .preview-pane .markdown-body :is(.hljs-comment, .hljs-quote) { 3648 color: var(--reader-syntax-comment, var(--fg-muted)); 3649 font-style: italic; 3650 } 3651 .preview-pane 3652 .markdown-body 3653 :is(.hljs-keyword, .hljs-selector-tag, .hljs-subst, .hljs-doctag) { 3654 color: var( 3655 --reader-syntax-keyword, 3656 var(--reader-code-fg, var(--fg-default)) 3657 ); 3658 } 3659 .preview-pane 3660 .markdown-body 3661 :is( 3662 .hljs-string, 3663 .hljs-regexp, 3664 .hljs-symbol, 3665 .hljs-bullet, 3666 .hljs-addition 3667 ) { 3668 color: var( 3669 --reader-syntax-string, 3670 var(--reader-code-fg, var(--fg-default)) 3671 ); 3672 } 3673 .preview-pane 3674 .markdown-body 3675 :is( 3676 .hljs-number, 3677 .hljs-literal, 3678 .hljs-selector-id, 3679 .hljs-selector-class 3680 ) { 3681 color: var( 3682 --reader-syntax-number, 3683 var(--reader-code-fg, var(--fg-default)) 3684 ); 3685 } 3686 .preview-pane 3687 .markdown-body 3688 :is( 3689 .hljs-title, 3690 .hljs-title.class_, 3691 .hljs-title.function_, 3692 .hljs-section 3693 ) { 3694 color: var( 3695 --reader-syntax-title, 3696 var(--reader-code-fg, var(--fg-default)) 3697 ); 3698 } 3699 .preview-pane 3700 .markdown-body 3701 :is( 3702 .hljs-attr, 3703 .hljs-attribute, 3704 .hljs-variable, 3705 .hljs-template-variable, 3706 .hljs-property, 3707 .hljs-type, 3708 .hljs-built_in, 3709 .hljs-name, 3710 .hljs-tag, 3711 .hljs-meta 3712 ) { 3713 color: var( 3714 --reader-syntax-meta, 3715 var(--reader-code-fg, var(--fg-default)) 3716 ); 3717 } 3718 .preview-pane .markdown-body :is(.hljs-emphasis) { 3719 font-style: italic; 3720 } 3721 .preview-pane .markdown-body :is(.hljs-strong) { 3722 font-weight: 700; 3723 } 3724 /* ââ Mermaid Viewer: overlay & shell ââââââââââââââââââââââââââ */ 3725 .mermaid-viewer { 3726 position: fixed; 3727 inset: 0; 3728 z-index: 70; 3729 display: flex; 3730 align-items: center; 3731 justify-content: center; 3732 padding: 0; 3733 background: rgba(0, 0, 0, 0.35);
3734 -webkit-backdrop-filter: blur(24px) saturate(1.05); 3735 backdrop-filter: blur(24px) saturate(1.05); 3736 animation: mv-overlay-in 220ms ease-out; 3737 } 3738 @keyframes mv-overlay-in { 3739 from { 3740 opacity: 0; 3741 } 3742 to { 3743 opacity: 1; 3744 } 3745 } 3746 @keyframes mv-shell-in { 3747 from { 3748 opacity: 0; 3749 transform: scale(0.985) translateY(4px); 3750 } 3751 to { 3752 opacity: 1; 3753 transform: scale(1) translateY(0); 3754 } 3755 } 3756 .mermaid-viewer[hidden] { 3757 display: none; 3758 } 3759 .mermaid-viewer-shell { 3760 display: flex; 3761 flex-direction: column; 3762 width: 100vw; 3763 height: 100vh; 3764 background: var(--bg-canvas); 3765 overflow: hidden; 3766 animation: mv-shell-in 220ms ease-out; 3767 } 3768 3769 /* ââ Header: compact floating toolbar ââââââââââââââââââââââââ */ 3770 .mermaid-viewer-header { 3771 display: flex; 3772 align-items: center; 3773 justify-content: space-between; 3774 gap: 12px; 3775 padding: 8px 16px; 3776 border-bottom: 1px solid var(--border-soft); 3777 background: var(--bg-raised); 3778 } 3779 .mermaid-viewer-title-wrap { 3780 display: flex; 3781 align-items: center; 3782 gap: 10px; 3783 min-width: 0; 3784 } 3785 .mermaid-viewer-title { 3786 margin: 0; 3787 font-family: var(--font-sans); 3788 font-size: 0.82rem; 3789 font-weight: 600; 3790 letter-spacing: 0.01em; 3791 white-space: nowrap; 3792 } 3793 .mermaid-viewer-help { 3794 display: none; 3795 } 3796 3797 /* ââ Toolbar: icon-only pill buttons âââââââââââââââââââââââââ */ 3798 .mermaid-viewer-actions { 3799 display: flex; 3800 align-items: center; 3801 gap: 2px; 3802 flex-shrink: 0; 3803 } 3804 .mermaid-viewer-sep { 3805 width: 1px; 3806 height: 18px; 3807 margin: 0 6px; 3808 background: var(--border-soft); 3809 flex-shrink: 0; 3810 } 3811 .mermaid-viewer-btn { 3812 display: inline-flex; 3813 align-items: center; 3814 justify-content: center; 3815 width: 32px; 3816 height: 32px; 3817 padding: 0; 3818 border: none; 3819 border-radius: 8px; 3820 background: transparent; 3821 color: var(--fg-muted); 3822 font: inherit; 3823 font-size: 0.78rem; 3824 cursor: pointer; 3825 transition: 3826 background 120ms ease, 3827 color 120ms ease; 3828 position: relative; 3829 } 3830 .mermaid-viewer-btn:hover { 3831 background: var(--bg-subtle); 3832 color: var(--fg-default); 3833 } 3834 .mermaid-viewer-btn:active { 3835 background: var(--border-soft); 3836 } 3837 .mermaid-viewer-btn:focus-visible { 3838 outline: 2px solid var(--accent-fg); 3839 outline-offset: 1px; 3840 } 3841 .mermaid-viewer-btn svg { 3842 width: 16px; 3843 height: 16px; 3844 flex: none; 3845 stroke-width: 1.75; 3846 } 3847 .mermaid-viewer-btn .mv-btn-label { 3848 position: absolute; 3849 width: 1px; 3850 height: 1px; 3851 overflow: hidden; 3852 clip: rect(0, 0, 0, 0); 3853 white-space: nowrap; 3854 } 3855 .mermaid-viewer-btn--text { 3856 width: auto; 3857 padding: 0 8px; 3858 font-weight: 500; 3859 font-size: 0.75rem; 3860 letter-spacing: 0.01em; 3861 } 3862 .mermaid-viewer-btn--close { 3863 width: 32px; 3864 height: 32px; 3865 border-radius: 8px; 3866 } 3867 .mermaid-viewer-btn--close:hover { 3868 background: rgba(180, 80, 80, 0.08); 3869 color: #b05050; 3870 } 3871 .mermaid-viewer-zoom-label { 3872 min-width: 40px; 3873 text-align: center; 3874 font-size: 0.73rem; 3875 font-weight: 600; 3876 font-variant-numeric: tabular-nums; 3877 color: var(--fg-muted); 3878 letter-spacing: 0.02em; 3879 user-select: none; 3880 } 3881 3882 /* ââ Stage: dot-grid canvas ââââââââââââââââââââââââââââââââââ */ 3883 .mermaid-viewer-stage { 3884 position: relative; 3885 flex: 1; 3886 overflow: auto; 3887 padding: 32px; 3888 background-color: var(--bg-canvas); 3889 background-image: radial-gradient( 3890 circle, 3891 color-mix(in srgb, var(--fg-muted) 12%, transparent) 1p
3891x, 3892 transparent 1px 3893 ); 3894 background-size: 20px 20px; 3895 cursor: grab; 3896 user-select: none; 3897 touch-action: none; 3898 } 3899 .mermaid-viewer-stage.is-dragging { 3900 cursor: grabbing; 3901 } 3902 .mermaid-viewer-content { 3903 display: flex; 3904 align-items: flex-start; 3905 justify-content: center; 3906 width: max-content; 3907 min-width: 100%; 3908 min-height: 100%; 3909 padding: 24px; 3910 } 3911 .mermaid-viewer-zoomable { 3912 transform-origin: top left; 3913 will-change: transform; 3914 } 3915 .mermaid-viewer-zoomable svg { 3916 display: block; 3917 max-width: none; 3918 height: auto; 3919 background: var(--bg-raised); 3920 border: 1px solid var(--border-soft); 3921 border-radius: 12px; 3922 box-shadow: 3923 0 1px 3px rgba(0, 0, 0, 0.06), 3924 0 8px 24px rgba(0, 0, 0, 0.08); 3925 } 3926 3927 /* ââ Footer: minimal status bar ââââââââââââââââââââââââââââââ */ 3928 .mermaid-viewer-footer { 3929 display: flex; 3930 align-items: center; 3931 justify-content: space-between; 3932 gap: 12px; 3933 padding: 6px 16px; 3934 border-top: 1px solid var(--border-soft); 3935 color: var(--fg-muted); 3936 font-size: 0.72rem; 3937 letter-spacing: 0.01em; 3938 background: var(--bg-raised); 3939 } 3940 .mermaid-viewer-footer kbd { 3941 display: inline-block; 3942 padding: 1px 5px; 3943 font-family: var(--font-mono); 3944 font-size: 0.82em; 3945 color: var(--fg-default); 3946 background: var(--bg-canvas); 3947 border: 1px solid var(--border-default); 3948 border-bottom-width: 2px; 3949 border-radius: 3px; 3950 line-height: 1.4; 3951 } 3952 3953 /* ââ Responsive ââââââââââââââââââââââââââââââââââââââââââââââ */ 3954 @media (max-width: 900px) { 3955 .mermaid-viewer-header { 3956 padding: 6px 10px; 3957 } 3958 .mermaid-viewer-footer { 3959 padding: 5px 10px; 3960 } 3961 .mermaid-viewer-stage { 3962 padding: 16px; 3963 } 3964 .mermaid-viewer-btn { 3965 width: 30px; 3966 height: 30px; 3967 } 3968 .mermaid-viewer-sep { 3969 margin: 0 4px; 3970 } 3971 } 3972 @media (max-width: 520px) { 3973 .mermaid-viewer-title { 3974 font-size: 0.76rem; 3975 } 3976 .mermaid-viewer-footer span:first-child { 3977 display: none; 3978 } 3979 } 3980 3981 /* ============================================================ 3982 Mindmap Viewer 3983 ============================================================ */ 3984 3985 .mindmap-viewer { 3986 position: fixed; 3987 inset: 0; 3988 z-index: 1200; 3989 display: flex; 3990 align-items: stretch; 3991 justify-content: stretch; 3992 background: color-mix(in srgb, var(--bg-canvas) 94%, transparent); 3993 backdrop-filter: blur(10px); 3994 -webkit-backdrop-filter: blur(10px); 3995 opacity: 0; 3996 pointer-events: none; 3997 transition: opacity 240ms cubic-bezier(0.16, 1, 0.3, 1); 3998 } 3999 4000 .mindmap-viewer[aria-hidden="false"] { 4001 opacity: 1; 4002 pointer-events: auto; 4003 } 4004 4005 .mindmap-viewer-shell { 4006 position: relative; 4007 flex: 1; 4008 display: flex; 4009 flex-direction: column; 4010 } 4011 4012 .mindmap-viewer-header { 4013 display: flex; 4014 align-items: flex-start; 4015 justify-content: space-between; 4016 padding: 28px 40px 18px; 4017 gap: 32px; 4018 } 4019 4020 .mindmap-viewer-title-wrap { 4021 display: flex; 4022 flex-direction: column; 4023 gap: 4px; 4024 min-width: 0; 4025 } 4026 4027 .mindmap-viewer-title { 4028 margin: 0; 4029 font-family: var(--font-serif); 4030 font-size: 22px; 4031 font-weight: 500; 4032 letter-spacing: -0.01em; 4033 color: var(--fg-default); 4034 } 4035 4036 .mindmap-viewer-help { 4037 margin: 0; 4038 font-family: var(--font-sans); 4039 font-size: 12px; 4040 color: var(--fg-muted); 4041 letter-spacing: 0.01em; 4042 } 4043 4044 .mindmap-viewer-actions { 4045 display: flex; 4046 align-items: center; 4047 gap: 8px; 4048 } 4049 4050 .mindmap-viewer-btn { 4051 display: inline-flex; 4052 align-items: center; 4053 gap: 6px; 4054 height: 32px; 4055 padding: 0 12px; 4056 font: 500 12px/1 var(--font-sans); 4057 color: var(--fg-muted); 4058 background: transparent; 4059 border: 1px solid var(--border-default); 4060 border-radius: 8px; 4061 cursor: pointer; 4062 transition: 4063 color 150ms, 4064 border-color 150ms, 4065 background 150ms; 4066 } 4067 4068 .mindmap-viewer-btn:hover { 4069 color: var(--fg-default); 4070 border-color: var(--border-strong); 4071 background: var(--bg-subtle); 4072 } 4073 4074 .mindmap-viewer-btn svg { 4075 width: 14px; 4076 height: 14px; 4077 } 4078 4079 .mindmap-viewer-zoom-label { 4080 min-width: 44px; 4081 text-align: center; 4082 font: 500 11px/1 var(--font-sans); 4083 color: var(--fg-muted); 4084 font-variant-numeric: tabular-nums; 4085 } 4086 4087 .mindmap-viewer-stage { 4088 position: relative; 4089 flex: 1; 4090 overflow: hidden; 4091 background: var(--mm-canvas-bg); 4092 cursor: grab; 4093 } 4094 4095 .mindmap-viewer-stage.is-panning { 4096 cursor: grabbing; 4097 } 4098 4099 .mindmap-svg { 4100 width: 100%; 4101 height: 100%; 4102 display: block; 4103 user-select: none; 4104 -webkit-user-select: none; 4105 } 4106 4107 .mindmap-svg-root { 4108 transform-origin: 0 0; 4109 transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1); 4110 } 4111 4112 .mindmap-svg-root.is-interactive { 4113 transition: none; 4114 } 4115 4116 /* ââ Nodes ââââââââââââââââââââââââââââââââââââââââââââââââ */ 4117 4118 .mm-node { 4119 cursor: default; 4120 transition: transform 200ms cubic-bezier(0.34, 1.3, 0.64, 1); 4121 transform-box: fill-box; 4122 transform-origin: center; 4123 } 4124 4125 /* Mindmap fan-out on open â nodes emerge with a staggered
4126 fade+defocus. Chose opacity+blur over scale to avoid 4127 fighting the SVG positioning transforms. Stagger by 4128 data-depth so the root node lands first, rings follow. 4129 Respects prefers-reduced-motion via the global reset. */ 4130 body.mindmap-open .mm-node { 4131 animation: mindmap-fan-out var(--motion-page) var(--ease-out) both; 4132 } 4133 body.mindmap-open .mm-node[data-depth="0"] { 4134 animation-delay: 0ms; 4135 } 4136 body.mindmap-open .mm-node[data-depth="1"] { 4137 animation-delay: 90ms; 4138 } 4139 body.mindmap-open .mm-node[data-depth="2"] { 4140 animation-delay: 180ms; 4141 } 4142 body.mindmap-open .mm-node[data-depth="3"] { 4143 animation-delay: 260ms; 4144 } 4145 body.mindmap-open .mm-node[data-depth="4"] { 4146 animation-delay: 320ms; 4147 } 4148 4149 @keyframes mindmap-fan-out { 4150 from { 4151 opacity: 0; 4152 filter: blur(3px); 4153 } 4154 to { 4155 opacity: 1; 4156 filter: blur(0); 4157 } 4158 } 4159 4160 /* Mindmap edges follow slightly behind the nodes so the 4161 structure assembles gracefully rather than flashing in. */ 4162 body.mindmap-open .mindmap-edge, 4163 body.mindmap-open path.mm-edge { 4164 animation: mindmap-edge-enter var(--motion-page) var(--ease-out) 160ms 4165 both; 4166 } 4167 @keyframes mindmap-edge-enter { 4168 from { 4169 opacity: 0; 4170 } 4171 to { 4172 opacity: var(--mm-edge-opacity-primary, 0.4); 4173 } 4174 } 4175 4176 .mm-node.is-interactive { 4177 cursor: pointer; 4178 } 4179 4180 .mm-node-card { 4181 fill: var(--mm-node-bg); 4182 stroke: var(--mm-node-border); 4183 stroke-width: 1; 4184 filter: var(--mm-node-filter); 4185 transition: 4186 stroke 180ms, 4187 filter 220ms; 4188 } 4189 4190 .mm-node.is-interactive:hover .mm-node-card { 4191 stroke: var(--mm-node-border-hover); 4192 filter: var(--mm-node-filter-hover); 4193 } 4194 4195 .mm-node.is-selected .mm-node-card { 4196 fill: var(--mm-node-bg-selected); 4197 stroke: var(--mm-node-border-hover); 4198 } 4199 4200 .mm-node.is-focused .mm-node-card { 4201 fill: var(--mm-node-bg-selected); 4202 stroke: var(--accent-fg); 4203 stroke-width: 1.5; 4204 filter: var(--mm-node-filter-hover); 4205 } 4206 4207 .mm-node.is-related .mm-node-card { 4208 stroke: color-mix(in srgb, var(--accent-fg) 48%, var(--mm-node-border)); 4209 } 4210 4211 .mm-node.is-muted .mm-node-card, 4212 .mm-node.is-muted .mm-node-typebar, 4213 .mm-node.is-muted .mm-node-label, 4214 .mm-node.is-muted .mm-node-badge { 4215 opacity: 0.24; 4216 } 4217 4218 .mm-node.is-interactive:focus-visible .mm-node-card { 4219 stroke: var(--accent-fg); 4220 stroke-width: 2; 4221 outline: none; 4222 } 4223 4224 .mm-node-typebar { 4225 /* color set inline per node type via CSS var */ 4226 } 4227 4228 .mm-node-label { 4229 font-family: var(--mm-label-serif); 4230 fill: var(--fg-default); 4231 dominant-baseline: middle; 4232 pointer-events: none; 4233 } 4234 4235 .mm-node-badge { 4236 font-family: var(--mm-badge-sans); 4237 font-size: var(--mm-badge-size); 4238 font-weight: 500; 4239 letter-spacing: 0.12em; 4240 text-transform: uppercase; 4241 fill: var(--fg-muted); 4242 dominant-baseline: middle; 4243 pointer-events: none; 4244 } 4245 4246 .mm-node[data-depth="0"] .mm-node-label { 4247 font-size: var(--mm-root-label-size); 4248 font-weight: 500; 4249 letter-spacing: 0.08em; 4250 text-transform: uppercase; 4251 } 4252 4253 .mm-node[data-depth="1"] .mm-node-label { 4254 font-size: var(--mm-primary-label-size); 4255 font-weight: 500; 4256 } 4257 4258 .mm-node[data-depth="2"] .mm-node-label, 4259 .mm-node[data-depth="3"] .mm-node-label { 4260 font-size: var(--mm-secondary-label-size); 4261 font-weight: 400; 4262 } 4263 4264 /* ââ Edges ââââââââââââââââââââââââââââââââââââââââââââââââ */ 4265 4266 .mm-edge { 4267 fill: none; 4268 stroke: var(--mm-edge-stroke); 4269 stroke-linecap: round; 4270 stroke-linejoin: round; 4271 pointer-events: none; 4272 } 4273 4274 .mm-edge[data-depth="1"] { 4275 stroke-width: 2; 4276 stroke-opacity: var(--mm-edge-opacity-primary); 4277 } 4278 4279 .mm-edge[data-depth="2"] { 4280 stroke-width: 1.5; 4281 stroke-opacity: var(--mm-edge-opacity-secondary); 4282 } 4283 4284 .mm-edge[data-depth="3"] { 4285 stroke-width: 1; 4286 stroke-opacity: var(--mm-edge-opacity-tertiary); 4287 } 4288 4289 .mm-edge.is-active { 4290 stroke: var(--accent-fg); 4291 stroke-opacity: 0.7; 4292 stroke-width: 2.25; 4293 } 4294 4295 .mm-edge.is-muted { 4296 stroke-opacity: 0.06; 4297 } 4298 4299 /* ââ Tooltip card (hover detail) ââââââââââââââââââââââââââ */ 4300 4301 .mm-tooltip { 4302 position: absolute; 4303 max-width: 280px; 4304 padding: 14px 18px; 4305 background: var(--bg-raised); 4306 border: 1px solid var(--border-default); 4307 border-radius: 10px; 4308 box-shadow: var(--shadow-md); 4309 pointer-events: none; 4310 opacity: 0; 4311 transform: translateY(4px); 4312 transition: 4313 opacity 140ms, 4314 transform 140ms; 4315 z-index: 5; 4316 } 4317 4318 .mm-tooltip.is-visible { 4319 opacity: 1; 4320 transform: translateY(0); 4321 } 4322 4323 .mm-tooltip-type { 4324 display: block; 4325 font: 500 10px/1 var(--font-sans); 4326 letter-spacing: 0.12em; 4327 text-transform: uppercase; 4328 color: var(--fg-muted); 4329 margin-bottom: 6px; 4330 } 4331 4332 .mm-tooltip-label { 4333 display: block; 4334 font: 500 15px/1.3 var(--font-serif); 4335 color: var(--fg-default); 4336 margin-bottom: 6px; 4337 } 4338 4339 .mm-tooltip-summary { 4340 display: block; 4341 font: 400 13px/1.55 var(--font-serif); 4342 color: var(--fg-muted); 4343 } 4344 4345 /* ââ Search and focus inspector âââââââââââââââââââââââââââ */ 4346 4347 .mindmap-search { 4348 position: absolute; 4349 top: 18px; 4350 left: 24px; 4351 z-index: 6; 4352 width: min(320px, calc(100% - 48px)); 4353 font-family: var(--font-sans); 4354 } 4355 4356 .mindmap-search-input { 4357 width: 100%; 4358 height: 36px; 4359 padding: 0 12px 0 34px; 4360 color: var(--fg-default); 4361 background: color-mix(in srgb, var(--bg-raised) 92%, transparent); 4362 border: 1px solid var(--border-default); 4363 border-radius: 8px; 4364 box-shadow: var(--shadow-sm); 4365 outline: none; 4366 font: 500 12px/1 var(--font-sans); 4367 } 4368 4369 .mindmap-search-input::placeholder { 4370 color: var(--fg-subtle); 4371 } 4372 4373 .mindmap-search-input:focus { 4374 border-color: var(--accent-fg); 4375 box-shadow: 0 0 0 3px 4376 color-mix(in srgb, var(--accent-fg) 16%, transparent); 4377 } 4378 4379 .mindmap-search-icon { 4380 position: absolute; 4381 left: 12px; 4382 top: 11px; 4383 width: 14px; 4384 height: 14px; 4385 color: var(--fg-muted); 4386 pointer-events: none; 4387 } 4388 4389 .mindmap-search-results { 4390 position: absolute; 4391 left: 0; 4392 right: 0; 4393 top: calc(100% + 6px); 4394 max-height: min(320px, calc(100vh - 260px)); 4395 overflow-y: auto; 4396 padding: 6px; 4397 background: var(--bg-raised); 4398 border: 1px solid var(--border-default); 4399 border-radius: 8px; 4400 box-shadow: var(--shadow-md); 4401 } 4402 4403 .mindmap-search-results.is-hidden { 4404 display: none; 4405 } 4406 4407 .mindmap-search-result { 4408 display: grid; 4409 grid-template-columns: 6px 1fr; 4410 gap: 10px; 4411 width: 100%; 4412 padding: 9px 10px; 4413 color: var(--fg-default); 4414 background: transparent; 4415 border: 0; 4416 border-radius: 6px; 4417 text-align: left; 4418 cursor: pointer; 4419 } 4420 4421 .mindmap-search-result:hover, 4422 .mindmap-search-result:focus-visible { 4423 background: var(--bg-subtle); 4424 outline: none; 4425 } 4426 4427 .mindmap-search-result-dot { 4428 width: 6px; 4429 height: 6px; 4430 margin-top: 6px; 4431 border-radius: 50%; 4432 } 4433 4434 .mindmap-search-result-label { 4435 display: block; 4436 font: 500 13px/1.25 var(--font-serif); 4437 } 4438 4439 .mindmap-search-result-type { 4440 display: block; 4441 margin-top: 3px; 4442 color: var(--fg-muted); 4443 font: 600 9px/1 var(--font-sans); 4444 letter-spacing: 0.12em; 4445 text-transform: uppercase; 4446 } 4447 4448 .mindmap-inspector { 4449 position: absolute; 4450 top: 18px; 4451 right: 24px; 4452 z-index: 6; 4453 width: min(340px, calc(100% - 48px)); 4454 max-height: calc(100% - 36px); 4455 overflow: auto; 4456 padding: 18px; 4457 color: var(--fg-default); 4458 background: color-mix(in srgb, var(--bg-raised) 94%, transparent); 4459 border: 1px solid var(--border-default); 4460 border-radius: 8px; 4461 box-shadow: var(--shadow-md); 4462 font-family: var(--font-sans); 4463 opacity: 0; 4464 pointer-events: none; 4465 transform: translateY(6px); 4466 transition: 4467 opacity 160ms, 4468 transform 160ms; 4469 } 4470 4471 .mindmap-inspector.is-visible { 4472 opacity: 1; 4473 pointer-events: auto; 4474 transform: translateY(0); 4475 } 4476 4477 .mindmap-inspector-header { 4478 display: flex; 4479 align-items: flex-start; 4480 justify-content: space-between; 4481 gap: 12px; 4482 } 4483 4484 .mindmap-inspector-type { 4485 display: flex; 4486 align-items: center; 4487 gap: 7px; 4488 color: var(--fg-muted); 4489 font: 600 10px/1 var(--font-sans); 4490 letter-spacing: 0.12em; 4491 text-transform: uppercase; 4492 } 4493 4494 .mindmap-inspector-dot { 4495 width: 7px; 4496 height: 7px; 4497 border-radius: 50%; 4498 } 4499 4500 .mindmap-inspector-title { 4501 margin: 10px 0 0; 4502 color: var(--fg-default); 4503 font: 500 19px/1.25 var(--font-serif); 4504 } 4505 4506 .mindmap-inspector-close { 4507 display: inline-flex; 4508 align-items: center; 4509 justify-content: center; 4510 width: 28px; 4511 height: 28px; 4512 color: var(--fg-muted); 4513 background: transparent; 4514 border: 1px solid var(--border-default); 4515 border-radius: 8px; 4516 cursor: pointer; 4517 } 4518 4519 .mindmap-inspector-close:hover, 4520 .mindmap-inspector-close:focus-visible { 4521 color: var(--fg-default); 4522 background: var(--bg-subtle); 4523 outline: none; 4524 } 4525 4526 .mindmap-inspector-summary { 4527 margin: 12px 0 0; 4528 color: var(--fg-muted); 4529 font: 400 13px/1.55 var(--font-serif); 4530 } 4531 4532 .mindmap-inspector-actions { 4533 display: flex; 4534 gap: 8px; 4535 margin-top: 16px; 4536 } 4537 4538 .mindmap-inspector-action { 4539 display: inline-flex; 4540 align-items: center; 4541 justify-content: center; 4542 gap: 6px; 4543 height: 34px; 4544 padding: 0 12px; 4545 color: var(--bg-canvas); 4546 background: var(--fg-default); 4547 border: 0; 4548 border-radius: 8px; 4549 font: 600 12px/1 var(--font-sans); 4550 cursor: pointer; 4551 } 4552 4553 .mindmap-inspector-action:hover, 4554 .mindmap-inspector-action:focus-visible { 4555 background: var(--accent-fg); 4556 outline: none; 4557 } 4558 4559 .mindmap-inspector-section { 4560 margin-top: 18px; 4561 padding-top: 14px; 4562 border-top: 1px solid var(--border-soft); 4563 } 4564 4565 .mindmap-inspector-section-title { 4566 margin: 0 0 8px; 4567 color: var(--fg-muted); 4568 font: 600 10px/1 var(--font-sans); 4569 letter-spacing: 0.12em; 4570 text-transform: uppercase; 4571 } 4572 4573 .mindmap-connection-list { 4574 display: grid; 4575 gap: 6px; 4576 } 4577 4578 .mindmap-connection { 4579 display: grid; 4580 grid-template-columns: 6px 1fr; 4581 gap: 10px; 4582 width: 100%; 4583 padding: 8px; 4584 color: var(--fg-default); 4585 background: transparent; 4586 border: 0; 4587 border-radius: 6px; 4588 text-align: left; 4589 cursor: pointer; 4590 } 4591 4592 .mindmap-connection:hover, 4593 .mindmap-connection:focus-visible { 4594 background: var(--bg-subtle); 4595 outline: none; 4596 } 4597 4598 .mindmap-connection-dot { 4599 width: 6px; 4600 height: 6px; 4601 margin-top: 6px; 4602 border-radius: 50%; 4603 } 4604 4605 .mindmap-connection-label { 4606 display: block; 4607 overflow: hidden; 4608 text-overflow: ellipsis; 4609 white-space: nowrap; 4610 font: 500 13px/1.25 var(--font-serif); 4611 } 4612 4613 .mindmap-connection-meta { 4614 display: block; 4615 margin-top: 3px; 4616 color: var(--fg-muted); 4617 font: 600 9px/1 var(--font-sans); 4618 letter-spacing: 0.1em; 4619 text-transform: uppercase; 4620 } 4621 4622 @media (max-width: 860px) { 4623 .mindmap-search { 4624 top: 12px; 4625 left: 12px; 4626 width: min(300px, calc(100% - 24px)); 4627 } 4628 4629 .mindmap-inspector { 4630 top: auto; 4631 right: 12px; 4632 bottom: 12px; 4633 left: 12px; 4634 width: auto; 4635 max-height: 42%; 4636 } 4637 } 4638 4639 /* ââ Entry motion âââââââââââââââââââââââââââââââââââââââââ */ 4640 4641 .mindmap-viewer[aria-hidden="false"] .mm-node {
4642 animation: mm-node-in 560ms cubic-bezier(0.16, 1, 0.3, 1) both; 4643 animation-delay: calc(var(--mm-stagger, 0) * 60ms); 4644 } 4645 4646 .mindmap-viewer[aria-hidden="false"] .mm-edge { 4647 animation: mm-edge-in 480ms cubic-bezier(0.16, 1, 0.3, 1) both; 4648 animation-delay: calc(var(--mm-stagger, 0) * 60ms + 120ms); 4649 } 4650 4651 @keyframes mm-node-in { 4652 from { 4653 opacity: 0; 4654 transform: translateY(8px); 4655 } 4656 to { 4657 opacity: 1; 4658 transform: translateY(0); 4659 } 4660 } 4661 4662 @keyframes mm-edge-in { 4663 from { 4664 opacity: 0; 4665 } 4666 to { 4667 opacity: 1; 4668 } 4669 } 4670 4671 @media (prefers-reduced-motion: reduce) { 4672 .mindmap-viewer[aria-hidden="false"] .mm-node, 4673 .mindmap-viewer[aria-hidden="false"] .mm-edge { 4674 animation: none; 4675 } 4676 } 4677 4678 /* ââ Loading / empty states âââââââââââââââââââââââââââââââ */ 4679 4680 .mm-state-wrap { 4681 position: absolute; 4682 inset: 0; 4683 display: flex; 4684 align-items: center; 4685 justify-content: center; 4686 flex-direction: column; 4687 gap: 16px; 4688 color: var(--fg-muted); 4689 font-family: var(--font-sans); 4690 } 4691 4692 .mm-state-wrap.is-hidden { 4693 display: none; 4694 } 4695 4696 .mm-spinner { 4697 width: 20px; 4698 height: 20px; 4699 border: 2px solid var(--border-default); 4700 border-top-color: var(--accent-fg); 4701 border-radius: 50%; 4702 animation: mm-spin 900ms linear infinite; 4703 } 4704 4705 @keyframes mm-spin { 4706 to { 4707 transform: rotate(360deg); 4708 } 4709 } 4710 4711 .mm-state-label { 4712 font-size: 13px; 4713 letter-spacing: 0.02em; 4714 } 4715 4716 .mm-state-retry { 4717 display: inline-flex; 4718 align-items: center; 4719 gap: 6px; 4720 height: 32px; 4721 padding: 0 14px; 4722 font: 500 12px/1 var(--font-sans); 4723 color: var(--accent-fg); 4724 background: transparent; 4725 border: 1px solid var(--border-default); 4726 border-radius: 8px; 4727 cursor: pointer; 4728 transition: 4729 color 150ms, 4730 border-color 150ms, 4731 background 150ms; 4732 } 4733 4734 .mm-state-retry:hover { 4735 background: var(--bg-subtle); 4736 border-color: var(--accent-fg); 4737 } 4738 4739 /* ââ Legend (semantic type key) âââââââââââââââââââââââââââ */ 4740 4741 .mindmap-legend { 4742 display: flex; 4743 flex-wrap: wrap; 4744 gap: 10px 18px; 4745 padding: 0 40px 16px; 4746 border-bottom: 1px solid var(--border-soft); 4747 } 4748 4749 .mindmap-legend-chip { 4750 display: inline-flex; 4751 align-items: center; 4752 gap: 6px; 4753 font: 500 10px/1 var(--font-sans); 4754 letter-spacing: 0.12em; 4755 text-transform: uppercase; 4756 color: var(--fg-muted); 4757 } 4758 4759 .mindmap-legend-dot { 4760 width: 6px; 4761 height: 6px; 4762 border-radius: 50%; 4763 display: inline-block; 4764 } 4765 4766 @media (max-width: 720px) { 4767 .mindmap-legend { 4768 padding: 0 20px 12px; 4769 gap: 8px 14px; 4770 } 4771 } 4772 4773 @media (max-width: 720px) { 4774 .mindmap-viewer-header { 4775 padding: 20px 20px 14px; 4776 flex-direction: column; 4777 align-items: stretch; 4778 gap: 14px; 4779 } 4780 .mindmap-viewer-actions { 4781 flex-wrap: wrap; 4782 } 4783 .mindmap-viewer-btn span { 4784 display: none; 4785 } 4786 } 4787 4788 /* ââ Screen-reader-only concept list ââââââââââââââââââââââ */ 4789 4790 .mindmap-sr-only { 4791 position: absolute; 4792 width: 1px; 4793 height: 1px; 4794 padding: 0; 4795 margin: -1px; 4796 overflow: hidden; 4797 clip: rect(0, 0, 0, 0); 4798 white-space: nowrap; 4799 border: 0; 4800 } 4801 4802 .mm-jump-flash { 4803 animation: mm-flash 1800ms ease-out; 4804 } 4805 4806 @keyframes mm-flash { 4807 0% { 4808 background-color: color-mix( 4809 in srgb, 4810 var(--accent-fg) 22%, 4811 transparent 4812 ); 4813 } 4814 40% { 4815 background-color: color-mix( 4816 in srgb, 4817 var(--accent-fg) 14%, 4818 transparent 4819 ); 4820 } 4821 100% { 4822 background-color: transparent; 4823 } 4824 } 4825 4826 @media (prefers-reduced-motion: reduce) { 4827 .mm-jump-flash { 4828 animation: none; 4829 } 4830 } 4831 4832 .markdown-body .raw-html { 4833 border-style: dashed; 4834 opacity: 0.85; 4835 } 4836 4837 /* Tables */ 4838 .markdown-body table { 4839 margin: 1.5em 0; 4840 display: block; 4841 width: max-content; 4842 max-width: 100%; 4843 overflow: auto; 4844 border-spacing: 0; 4845 border-collapse: collapse; 4846 font-size: 0.92em; 4847 font-family: var(--font-sans); 4848 } 4849 .markdown-body table th, 4850 .markdown-body table td { 4851 padding: 10px 16px; 4852 border-bottom: 1px solid var(--border-soft); 4853 text-align: left; 4854 } 4855 .markdown-body table th { 4856 font-weight: 600; 4857 color: var(--fg-muted); 4858 text-transform: uppercase; 4859 letter-spacing: 0.04em; 4860 font-size: 0.82em; 4861 border-bottom: 1px solid var(--border-strong); 4862 } 4863 .markdown-body table tr:last-child td { 4864 border-bottom: 0; 4865 } 4866 4867 .markdown-body .csv-sheet-wrap { 4868 margin: 1.5em 0; 4869 max-width: 100%; 4870 overflow: auto; 4871 border: 1px solid 4872 var(--reader-border-default, var(--border-default)); 4873 border-radius: 8px; 4874 background: var(--reader-bg-surface, var(--bg-raised)); 4875 } 4876 4877 .markdown-body .csv-sheet { 4878 display: table; 4879 width: max-content; 4880 min-width: 100%; 4881 max-width: none; 4882 margin: 0; 4883 overflow: visible; 4884 border-collapse: separate; 4885 border-spacing: 0; 4886 font-family: var(--font-sans); 4887 font-size: 0.88em; 4888 } 4889 4890 .markdown-body .csv-sheet th, 4891 .markdown-body .csv-sheet td { 4892 min-width: 9ch; 4893 max-width: 28ch; 4894 padding: 8px 12px; 4895 border-right: 1px solid 4896 var(--reader-border-soft, var(--border-soft)); 4897 border-bottom: 1px solid 4898 var(--reader-border-soft, var(--border-soft)); 4899 color: var(--reader-fg-default, var(--fg-default)); 4900 text-align: left; 4901 text-transform: none; 4902 letter-spacing: 0; 4903 vertical-align: top; 4904 white-space: normal;
4905 overflow-wrap: anywhere; 4906 } 4907 4908 .markdown-body .csv-sheet thead th { 4909 position: sticky; 4910 top: 0; 4911 z-index: 1; 4912 border-bottom-color: var( 4913 --reader-border-default, 4914 var(--border-default) 4915 ); 4916 background: var(--reader-bg-subtle, var(--bg-subtle)); 4917 color: var(--reader-fg-default, var(--fg-default)); 4918 font-size: 0.82em; 4919 font-weight: 600; 4920 } 4921 4922 .markdown-body .csv-sheet tbody th { 4923 width: 1%; 4924 min-width: 4ch; 4925 max-width: 6ch; 4926 background: var(--reader-bg-subtle, var(--bg-subtle)); 4927 color: var(--reader-fg-muted, var(--fg-muted)); 4928 font-size: 0.82em; 4929 font-weight: 500; 4930 } 4931 4932 .markdown-body .csv-sheet tr:last-child th, 4933 .markdown-body .csv-sheet tr:last-child td { 4934 border-bottom: 0; 4935 } 4936 4937 .markdown-body .csv-sheet th:last-child, 4938 .markdown-body .csv-sheet td:last-child { 4939 border-right: 0; 4940 } 4941 4942 .markdown-body .csv-sheet td:empty::after { 4943 content: "\00a0"; 4944 } 4945 4946 .markdown-body .csv-sheet-note { 4947 margin: -0.9em 0 1.5em; 4948 color: var(--reader-fg-muted, var(--fg-muted)); 4949 font-family: var(--font-sans); 4950 font-size: 0.86em; 4951 } 4952 4953 /* Media */ 4954 .markdown-body img, 4955 .markdown-body video { 4956 display: block; 4957 max-width: 100%; 4958 margin: 1.5em auto; 4959 border-radius: 8px; 4960 box-shadow: var(--shadow-md); 4961 } 4962 .markdown-body video { 4963 width: min(100%, 960px); 4964 background: #000; 4965 } 4966 .markdown-body .blocked-resource { 4967 display: block; 4968 margin: 1.4em 0; 4969 padding: 12px 14px; 4970 font-family: var(--font-sans); 4971 font-size: 0.9rem; 4972 color: var(--fg-muted); 4973 background: var(--bg-subtle); 4974 border: 1px solid var(--border-soft); 4975 border-radius: 8px; 4976 } 4977 4978 /* Task lists */ 4979 .markdown-body ul > li.task-list-item { 4980 list-style: none; 4981 } 4982 .markdown-body .task-list-item-checkbox { 4983 display: inline-flex; 4984 align-items: center; 4985 justify-content: center; 4986 width: 0.98em; 4987 height: 0.98em; 4988 margin: 0.08em 0.62em 0 0; 4989 vertical-align: text-top; 4990 border: 1px solid var(--border-strong); 4991 border-radius: 0.24em; 4992 background: var(--bg-raised); 4993 color: transparent; 4994 box-shadow: var(--shadow-sm); 4995 } 4996 .markdown-body .task-list-item-checkbox.is-checked { 4997 border-color: var(--accent-fg); 4998 background: color-mix(in srgb, var(--accent-fg) 12%, var(--bg-raised)); 4999 color: var(--accent-fg); 5000 } 5001 .markdown-body .task-list-item-checkbox svg { 5002 width: 0.72em; 5003 height: 0.72em; 5004 display: block; 5005 } 5006 5007 /* kbd */ 5008 .markdown-body kbd { 5009 display: inline-block; 5010 padding: 2px 6px; 5011 font-family: var(--font-mono); 5012 font-size: 0.78em; 5013 color: var(--fg-default); 5014 background: var(--bg-raised); 5015 border: 1px solid var(--border-default); 5016 border-bottom-width: 2px; 5017 border-radius: 4px; 5018 } 5019 5020 /* Details/summary */ 5021 .markdown-body details { 5022 margin: 1.2em 0; 5023 padding: 12px 16px; 5024 background: var(--bg-subtle); 5025 border: 1px solid var(--border-soft); 5026 border-radius: 8px; 5027 } 5028 .markdown-body details summary { 5029 cursor: pointer; 5030 font-weight: 600; 5031 color: var(--fg-default); 5032 } 5033 .markdown-body details[open] summary { 5034 margin-bottom: 8px; 5035 } 5036 5037 /* ---------- Responsive ---------- 5038 Progressive degradation strategy: 5039 ⢠â¤1024px: hide verbose .status text (live-region still announces 5040 state to AT). Prevents the 769â950px "squeeze zone" where 5041 .status collapsed to 0 width, creating visual dead space. 5042 ⢠â¤768px: wrap toolbar, enlarge tap targets to WCAG 2.5.5 AA 5043 (44Ã44px) since this is the coarse-pointer territory. 5044 */ 5045 @media (max-width: 1024px) { 5046 .toolbar { 5047 flex-wrap: wrap; 5048 } 5049 .toolbar .status { 5050 display: none; 5051 } 5052 } 5053 5054 @media (max-width: 768px) { 5055 .toolbar { 5056 padding: 8px 14px; 5057 gap: 6px; 5058 flex-wrap: wrap; 5059 } 5060 /* WCAG 2.5.5 AA â minimum 44Ã44px tap target on touch-sized 5061 viewports. Also covers Apple HIG (44pt) and Material (48dp). */ 5062 .toolbar .btn, 5063 .toolbar .tb-segment .tb-seg-btn { 5064 min-height: 44px; 5065 min-width: 44px; 5066 } 5067 .toolbar .tb-segment { 5068 padding: 4px; 5069 } 5070 .tb-divider { 5071 display: none; 5072 } 5073 .brand { 5074 margin-right: 0; 5075 } 5076 .brand-name:not(.is-file):not(.is-untitled) { 5077 display: none; 5078 } 5079 .toolbar .status { 5080 display: block; 5081 order: 11; 5082 flex-basis: 100%; 5083 max-width: none; 5084 overflow: visible; 5085 white-space: normal; 5086 } 5087 .reading-panel { 5088 width: min(320px, calc(100vw - 32px)); 5089 } 5090 .preview-pane { 5091 padding: 40px 22px 96px; 5092 } 5093 #editor { 5094 padding: 32px 20px 96px; 5095 } 5096 .markdown-body h1 { 5097 font-size: 2em; 5098 } 5099 .markdown-body h2 { 5100 font-size: 1.5em; 5101 } 5102 } 5103 5104 @media (max-width: 820px) { 5105 .reading-settings .reading-panel, 5106 .ont-settings .ont-panel { 5107 position: fixed; 5108 top: 12px; 5109 right: 12px; 5110 bottom: auto; 5111 left: 12px; 5112 margin: 0 auto; 5113 width: min(420px, calc(100vw - 24px)); 5114 max-width: calc(100vw - 24px); 5115 height: fit-content; 5116 max-height: min(720px, calc(100dvh - 24px)); 5117 z-index: 9500; 5118 animation: panel-center-pop 220ms cubic-bezier(0.22, 1, 0.36, 1); 5119 animation-fill-mode: both; 5120 } 5121 .reading-settings .reading-panel { 5122 overflow-y: auto; 5123 overscroll-behavior: contain; 5124 } 5125 .ont-settings .ont-panel { 5126 overflow-y: auto; 5127 overscroll-behavior: contain; 5128 } 5129 @media (max-height: 640px) { 5130 .reading-settings .reading-panel { 5131 overflow-y: auto; 5132 overscroll-behavior: contain; 5133 } 5134 } 5135 @keyframes panel-center-pop { 5136 from { 5137 opacity: 0; 5138 transform: translateY(-8px); 5139 } 5140 to { 5141 opacity: 1; 5142 transform: translateY(0); 5143 } 5144 } 5145 .ont-settings .ont-header { 5146 position: sticky; 5147 top: 0; 5148 background: var(--bg-raised); 5149 border-bottom: 1px solid var(--border-soft); 5150 z-index: 3; 5151 } 5152 .reading-settings .reading-panel-header { 5153 position: sticky; 5154 top: -18px; 5155 margin: -18px -18px 16px; 5156 padding: 18px 18px 12px; 5157 background: var(--bg-raised); 5158 border-bottom: 1px solid var(--border-soft); 5159 z-index: 3; 5160 } 5161 .reading-width-control { 5162 display: none; 5163 } 5164 .reading-actions { 5165 flex-wrap: wrap; 5166 } 5167 .reading-actions .btn { 5168 flex: 1 1 140px; 5169 justify-content: center; 5170 } 5171 } 5172 5173 /* ---------- Ontology Insights Panel ---------- */ 5174 .ont-settings { 5175 position: relative; 5176 } 5177 #ontologyToggleBtn[aria-expanded="true"] { 5178 background: var(--bg-subtle); 5179 border-color: var(--border-strong); 5180 } 5181 .ont-panel { 5182 position: absolute; 5183 top: calc(100% + 6px); 5184 right: 0; 5185 z-index: 900; 5186 width: min(320px, calc(100vw - 32px)); 5187 max-height: min(480px, calc(100vh - 80px)); 5188 overflow-y: auto; 5189 background: var(--bg-raised); 5190 border: 1px solid var(--border-soft); 5191 border-radius: 14px; 5192 box-shadow: 0 12px 40px rgba(0, 0, 0, 0.06); 5193 font-family: var(--font-sans); 5194 font-size: 12px; 5195 color: var(--fg-default); 5196 animation: panel-enter 200ms ease-out; 5197 } 5198 :root[data-theme="dark"] .ont-panel { 5199 box-shadow: 0 12px 40px rgba(0, 0, 0, 0.32); 5200 } 5201 .ont-panel.hidden { 5202 display: none; 5203 } 5204 .ont-header { 5205 display: flex; 5206 align-items: center; 5207 justify-content: space-between; 5208 padding: 14px 16px 12px; 5209 border-bottom: 1px solid var(--border-soft); 5210 position: sticky; 5211 top: 0; 5212 background: var(--bg-raised); 5213 border-radius: 14px 14px 0 0; 5214 z-index: 1; 5215 } 5216 .ont-title { 5217 font-weight: 700; 5218 font-size: 12px; 5219 } 5220 .ont-summary { 5221 font-size: 10px; 5222 color: var(--fg-muted); 5223 } 5224 .ont-close { 5225 width: 22px; 5226 height: 22px; 5227 display: flex; 5228 align-items: center; 5229 justify-content: center; 5230 border-radius: 5px; 5231 border: 0; 5232 background: transparent; 5233 color: var(--fg-muted); 5234 cursor: pointer; 5235 font-size: 14px; 5236 line-height: 1; 5237 } 5238 .ont-close:hover { 5239 background: var(--bg-subtle); 5240 color: var(--fg-default); 5241 } 5242 .ont-section-label { 5243 font-size: 10px; 5244 font-weight: 700; 5245 text-transform: uppercase; 5246 letter-spacing: 0.8px; 5247 color: var(--fg-muted); 5248 padding: 10px 14px 6px; 5249 } 5250 .ont-section-row { 5251 display: flex; 5252 align-items: center; 5253 gap: 8px; 5254 padding: 6px 14px; 5255 border: 0; 5256 background: transparent; 5257 width: 100%; 5258 text-align: left; 5259 cursor: pointer; 5260 font-family: var(--font-sans); 5261 font-size: 12px; 5262 color: var(--fg-default); 5263 border-radius: 0; 5264 } 5265 .ont-section-row:hover { 5266 background: var(--bg-subtle); 5267 } 5268 .ont-section-row:focus-visible { 5269 outline: none; 5270 box-shadow: inset 0 0 0 2px var(--selection); 5271 } 5272 .ont-section-dot { 5273 width: 7px; 5274 height: 7px; 5275 border-radius: 50%; 5276 flex-shrink: 0; 5277 } 5278 .ont-section-name { 5279 flex: 1; 5280 font-weight: 500; 5281 white-space: nowrap; 5282 overflow: hidden; 5283 text-overflow: ellipsis; 5284 } 5285 .ont-section-badge { 5286 font-size: 9px; 5287 color: var(--fg-muted); 5288 padding: 1px 6px; 5289 border-radius: 8px; 5290 flex-shrink: 0; 5291 } 5292 .ont-section-badge.is-loading { 5293 width: 36px; 5294 height: 14px; 5295 border-radius: 7px; 5296 background: linear-gradient( 5297 90deg, 5298 var(--bg-subtle) 25%, 5299 var(--bg-muted) 50%, 5300 var(--bg-subtle) 75% 5301 ); 5302 background-size: 200% 100%; 5303 animation: ont-shimmer 1.5s ease infinite; 5304 } 5305 @keyframes ont-shimmer { 5306 0% { 5307 background-position: 200% 0; 5308 } 5309 100% { 5310 background-position: -200% 0; 5311 } 5312 } 5313 .ont-divider { 5314 border-top: 1px solid var(--border-soft); 5315 margin: 0 14px; 5316 } 5317 .ont-concepts { 5318 display: flex; 5319 flex-wrap: wrap; 5320 gap: 5px; 5321 padding: 6px 14px 14px; 5322 } 5323 .ont-concept-pill { 5324 display: inline-flex; 5325 align-items: center; 5326 gap: 4px; 5327 background: rgba(214, 176, 89, 0.12); 5328 border: 1px solid rgba(214, 176, 89, 0.18); 5329 padding: 3px 9px; 5330 border-radius: 12px; 5331 font-size: 11px; 5332 color: var(--fg-muted); 5333 cursor: pointer; 5334 font-family: var(--font-sans); 5335 font-weight: 400; 5336 } 5337 .ont-concept-pill:hover { 5338 background: rgba(214, 176, 89, 0.22); 5339 } 5340 .ont-concept-pill:focus-visible { 5341 outline: none; 5342 box-shadow: 0 0 0 2px var(--selection); 5343 } 5344 .ont-concept-count { 5345 font-size: 9px; 5346 color: var(--fg-subtle); 5347 } 5348 .ont-shape-bar { 5349 display: flex; 5350 height: 6px; 5351 border-radius: 3px; 5352 overflow: hidden; 5353 gap: 1px; 5354 margin: 0 14px; 5355 } 5356 .ont-shape-segment { 5357 transition: flex 200ms ease; 5358 } 5359 .ont-shape-labels { 5360 display: flex; 5361 justify-content: space-between; 5362 padding: 4px 14px 12px; 5363 font-size: 9px; 5364 color: var(--fg-subtle); 5365 } 5366 .ont-shape-section { 5367 padding: 8px 14px 4px; 5368 border-top: 1px solid var(--border-soft); 5369 } 5370 .ont-stale-link { 5371 font-size: 10px; 5372 color: var(--accent-fg); 5373 cursor: pointer; 5374 background: none; 5375 border: none; 5376 padding: 0; 5377 font-family: var(--font-sans); 5378 text-decoration: underline; 5379 text-decoration-color: color-mix( 5380 in srgb, 5381 var(--accent-fg) 30%, 5382 transparent 5383 ); 5384 } 5385 .ont-stale-link:hover { 5386 text-decoration-color: currentColor; 5387 } 5388 .ont-empty, 5389 .ont-fallback { 5390 padding: 20px 14px; 5391 text-align: center; 5392 color: var(--fg-muted); 5393 font-size: 12px; 5394 line-height: 1.5; 5395 } 5396 .ont-preview-dot { 5397 display: none; 5398 width: 6px; 5399 height: 6px; 5400 border-radius: 50%; 5401 margin-right: 6px; 5402 flex-shrink: 0; 5403 vertical-align: middle; 5404 } 5405 .ontology-active .ont-preview-dot { 5406 display: inline-block; 5407 } 5408 .ont-term { 5409 border-bottom: 1.5px dotted transparent; 5410 } 5411 .ontology-active .ont-term { 5412 border-bottom-color: var(--ont-term-underline); 5413 } 5414 @keyframes ont-flash { 5415 0% { 5416 background: rgba(214, 176, 89, 0.35); 5417 } 5418 100% { 5419 background: transparent; 5420 } 5421 } 5422 .ont-term-flash { 5423 animation: ont-flash 0.8s ease-out forwards; 5424 border-radius: 2px; 5425 } 5426 5427 .export-print-root { 5428 display: none; 5429 } 5430 5431 @page { 5432 margin: 16mm 18mm; 5433 } 5434 5435 @media print { 5436 body.export-pdf-active { 5437 background: var(--reader-bg-canvas) !important; 5438 } 5439 body.export-pdf-active > *:not(#markvExportPrintRoot) { 5440 display: none !important; 5441 } 5442 body.export-pdf-active #markvExportPrintRoot { 5443 display: block !important; 5444 } 5445 .export-print-root { 5446 min-height: 100vh; 5447 background: var(--reader-bg-canvas); 5448 color: var(--reader-fg-default); 5449 } 5450 .export-print-document.markdown-body { 5451 max-width: none; 5452 margin: 0; 5453 padding: 0; 5454 color: var(--reader-fg-default); 5455 background: transparent; 5456 orphans: 3; 5457 widows: 3; 5458 } 5459 .export-print-document.markdown-body h1, 5460 .export-print-document.markdown-body h2, 5461 .export-print-document.markdown-body h3, 5462 .export-print-document.markdown-body h4, 5463 .export-print-document.markdown-body h5, 5464 .export-print-document.markdown-body h6 { 5465 break-after: avoid; 5466 page-break-after: avoid; 5467 color: var(--reader-fg-heading); 5468 } 5469 .export-print-document.markdown-body pre, 5470 .export-print-document.markdown-body table, 5471 .export-print-document.markdown-body blockquote, 5472 .export-print-document.markdown-body img, 5473 .export-print-document.markdown-body figure, 5474 .export-print-document.markdown-body .mermaid-block, 5475 .export-print-document.markdown-body .markdown-alert, 5476 .export-print-document.markdown-body .katex-display { 5477 break-inside: avoid; 5478 page-break-inside: avoid; 5479 } 5480 .export-print-document.markdown-body img, 5481 .export-print-document.markdown-body svg { 5482 max-width: 100%; 5483 height: auto; 5484 } 5485 .export-print-document.markdown-body pre, 5486 .export-print-document.markdown-body .mermaid-block { 5487 background: var(--reader-code-bg) !important; 5488 border-color: var(--reader-border-default) !important; 5489 } 5490 .export-print-document.markdown-body code { 5491 color: var(--reader-code-fg); 5492 } 5493 .export-pri
5493nt-document.markdown-body pre code.hljs { 5494 color: var(--reader-code-fg) !important; 5495 background: transparent !important; 5496 } 5497 .export-print-document.markdown-body .csv-sheet-wrap { 5498 max-width: 100%; 5499 overflow: visible; 5500 border-color: var(--reader-border-default) !important; 5501 box-shadow: none; 5502 break-inside: auto; 5503 page-break-inside: auto; 5504 } 5505 .export-print-document.markdown-body .csv-sheet { 5506 width: 100%; 5507 min-width: 0; 5508 table-layout: fixed; 5509 font-size: 0.72em; 5510 break-inside: auto; 5511 page-break-inside: auto; 5512 } 5513 .export-print-document.markdown-body .csv-sheet thead th { 5514 position: static; 5515 background: var(--reader-bg-subtle) !important; 5516 } 5517 .export-print-document.markdown-body .csv-sheet th, 5518 .export-print-document.markdown-body .csv-sheet td { 5519 max-width: none; 5520 padding: 6px 8px; 5521 border-color: var(--reader-border-soft) !important; 5522 white-space: normal; 5523 overflow-wrap: anywhere; 5524 } 5525 .export-print-document.markdown-body .csv-sheet tr { 5526 break-inside: avoid; 5527 page-break-inside: avoid; 5528 } 5529 .export-print-document.markdown-body :is(.hljs-comment, .hljs-quote) { 5530 color: var(--reader-syntax-comment) !important; 5531 font-style: italic; 5532 } 5533 .export-print-document.markdown-body 5534 :is(.hljs-keyword, .hljs-selector-tag, .hljs-subst, .hljs-doctag) { 5535 color: var(--reader-syntax-keyword) !important; 5536 } 5537 .export-print-document.markdown-body 5538 :is( 5539 .hljs-string, 5540 .hljs-regexp, 5541 .hljs-symbol, 5542 .hljs-bullet, 5543 .hljs-addition 5544 ) { 5545 color: var(--reader-syntax-string) !important; 5546 } 5547 .export-print-document.markdown-body 5548 :is( 5549 .hljs-number, 5550 .hljs-literal, 5551 .hljs-selector-id, 5552 .hljs-selector-class 5553 ) { 5554 color: var(--reader-syntax-number) !important; 5555 } 5556 .export-print-document.markdown-body 5557 :is( 5558 .hljs-title, 5559 .hljs-title.class_, 5560 .hljs-title.function_, 5561 .hljs-section 5562 ) { 5563 color: var(--reader-syntax-title) !important; 5564 } 5565 .export-print-document.markdown-body 5566 :is( 5567 .hljs-attr, 5568 .hljs-attribute, 5569 .hljs-variable, 5570 .hljs-template-variable, 5571 .hljs-property, 5572 .hljs-type, 5573 .hljs-built_in, 5574 .hljs-name, 5575 .hljs-tag, 5576 .hljs-meta 5577 ) { 5578 color: var(--reader-syntax-meta) !important; 5579 } 5580 .export-print-document.markdown-body .doc-highlight { 5581 background: transparent !important; 5582 box-shadow: inset 0 -1px 0 currentColor !important; 5583 } 5584 .export-timeout-fallback, 5585 .export-image-fallback { 5586 padding: 12px; 5587 border: 1px solid var(--reader-border-default); 5588 border-radius: 8px; 5589 background: var(--reader-bg-subtle); 5590 color: var(--reader-fg-default); 5591 } 5592 } 5593 5594 /* Reduce motion */ 5595 @media (prefers-reduced-motion: reduce) { 5596 * { 5597 transition-duration: 0.01ms !important; 5598 animation-duration: 0.01ms !important; 5599 } 5600 .chat-msg.assistant .typing-dots::after { 5601 animation: none; 5602 content: "..."; 5603 } 5604 .empty-state, 5605 .reading-panel, 5606 .ont-panel { 5607 animation: none; 5608 } 5609 } 5610 5611 /* ============================================================ 5612 Chat Bubble â floating Q&A panel (Ollama / Groq RAG) 5613 ============================================================ */ 5614 .chat-fab { 5615 position: fixed; 5616 bottom: 24px; 5617 right: 24px; 5618 z-index: 1000; 5619 width: 46px; 5620 height: 46px; 5621 border-radius: 50%; 5622 border: none; 5623 background: var(--fg-default); 5624 color: var(--bg-canvas); 5625 cursor: pointer; 5626 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); 5627 display: flex; 5628 align-items: center; 5629 justify-content: center; 5630 transition: 5631 transform 200ms ease, 5632 box-shadow 200ms ease, 5633 opacity 200ms ease; 5634 opacity: 0.85; 5635 } 5636 .chat-fab:hover { 5637 transform: scale(1.06); 5638 box-shadow: 0 6px 28px rgba(0, 0, 0, 0.14); 5639 opacity: 1; 5640 } 5641 .chat-fab.hidden { 5642 display: none; 5643 } 5644 .scroll-top-btn { 5645 position: fixed; 5646 right: max(24px, env(safe-area-inset-right)); 5647 bottom: max(86px, calc(env(safe-area-inset-bottom) + 86px)); 5648 z-index: 900; 5649 width: 38px; 5650 height: 38px; 5651 border: 1px solid var(--border-default); 5652 border-radius: 999px; 5653 background: color-mix(in srgb, var(--bg-raised) 88%, transparent); 5654 color: var(--fg-muted); 5655 cursor: pointer; 5656 display: inline-flex; 5657 align-items: center; 5658 justify-content: center; 5659 box-shadow: 0 18px 34px -28px rgba(0, 0, 0, 0.45); 5660 backdrop-filter: blur(10px); 5661 -webkit-backdrop-filter: blur(10px); 5662 opacity: 0; 5663 pointer-events: none; 5664 transform: translateY(8px); 5665 transition: 5666 opacity 180ms ease, 5667 transform 180ms ease, 5668 color 180ms ease, 5669 border-color 180ms ease, 5670 background-color 180ms ease; 5671 } 5672 .scroll-top-btn.is-visible { 5673 opacity: 0.82; 5674 pointer-events: auto; 5675 transform: translateY(0); 5676 } 5677 .scroll-top-btn:hover, 5678 .scroll-top-btn:focus-visible { 5679 background: var(--bg-subtle); 5680 border-color: var(--border-strong); 5681 color: var(--fg-default); 5682 opacity: 1; 5683 } 5684 .scroll-top-btn.is-visible:active { 5685 transform: translateY(1px); 5686 } 5687 .scroll-top-btn svg { 5688 width: 16px; 5689 height: 16px; 5690 } 5691 body.library-open .scroll-top-btn, 5692 body.mindmap-open .scroll-top-btn, 5693 body.mv-more-open .scroll-top-btn { 5694 opacity: 0; 5695 pointer-events: none; 5696 transform: translateY(8px); 5697 } 5698 body.chat-open .scroll-top-btn { 5699 right: calc(360px + max(24px, env(safe-area-inset-right))); 5700 } 5701 @media (max-width: 640px) { 5702 .scroll-top-btn { 5703 right: 50%; 5704 bottom: max(16px, env(safe-area-inset-bottom)); 5705 transform: translate(50%, 8px); 5706 } 5707 .scroll-top-btn.is-visible { 5708 transform: translate(50%, 0); 5709 } 5710 .scroll-top-btn.is-visible:active { 5711 transform: translate(50%, 1px); 5712 } 5713 body.library-open .scroll-top-btn, 5714 body.mindmap-open .scroll-top-btn, 5715 body.mv-more-open .scroll-top-btn { 5716 transform: translate(50%, 8px); 5717 } 5718 } 5719 @media (max-width: 600px) { 5720 body.chat-open .scroll-top-btn { 5721 opacity: 0; 5722 pointer-events: none; 5723 transform: translate(50%, 8px); 5724 } 5725 } 5726 5727 .chat-panel { 5728 position: fixed; 5729 top: 0; 5730 right: 0; 5731 bottom: 0; 5732 z-index: 200; 5733 width: 360px; 5734 max-width: 90vw; 5735 height: 100vh; 5736 background: var(--bg-raised); 5737 border-left: 1px solid var(--border-soft); 5738 border-radius: 0; 5739 box-shadow: -4px 0 30px rgba(0, 0, 0, 0.05); 5740 display: flex; 5741 flex-direction: column; 5742 overflow: hidden; 5743 transform: translateX(0); 5744 transition: 5745 transform 350ms cubic-bezier(0.22, 1, 0.36, 1), 5746 visibility 350ms; 5747 visibility: visible; 5748 } 5749 /* Keep panel in render tree so transitions work */ 5750 #chatPanel[hidden] { 5751 display: flex !important; 5752 } 5753 .chat-panel.hidden { 5754 transform: translateX(100%); 5755 visibility: hidden; 5756 pointer-events: none; 5757 } 5758 /* Push main content + toolbar when sidebar is open */ 5759 #main, 5760 .toolbar { 5761 transition: 5762 margin-right 300ms cubic-bezier(0.4, 0, 0.2, 1), 5763 margin-left 300ms cubic-bezier(0.4, 0, 0.2, 1); 5764 } 5765 body.chat-open #main, 5766 body.chat-open .toolbar { 5767 margin-right: 360px; 5768 } 5769 body.library-open #main, 5770 body.library-open .toolbar { 5771 margin-left: 320px; 5772 } 5773 5774 /* Library drawer stagger â when `body.library-open` is set, 5775 direct children of #libraryBody fade/slide in with a 40ms 5776 cascade. Capped at item 12 so very long libraries don't 5777 crawl. Reduced-motion users get the instant show via the 5778 global reset. */ 5779 body.library-open #libraryBody > * { 5780 animation: library-item-enter var(--motion-panel) var(--ease-out) both; 5781 } 5782 body.library-open #libraryBody > *:nth-child(1) { 5783 animation-delay: 0ms; 5784 } 5785 body.library-open #libraryBody > *:nth-child(2) { 5786 animation-delay: 40ms; 5787 } 5788 body.library-open #libraryBody > *:nth-child(3) { 5789 animation-delay: 80ms; 5790 } 5791 body.library-open #libraryBody > *:nth-child(4) { 5792 animation-delay: 120ms; 5793 } 5794 body.library-open #libraryBody > *:nth-child(5) { 5795 animation-delay: 160ms; 5796 } 5797 body.library-open #libraryBody > *:nth-child(6) { 5798 animation-delay: 200ms; 5799 } 5800 body.library-open #libraryBody > *:nth-child(7) { 5801 animation-delay: 240ms; 5802 } 5803 body.library-open #libraryBody > *:nth-child(8) { 5804 animation-delay: 280ms; 5805 } 5806 body.library-open #libraryBody > *:nth-child(9) { 5807 animation-delay: 320ms; 5808 } 5809 body.library-open #libraryBody > *:nth-child(10) { 5810 animation-delay: 360ms; 5811 } 5812 body.library-open #libraryBody > *:nth-child(11) { 5813 animation-delay: 400ms; 5814 } 5815 body.library-open #libraryBody > *:nth-child(12) { 5816 animation-delay: 440ms; 5817 } 5818 body.library-open #libraryBody > *:nth-child(n + 13) { 5819 animation-delay: 480ms; 5820 } 5821 5822 @keyframes library-item-enter { 5823 from { 5824 opacity: 0; 5825 transform: translateX(-6px); 5826 } 5827 to { 5828 opacity: 1; 5829 transform: translateX(0); 5830 } 5831 } 5832 body.mindmap-open { 5833 overflow: hidden; 5834 } 5835 @media (max-width: 600px) { 5836 body.chat-open #main, 5837 body.chat-open .toolbar, 5838 body.library-open #main, 5839 body.library-open .toolbar { 5840 margin-right: 0; 5841 margin-left: 0; 5842 } 5843 .chat-panel { 5844 width: 100vw; 5845 max-width: 100vw; 5846 } 5847 } 5848 5849 .chat-header { 5850 display: flex; 5851 align-items: center; 5852 gap: 8px; 5853 padding: 12px 14px; 5854 border-bottom: 1px solid var(--border-soft); 5855 font-family: var(--font-sans); 5856 font-size: 13px; 5857 font-weight: 600; 5858 color: var(--fg-default); 5859 flex-shrink: 0; 5860 } 5861 .chat-header-title { 5862 flex: 1; 5863 } 5864 .chat-header-btn { 5865 background: none; 5866 border: none; 5867 color: var(--fg-muted); 5868 cursor: pointer; 5869 padding: 4px; 5870 border-radius: 6px; 5871 display: flex; 5872 align-items: center; 5873 justify-content: center; 5874 } 5875 .chat-header-btn:hover { 5876 color: var(--fg-default); 5877 background: var(--bg-subtle); 5878 } 5879 5880 .chat-messages { 5881 flex: 1; 5882 overflow-y: auto; 5883 padding: 12px 14px; 5884 display: flex; 5885 flex-direction: column; 5886 gap: 10px; 5887 font-family: var(--font-sans); 5888 font-size: 13px; 5889 line-height: 1.55; 5890 color: var(--fg-default); 5891 } 5892 .chat-msg { 5893 padding: 8px 12px; 5894 border-radius: 10px; 5895 max-width: 88%; 5896 word-wrap: break-word; 5897 white-space: pre-wrap; 5898 } 5899 .chat-msg.user { 5900 align-self: flex-end; 5901 background: var(--fg-default); 5902 color: var(--bg-canvas); 5903 } 5904 .chat-msg.assistant { 5905 align-self: flex-start; 5906 background: var(--bg-subtle); 5907 color: var(--fg-default); 5908 } 5909 .chat-msg.system { 5910 align-self: center; 5911 font-size: 11.5px; 5912 color: var(--fg-subtle); 5913 background: none; 5914 padding: 4px 0; 5915 text-align: center; 5916 } 5917 .chat-msg.markdown-rendered { 5918 white-space: normal; 5919 line-height: 1.6; 5920 } 5921 .chat-msg.markdown-rendered > *:first-child { 5922 margin-top: 0; 5923 } 5924 .chat-msg.markdown-rendered > *:last-child { 5925 margin-bottom: 0; 5926 } 5927 .chat-msg.markdown-rendered p { 5928 margin: 0 0 0.8em; 5929 } 5930 .chat-msg.markdown-rendered h1, 5931 .chat-msg.markdown-rendered h2, 5932 .chat-msg.markdown-rendered h3, 5933 .chat-msg.markdown-rendered h4, 5934 .chat-msg.markdown-rendered h5, 5935 .chat-msg.markdown-rendered h6 { 5936 margin: 1.1em 0 0.45em; 5937 line-height: 1.25; 5938 font-family: var(--font-sans); 5939 font-weight: 600; 5940 letter-spacing: -0.015em; 5941 } 5942 .chat-msg.markdown-rendered h1 { 5943 font-size: 1.28em; 5944 } 5945 .chat-msg.markdown-rendered h2 { 5946 font-size: 1.16em; 5947 } 5948 .chat-msg.markdown-rendered h3, 5949 .chat-msg.markdown-rendered h4, 5950 .chat-msg.markdown-rendered h5, 5951 .chat-msg.markdown-rendered h6 { 5952 font-size: 1.02em; 5953 } 5954 .chat-msg.markdown-rendered ul, 5955 .chat-msg.markdown-rendered ol { 5956 margin: 0 0 0.8em; 5957 padding-left: 1.4em; 5958 } 5959 .chat-msg.markdown-rendered li + li { 5960 margin-top: 0.28em; 5961 } 5962 .chat-msg.markdown-rendered li > p { 5963 margin: 0.35em 0; 5964 } 5965 .chat-msg.markdown-rendered a { 5966 color: var(--accent-fg); 5967 text-decoration: underline; 5968 text-decoration-thickness: 1px; 5969 text-decoration-color: color-mix( 5970 in srgb, 5971 var(--accent-fg) 28%, 5972 transparent 5973 ); 5974 text-underline-offset: 2px; 5975 } 5976 .chat-msg.markdown-rendered a:hover { 5977 color: var(--accent-hover); 5978 text-decoration-color: currentColor; 5979 } 5980 .chat-msg.markdown-rendered blockquote { 5981 margin: 0 0 0.9em; 5982 padding: 0.1em 0 0.1em 0.9em; 5983 color: var(--fg-muted); 5984 border-left: 3px solid var(--blockquote-border); 5985 } 5986 .chat-msg.markdown-rendered .markdown-alert { 5987 margin: 0 0 0.9em; 5988 padding: 0.8em 0.9em 0.85em; 5989 } 5990 .chat-msg.markdown-rendered .markdown-alert-title { 5991 font-size: 0.85rem; 5992 } 5993 .chat-msg.markdown-rendered hr { 5994 border: 0; 5995 border-top: 1px solid var(--border-soft); 5996 margin: 1em 0; 5997 } 5998 .chat-msg.markdown-rendered code, 5999 .chat-msg.markdown-rendered tt { 6000 padding: 0.14em 0.38em; 6001 font-family: var(--font-mono); 6002 font-size: 0.9em; 6003 color: var(--fg-default); 6004 background: var(--code-inline-bg); 6005 border-radius: 4px; 6006 white-space: break-spaces; 6007 } 6008 .chat-msg.markdown-rendered pre { 6009 margin: 0 0 0.9em; 6010 padding: 12px 14px; 6011 overflow-x: auto; 6012 background: var(--code-block-bg); 6013 border: 1px solid var(--border-soft); 6014 border-radius: 8px; 6015 } 6016 .chat-msg.markdown-rendered pre code, 6017 .chat-msg.markdown-rendered pre tt { 6018 display: inline; 6019 padding: 0; 6020 background: transparent; 6021 border: 0; 6022 border-radius: 0; 6023 white-space: pre; 6024 } 6025 .chat-msg.markdown-rendered .raw-html { 6026 border-style: dashed; 6027 } 6028 .chat-msg.markdown-rendered table { 6029 display: block; 6030 width: max-content; 6031 max-width: 100%; 6032 overflow-x: auto; 6033 margin: 0 0 0.9em; 6034 border-spacing: 0; 6035 border-collapse: collapse; 6036 font-size: 0.94em; 6037 } 6038 .chat-msg.markdown-rendered th, 6039 .chat-msg.markdown-rendered td { 6040 padding: 8px 10px; 6041 text-align: left; 6042 border-bottom: 1px solid var(--border-soft); 6043 } 6044 .chat-msg.markdown-rendered th { 6045 color: var(--fg-muted); 6046 fo
6046nt-weight: 600; 6047 } 6048 .chat-msg.markdown-rendered img { 6049 display: block; 6050 max-width: 100%; 6051 margin: 0 0 0.9em; 6052 border-radius: 8px; 6053 } 6054 .chat-msg.markdown-rendered .blocked-resource { 6055 display: block; 6056 margin: 0 0 0.9em; 6057 padding: 10px 12px; 6058 color: var(--fg-muted); 6059 background: var(--bg-muted); 6060 border: 1px solid var(--border-soft); 6061 border-radius: 8px; 6062 } 6063 .chat-msg.assistant .typing-dots::after { 6064 content: ""; 6065 animation: typingDots 1.2s steps(4, end) infinite; 6066 } 6067 @keyframes typingDots { 6068 0% { 6069 content: ""; 6070 } 6071 25% { 6072 content: "."; 6073 } 6074 50% { 6075 content: ".."; 6076 } 6077 75% { 6078 content: "..."; 6079 } 6080 } 6081 6082 .chat-input-area { 6083 padding: 12px 14px 14px; 6084 border-top: 1px solid var(--border-soft); 6085 flex-shrink: 0; 6086 } 6087 /* Single card wrapping textarea + action row */ 6088 .chat-input-card { 6089 display: flex; 6090 flex-direction: column; 6091 border: 1px solid var(--border-default); 6092 border-radius: 14px; 6093 background: var(--bg-canvas); 6094 transition: 6095 border-color 200ms ease, 6096 box-shadow 200ms ease; 6097 } 6098 .chat-input-card:focus-within { 6099 border-color: var(--accent-fg); 6100 box-shadow: 0 0 0 3px var(--selection); 6101 } 6102 .chat-input { 6103 flex: 1; 6104 border: none; 6105 border-radius: 14px 14px 0 0; 6106 padding: 12px 14px 6px; 6107 font-family: var(--font-sans); 6108 font-size: 13.5px; 6109 color: var(--fg-default); 6110 background: transparent; 6111 outline: none; 6112 resize: none; 6113 min-height: 64px; 6114 max-height: 140px; 6115 line-height: 1.55; 6116 } 6117 .chat-input::placeholder { 6118 color: var(--fg-subtle); 6119 } 6120 /* Bottom row: hint left, send button right */ 6121 .chat-input-actions { 6122 display: flex; 6123 align-items: center; 6124 justify-content: space-between; 6125 padding: 6px 10px 8px; 6126 } 6127 .chat-input-hint { 6128 font-family: var(--font-sans); 6129 font-size: 11px; 6130 color: var(--fg-subtle); 6131 user-select: none; 6132 } 6133 .chat-send-btn { 6134 width: 32px; 6135 height: 32px; 6136 border-radius: 50%; 6137 border: none; 6138 background: var(--fg-default); 6139 color: var(--bg-canvas); 6140 cursor: pointer; 6141 display: flex; 6142 align-items: center; 6143 justify-content: center; 6144 flex-shrink: 0; 6145 transition: 6146 opacity 120ms ease, 6147 transform 120ms ease; 6148 } 6149 .chat-send-btn:not(:disabled):hover { 6150 transform: scale(1.08); 6151 } 6152 .chat-send-btn:disabled { 6153 opacity: 0.3; 6154 cursor: not-allowed; 6155 } 6156 6157 /* ---------- AI Settings ---------- */ 6158 .chat-settings { 6159 padding: 12px 14px; 6160 border-top: 1px solid var(--border-soft); 6161 font-family: var(--font-sans); 6162 font-size: 12px; 6163 color: var(--fg-muted); 6164 display: flex; 6165 flex-direction: column; 6166 gap: 8px; 6167 flex-shrink: 0; 6168 } 6169 .chat-settings.hidden { 6170 display: none; 6171 } 6172 .chat-settings label { 6173 display: flex; 6174 flex-direction: column; 6175 gap: 3px; 6176 } 6177 .chat-settings label span { 6178 font-weight: 600; 6179 font-size: 11px; 6180 text-transform: uppercase; 6181 letter-spacing: 0.04em; 6182 color: var(--fg-subtle); 6183 } 6184 .chat-settings input, 6185 .chat-settings select { 6186 border: 1px solid var(--border-default); 6187 border-radius: 6px; 6188 padding: 5px 8px; 6189 font-size: 12px; 6190 color: var(--fg-default); 6191 background: var(--bg-canvas); 6192 font-family: var(--font-mono); 6193 } 6194 .chat-settings input:focus, 6195 .chat-settings select:focus { 6196 outline: none; 6197 border-color: var(--accent-fg); 6198 } 6199 .chat-settings-note { 6200 margin: 0; 6201 color: var(--fg-subtle); 6202 font-size: 11px; 6203 line-height: 1.45; 6204 } 6205 6206 /* The old inline chat settings panel is now permanently hidden. 6207 Its inputs remain in the DOM as the source of truth for AI config â 6208 the new global settings drawer mirrors values into them. */ 6209 #chatSettings { 6210 display: none !important; 6211 } 6212 6213 /* ---------- Files Drawer ---------- */ 6214 .lib-overlay { 6215 position: fixed; 6216 inset: 0; 6217 background: rgba(10, 14, 20, 0); 6218 backdrop-filter: blur(0px); 6219 -webkit-backdrop-filter: blur(0px); 6220 z-index: 9996; 6221 pointer-events: none; 6222 transition: 6223 background-color 220ms ease, 6224 backdrop-filter 220ms ease; 6225 } 6226 .lib-overlay.is-open { 6227 background: rgba(10, 14, 20, 0.16); 6228 backdrop-filter: blur(3px); 6229 -webkit-backdrop-filter: blur(3px); 6230 pointer-events: auto; 6231 } 6232 :root[data-theme="dark"] .lib-overlay.is-open { 6233 background: rgba(0, 0, 0, 0.38); 6234 } 6235 6236 .lib-drawer { 6237 position: fixed; 6238 top: 0; 6239 left: 0; 6240 height: 100vh; 6241 width: min(320px, 100vw); 6242 background: var(--bg-canvas); 6243 border-right: 1px solid var(--border-soft); 6244 box-shadow: none; 6245 z-index: 9997; 6246 display: flex; 6247 flex-direction: column; 6248 color: var(--fg-default); 6249 transform: translateX(-100%); 6250 transition: transform 260ms cubic-bezier(0.2, 0.7, 0.2, 1); 6251 } 6252 .lib-drawer.is-open { 6253 box-shadow: 24px 0 48px -32px rgba(0, 0, 0, 0.18); 6254 transform: translateX(0); 6255 } 6256 .lib-header { 6257 display: flex; 6258 align-items: center; 6259 gap: 10px; 6260 padding: 20px 18px 4px; 6261 flex-shrink: 0; 6262 } 6263 .lib-header-copy { 6264 flex: 1; 6265 min-width: 0; 6266 } 6267 .lib-header-title { 6268 margin: 0; 6269 font-family: var(--font-serif); 6270 font-size: 1.05rem; 6271 font-weight: 600; 6272 letter-spacing: -0.02em; 6273 color: var(--fg-default); 6274 } 6275 .lib-header-help { 6276 display: none; 6277 } 6278 .lib-close { 6279 background: transparent; 6280 border: 1px solid transparent; 6281 border-radius: 8px; 6282 width: 28px; 6283 height: 28px; 6284 display: inline-flex; 6285 align-items: center; 6286 justify-content: center; 6287 color: var(--fg-subtle); 6288 cursor: pointer; 6289 transition: 6290 background-color 140ms ease, 6291 color 140ms ease; 6292 } 6293 .lib-close:hover { 6294 background: var(--bg-subtle); 6295 color: var(--fg-default); 6296 } 6297 .lib-actions { 6298 display: flex; 6299 align-items: center; 6300 flex-wrap: wrap; 6301 gap: 10px; 6302 padding: 10px 18px 10px; 6303 flex-shrink: 0; 6304 } 6305 .lib-file-action { 6306 display: inline-flex; 6307 align-items: center; 6308 gap: 8px; 6309 padding: 7px 12px 7px 10px; 6310 background: transparent; 6311 border: 1px dashed var(--border-default); 6312 border-radius: 10px; 6313 color: var(--fg-muted); 6314 font-family: var(--font-sans); 6315 font-size: 12.5px; 6316 font-weight: 500; 6317 cursor: pointer; 6318 transition: 6319 color 140ms ease, 6320 border-color 140ms ease, 6321 background-color 140ms ease; 6322 } 6323 .lib-file-action:hover:not(:disabled) { 6324 color: var(--fg-default); 6325 border-color: var(--border-strong); 6326 background: var(--bg-subtle); 6327 } 6328 .lib-file-action:focus-visible { 6329 outline: none; 6330 box-shadow: 0 0 0 3px var(--selection); 6331 } 6332 .lib-file-action:disabled { 6333 opacity: 0.5;
6334 cursor: not-allowed; 6335 } 6336 .lib-file-action-icon { 6337 display: inline-flex; 6338 color: var(--fg-subtle); 6339 transform: translateY(-0.5px); 6340 } 6341 .lib-file-action-icon svg { 6342 width: 14px; 6343 height: 14px; 6344 flex-shrink: 0; 6345 } 6346 .lib-toggle-all { 6347 appearance: none; 6348 display: inline-flex; 6349 align-items: center; 6350 gap: 5px; 6351 padding: 0; 6352 margin-left: 2px; 6353 border: 0; 6354 background: transparent; 6355 color: var(--fg-subtle); 6356 font: inherit; 6357 font-size: 11.5px; 6358 line-height: 1; 6359 cursor: pointer; 6360 transition: color 140ms ease; 6361 } 6362 .lib-toggle-all[hidden] { 6363 display: none; 6364 } 6365 .lib-toggle-all:hover, 6366 .lib-toggle-all:focus-visible { 6367 color: var(--fg-default); 6368 } 6369 .lib-toggle-all svg { 6370 width: 9px; 6371 height: 9px; 6372 flex-shrink: 0; 6373 opacity: 0.75; 6374 transition: opacity 140ms ease; 6375 } 6376 .lib-toggle-all:hover svg, 6377 .lib-toggle-all:focus-visible svg { 6378 opacity: 1; 6379 } 6380 .lib-supported-note { 6381 margin: 2px 18px 10px; 6382 padding: 9px 11px; 6383 font-size: 11.5px; 6384 line-height: 1.55; 6385 color: var(--fg-subtle); 6386 background: var(--bg-subtle); 6387 border-radius: 9px; 6388 } 6389 .lib-body { 6390 flex: 1; 6391 min-height: 0; 6392 overflow: auto; 6393 padding: 6px 8px 24px; 6394 } 6395 .lib-empty { 6396 padding: 32px 18px 24px; 6397 text-align: center; 6398 color: var(--fg-subtle); 6399 } 6400 .lib-empty-title { 6401 margin: 0 0 6px; 6402 font-family: var(--font-serif); 6403 font-size: 14px; 6404 font-weight: 500; 6405 letter-spacing: -0.01em; 6406 color: var(--fg-muted); 6407 } 6408 .lib-empty-copy { 6409 margin: 0; 6410 font-size: 12px; 6411 line-height: 1.6; 6412 color: var(--fg-subtle); 6413 } 6414 .lib-drafts { 6415 margin: 0; 6416 padding: 8px 0 12px; 6417 border-bottom: 1px solid var(--border-soft); 6418 } 6419 .lib-drafts + .lib-folder { 6420 margin-top: 8px; 6421 } 6422 .lib-section-label { 6423 display: block; 6424 padding: 0 12px 6px; 6425 font-family: var(--font-sans); 6426 font-size: 10.5px; 6427 font-weight: 700; 6428 letter-spacing: 0.12em; 6429 text-transform: uppercase; 6430 color: var(--fg-subtle); 6431 } 6432 .lib-draft-node { 6433 display: flex; 6434 align-items: center; 6435 gap: 4px; 6436 min-width: 0; 6437 } 6438 .lib-draft-row { 6439 position: relative; 6440 flex: 1; 6441 min-width: 0; 6442 display: flex; 6443 align-items: center; 6444 gap: 7px; 6445 padding: 6px 10px; 6446 border: 0; 6447 background: transparent; 6448 border-radius: 7px; 6449 color: var(--fg-muted); 6450 font: inherit; 6451 font-size: 12.5px; 6452 text-align: left; 6453 cursor: pointer; 6454 transition: 6455 background-color 140ms ease, 6456 color 140ms ease; 6457 } 6458 .lib-draft-row:hover { 6459 background: var(--bg-subtle); 6460 color: var(--fg-default); 6461 } 6462 .lib-draft-row.is-active { 6463 color: var(--fg-default); 6464 font-weight: 500; 6465 } 6466 .lib-draft-row.is-active::before { 6467 content: ""; 6468 position: absolute; 6469 left: 3px; 6470 top: 6px; 6471 bottom: 6px; 6472 width: 2px; 6473 border-radius: 1px; 6474 background: color-mix(in srgb, var(--accent-fg) 55%, transparent); 6475 } 6476 .lib-draft-dot { 6477 width: 14px; 6478 height: 14px; 6479 display: inline-flex; 6480 align-items: center; 6481 justify-content: center; 6482 color: var(--fg-subtle); 6483 flex-shrink: 0; 6484 } 6485 .lib-draft-dot::before { 6486 content: ""; 6487 width: 7px; 6488 height: 9px; 6489 border: 1.3px solid currentColor; 6490 border-radius: 2px; 6491 opacity: 0.52; 6492 } 6493 .lib-draft-row[data-dirty="true"] .lib-draft-dot::before { 6494 width: 6px; 6495 height: 6px; 6496 border: 0; 6497 border-radius: 999px; 6498 background: color-mix(in srgb, var(--accent-fg) 62%, transparent); 6499 opacity: 0.95; 6500 } 6501 .lib-draft-label { 6502 min-width: 0; 6503 overflow: hidden; 6504 text-overflow: ellipsis; 6505 white-space: nowrap; 6506 } 6507 .lib-draft-discard { 6508 appearance: none; 6509 width: 24px; 6510 height: 24px; 6511 border: 0; 6512 border-radius: 6px; 6513 background: transparent; 6514 color: var(--fg-subtle); 6515 cursor: pointer; 6516 display: inline-flex; 6517 align-items: center; 6518 justify-content: center; 6519 opacity: 0; 6520 flex-shrink: 0; 6521 transition: 6522 opacity 140ms ease, 6523 color 140ms ease, 6524 background-color 140ms ease; 6525 } 6526 .lib-draft-node:hover .lib-draft-discard, 6527 .lib-draft-node:focus-within .lib-draft-discard { 6528 opacity: 1; 6529 } 6530 .lib-draft-discard:hover { 6531 color: color-mix(in srgb, var(--alert-warning) 72%, var(--fg-default)); 6532 background: var(--bg-subtle); 6533 } 6534 .lib-draft-discard svg { 6535 width: 13px; 6536 height: 13px; 6537 } 6538 @media (hover: none) { 6539 .lib-draft-discard { 6540 opacity: 0.55; 6541 } 6542 } 6543 .lib-folder { 6544 margin: 0; 6545 padding: 10px 0 4px; 6546 position: relative; 6547 transition: opacity 140ms ease; 6548 } 6549 .lib-folder + .lib-folder { 6550 margin-top: 8px; 6551 padding-top: 14px; 6552 border-top: 1px solid var(--border-soft); 6553 } 6554 .lib-folder[data-dragging="true"] { 6555 opacity: 0.35; 6556 } 6557 .lib-folder[data-drop="before"]::before, 6558 .lib-folder[data-drop="after"]::after { 6559 content: ""; 6560 position: absolute; 6561 left: 8px; 6562 right: 8px; 6563 height: 2px; 6564 background: var(--accent-fg); 6565 border-radius: 2px; 6566 pointer-events: none; 6567 } 6568 .lib-folder[data-drop="before"]::before { 6569 top: -1px; 6570 } 6571 .lib-folder[data-drop="after"]::after { 6572 bottom: -1px; 6573 } 6574 .lib-folder-head { 6575 padding: 4px 10px; 6576 cursor: pointer; 6577 border-radius: 8px; 6578 transition: background-color 140ms ease; 6579 } 6580 .lib-folder-head:hover { 6581 background: var(--bg-subtle); 6582 } 6583 .lib-folder-top { 6584 display: flex; 6585 align-items: flex-start; 6586 gap: 8px; 6587 } 6588 .lib-folder-toggle { 6589 appearance: none; 6590 border: 0; 6591 background: transparent; 6592 padding: 4px; 6593 margin: -2px 0 -2px -4px; 6594 color: var(--fg-subtle); 6595 cursor: pointer; 6596 border-radius: 6px; 6597 display: inline-flex; 6598 align-items: center; 6599 justify-content: center; 6600 flex-shrink: 0; 6601 transition: 6602 color 140ms ease, 6603 background-color 140ms ease; 6604 } 6605 .lib-folder-toggle:hover, 6606 .lib-folder-toggle:focus-visible { 6607 color: var(--fg-default); 6608 background: var(--bg-subtle); 6609 } 6610 .lib-folder-toggle svg { 6611 width: 10px; 6612 height: 10px; 6613 transition: transform 160ms ease; 6614 } 6615 .lib-folder-toggle[aria-expanded="true"] svg { 6616 transform: rotate(90deg); 6617 } 6618 .lib-folder-meta { 6619 flex: 1; 6620 min-width: 0; 6621 display: flex; 6622 align-items: baseline; 6623 flex-wrap: nowrap; 6624 column-gap: 8px; 6625 row-gap: 2px; 6626 } 6627 .lib-folder-name { 6628 margin: 0; 6629 font-family: var(--font-serif); 6630 font-size: 13.5px; 6631 font-weight: 500; 6632 line-height: 1.3; 6633 letter-spacing: -0.01em; 6634 color: var(--fg-default); 6635 min-width: 0; 6636 overflow: hidden; 6637 text-overflow: ellipsis; 6638 white-space: nowrap; 6639 } 6640 .lib-folder-sub { 6641 display: none; 6642 } 6643 .lib-folder-status { 6644 margin: 0; 6645 padding: 0; 6646 background: transparent; 6647 font-size: 11px; 6648 color: var(--fg-subtle); 6649 display: inline; 6650 } 6651 .lib-folder-status[data-status="ready"] { 6652 display: none; 6653 } 6654 .lib-folder-status[data-status="needs-permission"] { 6655 color: color-mix(in srgb, var(--alert-warning) 70%, var(--fg-default)); 6656 } 6657 .lib-folder-status[data-status="loading"]::before { 6658 content: ""; 6659 display: inline-block; 6660 width: 6px; 6661 height: 6px; 6662 border-radius: 50%; 6663 background: currentColor; 6664 margin-right: 6px; 6665 vertical-align: baseline; 6666 opacity: 0.5; 6667 animation: lib-pulse 900ms ease-in-out infinite alternate; 6668 } 6669 @keyframes lib-pulse { 6670 from { 6671 opacity: 0.3; 6672 } 6673 to { 6674 opacity: 0.85; 6675 } 6676 } 6677 .lib-folder-actions { 6678 display: inline-flex; 6679 flex-wrap: wrap; 6680 gap: 10px; 6681 margin: 0 0 0 auto; 6682 flex-shrink: 0; 6683 opacity: 0; 6684 transition: opacity 140ms ease; 6685 } 6686 .lib-folder:hover .lib-folder-actions, 6687 .lib-folder:focus-within .lib-folder-actions { 6688 opacity: 1; 6689 } 6690 @media (hover: none) { 6691 .lib-folder-actions { 6692 opacity: 0.55; 6693 } 6694 } 6695 .lib-action-btn { 6696 appearance: none; 6697 border: 0; 6698 background: transparent; 6699 color: var(--fg-subtle); 6700 border-radius: 0; 6701 padding: 0; 6702 font: inherit; 6703 font-size: 11.5px; 6704 cursor: pointer; 6705 transition: color 140ms ease; 6706 } 6707 .lib-action-btn:hover { 6708 background: transparent; 6709 color: var(--fg-default); 6710 border-color: transparent; 6711 } 6712 .lib-action-btn[data-action="remove-folder"]:hover { 6713 color: color-mix(in srgb, var(--alert-warning) 72%, var(--fg-default)); 6714 } 6715 .lib-tree { 6716 padding: 1px 0 4px; 6717 } 6718 .lib-tree-node { 6719 display: block; 6720 } 6721 .lib-tree-row { 6722 position: relative; 6723 width: 100%; 6724 display: flex; 6725 align-items: center; 6726 gap: 6px; 6727 padding: 5px 10px; 6728 border: 0; 6729 background: transparent; 6730 border-radius: 7px; 6731 color: var(--fg-muted); 6732 font: inherit; 6733 font-size: 12.5px; 6734 text-align: left; 6735 cursor: pointer; 6736 transition: 6737 background-color 140ms ease, 6738 color 140ms ease; 6739 } 6740 .lib-tree-row:hover { 6741 background: var(--bg-subtle); 6742 color: var(--fg-default); 6743 } 6744 .lib-tree-row.is-active { 6745 background: transparent; 6746 color: var(--fg-default); 6747 font-weight: 500; 6748 } 6749 .lib-tree-row.is-active::before { 6750 content: ""; 6751 position: absolute; 6752 left: 3px; 6753 top: 6px; 6754 bottom: 6px; 6755 width: 2px; 6756 border-radius: 1px; 6757 background: color-mix(in srgb, var(--accent-fg) 55%, transparent); 6758 } 6759 .lib-tree-row:focus-visible, 6760 .lib-draft-row:focus-visible, 6761 .lib-draft-discard:focus-visible, 6762 .lib-close:focus-visible, 6763 .lib-toggle-all:focus-visible, 6764 .lib-folder-toggle:focus-visible, 6765 .lib-action-btn:focus-visible { 6766 outline: none; 6767 box-shadow: 0 0 0 3px var(--selection); 6768 } 6769 .lib-tree-row[data-kind="directory"] { 6770 color: var(--fg-muted); 6771 font-weight: 500; 6772 } 6773 .lib-tree-caret { 6774 width: 14px; 6775 height: 14px; 6776 display: inline-flex; 6777 align-items: center; 6778 justify-content: center; 6779 font-size: 9px; 6780 color: var(--fg-subtle); 6781 flex-shrink: 0; 6782 transition: 6783 transform 140ms ease, 6784 opacity 140ms ease; 6785 opacity: 0.7; 6786 } 6787 .lib-tree-row[data-kind="file"] .lib-tree-caret { 6788 opacity: 0.18; 6789 } 6790 .lib-tree-row[data-kind="directory"] .lib-tree-caret { 6791 font-size: 0; 6792 line-height: 0; 6793 background-color: currentColor; 6794 color: var(--fg-subtle); 6795 -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14' fill='none' stroke='white' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'><path d='M1.5 5V11.25A1 1 0 0 0 2.5 12.25H11.5A1 1 0 0 0 12.5 11.25V5.5A1 1 0 0 0 11.5 4.5H7L5.5 3H2.5A1 1 0 0 0 1.5 4Z'/></svg>") 6796 center / 13px 13px no-repeat; 6797 mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14' fill='none' stroke='white' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'><path d='M1.5 5V11.25A1 1 0 0 0 2.5 12.25H11.5A1 1 0 0 0 12.5 11.25V5.5A1 1 0 0 0 11.5 4.5H7L5.5 3H2.5A1 1 0 0 0 1.5 4Z'/></svg>") 6798 center / 13px 13px no-repeat; 6799 opacity: 0.58; 6800 } 6801 .lib-tree-row[data-kind="directory"][aria-expanded="true"] 6802 .lib-tree-caret { 6803 transform: none; 6804 opacity: 0.82; 6805 } 6806 .lib-tree-row[data-kind="directory"]:hover .lib-tree-caret { 6807 opacity: 0.88; 6808 } 6809 .lib-tree-row[aria-expanded="true"] .lib-tree-caret { 6810 transform: rotate(90deg); 6811 } 6812 .lib-tree-children { 6813 margin-left: 0; 6814 padding-left: 0; 6815 border-left: 0; 6816 } 6817 .lib-tree-label { 6818 min-width: 0; 6819 flex: 1; 6820 overflow: hidden; 6821 text-overflow: ellipsis; 6822 white-space: nowrap; 6823 } 6824 .lib-tree-empty { 6825 padding: 3px 12px 0 42px; 6826 font-size: 11.5px; 6827 font-style: italic; 6828 color: var(--fg-subtle); 6829 } 6830 6831 /* ---------- Global Settings Drawer ---------- */ 6832 .gs-overlay { 6833 position: fixed; 6834 inset: 0; 6835 background: rgba(10, 14, 20, 0); 6836 backdrop-filter: blur(0px); 6837 -webkit-backdrop-filter: blur(0px); 6838 z-index: 9998; 6839 pointer-events: none; 6840 transition: 6841 background-color 220ms ease, 6842 backdrop-filter 220ms ease; 6843 } 6844 .gs-overlay.is-open { 6845 background: rgba(10, 14, 20, 0.32); 6846 backdrop-filter: blur(3px); 6847 -webkit-backdrop-filter: blur(3px); 6848 pointer-events: auto; 6849 } 6850 :root[data-theme="dark"] .gs-overlay.is-open { 6851 background: rgba(0, 0, 0, 0.5); 6852 } 6853 6854 .gs-drawer { 6855 position: fixed; 6856 top: 0; 6857 right: 0; 6858 height: 100vh; 6859 width: min(440px, 100vw); 6860 background: var(--bg-canvas); 6861 border-left: 1px solid var(--border-soft); 6862 box-shadow: none; 6863 z-index: 9999; 6864 display: flex; 6865 flex-direction: column; 6866 font-family: var(--font-sans); 6867 color: var(--fg-default); 6868 transform: translateX(100%); 6869 transition: transform 260ms cubic-bezier(0.2, 0.7, 0.2, 1); 6870 } 6871 .gs-drawer.is-open { 6872 box-shadow: -24px 0 48px -24px rgba(0, 0, 0, 0.24); 6873 transform: translateX(0); 6874 } 6875 @media (prefers-reduced-motion: reduce) { 6876 .lib-drawer, 6877 .lib-overlay, 6878 .gs-drawer, 6879 .gs-overlay { 6880 transition: none; 6881 } 6882 } 6883 6884 .gs-header { 6885 display: flex; 6886 align-items: center; 6887 gap: 12px; 6888 padding: 18px 20px 16px; 6889 border-bottom: 1px solid var(--border-soft); 6890 flex-shrink: 0; 6891 } 6892 .gs-header-title { 6893 font-size: 15px; 6894 font-weight: 600; 6895 letter-spacing: -0.01em; 6896 color: var(--fg-default); 6897 margin: 0; 6898 flex: 1; 6899 } 6900 .gs-close { 6901 background: transparent; 6902 border: 1px solid transparent; 6903 border-radius: 6px; 6904 width: 28px; 6905 height: 28px; 6906 display: inline-flex; 6907 align-items: center; 6908 justify-content: center; 6909 cursor: pointer; 6910 color: var(--fg-muted); 6911 transition: 6912 background-color 140ms ease, 6913 color 140ms ease, 6914 border-color 140ms ease; 6915 } 6916 .gs-close:hover { 6917 background: var(--border-soft); 6918 color: var(--fg-default); 6919 } 6920 .gs-close:focus-visible { 6921 outline: none; 6922 border-color: var(--accent-fg); 6923 } 6924 6925 .gs-body { 6926 flex: 1; 6927 overflow-y: auto; 6928 padding: 20px; 6929 display: flex; 6930 flex-direction: column; 6931 gap: 18px; 6932 } 6933 6934 .gs-section-label { 6935 font-size: 10.5p
6935x; 6936 font-weight: 600; 6937 text-transform: uppercase; 6938 letter-spacing: 0.08em; 6939 color: var(--fg-subtle); 6940 margin: 0 0 10px; 6941 } 6942 6943 /* Status chip */ 6944 .gs-status { 6945 display: inline-flex; 6946 align-items: center; 6947 gap: 8px; 6948 padding: 8px 12px; 6949 border-radius: 999px; 6950 background: var(--border-soft); 6951 font-size: 12px; 6952 font-weight: 500; 6953 color: var(--fg-muted); 6954 align-self: flex-start; 6955 transition: 6956 background-color 160ms ease, 6957 color 160ms ease; 6958 } 6959 .gs-status-dot { 6960 width: 7px; 6961 height: 7px; 6962 border-radius: 50%; 6963 background: var(--fg-subtle); 6964 box-shadow: 0 0 0 0 rgba(86, 171, 100, 0); 6965 transition: 6966 background-color 160ms ease, 6967 box-shadow 160ms ease; 6968 } 6969 .gs-status.is-ready { 6970 background: rgba(86, 171, 100, 0.14); 6971 color: rgb(46, 120, 60); 6972 } 6973 .gs-status.is-ready .gs-status-dot { 6974 background: rgb(86, 171, 100); 6975 box-shadow: 0 0 0 3px rgba(86, 171, 100, 0.18); 6976 } 6977 :root[data-theme="dark"] .gs-status.is-ready { 6978 color: rgb(140, 210, 150); 6979 } 6980 .gs-status-text-mono { 6981 font-family: var(--font-mono); 6982 font-size: 11px; 6983 color: inherit; 6984 opacity: 0.85; 6985 } 6986 6987 /* Segmented control */ 6988 .gs-segmented { 6989 display: grid; 6990 grid-template-columns: 1fr 1fr; 6991 gap: 4px; 6992 padding: 4px; 6993 background: var(--border-soft); 6994 border-radius: 10px; 6995 position: relative; 6996 } 6997 .gs-seg-btn { 6998 appearance: none; 6999 background: transparent; 7000 border: 1px solid transparent; 7001 border-radius: 6px; 7002 padding: 6px 12px; 7003 font: inherit; 7004 font-size: 12.5px; 7005 font-weight: 500; 7006 color: var(--fg-muted); 7007 cursor: pointer; 7008 display: inline-flex; 7009 align-items: center; 7010 justify-content: center; 7011 gap: 7px; 7012 transition: 7013 background-color 180ms ease, 7014 color 180ms ease, 7015 box-shadow 180ms ease; 7016 } 7017 .gs-seg-btn:hover { 7018 color: var(--fg-default); 7019 } 7020 .gs-seg-btn[aria-pressed="true"] { 7021 background: var(--bg-canvas); 7022 color: var(--fg-default); 7023 box-shadow: 7024 0 1px 2px rgba(0, 0, 0, 0.06), 7025 0 1px 4px rgba(0, 0, 0, 0.04); 7026 } 7027 :root[data-theme="dark"] .gs-seg-btn[aria-pressed="true"] { 7028 background: rgba(255, 255, 255, 0.06); 7029 box-shadow: 7030 0 1px 2px rgba(0, 0, 0, 0.4), 7031 inset 0 0 0 1px rgba(255, 255, 255, 0.04); 7032 } 7033 .gs-seg-btn:focus-visible { 7034 outline: none; 7035 border-color: var(--accent-fg); 7036 } 7037 .gs-seg-icon { 7038 width: 14px; 7039 height: 14px; 7040 flex-shrink: 0; 7041 } 7042 7043 /* Provider config cards */ 7044 .gs-card { 7045 border: 1px solid var(--border-soft); 7046 border-radius: 12px; 7047 padding: 16px; 7048 background: var(--bg-canvas); 7049 display: flex; 7050 flex-direction: column; 7051 gap: 14px; 7052 opacity: 0; 7053 transform: translateY(4px); 7054 animation: gs-card-in 260ms ease forwards; 7055 } 7056 @keyframes gs-card-in { 7057 to { 7058 opacity: 1; 7059 transform: translateY(0); 7060 } 7061 } 7062 .gs-card[hidden] { 7063 display: none; 7064 } 7065 7066 .gs-field { 7067 display: flex; 7068 flex-direction: column; 7069 gap: 6px; 7070 } 7071 .gs-field-label { 7072 font-size: 11px; 7073 font-weight: 600; 7074 text-transform: uppercase; 7075 letter-spacing: 0.06em; 7076 color: var(--fg-subtle); 7077 } 7078 .gs-input-wrap { 7079 position: relative; 7080 display: flex; 7081 align-items: center; 7082 } 7083 .gs-input { 7084 flex: 1; 7085 border: 1px solid var(--border-default); 7086 border-radius: 8px; 7087 padding: 10px 12px; 7088 font-size: 13px; 7089 color: var(--fg-default); 7090 background: var(--bg-canvas); 7091 font-family: var(--font-mono); 7092 transition: 7093 border-color 140ms ease, 7094 box-shadow 140ms ease; 7095 } 7096 .gs-input:focus { 7097 outline: none; 7098 border-color: var(--accent-fg); 7099 box-shadow: 0 0 0 3px rgba(47, 129, 247, 0.16); 7100 } 7101 .gs-input.has-reveal { 7102 padding-right: 40px; 7103 } 7104 .gs-reveal-btn { 7105 position: absolute; 7106 right: 6px; 7107 top: 50%; 7108 transform: translateY(-50%); 7109 background: transparent; 7110 border: 1px solid transparent; 7111 border-radius: 6px; 7112 width: 28px; 7113 height: 28px; 7114 display: inline-flex; 7115 align-items: center; 7116 justify-content: center; 7117 cursor: pointer; 7118 color: var(--fg-subtle); 7119 transition: 7120 color 140ms ease, 7121 background-color 140ms ease; 7122 } 7123 .gs-reveal-btn:hover { 7124 color: var(--fg-default); 7125 background: var(--border-soft); 7126 } 7127 .gs-reveal-btn:focus-visible { 7128 outline: none; 7129 border-color: var(--accent-fg); 7130 } 7131 7132 .gs-field-hint { 7133 font-size: 11px; 7134 color: var(--fg-subtle); 7135 line-height: 1.45; 7136 margin: 0; 7137 } 7138 .gs-field-hint a { 7139 color: var(--accent-fg); 7140 text-decoration: none; 7141 } 7142 .gs-field-hint a:hover { 7143 text-decoration: underline; 7144 } 7145 7146 .gs-actions-row { 7147 display: flex; 7148 gap: 8px; 7149 align-items: center; 7150 flex-wrap: wrap; 7151 } 7152 .gs-test-btn { 7153 appearance: none; 7154 background: var(--bg-canvas); 7155 border: 1px solid var(--border-default); 7156 border-radius: 8px; 7157 padding: 8px 14px; 7158 font: inherit; 7159 font-size: 12px; 7160 font-weight: 500; 7161 color: var(--fg-default); 7162 cursor: pointer; 7163 display: inline-flex; 7164 align-items: center; 7165 gap: 6px; 7166 transition: 7167 border-color 140ms ease, 7168 background-color 140ms ease, 7169 color 140ms ease; 7170 } 7171 .gs-test-btn:hover:not(:disabled) { 7172 border-color: var(--accent-fg); 7173 color: var(--accent-fg); 7174 } 7175 .gs-test-btn:disabled { 7176 opacity: 0.55;
7177 cursor: not-allowed; 7178 } 7179 .gs-test-result { 7180 font-size: 11.5px; 7181 color: var(--fg-muted); 7182 display: inline-flex; 7183 align-items: center; 7184 gap: 5px; 7185 } 7186 .gs-test-result.is-ok { 7187 color: rgb(46, 120, 60); 7188 } 7189 .gs-test-result.is-err { 7190 color: rgb(190, 70, 70); 7191 } 7192 :root[data-theme="dark"] .gs-test-result.is-ok { 7193 color: rgb(140, 210, 150); 7194 } 7195 :root[data-theme="dark"] .gs-test-result.is-err { 7196 color: rgb(240, 140, 140); 7197 } 7198 7199 /* Card header: context subtitle + activation affordance */ 7200 .gs-card-head { 7201 display: flex; 7202 align-items: center; 7203 justify-content: space-between; 7204 gap: 12px; 7205 margin: -2px 0 2px; 7206 } 7207 .gs-card-sub { 7208 flex: 1 1 auto; 7209 min-width: 0; 7210 font-size: 11.5px; 7211 color: var(--fg-subtle); 7212 margin: 0; 7213 line-height: 1.4; 7214 } 7215 .gs-in-use-badge, 7216 .gs-activate-btn { 7217 flex-shrink: 0; 7218 } 7219 .gs-activate-btn { 7220 appearance: none; 7221 background: transparent; 7222 border: 1px solid var(--border-default); 7223 border-radius: 999px; 7224 padding: 6px 12px; 7225 font: inherit; 7226 font-size: 11.5px; 7227 font-weight: 500; 7228 color: var(--fg-muted); 7229 cursor: pointer; 7230 transition: 7231 border-color 160ms ease, 7232 color 160ms ease, 7233 background-color 160ms ease, 7234 opacity 160ms ease; 7235 } 7236 .gs-activate-btn:hover:not(:disabled) { 7237 border-color: var(--accent-fg); 7238 color: var(--accent-fg); 7239 } 7240 .gs-activate-btn:disabled { 7241 opacity: 0.5; 7242 cursor: not-allowed; 7243 border-color: var(--border-soft); 7244 color: var(--fg-muted); 7245 } 7246 .gs-activate-btn:focus-visible { 7247 outline: none; 7248 border-color: var(--accent-fg); 7249 box-shadow: 0 0 0 3px rgba(47, 129, 247, 0.16); 7250 } 7251 .gs-in-use-badge { 7252 display: inline-flex; 7253 align-items: center; 7254 gap: 6px; 7255 padding: 6px 12px; 7256 border-radius: 999px; 7257 background: rgba(86, 171, 100, 0.14); 7258 color: rgb(46, 120, 60); 7259 font-size: 11.5px; 7260 font-weight: 500; 7261 letter-spacing: 0.01em; 7262 } 7263 .gs-in-use-badge::before { 7264 content: ""; 7265 width: 6px; 7266 height: 6px; 7267 border-radius: 50%; 7268 background: rgb(86, 171, 100); 7269 box-shadow: 0 0 0 3px rgba(86, 171, 100, 0.18); 7270 } 7271 :root[data-theme="dark"] .gs-in-use-badge { 7272 color: rgb(140, 210, 150); 7273 } 7274 [hidden].gs-in-use-badge, 7275 [hidden].gs-activate-btn { 7276 display: none; 7277 } 7278 7279 .gs-foot { 7280 padding: 14px 20px; 7281 border-top: 1px solid var(--border-soft); 7282 font-size: 11px; 7283 color: var(--fg-subtle); 7284 line-height: 1.5; 7285 flex-shrink: 0; 7286 } 7287 7288 .doc-highlight { 7289 color: inherit; 7290 cursor: pointer; 7291 background: var(--reader-highlight-bg, rgba(180, 200, 140, 0.22)); 7292 box-shadow: var( 7293 --reader-highlight-shadow, 7294 inset 0 -1px 0 rgba(140, 170, 100, 0.22) 7295 ); 7296 border-radius: 0.22em; 7297 box-decoration-break: clone; 7298 -webkit-box-decoration-break: clone; 7299 transition: 7300 background-color 180ms ease, 7301 box-shadow 180ms ease; 7302 } 7303 :root[data-theme="dark"][data-reader-palette="original"] .doc-highlight { 7304 background: rgba(180, 200, 140, 0.16); 7305 box-shadow: inset 0 -1px 0 rgba(180, 200, 140, 0.16); 7306 } 7307 .doc-highlight:hover, 7308 .doc-highlight.is-active { 7309 background: var(--reader-highlight-hover-bg, rgba(180, 200, 140, 0.36)); 7310 box-shadow: var( 7311 --reader-highlight-hover-shadow, 7312 inset 0 -1px 0 rgba(140, 170, 100, 0.32) 7313 ); 7314 } 7315 :root[data-theme="dark"][data-reader-palette="original"] 7316 .doc-highlight:hover, 7317 :root[data-theme="dark"][data-reader-palette="original"] 7318 .doc-highlight.is-active { 7319 background: rgba(180, 200, 140, 0.28); 7320 box-shadow: inset 0 -1px 0 rgba(180, 200, 140, 0.24); 7321 } 7322 7323 .selection-bubble, 7324 .ctx-menu, 7325 .highlight-remove-chip, 7326 .highlight-undo { 7327 position: fixed; 7328 z-index: 2000; 7329 background: var(--bg-raised); 7330 border: 1px solid var(--border-soft); 7331 border-radius: 12px; 7332 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08); 7333 } 7334 :root[data-theme="dark"] .selection-bubble, 7335 :root[data-theme="dark"] .ctx-menu, 7336 :root[data-theme="dark"] .highlight-remove-chip, 7337 :root[data-theme="dark"] .highlight-undo { 7338 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); 7339 } 7340 .selection-bubble.hidden, 7341 .ctx-menu.hidden, 7342 .highlight-remove-chip.hidden, 7343 .highlight-undo.hidden { 7344 display: none; 7345 } 7346 7347 .selection-bubble { 7348 display: flex; 7349 align-items: center; 7350 gap: 6px; 7351 padding: 6px; 7352 transform: translateY(6px); 7353 opacity: 0; 7354 transition: 7355 opacity 120ms ease, 7356 transform 120ms ease; 7357 } 7358 .selection-bubble.is-visible { 7359 transform: translateY(0); 7360 opacity: 1; 7361 } 7362 .selection-action, 7363 .highlight-remove-chip, 7364 .ctx-menu-item, 7365 .highlight-undo-btn { 7366 touch-action: manipulation; 7367 } 7368 .selection-action { 7369 display: inline-flex; 7370 align-items: center; 7371 gap: 6px; 7372 min-height: 32px; 7373 padding: 0 10px; 7374 border: 1px solid transparent; 7375 border-radius: 8px; 7376 background: transparent; 7377 color: var(--fg-default); 7378 font-family: var(--font-sans); 7379 font-size: 12px; 7380 font-weight: 600; 7381 cursor: pointer; 7382 white-space: nowrap; 7383 transition: 7384 background-color 140ms ease, 7385 border-color 140ms ease, 7386 color 140ms ease, 7387 transform 120ms ease; 7388 } 7389 .selection-action:hover { 7390 background: var(--bg-subtle); 7391 } 7392 .selection-action:active { 7393 transform: scale(0.98); 7394 } 7395 .selection-action svg { 7396 flex-shrink: 0; 7397 color: var(--fg-muted); 7398 } 7399 .selection-action-primary { 7400 background: color-mix(in srgb, var(--accent-fg) 9%, transparent); 7401 border-color: color-mix(in srgb, var(--accent-fg) 24%, transparent); 7402 color: var(--accent-hover); 7403 } 7404 .selection-action-primary:hover { 7405 background: color-mix(in srgb, var(--accent-fg) 14%, transparent); 7406 border-color: color-mix(in srgb, var(--accent-fg) 34%, transparent); 7407 color: var(--accent-hover); 7408 } 7409 .selection-action-primary svg { 7410 color: currentColor; 7411 } 7412 7413 .highlight-remove-chip { 7414 display: inline-flex; 7415 align-items: center; 7416 gap: 6px; 7417 min-height: 32px; 7418 padding: 0 11px; 7419 color: var(--fg-default); 7420 font-family: var(--font-sans); 7421 font-size: 12px; 7422 font-weight: 600; 7423 cursor: pointer; 7424 } 7425 .highlight-remove-chip:hover { 7426 background: var(--bg-subtle); 7427 } 7428 7429 .highlight-undo { 7430 z-index: 2100; 7431 right: 20px; 7432 bottom: 20px; 7433 display: flex; 7434 align-items: center; 7435 gap: 12px; 7436 max-width: min(360px, calc(100vw - 32px)); 7437 padding: 10px 12px; 7438 } 7439 .highlight-undo-message { 7440 font-family: var(--font-sans); 7441 font-size: 12px; 7442 line-height: 1.45; 7443 color: var(--fg-default); 7444 } 7445 .highlight-undo-btn { 7446 display: inline-flex; 7447 align-items: center; 7448 justify-content: center; 7449 min-height: 30px; 7450 padding: 0 10px; 7451 border: 0; 7452 border-radius: 7px; 7453 background: var(--fg-default); 7454 color: var(--bg-canvas); 7455 font-family: var(--font-sans); 7456 font-size: 12px; 7457 font-weight: 600; 7458 cursor: pointer; 7459 } 7460 7461 /* ---------- Custom Context Menu ---------- */ 7462 .ctx-menu { 7463 padding: 4px; 7464 min-width: 180px; 7465 font-family: var(--font-sans); 7466 font-size: 13px; 7467 } 7468 .ctx-menu-item { 7469 display: flex; 7470 align-items: center; 7471 gap: 8px; 7472 padding: 7px 10px; 7473 border-radius: 6px; 7474 cursor: pointer; 7475 color: var(--fg-default); 7476 border: none; 7477 background: none; 7478 width: 100%; 7479 text-align: left; 7480 font-size: 13px; 7481 font-family: var(--font-sans); 7482 } 7483 .ctx-menu-item:hover { 7484 background: var(--bg-subtle); 7485 } 7486 .ctx-menu-item svg { 7487 flex-shrink: 0; 7488 color: var(--fg-muted); 7489 } 7490 7491 @media (pointer: coarse) { 7492 .selection-bubble { 7493 display: none !important; 7494 } 7495 .highlight-remove-chip { 7496 min-height: 44px; 7497 padding: 0 16px; 7498 font-size: 14px; 7499 border-radius: 12px; 7500 } 7501 } 7502 7503 @media (prefers-reduced-motion: reduce) { 7504 .selection-bubble, 7505 .doc-highlight { 7506 transition: none; 7507 } 7508 .ont-section-badge.is-loading { 7509 animation: none; 7510 } 7511 } 7512 7513 @media print { 7514 .selection-bubble, 7515 .ctx-menu, 7516 .highlight-remove-chip, 7517 .highlight-undo, 7518 .chat-fab, 7519 .scroll-top-btn, 7520 .chat-panel, 7521 .ont-panel { 7522 display: none !important; 7523 } 7524 .doc-highlight { 7525 background: transparent !important; 7526 box-shadow: inset 0 -1px 0 currentColor !important; 7527 } 7528 } 7529 </style> 7530 </head> 7531 <body> 7532 <div class="app"> 7533 <header class="toolbar" role="toolbar" aria-label="Viewer controls"> 7534 <div 7535 class="brand" 7536 title="Markdown Viewer" 7537 aria-label="MarkV â Markdown Viewer" 7538 >
7539 <span class="brand-mark" aria-hidden="true"> 7540 <svg 7541 xmlns="http://www.w3.org/2000/svg" 7542 viewBox="0 0 100 100" 7543 fill="currentColor" 7544 aria-hidden="true" 7545 focusable="false" 7546 > 7547 <!-- Four quadrants: straight inner edges to the cross gap, concave outer arcs through each corner --> 7548 <path d="M46 0L46 46L0 46Q6 6 46 0Z" /> 7549 <path d="M54 0L54 46L100 46Q94 6 54 0Z" /> 7550 <path d="M46 100L46 54L0 54Q6 94 46 100Z" /> 7551 <path d="M54 100L54 54L100 54Q94 94 54 100Z" /> 7552 </svg> 7553 </span> 7554 <span class="brand-name" id="brandName">MarkV</span> 7555 <span 7556 id="brandDirty" 7557 class="brand-dirty" 7558 aria-hidden="true" 7559 hidden 7560 ></span> 7561 </div> 7562 7563 <div id="status" class="status">Ready</div> 7564 <span id="wordCount" class="word-count" hidden></span> 7565 <div 7566 id="liveRegion" 7567 class="visually-hidden" 7568 aria-live="polite" 7569 aria-atomic="true" 7570 ></div> 7571 <div class="spacer"></div> 7572 7573 <!-- ââ Cluster: File â New · Files · Save âââââââââââââââ 7574 Save auto-hides in preview mode via existing CSS 7575 `body.preview-mode #saveBtn { display: none !important }`. --> 7576 <div class="tb-group" role="group" aria-label="File"> 7577 <button 7578 id="newDocumentBtn" 7579 type="button" 7580 class="btn btn--new" 7581 title="New entry" 7582 > 7583 <span class="btn-prefix" aria-hidden="true">+</span> 7584 New 7585 </button> 7586 7587 <button 7588 id="libraryToggleBtn" 7589 type="button" 7590 class="btn" 7591 title="Open files" 7592 aria-label="Open files" 7593 aria-haspopup="dialog" 7594 aria-controls="libraryDrawer" 7595 aria-expanded="false" 7596 > 7597 <svg 7598 width="15" 7599 height="15"
7600 viewBox="0 0 24 24" 7601 fill="none" 7602 stroke="currentColor" 7603 stroke-width="1.75" 7604 stroke-linecap="round" 7605 stroke-linejoin="round" 7606 aria-hidden="true" 7607 > 7608 <path 7609 d="M3 7.5A2.5 2.5 0 0 1 5.5 5H10l2 2h6.5A2.5 2.5 0 0 1 21 9.5v7A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5Z" 7610 /> 7611 </svg> 7612 Files 7613 </button> 7614 7615 <button 7616 id="commandPaletteBtn" 7617 type="button" 7618 class="btn" 7619 data-shortcut-id="global.search" 7620 data-title-base="Search files and commands" 7621 title="Search files and commands" 7622 aria-label="Search files and commands" 7623 aria-haspopup="dialog" 7624 aria-controls="commandPaletteDialog" 7625 aria-expanded="false" 7626 data-mv-overflow-section="File" 7627 > 7628 Search 7629 </button> 7630 7631 <button 7632 id="saveBtn" 7633 type="button" 7634 class="btn" 7635 data-shortcut-id="global.save" 7636 data-title-base="Save current document" 7637 title="Save current document" 7638 disabled 7639 hidden 7640 data-mv-overflow-section="File" 7641 > 7642 Save 7643 </button> 7644 </div> 7645 7646 <span class="tb-divider" aria-hidden="true"></span> 7647 7648 <!-- ââ Cluster: Edit & insights â Mode · Document insights ââ 7649 Edit-mode toggle paired with the document insights popover. 7650 Both pertain to "working with the current document." --> 7651 <div class="tb-group" role="group" aria-label="Edit and insights"> 7652 <button 7653 id="modeToggleBtn" 7654 type="button" 7655 class="btn mode-toggle-btn" 7656 data-shortcut-id="global.mode" 7657 data-title-base="Toggle edit mode" 7658 title="Toggle edit mode" 7659 aria-pressed="false" 7660 > 7661 <svg 7662 id="modeIconEdit" 7663 width="14" 7664 height="14"
7665 viewBox="0 0 16 16" 7666 fill="currentColor" 7667 aria-hidden="true" 7668 > 7669 <path 7670 d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z" 7671 /> 7672 </svg> 7673 <svg 7674 id="modeIconPreview" 7675 width="14" 7676 height="14" 7677 viewBox="0 0 16 16" 7678 fill="currentColor" 7679 aria-hidden="true" 7680 style="display: none" 7681 > 7682 <path 7683 d="M8 2C4.5 2 1.73 4.11.07 7.18a1.5 1.5 0 0 0 0 1.64C1.73 11.89 4.5 14 8 14s6.27-2.11 7.93-5.18a1.5 1.5 0 0 0 0-1.64C14.27 4.11 11.5 2 8 2Zm0 10a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" 7684 /> 7685 </svg> 7686 <span id="modeToggleLabel">Edit</span> 7687 </button> 7688 7689 <div class="ont-settings" data-mv-overflow-section="View"> 7690 <button 7691 id="ontologyToggleBtn" 7692 type="button" 7693 class="btn btn-icon" 7694 title="Document insights" 7695 aria-label="Document insights" 7696 aria-expanded="false" 7697 aria-controls="ontologyPanel" 7698 > 7699 <svg 7700 width="14" 7701 height="14"
7702 viewBox="0 0 16 16" 7703 fill="currentColor" 7704 aria-hidden="true" 7705 > 7706 <path 7707 d="M2.5 4a2.5 2.5 0 1 1 5 0 2.5 2.5 0 0 1-5 0ZM5 2.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm6.5 2a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm2-1a1 1 0 1 0 0 2 1 1 0 0 0 0-2ZM5 7a3 3 0 0 0-3 3v1.75a.75.75 0 0 0 1.5 0V10A1.5 1.5 0 0 1 5 8.5h1a.75.75 0 0 0 0-1.5H5Zm8 3a2 2 0 0 0-2-2h-.5a.75.75 0 0 0 0 1.5h.5a.5.5 0 0 1 .5.5v1.75a.75.75 0 0 0 1.5 0V10Z" 7708 /> 7709 </svg> 7710 </button> 7711 <div 7712 id="ontologyPanel" 7713 class="ont-panel hidden" 7714 role="region" 7715 aria-label="Document insights" 7716 aria-hidden="true" 7717 hidden 7718 > 7719 <div class="ont-header"> 7720 <span class="ont-title">Document Insights</span> 7721 <div style="display: flex; align-items: center; gap: 8px"> 7722 <span id="ontSummary" class="ont-summary"></span> 7723 <button 7724 id="ontCloseBtn" 7725 type="button" 7726 class="ont-close" 7727 aria-label="Close insights" 7728 > 7729 × 7730 </button> 7731 </div> 7732 </div> 7733 <div id="ontBody"> 7734 <div class="ont-empty">No content to analyze.</div> 7735 </div> 7736 </div> 7737 </div> 7738 </div> 7739 7740 <span class="tb-divider" aria-hidden="true"></span> 7741 7742 <!-- ââ Cluster: Refresh â Reload · Auto-refresh ââââââââââ --> 7743 <div 7744 class="tb-group" 7745 role="group" 7746 aria-label="Refresh" 7747 data-mv-overflow-section="Live" 7748 > 7749 <button 7750 id="reloadBtn" 7751 type="button" 7752 class="btn btn-icon" 7753 title="Reload current file" 7754 disabled 7755 aria-label="Reload" 7756 > 7757 <svg 7758 width="14" 7759 height="14"
7760 viewBox="0 0 16 16" 7761 fill="currentColor" 7762 aria-hidden="true" 7763 > 7764 <path 7765 d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .655-.834ZM8 1.5a6.5 6.5 0 0 0-4.95 2.277L1.67 2.397A.25.25 0 0 0 1.244 2.574v3.646a.25.25 0 0 0 .25.25h3.646a.25.25 0 0 0 .177-.427L3.942 4.84A5.5 5.5 0 0 1 13.5 8.16a.75.75 0 0 0 1.49-.16A7.003 7.003 0 0 0 8 1.5Z" 7766 /> 7767 </svg> 7768 </button> 7769 7770 <label 7771 class="auto-refresh" 7772 title="Auto refresh works for opened file handles" 7773 > 7774 <input id="autoRefreshToggle" type="checkbox" disabled /> 7775 <span>Auto</span> 7776 </label> 7777 </div> 7778 7779 <span class="tb-divider" aria-hidden="true"></span> 7780 7781 <!-- ââ Cluster: Display preferences â Reading · Theme ââââ --> 7782 <div 7783 class="tb-group" 7784 role="group" 7785 aria-label="Display preferences" 7786 data-mv-overflow-section="View" 7787 > 7788 <div class="reading-settings"> 7789 <button 7790 id="readingToggleBtn" 7791 type="button" 7792 class="btn" 7793 title="Adjust reading settings" 7794 aria-haspopup="dialog" 7795 aria-controls="readingPanel" 7796 aria-expanded="false" 7797 > 7798 <svg 7799 width="14" 7800 height="14"
7801 viewBox="0 0 16 16" 7802 fill="currentColor" 7803 aria-hidden="true" 7804 > 7805 <path 7806 d="M2.5 3a.75.75 0 0 1 .75-.75h9.5a.75.75 0 0 1 0 1.5h-9.5A.75.75 0 0 1 2.5 3Zm2 5a.75.75 0 0 1 .75-.75h5.5a.75.75 0 0 1 0 1.5h-5.5A.75.75 0 0 1 4.5 8Zm-2 5a.75.75 0 0 1 .75-.75h9.5a.75.75 0 0 1 0 1.5h-9.5A.75.75 0 0 1 2.5 13Z" 7807 /> 7808 </svg> 7809 Reading 7810 </button> 7811 7812 <div 7813 id="readingPanel" 7814 class="reading-panel" 7815 role="dialog" 7816 aria-modal="false" 7817 aria-labelledby="readingPanelTitle" 7818 aria-describedby="readingPanelHelp" 7819 aria-hidden="true" 7820 hidden 7821 > 7822 <div class="reading-panel-header"> 7823 <div> 7824 <h2 id="readingPanelTitle" class="reading-panel-title"> 7825 Reading 7826 </h2> 7827 <p id="readingPanelHelp" class="reading-panel-help"> 7828 Tune reading comfort. 7829 </p> 7830 </div> 7831 <span 7832 id="readingPresetCustom" 7833 class="reading-preset-custom" 7834 hidden 7835 >Custom</span 7836 > 7837 </div> 7838 7839 <section class="reading-panel-section"> 7840 <span class="reading-section-label">Preset</span> 7841 <div 7842 id="readingPresetGroup" 7843 class="reading-preset-group" 7844 role="radiogroup" 7845 aria-label="Reading preset" 7846 > 7847 <button 7848 type="button" 7849 class="btn reading-preset" 7850 data-preset="compact" 7851 role="radio" 7852 aria-checked="false" 7853 tabindex="-1" 7854 > 7855 Compact 7856 </button> 7857 <button 7858 type="button" 7859 class="btn reading-preset" 7860 data-preset="balanced" 7861 role="radio" 7862 aria-checked="true" 7863 tabindex="0" 7864 > 7865 Balanced 7866 </button> 7867 <button 7868 type="button" 7869 class="btn reading-preset" 7870 data-preset="spacious" 7871 role="radio" 7872 aria-checked="false" 7873 tabindex="-1" 7874 > 7875 Spacious 7876 </button> 7877 </div> 7878 </section> 7879 7880 <section class="reading-panel-section"> 7881 <span id="readingPaletteLabel" class="reading-section-label" 7882 >Palette</span 7883 > 7884 <div 7885 id="readingPaletteGroup" 7886 class="reading-palette-grid" 7887 role="radiogroup" 7888 aria-labelledby="readingPaletteLabel" 7889 > 7890 <button 7891 type="button" 7892 class="reading-palette" 7893 data-reader-palette="original" 7894 role="radio" 7895 aria-checked="true" 7896 tabindex="0" 7897 > 7898 <span 7899 class="reading-palette__swatch" 7900 aria-hidden="true" 7901 ></span> 7902 <span class="reading-palette__label">Original</span> 7903 </button> 7904 <button 7905 type="button" 7906 class="reading-palette" 7907 data-reader-palette="ivory" 7908 role="radio" 7909 aria-checked="false" 7910 tabindex="-1" 7911 > 7912 <span 7913 class="reading-palette__swatch" 7914 aria-hidden="true" 7915 ></span>
7916 <span class="reading-palette__label">Ivory</span> 7917 </button> 7918 <button 7919 type="button" 7920 class="reading-palette" 7921 data-reader-palette="manilla" 7922 role="radio" 7923 aria-checked="false" 7924 tabindex="-1" 7925 > 7926 <span 7927 class="reading-palette__swatch" 7928 aria-hidden="true" 7929 ></span> 7930 <span class="reading-palette__label">Manilla</span> 7931 </button> 7932 <button 7933 type="button" 7934 class="reading-palette" 7935 data-reader-palette="cloud" 7936 role="radio" 7937 aria-checked="false" 7938 tabindex="-1" 7939 > 7940 <span 7941 class="reading-palette__swatch" 7942 aria-hidden="true" 7943 ></span> 7944 <span class="reading-palette__label">Cloud</span> 7945 </button> 7946 <button 7947 type="button" 7948 class="reading-palette" 7949 data-reader-palette="slate" 7950 role="radio" 7951 aria-checked="false" 7952 tabindex="-1" 7953 > 7954 <span 7955 class="reading-palette__swatch" 7956 aria-hidden="true" 7957 ></span> 7958 <span class="reading-palette__label">Slate</span> 7959 </button> 7960 <button 7961 type="button" 7962 class="reading-palette" 7963 data-reader-palette="garden" 7964 role="radio" 7965 aria-checked="false" 7966 tabindex="-1" 7967 > 7968 <span 7969 class="reading-palette__swatch" 7970 aria-hidden="true" 7971 ></span> 7972 <span class="reading-palette__label">Garden</span> 7973 </button> 7974 </div> 7975 </section> 7976 7977 <section class="reading-panel-section"> 7978 <div class="reading-control"> 7979 <div class="reading-control-head"> 7980 <label for="readingFontSize">Text Size</label> 7981 <output 7982 id="readingFontSizeValue" 7983 class="reading-control-value" 7984 for="readingFontSize" 7985 ></output> 7986 </div> 7987 <input 7988 id="readingFontSize" 7989 class="reading-range" 7990 type="range" 7991 min="16" 7992 max="22" 7993 step="0.5" 7994 value="19" 7995 /> 7996 </div> 7997 7998 <div class="reading-control"> 7999 <div class="reading-control-head"> 8000 <label for="readingLineHeight">Line Spacing</label> 8001 <output 8002 id="readingLineHeightValue" 8003 class="reading-control-value" 8004 for="readingLineHeight" 8005 ></output> 8006 </div> 8007 <input 8008 id="readingLineHeight" 8009 class="reading-range" 8010 type="range" 8011 min="1.45" 8012 max="1.9" 8013 step="0.02" 8014 value="1.72" 8015 /> 8016 </div> 8017 8018 <div class="reading-control reading-width-control"> 8019 <div class="reading-control-head"> 8020 <label for="readingMeasure">Reading Width</label> 8021 <output 8022 id="readingMeasureValue" 8023 class="reading-control-value" 8024 for="readingMeasure" 8025 ></output> 8026 </div> 8027 <input 8028 id="readingMeasure" 8029 class="reading-range" 8030 type="range" 8031 min="560" 8032 max="820"
8033 step="20" 8034 value="680" 8035 /> 8036 </div> 8037 </section> 8038 8039 <div class="reading-actions"> 8040 <button 8041 id="readingResetBtn" 8042 type="button" 8043 class="btn" 8044 title="Reset to the Balanced preset" 8045 > 8046 Reset to Balanced 8047 </button> 8048 <button 8049 id="readingCloseBtn" 8050 type="button" 8051 class="btn" 8052 title="Close reading settings" 8053 > 8054 Close 8055 </button> 8056 </div> 8057 </div> 8058 </div> 8059 8060 <button 8061 id="themeToggleBtn" 8062 type="button" 8063 class="btn btn-icon" 8064 title="Toggle theme" 8065 aria-label="Toggle theme" 8066 > 8067 <!-- Active: state-neutral "contrast" icon. Reads as 8068 "theme toggle" regardless of current mode, following the 8069 iOS/macOS/Material `contrast` convention. 8070 Two-tone design: the filled half uses `--fg-default` 8071 (strong), the stroke uses `--fg-muted` (soft). Both 8072 tokens invert with theme, so light-on-dark becomes 8073 dark-on-light automatically â "vice versa" swap. */ --> 8074 <svg 8075 id="themeIconContrast" 8076 width="15" 8077 height="15"
8078 viewBox="0 0 24 24" 8079 aria-hidden="true" 8080 > 8081 <circle 8082 cx="12" 8083 cy="12" 8084 r="9" 8085 fill="none" 8086 stroke="var(--fg-muted)" 8087 stroke-width="1.75" 8088 /> 8089 <path d="M12 3 A9 9 0 0 1 12 21 Z" fill="var(--fg-default)" /> 8090 </svg> 8091 <!-- Legacy sun/moon icons preserved for easy revert. 8092 Remove this wrapper (or delete the `.theme-icons-legacy` 8093 CSS rule) to restore the state-aware icon swap. The JS 8094 in applyTheme() continues to toggle their inline display 8095 so swapping back is truly one-line. --> 8096 <span class="theme-icons-legacy" aria-hidden="true"> 8097 <svg 8098 id="themeIconSun" 8099 width="14" 8100 height="14" 8101 viewBox="0 0 16 16" 8102 fill="currentColor" 8103 aria-hidden="true" 8104 > 8105 <path 8106 d="M8 12a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-1.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0Zm0 13a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13Zm8-5a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8ZM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8Zm10.657-5.657a.75.75 0 0 1 0 1.061l-1.06 1.06a.75.75 0 1 1-1.061-1.06l1.06-1.06a.75.75 0 0 1 1.061 0Zm-9.193 9.193a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 1 1-1.061-1.06l1.06-1.061a.75.75 0 0 1 1.061 0Zm9.193 2.121a.75.75 0 0 1-1.06 0l-1.061-1.06a.75.75 0 0 1 1.06-1.061l1.061 1.06a.75.75 0 0 1 0 1.061ZM4.464 4.465a.75.75 0 0 1-1.06 0L2.343 3.404a.75.75 0 0 1 1.06-1.06l1.061 1.06a.75.75 0 0 1 0 1.06Z" 8107 /> 8108 </svg> 8109 <svg 8110 id="themeIconMoon" 8111 width="14" 8112 height="14"
8113 viewBox="0 0 16 16" 8114 fill="currentColor" 8115 aria-hidden="true" 8116 style="display: none" 8117 > 8118 <path 8119 d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z" 8120 /> 8121 </svg> 8122 </span> 8123 </button> 8124 </div> 8125 8126 <span class="tb-divider" aria-hidden="true"></span> 8127 8128 <!-- ââ Cluster: Mindmap, share & settings â Concept map · Snapshot link · Preferences ââ --> 8129 <div 8130 class="tb-group" 8131 role="group" 8132 aria-label="Mindmap, share, and settings" 8133 data-mv-overflow-section="Explore" 8134 > 8135 <button 8136 id="mindmapBtn" 8137 type="button" 8138 class="btn" 8139 title="Open concept mindmap" 8140 aria-label="Open concept mindmap" 8141 aria-pressed="false" 8142 > 8143 <svg 8144 width="14" 8145 height="14"
8146 viewBox="0 0 24 24" 8147 fill="none" 8148 stroke="currentColor" 8149 stroke-width="1.75" 8150 stroke-linecap="round" 8151 stroke-linejoin="round" 8152 aria-hidden="true" 8153 > 8154 <circle cx="12" cy="12" r="2.2"></circle> 8155 <circle cx="4" cy="5" r="1.8"></circle> 8156 <circle cx="20" cy="5" r="1.8"></circle> 8157 <circle cx="4" cy="19" r="1.8"></circle> 8158 <circle cx="20" cy="19" r="1.8"></circle> 8159 <path d="M12 12 L5.5 6"></path> 8160 <path d="M12 12 L18.5 6"></path> 8161 <path d="M12 12 L5.5 18"></path> 8162 <path d="M12 12 L18.5 18"></path> 8163 </svg> 8164 <span>Mindmap</span> 8165 </button> 8166 8167 <button 8168 id="shareBtn" 8169 type="button" 8170 class="btn" 8171 title="Share a snapshot link for the current markdown" 8172 disabled 8173 > 8174 <svg 8175 width="14" 8176 height="14"
8177 viewBox="0 0 24 24" 8178 fill="none" 8179 stroke="currentColor" 8180 stroke-width="1.75" 8181 stroke-linecap="round" 8182 stroke-linejoin="round" 8183 aria-hidden="true" 8184 > 8185 <circle cx="18" cy="5" r="2.2"></circle> 8186 <circle cx="6" cy="12" r="2.2"></circle> 8187 <circle cx="18" cy="19" r="2.2"></circle> 8188 <path d="M7.9 10.95 15.95 6.05"></path> 8189 <path d="M7.9 13.05 15.95 17.95"></path> 8190 </svg> 8191 <span id="shareBtnLabel">Share</span> 8192 </button> 8193 8194 <button 8195 id="paletteShortcutHint" 8196 type="button" 8197 class="btn palette-shortcut-hint" 8198 data-shortcut-id="global.search" 8199 aria-label="Open command palette" 8200 > 8201 âK 8202 </button> 8203 8204 <div class="export-split" data-mv-overflow-section="File"> 8205 <button 8206 id="exportBtn" 8207 type="button" 8208 class="export-split__main" 8209 title="Export current document" 8210 aria-label="Export current document" 8211 disabled 8212 > 8213 <svg 8214 class="icon"
8215 viewBox="0 0 24 24" 8216 fill="none" 8217 stroke="currentColor" 8218 stroke-width="1.75" 8219 stroke-linecap="round" 8220 stroke-linejoin="round" 8221 aria-hidden="true" 8222 > 8223 <path d="M12 3v12M7 10l5 5 5-5M5 21h14" /> 8224 </svg> 8225 <span id="exportLabel">Export PDF</span> 8226 </button> 8227 <span class="export-split__sep" aria-hidden="true"></span> 8228 <button 8229 id="exportCaretBtn" 8230 type="button" 8231 class="export-split__caret" 8232 aria-label="Choose export format" 8233 aria-haspopup="dialog" 8234 aria-controls="exportMenu" 8235 aria-expanded="false" 8236 disabled 8237 > 8238 <svg 8239 viewBox="0 0 24 24" 8240 fill="none" 8241 stroke="currentColor" 8242 stroke-width="2.4" 8243 stroke-linecap="round" 8244 stroke-linejoin="round" 8245 aria-hidden="true" 8246 > 8247 <polyline points="6 9 12 15 18 9" /> 8248 </svg> 8249 </button> 8250 <div 8251 id="exportMenu" 8252 class="export-menu" 8253 role="dialog" 8254 aria-label="Choose export format" 8255 hidden 8256 > 8257 <div class="export-menu__head">Format</div> 8258 <button id="exportPdfBtn" type="button" class="export-option"> 8259 <span class="export-option__label">PDF</span> 8260 <span class="export-option__ext">.pdf</span> 8261 </button> 8262 <button 8263 id="exportMarkdownBtn" 8264 type="button" 8265 class="export-option" 8266 > 8267 <span class="export-option__label">Markdown</span> 8268 <span class="export-option__ext">.md</span> 8269 </button> 8270 </div> 8271 </div> 8272 8273 <button 8274 id="globalSettingsBtn" 8275 type="button" 8276 class="btn btn-icon" 8277 title="Settings" 8278 aria-label="Open settings" 8279 aria-haspopup="dialog" 8280 aria-controls="globalSettingsDrawer" 8281 aria-expanded="false" 8282 > 8283 <svg 8284 width="14" 8285 height="14"
8286 viewBox="0 0 24 24" 8287 fill="none" 8288 stroke="currentColor" 8289 stroke-width="2" 8290 stroke-linecap="round" 8291 stroke-linejoin="round" 8292 aria-hidden="true" 8293 > 8294 <circle cx="12" cy="12" r="3" /> 8295 <path 8296 d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" 8297 /> 8298 </svg> 8299 </button> 8300 </div> 8301 8302 <span class="tb-divider" aria-hidden="true"></span> 8303 8304 <!-- ââ Cluster: Document actions â Clear (destructive) âââ --> 8305 <div 8306 class="tb-group" 8307 role="group" 8308 aria-label="Document actions" 8309 data-mv-overflow-section="Manage" 8310 > 8311 <button 8312 id="clearBtn" 8313 type="button" 8314 class="btn" 8315 title="Clear all content" 8316 > 8317 Clear 8318 </button> 8319 </div> 8320 8321 <!-- ââ Mobile-only "More" overflow trigger âââââââââââââââââââââ 8322 Hidden â¥641px. At â¤640px, JS teleports the secondary tb-group 8323 nodes into #mvMoreSheetBody and toggles this button's 8324 aria-expanded. See assets/toolbar-mobile.js. --> 8325 <button 8326 id="mvMoreBtn" 8327 type="button" 8328 class="btn btn-icon mv-more-btn" 8329 aria-haspopup="dialog" 8330 aria-controls="mvMoreSheet" 8331 aria-expanded="false" 8332 aria-label="More toolbar actions" 8333 title="More" 8334 > 8335 <svg 8336 width="16" 8337 height="16"
8338 viewBox="0 0 24 24" 8339 fill="currentColor" 8340 aria-hidden="true" 8341 focusable="false" 8342 > 8343 <circle cx="5" cy="12" r="1.8" /> 8344 <circle cx="12" cy="12" r="1.8" /> 8345 <circle cx="19" cy="12" r="1.8" /> 8346 </svg> 8347 </button> 8348 8349 <input 8350 id="fileInput" 8351 type="file" 8352 accept=".md,.markdown,.mdown,.mkdn,.txt,text/markdown,text/plain" 8353 hidden 8354 /> 8355 <input 8356 id="libraryFolderInput" 8357 type="file" 8358 webkitdirectory 8359 multiple 8360 hidden 8361 /> 8362 </header> 8363 8364 <!-- ââ Mobile More overflow sheet âââââââââââââââââââââââââââââââ 8365 Toolbar groups live inside .toolbar on desktop. On â¤640px, 8366 toolbar-mobile.js moves the secondary .tb-group elements into 8367 #mvMoreSheetBody so they remain a single source of truth 8368 (existing event handlers and IDs unchanged). --> 8369 <div 8370 id="mvMoreBackdrop" 8371 class="mv-more-backdrop" 8372 hidden 8373 aria-hidden="true" 8374 ></div> 8375 <aside 8376 id="mvMoreSheet" 8377 class="mv-more-sheet" 8378 role="dialog" 8379 aria-modal="true" 8380 aria-labelledby="mvMoreSheetTitle" 8381 aria-hidden="true" 8382 hidden 8383 > 8384 <header class="mv-more-sheet__header"> 8385 <h2 id="mvMoreSheetTitle" class="mv-more-sheet__title">More</h2> 8386 <button 8387 id="mvMoreCloseBtn" 8388 type="button" 8389 class="btn btn-icon mv-more-sheet__close" 8390 aria-label="Close more menu" 8391 title="Close" 8392 > 8393 <svg 8394 width="16" 8395 height="16"
8396 viewBox="0 0 24 24" 8397 fill="none" 8398 stroke="currentColor" 8399 stroke-width="2" 8400 stroke-linecap="round" 8401 stroke-linejoin="round" 8402 aria-hidden="true" 8403 focusable="false" 8404 > 8405 <path d="M6 6l12 12M18 6l-12 12" /> 8406 </svg> 8407 </button> 8408 </header> 8409 <div 8410 id="mvMoreSheetBody" 8411 class="mv-more-sheet__body" 8412 role="group" 8413 aria-label="More toolbar actions" 8414 ></div> 8415 </aside> 8416 8417 <div 8418 id="pwaUpdateToast" 8419 class="pwa-update-toast" 8420 role="status" 8421 aria-live="polite" 8422 aria-atomic="true" 8423 hidden 8424 > 8425 <div class="pwa-update-toast__body"> 8426 <p class="pwa-update-toast__eyebrow">MarkV</p> 8427 <p id="pwaUpdateToastTitle" class="pwa-update-toast__title"> 8428 A newer build is ready. 8429 </p> 8430 <p class="pwa-update-toast__copy"> 8431 Refresh when you're ready to pick up the latest shell. 8432 </p> 8433 </div> 8434 <div class="pwa-update-toast__actions"> 8435 <button 8436 id="pwaUpdateToastAction" 8437 type="button" 8438 class="btn btn-primary" 8439 title="Refresh MarkV to apply the latest update" 8440 > 8441 Refresh 8442 </button> 8443 <button 8444 id="pwaUpdateToastDismiss" 8445 type="button" 8446 class="btn pwa-update-toast__dismiss" 8447 title="Dismiss update notice" 8448 aria-label="Dismiss update notice" 8449 > 8450 <svg
8451 viewBox="0 0 16 16" 8452 fill="none" 8453 stroke="currentColor" 8454 stroke-width="1.8" 8455 stroke-linecap="round" 8456 aria-hidden="true" 8457 > 8458 <path d="M3.5 3.5 12.5 12.5"></path> 8459 <path d="M12.5 3.5 3.5 12.5"></path> 8460 </svg> 8461 </button> 8462 </div> 8463 </div> 8464 8465 <div 8466 id="appToast" 8467 class="pwa-update-toast app-toast" 8468 role="status" 8469 aria-live="polite" 8470 aria-atomic="true" 8471 aria-hidden="true" 8472 hidden 8473 > 8474 <div class="pwa-update-toast__body"> 8475 <p id="appToastEyebrow" class="pwa-update-toast__eyebrow">MarkV</p> 8476 <p id="appToastTitle" class="pwa-update-toast__title"></p> 8477 <p id="appToastMessage" class="pwa-update-toast__copy"></p> 8478 </div> 8479 <div class="pwa-update-toast__actions"> 8480 <button 8481 id="appToastDismiss" 8482 type="button" 8483 class="btn pwa-update-toast__dismiss" 8484 title="Dismiss message" 8485 aria-label="Dismiss message" 8486 > 8487 <svg
8488 viewBox="0 0 16 16" 8489 fill="none" 8490 stroke="currentColor" 8491 stroke-width="1.8" 8492 stroke-linecap="round" 8493 aria-hidden="true" 8494 > 8495 <path d="M3.5 3.5 12.5 12.5"></path> 8496 <path d="M12.5 3.5 3.5 12.5"></path> 8497 </svg> 8498 </button> 8499 </div> 8500 </div> 8501 8502 <main class="main" id="main"> 8503 <section class="editor-pane" aria-labelledby="editorLabel"> 8504 <label id="editorLabel" class="visually-hidden" for="editor" 8505 >Markdown editor</label 8506 > 8507 <textarea 8508 id="editor" 8509 aria-labelledby="editorLabel" 8510 spellcheck="false" 8511 placeholder="# Start typing markdown... 8512 8513Drop a .md file anywhere on the page, or open one from Files." 8514 ></textarea> 8515 </section> 8516 8517 <section 8518 class="preview-pane" 8519 aria-label="Rendered markdown preview" 8520 tabindex="0" 8521 > 8522 <article id="preview" class="markdown-body"></article> 8523 <aside 8524 id="readerConnections" 8525 class="reader-rail" 8526 aria-label="Document navigation and local connections" 8527 hidden 8528 ></aside> 8529 </section> 8530 </main> 8531 8532 <!-- Floating editor formatting toolbar (edit mode, selection-driven) --> 8533 <div 8534 id="editorToolbar" 8535 class="editor-toolbar" 8536 role="toolbar" 8537 aria-label="Text formatting" 8538 hidden 8539 > 8540 <div class="et-buttons"> 8541 <button 8542 type="button" 8543 data-et-action="bold" 8544 data-shortcut-id="editor.bold" 8545 data-title-base="Bold" 8546 title="Bold" 8547 aria-label="Bold" 8548 > 8549 <span class="et-glyph et-glyph-b">B</span> 8550 </button> 8551 <button 8552 type="button" 8553 data-et-action="italic" 8554 data-shortcut-id="editor.italic" 8555 data-title-base="Italic" 8556 title="Italic" 8557 aria-label="Italic" 8558 > 8559 <span class="et-glyph et-glyph-i">i</span> 8560 </button> 8561 <button 8562 type="button" 8563 data-et-action="inline-code" 8564 title="Inline code" 8565 aria-label="Inline code" 8566 > 8567 <span class="et-glyph et-glyph-code">`</span> 8568 </button> 8569 <button 8570 type="button" 8571 data-et-action="link" 8572 data-shortcut-id="editor.link" 8573 data-title-base="Link" 8574 title="Link" 8575 aria-label="Link" 8576 > 8577 <svg 8578 width="16" 8579 height="16"
8580 viewBox="0 0 16 16" 8581 fill="none" 8582 stroke="currentColor" 8583 stroke-width="1.6" 8584 stroke-linecap="round" 8585 stroke-linejoin="round" 8586 aria-hidden="true" 8587 > 8588 <path 8589 d="M6.5 9.5a2.8 2.8 0 0 0 4 0l2.4-2.4a2.8 2.8 0 0 0-4-4L7.7 4.3" 8590 /> 8591 <path 8592 d="M9.5 6.5a2.8 2.8 0 0 0-4 0L3.1 8.9a2.8 2.8 0 0 0 4 4l1.2-1.2" 8593 /> 8594 </svg> 8595 </button> 8596 <span class="et-divider" aria-hidden="true"></span> 8597 <button 8598 type="button" 8599 data-et-action="h1" 8600 title="Heading 1" 8601 aria-label="Heading 1" 8602 > 8603 <span class="et-glyph et-glyph-t1">T</span> 8604 </button> 8605 <button 8606 type="button" 8607 data-et-action="h2" 8608 title="Heading 2" 8609 aria-label="Heading 2" 8610 > 8611 <span class="et-glyph et-glyph-t2">T</span> 8612 </button> 8613 <button 8614 type="button" 8615 data-et-action="quote" 8616 title="Quote" 8617 aria-label="Quote" 8618 > 8619 <span class="et-glyph et-glyph-q">“</span> 8620 </button> 8621 <button 8622 type="button" 8623 data-et-action="code-block" 8624 title="Code block" 8625 aria-label="Code block" 8626 > 8627 <svg 8628 width="17" 8629 height="17"
8630 viewBox="0 0 16 16" 8631 fill="none" 8632 stroke="currentColor" 8633 stroke-width="1.45" 8634 stroke-linecap="round" 8635 stroke-linejoin="round" 8636 aria-hidden="true" 8637 > 8638 <path d="M5.8 4.2 2.2 8l3.6 3.8" /> 8639 <path d="m10.2 4.2 3.6 3.8-3.6 3.8" /> 8640 <path d="m8.8 3.3-1.6 9.4" /> 8641 </svg> 8642 </button> 8643 <button 8644 type="button" 8645 data-et-action="bullet-list" 8646 data-shortcut-id="editor.bullet-list" 8647 data-title-base="Bullet list" 8648 title="Bullet list" 8649 aria-label="Bullet list" 8650 > 8651 <svg 8652 width="17" 8653 height="17" 8654 viewBox="0 0 16 16" 8655 fill="none" 8656 stroke="currentColor" 8657 stroke-width="1.45" 8658 stroke-linecap="round" 8659 stroke-linejoin="round" 8660 aria-hidden="true" 8661 > 8662 <path d="M6.2 4h7" /> 8663 <path d="M6.2 8h7" /> 8664 <path d="M6.2 12h7" /> 8665 <path d="M2.8 4h.1" /> 8666 <path d="M2.8 8h.1" /> 8667 <path d="M2.8 12h.1" /> 8668 </svg> 8669 </button> 8670 <button 8671 type="button" 8672 data-et-action="numbered-list" 8673 data-shortcut-id="editor.numbered-list" 8674 data-title-base="Numbered list" 8675 title="Numbered list" 8676 aria-label="Numbered list" 8677 > 8678 <svg 8679 width="17" 8680 height="17"
8681 viewBox="0 0 16 16" 8682 fill="none" 8683 stroke="currentColor" 8684 stroke-width="1.45" 8685 stroke-linecap="round" 8686 stroke-linejoin="round" 8687 aria-hidden="true" 8688 > 8689 <path d="M6.2 4h7" /> 8690 <path d="M6.2 8h7" /> 8691 <path d="M6.2 12h7" /> 8692 <path d="M2.5 3.2h.8v2.2" /> 8693 <path 8694 d="M2.4 8.7c.9-.6 1.3-.9 1.3-1.3 0-.3-.3-.5-.6-.5-.3 0-.5.1-.7.3" 8695 /> 8696 <path 8697 d="M2.4 11.1c.2-.2.4-.3.7-.3.4 0 .7.2.7.5 0 .4-.3.6-.8.6h-.2" 8698 /> 8699 </svg> 8700 </button> 8701 <button 8702 type="button" 8703 data-et-action="image" 8704 title="Image markdown" 8705 aria-label="Image markdown" 8706 > 8707 <svg 8708 width="17" 8709 height="17" 8710 viewBox="0 0 16 16" 8711 fill="none" 8712 stroke="currentColor" 8713 stroke-width="1.45" 8714 stroke-linecap="round" 8715 stroke-linejoin="round" 8716 aria-hidden="true" 8717 > 8718 <rect x="2.5" y="3" width="11" height="10" rx="1.5" /> 8719 <path d="m3.5 11 3-3 2 2 1.5-1.5 2.5 2.5" /> 8720 <path d="M10.8 5.5h.1" /> 8721 </svg> 8722 </button> 8723 </div> 8724 <div class="et-link" role="group" aria-label="Insert link"> 8725 <input 8726 type="url" 8727 class="et-link-input" 8728 placeholder="Paste or type a linkâ¦" 8729 aria-label="Link URL" 8730 spellcheck="false" 8731 autocomplete="off" 8732 /> 8733 <button 8734 type="button" 8735 data-et-action="link-cancel" 8736 title="Cancel" 8737 aria-label="Cancel" 8738 > 8739 × 8740 </button> 8741 </div> 8742 <span class="et-arrow" aria-hidden="true"></span> 8743 </div> 8744 8745 <div 8746 id="mermaidViewer" 8747 class="mermaid-viewer" 8748 role="dialog" 8749 aria-modal="true" 8750 aria-labelledby="mermaidViewerTitle" 8751 aria-describedby="mermaidViewerHelp" 8752 aria-hidden="true" 8753 hidden 8754 > 8755 <div class="mermaid-viewer-shell"> 8756 <div class="mermaid-viewer-header"> 8757 <div class="mermaid-viewer-title-wrap"> 8758 <h2 id="mermaidViewerTitle" class="mermaid-viewer-title"> 8759 Diagram Viewer 8760 </h2> 8761 <p id="mermaidViewerHelp" class="mermaid-viewer-help"> 8762 Zoom, drag to pan, or download the rendered SVG. 8763 </p> 8764 </div> 8765 <div 8766 class="mermaid-viewer-actions" 8767 aria-label="Diagram viewer actions" 8768 > 8769 <!-- Zoom group --> 8770 <button 8771 id="mermaidViewerZoomOutBtn" 8772 type="button" 8773 class="mermaid-viewer-btn" 8774 title="Zoom out (â)" 8775 > 8776 <svg
8777 viewBox="0 0 24 24" 8778 fill="none" 8779 stroke="currentColor" 8780 stroke-width="1.75" 8781 stroke-linecap="round" 8782 stroke-linejoin="round" 8783 aria-hidden="true" 8784 > 8785 <circle cx="11" cy="11" r="7"></circle> 8786 <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 8787 <line x1="8" y1="11" x2="14" y2="11"></line> 8788 </svg> 8789 <span class="mv-btn-label">Zoom out</span> 8790 </button> 8791 <span 8792 id="mermaidViewerZoomLabel" 8793 class="mermaid-viewer-zoom-label" 8794 >100%</span 8795 > 8796 <button 8797 id="mermaidViewerZoomInBtn" 8798 type="button" 8799 class="mermaid-viewer-btn" 8800 title="Zoom in (+)" 8801 > 8802 <svg 8803 viewBox="0 0 24 24" 8804 fill="none" 8805 stroke="currentColor" 8806 stroke-width="1.75" 8807 stroke-linecap="round" 8808 stroke-linejoin="round" 8809 aria-hidden="true" 8810 > 8811 <circle cx="11" cy="11" r="7"></circle> 8812 <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 8813 <line x1="11" y1="8" x2="11" y2="14"></line> 8814 <line x1="8" y1="11" x2="14" y2="11"></line> 8815 </svg> 8816 <span class="mv-btn-label">Zoom in</span> 8817 </button> 8818 <span class="mermaid-viewer-sep" aria-hidden="true"></span> 8819 <!-- View group --> 8820 <button 8821 id="mermaidViewerFitBtn" 8822 type="button" 8823 class="mermaid-viewer-btn" 8824 title="Fit to view (F)" 8825 > 8826 <svg
8827 viewBox="0 0 24 24" 8828 fill="none" 8829 stroke="currentColor" 8830 stroke-width="1.75" 8831 stroke-linecap="round" 8832 stroke-linejoin="round" 8833 aria-hidden="true" 8834 > 8835 <path d="M15 3h6v6"></path> 8836 <path d="M9 21H3v-6"></path> 8837 <path d="M21 3l-7 7"></path> 8838 <path d="M3 21l7-7"></path> 8839 </svg> 8840 <span class="mv-btn-label">Fit to view</span> 8841 </button> 8842 <button 8843 id="mermaidViewerResetBtn" 8844 type="button" 8845 class="mermaid-viewer-btn mermaid-viewer-btn--text" 8846 title="Actual size (0)" 8847 > 8848 1:1 8849 </button> 8850 <span class="mermaid-viewer-sep" aria-hidden="true"></span> 8851 <!-- Download --> 8852 <button 8853 id="mermaidViewerDownloadBtn" 8854 type="button" 8855 class="mermaid-viewer-btn" 8856 title="Download SVG" 8857 > 8858 <svg
8859 viewBox="0 0 24 24" 8860 fill="none" 8861 stroke="currentColor" 8862 stroke-width="1.75" 8863 stroke-linecap="round" 8864 stroke-linejoin="round" 8865 aria-hidden="true" 8866 > 8867 <path d="M12 3v12"></path> 8868 <path d="M8 11l4 4 4-4"></path> 8869 <path d="M20 21H4"></path> 8870 </svg> 8871 <span class="mv-btn-label">Download SVG</span> 8872 </button> 8873 <span class="mermaid-viewer-sep" aria-hidden="true"></span> 8874 <!-- Close --> 8875 <button 8876 id="mermaidViewerCloseBtn" 8877 type="button" 8878 class="mermaid-viewer-btn mermaid-viewer-btn--close" 8879 title="Close (Esc)" 8880 > 8881 <svg 8882 viewBox="0 0 24 24" 8883 fill="none" 8884 stroke="currentColor" 8885 stroke-width="1.75" 8886 stroke-linecap="round" 8887 stroke-linejoin="round" 8888 aria-hidden="true" 8889 > 8890 <line x1="18" y1="6" x2="6" y2="18"></line> 8891 <line x1="6" y1="6" x2="18" y2="18"></line> 8892 </svg> 8893 <span class="mv-btn-label">Close</span> 8894 </button> 8895 </div> 8896 </div> 8897 <div 8898 id="mermaidViewerStage" 8899 class="mermaid-viewer-stage" 8900 tabindex="0" 8901 aria-label="Diagram canvas" 8902 > 8903 <div class="mermaid-viewer-content"> 8904 <div 8905 id="mermaidViewerZoomable" 8906 class="mermaid-viewer-zoomable" 8907 ></div> 8908 </div> 8909 </div> 8910 <div class="mermaid-viewer-footer"> 8911 <span 8912 ><kbd>+</kbd> <kbd>â</kbd> zoom <kbd>0</kbd> reset 8913 <kbd>F</kbd> fit <kbd>Esc</kbd> close</span 8914 > 8915 <span id="mermaidViewerMeta">No diagram selected.</span> 8916 </div> 8917 </div> 8918 </div> 8919 8920 <div 8921 id="mindmapViewer" 8922 class="mindmap-viewer" 8923 role="dialog" 8924 aria-modal="true" 8925 aria-labelledby="mindmapViewerTitle" 8926 aria-describedby="mindmapViewerHelp" 8927 aria-hidden="true" 8928 hidden 8929 > 8930 <div class="mindmap-viewer-shell"> 8931 <div class="mindmap-viewer-header"> 8932 <div class="mindmap-viewer-title-wrap"> 8933 <h2 id="mindmapViewerTitle" class="mindmap-viewer-title"> 8934 Concept Mindmap 8935 </h2> 8936 <p id="mindmapViewerHelp" class="mindmap-viewer-help"> 8937 Click a concept to inspect its neighborhood. Drag to pan, scroll 8938 to zoom. 8939 </p> 8940 </div> 8941 <div 8942 class="mindmap-viewer-actions" 8943 aria-label="Mindmap viewer actions" 8944 > 8945 <button 8946 id="mindmapZoomOutBtn" 8947 type="button" 8948 class="mindmap-viewer-btn" 8949 title="Zoom out" 8950 > 8951 <svg
8952 viewBox="0 0 24 24" 8953 fill="none" 8954 stroke="currentColor" 8955 stroke-width="1.75" 8956 stroke-linecap="round" 8957 stroke-linejoin="round" 8958 aria-hidden="true" 8959 > 8960 <circle cx="11" cy="11" r="7"></circle> 8961 <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 8962 <line x1="8" y1="11" x2="14" y2="11"></line> 8963 </svg> 8964 <span>Out</span> 8965 </button> 8966 <span id="mindmapZoomLabel" class="mindmap-viewer-zoom-label" 8967 >100%</span 8968 > 8969 <button 8970 id="mindmapZoomInBtn" 8971 type="button" 8972 class="mindmap-viewer-btn" 8973 title="Zoom in" 8974 > 8975 <svg 8976 viewBox="0 0 24 24" 8977 fill="none" 8978 stroke="currentColor" 8979 stroke-width="1.75" 8980 stroke-linecap="round" 8981 stroke-linejoin="round" 8982 aria-hidden="true" 8983 > 8984 <circle cx="11" cy="11" r="7"></circle> 8985 <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 8986 <line x1="11" y1="8" x2="11" y2="14"></line> 8987 <line x1="8" y1="11" x2="14" y2="11"></line> 8988 </svg> 8989 <span>In</span> 8990 </button> 8991 <button 8992 id="mindmapFitBtn" 8993 type="button" 8994 class="mindmap-viewer-btn" 8995 title="Fit to view" 8996 > 8997 <svg
8998 viewBox="0 0 24 24" 8999 fill="none" 9000 stroke="currentColor" 9001 stroke-width="1.75" 9002 stroke-linecap="round" 9003 stroke-linejoin="round" 9004 aria-hidden="true" 9005 > 9006 <path d="M15 3h6v6"></path> 9007 <path d="M9 21H3v-6"></path> 9008 <path d="M21 3l-7 7"></path> 9009 <path d="M3 21l7-7"></path> 9010 </svg> 9011 <span>Fit</span> 9012 </button> 9013 <button 9014 id="mindmapRegenerateBtn" 9015 type="button" 9016 class="mindmap-viewer-btn" 9017 title="Regenerate from current document" 9018 > 9019 <svg 9020 viewBox="0 0 24 24" 9021 fill="none" 9022 stroke="currentColor" 9023 stroke-width="1.75" 9024 stroke-linecap="round" 9025 stroke-linejoin="round" 9026 aria-hidden="true" 9027 > 9028 <path d="M21 12a9 9 0 1 1-3-6.7"></path> 9029 <polyline points="21 4 21 10 15 10"></polyline> 9030 </svg> 9031 <span>Regenerate</span> 9032 </button> 9033 <button 9034 id="mindmapCloseBtn" 9035 type="button" 9036 class="mindmap-viewer-btn" 9037 title="Close (Esc)" 9038 aria-label="Close mindmap viewer" 9039 > 9040 <svg
9041 viewBox="0 0 24 24" 9042 fill="none" 9043 stroke="currentColor" 9044 stroke-width="1.75" 9045 stroke-linecap="round" 9046 stroke-linejoin="round" 9047 aria-hidden="true" 9048 > 9049 <line x1="18" y1="6" x2="6" y2="18"></line> 9050 <line x1="6" y1="6" x2="18" y2="18"></line> 9051 </svg> 9052 </button> 9053 </div> 9054 </div> 9055 9056 <div 9057 id="mindmapLegend" 9058 class="mindmap-legend" 9059 aria-label="Concept type legend" 9060 ></div> 9061 9062 <div id="mindmapStage" class="mindmap-viewer-stage"> 9063 <div id="mindmapSearch" class="mindmap-search" role="search"> 9064 <svg 9065 class="mindmap-search-icon" 9066 viewBox="0 0 24 24" 9067 fill="none" 9068 stroke="currentColor" 9069 stroke-width="1.75" 9070 stroke-linecap="round" 9071 stroke-linejoin="round" 9072 aria-hidden="true" 9073 > 9074 <circle cx="11" cy="11" r="7"></circle> 9075 <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 9076 </svg> 9077 <input 9078 id="mindmapSearchInput" 9079 class="mindmap-search-input" 9080 type="search" 9081 autocomplete="off" 9082 spellcheck="false" 9083 placeholder="Find concept" 9084 aria-label="Find concept" 9085 aria-controls="mindmapSearchResults" 9086 /> 9087 <div 9088 id="mindmapSearchResults" 9089 class="mindmap-search-results is-hidden" 9090 role="listbox" 9091 aria-label="Matching concepts" 9092 ></div> 9093 </div> 9094 9095 <svg 9096 id="mindmapSvg" 9097 class="mindmap-svg" 9098 xmlns="http://www.w3.org/2000/svg" 9099 role="img" 9100 aria-labelledby="mindmapViewerTitle" 9101 aria-describedby="mindmapSrList" 9102 > 9103 <g id="mindmapSvgRoot" class="mindmap-svg-root"> 9104 <g id="mindmapEdges"></g> 9105 <g id="mindmapNodes"></g> 9106 </g> 9107 </svg> 9108 9109 <div 9110 id="mindmapSrList" 9111 class="mindmap-sr-only" 9112 aria-live="polite" 9113 ></div> 9114 9115 <div id="mindmapTooltip" class="mm-tooltip" role="tooltip"> 9116 <span class="mm-tooltip-type"></span> 9117 <span class="mm-tooltip-label"></span> 9118 <span class="mm-tooltip-summary"></span> 9119 </div> 9120 9121 <aside 9122 id="mindmapInspector" 9123 class="mindmap-inspector" 9124 aria-live="polite" 9125 aria-hidden="true" 9126 ></aside> 9127 9128 <div id="mindmapLoading" class="mm-state-wrap is-hidden"> 9129 <div class="mm-spinner" aria-hidden="true"></div> 9130 <p class="mm-state-label">Extracting conceptsâ¦</p> 9131 </div> 9132 9133 <div id="mindmapError" class="mm-state-wrap is-hidden"> 9134 <p class="mm-state-label" id="mindmapErrorMessage"> 9135 Something went wrong. 9136 </p> 9137 <button id="mindmapRetryBtn" type="button" class="mm-state-retry"> 9138 Try again 9139 </button> 9140 </div> 9141 </div> 9142 </div> 9143 </div> 9144 9145 <div 9146 id="selectionBubble" 9147 class="selection-bubble hidden" 9148 role="toolbar" 9149 aria-label="Selection actions" 9150 aria-hidden="true" 9151 hidden 9152 > 9153 <button 9154 id="selectionCopyBtn" 9155 type="button" 9156 class="selection-action" 9157 title="Copy selection" 9158 > 9159 <svg 9160 width="13" 9161 height="13"
9162 viewBox="0 0 24 24" 9163 fill="none" 9164 stroke="currentColor" 9165 stroke-width="2" 9166 stroke-linecap="round" 9167 stroke-linejoin="round" 9168 aria-hidden="true" 9169 > 9170 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" /> 9171 <path 9172 d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" 9173 /> 9174 </svg> 9175 Copy 9176 </button> 9177 <button 9178 id="selectionHighlightBtn" 9179 type="button" 9180 class="selection-action selection-action-primary" 9181 title="Highlight selection" 9182 > 9183 <svg 9184 width="13" 9185 height="13" 9186 viewBox="0 0 24 24" 9187 fill="none" 9188 stroke="currentColor" 9189 stroke-width="2" 9190 stroke-linecap="round" 9191 stroke-linejoin="round" 9192 aria-hidden="true" 9193 > 9194 <path d="m9 11 6-6 4 4-6 6" /> 9195 <path d="M12.5 7.5 16.5 11.5" /> 9196 <path d="M4 20h6" /> 9197 </svg> 9198 Highlight 9199 </button> 9200 <button 9201 id="selectionAskBtn" 9202 type="button" 9203 class="selection-action" 9204 title="Ask about selection" 9205 > 9206 <svg 9207 width="13" 9208 height="13"
9209 viewBox="0 0 24 24" 9210 fill="none" 9211 stroke="currentColor" 9212 stroke-width="2" 9213 stroke-linecap="round" 9214 stroke-linejoin="round" 9215 aria-hidden="true" 9216 > 9217 <circle cx="12" cy="12" r="10" /> 9218 <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /> 9219 <line x1="12" y1="17" x2="12.01" y2="17" /> 9220 </svg> 9221 Ask 9222 </button> 9223 <button 9224 id="selectionSummarizeBtn" 9225 type="button" 9226 class="selection-action" 9227 title="Summarize selection" 9228 > 9229 <svg 9230 width="13" 9231 height="13" 9232 viewBox="0 0 24 24" 9233 fill="none" 9234 stroke="currentColor" 9235 stroke-width="2" 9236 stroke-linecap="round" 9237 stroke-linejoin="round" 9238 aria-hidden="true" 9239 > 9240 <line x1="21" y1="10" x2="3" y2="10" /> 9241 <line x1="21" y1="6" x2="3" y2="6" /> 9242 <line x1="21" y1="14" x2="3" y2="14" /> 9243 <line x1="21" y1="18" x2="3" y2="18" /> 9244 </svg> 9245 Summarize 9246 </button> 9247 </div> 9248 9249 <button 9250 id="highlightRemoveChip" 9251 type="button" 9252 class="highlight-remove-chip hidden" 9253 title="Remove highlight" 9254 aria-label="Remove highlight" 9255 aria-hidden="true" 9256 hidden 9257 > 9258 <svg 9259 width="13" 9260 height="13"
9261 viewBox="0 0 24 24" 9262 fill="none" 9263 stroke="currentColor" 9264 stroke-width="2" 9265 stroke-linecap="round" 9266 stroke-linejoin="round" 9267 aria-hidden="true" 9268 > 9269 <line x1="18" y1="6" x2="6" y2="18" /> 9270 <line x1="6" y1="6" x2="18" y2="18" /> 9271 </svg> 9272 Remove 9273 </button> 9274 9275 <div 9276 id="highlightUndoToast" 9277 class="highlight-undo hidden" 9278 role="status" 9279 aria-live="polite" 9280 aria-atomic="true" 9281 aria-hidden="true" 9282 hidden 9283 > 9284 <span id="highlightUndoMessage" class="highlight-undo-message" 9285 >Highlight removed.</span 9286 > 9287 <button id="highlightUndoBtn" type="button" class="highlight-undo-btn"> 9288 Undo 9289 </button> 9290 </div> 9291 9292 <div 9293 id="ctxMenu" 9294 class="ctx-menu hidden" 9295 role="menu" 9296 aria-label="Selection actions" 9297 aria-hidden="true" 9298 hidden 9299 > 9300 <button 9301 id="ctxHighlight" 9302 class="ctx-menu-item" 9303 type="button" 9304 role="menuitem" 9305 > 9306 <svg 9307 width="14" 9308 height="14"
9309 viewBox="0 0 24 24" 9310 fill="none" 9311 stroke="currentColor" 9312 stroke-width="2" 9313 stroke-linecap="round" 9314 stroke-linejoin="round" 9315 aria-hidden="true" 9316 > 9317 <path d="m9 11 6-6 4 4-6 6" /> 9318 <path d="M12.5 7.5 16.5 11.5" /> 9319 <path d="M4 20h6" /> 9320 </svg> 9321 Highlight selection 9322 </button> 9323 <button id="ctxAsk" class="ctx-menu-item" type="button" role="menuitem"> 9324 <svg 9325 width="14" 9326 height="14" 9327 viewBox="0 0 24 24" 9328 fill="none" 9329 stroke="currentColor" 9330 stroke-width="2" 9331 stroke-linecap="round" 9332 stroke-linejoin="round" 9333 aria-hidden="true" 9334 > 9335 <circle cx="12" cy="12" r="10" /> 9336 <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /> 9337 <line x1="12" y1="17" x2="12.01" y2="17" /> 9338 </svg> 9339 Ask about selection 9340 </button> 9341 <button 9342 id="ctxSummarize" 9343 class="ctx-menu-item" 9344 type="button" 9345 role="menuitem" 9346 > 9347 <svg 9348 width="14" 9349 height="14"
9350 viewBox="0 0 24 24" 9351 fill="none" 9352 stroke="currentColor" 9353 stroke-width="2" 9354 stroke-linecap="round" 9355 stroke-linejoin="round" 9356 aria-hidden="true" 9357 > 9358 <line x1="21" y1="10" x2="3" y2="10" /> 9359 <line x1="21" y1="6" x2="3" y2="6" /> 9360 <line x1="21" y1="14" x2="3" y2="14" /> 9361 <line x1="21" y1="18" x2="3" y2="18" /> 9362 </svg> 9363 Summarize selection 9364 </button> 9365 </div> 9366 </div> 9367 9368
9368<script> 9369 /** 9370 * marked - a markdown parser 9371 * Copyright (c) 2011-2022, Christopher Jeffrey. (MIT Licensed) 9372 * https://github.com/markedjs/marked 9373 */ 9374 9375 /** 9376 * DO NOT EDIT THIS FILE 9377 * The code in this file is generated from files in ./src/ 9378 */ 9379 9380 (function (global, factory) { 9381 typeof exports === "object" && typeof module !== "undefined" 9382 ? factory(exports) 9383 : typeof define === "function" && define.amd 9384 ? define(["exports"], factory) 9385 : ((global = 9386 typeof globalThis !== "undefined" 9387 ? globalThis 9388 : global || self), 9389 factory((global.marked = {}))); 9390 })(this, function (exports) { 9391 "use strict"; 9392 9393 function _defineProperties(target, props) { 9394 for (var i = 0; i < props.length; i++) { 9395 var descriptor = props[i]; 9396 descriptor.enumerable = descriptor.enumerable || false; 9397 descriptor.configurable = true; 9398 if ("value" in descriptor) descriptor.writable = true; 9399 Object.defineProperty(target, descriptor.key, descriptor); 9400 } 9401 } 9402 9403 function _createClass(Constructor, protoProps, staticProps) { 9404 if (protoProps) _defineProperties(Constructor.prototype, protoProps); 9405 if (staticProps) _defineProperties(Constructor, staticProps); 9406 Object.defineProperty(Constructor, "prototype", { 9407 writable: false, 9408 }); 9409 return Constructor; 9410 } 9411 9412 function _unsupportedIterableToArray(o, minLen) { 9413 if (!o) return; 9414 if (typeof o === "string") return _arrayLikeToArray(o, minLen); 9415 var n = Object.prototype.toString.call(o).slice(8, -1); 9416 if (n === "Object" && o.constructor) n = o.constructor.name; 9417 if (n === "Map" || n === "Set") return Array.from(o); 9418 if ( 9419 n === "Arguments" || 9420 /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n) 9421 ) 9422 return _arrayLikeToArray(o, minLen); 9423 } 9424 9425 function _arrayLikeToArray(arr, len) { 9426 if (len == null || len > arr.length) len = arr.length; 9427 9428 for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; 9429 9430 return arr2; 9431 } 9432 9433 function _createForOfIteratorHelperLoose(o, allowArrayLike) { 9434 var it = 9435 (typeof Symbol !== "undefined" && o[Symbol.iterator]) || 9436 o["@@iterator"]; 9437 if (it) return (it = it.call(o)).next.bind(it); 9438 9439 if ( 9440 Array.isArray(o) || 9441 (it = _unsupportedIterableToArray(o)) || 9442 (allowArrayLike && o && typeof o.length === "number") 9443 ) { 9444 if (it) o = it; 9445 var i = 0; 9446 return function () { 9447 if (i >= o.length) 9448 return { 9449 done: true, 9450 }; 9451 return { 9452 done: false, 9453 value: o[i++], 9454 }; 9455 }; 9456 } 9457 9458 throw new TypeError( 9459 "Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.", 9460 ); 9461 } 9462 9463 function getDefaults() { 9464 return { 9465 baseUrl: null, 9466 breaks: false, 9467 extensions: null, 9468 gfm: true, 9469 headerIds: true, 9470 headerPrefix: "", 9471 highlight: null, 9472 langPrefix: "language-", 9473 mangle: true, 9474 pedantic: false, 9475 renderer: null, 9476 sanitize: false, 9477 sanitizer: null, 9478 silent: false, 9479 smartLists: false, 9480 smartypants: false, 9481 tokenizer: null, 9482 walkTokens: null, 9483 xhtml: false, 9484 }; 9485 } 9486 exports.defaults = getDefaults(); 9487 function changeDefaults(newDefaults) { 9488 exports.defaults = newDefaults; 9489 } 9490 9491 /** 9492 * Helpers 9493 */ 9494 var escapeTest = /[&<>"']/; 9495 var escapeReplace = /[&<>"']/g; 9496 var escapeTestNoEncode = /[<>"']|&(?!#?\w+;)/; 9497 var escapeReplaceNoEncode = /[<>"']|&(?!#?\w+;)/g; 9498 var escapeReplacements = { 9499 "&": "&", 9500 "<": "<", 9501 ">": ">", 9502 '"': """, 9503 "'": "'", 9504 }; 9505 9506 var getEscapeReplacement = function getEscapeReplacement(ch) { 9507 return escapeReplacements[ch]; 9508 }; 9509 9510 function escape(html, encode) { 9511 if (encode) { 9512 if (escapeTest.test(html)) { 9513 return html.replace(escapeReplace, getEscapeReplacement); 9514 } 9515 } else { 9516 if (escapeTestNoEncode.test(html)) { 9517 return html.replace(escapeReplaceNoEncode, getEscapeReplacement); 9518 } 9519 } 9520 9521 return html; 9522 } 9523 var unescapeTest = /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi; 9524 /** 9525 * @param {string} html 9526 */ 9527 9528 function unescape(html) { 9529 // explicitly match decimal, hex, and named HTML entities 9530 return html.replace(unescapeTest, function (_, n) { 9531 n = n.toLowerCase(); 9532 if (n === "colon") return ":"; 9533 9534 if (n.charAt(0) === "#") { 9535 return n.charAt(1) === "x" 9536 ? String.fromCharCode(parseInt(n.substring(2), 16)) 9537 : String.fromCharCode(+n.substring(1)); 9538 } 9539 9540 return ""; 9541 }); 9542 } 9543 var caret = /(^|[^\[])\^/g; 9544 /** 9545 * @param {string | RegExp} regex 9546 * @param {string} opt 9547 */ 9548 9549 function edit(regex, opt) { 9550 regex = typeof regex === "string" ? regex : regex.source; 9551 opt = opt || ""; 9552 var obj = { 9553 replace: function replace(name, val) { 9554 val = val.source || val; 9555 val = val.replace(caret, "$1"); 9556 regex = regex.replace(name, val); 9557 return obj; 9558 }, 9559 getRegex: function getRegex() { 9560 return new RegExp(regex, opt); 9561 }, 9562 }; 9563 return obj; 9564 } 9565 var nonWordAndColonTest = /[^\w:]/g; 9566 var originIndependentUrl = /^$|^[a-z][a-z0-9+.-]*:|^[?#]/i; 9567 /** 9568 * @param {boolean} sanitize 9569 * @param {string} base 9570 * @param {string} href 9571 */ 9572 9573 function cleanUrl(sanitize, base, href) { 9574 if (sanitize) { 9575 var prot; 9576 9577 try { 9578 prot = decodeURIComponent(unescape(href)) 9579 .replace(nonWordAndColonTest, "") 9580 .toLowerCase(); 9581 } catch (e) { 9582 return null; 9583 } 9584 9585 if ( 9586 prot.indexOf("javascript:") === 0 || 9587 prot.indexOf("vbscript:") === 0 || 9588 prot.indexOf("data:") === 0 9589 ) { 9590 return null; 9591 } 9592 } 9593 9594 if (base && !originIndependentUrl.test(href)) { 9595 href = resolveUrl(base, href); 9596 } 9597 9598 try { 9599 href = encodeURI(href).replace(/%25/g, "%"); 9600 } catch (e) { 9601 return null; 9602 } 9603 9604 return href; 9605 } 9606 var baseUrls = {}; 9607 var justDomain = /^[^:]+:\/*[^/]*$/; 9608 var protocol = /^([^:]+:)[\s\S]*$/; 9609 var domain = /^([^:]+:\/*[^/]*)[\s\S]*$/; 9610 /** 9611 * @param {string} base 9612 * @param {string} href 9613 */ 9614 9615 function resolveUrl(base, href) { 9616 if (!baseUrls[" " + base]) { 9617 // we can ignore everything in base after the last slash of its path component, 9618 // but we might need to add _that_ 9619 // https://tools.ietf.org/html/rfc3986#section-3 9620 if (justDomain.test(base)) { 9621 baseUrls[" " + base] = base + "/"; 9622 } else { 9623 baseUrls[" " + base] = rtrim(base, "/", true); 9624 } 9625 } 9626 9627 base = baseUrls[" " + base]; 9628 var relativeBase = base.indexOf(":") === -1; 9629 9630 if (href.substring(0, 2) === "//") { 9631 if (relativeBase) { 9632 return href; 9633 } 9634 9635 return base.replace(protocol, "$1") + href; 9636 } else if (href.charAt(0) === "/") { 9637 if (relativeBase) { 9638 return href; 9639 } 9640 9641 return base.replace(domain, "$1") + href; 9642 } else { 9643 return base + href; 9644 } 9645 } 9646 var noopTest = { 9647 exec: function noopTest() {}, 9648 }; 9649 function merge(obj) { 9650 var i = 1, 9651 target, 9652 key; 9653 9654 for (; i < arguments.length; i++) { 9655 target = arguments[i]; 9656 9657 for (key in target) { 9658 if (Object.prototype.hasOwnProperty.call(target, key)) { 9659 obj[key] = target[key]; 9660 } 9661 } 9662 } 9663 9664 return obj; 9665 } 9666 function splitCells(tableRow, count) { 9667 // ensure that every cell-delimiting pipe has a space 9668 // before it to distinguish it from an escaped pipe 9669 var row = tableRow.replace(/\|/g, function (match, offset, str) { 9670 var escaped = false, 9671 curr = offset; 9672 9673 while (--curr >= 0 && str[curr] === "\\") { 9674 escaped = !escaped; 9675 } 9676 9677 if (escaped) { 9678 // odd number of slashes means | is escaped 9679 // so we leave it alone 9680 return "|"; 9681 } else { 9682 // add space before unescaped | 9683 return " |"; 9684 } 9685 }), 9686 cells = row.split(/ \|/); 9687 var i = 0; // First/last cell in a row cannot be empty if it has no leading/trailing pipe 9688 9689 if (!cells[0].trim()) { 9690 cells.shift(); 9691 } 9692 9693 if (cells.length > 0 && !cells[cells.length - 1].trim()) { 9694 cells.pop(); 9695 } 9696 9697 if (cells.length > count) { 9698 cells.splice(count); 9699 } else { 9700 while (cells.length < count) { 9701 cells.push(""); 9702 } 9703 } 9704 9705 for (; i < cells.length; i++) { 9706 // leading or trailing whitespace is ignored per the gfm spec 9707 cells[i] = cells[i].trim().replace(/\\\|/g, "|"); 9708 } 9709 9710 return cells; 9711 } 9712 /** 9713 * Remove trailing 'c's. Equivalent to str.replace(/c*$/, ''). 9714 * /c*$/ is vulnerable to REDOS. 9715 * 9716 * @param {string} str 9717 * @param {string} c 9718 * @param {boolean} invert Remove suffix of non-c chars instead. Default falsey. 9719 */ 9720 9721 function rtrim(str, c, invert) { 9722 var l = str.length; 9723 9724 if (l === 0) { 9725 return ""; 9726 } // Length of suffix matching the invert condition. 9727 9728 var suffLen = 0; // Step left until we fail to match the invert condition. 9729 9730 while (suffLen < l) { 9731 var currChar = str.charAt(l - suffLen - 1); 9732 9733 if (currChar === c && !invert) { 9734 suffLen++; 9735 } else if (currChar !== c && invert) { 9736 suffLen++; 9737 } else { 9738 break; 9739 } 9740 } 9741 9742 return str.slice(0, l - suffLen); 9743 } 9744 function findClosingBracket(str, b) { 9745 if (str.indexOf(b[1]) === -1) { 9746 return -1; 9747 } 9748 9749 var l = str.length; 9750 var level = 0, 9751 i = 0; 9752 9753 for (; i < l; i++) { 9754 if (str[i] === "\\") { 9755 i++; 9756 } else if (str[i] === b[0]) { 9757 level++; 9758 } else if (str[i] === b[1]) { 9759 level--; 9760 9761 if (level < 0) { 9762 return i; 9763 } 9764 } 9765 } 9766 9767 return -1; 9768 } 9769 function checkSanitizeDeprecation(opt) { 9770 if (opt && opt.sanitize && !opt.silent) { 9771 console.warn( 9772 "marked(): sanitize and sanitizer parameters are deprecated since version 0.7.0, should not be used and will be removed in the future. Read more here: https://marked.js.org/#/USING_ADVANCED.md#options", 9773 ); 9774 } 9775 } // copied from https://stackoverflow.com/a/5450113/806777 9776 9777 /** 9778 * @param {string} pattern 9779 * @param {number} count 9780 */ 9781 9782 function repeatString(pattern, count) { 9783 if (count < 1) { 9784 return ""; 9785 } 9786 9787 var result = ""; 9788 9789 while (count > 1) { 9790 if (count & 1) { 9791 result += pattern; 9792 } 9793 9794 count >>= 1; 9795 pattern += pattern; 9796 } 9797 9798 return result + pattern; 9799 } 9800 9801 function outputLink(cap, link, raw, lexer) { 9802 var href = link.href; 9803 var title = link.title ? escape(link.title) : null; 9804 var text = cap[1].replace(/\\([\[\]])/g, "$1"); 9805 9806 if (cap[0].charAt(0) !== "!") { 9807 lexer.state.inLink = true; 9808 var token = { 9809 type: "link", 9810 raw: raw, 9811 href: href, 9812 title: title, 9813 text: text, 9814 tokens: lexer.inlineTokens(text, []), 9815 }; 9816 lexer.state.inLink = false;
9817 return token; 9818 } 9819 9820 return { 9821 type: "image", 9822 raw: raw, 9823 href: href, 9824 title: title, 9825 text: escape(text), 9826 }; 9827 } 9828 9829 function indentCodeCompensation(raw, text) { 9830 var matchIndentToCode = raw.match(/^(\s+)(?:```)/); 9831 9832 if (matchIndentToCode === null) { 9833 return text; 9834 } 9835 9836 var indentToCode = matchIndentToCode[1]; 9837 return text 9838 .split("\n") 9839 .map(function (node) { 9840 var matchIndentInNode = node.match(/^\s+/); 9841 9842 if (matchIndentInNode === null) { 9843 return node; 9844 } 9845 9846 var indentInNode = matchIndentInNode[0]; 9847 9848 if (indentInNode.length >= indentToCode.length) { 9849 return node.slice(indentToCode.length); 9850 } 9851 9852 return node; 9853 }) 9854 .join("\n"); 9855 } 9856 /** 9857 * Tokenizer 9858 */ 9859 9860 var Tokenizer = /*#__PURE__*/ (function () { 9861 function Tokenizer(options) { 9862 this.options = options || exports.defaults; 9863 } 9864 9865 var _proto = Tokenizer.prototype; 9866 9867 _proto.space = function space(src) { 9868 var cap = this.rules.block.newline.exec(src); 9869 9870 if (cap && cap[0].length > 0) { 9871 return { 9872 type: "space", 9873 raw: cap[0], 9874 }; 9875 } 9876 }; 9877 9878 _proto.code = function code(src) { 9879 var cap = this.rules.block.code.exec(src); 9880 9881 if (cap) { 9882 var text = cap[0].replace(/^ {1,4}/gm, ""); 9883 return { 9884 type: "code", 9885 raw: cap[0], 9886 codeBlockStyle: "indented", 9887 text: !this.options.pedantic ? rtrim(text, "\n") : text, 9888 }; 9889 } 9890 }; 9891 9892 _proto.fences = function fences(src) { 9893 var cap = this.rules.block.fences.exec(src); 9894 9895 if (cap) { 9896 var raw = cap[0]; 9897 var text = indentCodeCompensation(raw, cap[3] || ""); 9898 return { 9899 type: "code", 9900 raw: raw, 9901 lang: cap[2] ? cap[2].trim() : cap[2], 9902 text: text, 9903 }; 9904 } 9905 }; 9906 9907 _proto.heading = function heading(src) { 9908 var cap = this.rules.block.heading.exec(src); 9909 9910 if (cap) { 9911 var text = cap[2].trim(); // remove trailing #s 9912 9913 if (/#$/.test(text)) { 9914 var trimmed = rtrim(text, "#"); 9915 9916 if (this.options.pedantic) { 9917 text = trimmed.trim(); 9918 } else if (!trimmed || / $/.test(trimmed)) { 9919 // CommonMark requires space before trailing #s 9920 text = trimmed.trim(); 9921 } 9922 } 9923 9924 var token = { 9925 type: "heading", 9926 raw: cap[0], 9927 depth: cap[1].length, 9928 text: text, 9929 tokens: [], 9930 }; 9931 this.lexer.inline(token.text, token.tokens); 9932 return token; 9933 } 9934 }; 9935 9936 _proto.hr = function hr(src) { 9937 var cap = this.rules.block.hr.exec(src); 9938 9939 if (cap) { 9940 return { 9941 type: "hr", 9942 raw: cap[0], 9943 }; 9944 } 9945 }; 9946 9947 _proto.blockquote = function blockquote(src) { 9948 var cap = this.rules.block.blockquote.exec(src); 9949 9950 if (cap) { 9951 var text = cap[0].replace(/^ *>[ \t]?/gm, ""); 9952 return { 9953 type: "blockquote", 9954 raw: cap[0], 9955 tokens: this.lexer.blockTokens(text, []), 9956 text: text, 9957 }; 9958 } 9959 }; 9960 9961 _proto.list = function list(src) { 9962 var cap = this.rules.block.list.exec(src); 9963 9964 if (cap) { 9965 var raw, 9966 istask, 9967 ischecked, 9968 indent, 9969 i, 9970 blankLine, 9971 endsWithBlankLine, 9972 line, 9973 nextLine, 9974 rawLine, 9975 itemContents, 9976 endEarly; 9977 var bull = cap[1].trim(); 9978 var isordered = bull.length > 1; 9979 var list = { 9980 type: "list", 9981 raw: "", 9982 ordered: isordered, 9983 start: isordered ? +bull.slice(0, -1) : "", 9984 loose: false, 9985 items: [], 9986 }; 9987 bull = isordered ? "\\d{1,9}\\" + bull.slice(-1) : "\\" + bull; 9988 9989 if (this.options.pedantic) { 9990 bull = isordered ? bull : "[*+-]"; 9991 } // Get next list item 9992 9993 var itemRegex = new RegExp( 9994 "^( {0,3}" + bull + ")((?:[\t ][^\\n]*)?(?:\\n|$))", 9995 ); // Check if current bullet point can start a new List Item 9996 9997 while (src) { 9998 endEarly = false;
9999 10000 if (!(cap = itemRegex.exec(src))) { 10001 break; 10002 } 10003 10004 if (this.rules.block.hr.test(src)) { 10005 // End list if bullet was actually HR (possibly move into itemRegex?) 10006 break; 10007 } 10008 10009 raw = cap[0]; 10010 src = src.substring(raw.length); 10011 line = cap[2].split("\n", 1)[0]; 10012 nextLine = src.split("\n", 1)[0]; 10013 10014 if (this.options.pedantic) { 10015 indent = 2; 10016 itemContents = line.trimLeft(); 10017 } else { 10018 indent = cap[2].search(/[^ ]/); // Find first non-space char 10019 10020 indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent 10021 10022 itemContents = line.slice(indent); 10023 indent += cap[1].length; 10024 } 10025 10026 blankLine = false; 10027 10028 if (!line && /^ *$/.test(nextLine)) { 10029 // Items begin with at most one blank line 10030 raw += nextLine + "\n"; 10031 src = src.substring(nextLine.length + 1); 10032 endEarly = true; 10033 } 10034 10035 if (!endEarly) { 10036 var nextBulletRegex = new RegExp( 10037 "^ {0," + 10038 Math.min(3, indent - 1) + 10039 "}(?:[*+-]|\\d{1,9}[.)])((?: [^\\n]*)?(?:\\n|$))", 10040 ); 10041 var hrRegex = new RegExp( 10042 "^ {0," + 10043 Math.min(3, indent - 1) + 10044 "}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)", 10045 ); 10046 var fencesBeginRegex = new RegExp( 10047 "^ {0," + Math.min(3, indent - 1) + "}(?:```|~~~)", 10048 ); 10049 var headingBeginRegex = new RegExp( 10050 "^ {0," + Math.min(3, indent - 1) + "}#", 10051 ); // Check if following lines should be included in List Item 10052 10053 while (src) { 10054 rawLine = src.split("\n", 1)[0]; 10055 line = rawLine; // Re-align to follow commonmark nesting rules 10056 10057 if (this.options.pedantic) { 10058 line = line.replace(/^ {1,4}(?=( {4})*[^ ])/g, " "); 10059 } // End list item if found code fences 10060 10061 if (fencesBeginRegex.test(line)) { 10062 break; 10063 } // End list item if found start of new heading 10064 10065 if (headingBeginRegex.test(line)) { 10066 break; 10067 } // End list item if found start of new bullet 10068 10069 if (nextBulletRegex.test(line)) { 10070 break; 10071 } // Horizontal rule found 10072 10073 if (hrRegex.test(src)) { 10074 break; 10075 } 10076 10077 if (line.search(/[^ ]/) >= indent || !line.trim()) { 10078 // Dedent if possible 10079 itemContents += "\n" + line.slice(indent); 10080 } else if (!blankLine) { 10081 // Until blank line, item doesn't need indentation 10082 itemContents += "\n" + line; 10083 } else { 10084 // Otherwise, improper indentation ends this item 10085 break; 10086 } 10087 10088 if (!blankLine && !line.trim()) { 10089 // Check if current line is blank 10090 blankLine = true; 10091 } 10092 10093 raw += rawLine + "\n"; 10094 src = src.substring(rawLine.length + 1); 10095 } 10096 } 10097 10098 if (!list.loose) { 10099 // If the previous item ended with a blank line, the list is loose 10100 if (endsWithBlankLine) { 10101 list.loose = true; 10102 } else if (/\n *\n *$/.test(raw)) { 10103 endsWithBlankLine = true; 10104 } 10105 } // Check for task list items 10106 10107 if (this.options.gfm) { 10108 istask = /^\[[ xX]\] /.exec(itemContents); 10109 10110 if (istask) { 10111 ischecked = istask[0] !== "[ ] "; 10112 itemContents = itemContents.replace(/^\[[ xX]\] +/, ""); 10113 } 10114 } 10115 10116 list.items.push({ 10117 type: "list_item", 10118 raw: raw, 10119 task: !!istask, 10120 checked: ischecked, 10121 loose: false, 10122 text: itemContents, 10123 }); 10124 list.raw += raw; 10125 } // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to
10125simplify/speed up endsWithBlankLine logic 10126 10127 list.items[list.items.length - 1].raw = raw.trimRight(); 10128 list.items[list.items.length - 1].text = itemContents.trimRight(); 10129 list.raw = list.raw.trimRight(); 10130 var l = list.items.length; // Item child tokens handled here at end because we needed to have the final item to trim it first 10131 10132 for (i = 0; i < l; i++) { 10133 this.lexer.state.top = false; 10134 list.items[i].tokens = this.lexer.blockTokens( 10135 list.items[i].text, 10136 [], 10137 ); 10138 var spacers = list.items[i].tokens.filter(function (t) { 10139 return t.type === "space"; 10140 }); 10141 var hasMultipleLineBreaks = spacers.every(function (t) { 10142 var chars = t.raw.split(""); 10143 var lineBreaks = 0; 10144 10145 for ( 10146 var _iterator = _createForOfIteratorHelperLoose(chars), 10147 _step; 10148 !(_step = _iterator()).done; 10149 ) { 10150 var _char = _step.value; 10151 10152 if (_char === "\n") { 10153 lineBreaks += 1; 10154 } 10155 10156 if (lineBreaks > 1) { 10157 return true; 10158 } 10159 } 10160 10161 return false; 10162 }); 10163 10164 if (!list.loose && spacers.length && hasMultipleLineBreaks) { 10165 // Having a single line break doesn't mean a list is loose. A single line break is terminating the last list item 10166 list.loose = true; 10167 list.items[i].loose = true; 10168 } 10169 } 10170 10171 return list; 10172 } 10173 }; 10174 10175 _proto.html = function html(src) { 10176 var cap = this.rules.block.html.exec(src); 10177 10178 if (cap) { 10179 var token = { 10180 type: "html", 10181 raw: cap[0], 10182 pre: 10183 !this.options.sanitizer && 10184 (cap[1] === "pre" || 10185 cap[1] === "script" || 10186 cap[1] === "style"), 10187 text: cap[0], 10188 }; 10189 10190 if (this.options.sanitize) { 10191 token.type = "paragraph"; 10192 token.text = this.options.sanitizer 10193 ? this.options.sanitizer(cap[0]) 10194 : escape(cap[0]); 10195 token.tokens = []; 10196 this.lexer.inline(token.text, token.tokens); 10197 } 10198 10199 return token; 10200 } 10201 }; 10202 10203 _proto.def = function def(src) { 10204 var cap = this.rules.block.def.exec(src); 10205 10206 if (cap) { 10207 if (cap[3]) cap[3] = cap[3].substring(1, cap[3].length - 1); 10208 var tag = cap[1].toLowerCase().replace(/\s+/g, " "); 10209 return { 10210 type: "def", 10211 tag: tag, 10212 raw: cap[0], 10213 href: cap[2], 10214 title: cap[3], 10215 }; 10216 } 10217 }; 10218 10219 _proto.table = function table(src) { 10220 var cap = this.rules.block.table.exec(src); 10221 10222 if (cap) { 10223 var item = { 10224 type: "table", 10225 header: splitCells(cap[1]).map(function (c) { 10226 return { 10227 text: c, 10228 }; 10229 }), 10230 align: cap[2].replace(/^ *|\| *$/g, "").split(/ *\| */), 10231 rows: 10232 cap[3] && cap[3].trim() 10233 ? cap[3].replace(/\n[ \t]*$/, "").split("\n") 10234 : [], 10235 }; 10236 10237 if (item.header.length === item.align.length) { 10238 item.raw = cap[0]; 10239 var l = item.align.length; 10240 var i, j, k, row; 10241 10242 for (i = 0; i < l; i++) { 10243 if (/^ *-+: *$/.test(item.align[i])) { 10244 item.align[i] = "right"; 10245 } else if (/^ *:-+: *$/.test(item.align[i])) { 10246 item.align[i] = "center"; 10247 } else if (/^ *:-+ *$/.test(item.align[i])) { 10248 item.align[i] = "left"; 10249 } else { 10250 item.align[i] = null; 10251 } 10252 } 10253 10254 l = item.rows.length; 10255 10256 for (i = 0; i < l; i++) { 10257 item.rows[i] = splitCells( 10258 item.rows[i], 10259 item.header.length, 10260 ).map(function (c) { 10261 return { 10262 text: c, 10263 }; 10264 }); 10265 } // parse child tokens inside headers and cells 10266 // header child tokens 10267 10268 l = item.header.length; 10269 10270 for (j = 0; j < l; j++) { 10271 item.header[j].tokens = []; 10272 this.lexer.inline(item.header[j].text, item.header[j].tokens); 10273 } // cell child tokens 10274 10275 l = item.rows.length; 10276 10277 for (j = 0; j < l; j++) { 10278 row = item.rows[j]; 10279 10280 for (k = 0; k < row.length; k++) { 10281 row[k].tokens = []; 10282 this.lexer.inline(row[k].text, row[k].tokens); 10283 } 10284 } 10285 10286 return item; 10287 } 10288 } 10289 }; 10290 10291 _proto.lheading = function lheading(src) { 10292 var cap = this.rules.block.lheading.exec(src); 10293 10294 if (cap) { 10295 var token = { 10296 type: "heading", 10297 raw: cap[0], 10298 depth: cap[2].charAt(0) === "=" ? 1 : 2, 10299 text: cap[1], 10300 tokens: [], 10301 }; 10302 this.lexer.inline(token.text, token.tokens); 10303 return token; 10304 } 10305 }; 10306 10307 _proto.paragraph = function paragraph(src) { 10308 var cap = this.rules.block.paragraph.exec(src); 10309 10310 if (cap) { 10311 var token = { 10312 type: "paragraph", 10313 raw: cap[0], 10314 text: 10315 cap[1].charAt(cap[1].length - 1) === "\n" 10316 ? cap[1].slice(0, -1) 10317 : cap[1], 10318 tokens: [], 10319 }; 10320 this.lexer.inline(token.text, token.tokens); 10321 return token; 10322 } 10323 }; 10324 10325 _proto.text = function text(src) { 10326 var cap = this.rules.block.text.exec(src); 10327 10328 if (cap) { 10329 var token = { 10330 type: "text", 10331 raw: cap[0], 10332 text: cap[0], 10333 tokens: [], 10334 }; 10335 this.lexer.inline(token.text, token.tokens); 10336 return token; 10337 } 10338 }; 10339 10340 _proto.escape = function escape$1(src) { 10341 var cap = this.rules.inline.escape.exec(src); 10342 10343 if (cap) { 10344 return { 10345 type: "escape", 10346 raw: cap[0], 10347 text: escape(cap[1]), 10348 }; 10349 } 10350 }; 10351 10352 _proto.tag = function tag(src) { 10353 var cap = this.rules.inline.tag.exec(src); 10354 10355 if (cap) { 10356 if (!this.lexer.state.inLink && /^<a /i.test(cap[0])) { 10357 this.lexer.state.inLink = true; 10358 } else if (this.lexer.state.inLink && /^<\/a>/i.test(cap[0])) { 10359 this.lexer.state.inLink = false;
10360 } 10361 10362 if ( 10363 !this.lexer.state.inRawBlock && 10364 /^<(pre|code|kbd|script)(\s|>)/i.test(cap[0]) 10365 ) { 10366 this.lexer.state.inRawBlock = true; 10367 } else if ( 10368 this.lexer.state.inRawBlock && 10369 /^<\/(pre|code|kbd|script)(\s|>)/i.test(cap[0]) 10370 ) { 10371 this.lexer.state.inRawBlock = false; 10372 } 10373 10374 return { 10375 type: this.options.sanitize ? "text" : "html", 10376 raw: cap[0], 10377 inLink: this.lexer.state.inLink, 10378 inRawBlock: this.lexer.state.inRawBlock, 10379 text: this.options.sanitize 10380 ? this.options.sanitizer 10381 ? this.options.sanitizer(cap[0]) 10382 : escape(cap[0]) 10383 : cap[0], 10384 }; 10385 } 10386 }; 10387 10388 _proto.link = function link(src) { 10389 var cap = this.rules.inline.link.exec(src); 10390 10391 if (cap) { 10392 var trimmedUrl = cap[2].trim(); 10393 10394 if (!this.options.pedantic && /^</.test(trimmedUrl)) { 10395 // commonmark requires matching angle brackets 10396 if (!/>$/.test(trimmedUrl)) { 10397 return; 10398 } // ending angle bracket cannot be escaped 10399 10400 var rtrimSlash = rtrim(trimmedUrl.slice(0, -1), "\\"); 10401 10402 if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { 10403 return; 10404 } 10405 } else { 10406 // find closing parenthesis 10407 var lastParenIndex = findClosingBracket(cap[2], "()"); 10408 10409 if (lastParenIndex > -1) { 10410 var start = cap[0].indexOf("!") === 0 ? 5 : 4; 10411 var linkLen = start + cap[1].length + lastParenIndex; 10412 cap[2] = cap[2].substring(0, lastParenIndex); 10413 cap[0] = cap[0].substring(0, linkLen).trim(); 10414 cap[3] = ""; 10415 } 10416 } 10417 10418 var href = cap[2]; 10419 var title = ""; 10420 10421 if (this.options.pedantic) { 10422 // split pedantic href and title 10423 var link = /^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(href); 10424 10425 if (link) { 10426 href = link[1]; 10427 title = link[3]; 10428 } 10429 } else { 10430 title = cap[3] ? cap[3].slice(1, -1) : ""; 10431 } 10432 10433 href = href.trim(); 10434 10435 if (/^</.test(href)) { 10436 if (this.options.pedantic && !/>$/.test(trimmedUrl)) { 10437 // pedantic allows starting angle bracket without ending angle bracket 10438 href = href.slice(1); 10439 } else { 10440 href = href.slice(1, -1); 10441 } 10442 } 10443 10444 return outputLink( 10445 cap, 10446 { 10447 href: href 10448 ? href.replace(this.rules.inline._escapes, "$1") 10449 : href, 10450 title: title 10451 ? title.replace(this.rules.inline._escapes, "$1") 10452 : title, 10453 }, 10454 cap[0], 10455 this.lexer, 10456 ); 10457 } 10458 }; 10459 10460 _proto.reflink = function reflink(src, links) { 10461 var cap; 10462 10463 if ( 10464 (cap = this.rules.inline.reflink.exec(src)) || 10465 (cap = this.rules.inline.nolink.exec(src)) 10466 ) { 10467 var link = (cap[2] || cap[1]).replace(/\s+/g, " "); 10468 link = links[link.toLowerCase()]; 10469 10470 if (!link || !link.href) { 10471 var text = cap[0].charAt(0); 10472 return { 10473 type: "text", 10474 raw: text, 10475 text: text, 10476 }; 10477 } 10478 10479 return outputLink(cap, link, cap[0], this.lexer); 10480 } 10481 }; 10482 10483 _proto.emStrong = function emStrong(src, maskedSrc, prevChar) { 10484 if (prevChar === void 0) { 10485 prevChar = ""; 10486 } 10487 10488 var match = this.rules.inline.emStrong.lDelim.exec(src); 10489 if (!match) return; // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well 10490 10491 if ( 10492 match[3] && 10493 prevChar.match( 10494
vendor: 9,025 bytes, line 10494
10494/(?:[0-9A-Za-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E]|\uD838[\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A])/
10494, 10495 ) 10496 ) 10497 return; 10498 var nextChar = match[1] || match[2] || ""; 10499 10500 if ( 10501 !nextChar || 10502 (nextChar && 10503 (prevChar === "" || 10504 this.rules.inline.punctuation.exec(prevChar))) 10505 ) { 10506 var lLength = match[0].length - 1; 10507 var rDelim, 10508 rLength, 10509 delimTotal = lLength, 10510 midDelimTotal = 0; 10511 var endReg = 10512 match[0][0] === "*" 10513 ? this.rules.inline.emStrong.rDelimAst 10514 : this.rules.inline.emStrong.rDelimUnd; 10515 endReg.lastIndex = 0; // Clip maskedSrc to same section of string as src (move to lexer?) 10516 10517 maskedSrc = maskedSrc.slice(-1 * src.length + lLength); 10518 10519 while ((match = endReg.exec(maskedSrc)) != null) { 10520 rDelim = 10521 match[1] || 10522 match[2] || 10523 match[3] || 10524 match[4] || 10525 match[5] || 10526 match[6]; 10527 if (!rDelim) continue; // skip single * in __abc*abc__ 10528 10529 rLength = rDelim.length; 10530 10531 if (match[3] || match[4]) { 10532 // found another Left Delim 10533 delimTotal += rLength; 10534 continue; 10535 } else if (match[5] || match[6]) { 10536 // either Left or Right Delim 10537 if (lLength % 3 && !((lLength + rLength) % 3)) { 10538 midDelimTotal += rLength; 10539 continue; // CommonMark Emphasis Rules 9-10 10540 } 10541 } 10542 10543 delimTotal -= rLength; 10544 if (delimTotal > 0) continue; // Haven't found enough closing delimiters 10545 // Remove extra characters. *a*** -> *a* 10546 10547 rLength = Math.min( 10548 rLength, 10549 rLength + delimTotal + midDelimTotal, 10550 ); // Create `em` if smallest delimiter has odd char count. *a*** 10551 10552 if (Math.min(lLength, rLength) % 2) { 10553 var _text = src.slice(1, lLength + match.index + rLength); 10554 10555 return { 10556 type: "em", 10557 raw: src.slice(0, lLength + match.index + rLength + 1), 10558 text: _text, 10559 tokens: this.lexer.inlineTokens(_text, []), 10560 }; 10561 } // Create 'strong' if smallest delimiter has even char count. **a*** 10562 10563 var text = src.slice(2, lLength + match.index + rLength - 1); 10564 return { 10565 type: "strong", 10566 raw: src.slice(0, lLength + match.index + rLength + 1), 10567 text: text, 10568 tokens: this.lexer.inlineTokens(text, []), 10569 }; 10570 } 10571 } 10572 }; 10573 10574 _proto.codespan = function codespan(src) { 10575 var cap = this.rules.inline.code.exec(src); 10576 10577 if (cap) { 10578 var text = cap[2].replace(/\n/g, " "); 10579 var hasNonSpaceChars = /[^ ]/.test(text); 10580 var hasSpaceCharsOnBothEnds = /^ /.test(text) && / $/.test(text); 10581 10582 if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { 10583 text = text.substring(1, text.length - 1); 10584 } 10585 10586 text = escape(text, true); 10587 return { 10588 type: "codespan", 10589 raw: cap[0], 10590 text: text, 10591 }; 10592 } 10593 }; 10594 10595 _proto.br = function br(src) { 10596 var cap = this.rules.inline.br.exec(src); 10597 10598 if (cap) { 10599 return { 10600 type: "br", 10601 raw: cap[0], 10602 }; 10603 } 10604 }; 10605 10606 _proto.del = function del(src) { 10607 var cap = this.rules.inline.del.exec(src); 10608 10609 if (cap) { 10610 return { 10611 type: "del", 10612 raw: cap[0], 10613 text: cap[2], 10614 tokens: this.lexer.inlineTokens(cap[2], []), 10615 }; 10616 } 10617 }; 10618 10619 _proto.autolink = function autolink(src, mangle) { 10620 var cap = this.rules.inline.autolink.exec(src); 10621 10622 if (cap) { 10623 var text, href; 10624 10625 if (cap[2] === "@") { 10626 text = escape(this.options.mangle ? mangle(cap[1]) : cap[1]); 10627 href = "mailto:" + text; 10628 } else { 10629 text = escape(cap[1]); 10630 href = text; 10631 } 10632 10633 return { 10634 type: "link", 10635 raw: cap[0], 10636 text: text, 10637 href: href, 10638 tokens: [ 10639 { 10640 type: "text", 10641 raw: text, 10642 text: text, 10643 }, 10644 ], 10645 }; 10646 } 10647 }; 10648 10649 _proto.url = function url(src, mangle) { 10650 var cap; 10651 10652 if ((cap = this.rules.inline.url.exec(src))) { 10653 var text, href; 10654 10655 if (cap[2] === "@") { 10656 text = escape(this.options.mangle ? mangle(cap[0]) : cap[0]); 10657 href = "mailto:" + text; 10658 } else { 10659 // do extended autolink path validation 10660 var prevCapZero; 10661 10662 do { 10663 prevCapZero = cap[0]; 10664 cap[0] = this.rules.inline._backpedal.exec(cap[0])[0]; 10665 } while (prevCapZero !== cap[0]); 10666 10667 text = escape(cap[0]); 10668 10669 if (cap[1] === "www.") { 10670 href = "http://" + text; 10671 } else { 10672 href = text; 10673 } 10674 } 10675 10676 return { 10677 type: "link", 10678 raw: cap[0], 10679 text: text, 10680 href: href, 10681 tokens: [ 10682 { 10683 type: "text", 10684 raw: text, 10685 text: text, 10686 }, 10687 ], 10688 }; 10689 } 10690 }; 10691 10692 _proto.inlineText = function inlineText(src, smartypants) { 10693 var cap = this.rules.inline.text.exec(src); 10694 10695 if (cap) { 10696 var text; 10697 10698 if (this.lexer.state.inRawBlock) { 10699 text = this.options.sanitize 10700 ? this.options.sanitizer 10701 ? this.options.sanitizer(cap[0]) 10702 : escape(cap[0]) 10703 : cap[0]; 10704 } else { 10705 text = escape( 10706 this.options.smartypants ? smartypants(cap[0]) : cap[0], 10707 ); 10708 } 10709 10710 return { 10711 type: "text", 10712 raw: cap[0], 10713 text: text, 10714 }; 10715 } 10716 }; 10717 10718 return Tokenizer; 10719 })(); 10720 10721 /** 10722 * Block-Level Grammar 10723 */ 10724 10725 var block = { 10726 newline: /^(?: *(?:\n|$))+/, 10727 code: /^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/, 10728 fences: 10729 /^ {0,3}(`{3,}(?=[^`\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~`]* *(?=\n|$)|$)/, 10730 hr: /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/, 10731 heading: /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/, 10732 blockquote: /^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/, 10733 list: /^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/, 10734 html: 10735 "^ {0,3}(?:" + // optional indentation 10736 "<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:</\\1>[^\\n]*\\n+|$)" + // (1) 10737 "|comment[^\\n]*(\\n+|$)" + // (2) 10738 "|<\\?[\\s\\S]*?(?:\\?>\\n*|$)" + // (3) 10739 "|<![A-Z][\\s\\S]*?(?:>\\n*|$)" + // (4) 10740 "|<!\\[CDATA\\[[\\s\\S]*?(?:\\]\\]>\\n*|$)" + // (5) 10741 "|</?(tag)(?: +|\\n|/?>
10741)[\\s\\S]*?(?:(?:\\n *)+\\n|$)" + // (6) 10742 "|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)" + // (7) open tag 10743 "|</(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)" + // (7) closing tag 10744 ")", 10745 def: /^ {0,3}\[(label)\]: *(?:\n *)?<?([^\s>]+)>?(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/, 10746 table: noopTest, 10747 lheading: /^([^\n]+)\n {0,3}(=+|-+) *(?:\n+|$)/, 10748 // regex template, placeholders will be replaced according to different paragraph 10749 // interruption rules of commonmark and the original markdown spec: 10750 _paragraph: 10751 /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/, 10752 text: /^[^\n]+/, 10753 }; 10754 block._label = /(?!\s*\])(?:\\.|[^\[\]\\])+/; 10755 block._title = 10756 /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/; 10757 block.def = edit(block.def) 10758 .replace("label", block._label) 10759 .replace("title", block._title) 10760 .getRegex(); 10761 block.bullet = /(?:[*+-]|\d{1,9}[.)])/; 10762 block.listItemStart = edit(/^( *)(bull) */) 10763 .replace("bull", block.bullet) 10764 .getRegex(); 10765 block.list = edit(block.list) 10766 .replace(/bull/g, block.bullet) 10767 .replace( 10768 "hr", 10769 "\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))", 10770 ) 10771 .replace("def", "\\n+(?=" + block.def.source + ")") 10772 .getRegex(); 10773 block._tag = 10774 "address|article|aside|base|basefont|blockquote|body|caption" + 10775 "|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption" + 10776 "|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe" + 10777 "|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option" + 10778 "|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr" + 10779 "|track|ul"; 10780 block._comment = /<!--(?!-?>)[\s\S]*?(?:-->|$)/; 10781 block.html = edit(block.html, "i") 10782 .replace("comment", block._comment) 10783 .replace("tag", block._tag) 10784 .replace( 10785 "attribute", 10786 / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/, 10787 ) 10788 .getRegex(); 10789 block.paragraph = edit(block._paragraph) 10790 .replace("hr", block.hr) 10791 .replace("heading", " {0,3}#{1,6} ") 10792 .replace("|lheading", "") // setex headings don't interrupt commonmark paragraphs 10793 .replace("|table", "") 10794 .replace("blockquote", " {0,3}>") 10795 .replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n") 10796 .replace("list", " {0,3}(?:[*+-]|1[.)]) ") // only lists starting from 1 can interrupt 10797 .replace( 10798 "html", 10799 "</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)", 10800 ) 10801 .replace("tag", block._tag) // pars can be interrupted by type (6) html blocks 10802 .getRegex(); 10803 block.blockquote = edit(block.blockquote) 10804 .replace("paragraph", block.paragraph) 10805 .getRegex(); 10806 /** 10807 * Normal Block Grammar 10808 */ 10809 10810 block.normal = merge({}, block); 10811 /** 10812 * GFM Block Grammar 10813 */ 10814 10815 block.gfm = merge({}, block.normal, { 10816 table: 10817 "^ *([^\\n ].*\\|.*)\\n" + // Header 10818 " {0,3}(?:\\| *)?(:?-+:? *(?:\\| *:?-+:? *)*)(?:\\| *)?" + // Align 10819 "(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)", // Cells 10820 }); 10821 block.gfm.table = edit(block.gfm.table) 10822 .replace("hr", block.hr) 10823 .replace("heading", " {0,3}#{1,6} ") 10824 .replace("blockquote", " {0,3}>") 10825 .replace("code", " {4}[^\\n]") 10826 .replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n") 10827 .replace("list", " {0,3}(?:[*+-]|1[.)]) ") // only lists starting from 1 can interrupt 10828 .replace( 10829 "html", 10830 "</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)", 10831 ) 10832 .replace("tag", block._tag) // tables can be interrupted by type (6) html blocks 10833 .getRegex(); 10834 block.gfm.paragraph = edit(block._paragraph) 10835 .replace("hr", block.hr) 10836 .replace("heading", " {0,3}#{1,6} ") 10837 .replace("|lheading", "") // setex headings don't interrupt commonmark paragraphs 10838 .replace("table", block.gfm.table) // interrupt paragraphs with table 10839 .replace("blockquote", " {0,3}>") 10840 .replace("fences", " {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n") 10841 .replace("list", " {0,3}(?:[*+-]|1[.)]) ") // only lists starting from 1 can interrupt 10842 .replace( 10843 "html", 10844 "</?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)", 10845 ) 10846 .replace("tag", block._tag) // pars can be interrupted by type (6) html blocks 10847 .getRegex(); 10848 /** 10849 * Pedantic grammar (original John Gruber's loose markdown specification) 10850 */ 10851 10852 block.pedantic = merge({}, block.normal, { 10853 html: edit( 10854 "^ *(?:comment *(?:\\n|\\s*$)" + 10855 "|<(tag)[\\s\\S]+?</\\1> *(?:\\n{2,}|\\s*$)" + // closed tag 10856 "|<tag(?:\"[^\"]*\"|'[^']*'|\\s[^'\"/>\\s]*)*?/?> *(?:\\n{2,}|\\s*$))", 10857 ) 10858 .replace("comment", block._comment) 10859 .replace( 10860 /tag/g, 10861 "(?!(?:" + 10862 "a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub" + 10863 "|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)" + 10864 "\\b)\\w+(?!:|[^\\w\\s@]*@)\\b", 10865 ) 10866 .getRegex(), 10867 def: /^ *\[([^\]]+)\]: *<?([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, 10868 heading: /^(#{1,6})(.*)(?:\n+|$)/, 10869 fences: noopTest, 10870 // fences not supported 10871 paragraph: edit(block.normal._paragraph) 10872 .replace("hr", block.hr) 10873 .replace("heading", " *#{1,6} *[^\n]") 10874 .replace("lheading", block.lheading) 10875 .replace("blockquote", " {0,3}>") 10876 .replace("|fences", "") 10877 .replace("|list", "") 10878 .replace("|html", "") 10879 .getRegex(), 10880 }); 10881 /** 10882 * Inline-Level Grammar 10883 */ 10884 10885 var inline = { 10886 escape: /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/, 10887 autolink: /^<(scheme:[^\s\x00-\x1f<>]*|email)>/, 10888 url: noopTest, 10889 tag: 10890 "^comment" + 10891 "|^</[a-zA-Z][\\w:-]*\\s*>" + // self-closing tag 10892 "|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>" + // open tag 10893 "|^<\\?[\\s\\S]*?\\?>" + // processing instruction, e.g. <?php ?> 10894 "|^<![a-zA-Z]+\\s[\\s\\S]*?>" + // declaration, e.g. <!DOCTYPE html> 10895 "|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>", 10896 // CDATA section 10897 link: /^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/, 10898 reflink: /^!?\[(label)\]\[(ref)\]/, 10899 nolink: /^!?\[(ref)\](?:\[\])?/, 10900 reflinkSearch: "reflink|nolink(?!\\()", 10901 emStrong: { 10902 lDelim: /^(?:\*+(?:([punct_])|[^\s*]))|^_+(?:([punct*])|([^\s_]))/, 10903 // (1) and (2) can only be a Right Delimiter. (3) and (4) can only be Left. (5) and (6) can be either Left or Right. 10904 // () Skip orphan inside strong () Consume to delim (1) #*** (2) a***#, a*** (3) #***a, ***a (4) ***# (5) #***# (6) a***a 10905 rDelimAst: 10906 /^[^_*]*?\_\_[^_*]*?\*[^_*]*?(?=\_\_)|[^*]+(?=[^*])|[punct_](\*+)(?=[\s]|$)|[^punct*_\s](\*+)(?=[punct_\s]|$)|[punct_\s](\*+)(?=[^punct*_\s])|[\s](\*+)(?=[punct_])|[punct_](\*+)(?=[punct_])|[^punct*_\s](\*+)(?=[^punct*_\s])/, 10907 rDelimUnd: 10908 /^[^_*]*?\*\*[^_*]*?\_[^_*]*?(?=\*\*)|[^_]+(?=[^_])|[punct*](\_+)(?=[\s]|$)|[^punct*_\s](\_+)(?=[punct*\s]|$)|[punct*\s](\_+)(?=[^punct*_\s])|[\s](\_+)(?=[punct*])|[punct*](\_+)(?=[punct*])/, // ^- Not allowed for _ 10909 }, 10910 code: /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/, 10911 br: /^( {2,}|\\)\n(?!\s*$)/, 10912 del: noopTest, 10913 text: /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/, 10914 punctuation: /^([\spunctuation])/, 10915 }; // list of punctuation marks from CommonMark spec 10916 // without * and _ to handle the different emphasis markers * and _ 10917 10918 inline._punctuation = "!\"#$%&'()+\\-.,/:;<=>?@\\[\\]`^{|}~"; 10919 inline.punctuation = edit(inline.punctuation) 10920 .replace(/punctuation/g, inline._punctuation) 10921 .getRegex(); // sequences em should skip over [title](link), `code`, <html> 10922 10923 inline.blockSkip = /\[[^\]]*?\]\([^\)]*?\)|`[^`]*?`|<[^>]*?>/g; 10924 inline.escapedEmSt = /\\\*|\\_/g; 10925 inline._comment = edit(block._comment) 10926 .replace("(?:-->|$)", "-->") 10927 .getRegex(); 10928 inline.emStrong.lDelim = edit(inline.emStrong.lDelim) 10929 .replace(/punct/g, inline._punctuation) 10930 .getRegex(); 10931 inline.emStrong.rDelimAst = edit(inline.emStrong.rDelimAst, "g") 10932 .replace(/punct/g, inline._punctuation) 10933 .getRegex(); 10934 inline.emStrong.rDelimUnd = edit(inline.emStrong.rDelimUnd, "g") 10935 .replace(/punct/g, inline._punctuation) 10936 .getRegex(); 10937 inline._escapes = /\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g; 10938 inline._scheme = /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/; 10939 inline._email = 10940 /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/; 10941 inline.autolink = edit(inline.autolink) 10942 .replace("scheme", inline._scheme) 10943 .replace("email", inline._email) 10944 .getRegex(); 10945 inline._attribute = 10946 /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/; 10947 inline.tag = edit(inline.tag)
10948 .replace("comment", inline._comment) 10949 .replace("attribute", inline._attribute) 10950 .getRegex(); 10951 inline._label = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; 10952 inline._href = /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/; 10953 inline._title = 10954 /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/; 10955 inline.link = edit(inline.link) 10956 .replace("label", inline._label) 10957 .replace("href", inline._href) 10958 .replace("title", inline._title) 10959 .getRegex(); 10960 inline.reflink = edit(inline.reflink) 10961 .replace("label", inline._label) 10962 .replace("ref", block._label) 10963 .getRegex(); 10964 inline.nolink = edit(inline.nolink) 10965 .replace("ref", block._label) 10966 .getRegex(); 10967 inline.reflinkSearch = edit(inline.reflinkSearch, "g") 10968 .replace("reflink", inline.reflink) 10969 .replace("nolink", inline.nolink) 10970 .getRegex(); 10971 /** 10972 * Normal Inline Grammar 10973 */ 10974 10975 inline.normal = merge({}, inline); 10976 /** 10977 * Pedantic Inline Grammar 10978 */ 10979 10980 inline.pedantic = merge({}, inline.normal, { 10981 strong: { 10982 start: /^__|\*\*/, 10983 middle: 10984 /^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/, 10985 endAst: /\*\*(?!\*)/g, 10986 endUnd: /__(?!_)/g, 10987 }, 10988 em: { 10989 start: /^_|\*/, 10990 middle: 10991 /^()\*(?=\S)([\s\S]*?\S)\*(?!\*)|^_(?=\S)([\s\S]*?\S)_(?!_)/, 10992 endAst: /\*(?!\*)/g, 10993 endUnd: /_(?!_)/g, 10994 }, 10995 link: edit(/^!?\[(label)\]\((.*?)\)/) 10996 .replace("label", inline._label) 10997 .getRegex(), 10998 reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/) 10999 .replace("label", inline._label) 11000 .getRegex(), 11001 }); 11002 /** 11003 * GFM Inline Grammar 11004 */ 11005 11006 inline.gfm = merge({}, inline.normal, { 11007 escape: edit(inline.escape).replace("])", "~|])").getRegex(), 11008 _extended_email: 11009 /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/, 11010 url: /^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/, 11011 _backpedal: 11012 /(?:[^?!.,:;*_~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_~)]+(?!$))+/, 11013 del: /^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/, 11014 text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\<!\[`*~_]|\b_|https?:\/\/|ftp:\/\/|www\.|$)|[^ ](?= {2,}\n)|[^a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-](?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)))/, 11015 }); 11016 inline.gfm.url = edit(inline.gfm.url, "i") 11017 .replace("email", inline.gfm._extended_email) 11018 .getRegex(); 11019 /** 11020 * GFM + Line Breaks Inline Grammar 11021 */ 11022 11023 inline.breaks = merge({}, inline.gfm, { 11024 br: edit(inline.br).replace("{2,}", "*").getRegex(), 11025 text: edit(inline.gfm.text) 11026 .replace("\\b_", "\\b_| {2,}\\n") 11027 .replace(/\{2,\}/g, "*") 11028 .getRegex(), 11029 }); 11030 11031 /** 11032 * smartypants text replacement 11033 * @param {string} text 11034 */ 11035 11036 function smartypants(text) { 11037 return text // em-dashes 11038 .replace(/---/g, "\u2014") // en-dashes 11039 .replace(/--/g, "\u2013") // opening singles 11040 .replace(/(^|[-\u2014/(\[{"\s])'/g, "$1\u2018") // closing singles & apostrophes 11041 .replace(/'/g, "\u2019") // opening doubles 11042 .replace(/(^|[-\u2014/(\[{\u2018\s])"/g, "$1\u201C") // closing doubles 11043 .replace(/"/g, "\u201D") // ellipses 11044 .replace(/\.{3}/g, "\u2026"); 11045 } 11046 /** 11047 * mangle email addresses 11048 * @param {string} text 11049 */ 11050 11051 function mangle(text) { 11052 var out = "", 11053 i, 11054 ch; 11055 var l = text.length; 11056 11057 for (i = 0; i < l; i++) { 11058 ch = text.charCodeAt(i); 11059 11060 if (Math.random() > 0.5) { 11061 ch = "x" + ch.toString(16); 11062 } 11063 11064 out += "&#" + ch + ";"; 11065 } 11066 11067 return out; 11068 } 11069 /** 11070 * Block Lexer 11071 */ 11072 11073 var Lexer = /*#__PURE__*/ (function () { 11074 function Lexer(options) { 11075 this.tokens = []; 11076 this.tokens.links = Object.create(null); 11077 this.options = options || exports.defaults; 11078 this.options.tokenizer = this.options.tokenizer || new Tokenizer(); 11079 this.tokenizer = this.options.tokenizer; 11080 this.tokenizer.options = this.options; 11081 this.tokenizer.lexer = this; 11082 this.inlineQueue = []; 11083 this.state = { 11084 inLink: false, 11085 inRawBlock: false, 11086 top: true, 11087 }; 11088 var rules = { 11089 block: block.normal, 11090 inline: inline.normal, 11091 }; 11092 11093 if (this.options.pedantic) { 11094 rules.block = block.pedantic; 11095 rules.inline = inline.pedantic; 11096 } else if (this.options.gfm) { 11097 rules.block = block.gfm; 11098 11099 if (this.options.breaks) { 11100 rules.inline = inline.breaks; 11101 } else { 11102 rules.inline = inline.gfm; 11103 } 11104 } 11105 11106 this.tokenizer.rules = rules; 11107 } 11108 /** 11109 * Expose Rules 11110 */ 11111 11112 /** 11113 * Static Lex Method 11114 */ 11115 Lexer.lex = function lex(src, options) { 11116 var lexer = new Lexer(options); 11117 return lexer.lex(src); 11118 }; 11119 /** 11120 * Static Lex Inline Method 11121 */ 11122 11123 Lexer.lexInline = function lexInline(src, options) { 11124 var lexer = new Lexer(options); 11125 return lexer.inlineTokens(src); 11126 }; 11127 /** 11128 * Preprocessing 11129 */ 11130 11131 var _proto = Lexer.prototype; 11132 11133 _proto.lex = function lex(src) { 11134 src = src.replace(/\r\n|\r/g, "\n"); 11135 this.blockTokens(src, this.tokens); 11136 var next; 11137 11138 while ((next = this.inlineQueue.shift())) { 11139 this.inlineTokens(next.src, next.tokens); 11140 } 11141 11142 return this.tokens; 11143 }; 11144 /** 11145 * Lexing 11146 */ 11147 11148 _proto.blockTokens = function blockTokens(src, tokens) { 11149 var _this = this; 11150 11151 if (tokens === void 0) { 11152 tokens = []; 11153 } 11154 11155 if (this.options.pedantic) { 11156 src = src.replace(/\t/g, " ").replace(/^ +$/gm, ""); 11157 } else { 11158 src = src.replace(/^( *)(\t+)/gm, function (_, leading, tabs) { 11159 return leading + " ".repeat(tabs.length); 11160 }); 11161 } 11162 11163 var token, lastToken, cutSrc, lastParagraphClipped; 11164 11165 while (src) { 11166 if ( 11167 this.options.extensions && 11168 this.options.extensions.block && 11169 this.options.extensions.block.some(function (extTokenizer) { 11170 if ( 11171 (token = extTokenizer.call( 11172 { 11173 lexer: _this, 11174 }, 11175 src, 11176 tokens, 11177 )) 11178 ) { 11179 src = src.substring(token.raw.length); 11180 tokens.push(token); 11181 return true; 11182 } 11183 11184 return false;
11185 }) 11186 ) { 11187 continue; 11188 } // newline 11189 11190 if ((token = this.tokenizer.space(src))) { 11191 src = src.substring(token.raw.length); 11192 11193 if (token.raw.length === 1 && tokens.length > 0) { 11194 // if there's a single \n as a spacer, it's terminating the last line, 11195 // so move it there so that we don't get unecessary paragraph tags 11196 tokens[tokens.length - 1].raw += "\n"; 11197 } else { 11198 tokens.push(token); 11199 } 11200 11201 continue; 11202 } // code 11203 11204 if ((token = this.tokenizer.code(src))) { 11205 src = src.substring(token.raw.length); 11206 lastToken = tokens[tokens.length - 1]; // An indented code block cannot interrupt a paragraph. 11207 11208 if ( 11209 lastToken && 11210 (lastToken.type === "paragraph" || lastToken.type === "text") 11211 ) { 11212 lastToken.raw += "\n" + token.raw; 11213 lastToken.text += "\n" + token.text; 11214 this.inlineQueue[this.inlineQueue.length - 1].src = 11215 lastToken.text; 11216 } else { 11217 tokens.push(token); 11218 } 11219 11220 continue; 11221 } // fences 11222 11223 if ((token = this.tokenizer.fences(src))) { 11224 src = src.substring(token.raw.length); 11225 tokens.push(token); 11226 continue; 11227 } // heading 11228 11229 if ((token = this.tokenizer.heading(src))) { 11230 src = src.substring(token.raw.length); 11231 tokens.push(token); 11232 continue; 11233 } // hr 11234 11235 if ((token = this.tokenizer.hr(src))) { 11236 src = src.substring(token.raw.length); 11237 tokens.push(token); 11238 continue; 11239 } // blockquote 11240 11241 if ((token = this.tokenizer.blockquote(src))) { 11242 src = src.substring(token.raw.length); 11243 tokens.push(token); 11244 continue; 11245 } // list 11246 11247 if ((token = this.tokenizer.list(src))) { 11248 src = src.substring(token.raw.length); 11249 tokens.push(token); 11250 continue; 11251 } // html 11252 11253 if ((token = this.tokenizer.html(src))) { 11254 src = src.substring(token.raw.length); 11255 tokens.push(token); 11256 continue; 11257 } // def 11258 11259 if ((token = this.tokenizer.def(src))) { 11260 src = src.substring(token.raw.length); 11261 lastToken = tokens[tokens.length - 1]; 11262 11263 if ( 11264 lastToken && 11265 (lastToken.type === "paragraph" || lastToken.type === "text") 11266 ) { 11267 lastToken.raw += "\n" + token.raw; 11268 lastToken.text += "\n" + token.raw; 11269 this.inlineQueue[this.inlineQueue.length - 1].src = 11270 lastToken.text; 11271 } else if (!this.tokens.links[token.tag]) { 11272 this.tokens.links[token.tag] = { 11273 href: token.href, 11274 title: token.title, 11275 }; 11276 } 11277 11278 continue; 11279 } // table (gfm) 11280 11281 if ((token = this.tokenizer.table(src))) { 11282 src = src.substring(token.raw.length); 11283 tokens.push(token); 11284 continue; 11285 } // lheading 11286 11287 if ((token = this.tokenizer.lheading(src))) { 11288 src = src.substring(token.raw.length); 11289 tokens.push(token); 11290 continue; 11291 } // top-level paragraph 11292 // prevent paragraph consuming extensions by clipping 'src' to extension start 11293 11294 cutSrc = src; 11295 11296 if ( 11297 this.options.extensions && 11298 this.options.extensions.startBlock 11299 ) { 11300 (function () { 11301 var startIndex = Infinity; 11302 var tempSrc = src.slice(1); 11303 var tempStart = void 0; 11304 11305 _this.options.extensions.startBlock.forEach( 11306 function (getStartIndex) { 11307 tempStart = getStartIndex.call( 11308 { 11309 lexer: this, 11310 }, 11311 tempSrc, 11312 ); 11313 11314 if (typeof tempStart === "number" && tempStart >= 0) { 11315 startIndex = Math.min(startIndex, tempStart); 11316 } 11317 }, 11318 ); 11319 11320 if (startIndex < Infinity && startIndex >= 0) { 11321 cutSrc = src.substring(0, startIndex + 1); 11322 } 11323 })(); 11324 } 11325 11326 if ( 11327 this.state.top && 11328 (token = this.tokenizer.paragraph(cutSrc)) 11329 ) { 11330 lastToken = tokens[tokens.length - 1]; 11331 11332 if (lastParagraphClipped && lastToken.type === "paragraph") { 11333 lastToken.raw += "\n" + token.raw; 11334 lastToken.text += "\n" + token.text; 11335 this.inlineQueue.pop(); 11336 this.inlineQueue[this.inlineQueue.length - 1].src = 11337 lastToken.text; 11338 } else { 11339 tokens.push(token); 11340 } 11341 11342 lastParagraphClipped = cutSrc.length !== src.length; 11343 src = src.substring(token.raw.length); 11344 continue; 11345 } // text 11346 11347 if ((token = this.tokenizer.text(src))) { 11348 src = src.substring(token.raw.length); 11349 lastToken = tokens[tokens.length - 1]; 11350 11351 if (lastToken && lastToken.type === "text") { 11352 lastToken.raw += "\n" + token.raw; 11353 lastToken.text += "\n" + token.text; 11354 this.inlineQueue.pop(); 11355 this.inlineQueue[this.inlineQueue.length - 1].src = 11356 lastToken.text; 11357 } else { 11358 tokens.push(token); 11359 } 11360 11361 continue; 11362 } 11363 11364 if (src) { 11365 var errMsg = "Infinite loop on byte: " + src.charCo
11365deAt(0); 11366 11367 if (this.options.silent) { 11368 console.error(errMsg); 11369 break; 11370 } else { 11371 throw new Error(errMsg); 11372 } 11373 } 11374 } 11375 11376 this.state.top = true; 11377 return tokens; 11378 }; 11379 11380 _proto.inline = function inline(src, tokens) { 11381 if (tokens === void 0) { 11382 tokens = []; 11383 } 11384 11385 this.inlineQueue.push({ 11386 src: src, 11387 tokens: tokens, 11388 }); 11389 return tokens; 11390 }; 11391 /** 11392 * Lexing/Compiling 11393 */ 11394 11395 _proto.inlineTokens = function inlineTokens(src, tokens) { 11396 var _this2 = this; 11397 11398 if (tokens === void 0) { 11399 tokens = []; 11400 } 11401 11402 var token, lastToken, cutSrc; // String with links masked to avoid interference with em and strong 11403 11404 var maskedSrc = src; 11405 var match; 11406 var keepPrevChar, prevChar; // Mask out reflinks 11407 11408 if (this.tokens.links) { 11409 var links = Object.keys(this.tokens.links); 11410 11411 if (links.length > 0) { 11412 while ( 11413 (match = 11414 this.tokenizer.rules.inline.reflinkSearch.exec( 11415 maskedSrc, 11416 )) != null 11417 ) { 11418 if ( 11419 links.includes( 11420 match[0].slice(match[0].lastIndexOf("[") + 1, -1), 11421 ) 11422 ) { 11423 maskedSrc = 11424 maskedSrc.slice(0, match.index) + 11425 "[" + 11426 repeatString("a", match[0].length - 2) + 11427 "]" + 11428 maskedSrc.slice( 11429 this.tokenizer.rules.inline.reflinkSearch.lastIndex, 11430 ); 11431 } 11432 } 11433 } 11434 } // Mask out other blocks 11435 11436 while ( 11437 (match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != 11438 null 11439 ) { 11440 maskedSrc = 11441 maskedSrc.slice(0, match.index) + 11442 "[" + 11443 repeatString("a", match[0].length - 2) + 11444 "]" + 11445 maskedSrc.slice( 11446 this.tokenizer.rules.inline.blockSkip.lastIndex, 11447 ); 11448 } // Mask out escaped em & strong delimiters 11449 11450 while ( 11451 (match = 11452 this.tokenizer.rules.inline.escapedEmSt.exec(maskedSrc)) != null 11453 ) { 11454 maskedSrc = 11455 maskedSrc.slice(0, match.index) + 11456 "++" + 11457 maskedSrc.slice( 11458 this.tokenizer.rules.inline.escapedEmSt.lastIndex, 11459 ); 11460 } 11461 11462 while (src) { 11463 if (!keepPrevChar) { 11464 prevChar = ""; 11465 } 11466 11467 keepPrevChar = false; // extensions 11468 11469 if ( 11470 this.options.extensions && 11471 this.options.extensions.inline && 11472 this.options.extensions.inline.some(function (extTokenizer) { 11473 if ( 11474 (token = extTokenizer.call( 11475 { 11476 lexer: _this2, 11477 }, 11478 src, 11479 tokens, 11480 )) 11481 ) { 11482 src = src.substring(token.raw.length); 11483 tokens.push(token); 11484 return true; 11485 } 11486 11487 return false; 11488 }) 11489 ) { 11490 continue; 11491 } // escape 11492 11493 if ((token = this.tokenizer.escape(src))) { 11494 src = src.substring(token.raw.length); 11495 tokens.push(token); 11496 continue; 11497 } // tag 11498 11499 if ((token = this.tokenizer.tag(src))) { 11500 src = src.substring(token.raw.length); 11501 lastToken = tokens[tokens.length - 1]; 11502 11503 if ( 11504 lastToken && 11505 token.type === "text" && 11506 lastToken.type === "text" 11507 ) { 11508 lastToken.raw += token.raw; 11509 lastToken.text += token.text; 11510 } else { 11511 tokens.push(token); 11512 } 11513 11514 continue; 11515 } // link 11516 11517 if ((token = this.tokenizer.link(src))) { 11518 src = src.substring(token.raw.length); 11519 tokens.push(token); 11520 continue; 11521 } // reflink, nolink 11522 11523 if ((token = this.tokenizer.reflink(src, this.tokens.links))) { 11524 src = src.substring(token.raw.length); 11525 lastToken = tokens[tokens.length - 1]; 11526 11527 if ( 11528 lastToken && 11529 token.type === "text" && 11530 lastToken.type === "text" 11531 ) { 11532 lastToken.raw += token.raw; 11533 lastToken.text += token.text; 11534 } else { 11535 tokens.push(token); 11536 } 11537 11538 continue; 11539 } // em & strong 11540 11541 if ((token = this.tokenizer.emStrong(src, maskedSrc, prevChar))) { 11542 src = src.substring(token.raw.length); 11543 tokens.push(token); 11544 continue; 11545 } // code 11546 11547 if ((token = this.tokenizer.codespan(src))) { 11548 src = src.substring(token.raw.length); 11549 tokens.push(token); 11550 continue; 11551 } // br 11552 11553 if ((token = this.tokenizer.br(src))) { 11554 src = src.substring(token.raw.length); 11555 tokens.push(token); 11556 continue; 11557 } // del (gfm) 11558 11559 if ((token = this.tokenizer.del(src))) { 11560 src = src.substring(token.raw.length); 11561 tokens.push(token); 11562 continue; 11563 } // autolink 11564 11565 if ((token = this.tokenizer.autolink(src, mangle))) { 11566 src = src.substring(token.raw.length); 11567 tokens.push(token); 11568 continue; 11569 } // url (gfm) 11570 11571 if ( 11572 !this.state.inLink && 11573 (token = this.tokenizer.url(src, mangle)) 11574 ) { 11575 src = src.substring(token.raw.length); 11576 tokens.push(token); 11577 continue; 11578 } // text 11579 // prevent inlineText consuming extensions by clipping 'src' to extension start 11580 11581 cutSrc = src; 11582 11583 if ( 11584 this.options.extensions && 11585 this.options.extensions.startInline 11586 ) { 11587 (function () { 11588 var startIndex = Infinity; 11589 var tempSrc = src.slice(1); 11590 var tempStart = void 0; 11591 11592 _this2.options.extensions.startInline.forEach( 11593 function (getStartIndex) { 11594 tempStart = getStartIndex.call( 11595 { 11596 lexer: this, 11597 }, 11598 tempSrc, 11599 ); 11600 11601 if (typeof tempStart === "number" && tempStart >= 0) { 11602 startIndex = Math.min(startIndex, tempStart); 11603 } 11604 }, 11605 ); 11606 11607 if (startIndex < Infinity && startIndex >= 0) { 11608 cutSrc = src.substring(0, startIndex + 1); 11609 } 11610 })(); 11611 } 11612 11613 if ((token = this.tokenizer.inlineText(cutSrc, smartypants)
11613)) { 11614 src = src.substring(token.raw.length); 11615 11616 if (token.raw.slice(-1) !== "_") { 11617 // Track prevChar before string of ____ started 11618 prevChar = token.raw.slice(-1); 11619 } 11620 11621 keepPrevChar = true; 11622 lastToken = tokens[tokens.length - 1]; 11623 11624 if (lastToken && lastToken.type === "text") { 11625 lastToken.raw += token.raw; 11626 lastToken.text += token.text; 11627 } else { 11628 tokens.push(token); 11629 } 11630 11631 continue; 11632 } 11633 11634 if (src) { 11635 var errMsg = "Infinite loop on byte: " + src.charCodeAt(0); 11636 11637 if (this.options.silent) { 11638 console.error(errMsg); 11639 break; 11640 } else { 11641 throw new Error(errMsg); 11642 } 11643 } 11644 } 11645 11646 return tokens; 11647 }; 11648 11649 _createClass(Lexer, null, [ 11650 { 11651 key: "rules", 11652 get: function get() { 11653 return { 11654 block: block, 11655 inline: inline, 11656 }; 11657 }, 11658 }, 11659 ]); 11660 11661 return Lexer; 11662 })(); 11663 11664 /** 11665 * Renderer 11666 */ 11667 11668 var Renderer = /*#__PURE__*/ (function () {
11669 function Renderer(options) { 11670 this.options = options || exports.defaults; 11671 } 11672 11673 var _proto = Renderer.prototype; 11674 11675 _proto.code = function code(_code, infostring, escaped) { 11676 var lang = (infostring || "").match(/\S*/)[0]; 11677 11678 if (this.options.highlight) { 11679 var out = this.options.highlight(_code, lang); 11680 11681 if (out != null && out !== _code) { 11682 escaped = true; 11683 _code = out; 11684 } 11685 } 11686 11687 _code = _code.replace(/\n$/, "") + "\n"; 11688 11689 if (!lang) { 11690 return ( 11691 "<pre><code>" + 11692 (escaped ? _code : escape(_code, true)) + 11693 "</code></pre>\n" 11694 ); 11695 } 11696 11697 return ( 11698 '<pre><code class="' + 11699 this.options.langPrefix + 11700 escape(lang, true) + 11701 '">' + 11702 (escaped ? _code : escape(_code, true)) + 11703 "</code></pre>\n" 11704 ); 11705 }; 11706 /** 11707 * @param {string} quote 11708 */ 11709 11710 _proto.blockquote = function blockquote(quote) { 11711 return "<blockquote>\n" + quote + "</blockquote>\n"; 11712 }; 11713 11714 _proto.html = function html(_html) { 11715 return _html; 11716 }; 11717 /** 11718 * @param {string} text 11719 * @param {string} level 11720 * @param {string} raw 11721 * @param {any} slugger 11722 */ 11723 11724 _proto.heading = function heading(text, level, raw, slugger) { 11725 if (this.options.headerIds) { 11726 var id = this.options.headerPrefix + slugger.slug(raw); 11727 return ( 11728 "<h" + 11729 level + 11730 ' id="' + 11731 id + 11732 '">' + 11733 text + 11734 "</h" + 11735 level + 11736 ">\n" 11737 ); 11738 } // ignore IDs 11739 11740 return "<h" + level + ">" + text + "</h" + level + ">\n"; 11741 }; 11742 11743 _proto.hr = function hr() { 11744 return this.options.xhtml ? "<hr/>\n" : "<hr>\n"; 11745 }; 11746 11747 _proto.list = function list(body, ordered, start) { 11748 var type = ordered ? "ol" : "ul", 11749 startatt = ordered && start !== 1 ? ' start="' + start + '"' : ""; 11750 return "<" + type + startatt + ">\n" + body + "</" + type + ">\n"; 11751 }; 11752 /** 11753 * @param {string} text 11754 */ 11755 11756 _proto.listitem = function listitem(text) { 11757 return "<li>" + text + "</li>\n"; 11758 }; 11759 11760 _proto.checkbox = function checkbox(checked) { 11761 return ( 11762 "<input " + 11763 (checked ? 'checked="" ' : "") + 11764 'disabled="" type="checkbox"' + 11765 (this.options.xhtml ? " /" : "") + 11766 "> " 11767 ); 11768 }; 11769 /** 11770 * @param {string} text 11771 */ 11772 11773 _proto.paragraph = function paragraph(text) { 11774 return "<p>" + text + "</p>\n"; 11775 }; 11776 /** 11777 * @param {string} header 11778 * @param {string} body 11779 */ 11780 11781 _proto.table = function table(header, body) { 11782 if (body) body = "<tbody>" + body + "</tbody>"; 11783 return ( 11784 "<table>\n" + 11785 "<thead>\n" + 11786 header + 11787 "</thead>\n" + 11788 body + 11789 "</table>\n" 11790 ); 11791 }; 11792 /** 11793 * @param {string} content 11794 */ 11795 11796 _proto.tablerow = function tablerow(content) { 11797 return "<tr>\n" + content + "</tr>\n"; 11798 }; 11799 11800 _proto.tablecell = function tablecell(content, flags) { 11801 var type = flags.header ? "th" : "td"; 11802 var tag = flags.align 11803 ? "<" + type + ' align="' + flags.align + '">' 11804 : "<" + type + ">"; 11805 return tag + content + ("</" + type + ">\n"); 11806 }; 11807 /** 11808 * span level renderer 11809 * @param {string} text 11810 */ 11811 11812 _proto.strong = function strong(text) { 11813 return "<strong>
11813" + text + "</strong>"; 11814 }; 11815 /** 11816 * @param {string} text 11817 */ 11818 11819 _proto.em = function em(text) { 11820 return "<em>" + text + "</em>"; 11821 }; 11822 /** 11823 * @param {string} text 11824 */ 11825 11826 _proto.codespan = function codespan(text) { 11827 return "<code>" + text + "</code>"; 11828 }; 11829 11830 _proto.br = function br() { 11831 return this.options.xhtml ? "<br/>" : "<br>"; 11832 }; 11833 /** 11834 * @param {string} text 11835 */ 11836 11837 _proto.del = function del(text) { 11838 return "<del>" + text + "</del>"; 11839 }; 11840 /** 11841 * @param {string} href 11842 * @param {string} title 11843 * @param {string} text 11844 */ 11845 11846 _proto.link = function link(href, title, text) { 11847 href = cleanUrl(this.options.sanitize, this.options.baseUrl, href); 11848 11849 if (href === null) { 11850 return text; 11851 } 11852 11853 var out = '<a href="' + escape(href) + '"'; 11854 11855 if (title) { 11856 out += ' title="' + title + '"'; 11857 } 11858 11859 out += ">" + text + "</a>"; 11860 return out; 11861 }; 11862 /** 11863 * @param {string} href 11864 * @param {string} title 11865 * @param {string} text 11866 */ 11867 11868 _proto.image = function image(href, title, text) { 11869 href = cleanUrl(this.options.sanitize, this.options.baseUrl, href); 11870 11871 if (href === null) { 11872 return text; 11873 } 11874 11875 var out = '<img src="' + href + '" alt="' + text + '"'; 11876 11877 if (title) { 11878 out += ' title="' + title + '"'; 11879 } 11880 11881 out += this.options.xhtml ? "/>" : ">"; 11882 return out; 11883 }; 11884 11885 _proto.text = function text(_text) { 11886 return _text; 11887 }; 11888 11889 return Renderer; 11890 })(); 11891 11892 /** 11893 * TextRenderer 11894 * returns only the textual part of the token 11895 */ 11896 var TextRenderer = /*#__PURE__*/ (function () { 11897 function TextRenderer() {} 11898 11899 var _proto = TextRenderer.prototype; 11900 11901 // no need for block level renderers 11902 _proto.strong = function strong(text) { 11903 return text; 11904 }; 11905 11906 _proto.em = function em(text) { 11907 return text; 11908 }; 11909 11910 _proto.codespan = function codespan(text) { 11911 return text; 11912 }; 11913 11914 _proto.del = function del(text) { 11915 return text; 11916 }; 11917 11918 _proto.html = function html(text) { 11919 return text; 11920 }; 11921 11922 _proto.text = function text(_text) { 11923 return _text; 11924 }; 11925 11926 _proto.link = function link(href, title, text) { 11927 return "" + text; 11928 }; 11929 11930 _proto.image = function image(href, title, text) { 11931 return "" + text; 11932 }; 11933 11934 _proto.br = function br() { 11935 return ""; 11936 }; 11937 11938 return TextRenderer; 11939 })(); 11940 11941 /** 11942 * Slugger generates header id 11943 */ 11944 var Slugger = /*#__PURE__*/ (function () { 11945 function Slugger() { 11946 this.seen = {}; 11947 } 11948 /** 11949 * @param {string} value 11950 */ 11951 11952 var _proto = Slugger.prototype; 11953 11954 _proto.serialize = function serialize(value) { 11955 return value 11956 .toLowerCase() 11957 .trim() // remove html tags 11958 .replace(/<[!\/a-z].*?>/gi, "") // remove unwanted chars 11959 .replace( 11960 /[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, 11961 "", 11962 ) 11963 .replace(/\s/g, "-"); 11964 }; 11965 /** 11966 * Finds the next safe (unique) slug to use 11967 * @param {string} originalSlug 11968 * @param {boolean} isDryRun 11969 */ 11970 11971 _proto.getNextSafeSlug = function getNextSafeSlug( 11972 originalSlug, 11973 isDryRun, 11974 ) { 11975 var slug = originalSlug; 11976 var occurenceAccumulator = 0; 11977 11978 if (this.seen.hasOwnProperty(slug)) { 11979 occurenceAccumulator = this.seen[originalSlug]; 11980 11981 do { 11982 occurenceAccumulator++; 11983 slug = originalSlug + "-" + occurenceAccumulator; 11984 } while (this.seen.hasOwnProperty(slug)); 11985 } 11986 11987 if (!isDryRun) { 11988 this.seen[originalSlug] = occurenceAccumulator; 11989 this.seen[slug] = 0; 11990 } 11991 11992 return slug; 11993 }; 11994 /** 11995 * Convert string to unique id 11996 * @param {object} [options] 11997 * @param {boolean} [options.dryrun] Generates the next unique slug without 11998 * updating the internal accumulator. 11999 */ 12000 12001 _proto.slug = function slug(value, options) { 12002 if (options === void 0) { 12003 options = {}; 12004 } 12005 12006 var slug = this.serialize(value); 12007 return this.getNextSafeSlug(slug, options.dryrun); 12008 }; 12009 12010 return Slugger; 12011 })(); 12012 12013 /** 12014 * Parsing & Compiling 12015 */ 12016 12017 var Parser = /*#__PURE__*/ (function () { 12018 function Parser(options) { 12019 this.options = options || exports.defaults; 12020 this.options.renderer = this.options.renderer || new Renderer(); 12021 this.renderer = this.options.renderer; 12022 this.renderer.options = this.options; 12023 this.textRenderer = new TextRenderer(); 12024 this.slugger = new Slugger(); 12025 } 12026 /** 12027 * Static Parse Method 12028 */ 12029 12030 Parser.parse = function parse(tokens, options) { 12031 var parser = new Parser(options); 12032 return parser.parse(tokens); 12033 }; 12034 /** 12035 * Static Parse Inline Method 12036 */ 12037 12038 Parser.parseInline = function parseInline(tokens, options) { 12039 var parser = new Parser(options); 12040 return parser.parseInline(tokens); 12041 }; 12042 /** 12043 * Parse Loop 12044 */ 12045 12046 var _proto = Parser.prototype; 12047 12048 _proto.parse = function parse(tokens, top) { 12049 if (top === void 0) { 12050 top = true; 12051 } 12052 12053 var out = "", 12054 i, 12055 j, 12056 k, 12057 l2, 12058 l3, 12059 row, 12060 cell, 12061 header, 12062 body, 12063 token, 12064 ordered, 12065 start, 12066 loose, 12067 itemBody, 12068 item, 12069 checked, 12070 task, 12071 checkbox, 12072 ret; 12073 var l = tokens.length; 12074 12075 for (i = 0; i < l; i++) { 12076 token = tokens[i]; // Run any renderer extensions 12077 12078 if ( 12079 this.options.extensions && 12080 this.options.extensions.renderers && 12081 this.options.extensions.renderers[token.type] 12082 ) { 12083 ret = this.options.extensions.renderers[token.type].call( 12084 { 12085 parser: this, 12086 }, 12087 token, 12088 ); 12089 12090 if ( 12091 ret !== false || 12092 ![ 12093 "space", 12094 "hr", 12095 "heading", 12096 "code", 12097 "table", 12098 "blockquote", 12099 "list", 12100 "html", 12101 "paragraph", 12102 "text", 12103 ].includes(token.type) 12104 ) { 12105 out += ret || ""; 12106 continue; 12107 } 12108 } 12109 12110 switch (token.type) { 12111 case "space": { 12112 continue; 12113 } 12114 12115 case "hr": { 12116 out += this.renderer.hr(); 12117 continue; 12118 } 12119 12120 case "heading": { 12121 out += this.renderer.heading( 12122 this.parseInline(token.tokens), 12123 token.depth, 12124 unescape(this.parseInline(token.tokens, this.textRenderer)), 12125 this.slugger, 12126 ); 12127 continue; 12128 } 12129 12130 case "code": { 12131 out += this.renderer.code( 12132 token.text, 12133 token.lang, 12134 token.escaped, 12135 ); 12136 continue; 12137 } 12138 12139 case "table": { 12140 header = ""; // header 12141 12142 cell = ""; 12143 l2 = token.header.length; 12144 12145 for (j = 0; j < l2; j++) { 12146 cell += this.renderer.tablecell( 12147 this.parseInline(token.header[j].tokens), 12148 { 12149 header: true, 12150 align: token.align[j], 12151 }, 12152 ); 12153 } 12154 12155 header += this.renderer.tablerow(cell); 12156 body = ""; 12157 l2 = token.rows.length; 12158 12159 for (j = 0; j < l2; j++) { 12160 row = token.rows[j]; 12161 cell = ""; 12162 l3 = row.length; 12163 12164 for (k = 0; k < l3; k++) { 12165 cell += this.renderer.tablecell( 12166 this.parseInline(row[k].tokens), 12167 { 12168 header: false, 12169 align: token.align[k], 12170 }, 12171 ); 12172 } 12173 12174 body += this.renderer.tablerow(cell); 12175 } 12176 12177 out += this.renderer.table(header, body); 12178 continue; 12179 } 12180 12181 case "blockquote": { 12182 body = this.parse(token.tokens); 12183 out += this.renderer.blockquote(body); 12184 continue; 12185 } 12186 12187 case "list": { 12188 ordered = token.ordered; 12189 start = token.start; 12190 loose = token.loose; 12191 l2 = token.items.length; 12192 body = ""; 12193 12194 for (j = 0; j < l2; j++) { 12195 item = token.items[j]; 12196 checked = item.checked; 12197 task = item.task; 12198 itemBody = ""; 12199 12200 if (item.task) { 12201 checkbox = this.renderer.checkbox(checked); 12202 12203 if (loose) { 12204 if ( 12205 item.tokens.length > 0 && 12206 item.tokens[0].type === "paragraph" 12207 ) { 12208 item.tokens[0].text = 12209 checkbox + " " + item.tokens[0].text; 12210 12211 if ( 12212 item.tokens[0].tokens && 12213 item.tokens[0].tokens.length > 0 && 12214 item.tokens[0].tokens[0].type === "text" 12215 ) { 12216 item.tokens[0].tokens[0].text = 12217 checkbox + " " + item.tokens[0].tokens[0].text; 12218 } 12219 } else { 12220 item.tokens.unshift({ 12221 type: "text", 12222 text: checkbox, 12223 }); 12224 } 12225 } else { 12226 itemBody += checkbox; 12227 } 12228 } 12229 12230 itemBody += this.parse(item.tokens, loose); 12231 body += this.renderer.listitem(itemBody, task, checked); 12232 } 12233 12234 out += this.renderer.list(body, ordered, start); 12235 continue; 12236 } 12237 12238 case "html": {
12239 // TODO parse inline content if parameter markdown=1 12240 out += this.renderer.html(token.text); 12241 continue; 12242 } 12243 12244 case "paragraph": { 12245 out += this.renderer.paragraph( 12246 this.parseInline(token.tokens), 12247 ); 12248 continue; 12249 } 12250 12251 case "text": { 12252 body = token.tokens 12253 ? this.parseInline(token.tokens) 12254 : token.text; 12255 12256 while (i + 1 < l && tokens[i + 1].type === "text") { 12257 token = tokens[++i]; 12258 body += 12259 "\n" + 12260 (token.tokens 12261 ? this.parseInline(token.tokens) 12262 : token.text); 12263 } 12264 12265 out += top ? this.renderer.paragraph(body) : body; 12266 continue; 12267 } 12268 12269 default: { 12270 var errMsg = 12271 'Token with "' + token.type + '" type was not found.'; 12272 12273 if (this.options.silent) { 12274 console.error(errMsg); 12275 return; 12276 } else { 12277 throw new Error(errMsg); 12278 } 12279 } 12280 } 12281 } 12282 12283 return out; 12284 }; 12285 /** 12286 * Parse Inline Tokens 12287 */ 12288 12289 _proto.parseInline = function parseInline(tokens, renderer) { 12290 renderer = renderer || this.renderer; 12291 var out = "", 12292 i, 12293 token, 12294 ret; 12295 var l = tokens.length; 12296 12297 for (i = 0; i < l; i++) { 12298 token = tokens[i]; // Run any renderer extensions 12299 12300 if ( 12301 this.options.extensions && 12302 this.options.extensions.renderers && 12303 this.options.extensions.renderers[token.type] 12304 ) { 12305 ret = this.options.extensions.renderers[token.type].call( 12306 { 12307 parser: this, 12308 }, 12309 token, 12310 ); 12311 12312 if ( 12313 ret !== false || 12314 ![ 12315 "escape", 12316 "html", 12317 "link", 12318 "image", 12319 "strong", 12320 "em", 12321 "codespan", 12322 "br", 12323 "del", 12324 "text", 12325 ].includes(token.type) 12326 ) { 12327 out += ret || ""; 12328 continue; 12329 } 12330 } 12331 12332 switch (token.type) { 12333 case "escape": { 12334 out += renderer.text(token.text); 12335 break; 12336 } 12337 12338 case "html": { 12339 out += renderer.html(token.text); 12340 break; 12341 } 12342 12343 case "link": { 12344 out += renderer.link( 12345 token.href, 12346 token.title, 12347 this.parseInline(token.tokens, renderer), 12348 ); 12349 break; 12350 } 12351 12352 case "image": { 12353 out += renderer.image(token.href, token.title, token.text); 12354 break; 12355 } 12356 12357 case "strong": { 12358 out += renderer.strong( 12359 this.parseInline(token.tokens, renderer), 12360 ); 12361 break; 12362 } 12363 12364 case "em": { 12365 out += renderer.em(this.parseInline(token.tokens, renderer)); 12366 break; 12367 } 12368 12369 case "codespan": { 12370 out += renderer.codespan(token.text); 12371 break; 12372 } 12373 12374 case "br": { 12375 out += renderer.br(); 12376 break; 12377 } 12378 12379 case "del": { 12380 out += renderer.del(this.parseInline(token.tokens, renderer)); 12381 break; 12382 } 12383 12384 case "text": { 12385 out += renderer.text(token.text); 12386 break; 12387 } 12388 12389 default: { 12390 var errMsg = 12391 'Token with "' + token.type + '" type was not found.'; 12392 12393 if (this.options.silent) { 12394 console.error(errMsg); 12395 return; 12396 } else { 12397 throw new Error(errMsg); 12398 } 12399 } 12400 } 12401 } 12402 12403 return out; 12404 }; 12405 12406 return Parser; 12407 })(); 12408 12409 /** 12410 * Marked 12411 */ 12412 12413 function marked(src, opt, callback) { 12414 // throw error in case of non string input 12415 if (typeof src === "undefined" || src === null) { 12416 throw new Error("marked(): input parameter is undefined or null"); 12417 } 12418 12419 if (typeof src !== "string") { 12420 throw new Error( 12421 "marked(): input parameter is of type " + 12422 Object.prototype.toString.call(src) + 12423 ", string expected", 12424 ); 12425 } 12426 12427 if (typeof opt === "function") { 12428 callback = opt; 12429 opt = null; 12430 } 12431 12432 opt = merge({}, marked.defaults, opt || {}); 12433 checkSanitizeDeprecation(opt); 12434 12435 if (callback) { 12436 var highlight = opt.highlight; 12437 var tokens; 12438 12439 try { 12440 tokens = Lexer.lex(src, opt); 12441 } catch (e) { 12442 return callback(e); 12443 } 12444 12445 var done = function done(err) { 12446 var out; 12447 12448 if (!err) { 12449 try { 12450 if (opt.walkTokens) { 12451 marked.walkTokens(tokens, opt.walkTokens); 12452 } 12453 12454 out = Parser.parse(tokens, opt); 12455 } catch (e) { 12456 err = e; 12457 } 12458 } 12459 12460 opt.highlight = highlight; 12461 return err ? callback(err) : callback(null, out); 12462 }; 12463 12464 if (!highlight || highlight.length < 3) { 12465 return done(); 12466 } 12467 12468 delete opt.highlight; 12469 if (!tokens.length) return done(); 12470 var pending = 0; 12471 marked.walkTokens(tokens, function (token) { 12472 if (token.type === "code") { 12473 pending++; 12474 setTimeout(function () { 12475 highlight(token.text, token.lang, function (err, code) { 12476 if (err) { 12477 return done(err); 12478 } 12479 12480 if (code != null && code !== token.text) { 12481 token.text = code; 12482 token.escaped = true; 12483 } 12484 12485 pending--; 12486 12487 if (pending === 0) { 12488 done(); 12489 } 12490 }); 12491 }, 0); 12492 } 12493 }); 12494 12495 if (pending === 0) { 12496 done(); 12497 } 12498 12499 return; 12500 } 12501 12502 try { 12503 var _tokens = Lexer.lex(src, opt); 12504 12505 if (opt.walkTokens) { 12506 marked.walkTokens(_tokens, opt.walkTokens); 12507 } 12508 12509 return Parser.parse(_tokens, opt); 12510 } catch (e) { 12511 e.message += 12512 "\nPlease report this to https://github.com/markedjs/marked."; 12513 12514 if (opt.silent) { 12515 return ( 12516 "<p>An error occurred:</p><pre>" + 12517 escape(e.message + "", true) + 12518 "</pre>" 12519 ); 12520 } 12521 12522 throw e; 12523 } 12524 } 12525 /** 12526 * Options 12527 */ 12528 12529 marked.options = marked.setOptions = function (opt) { 12530 merge(marked.defaults, opt); 12531 changeDefaults(marked.defaults); 12532 return marked; 12533 }; 12534 12535 marked.getDefaults = getDefaults; 12536 marked.defaults = exports.defaults; 12537 /** 12538 * Use Extension 12539 */ 12540 12541 marked.use = function () { 12542 for ( 12543 var _len = arguments.length, args = new Array(_len), _key = 0; 12544 _key < _len; 12545 _key++ 12546 ) { 12547 args[_key] = arguments[_key]; 12548 } 12549 12550 var opts = merge.apply(void 0, [{}].concat(args)); 12551 var extensions = marked.defaults.extensions || { 12552 renderers: {}, 12553 childTokens: {}, 12554 }; 12555 var hasExtensions;
12556 args.forEach(function (pack) { 12557 // ==-- Parse "addon" extensions --== // 12558 if (pack.extensions) { 12559 hasExtensions = true; 12560 pack.extensions.forEach(function (ext) { 12561 if (!ext.name) { 12562 throw new Error("extension name required"); 12563 } 12564 12565 if (ext.renderer) { 12566 // Renderer extensions 12567 var prevRenderer = extensions.renderers 12568 ? extensions.renderers[ext.name] 12569 : null; 12570 12571 if (prevRenderer) { 12572 // Replace extension with func to run new extension but fall back if false 12573 extensions.renderers[ext.name] = function () { 12574 for ( 12575 var _len2 = arguments.length, 12576 args = new Array(_len2), 12577 _key2 = 0; 12578 _key2 < _len2; 12579 _key2++ 12580 ) { 12581 args[_key2] = arguments[_key2]; 12582 } 12583 12584 var ret = ext.renderer.apply(this, args); 12585 12586 if (ret === false) { 12587 ret = prevRenderer.apply(this, args); 12588 } 12589 12590 return ret; 12591 }; 12592 } else { 12593 extensions.renderers[ext.name] = ext.renderer; 12594 } 12595 } 12596 12597 if (ext.tokenizer) { 12598 // Tokenizer Extensions 12599 if ( 12600 !ext.level || 12601 (ext.level !== "block" && ext.level !== "inline") 12602 ) { 12603 throw new Error( 12604 "extension level must be 'block' or 'inline'", 12605 ); 12606 } 12607 12608 if (extensions[ext.level]) { 12609 extensions[ext.level].unshift(ext.tokenizer); 12610 } else { 12611 extensions[ext.level] = [ext.tokenizer]; 12612 } 12613 12614 if (ext.start) { 12615 // Function to check for start of token 12616 if (ext.level === "block") { 12617 if (extensions.startBlock) { 12618 extensions.startBlock.push(ext.start); 12619 } else { 12620 extensions.startBlock = [ext.start]; 12621 } 12622 } else if (ext.level === "inline") { 12623 if (extensions.startInline) { 12624 extensions.startInline.push(ext.start); 12625 } else { 12626 extensions.startInline = [ext.start]; 12627 } 12628 } 12629 } 12630 } 12631 12632 if (ext.childTokens) { 12633 // Child tokens to be visited by walkTokens 12634 extensions.childTokens[ext.name] = ext.childTokens; 12635 } 12636 }); 12637 } // ==-- Parse "overwrite" extensions --== // 12638 12639 if (pack.renderer) { 12640 (function () { 12641 var renderer = marked.defaults.renderer || new Renderer(); 12642 12643 var _loop = function _loop(prop) { 12644 var prevRenderer = renderer[prop]; // Replace renderer with func to run extension, but fall back if false 12645 12646 renderer[prop] = function () { 12647 for ( 12648 var _len3 = arguments.length, 12649 args = new Array(_len3), 12650 _key3 = 0; 12651 _key3 < _len3; 12652 _key3++ 12653 ) { 12654 args[_key3] = arguments[_key3]; 12655 } 12656 12657 var ret = pack.renderer[prop].apply(renderer, args); 12658 12659 if (ret === false) { 12660 ret = prevRenderer.apply(renderer, args); 12661 } 12662 12663 return ret; 12664 }; 12665 }; 12666 12667 for (var prop in pack.renderer) { 12668 _loop(prop); 12669 } 12670 12671 opts.renderer = renderer; 12672 })(); 12673 } 12674 12675 if (pack.tokenizer) { 12676 (function () { 12677 var tokenizer = marked.defaults.tokenizer || new Tokenizer(); 12678 12679 var _loop2 = function _loop2(prop) { 12680 var prevTokenizer = tokenizer[prop]; // Replace tokenizer with func to run extension, but fall back if false 12681 12682 tokenizer[prop] = function () { 12683 for ( 12684 var _len4 = arguments.length, 12685 args = new Array(_len4), 12686 _key4 = 0; 12687 _key4 < _len4; 12688 _key4++ 12689 ) {
12690 args[_key4] = arguments[_key4]; 12691 } 12692 12693 var ret = pack.tokenizer[prop].apply(tokenizer, args); 12694 12695 if (ret === false) { 12696 ret = prevTokenizer.apply(tokenizer, args); 12697 } 12698 12699 return ret; 12700 }; 12701 }; 12702 12703 for (var prop in pack.tokenizer) { 12704 _loop2(prop); 12705 } 12706 12707 opts.tokenizer = tokenizer; 12708 })(); 12709 } // ==-- Parse WalkTokens extensions --== // 12710 12711 if (pack.walkTokens) { 12712 var _walkTokens = marked.defaults.walkTokens; 12713 12714 opts.walkTokens = function (token) { 12715 pack.walkTokens.call(this, token); 12716 12717 if (_walkTokens) { 12718 _walkTokens.call(this, token); 12719 } 12720 }; 12721 } 12722 12723 if (hasExtensions) { 12724 opts.extensions = extensions; 12725 } 12726 12727 marked.setOptions(opts); 12728 }); 12729 }; 12730 /** 12731 * Run callback for every token 12732 */ 12733 12734 marked.walkTokens = function (tokens, callback) { 12735 var _loop3 = function _loop3() { 12736 var token = _step.value; 12737 callback.call(marked, token); 12738 12739 switch (token.type) { 12740 case "table": { 12741 for ( 12742 var _iterator2 = _createForOfIteratorHelperLoose( 12743 token.header, 12744 ), 12745 _step2; 12746 !(_step2 = _iterator2()).done; 12747 ) { 12748 var cell = _step2.value; 12749 marked.walkTokens(cell.tokens, callback); 12750 } 12751 12752 for ( 12753 var _iterator3 = _createForOfIteratorHelperLoose(token.rows), 12754 _step3; 12755 !(_step3 = _iterator3()).done; 12756 ) { 12757 var row = _step3.value; 12758 12759 for ( 12760 var _iterator4 = _createForOfIteratorHelperLoose(row), 12761 _step4; 12762 !(_step4 = _iterator4()).done; 12763 ) { 12764 var _cell = _step4.value; 12765 marked.walkTokens(_cell.tokens, callback); 12766 } 12767 } 12768 12769 break; 12770 } 12771 12772 case "list": { 12773 marked.walkTokens(token.items, callback); 12774 break; 12775 } 12776 12777 default: { 12778 if ( 12779 marked.defaults.extensions && 12780 marked.defaults.extensions.childTokens && 12781 marked.defaults.extensions.childTokens[token.type] 12782 ) { 12783 // Walk any extensions 12784 marked.defaults.extensions.childTokens[token.type].forEach( 12785 function (childTokens) { 12786 marked.walkTokens(token[childTokens], callback); 12787 }, 12788 ); 12789 } else if (token.tokens) { 12790 marked.walkTokens(token.tokens, callback); 12791 } 12792 } 12793 } 12794 }; 12795 12796 for ( 12797 var _iterator = _createForOfIteratorHelperLoose(tokens), _step; 12798 !(_step = _iterator()).done; 12799 ) { 12800 _loop3(); 12801 } 12802 }; 12803 /** 12804 * Parse Inline 12805 * @param {string} src 12806 */ 12807 12808 marked.parseInline = function (src, opt) { 12809 // throw error in case of non string input 12810 if (typeof src === "undefined" || src === null) { 12811 throw new Error( 12812 "marked.parseInline(): input parameter is undefined or null", 12813 ); 12814 } 12815 12816 if (typeof src !== "string") { 12817 throw new Error( 12818 "marked.parseInline(): input parameter is of type " + 12819 Object.prototype.toString.call(src) + 12820 ", string expected", 12821 ); 12822 } 12823 12824 opt = merge({}, marked.defaults, opt || {}); 12825 checkSanitizeDeprecation(opt); 12826 12827 try { 12828 var tokens = Lexer.lexInline(src, opt); 12829 12830 if (opt.walkTokens) { 12831 marked.walkTokens(tokens, opt.walkTokens); 12832 } 12833 12834 return Parser.parseInline(tokens, opt); 12835 } catch (e) { 12836 e.message += 12837 "\nPlease report this to https://github.com/markedjs/marked."; 12838 12839 if (opt.silent) { 12840 return ( 12841 "<p>An error occurred:</p><pre>" + 12842 escape(e.message + "", true) + 12843 "</pre>" 12844 ); 12845 } 12846 12847 throw e; 12848 } 12849 }; 12850 /** 12851 * Expose 12852 */ 12853 12854 marked.Parser = Parser; 12855 marked.parser = Parser.parse; 12856 marked.Renderer = Renderer; 12857 marked.TextRenderer = TextRenderer; 12858 marked.Lexer = Lexer; 12859 marked.lexer = Lexer.lex; 12860 marked.Tokenizer = Tokenizer; 12861 marked.Slugger = Slugger; 12862 marked.parse = marked; 12863 var options = marked.options; 12864 var setOptions = marked.setOptions; 12865 var use = marked.use; 12866 var walkTokens = marked.walkTokens; 12867 var parseInline = marked.parseInline; 12868 var parse = marked; 12869 var parser = Parser.parse; 12870 var lexer = Lexer.lex; 12871 12872 exports.Lexer = Lexer; 12873 exports.Parser = Parser; 12874 exports.Renderer = Renderer; 12875 exports.Slugger = Slugger; 12876 exports.TextRenderer = TextRenderer; 12877 exports.Tokenizer = Tokenizer; 12878 exports.getDefaults = getDefaults; 12879 exports.lexer = lexer;
12880 exports.marked = marked; 12881 exports.options = options; 12882 exports.parse = parse; 12883 exports.parseInline = parseInline; 12884 exports.parser = parser; 12885 exports.setOptions = setOptions; 12886 exports.use = use; 12887 exports.walkTokens = walkTokens; 12888 12889 Object.defineProperty(exports, "__esModule", { value: true }); 12890 }); 12891 </script>
12891 12892 12893
12893<script> 12894 (function () { 12895 "use strict"; 12896 12897 // Respects OS-level reduced-motion preference when invoking 12898 // programmatic smooth scrolls. Browser UAs are inconsistent 12899 // about honoring this for `scrollIntoView({behavior:"smooth"})`, 12900 // so we downgrade explicitly. 12901 function prefersReducedMotion() { 12902 return !!( 12903 window.matchMedia && 12904 window.matchMedia("(prefers-reduced-motion: reduce)").matches 12905 ); 12906 } 12907 12908 const SESSION_CONTENT_KEY = "fast-md-viewer-session-content"; 12909 const SESSION_META_KEY = "fast-md-viewer-session-meta"; 12910 const READING_SETTINGS_KEY = "md-viewer-reading"; 12911 const READER_PALETTE_STORAGE_KEY = 12912 (window.MarkVReaderPalettes && 12913 window.MarkVReaderPalettes.STORAGE_KEY) || 12914 "md-viewer-reader-palette"; 12915 const READER_LIGHT_PALETTE_STORAGE_KEY = 12916 "md-viewer-reader-light-palette"; 12917 const THEME_KEY = "md-viewer-theme"; 12918 const HIGHLIGHT_STORAGE_KEY = "md-viewer-highlights"; 12919 const DRAFT_HIGHLIGHT_STORAGE_KEY = "md-viewer-draft-highlights"; 12920 const LIBRARY_DB_NAME = "md-viewer-library"; 12921 const LIBRARY_DB_VERSION = 2; 12922 const LIBRARY_FOLDER_STORE = "folders"; 12923 const LIBRARY_META_STORE = "libraryMeta"; 12924 const UNTITLED_DRAFT_DB_NAME = "md-viewer-untitled-drafts"; 12925 const UNTITLED_DRAFT_DB_VERSION = 1; 12926 const UNTITLED_DRAFT_STORE = "drafts"; 12927 const UNTITLED_DRAFT_BACKUP_KEY = "md-viewer-untitled-drafts-backup"; 12928 const LIBRARY_LIMITS = Object.freeze({ 12929 maxDepth: 12, 12930 maxFiles: 2000, 12931 maxFolders: 12, 12932 yieldEvery: 40, 12933 }); 12934 const MAX_SHARE_URL_LENGTH = 32768; 12935 const LONG_SHARE_URL_WARNING_LENGTH = 12000; 12936 const LIBRARY_IGNORED_NAMES = new Set([ 12937 ".git", 12938 "node_modules", 12939 ".next", 12940 "dist", 12941 "build", 12942 ".turbo", 12943 ".DS_Store", 12944 ]); 12945 const MAX_HIGHLIGHTS_PER_DOCUMENT = 200; 12946 const MIN_HIGHLIGHT_SELECTION_LENGTH = 3; 12947 const HIGHLIGHT_CONTEXT_WINDOW = 32; 12948 const MERMAID_SCRIPT_URL = "./vendor/mermaid.min.js"; 12949 const BEAUTIFUL_MERMAID_SCRIPT_URL = 12950 "./vendor/beautiful-mermaid.min.js"; 12951 const MERMAID_RENDERER_NAME = "mermaid"; 12952 const BEAUTIFUL_MERMAID_RENDERER_NAME = "beautiful-mermaid"; 12953 const HLJS_CDN = 12954 "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1"; 12955 const HLJS_LIGHT_THEME_URL = HLJS_CDN + "/styles/github.min.css"; 12956 const HLJS_DARK_THEME_URL = HLJS_CDN + "/styles/github-dark.min.css"; 12957 const KATEX_CDN = "https://cdn.jsdelivr.net/npm/[email protected]/dist"; 12958 const libraryCore = window.MarkVLibraryCore || {}; 12959 const readerPaletteApi = window.MarkVReaderPalettes || null; 12960 const REMOTE_ASSET_INTEGRITY = Object.freeze({ 12961 [HLJS_CDN + "/highlight.min.js"]: 12962 "sha384-RH2xi4eIQ/gjtbs9fUXM68sLSi99C7ZWBRX1vDrVv6GQXRibxXLbwO2NGZB74MbU", 12963 [HLJS_CDN + "/styles/github.min.css"]: 12964 "sha384-eFTL69TLRZTkNfYZOLM+G04821K1qZao/4QLJbet1pP4tcF+fdXq/9CdqAbWRl/L", 12965 [HLJS_CDN + "/styles/github-dark.min.css"]: 12966 "sha384-wH75j6z1lH97ZOpMOInqhgKzFkAInZPPSPlZpYKYTOqsaizPvhQZmAtLcPKXpLyH", 12967 [KATEX_CDN + "/katex.min.css"]: 12968 "sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP", 12969 [KATEX_CDN + "/katex.min.js"]: 12970 "sha384-cMkvdD8LoxVzGF/RPUKAcvmm49FQ0oxwDF3BGKtDXcEc+T1b2N+teh/OJfpU0jr6", 12971 }); 12972 const HIGHLIGHT_EXCLUDED_SELECTOR = 12973 "pre, code, .raw-html, .blocked-resource, .mermaid-block"; 12974 const READING_PRESETS = { 12975 compact: { fontSize: 18, lineHeight: 1.58, measure: 620 }, 12976 balanced: { fontSize: 19, lineHeight: 1.72, measure: 680 }, 12977 spacious: { fontSize: 20, lineHeight: 1.82, measure: 740 }, 12978 }; 12979 const READING_DEFAULTS = { 12980 preset: "balanced", 12981 fontSize: 19, 12982 lineHeight: 1.72, 12983 measure: 680, 12984 }; 12985 const DEFAULT_SAMPLE_FALLBACK = `# Fast Markdown Viewer 12986 12987Paste markdown on the left, open a local file, or drag one into the page. 12988 12989## Why this helps 12990 12991- Reload button to bypass stale previews 12992- Auto refresh for opened file handles 12993- Untitled drafts restore after reopen 12994- Readable typography for long documents 12995 12996## Table 12997 12998| Feature | What it does | 12999| --- | --- | 13000| Open .md | Loads a local file directly | 13001| Live preview | Renders while you type | 13002| Privacy mode | Keeps local drafts private and blocks remote images | 13003 13004> This page is intentionally simple and privacy-first.`; 13005 13006 async function loadDefaultSample() { 13007 try { 13008 const res = await fetch("assets/sample-developer.md"); 13009 if (!res.ok) throw new Error(res.statusText); 13010 return await res.text(); 13011 } catch (_) { 13012 return DEFAULT_SAMPLE_FALLBACK; 13013 } 13014 } 13015 13016 /* ============================================================ 13017 * Concept Mindmap â schema and fixture 13018 * ============================================================ */ 13019 13020 /** 13021 * @typedef {Object} ConceptNode 13022 * @property {string} id 13023 * @property {string} label 13024 * @property {"goal"|"non_goal"|"feature"|"constraint"|"depen
13024dency"|"actor"|"assumption"|"open_question"|"success_metric"} type 13025 * @property {string} summary 13026 * @property {string|null} sourceAnchor 13027 * @property {string} parent 13028 */ 13029 13030 /** 13031 * @typedef {Object} ConceptGraph 13032 * @property {{id: "root", label: string, summary: string}} root 13033 * @property {ConceptNode[]} nodes 13034 * @property {Array<{from: string, to: string, kind: string}>} edges 13035 */ 13036 13037 const MINDMAP_TYPE_COLOR_VAR = { 13038 goal: "--ont-intro", 13039 non_goal: "--ont-definition", 13040 feature: "--ont-instruction", 13041 constraint: "--ont-argument", 13042 dependency: "--ont-argument", 13043 actor: "--ont-narrative", 13044 assumption: "--ont-example", 13045 open_question: "--ont-unknown", 13046 success_metric: "--ont-explanation", 13047 }; 13048 13049 const MINDMAP_TYPE_LABEL = { 13050 goal: "Goal", 13051 non_goal: "Non-goal", 13052 feature: "Feature", 13053 constraint: "Constraint", 13054 dependency: "Dependency", 13055 actor: "Actor", 13056 assumption: "Assumption", 13057 open_question: "Open question", 13058 success_metric: "Success metric", 13059 }; 13060 13061 /** @type {ConceptGraph} */ 13062 const MINDMAP_FIXTURE = { 13063 root: { 13064 id: "root", 13065 label: "Sample PRD", 13066 summary: 13067 "A concept map fixture used while the LLM extractor is offline.", 13068 }, 13069 nodes: [ 13070 { 13071 id: "g-reader-orient", 13072 label: "Reader Orientation", 13073 type: "goal", 13074 summary: 13075 "Let a reader see the shape of a dense PRD within 10 seconds of opening.", 13076 sourceAnchor: "goals", 13077 parent: "root", 13078 }, 13079 { 13080 id: "g-jump-nav", 13081 label: "Jump Navigation", 13082 type: "goal", 13083 summary: 13084 "Click any concept to scroll the source section into view.", 13085 sourceAnchor: "goals", 13086 parent: "root", 13087 }, 13088 { 13089 id: "f-extract", 13090 label: "Concept Extraction", 13091 type: "feature", 13092 summary: 13093 "LLM converts the markdown into a structured concept graph.", 13094 sourceAnchor: "features", 13095 parent: "root", 13096 }, 13097 { 13098 id: "f-render", 13099 label: "SVG Renderer", 13100 type: "feature", 13101 summary: 13102 "Custom radial SVG with editorial typography and inked edges.", 13103 sourceAnchor: "features", 13104 parent: "root", 13105 }, 13106 { 13107 id: "c-single-file", 13108 label: "Single HTML File", 13109 type: "constraint", 13110 summary: 13111 "All code must live inside the existing index.html; no new dependencies.", 13112 sourceAnchor: "constraints", 13113 parent: "root", 13114 }, 13115 { 13116 id: "c-theme", 13117 label: "Theme Fidelity", 13118 type: "constraint", 13119 summary: 13120 "Mindmap must feel like the reading pane in both light and dark themes.", 13121 sourceAnchor: "constraints", 13122 parent: "root", 13123 }, 13124 { 13125 id: "q-accuracy", 13126 label: "Extraction Accuracy", 13127 type: "open_question", 13128 summary: 13129 "How do we evaluate whether the LLM-produced graph is trustworthy?", 13130 sourceAnchor: "open-questions", 13131 parent: "root", 13132 }, 13133 { 13134 id: "m-time-to-shape", 13135 label: "Time to Shape", 13136 type: "success_metric", 13137 summary: 13138 "Median time from click to interactive mindmap, target under 4 seconds.", 13139 sourceAnchor: "success-metrics", 13140 parent: "root", 13141 }, 13142 13143 { 13144 id: "f-extract-prompt", 13145 label: "Prompt Contract", 13146 type: "feature", 13147 summary: "Strict JSON schema output from the LLM, no free prose.", 13148 sourceAnchor: "features", 13149 parent: "f-extract", 13150 }, 13151 { 13152 id: "f-extract-cache", 13153 label: "Per-doc Cache", 13154 type: "feature", 13155 summary: 13156 "Cache by content hash so re-opening the same doc is instant.", 13157 sourceAnchor: "features", 13158 parent: "f-extract", 13159 }, 13160 13161 { 13162 id: "f-render-layout", 13163 label: "Radial Layout", 13164 type: "feature", 13165 summary: 13166 "Custom tree layout; subtree weight drives angular allocation.", 13167 sourceAnchor: "features", 13168 parent: "f-render", 13169 }, 13170 { 13171 id: "f-render-motion", 13172 label: "Staggered Reveal", 13173 type: "feature", 13174 summary: 13175 "Nodes fade up in rings; root first, primaries next, secondaries last.", 13176 sourceAnchor: "features", 13177 parent: "f-render", 13178 }, 13179 ], 13180 edges: [ 13181 { from: "g-reader-orient", to: "f-render", kind: "supp
13181orts" }, 13182 { from: "g-jump-nav", to: "f-render", kind: "supports" }, 13183 { from: "c-single-file", to: "f-render", kind: "blocks" }, 13184 { 13185 from: "m-time-to-shape", 13186 to: "f-extract-cache", 13187 kind: "measured_by", 13188 }, 13189 ], 13190 }; 13191 13192 /* ============================================================ 13193 * Concept Mindmap â layout engine (pure) 13194 * ============================================================ */ 13195 13196 /** 13197 * Builds a tree rooted at graph.root and computes radial positions. 13198 * Returns a new object; does not mutate the input. 13199 * @param {ConceptGraph} graph 13200 * @param {{ring1:number, ring2:number, ring3:number}} [config] 13201 */ 13202 function computeRadialLayout(graph, config) { 13203 // Ring radii are computed further down from the real card widths, 13204 // not fixed values â see "Width-aware ring radii" below. This is 13205 // what prevents long labels from overlapping on the same ring. 13206 13207 // Index nodes by id. Root is synthetic from graph.root. 13208 const nodeById = new Map(); 13209 const rootNode = { 13210 id: "root", 13211 label: (graph.root && graph.root.label) || "Your Document", 13212 type: "root", 13213 summary: (graph.root && graph.root.summary) || "", 13214 sourceAnchor: null, 13215 parent: null, 13216 children: [], 13217 depth: 0, 13218 x: 0, 13219 y: 0, 13220 angle: 0, 13221 leaves: 0, 13222 }; 13223 nodeById.set("root", rootNode); 13224 13225 for (const n of graph.nodes) { 13226 nodeById.set(n.id, { 13227 ...n, 13228 children: [], 13229 depth: 0, 13230 x: 0, 13231 y: 0, 13232 angle: 0, 13233 leaves: 0, 13234 }); 13235 } 13236 13237 // Link children to parents. 13238 for (const n of graph.nodes) { 13239 const parent = nodeById.get(n.parent); 13240 const child = nodeById.get(n.id); 13241 if (!parent) continue; // orphan â skipped 13242 parent.children.push(child); 13243 } 13244 13245 // Depth assignment and leaf count (two passes). 13246 function setDepth(node, d) { 13247 node.depth = d; 13248 for (const c of node.children) setDepth(c, d + 1); 13249 } 13250 setDepth(rootNode, 0); 13251 13252 function countLeaves(node) { 13253 if (node.children.length === 0) { 13254 node.leaves = 1; 13255 return 1; 13256 } 13257 let total = 0; 13258 for (const c of node.children) total += countLeaves(c); 13259 node.leaves = total; 13260 return total; 13261 } 13262 countLeaves(rootNode); 13263 13264 // --- Width-aware ring radii --- 13265 // Each ring grows until its circumference fits every card on that 13266 // depth plus breathing room, and stays far enough from the inner 13267 // ring that edges remain legible. Reacting to real card widths is 13268 // what kills overlap for any label length. 13269 const MIN_RING_RADII = [0, 360, 680, 1000]; 13270 const MIN_INTER_RING_GAP = 240; 13271 const CARD_ANGULAR_GAP = 48; // px of breathing room between siblings 13272 13273 const nodesByDepth = [[], [], [], []]; 13274 for (const n of nodeById.values()) { 13275 const d = Math.min(n.depth, nodesByDepth.length - 1); 13276 nodesByDepth[d].push(n); 13277 } 13278 const cardWidthById = new Map(); 13279 for (const n of nodeById.values()) { 13280 cardWidthById.set(n.id, measureNodeCard(n).width); 13281 } 13282 13283 const ringRadii = [0]; 13284 for (let d = 1; d < nodesByDepth.length; d++) { 13285 const nodes = nodesByDepth[d]; 13286 const totalW = nodes.reduce( 13287 (s, n) => s + cardWidthById.get(n.id) + CARD_ANGULAR_GAP, 13288 0, 13289 ); 13290 const circFit = totalW / (2 * Math.PI); 13291 const floor = Math.max( 13292 MIN_RING_RADII[d], 13293 ringRadii[d - 1] + MIN_INTER_RING_GAP, 13294 ); 13295 ringRadii.push(Math.max(circFit, floor)); 13296 } 13297 13298 // Honour explicit config overrides when the caller wants bigger rings. 13299 if (config) { 13300 if (config.ring1) 13301 ringRadii[1] = Math.max(ringRadii[1], config.ring1); 13302 if (config.ring2) 13303 ringRadii[2] = Math.max(ringRadii[2], config.ring2); 13304 if (config.ring3)
13305 ringRadii[3] = Math.max(ringRadii[3], config.ring3); 13306 } 13307 13308 // --- Demand-weighted angle assignment --- 13309 // Each subtree asks for an angular share proportional to the widest 13310 // angular footprint anywhere inside it (its own card vs. the sum of 13311 // its children). Wide labels get a wide slice; short ones don't 13312 // steal angle they don't need. 13313 function subtreeAngularDemand(node) { 13314 const r = 13315 ringRadii[Math.min(node.depth, ringRadii.length - 1)] || 1; 13316 const ownArc = 13317 node.depth === 0 13318 ? 0 13319 : (cardWidthById.get(node.id) + CARD_ANGULAR_GAP) / r; 13320 if (node.children.length === 0) return ownArc || 0.0001; 13321 let childSum = 0; 13322 for (const c of node.children) childSum += subtreeAngularDemand(c); 13323 return Math.max(ownArc, childSum); 13324 } 13325 13326 function assign(node, startAngle, endAngle) { 13327 const midAngle = (startAngle + endAngle) / 2; 13328 const r = ringRadii[Math.min(node.depth, ringRadii.length - 1)]; 13329 node.angle = midAngle; 13330 node.x = r * Math.cos(midAngle - Math.PI / 2); 13331 node.y = r * Math.sin(midAngle - Math.PI / 2); 13332 13333 if (node.children.length === 0) return; 13334 const span = endAngle - startAngle; 13335 const demands = node.children.map(subtreeAngularDemand); 13336 const total = demands.reduce((s, v) => s + v, 0) || 1; 13337 let cursor = startAngle; 13338 for (let i = 0; i < node.children.length; i++) { 13339 const childSpan = (demands[i] / total) * span; 13340 assign(node.children[i], cursor, cursor + childSpan); 13341 cursor += childSpan; 13342 } 13343 } 13344 assign(rootNode, 0, Math.PI * 2); 13345 13346 // Collect into a flat positioned list plus edge list. 13347 const positionedNodes = []; 13348 function collect(node) { 13349 positionedNodes.push(node); 13350 for (const c of node.children) collect(c); 13351 } 13352 collect(rootNode); 13353 13354 const treeEdges = []; 13355 for (const n of positionedNodes) { 13356 if (n.parent == null) continue; 13357 const parent = nodeById.get(n.parent); 13358 treeEdges.push({ from: parent, to: n, depth: n.depth }); 13359 } 13360 13361 return { 13362 root: rootNode, 13363 nodes: positionedNodes, 13364 treeEdges, 13365 crossEdges: (graph.edges || []) 13366 .map((e) => ({ 13367 from: nodeById.get(e.from), 13368 to: nodeById.get(e.to), 13369 kind: e.kind, 13370 })) 13371 .filter((e) => e.from && e.to), 13372 }; 13373 } 13374 13375 /** 13376 * Quadratic bezier edge path, with control point pulled toward origin. 13377 * Includes deterministic per-edge jitter so the inked feel is stable 13378 * across re-renders but varied across edges. 13379 */ 13380 function edgePath(from, to, seed) { 13381 const midX = (from.x + to.x) / 2; 13382 const midY = (from.y + to.y) / 2; 13383 // Pull control point 30% toward origin. 13384 let cx = midX * 0.7; 13385 let cy = midY * 0.7; 13386 // Seeded jitter, stable per edge. 13387 const jitter = seededJitter(seed); 13388 cx += jitter.x * 8; 13389 cy += jitter.y * 8; 13390 return `M ${from.x.toFixed(2)},${from.y.toFixed(2)} Q ${cx.toFixed(2)},${cy.toFixed(2)} ${to.x.toFixed(2)},${to.y.toFixed(2)}`; 13391 } 13392 13393 function seededJitter(seed) { 13394 // Mulberry32 one-shot. 13395 let s = 0; 13396 for (let i = 0; i < seed.length; i++) 13397 s = (s * 31 + seed.charCodeAt(i)) | 0; 13398 s = (s + 0x6d2b79f5) | 0; 13399 let t = s; 13400 t = Math.imul(t ^ (t >>> 15), t | 1); 13401 t ^= t + Math.imul(t ^ (t >>> 7), t | 61); 13402 const r1 = (((t ^ (t >>> 14)) >>> 0) / 4294967296) * 2 - 1; 13403 s = (s + 0x9e3779b9) | 0; 13404 t = s; 13405 t = Math.imul(t ^ (t >>> 15), t | 1); 13406 t ^= t + Math.imul(t ^ (t >>> 7), t | 61); 13407 const r2 = (((t ^ (t >>> 14)) >>> 0) / 4294967296) * 2 - 1; 13408 return { x: r1, y: r2 }; 13409 } 13410 13411 /**
13412 * Compute the axis-aligned bounding box of all positioned nodes, 13413 * with estimated node half-widths included. 13414 */ 13415 function computeBounds(positionedNodes) { 13416 if (positionedNodes.length === 0) { 13417 return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; 13418 } 13419 let minX = Infinity, 13420 minY = Infinity, 13421 maxX = -Infinity, 13422 maxY = -Infinity; 13423 for (const n of positionedNodes) { 13424 const dims = measureNodeCard(n); 13425 const halfW = dims.width / 2; 13426 const halfH = dims.height / 2; 13427 if (n.x - halfW < minX) minX = n.x - halfW; 13428 if (n.x + halfW > maxX) maxX = n.x + halfW; 13429 if (n.y - halfH < minY) minY = n.y - halfH; 13430 if (n.y + halfH > maxY) maxY = n.y + halfH; 13431 } 13432 return { minX, minY, maxX, maxY }; 13433 } 13434 13435 function estimateNodeHalfWidth(node) { 13436 return measureNodeCard(node).width / 2; 13437 } 13438 13439 /* ============================================================ 13440 * Concept Mindmap â SVG renderer 13441 * ============================================================ */ 13442 13443 const SVG_NS = "http://www.w3.org/2000/svg"; 13444 13445 /** 13446 * Render a laid-out graph into the #mindmapSvg element. 13447 * Clears previous content first. 13448 * @param {ReturnType<typeof computeRadialLayout>} laid 13449 */ 13450 function renderMindmap(laid) { 13451 const svg = document.getElementById("mindmapSvg"); 13452 const rootG = document.getElementById("mindmapSvgRoot"); 13453 const edgesG = document.getElementById("mindmapEdges"); 13454 const nodesG = document.getElementById("mindmapNodes"); 13455 if (!svg || !edgesG || !nodesG || !rootG) return; 13456 13457 edgesG.textContent = ""; 13458 nodesG.textContent = ""; 13459 13460 // --- Edges first so nodes render on top --- 13461 laid.treeEdges.forEach((e, idx) => { 13462 const path = document.createElementNS(SVG_NS, "path"); 13463 path.setAttribute("class", "mm-edge"); 13464 path.setAttribute("data-depth", String(e.depth)); 13465 path.setAttribute("data-from", e.from.id); 13466 path.setAttribute("data-to", e.to.id); 13467 path.setAttribute("data-kind", "parent"); 13468 path.setAttribute( 13469 "d", 13470 edgePath(e.from, e.to, `tree-${idx}-${e.to.id}`), 13471 ); 13472 path.style.setProperty("--mm-stagger", String(idx)); 13473 edgesG.appendChild(path); 13474 }); 13475 13476 // Cross edges (optional visual layer â lighter, dashed) 13477 laid.crossEdges.forEach((e, idx) => { 13478 const path = document.createElementNS(SVG_NS, "path"); 13479 path.setAttribute("class", "mm-edge mm-edge-cross"); 13480 path.setAttribute("data-depth", "3"); 13481 path.setAttribute("data-from", e.from.id); 13482 path.setAttribute("data-to", e.to.id); 13483 path.setAttribute("data-kind", e.kind || "related"); 13484 path.setAttribute("stroke-dasharray", "3 5"); 13485 path.setAttribute( 13486 "d", 13487 edgePath(e.from, e.to, `cross-${idx}-${e.from.id}-${e.to.id}`), 13488 ); 13489 edgesG.appendChild(path); 13490 }); 13491 13492 // --- Nodes --- 13493 laid.nodes.forEach((node, idx) => { 13494 const g = document.createElementNS(SVG_NS, "g"); 13495 g.setAttribute("class", "mm-node"); 13496 g.setAttribute("data-depth", String(node.depth)); 13497 g.setAttribute("data-id", node.id); 13498 g.setAttribute("data-type", node.type); 13499 if (node.depth > 0) { 13500 g.classList.add("is-interactive"); 13501 g.setAttribute("role", "button"); 13502 g.setAttribute("tabindex", "0"); 13503 g.setAttribute( 13504 "aria-label", 13505 `${MINDMAP_TYPE_LABEL[node.type] || node.type || "Concept"}: ${node.label}. Inspect concept.`, 13506 ); 13507 } 13508 g.style.setProperty("--mm-stagger", String(idx)); 13509 13510 // Measure the label to size the card. 13511 const dims = measureNodeCard(node); 13512
13513 // Background card 13514 const card = document.createElementNS(SVG_NS, "rect"); 13515 card.setAttribute("class", "mm-node-card"); 13516 card.setAttribute("x", String(node.x - dims.width / 2)); 13517 card.setAttribute("y", String(node.y - dims.height / 2)); 13518 card.setAttribute("width", String(dims.width)); 13519 card.setAttribute("height", String(dims.height)); 13520 card.setAttribute("rx", "12"); 13521 card.setAttribute("ry", "12"); 13522 g.appendChild(card); 13523 13524 // Left-edge semantic bar (non-root only) 13525 if ( 13526 node.depth > 0 && 13527 node.type && 13528 MINDMAP_TYPE_COLOR_VAR[node.type] 13529 ) { 13530 const bar = document.createElementNS(SVG_NS, "rect"); 13531 bar.setAttribute("class", "mm-node-typebar"); 13532 bar.setAttribute("x", String(node.x - dims.width / 2)); 13533 bar.setAttribute("y", String(node.y - dims.height / 2)); 13534 bar.setAttribute("width", "3"); 13535 bar.setAttribute("height", String(dims.height)); 13536 bar.setAttribute("rx", "1.5"); 13537 bar.setAttribute("ry", "1.5"); 13538 bar.setAttribute( 13539 "fill", 13540 `var(${MINDMAP_TYPE_COLOR_VAR[node.type]})`, 13541 ); 13542 g.appendChild(bar); 13543 } 13544 13545 // Type badge (non-root only) 13546 if (node.depth > 0 && node.type && MINDMAP_TYPE_LABEL[node.type]) { 13547 const badge = document.createElementNS(SVG_NS, "text"); 13548 badge.setAttribute("class", "mm-node-badge"); 13549 badge.setAttribute("x", String(node.x)); 13550 badge.setAttribute("y", String(node.y - dims.height / 2 + 14)); 13551 badge.setAttribute("text-anchor", "middle"); 13552 badge.textContent = MINDMAP_TYPE_LABEL[node.type].toUpperCase(); 13553 g.appendChild(badge); 13554 } 13555 13556 // Main label â multi-line via <tspan>s. dims.lines is the 13557 // pre-wrapped array from measureNodeCard; vertically center 13558 // the whole block in the card, shifted down slightly when a 13559 // type badge is sitting at the top. 13560 const label = document.createElementNS(SVG_NS, "text"); 13561 label.setAttribute("class", "mm-node-label"); 13562 label.setAttribute("x", String(node.x)); 13563 label.setAttribute("text-anchor", "middle"); 13564 13565 const labelLines = 13566 dims.lines && dims.lines.length ? dims.lines : [node.label || ""]; 13567 const isRootNode = node.depth === 0; 13568 const lineHeight = isRootNode ? 30 : node.depth === 1 ? 24 : 22; 13569 const hasBadge = 13570 !isRootNode && node.type && MINDMAP_TYPE_LABEL[node.type]; 13571 const badgePush = hasBadge ? 12 : 0; 13572 const firstLineY = 13573 node.y - ((labelLines.length - 1) * lineHeight) / 2 + badgePush; 13574 13575 labelLines.forEach((line, li) => { 13576 const tspan = document.createElementNS(SVG_NS, "tspan"); 13577 tspan.setAttribute("x", String(node.x)); 13578 tspan.setAttribute("y", String(firstLineY + li * lineHeight)); 13579 tspan.textContent = line; 13580 label.appendChild(tspan); 13581 }); 13582 g.appendChild(label); 13583 13584 nodesG.appendChild(g); 13585 }); 13586 13587 // Render the screen-reader-only concept list and legend as side-effects. 13588 renderMindmapSrList(laid); 13589 renderMindmapLegend(laid); 13590 if ( 13591 mindmapState.activeNodeId && 13592 !laid.nodes.some((node) => node.id === mindmapState.activeNodeId) 13593 ) { 13594 mindmapState.activeNodeId = null; 13595 } 13596 updateMindmapFocusSurface(); 13597 13598 return { bounds: computeBounds(laid.nodes) }; 13599 } 13600 13601 /** 13602 * Render a nested <ul> mirror of the concept tree for assistive tech. 13603 * Root > primaries > secondaries. Each item shows "{type}: {label} â {summary}". 13604 */ 13605 function renderMindmapSrList(laid) { 13606 const target = document.getElementById("mindmapSrList"); 13607 if (!target) return; 13608 function renderNode(node) { 13609 const li = document.createElement("li"); 13610 const typeText = 13611 node.depth === 0 13612 ? "Document" 13613 : MINDMAP_TYPE_LABEL[node.type] || "Concept"; 13614 const summary = node.summary ? ` â ${node.summary}` : ""; 13615 li.textContent = `${typeText}: ${node.label || ""}${summary}`; 13616 if (node.children && node.children.length > 0) { 13617 const ul = document.createElement("ul"); 13618 for (const c of node.children) ul.appendChild(renderNode(c)); 13619 li.appendChild(ul); 13620 } 13621 return li; 13622 } 13623 const ul = document.createElement("ul"); 13624 ul.appendChild(renderNode(laid.root)); 13625 target.textContent = ""; 13626 target.appendChild(ul); 13627 } 13628 13629 /**
13630 * Render the type legend below the header. Only types that actually 13631 * appear in the current graph are shown. 13632 */ 13633 function renderMindmapLegend(laid) { 13634 const target = document.getElementById("mindmapLegend"); 13635 if (!target) return; 13636 const seen = new Set(); 13637 for (const n of laid.nodes) { 13638 if (n.depth > 0 && n.type && MINDMAP_TYPE_LABEL[n.type]) 13639 seen.add(n.type); 13640 } 13641 target.textContent = ""; 13642 const order = [ 13643 "goal", 13644 "non_goal", 13645 "feature", 13646 "constraint", 13647 "dependency", 13648 "actor", 13649 "assumption", 13650 "success_metric", 13651 "open_question", 13652 ]; 13653 for (const type of order) { 13654 if (!seen.has(type)) continue; 13655 const chip = document.createElement("span"); 13656 chip.className = "mindmap-legend-chip"; 13657 const dot = document.createElement("span"); 13658 dot.className = "mindmap-legend-dot"; 13659 dot.style.background = `var(${MINDMAP_TYPE_COLOR_VAR[type]})`; 13660 const label = document.createElement("span"); 13661 label.textContent = MINDMAP_TYPE_LABEL[type]; 13662 chip.appendChild(dot); 13663 chip.appendChild(label); 13664 target.appendChild(chip); 13665 } 13666 } 13667 13668 /* ---------------------------------------------------------- 13669 * Real text measurement â hidden <text> elements styled like 13670 * each depth's labels, so getComputedTextLength() returns the 13671 * exact on-screen width. This is what we use to size cards and 13672 * wrap long labels so text never overflows its card. 13673 * ---------------------------------------------------------- */ 13674 const _mmMeasureElements = {}; 13675 function getMMMeasureText(depth) { 13676 const key = depth >= 2 ? 2 : depth; 13677 if (_mmMeasureElements[key] && _mmMeasureElements[key].isConnected) { 13678 return _mmMeasureElements[key]; 13679 } 13680 const svg = document.getElementById("mindmapSvg"); 13681 if (!svg) return null; 13682 const g = document.createElementNS(SVG_NS, "g"); 13683 g.setAttribute("class", "mm-node"); 13684 g.setAttribute("data-depth", String(key)); 13685 g.setAttribute("aria-hidden", "true"); 13686 g.setAttribute("pointer-events", "none"); 13687 g.setAttribute("visibility", "hidden"); 13688 const t = document.createElementNS(SVG_NS, "text"); 13689 t.setAttribute("class", "mm-node-label"); 13690 t.setAttribute("x", "-99999"); 13691 t.setAttribute("y", "-99999"); 13692 g.appendChild(t); 13693 svg.appendChild(g); 13694 _mmMeasureElements[key] = t; 13695 return t; 13696 } 13697 13698 function measureMMText(text, depth) { 13699 const el = getMMMeasureText(depth); 13700 const safe = String(text || ""); 13701 if (!el) { 13702 const per = depth === 0 ? 18 : depth === 1 ? 11 : 8.5; 13703 return safe.length * per; 13704 } 13705 el.textContent = safe; 13706 try { 13707 return el.getComputedTextLength(); 13708 } catch { 13709 const per = depth === 0 ? 18 : depth === 1 ? 11 : 8.5; 13710 return safe.length * per; 13711 } 13712 } 13713 13714 /** 13715 * Greedy word-wrap at a real pixel width. Packs words until the 13716 * next word would overflow, then starts a new line. The final 13717 * allowed line absorbs any remaining words and ellipsizes if they 13718 * still don't fit. Single words wider than maxWidth are kept on 13719 * their own line â the card will widen to accommodate them. 13720 */ 13721 function wrapMMLabel(text, depth, maxWidth, maxLines) { 13722 const words = String(text || "") 13723 .trim() 13724 .split(/\s+/); 13725 if (words.length === 0 || (words.length === 1 && words[0] === "")) { 13726 return [""]; 13727 } 13728 const lines = []; 13729 let current = ""; 13730 for (let i = 0; i < words.length; i++) { 13731 const candidate = current ? current + " " + words[i] : words[i]; 13732 if (measureMMText(candidate, depth) <= maxWidth) { 13733 current = candidate; 13734 continue; 13735 } 13736 if (current) lines.push(current); 13737 if (lines.length >= maxLines - 1) { 13738 let last = words[i]; 13739 for (let j = i + 1; j < words.length; j++) { 13740 const tryAdd = last + " " + words[j]; 13741 if (measureMMText(tryAdd, depth) <= maxWidth) { 13742 last = tryAdd; 13743 } else { 13744 while ( 13745 last.length > 1 && 13746 measureMMText(last + "â¦", depth) > maxWidth 13747 ) { 13748 last = last.slice(0, -1); 13749 } 13750 last += "â¦"; 13751 break; 13752 } 13753 } 13754 lines.push(last); 13755 return lines; 13756 } 13757 current = words[i]; 13758 } 13759 if (current) lines.push(current); 13760 return lines; 13761 } 13762 13763 /** 13764 * Measure a node's card using real SVG text metrics and wrap long 13765 * labels across up to two lines. Returns width, height, and the 13766 * pre-wrapped lines array to render as <tspan>s. 13767 */ 13768 function measureNodeCard(node) { 13769 const label = node.label || "";
13770 const isRoot = node.depth === 0; 13771 const isPrimary = node.depth === 1; 13772 const paddingX = isRoot ? 48 : 36; 13773 const paddingY = isRoot ? 32 : 26; 13774 const lineHeight = isRoot ? 30 : isPrimary ? 24 : 22; 13775 const badgeHeight = 13776 !isRoot && node.type && MINDMAP_TYPE_LABEL[node.type] ? 22 : 0; 13777 const minWidth = isRoot ? 260 : 180; 13778 const maxWidth = isRoot ? 560 : isPrimary ? 380 : 300; 13779 const maxLines = 2; 13780 13781 const singleLineWidth = measureMMText(label, node.depth) + paddingX; 13782 if (singleLineWidth <= maxWidth) { 13783 return { 13784 width: Math.max(minWidth, singleLineWidth), 13785 height: badgeHeight + lineHeight + paddingY, 13786 lines: [label], 13787 }; 13788 } 13789 13790 const lines = wrapMMLabel( 13791 label, 13792 node.depth, 13793 maxWidth - paddingX, 13794 maxLines, 13795 ); 13796 let widest = 0; 13797 for (const ln of lines) { 13798 const w = measureMMText(ln, node.depth); 13799 if (w > widest) widest = w; 13800 } 13801 return {
13802 width: Math.max(minWidth, widest + paddingX), 13803 height: badgeHeight + lines.length * lineHeight + paddingY, 13804 lines, 13805 }; 13806 } 13807 13808 /* ============================================================ 13809 * Concept Mindmap â viewer controller 13810 * ============================================================ */ 13811 13812 const mindmapState = { 13813 laid: null, 13814 scale: 1, 13815 tx: 0, 13816 ty: 0, 13817 baseScale: 1, 13818 isPanning: false, 13819 panStartX: 0, 13820 panStartY: 0, 13821 panStartTx: 0, 13822 panStartTy: 0, 13823 currentGraph: null, 13824 currentDocHash: null, 13825 previouslyFocused: null, 13826 hideTimer: 0, 13827 isLoading: false, 13828 rateLimitUntil: 0, 13829 activeNodeId: null, 13830 }; 13831 13832 const MINDMAP_MAX_MARKDOWN_CHARS = 20000; 13833 13834 function getMindmapEls() { 13835 return { 13836 viewer: document.getElementById("mindmapViewer"), 13837 stage: document.getElementById("mindmapStage"), 13838 svg: document.getElementById("mindmapSvg"), 13839 svgRoot: document.getElementById("mindmapSvgRoot"), 13840 zoomLabel: document.getElementById("mindmapZoomLabel"), 13841 tooltip: document.getElementById("mindmapTooltip"), 13842 inspector: document.getElementById("mindmapInspector"), 13843 searchInput: document.getElementById("mindmapSearchInput"), 13844 searchResults: document.getElementById("mindmapSearchResults"), 13845 loading: document.getElementById("mindmapLoading"), 13846 errorPane: document.getElementById("mindmapError"), 13847 errorMsg: document.getElementById("mindmapErrorMessage"), 13848 }; 13849 } 13850 13851 function clearMindmapSurface() { 13852 const { 13853 svgRoot, 13854 zoomLabel, 13855 tooltip, 13856 inspector, 13857 searchInput, 13858 searchResults, 13859 loading, 13860 errorPane, 13861 errorMsg, 13862 } = getMindmapEls(); 13863 const edgesG = document.getElementById("mindmapEdges"); 13864 const nodesG = document.getElementById("mindmapNodes"); 13865 const legend = document.getElementById("mindmapLegend"); 13866 const srList = document.getElementById("mindmapSrList"); 13867 if (edgesG) edgesG.textContent = ""; 13868 if (nodesG) nodesG.textContent = ""; 13869 if (legend) legend.textContent = ""; 13870 if (srList) srList.textContent = ""; 13871 if (svgRoot) { 13872 svgRoot.classList.remove("is-interactive"); 13873 svgRoot.setAttribute("transform", "translate(0 0) scale(1)"); 13874 } 13875 if (zoomLabel) zoomLabel.textContent = "100%"; 13876 if (tooltip) tooltip.classList.remove("is-visible"); 13877 if (inspector) { 13878 inspector.classList.remove("is-visible"); 13879 inspector.setAttribute("aria-hidden", "true"); 13880 inspector.textContent = ""; 13881 } 13882 if (searchInput) searchInput.value = ""; 13883 if (searchResults) { 13884 searchResults.classList.add("is-hidden"); 13885 searchResults.textContent = ""; 13886 } 13887 if (loading) loading.classList.add("is-hidden"); 13888 if (errorPane) { 13889 errorPane.classList.add("is-hidden"); 13890 errorPane.dataset.kind = ""; 13891 } 13892 if (errorMsg) errorMsg.textContent = "Something went wrong."; 13893 } 13894 13895 function resetMindmapState(options) { 13896 const closeViewer = !!(options && options.closeViewer); 13897 const forgetDocHash = !options || options.forgetDocHash !== false; 13898 const { viewer } = getMindmapEls(); 13899 if (mindmapState.hideTimer) { 13900 clearTimeout(mindmapState.hideTimer); 13901 mindmapState.hideTimer = 0; 13902 } 13903 if (closeViewer && viewer) { 13904 viewer.setAttribute("aria-hidden", "true"); 13905 viewer.hidden = true; 13906 document.body.classList.remove("mindmap-open"); 13907 } 13908 clearMindmapSurface(); 13909 mindmapState.laid = null; 13910 mindmapState.scale = 1; 13911 mindmapState.tx = 0; 13912 mindmapState.ty = 0; 13913 mindmapState.baseScale = 1; 13914 mindmapState.isPanning = false;
13915 mindmapState.panStartX = 0; 13916 mindmapState.panStartY = 0; 13917 mindmapState.panStartTx = 0; 13918 mindmapState.panStartTy = 0; 13919 mindmapState.currentGraph = null; 13920 mindmapState.activeNodeId = null; 13921 if (forgetDocHash) { 13922 mindmapState.currentDocHash = null; 13923 } 13924 if (closeViewer) { 13925 mindmapState.previouslyFocused = null; 13926 } 13927 } 13928 13929 function openMindmapViewer() { 13930 const { viewer } = getMindmapEls(); 13931 if (!viewer) return; 13932 if (mindmapState.hideTimer) { 13933 clearTimeout(mindmapState.hideTimer); 13934 mindmapState.hideTimer = 0; 13935 } 13936 mindmapState.previouslyFocused = document.activeElement; 13937 viewer.hidden = false; 13938 requestAnimationFrame(() => { 13939 viewer.setAttribute("aria-hidden", "false"); 13940 const closeBtn = document.getElementById("mindmapCloseBtn"); 13941 if (closeBtn) closeBtn.focus(); 13942 }); 13943 document.body.classList.add("mindmap-open"); 13944 } 13945 13946 function closeMindmapViewer() { 13947 const { viewer } = getMindmapEls(); 13948 if (!viewer) return; 13949 if (mindmapState.hideTimer) { 13950 clearTimeout(mindmapState.hideTimer); 13951 } 13952 viewer.setAttribute("aria-hidden", "true"); 13953 document.body.classList.remove("mindmap-open"); 13954 mindmapState.hideTimer = setTimeout(() => { 13955 if (viewer.getAttribute("aria-hidden") === "true") { 13956 viewer.hidden = true; 13957 } 13958 mindmapState.hideTimer = 0; 13959 }, 260); 13960 // Restore focus to whatever had it before open. 13961 if ( 13962 mindmapState.previouslyFocused && 13963 typeof mindmapState.previouslyFocused.focus === "function" 13964 ) { 13965 try { 13966 mindmapState.previouslyFocused.focus(); 13967 } catch (_) {} 13968 } 13969 } 13970 13971 function isMindmapFocusable(element) { 13972 if (!element) return false; 13973 if (element.hasAttribute("disabled")) return false; 13974 if (element.closest('[hidden], .is-hidden, [aria-hidden="true"]')) { 13975 return false; 13976 } 13977 const style = window.getComputedStyle(element); 13978 if (style.display === "none" || style.visibility === "hidden") { 13979 return false; 13980 } 13981 return typeof element.getClientRects === "function" 13982 ? element.getClientRects().length > 0 13983 : true; 13984 } 13985 13986 function getMindmapFocusableElements() { 13987 const { viewer } = getMindmapEls(); 13988 if (!viewer) return []; 13989 return Array.from( 13990 viewer.querySelectorAll( 13991 'button, input, [tabindex]:not([tabindex="-1"])', 13992 ), 13993 ).filter(isMindmapFocusable); 13994 } 13995 13996 /** 13997 * Trap focus inside the modal â Tab cycles through the focusable 13998 * descendants of the viewer. Shift+Tab cycles backward. 13999 */ 14000 function trapMindmapFocus(event) { 14001 if (event.key !== "Tab") return; 14002 const { viewer } = getMindmapEls(); 14003 if (!viewer || viewer.getAttribute("aria-hidden") === "true") return; 14004 const focusables = getMindmapFocusableElements(); 14005 if (focusables.length === 0) return; 14006 const first = focusables[0]; 14007 const last = focusables[focusables.length - 1]; 14008 if (event.shiftKey && document.activeElement === first) { 14009 event.preventDefault(); 14010 last.focus(); 14011 } else if (!event.shiftKey && document.activeElement === last) { 14012 event.preventDefault(); 14013 first.focus(); 14014 } 14015 } 14016 14017 function applyMindmapTransform() { 14018 const { svgRoot, zoomLabel } = getMindmapEls(); 14019 if (!svgRoot) return; 14020 svgRoot.setAttribute( 14021 "transform", 14022 `translate(${mindmapState.tx} ${mindmapState.ty}) scale(${mindmapState.scale})`, 14023 ); 14024 if (zoomLabel) { 14025 zoomLabel.textContent = `${Math.round(mindmapState.scale * 100)}%`; 14026 } 14027 } 14028 14029 function fitMindmapToView(animated) { 14030 const { stage, svgRoot } = getMindmapEls(); 14031 if (!stage || !svgRoot || !mindmapState.laid) return; 14032 14033 const bounds = computeBounds(mindmapState.laid.nodes); 14034 const w = bounds.maxX - bounds.minX; 14035 const h = bounds.maxY - bounds.minY; 14036 const rect = stage.getBoundingClientRect(); 14037 const pad = 120; 14038 const scaleX = (rect.width - pad * 2) / Math.max(1, w); 14039 const scaleY = (rect.height - pad * 2) / Math.max(1, h); 14040 const scale = Math.min(scaleX, scaleY, 1.1); 14041 14042 // Center the bounds in the stage. 14043 const boundsCx = (bounds.minX + bounds.maxX) / 2; 14044 const boundsCy = (bounds.minY + bounds.maxY) / 2; 14045 const tx = rect.width / 2 - boundsCx * scale; 14046 const ty = rect.height / 2 - boundsCy * scale; 14047 14048 if (animated) { 14049 svgRoot.classList.remove("is-interactive"); 14050 } else { 14051 svgRoot.classList.add("is-interactive"); 14052 } 14053 mindmapState.scale = scale; 14054 mindmapState.baseScale = scale; 14055 mindmapState.tx = tx; 14056 mindmapState.ty = ty; 14057 applyMindmapTransform(); 14058 } 14059 14060 function setMindmapZoom(nextScale, focalX, focalY) { 14061 const clamped = Math.max(0.3, Math.min(2.5, nextScale));
14062 if (typeof focalX === "number" && typeof focalY === "number") { 14063 // Zoom around the focal point (screen coords inside stage). 14064 const { tx, ty, scale } = mindmapState; 14065 const worldX = (focalX - tx) / scale; 14066 const worldY = (focalY - ty) / scale; 14067 mindmapState.scale = clamped; 14068 mindmapState.tx = focalX - worldX * clamped; 14069 mindmapState.ty = focalY - worldY * clamped; 14070 } else { 14071 mindmapState.scale = clamped; 14072 } 14073 applyMindmapTransform(); 14074 } 14075 14076 function getMindmapTypeColor(type) { 14077 return MINDMAP_TYPE_COLOR_VAR[type] 14078 ? `var(${MINDMAP_TYPE_COLOR_VAR[type]})` 14079 : "var(--fg-subtle)"; 14080 } 14081 14082 function describeMindmapEdgeKind(kind) { 14083 const labels = { 14084 parent: "Parent link", 14085 child: "Child", 14086 part_of: "Part of", 14087 depends_on: "Depends on", 14088 blocks: "Blocks", 14089 supports: "Supports", 14090 contradicts: "Contradicts", 14091 measured_by: "Measured by", 14092 related: "Related", 14093 }; 14094 return labels[kind] || "Related"; 14095 } 14096 14097 function getMindmapNodeMap() { 14098 const map = new Map(); 14099 if (!mindmapState.laid || !mindmapState.laid.nodes) return map; 14100 for (const node of mindmapState.laid.nodes) { 14101 map.set(node.id, node); 14102 } 14103 return map; 14104 } 14105 14106 function getMindmapFocusNeighborhood(nodeId) { 14107 const laid = mindmapState.laid; 14108 const related = new Set([nodeId]); 14109 const activeEdges = new Set(); 14110 if (!laid) return { related, activeEdges }; 14111 14112 for (const edge of laid.treeEdges || []) { 14113 if (edge.from.id === nodeId || edge.to.id === nodeId) { 14114 related.add(edge.from.id);
14115 related.add(edge.to.id); 14116 activeEdges.add(`${edge.from.id}->${edge.to.id}`); 14117 } 14118 } 14119 for (const edge of laid.crossEdges || []) { 14120 if (edge.from.id === nodeId || edge.to.id === nodeId) { 14121 related.add(edge.from.id); 14122 related.add(edge.to.id); 14123 activeEdges.add(`${edge.from.id}->${edge.to.id}`); 14124 } 14125 } 14126 14127 return { related, activeEdges }; 14128 } 14129 14130 function getMindmapConnectedNodes(node) { 14131 const laid = mindmapState.laid; 14132 if (!node || !laid) return []; 14133 const nodeMap = getMindmapNodeMap(); 14134 const byId = new Map(); 14135 14136 function addConnection(id, relation, kind) { 14137 if (!id || id === node.id || byId.has(id)) return; 14138 const target = nodeMap.get(id); 14139 if (!target) return; 14140 byId.set(id, { 14141 node: target, 14142 relation, 14143 kind: kind || relation, 14144 }); 14145 } 14146 14147 addConnection(node.parent, "Parent", "parent"); 14148 for (const child of node.children || []) { 14149 addConnection(child.id, "Child", "child"); 14150 } 14151 for (const edge of laid.crossEdges || []) { 14152 if (edge.from.id === node.id) { 14153 addConnection( 14154 edge.to.id, 14155 describeMindmapEdgeKind(edge.kind), 14156 edge.kind, 14157 ); 14158 } else if (edge.to.id === node.id) { 14159 addConnection( 14160 edge.from.id, 14161 describeMindmapEdgeKind(edge.kind), 14162 edge.kind, 14163 ); 14164 } 14165 } 14166 14167 return Array.from(byId.values()).sort((a, b) => { 14168 if (a.node.depth !== b.node.depth) 14169 return a.node.depth - b.node.depth; 14170 return String(a.node.label || "").localeCompare( 14171 String(b.node.label || ""), 14172 ); 14173 }); 14174 } 14175 14176 function renderMindmapInspector(node) { 14177 const { inspector } = getMindmapEls(); 14178 if (!inspector) return; 14179 inspector.textContent = ""; 14180 if (!node) { 14181 inspector.classList.remove("is-visible"); 14182 inspector.setAttribute("aria-hidden", "true"); 14183 return; 14184 } 14185 14186 const header = document.createElement("div"); 14187 header.className = "mindmap-inspector-header"; 14188 14189 const titleWrap = document.createElement("div"); 14190 const type = document.createElement("div"); 14191 type.className = "mindmap-inspector-type"; 14192 const dot = document.createElement("span"); 14193 dot.className = "mindmap-inspector-dot"; 14194 dot.style.background = getMindmapTypeColor(node.type); 14195 const typeLabel = document.createElement("span"); 14196 typeLabel.textContent = 14197 node.depth === 0 14198 ? "Document" 14199 : MINDMAP_TYPE_LABEL[node.type] || "Concept"; 14200 type.appendChild(dot); 14201 type.appendChild(typeLabel); 14202 14203 const title = document.createElement("h3"); 14204 title.className = "mindmap-inspector-title"; 14205 title.textContent = node.label || "Untitled concept"; 14206 titleWrap.appendChild(type); 14207 titleWrap.appendChild(title); 14208 14209 const close = document.createElement("button"); 14210 close.type = "button"; 14211 close.className = "mindmap-inspector-close"; 14212 close.setAttribute("aria-label", "Close concept details"); 14213 close.innerHTML = 14214 '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>'; 14215 close.addEventListener("click", clearMindmapFocusedNode); 14216 14217 header.appendChild(titleWrap); 14218 header.appendChild(close); 14219 inspector.appendChild(header); 14220 14221 if (node.summary) { 14222 const summary = document.createElement("p"); 14223 summary.className = "mindmap-inspector-summary"; 14224 summary.textContent = node.summary; 14225 inspector.appendChild(summary); 14226 } 14227 14228 const sourceTarget = 14229 node.depth > 0 14230 ? resolveMindmapTarget(node, document.getElementById("preview")) 14231 : null; 14232 if (sourceTarget) { 14233 const actions = document.createElement("div"); 14234 actions.className = "mindmap-inspector-actions"; 14235 const jump = document.createElement("button"); 14236 jump.type = "button"; 14237 jump.className = "mindmap-inspector-action"; 14238 jump.textContent = "Open section"; 14239 jump.addEventListener("click", () => jumpToMindmapSource(node)); 14240 actions.appendChild(jump); 14241 inspector.appendChild(actions); 14242 } 14243 14244 const connections = getMindmapConnectedNodes(node); 14245 if (connections.length) { 14246 const section = document.createElement("div"); 14247 section.className = "mindmap-inspector-section"; 14248 const sectionTitle = document.createElement("p"); 14249 sectionTitle.className = "mindmap-inspector-section-title"; 14250 sectionTitle.textContent = `Connections (${connections.length})`; 14251 section.appendChild(sectionTitle); 14252 14253 const list = document.createElement("div"); 14254 list.className = "mindmap-connection-list"; 14255 for (const item of connections) { 14256 const button = document.createElement("button"); 14257 button.type = "button"; 14258 button.className = "mindmap-connection"; 14259 button.addEventListener("click", () => { 14260 focusMindmapNode(item.node.id, { center: true }); 14261 }); 14262 14263 const itemDot = document.createElement("span"); 14264 itemDot.className = "mindmap-connection-dot";
14265 itemDot.style.background = getMindmapTypeColor(item.node.type); 14266 14267 const body = document.createElement("span"); 14268 const label = document.createElement("span"); 14269 label.className = "mindmap-connection-label"; 14270 label.textContent = item.node.label || "Untitled concept"; 14271 const meta = document.createElement("span"); 14272 meta.className = "mindmap-connection-meta"; 14273 meta.textContent = item.relation; 14274 body.appendChild(label); 14275 body.appendChild(meta); 14276 14277 button.appendChild(itemDot); 14278 button.appendChild(body); 14279 list.appendChild(button); 14280 } 14281 section.appendChild(list); 14282 inspector.appendChild(section); 14283 } 14284 14285 inspector.classList.add("is-visible"); 14286 inspector.setAttribute("aria-hidden", "false"); 14287 } 14288 14289 function updateMindmapFocusSurface() { 14290 const activeId = mindmapState.activeNodeId; 14291 const activeNode = activeId ? getMindmapNodeById(activeId) : null; 14292 const focus = activeNode 14293 ? getMindmapFocusNeighborhood(activeId) 14294 : null; 14295 const nodeEls = document.querySelectorAll("#mindmapNodes .mm-node"); 14296 const edgeEls = document.querySelectorAll("#mindmapEdges .mm-edge"); 14297 14298 nodeEls.forEach((el) => { 14299 const id = el.getAttribute("data-id"); 14300 const isFocused = !!activeNode && id === activeId; 14301 const isRelated = !!focus && focus.related.has(id); 14302 el.classList.toggle("is-focused", isFocused); 14303 el.classList.toggle( 14304 "is-related", 14305 !!activeNode && isRelated && !isFocused, 14306 ); 14307 el.classList.toggle("is-muted", !!activeNode && !isRelated); 14308 if (isFocused) { 14309 el.setAttribute("aria-pressed", "true"); 14310 } else { 14311 el.removeAttribute("aria-pressed"); 14312 } 14313 }); 14314 14315 edgeEls.forEach((el) => { 14316 const from = el.getAttribute("data-from"); 14317 const to = el.getAttribute("data-to"); 14318 const isActive = !!focus && focus.activeEdges.has(`${from}->${to}`); 14319 el.classList.toggle("is-active", isActive); 14320 el.classList.toggle("is-muted", !!activeNode && !isActive); 14321 }); 14322 14323 renderMindmapInspector(activeNode); 14324 } 14325 14326 function centerMindmapNode(node) { 14327 const { stage, svgRoot } = getMindmapEls(); 14328 if (!stage || !svgRoot || !node) return; 14329 const rect = stage.getBoundingClientRect(); 14330 const targetScale = Math.max( 14331 mindmapState.scale, 14332 Math.min( 14333 1.25, 14334 Math.max(mindmapState.baseScale * 1.55, mindmapState.baseScale), 14335 ), 14336 ); 14337 svgRoot.classList.remove("is-interactive"); 14338 mindmapState.scale = targetScale; 14339 mindmapState.tx = rect.width / 2 - node.x * targetScale; 14340 mindmapState.ty = rect.height / 2 - node.y * targetScale; 14341 applyMindmapTransform(); 14342 } 14343 14344 function focusMindmapNode(id, options) { 14345 const node = getMindmapNodeById(id); 14346 if (!node) return; 14347 mindmapState.activeNodeId = id; 14348 updateMindmapFocusSurface(); 14349 if (!options || options.center !== false) { 14350 centerMindmapNode(node); 14351 } 14352 } 14353 14354 function clearMindmapFocusedNode() { 14355 if (!mindmapState.activeNodeId) return; 14356 mindmapState.activeNodeId = null; 14357 updateMindmapFocusSurface(); 14358 } 14359 14360 function getMindmapSearchResults(query) { 14361 if (!mindmapState.laid || !query.trim()) return []; 14362 const tokens = query 14363 .toLowerCase() 14364 .split(/\s+/) 14365 .map((token) => token.trim()) 14366 .filter(Boolean); 14367 if (!tokens.length) return []; 14368 14369 return mindmapState.laid.nodes 14370 .filter((node) => node.depth > 0) 14371 .map((node) => { 14372 const label = String(node.label || ""); 14373 const typeLabel = MINDMAP_TYPE_LABEL[node.type] || ""; 14374 const haystack = 14375 `${label} ${typeLabel} ${node.summary || ""}`.toLowerCase(); 14376 if (!tokens.every((token) => haystack.includes(token))) 14377 return null; 14378 let score = 0; 14379 const lowerLabel = label.toLowerCase();
14380 if (lowerLabel === query.toLowerCase()) score += 10; 14381 if (lowerLabel.startsWith(query.toLowerCase())) score += 6; 14382 if (lowerLabel.includes(query.toLowerCase())) score += 3; 14383 score += Math.max(0, 4 - node.depth); 14384 return { node, score }; 14385 }) 14386 .filter(Boolean) 14387 .sort( 14388 (a, b) => 14389 b.score - a.score || a.node.label.localeCompare(b.node.label), 14390 ) 14391 .slice(0, 8) 14392 .map((entry) => entry.node); 14393 } 14394 14395 function hideMindmapSearchResults() { 14396 const { searchResults } = getMindmapEls(); 14397 if (!searchResults) return; 14398 searchResults.classList.add("is-hidden"); 14399 searchResults.textContent = ""; 14400 } 14401 14402 function renderMindmapSearchResults() { 14403 const { searchInput, searchResults } = getMindmapEls(); 14404 if (!searchInput || !searchResults) return; 14405 const query = searchInput.value.trim(); 14406 searchResults.textContent = ""; 14407 if (!query) { 14408 hideMindmapSearchResults(); 14409 return; 14410 } 14411 14412 const results = getMindmapSearchResults(query); 14413 if (!results.length) { 14414 const empty = document.createElement("div"); 14415 empty.className = "mindmap-search-result"; 14416 empty.setAttribute("aria-disabled", "true"); 14417 const spacer = document.createElement("span"); 14418 const label = document.createElement("span"); 14419 label.className = "mindmap-search-result-label"; 14420 label.textContent = "No matches"; 14421 empty.appendChild(spacer); 14422 empty.appendChild(label); 14423 searchResults.appendChild(empty); 14424 } else { 14425 for (const node of results) { 14426 const button = document.createElement("button"); 14427 button.type = "button"; 14428 button.className = "mindmap-search-result"; 14429 button.setAttribute("role", "option"); 14430 button.addEventListener("click", (event) => { 14431 event.preventDefault(); 14432 event.stopPropagation(); 14433 searchInput.value = ""; 14434 hideMindmapSearchResults(); 14435 focusMindmapNode(node.id, { center: true }); 14436 }); 14437 14438 const dot = document.createElement("span"); 14439 dot.className = "mindmap-search-result-dot"; 14440 dot.style.background = getMindmapTypeColor(node.type); 14441 const body = document.createElement("span"); 14442 const label = document.createElement("span"); 14443 label.className = "mindmap-search-result-label"; 14444 label.textContent = node.label || "Untitled concept"; 14445 const type = document.createElement("span"); 14446 type.className = "mindmap-search-result-type"; 14447 type.textContent = MINDMAP_TYPE_LABEL[node.type] || "Concept"; 14448 body.appendChild(label); 14449 body.appendChild(type); 14450 button.appendChild(dot); 14451 button.appendChild(body); 14452 searchResults.appendChild(button); 14453 } 14454 } 14455 searchResults.classList.remove("is-hidden"); 14456 } 14457 14458 function wireMindmapInteractions() { 14459 const els = getMindmapEls(); 14460 const { viewer, stage, svgRoot, searchInput } = els; 14461 if (!viewer || !stage) return; 14462 14463 // Close button 14464 document 14465 .getElementById("mindmapCloseBtn") 14466 .addEventListener("click", closeMindmapViewer); 14467 14468 // Zoom buttons 14469 document 14470 .getElementById("mindmapZoomInBtn") 14471 .addEventListener("click", () => { 14472 const rect = stage.getBoundingClientRect(); 14473 setMindmapZoom( 14474 mindmapState.scale * 1.18, 14475 rect.width / 2, 14476 rect.height / 2, 14477 ); 14478 }); 14479 document 14480 .getElementById("mindmapZoomOutBtn") 14481 .addEventListener("click", () => { 14482 const rect = stage.getBoundingClientRect(); 14483 setMindmapZoom( 14484 mindmapState.scale / 1.18, 14485 rect.width / 2, 14486 rect.height / 2, 14487 ); 14488 }); 14489 document 14490 .getElementById("mindmapFitBtn") 14491 .addEventListener("click", () => { 14492 svgRoot.classList.remove("is-interactive"); 14493 fitMindmapToView(true); 14494 }); 14495 14496 // Keyboard â local (modal scope) 14497 viewer.addEventListener("keydown", (event) => { 14498 if (event.key === "Escape") { 14499 closeMindmapViewer(); 14500 return; 14501 } 14502 trapMindmapFocus(event); 14503 if (event.key === "+" || event.key === "=") { 14504 const rect = stage.getBoundingClientRect(); 14505 setMindmapZoom( 14506 mindmapState.scale * 1.18, 14507 rect.width / 2, 14508 rect.height / 2, 14509 ); 14510 } else if (event.key === "-") { 14511 const rect = stage.getBoundingClientRect(); 14512 setMindmapZoom( 14513 mindmapState.scale / 1.18, 14514 rect.width / 2, 14515 rect.height / 2, 14516 ); 14517 } else if (event.key === "f" || event.key === "F") { 14518 fitMindmapToView(true); 14519 } 14520 }); 14521 14522 if (searchInput) { 14523 searchInput.addEventListener("input", renderMindmapSearchResults); 14524 searchInput.addEventListener("focus", renderMindmapSearchResults); 14525 searchInput.addEventListener("keydown", (event) => { 14526 if (event.key === "Escape") { 14527 searchInput.value = ""; 14528 hideMindmapSearchResults(); 14529 searchInput.blur(); 14530 return; 14531 } 14532 if (event.key !== "Enter") return; 14533 const first = getMindmapSearchResults( 14534 searchInput.value.trim(), 14535 )[0]; 14536 if (!first) return; 14537 event.preventDefault(); 14538 searchInput.value = ""; 14539 hideMindmapSearchResults(); 14540 focusMindmapNode(first.id, { center: true }); 14541 }); 14542 } 14543 14544 // Keyboard â global shortcut to open (Cmd/Ctrl+M) 14545 document.addEventListener("keydown", (event) => { 14546 const mod = event.metaKey || event.ctrlKey; 14547 if (!mod || event.key.toLowerCase() !== "m") return; 14548 // Skip if user is typing in the editor textarea. 14549 const tag = (event.target && event.target.tagName) || ""; 14550 if (tag === "TEXTAREA" || tag === "INPUT") return; 14551 event.preventDefault(); 14552 const btn = document.getElementById("mindmapBtn"); 14553 if (btn) btn.click(); 14554 }); 14555 14556 // Wheel zoom 14557 stage.addEventListener( 14558 "wheel", 14559 (event) => { 14560 if (viewer.getAttribute("aria-hidden") === "true") return; 14561 event.preventDefault(); 14562 const rect = stage.getBoundingClientRect(); 14563 const focalX = event.clientX - rect.left; 14564 const focalY = event.clientY - rect.top; 14565 const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1; 14566 svgRoot.classList.add("is-interactive"); 14567 setMindmapZoom(mindmapState.scale * factor, focalX, focalY); 14568 }, 14569 { passive: false }, 14570 ); 14571 14572 // Drag to pan (only when starting on empty space, not on a node). 14573 stage.addEventListener("pointerdown", (event) => { 14574 if (event.target.closest(".mm-node")) return; 14575 if (event.target.closest(".mindmap-search, .mindmap-inspector")) 14576 return; 14577 mindmapState.isPanning = true; 14578 mindmapState.panStartX = event.clientX; 14579 mindmapState.panStartY = event.clientY; 14580 mindmapState.panStartTx = mindmapState.tx; 14581 mindmapState.panStartTy = mindmapState.ty; 14582 stage.classList.add("is-panning"); 14583 svgRoot.classList.add("is-interactive"); 14584 stage.setPointerCapture(event.pointerId); 14585 }); 14586 stage.addEventListener("pointermove", (event) => { 14587 if (!mindmapState.isPanning) return; 14588 const dx = event.clientX - mindmapState.panStartX; 14589 const dy = event.clientY - mindmapState.panStartY; 14590 mindmapState.tx = mindmapState.panStartTx + dx; 14591 mindmapState.ty = mindmapState.panStartTy + dy; 14592 applyMindmapTransform(); 14593 }); 14594 const endPan = (event) => { 14595 if (!mindmapState.isPanning) return; 14596 mindmapState.isPanning = false;
14597 stage.classList.remove("is-panning"); 14598 try { 14599 stage.releasePointerCapture(event.pointerId); 14600 } catch (_) {} 14601 }; 14602 stage.addEventListener("pointerup", endPan); 14603 stage.addEventListener("pointercancel", endPan); 14604 14605 // Backdrop click closes 14606 viewer.addEventListener("click", (event) => { 14607 if (event.target === viewer) closeMindmapViewer(); 14608 }); 14609 14610 stage.addEventListener("click", (event) => { 14611 if (event.target.closest(".mm-node")) return; 14612 if (event.target.closest(".mindmap-search, .mindmap-inspector")) 14613 return; 14614 clearMindmapFocusedNode(); 14615 hideMindmapSearchResults(); 14616 }); 14617 } 14618 14619 /* ============================================================ 14620 * Concept Mindmap â preview navigation 14621 * ============================================================ */ 14622 14623 /** 14624 * Jump to the source section corresponding to a node. 14625 * Scrolls the preview, applies a transient highlight to the heading. 14626 */ 14627 function slugifyMindmapText(text) { 14628 return String(text || "") 14629 .toLowerCase() 14630 .normalize("NFKD") 14631 .replace(/[\u0300-\u036f]/g, "") 14632 .replace(/&/g, " and ") 14633 .replace(/[^a-z0-9\s-]/g, " ") 14634 .trim() 14635 .replace(/\s+/g, "-") 14636 .replace(/-+/g, "-"); 14637 } 14638 14639 function normalizeMindmapToken(token) { 14640 const value = String(token || "").toLowerCase(); 14641 if (value.endsWith("ies") && value.length > 4) 14642 return value.slice(0, -3) + "y"; 14643 if (value.endsWith("s") && value.length > 3) 14644 return value.slice(0, -1); 14645 return value; 14646 } 14647 14648 function getMindmapNodeById(id) { 14649 return mindmapState.laid && mindmapState.laid.nodes 14650 ? mindmapState.laid.nodes.find((n) => n.id === id) || null 14651 : null; 14652 } 14653 14654 function findMindmapTargetByAnchor(preview, anchor) { 14655 if (!anchor) return null; 14656 let target = preview.querySelector(`#${CSS.escape(anchor)}`); 14657 if (target) return target; 14658 14659 const slug = String(anchor).toLowerCase(); 14660 const headings = preview.querySelectorAll("h1, h2, h3, h4, h5, h6"); 14661 for (const heading of headings) { 14662 if (heading.id && heading.id.toLowerCase() === slug) { 14663 return heading; 14664 } 14665 } 14666 return null; 14667 } 14668 14669 function resolveMindmapTarget(node, preview) { 14670 if (!node || !preview) return null; 14671 14672 const triedAnchors = new Set(); 14673 const tryAnchor = (anchor) => { 14674 if (!anchor || triedAnchors.has(anchor)) return null; 14675 triedAnchors.add(anchor); 14676 return findMindmapTargetByAnchor(preview, anchor); 14677 }; 14678 14679 let target = tryAnchor(node.sourceAnchor); 14680 if (target) return target; 14681 14682 target = tryAnchor(slugifyMindmapText(node.label)); 14683 if (target) return target; 14684 14685 const headings = Array.from( 14686 preview.querySelectorAll("h1, h2, h3, h4, h5, h6"), 14687 ); 14688 if (headings.length === 0) return null; 14689 14690 const labelTokens = Array.from( 14691 new Set( 14692 String(node.label || "") 14693 .toLowerCase() 14694 .replace(/[^a-z0-9\s]/g, " ") 14695 .split(/\s+/) 14696 .map(normalizeMindmapToken) 14697 .filter((token) => token.length > 2), 14698 ), 14699 ); 14700 const summaryTokens = Array.from( 14701 new Set( 14702 String(node.summary || "") 14703 .toLowerCase() 14704 .replace(/[^a-z0-9\s]/g, " ") 14705 .split(/\s+/) 14706 .map(normalizeMindmapToken) 14707 .filter((token) => token.length > 3), 14708 ), 14709 ); 14710 14711 let best = null; 14712 for (const heading of headings) { 14713 const headingText = heading.textContent.trim(); 14714 const headingSlug = slugifyMindmapText(headingText); 14715 let score = 0; 14716 let labelMatches = 0; 14717 14718 if (headingSlug && headingSlug === slugifyMindmapText(node.label)) 14719 score += 8; 14720 14721 const headingTokens = new Set( 14722 headingText 14723 .toLowerCase() 14724 .replace(/[^a-z0-9\s]/g, " ") 14725 .split(/\s+/) 14726 .map(normalizeMindmapToken) 14727 .filter((token) => token.length > 2), 14728 ); 14729 14730 for (const token of labelTokens) { 14731 if (headingTokens.has(token)) { 14732 score += 3; 14733 labelMatches += 1; 14734 } 14735 } 14736 for (const token of summaryTokens) {
14737 if (headingTokens.has(token)) score += 1; 14738 } 14739 14740 if (!best || score > best.score) { 14741 best = { heading, score, labelMatches }; 14742 } 14743 } 14744 14745 if (best && best.score >= 3 && best.labelMatches > 0) { 14746 return best.heading; 14747 } 14748 14749 let ancestorId = node.parent; 14750 while (ancestorId) { 14751 const ancestor = getMindmapNodeById(ancestorId); 14752 if (!ancestor) break; 14753 const ancestorTokens = new Set( 14754 String(ancestor.label || "") 14755 .toLowerCase() 14756 .replace(/[^a-z0-9\s]/g, " ") 14757 .split(/\s+/) 14758 .map(normalizeMindmapToken) 14759 .filter((token) => token.length > 2), 14760 ); 14761 const hasAncestorOverlap = labelTokens.some((token) => 14762 ancestorTokens.has(token), 14763 ); 14764 target = tryAnchor(ancestor.sourceAnchor); 14765 if (target && hasAncestorOverlap) return target; 14766 ancestorId = ancestor.parent; 14767 } 14768 14769 return null; 14770 } 14771 14772 function scrollMindmapSourceTarget(target) { 14773 if (!target) return; 14774 const toolbarHeight = 14775 Number.parseFloat( 14776 getComputedStyle(document.documentElement).getPropertyValue( 14777 "--toolbar-h", 14778 ), 14779 ) || 52; 14780 const targetTop = 14781 target.getBoundingClientRect().top + 14782 window.scrollY - 14783 toolbarHeight - 14784 24; 14785 window.scrollTo({ 14786 top: Math.max(0, targetTop), 14787 behavior: "auto", 14788 }); 14789 target.classList.add("mm-jump-flash"); 14790 setTimeout(() => target.classList.remove("mm-jump-flash"), 1800); 14791 } 14792 14793 function jumpToMindmapSource(node) { 14794 if (!node) return; 14795 14796 const preview = document.getElementById("preview"); 14797 if (!preview) return; 14798 const target = resolveMindmapTarget(node, preview); 14799 if (!target) return; 14800 14801 closeMindmapViewer(); 14802 requestAnimationFrame(() => { 14803 requestAnimationFrame(() => scrollMindmapSourceTarget(target)); 14804 }); 14805 } 14806 14807 function wireMindmapNodeClicks() { 14808 const nodesG = document.getElementById("mindmapNodes"); 14809 if (!nodesG) return; 14810 nodesG.addEventListener("click", (event) => { 14811 const g = event.target.closest(".mm-node"); 14812 if (!g) return; 14813 event.preventDefault(); 14814 event.stopPropagation(); 14815 const id = g.getAttribute("data-id"); 14816 focusMindmapNode(id, { center: true }); 14817 }); 14818 nodesG.addEventListener("dblclick", (event) => { 14819 const g = event.target.closest(".mm-node"); 14820 if (!g) return; 14821 event.preventDefault(); 14822 event.stopPropagation(); 14823 const id = g.getAttribute("data-id"); 14824 const node = getMindmapNodeById(id); 14825 if (node && node.depth > 0) jumpToMindmapSource(node); 14826 }); 14827 nodesG.addEventListener("keydown", (event) => { 14828 if (event.key !== "Enter" && event.key !== " ") return; 14829 const g = event.target.closest(".mm-node"); 14830 if (!g) return; 14831 event.preventDefault(); 14832 const id = g.getAttribute("data-id"); 14833 const node = getMindmapNodeById(id); 14834 if (!node) return; 14835 if ( 14836 event.key === "Enter" && 14837 mindmapState.activeNodeId === id && 14838 node.depth > 0 14839 ) { 14840 jumpToMindmapSource(node); 14841 return; 14842 } 14843 focusMindmapNode(id, { center: true }); 14844 }); 14845 } 14846 14847 /* ============================================================ 14848 * Concept Mindmap â hover tooltip 14849 * ============================================================ */ 14850 14851 function wireMindmapHover() { 14852 const nodesG = document.getElementById("mindmapNodes"); 14853 const { tooltip, stage } = getMindmapEls(); 14854 if (!nodesG || !tooltip || !stage) return; 14855 14856 const typeEl = tooltip.querySelector(".mm-tooltip-type"); 14857 const labelEl = tooltip.querySelector(".mm-tooltip-label"); 14858 const summaryEl = tooltip.querySelector(".mm-tooltip-summary"); 14859 14860 function showTooltip(node, clientX, clientY) { 14861 const label = MINDMAP_TYPE_LABEL[node.type] || "Concept"; 14862 typeEl.textContent = label.toUpperCase(); 14863 labelEl.textContent = node.label || ""; 14864 summaryEl.textContent = node.summary || ""; 14865 const stageRect = stage.getBoundingClientRect(); 14866 const x = clientX - stageRect.left + 14; 14867 const y = clientY - stageRect.top + 14; 14868 tooltip.style.left = `${x}px`; 14869 tooltip.style.top = `${y}px`; 14870 tooltip.classList.add("is-visible"); 14871 } 14872 function hideTooltip() { 14873 tooltip.classList.remove("is-visible"); 14874 } 14875 14876 nodesG.addEventListener("pointerover", (event) => { 14877 const g = event.target.closest(".mm-node"); 14878 if (!g) return; 14879 const id = g.getAttribute("data-id"); 14880 const node = 14881 mindmapState.laid && 14882 mindmapState.laid.nodes.find((n) => n.id === id); 14883 if (node) showTooltip(node, event.clientX, event.clientY); 14884 }); 14885 nodesG.addEventListener("pointermove", (event) => { 14886 if (!tooltip.classList.contains("is-visible")) return; 14887 const g = event.target.closest(".mm-node"); 14888 if (!g) { 14889 hideTooltip(); 14890 return; 14891 } 14892 const stageRect = stage.getBoundingClientRect(); 14893 tooltip.style.left = `${event.clientX - stageRect.left + 14}px`; 14894 tooltip.style.top = `${event.clientY - stageRect.top + 14}px`; 14895 }); 14896 nodesG.addEventListener("pointerout", (event) => { 14897 if ( 14898 event.relatedTarget && 14899 event.relatedTarget.closest && 14900 event.relatedTarget.closest(".mm-node") 14901 ) 14902 return; 14903 hideTooltip(); 14904 }); 14905 stage.addEventListener("pointerdown", hideTooltip); 14906 } 14907 14908 /* ============================================================
14909 * Concept Mindmap â content hash + local cache 14910 * ============================================================ */ 14911 14912 // v3 invalidates older entries generated before digest compaction and 14913 // edge-kind normalization were added. 14914 const MINDMAP_CACHE_KEY = "markv:mindmap-cache:v3"; 14915 const MINDMAP_CACHE_MAX = 12; 14916 14917 async function hashString(str) { 14918 if (window.crypto && window.crypto.subtle) { 14919 const buf = new TextEncoder().encode(str); 14920 const digest = await window.crypto.subtle.digest("SHA-256", buf); 14921 const bytes = new Uint8Array(digest); 14922 let out = ""; 14923 for (let i = 0; i < bytes.length; i++) { 14924 out += bytes[i].toString(16).padStart(2, "0"); 14925 } 14926 return out.slice(0, 16); 14927 } 14928 // Fallback: DJB2 14929 let h = 5381; 14930 for (let i = 0; i < str.length; i++) 14931 h = ((h << 5) + h + str.charCodeAt(i)) | 0; 14932 return String(h >>> 0); 14933 } 14934 14935 function loadMindmapCache() { 14936 try { 14937 const raw = window.localStorage.getItem(MINDMAP_CACHE_KEY); 14938 if (!raw) return {}; 14939 const parsed = JSON.parse(raw); 14940 return parsed && typeof parsed === "object" ? parsed : {}; 14941 } catch (_) { 14942 return {}; 14943 } 14944 } 14945 14946 function saveMindmapCache(cache) { 14947 try { 14948 // Trim to most recent MINDMAP_CACHE_MAX entries. 14949 const keys = Object.keys(cache).sort( 14950 (a, b) => (cache[b].ts || 0) - (cache[a].ts || 0), 14951 ); 14952 const trimmed = {}; 14953 for (const k of keys.slice(0, MINDMAP_CACHE_MAX)) 14954 trimmed[k] = cache[k]; 14955 window.localStorage.setItem( 14956 MINDMAP_CACHE_KEY, 14957 JSON.stringify(trimmed), 14958 ); 14959 } catch (_) {} 14960 } 14961 14962 function getCachedGraph(docHash) { 14963 const cache = loadMindmapCache(); 14964 const entry = cache[docHash]; 14965 return entry ? entry.graph : null; 14966 } 14967 14968 function setCachedGraph(docHash, graph) { 14969 const cache = loadMindmapCache(); 14970 cache[docHash] = { graph, ts: Date.now() }; 14971 saveMindmapCache(cache); 14972 } 14973 14974 /* ============================================================ 14975 * Concept Mindmap â LLM extractor 14976 * ============================================================ */ 14977 14978 const MINDMAP_SYSTEM_PROMPT = `You are a concept extractor for technical documents. Read the markdown document and return a JSON concept graph that helps a reader understand the document's shape. 14979 14980REQUIREMENTS: 14981- Return ONLY valid JSON matching the schema. No prose, no markdown fences. 14982- The supplied document may be raw markdown or a structure-focused digest. Treat headings, anchor lines, and excerpts as authoritative. 14983- If LIBRARY_CONTEXT is present, treat it as deterministic context from the active library folder. 14984- sourceAnchor must be null or one of the listed available_anchors. Do not invent anchors. 14985- Prefer explicit outbound_links, backlinks, Related Concepts, and neighbors when choosing cross-tree edges. 14986- Use linked library pages to improve labels and summaries, but keep the map centered on the current document. 14987- Root = the document itself. Label: 1-4 words capturing what this doc IS. 14988- Primary nodes (parent: "root"): 4-7 load-bearing concepts a reader must understand. 14989- Secondary nodes: decomposition of a primary, max 3-4 children per primary. 14990- Total non-root nodes: max 24. Quality over coverage. 14991- Each node.type must be one of: goal, non_goal, feature, constraint, dependency, actor, assumption, open_question, success_metric. 14992- Each node.label: 1-5 words, human phrasing, no trailing punctuation. 14993- Each node.summary: one sentence, max 180 characters. 14994- Each node.sourceAnchor: the heading slug (kebab-case, lowercase, no #) if the concept maps to a specific heading; else null. 14995- edges: cross-tree relationships only. Kinds: part_of, depends_on, blocks, supports, contradicts, measured_by. 14996- "dependency" is a valid node.type but NOT a valid edge.kind. For dependency relationships, edge.kind must be "depends_on". 14997- If you are unsure about an edge kind, omit the edge instead of inventing a new enum value. 14998- Do not restate the parent-child tree in edges; edges are only for cross-tree relationships. 14999 15000SCHEMA: 15001{ 15002 "root": { "id": "root", "label": string, "summary": string }, 15003 "nodes": [ { "id": string, "label": string, "type": string, "summary": string, "sourceAnchor": string|null, "parent": string } ], 15004 "edges": [ { "from": string, "to": string, "kind": string } ] 15005} 15006 15007STYLE: 15008- Plain editorial English. 15009- Never "The user shall be able to" â write the noun. 15010- Never generic labels like "Implementation" or "Requirements" â be specific. 15011 15012Return JSON only.`; 15013 15014 const MINDMAP_SOURCE_CHAR_LIMIT = 9000; 15015 const MINDMAP_SOURCE_SECTION_LIMIT = 18; 15016 const MINDMAP_SOURCE_SECTION_SNIPPET_CHARS = 320; 15017 const MINDMAP_SOURCE_OVERVIEW_CHARS = 900;
15018 const MINDMAP_GROQ_LOW_TPM_MODEL_RE = 15019 /^(?:qwen\/qwen3-32b|llama-3\.1-8b-instant|allam-2-7b)$/i; 15020 const MINDMAP_MAX_COMPLETION_TOKENS = 1400; 15021 15022 const MINDMAP_EDGE_KIND_ALIASES = { 15023 part_of: "part_of", 15024 partof: "part_of", 15025 depends_on: "depends_on", 15026 dependson: "depends_on", 15027 dependency: "depends_on", 15028 dependencies: "depends_on", 15029 dependent_on: "depends_on", 15030 depends: "depends_on", 15031 requires: "depends_on", 15032 requirement: "depends_on", 15033 blocked: "blocks", 15034 blocks: "blocks", 15035 supports: "supports", 15036 support: "supports", 15037 contradicts: "contradicts", 15038 contradict: "contradicts", 15039 measured_by: "measured_by", 15040 measuredby: "measured_by", 15041 measures: "measured_by", 15042 }; 15043 15044 const MINDMAP_NODE_TYPE_ENUM = [ 15045 "goal", 15046 "non_goal", 15047 "feature", 15048 "constraint", 15049 "dependency", 15050 "actor", 15051 "assumption", 15052 "open_question", 15053 "success_metric", 15054 ]; 15055 15056 const MINDMAP_EDGE_KIND_ENUM = [ 15057 "part_of", 15058 "depends_on", 15059 "blocks", 15060 "supports", 15061 "contradicts", 15062 "measured_by", 15063 ]; 15064 15065 const MINDMAP_JSON_SCHEMA = { 15066 type: "object", 15067 additionalProperties: false, 15068 required: ["root", "nodes", "edges"], 15069 properties: { 15070 root: { 15071 type: "object", 15072 additionalProperties: false, 15073 required: ["id", "label", "summary"], 15074 properties: { 15075 id: { type: "string", enum: ["root"] }, 15076 label: { type: "string" }, 15077 summary: { type: "string" }, 15078 }, 15079 }, 15080 nodes: { 15081 type: "array", 15082 items: { 15083 type: "object", 15084 additionalProperties: false, 15085 required: [ 15086 "id", 15087 "label", 15088 "type", 15089 "summary", 15090 "sourceAnchor", 15091 "parent", 15092 ], 15093 properties: { 15094 id: { type: "string" }, 15095 label: { type: "string" }, 15096 type: { type: "string", enum: MINDMAP_NODE_TYPE_ENUM }, 15097 summary: { type: "string" }, 15098 sourceAnchor: { type: ["string", "null"] }, 15099 parent: { type: "string" }, 15100 }, 15101 }, 15102 }, 15103 edges: { 15104 type: "array", 15105 items: { 15106 type: "object", 15107 additionalProperties: false, 15108 required: ["from", "to", "kind"], 15109 properties: { 15110 from: { type: "string" }, 15111 to: { type: "string" }, 15112 kind: { type: "string", enum: MINDMAP_EDGE_KIND_ENUM }, 15113 }, 15114 }, 15115 }, 15116 }, 15117 }; 15118 15119 function getMindmapLlmSettings() { 15120 if (typeof window.markvGetLlmSettings === "function") { 15121 try { 15122 const settings = window.markvGetLlmSettings(); 15123 return settings && typeof settings === "object" ? settings : null; 15124 } catch (_) {} 15125 } 15126 return null; 15127 } 15128 15129 function normalizeMindmapWhitespace(text) { 15130 return String(text || "") 15131 .replace(/\s+/g, " ") 15132 .trim(); 15133 } 15134 15135 function clipMindmapText(text, maxChars) { 15136 const normalized = normalizeMindmapWhitespace(text); 15137 if (!normalized || normalized.length <= maxChars) return normalized; 15138 const clipped = normalized.slice(0, Math.max(0, maxChars - 1)); 15139 const boundary = clipped.lastIndexOf(" "); 15140 return ( 15141 (boundary > 48 ? clipped.slice(0, boundary) : clipped).trim() + "â¦" 15142 ); 15143 } 15144 15145 function cleanMindmapInlineMarkdown(text) { 15146 return normalizeMindmapWhitespace( 15147 String(text || "") 15148 .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1") 15149 .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (_match, alt) { 15150 return alt ? `[image: ${alt}]` : "[image]"; 15151 }) 15152 .replace(/`([^`]+)`/g, "$1") 15153 .replace(/[*_~]/g, "") 15154 .replace(/<\/?[^>]+>/g, ""), 15155 ); 15156 } 15157
15158 function simplifyMindmapMarkdown(text) { 15159 return normalizeMindmapWhitespace( 15160 String(text || "") 15161 .replace(/```([^\n`]*)\n[\s\S]*?```/g, function (_match, lang) { 15162 const label = normalizeMindmapWhitespace(lang).toLowerCase(); 15163 return label ? `\n[${label} code block]\n` : "\n[code block]\n"; 15164 }) 15165 .replace(/~~~([^\n~]*)\n[\s\S]*?~~~/g, function (_match, lang) { 15166 const label = normalizeMindmapWhitespace(lang).toLowerCase(); 15167 return label ? `\n[${label} code block]\n` : "\n[code block]\n"; 15168 }) 15169 .replace(/^ {0,3}\|(?:[-: ]+\|)+\s*$/gm, "") 15170 .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (_match, alt) { 15171 return alt ? `[image: ${alt}]` : "[image]"; 15172 }) 15173 .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1") 15174 .replace(/<https?:\/\/[^>]+>/g, "[link]") 15175 .replace(/`([^`]+)`/g, "$1") 15176 .replace(/^ {0,3}>\s?/gm, "") 15177 .replace(/^(\s*)([-*+]|\d+\.)\s+/gm, "$1- "), 15178 ); 15179 } 15180 15181 function parseMindmapMarkdownSections(markdown) { 15182 const lines = String(markdown || "").split(/\r?\n/); 15183 const sections = []; 15184 let current = { 15185 heading: "Document", 15186 level: 0, 15187 anchor: null, 15188 lines: [], 15189 }; 15190 let inFence = false; 15191 15192 function pushCurrent() { 15193 const rawText = current.lines.join("\n"); 15194 const text = simplifyMindmapMarkdown(rawText); 15195 if (!text && current.level === 0 && current.heading === "Document") 15196 return; 15197 if (!current.heading && !text) return; 15198 sections.push({ 15199 heading: current.heading || "Document", 15200 level: current.level || 0, 15201 anchor: current.anchor || null, 15202 text: text, 15203 }); 15204 } 15205 15206 for (let i = 0; i < lines.length; i++) { 15207 const line = lines[i]; 15208 if (/^(```|~~~)/.test(line.trim())) { 15209 inFence = !inFence; 15210 current.lines.push(line); 15211 continue; 15212 } 15213 if (!inFence) { 15214 const headingMatch = line.match( 15215 /^ {0,3}(#{1,6})[ \t]+(.+?)\s*#*\s*$/, 15216 ); 15217 if (headingMatch) { 15218 pushCurrent(); 15219 const headingText = cleanMindmapInlineMarkdown(headingMatch[2]); 15220 current = { 15221 heading: headingText || "Untitled Section", 15222 level: headingMatch[1].length, 15223 anchor: slugifyMindmapText(headingText), 15224 lines: [], 15225 }; 15226 continue; 15227 } 15228 } 15229 current.lines.push(line); 15230 } 15231 15232 pushCurrent(); 15233 return sections.filter(function (section) { 15234 return section.heading || section.text; 15235 }); 15236 } 15237 15238 function shouldCompactMindmapSource(markdown) { 15239 const settings = getMindmapLlmSettings(); 15240 const model = 15241 settings && settings.provider === "groq" 15242 ? String(settings.groqModel || "").trim() 15243 : ""; 15244 if ( 15245 settings && 15246 settings.provider === "groq" && 15247 MINDMAP_GROQ_LOW_TPM_MODEL_RE.test(model) 15248 ) { 15249 return true; 15250 } 15251 return String(markdown || "").length > 12000; 15252 } 15253 15254 function buildMindmapDocumentSource(markdown) { 15255 const raw = String(markdown || ""); 15256 if (!shouldCompactMindmapSource(raw)) { 15257 return { 15258 mode: "raw_markdown", 15259 text: raw.slice(0, MINDMAP_MAX_MARKDOWN_CHARS), 15260 truncated: raw.length > MINDMAP_MAX_MARKDOWN_CHARS, 15261 }; 15262 } 15263 15264 const simplified = simplifyMindmapMarkdown(raw); 15265 const sections = parseMindmapMarkdownSections(raw); 15266 const preview = document.getElementById("preview"); 15267 const title = 15268 normalizeMindmapWhitespace( 15269 sections[0] && sections[0].level === 1 15270 ? sections[0].heading 15271 : preview && preview.dataset && preview.dataset.documentLabel, 15272 ) || "Document"; 15273 15274 const parts = [ 15275 "# " + title, 15276 "", 15277 "Document digest for concept extraction. Heading text, anchor lines, and excerpts are authoritative.", 15278 "", 15279 "## Overview", 15280 clipMindmapText(simplified, MINDMAP_SOURCE_OVERVIEW_CHARS) || 15281 "No overview available.", 15282 ]; 15283 15284 let usedChars = parts.join("\n").length; 15285 const maxSections = Math.min( 15286 MINDMAP_SOURCE_SECTION_LIMIT, 15287 sections.length, 15288 ); 15289 15290 for (let i = 0; i < maxSections; i++) { 15291 const section = sections[i]; 15292 const snippet = clipMindmapText( 15293 section.text, 15294 MINDMAP_SOURCE_SECTION_SNIPPET_CHARS, 15295 ); 15296 if (!snippet) continue; 15297 const depth = Math.min(Math.max(section.level || 2, 2), 6); 15298 const block = [ 15299 `${"#".repeat(depth)} ${section.heading}`, 15300 section.anchor ? `anchor: ${section.anchor}` : "anchor: null", 15301 snippet, 15302 ].join("\n"); 15303 if (usedChars + block.length > MINDMAP_SOURCE_CHAR_LIMIT) break; 15304 parts.push("", block); 15305 usedChars += block.length; 15306 } 15307 15308 return { 15309 mode: "digest", 15310 text: parts.join("\n").trim(), 15311 truncated: true, 15312 }; 15313 } 15314 15315 function getMindmapFallbackPath() { 15316 const rawName = String(state.currentName || "document.md").trim(); 15317 return rawName || "document.md"; 15318 } 15319 15320 function getOrderedLibraryMindmapPaths(folder, currentPath) { 15321 if ( 15322 !folder || 15323 !folder.fileHandlesByPath || 15324 typeof folder.fileHandlesByPath.keys !== "function" 15325 ) { 15326 return []; 15327 } 15328 const currentDir = String(currentPath || "") 15329 .split("/") 15330 .slice(0, -1) 15331 .join("/"); 15332 return Array.from(folder.fileHandlesByPath.keys()) 15333 .filter(function (path) { 15334 return path && path !== currentPath; 15335 }) 15336 .sort(function (a, b) { 15337 const aDir = String(a).split("/").slice(0, -1).join("/"); 15338 const bDir = String(b).split("/").slice(0, -1).join("/"); 15339 const aSame = aDir === currentDir ? 0 : 1; 15340 const bSame = bDir === currentDir ? 0 : 1; 15341 if (aSame !== bSame) return aSame - bSame; 15342 if (typeof libraryCore.compareLibraryNames === "function") { 15343 return libraryCore.compareLibraryNames(a, b); 15344 } 15345 return String(a).localeCompare(String(b), undefined, { 15346 numeric: true, 15347 sensitivity: "base", 15348 }); 15349 }); 15350 } 15351 15352 async function readLibraryMindmapFile(folder, path) { 15353 if (!folder || !folder.fileHandlesByPath) return null; 15354 const handle = folder.fileHandlesByPath.get(path); 15355 if (!handle || typeof handle.getFile !== "function") return null; 15356 try { 15357 const file = await handle.getFile(); 15358 return { 15359 path: path, 15360 text: await file.text(), 15361 }; 15362 } catch (error) { 15363 console.warn("[mindmap] skipped unreadable library context file", { 15364 path: path,
15365 error: error && error.message ? error.message : String(error), 15366 }); 15367 return null; 15368 } 15369 } 15370 15371 async function buildActiveLibraryMindmapContext(markdown) { 15372 if (typeof libraryCore.buildLibraryMindmapContext !== "function") { 15373 return null; 15374 } 15375 15376 const currentPath = 15377 state.sourceMode === "library" && state.libraryActiveFilePath 15378 ? state.libraryActiveFilePath 15379 : getMindmapFallbackPath(); 15380 const files = []; 15381 const folder = 15382 state.sourceMode === "library" && state.libraryActiveFolderId 15383 ? getLibraryFolderById(state.libraryActiveFolderId) 15384 : null; 15385 15386 if (folder && folder.status === "ready") { 15387 const paths = getOrderedLibraryMindmapPaths(folder, currentPath); 15388 for (const path of paths) { 15389 const item = await readLibraryMindmapFile(folder, path); 15390 if (item) files.push(item); 15391 if (files.length % 20 === 0) await yieldToUi(); 15392 } 15393 } 15394 15395 return libraryCore.buildLibraryMindmapContext({ 15396 currentPath: currentPath, 15397 currentMarkdown: markdown, 15398 files: files, 15399 }); 15400 } 15401 15402 function getMindmapLibraryContextCacheKey(context) { 15403 if (!context || typeof context !== "object") return ""; 15404 const compact = { 15405 current: context.current && context.current.path, 15406 outbound: (context.outboundLinks || []).map(function (item) { 15407 return [item.path, item.related ? 1 : 0]; 15408 }), 15409 backlinks: (context.backlinks || []).map(function (item) { 15410 return [item.path, item.occurrenceCount || 1]; 15411 }), 15412 neighbors: (context.neighbors || []).map(function (item) { 15413 return [item.path, item.direction, item.summary || ""]; 15414 }), 15415 }; 15416 try { 15417 return JSON.stringify(compact); 15418 } catch (_error) { 15419 return ""; 15420 } 15421 } 15422 15423 function formatMindmapLibraryContext(context) { 15424 if ( 15425 !context || 15426 typeof libraryCore.formatLibraryMindmapContextForPrompt !== 15427 "function" 15428 ) { 15429 return ""; 15430 } 15431 return libraryCore.formatLibraryMindmapContextForPrompt(context); 15432 } 15433 15434 function groundMindmapGraphToLibraryContext(graph, context) { 15435 if ( 15436 context && 15437 typeof libraryCore.groundMindmapGraphWithLibraryContext === 15438 "function" 15439 ) { 15440 return libraryCore.groundMindmapGraphWithLibraryContext( 15441 graph, 15442 context, 15443 ); 15444 } 15445 return graph; 15446 } 15447 15448 function buildMindmapExtractionSource(markdown, libraryContext) { 15449 const source = buildMindmapDocumentSource(markdown); 15450 const formattedContext = formatMindmapLibraryContext(libraryContext); 15451 if (!formattedContext) return source; 15452 return { 15453 mode: source.mode + "+library_context", 15454 text: source.text + "\n\n---\n\n" + formattedContext, 15455 truncated: source.truncated, 15456 }; 15457 } 15458 15459 function getMindmapGroqResponseFormat(model) { 15460 const normalized = String(model || "") 15461 .trim() 15462 .toLowerCase(); 15463 if ( 15464 normalized === "openai/gpt-oss-20b" || 15465 normalized === "openai/gpt-oss-120b" 15466 ) { 15467 return { 15468 type: "json_schema", 15469 json_schema: { 15470 name: "concept_graph", 15471 strict: true, 15472 schema: MINDMAP_JSON_SCHEMA, 15473 }, 15474 }; 15475 } 15476 if ( 15477 normalized === "openai/gpt-oss-safeguard-20b" || 15478 normalized === "meta-llama/llama-4-scout-17b-16e-instruct" 15479 ) { 15480 return { 15481 type: "json_schema", 15482 json_schema: { 15483 name: "concept_graph",
15484 strict: false, 15485 schema: MINDMAP_JSON_SCHEMA, 15486 }, 15487 }; 15488 } 15489 return { type: "json_object" }; 15490 } 15491 15492 window.markvGetMindmapGroqResponseFormat = getMindmapGroqResponseFormat; 15493 15494 /** 15495 * Invokes the shared LLM client (wired by chat feature) and returns a 15496 * validated ConceptGraph. The client must be exposed as 15497 * window.markvLLM = async ({system, user}) => string returning raw text. 15498 * Throws on any validation failure. 15499 * @param {string} markdown 15500 * @param {ReturnType<typeof buildActiveLibraryMindmapContext>} [libraryContext] 15501 * @returns {Promise<ConceptGraph>} 15502 */ 15503 async function extractConcepts(markdown, libraryContext) { 15504 if (typeof window.markvLLM !== "function") { 15505 throw new Error( 15506 "No LLM client registered (window.markvLLM is not set)", 15507 ); 15508 } 15509 const source = buildMindmapExtractionSource(markdown, libraryContext); 15510 const userPrompt = 15511 `Extract the concept graph for the following technical document.\n\n` + 15512 `SOURCE_MODE: ${source.mode}\n\nDOCUMENT:\n\n${source.text}`; 15513 const raw = await window.markvLLM({ 15514 system: MINDMAP_SYSTEM_PROMPT, 15515 user: userPrompt, 15516 maxCompletionTokens: MINDMAP_MAX_COMPLETION_TOKENS, 15517 }); 15518 return groundMindmapGraphToLibraryContext( 15519 parseAndValidateGraph(raw), 15520 libraryContext, 15521 ); 15522 } 15523 15524 /** 15525 * Accepts raw LLM output (potentially with markdown fences) and returns 15526 * a validated ConceptGraph. Throws with a helpful message on failure. 15527 */ 15528 function extractBalancedJsonObject(text) { 15529 const firstBrace = text.indexOf("{"); 15530 if (firstBrace === -1) return text; 15531 15532 let start = firstBrace; 15533 let depth = 0; 15534 let inString = false; 15535 let escaped = false; 15536 15537 for (let i = firstBrace; i < text.length; i++) { 15538 const ch = text[i]; 15539 if (inString) { 15540 if (escaped) { 15541 escaped = false; 15542 } else if (ch === "\\") { 15543 escaped = true; 15544 } else if (ch === '"') { 15545 inString = false; 15546 } 15547 continue; 15548 } 15549 15550 if (ch === '"') { 15551 inString = true; 15552 continue; 15553 } 15554 if (ch === "{") { 15555 if (depth === 0) start = i; 15556 depth += 1; 15557 } else if (ch === "}") { 15558 depth -= 1; 15559 if (depth === 0) { 15560 return text.slice(start, i + 1); 15561 } 15562 } 15563 } 15564 15565 return text; 15566 } 15567 15568 function stripJsonTrailingCommas(text) { 15569 let out = ""; 15570 let inString = false; 15571 let escaped = false; 15572 15573 for (let i = 0; i < text.length; i++) { 15574 const ch = text[i]; 15575 if (inString) { 15576 out += ch; 15577 if (escaped) { 15578 escaped = false; 15579 } else if (ch === "\\") { 15580 escaped = true; 15581 } else if (ch === '"') { 15582 inString = false; 15583 } 15584 continue; 15585 } 15586 15587 if (ch === '"') { 15588 inString = true; 15589 out += ch; 15590 continue; 15591 } 15592 15593 if (ch === ",") { 15594 let j = i + 1; 15595 while (j < text.length && /\s/.test(text[j])) j += 1; 15596 if (text[j] === "}" || text[j] === "]") { 15597 continue; 15598 } 15599 } 15600 15601 out += ch; 15602 } 15603 15604 return out; 15605 } 15606 15607 function normalizeMindmapJsonText(raw) { 15608 let text = String(raw || "") 15609 .trim() 15610 .replace(/^\uFEFF/, ""); 15611 const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i); 15612 if (fenceMatch) text = fenceMatch[1].trim(); 15613 return extractBalancedJsonObject(text).trim(); 15614 } 15615 15616 const MINDMAP_TYPE_ALIASES = { 15617 goal: "goal", 15618 goals: "goal", 15619 non_goal: "non_goal", 15620 non_goals: "non_goal", 15621 nongoal: "non_goal", 15622 nongoals: "non_goal", 15623 feature: "feature", 15624 features: "feature", 15625 constraint: "constraint", 15626 constraints: "constraint", 15627 dependency: "dependency", 15628 dependencies: "dependency", 15629 actor: "actor", 15630 actors: "actor", 15631 assumption: "assumption", 15632 assumptions: "assumption", 15633 open_question: "open_question", 15634 open_questions: "open_question", 15635 question: "open_question", 15636 questions: "open_question", 15637 success_metric: "success_metric", 15638 success_metrics: "success_metric", 15639 metric: "success_metric", 15640 metrics: "success_metric", 15641 kpi: "success_metric", 15642 kpis: "success_metric", 15643 }; 15644 15645 function normalizeMindmapNodeType(type) { 15646 if (typeof type !== "string") return type; 15647 const canonical = type 15648 .trim() 15649 .toLowerCase() 15650 .replace(/[\s-]+/g, "_"); 15651 return MINDMAP_TYPE_ALIASES[canonical] || canonical; 15652 } 15653 15654 function normalizeMindmapEdgeKind(kind) { 15655 if (typeof kind !== "string") return kind; 15656 const canonical = kind 15657 .trim() 15658 .toLowerCase() 15659 .replace(/[\s-]+/g, "_"); 15660 const collapsed = canonical.replace(/_/g, ""); 15661 return ( 15662 MINDMAP_EDGE_KIND_ALIASES[canonical] || 15663 MINDMAP_EDGE_KIND_ALIASES[collapsed] || 15664 canonical 15665 ); 15666 } 15667 15668 function parseAndValidateGraph(raw) { 15669 if (typeof raw !== "string") 15670 throw new Error("LLM returned non-string output"); 15671 const text = normalizeMindmapJsonText(raw); 15672 let parsed; 15673 try { 15674 parsed = JSON.parse(text); 15675 } catch (e) { 15676 try { 15677 parsed = JSON.parse(stripJsonTrailingCommas(text)); 15678 } catch (_) {
15679 throw new Error(`LLM output is not valid JSON: ${e.message}`); 15680 } 15681 } 15682 if (!parsed || typeof parsed !== "object") 15683 throw new Error("Graph must be an object"); 15684 if ( 15685 !parsed.root || 15686 parsed.root.id !== "root" || 15687 typeof parsed.root.label !== "string" 15688 ) { 15689 throw new Error("Graph.root is missing or malformed"); 15690 } 15691 if (!Array.isArray(parsed.nodes)) 15692 throw new Error("Graph.nodes must be an array"); 15693 if (parsed.nodes.length === 0) throw new Error("Graph has no nodes"); 15694 if (parsed.nodes.length > 30) 15695 throw new Error( 15696 `Graph has too many nodes (${parsed.nodes.length})`, 15697 ); 15698 15699 if ( 15700 typeof libraryCore.normalizeMindmapGraphReferences === "function" 15701 ) { 15702 parsed = libraryCore.normalizeMindmapGraphReferences(parsed, { 15703 maxDepth: 3, 15704 allowedNodeTypes: MINDMAP_NODE_TYPE_ENUM, 15705 fallbackNodeType: "feature", 15706 normalizeNodeType: normalizeMindmapNodeType, 15707 allowedEdgeKinds: MINDMAP_EDGE_KIND_ENUM, 15708 fallbackEdgeKind: "supports", 15709 normalizeEdgeKind: normalizeMindmapEdgeKind, 15710 }); 15711 } 15712 15713 const seen = new Set(["root"]); 15714 const allowedTypes = new Set(Object.keys(MINDMAP_TYPE_LABEL)); 15715 const allowedEdgeKinds = new Set([ 15716 "part_of", 15717 "depends_on", 15718 "blocks", 15719 "supports", 15720 "contradicts", 15721 "measured_by", 15722 ]); 15723 const parentById = new Map([["root", null]]); 15724 for (const n of parsed.nodes) { 15725 if (!n.id || typeof n.id !== "string") 15726 throw new Error("Node missing id"); 15727 if (seen.has(n.id)) throw new Error(`Duplicate node id: ${n.id}`); 15728 seen.add(n.id); 15729 parentById.set(n.id, n.parent); 15730 if (!n.label || typeof n.label !== "string") 15731 throw new Error(`Node ${n.id} missing label`); 15732 n.type = normalizeMindmapNodeType(n.type); 15733 if (!allowedTypes.has(n.type)) 15734 throw new Error(`Node ${n.id} has invalid type: ${n.type}`); 15735 if (!n.parent || typeof n.parent !== "string") n.parent = "root"; 15736 if (typeof n.summary !== "string") n.summary = ""; 15737 if (typeof n.sourceAnchor !== "string" && n.sourceAnchor !== null) 15738 n.sourceAnchor = null; 15739 } 15740 // Every non-root parent must resolve to a known id. 15741 for (const n of parsed.nodes) { 15742 if (n.parent !== "root" && !seen.has(n.parent)) { 15743 throw new Error( 15744 `Node ${n.id} references unknown parent ${n.parent}`, 15745 ); 15746 } 15747 } 15748 if (!Array.isArray(parsed.edges)) parsed.edges = []; 15749 for (const edge of parsed.edges) { 15750 if (!edge || typeof edge !== "object") 15751 throw new Error("Edge must be an object"); 15752 if (!seen.has(edge.from)) 15753 throw new Error(`Edge references unknown node ${edge.from}`); 15754 if (!seen.has(edge.to)) 15755 throw new Error(`Edge references unknown node ${edge.to}`); 15756 edge.kind = normalizeMindmapEdgeKind(edge.kind); 15757 if (!allowedEdgeKinds.has(edge.kind)) 15758 throw new Error(`Edge has invalid kind: ${edge.kind}`); 15759 } 15760 15761 const visitState = new Map(); 15762 const depthById = new Map([["root", 0]]); 15763 function resolveDepth(id) { 15764 if (id === "root") return 0; 15765 const state = visitState.get(id); 15766 if (state === "visiting") { 15767 throw new Error(`Cycle detected in parent chain at ${id}`); 15768 } 15769 if (state === "done") return depthById.get(id); 15770 15771 visitState.set(id, "visiting"); 15772 const parentId = parentById.get(id); 15773 if (!parentById.has(parentId)) { 15774 throw new Error(`Node ${id} is not rooted under root`); 15775 } 15776 15777 const depth = resolveDepth(parentId) + 1; 15778 if (depth > 3) { 15779 throw new Error(`Node ${id}
15779 exceeds supported depth`); 15780 } 15781 depthById.set(id, depth); 15782 visitState.set(id, "done"); 15783 return depth; 15784 } 15785 15786 for (const n of parsed.nodes) { 15787 resolveDepth(n.id); 15788 } 15789 return parsed; 15790 } 15791 15792 const els = { 15793 brandName: document.getElementById("brandName"), 15794 brandDirty: document.getElementById("brandDirty"), 15795 editor: document.getElementById("editor"), 15796 preview: document.getElementById("preview"), 15797 readerConnections: document.getElementById("readerConnections"), 15798 newDocumentBtn: document.getElementById("newDocumentBtn"), 15799 openFileBtn: document.getElementById("openFileBtn"), 15800 saveBtn: document.getElementById("saveBtn"), 15801 exportBtn: document.getElementById("exportBtn"), 15802 exportMenu: document.getElementById("exportMenu"), 15803 exportPdfBtn: document.getElementById("exportPdfBtn"), 15804 exportMarkdownBtn: document.getElementById("exportMarkdownBtn"), 15805 shareBtn: document.getElementById("shareBtn"), 15806 shareBtnLabel: document.getElementById("shareBtnLabel"), 15807 modeToggleBtn: document.getElementById("modeToggleBtn"), 15808 libraryToggleBtn: document.getElementById("libraryToggleBtn"), 15809 reloadBtn: document.getElementById("reloadBtn"), 15810 autoRefreshToggle: document.getElementById("autoRefreshToggle"), 15811 clearBtn: document.getElementById("clearBtn"), 15812 readingToggleBtn: document.getElementById("readingToggleBtn"), 15813 readingPanel: document.getElementById("readingPanel"), 15814 readingPresetGroup: document.getElementById("readingPresetGroup"), 15815 readingPresetButtons: Array.from( 15816 document.querySelectorAll(".reading-preset"), 15817 ), 15818 readingPaletteGroup: document.getElementById("readingPaletteGroup"), 15819 readingPaletteButtons: Array.from( 15820 document.querySelectorAll(".reading-palette"), 15821 ), 15822 readingPresetCustom: document.getElementById("readingPresetCustom"), 15823 readingFontSizeInput: document.getElementById("readingFontSize"), 15824 readingFontSizeValue: document.getElementById("readingFontSizeValue"), 15825 readingLineHeightInput: document.getElementById("readingLineHeight"), 15826 readingLineHeightValue: document.getElementById( 15827 "readingLineHeightValue", 15828 ), 15829 readingMeasureInput: document.getElementById("readingMeasure"), 15830 readingMeasureValue: document.getElementById("readingMeasureValue"), 15831 readingResetBtn: document.getElementById("readingResetBtn"), 15832 readingCloseBtn: document.getElementById("readingCloseBtn"), 15833 status: document.getElementById("status"), 15834 wordCount: document.getElementById("wordCount"), 15835 liveRegion: document.getElementById("liveRegion"), 15836 appToast: document.getElementById("appToast"), 15837 appToastEyebrow: document.getElementById("appToastEyebrow"), 15838 appToastTitle: document.getElementById("appToastTitle"), 15839 appToastMessage: document.getElementById("appToastMessage"), 15840 appToastDismiss: document.getElementById("appToastDismiss"), 15841 fileInput: document.getElementById("fileInput"), 15842 libraryFolderInput: document.getElementById("libraryFolderInput"), 15843 previewPane: document.querySelector(".preview-pane"), 15844 selectionBubble: document.getElementById("selectionBubble"), 15845 selectionCopyBtn: document.getElementById("selectionCopyBtn"), 15846 selectionHighlightBtn: document.getElementById( 15847 "selectionHighlightBtn", 15848 ), 15849 selectionAskBtn: document.getElementById("selectionAskBtn"), 15850 selectionSummarizeBtn: document.getElementById( 15851 "selectionSummarizeBtn", 15852 ), 15853 highlightRemoveChip: document.getElementById("highlightRemoveChip"), 15854 highlightUndoToast: document.getElementById("highlightUndoToast"), 15855 highlightUndoMessage: document.getElementById("highlightUndoMessage"), 15856 highlightUndoBtn: document.getElementById("highlightUndoBtn"), 15857 ctxMenu: document.getElementById("ctxMenu"), 15858 ctxHighlight: document.getElementById("ctxHighlight"), 15859 ctxAsk: document.getElementById("ctxAsk"), 15860 ctxSummarize: document.getElementById("ctxSummarize"), 15861 mermaidViewer: document.getElementById("mermaidViewer"), 15862 mermaidViewerStage: document.getElementById("mermaidViewerStage"), 15863 mermaidViewerZoomable: document.getElementById( 15864 "mermaidViewerZoomable", 15865 ), 15866 mermaidViewerZoomInBtn: document.getElementById( 15867 "mermaidViewerZoomInBtn", 15868 ), 15869 mermaidViewerZoomOutBtn: document.getElementById( 15870 "mermaidViewerZoomOutBtn", 15871 ), 15872 mermaidViewerResetBtn: document.getElementById( 15873 "mermaidViewerResetBtn", 15874 ), 15875 mermaidViewerFitBtn: document.getElementById("mermaidViewerFitBtn"), 15876 mermaidViewerDownloadBtn: document.getElementById( 15877 "mermaidViewerDownloadBtn", 15878 ), 15879 mermaidViewerCloseBtn: document.getElementById( 15880 "mermaidViewerCloseBtn", 15881 ), 15882 mermaidViewerZoomLabel: document.getElementById( 15883 "mermaidViewerZoomLabel", 15884 ), 15885 mermaidViewerMeta: document.getElementById("mermaidViewerMeta"), 15886 libraryOverlay: document.getElementById("libraryOverlay"), 15887 libraryDrawer: document.getElementById("libraryDrawer"), 15888 libraryAddBtn: document.getElementById("libraryAddBtn"), 15889 libraryToggleAllBtn: document.getElementById("libraryToggleAllBtn"), 15890 libraryCloseBtn: document.getElementById("libraryCloseBtn"), 15891 libraryBody: document.getElementById("libraryBody"), 15892 libraryEmpty: document.getElementById("libraryEmpty"), 15893 librarySupportedNote: document.getElementById("librarySupportedNote"), 15894 }; 15895 let libraryUiWired = false;
15896 15897 function syncLibraryRefs() { 15898 els.libraryOverlay = 15899 els.libraryOverlay || document.getElementById("libraryOverlay"); 15900 els.libraryDrawer = 15901 els.libraryDrawer || document.getElementById("libraryDrawer"); 15902 els.openFileBtn = 15903 els.openFileBtn || document.getElementById("openFileBtn"); 15904 els.libraryAddBtn = 15905 els.libraryAddBtn || document.getElementById("libraryAddBtn"); 15906 els.libraryToggleAllBtn = 15907 els.libraryToggleAllBtn || 15908 document.getElementById("libraryToggleAllBtn"); 15909 els.libraryCloseBtn = 15910 els.libraryCloseBtn || document.getElementById("libraryCloseBtn"); 15911 els.libraryBody = 15912 els.libraryBody || document.getElementById("libraryBody"); 15913 els.libraryEmpty = 15914 els.libraryEmpty || document.getElementById("libraryEmpty"); 15915 els.librarySupportedNote = 15916 els.librarySupportedNote || 15917 document.getElementById("librarySupportedNote"); 15918 15919 return Boolean( 15920 els.libraryOverlay && 15921 els.libraryDrawer && 15922 els.openFileBtn && 15923 els.libraryAddBtn && 15924 els.libraryToggleAllBtn && 15925 els.libraryCloseBtn && 15926 els.libraryBody && 15927 els.libraryEmpty && 15928 els.librarySupportedNote, 15929 ); 15930 } 15931 15932 const state = { 15933 fileHandle: null, 15934 sourceMode: "editor", 15935 currentName: "", 15936 baseHref: window.location.href, 15937 contentVersion: 0, 15938 lastRenderedContentVersion: -1, 15939 lastRenderedDocumentToken: "", 15940 renderTimer: null, 15941 autoRefreshTimer: null, 15942 autoRefreshInFlight: false, 15943 dirty: false, 15944 dragDepth: 0, 15945 readingSettings: null, 15946 readerPalette: "original", 15947 readerPaletteInitialized: false, 15948 readerPaletteScheme: "light", 15949 highlightDocKey: "", 15950 currentHighlights: [], 15951 resolvedHighlights: [], 15952 highlightTextModel: null, 15953 activeHighlightId: "", 15954 selectionController: null, 15955 selectionBubbleTimer: null, 15956 highlightUndo: null, 15957 highlightUndoTimer: null, 15958 appToastTimer: null, 15959 mermaidApiPromise: null, 15960 beautifulMermaidApiPromise: null, 15961 hljsPromise: null, 15962 katexPromise: null, 15963 previewRenderGeneration: 0, 15964 themeMermaidRenderTimer: null, 15965 themeRefreshToken: 0, 15966 editorSelection: 0, 15967 mermaidViewerOpen: false, 15968 mermaidViewerScale: 1, 15969 mermaidViewerBaseWidth: 1, 15970 mermaidViewerBaseHeight: 1, 15971 mermaidViewerDownloadUrl: "", 15972 mermaidViewerLastFocused: null, 15973 mermaidViewerDrag: null, 15974 hasSessionDraft: false, 15975 untitledDraftDbPromise: null, 15976 untitledDrafts: [], 15977 untitledDraftId: "", 15978 untitledDraftWriteTimer: null, 15979 sharedSnapshotMeta: null, 15980 shareInFlight: false, 15981 sharePendingMayTakeTime: false, 15982 exportInFlight: false, 15983 libraryRestorePending: false, 15984 libraryDbPromise: null, 15985 libraryDrawerLastFocused: null, 15986 libraryFolders: [], 15987 libraryFolderOrder: [], 15988 libraryExpandedPathsByFolder: {}, 15989 libraryCollapsedFolderIds: [], 15990 libraryActiveFolderId: "", 15991 libraryActiveFilePath: "", 15992 libraryCurrentFileHandle: null, 15993 libraryCurrentFolderHandle: null, 15994 libraryPendingImportTargetFolderId: "", 15995 libraryPendingImportToken: 0, 15996 libraryIsOpen: false, 15997 libraryNextScanRevision: 0, 15998 highlightMemoryBuckets: { 15999 local: {}, 16000 session: {}, 16001 }, 16002 }; 16003 let readingLastFocusedElement = null; 16004 16005 const attrMap = { 16006 "&": "&", 16007 "<": "<", 16008 ">": ">", 16009 '"': """, 16010 "'": "'", 16011 }; 16012 16013 function escapeHtml(value) { 16014 return String(value).replace(/[&<>"']/g, function (char) { 16015 return attrMap[char]; 16016 }); 16017 } 16018 16019 const READER_CONNECTIONS_MAX_NEIGHBORS = 10; 16020 let readerConnectionsRequestToken = 0; 16021 let readerOutlineHeadings = []; 16022 let readerOutlineRaf = 0; 16023 16024 function hideReaderRail() { 16025 readerOutlineHeadings = []; 16026 if (els.readerConnections) { 16027 els.readerConnections.hidden = true; 16028 els.readerConnections.textContent = ""; 16029 els.readerConnections.classList.remove("has-reader-connections"); 16030 } 16031 if (els.previewPane) { 16032 els.previewPane.classList.remove("has-reader-rail"); 16033 } 16034 } 16035 16036 function getReaderHeadingText(heading) { 16037 if (!heading) return ""; 16038 const clone = heading.cloneNode(true); 16039 clone.querySelectorAll(".heading-anchor").forEach(function (anchor) { 16040 anchor.remove(); 16041 }); 16042 return String(clone.textContent || "") 16043 .replace(/\s+/g, " ") 16044 .replace(/^#\s*/, "") 16045 .trim(); 16046 } 16047 16048 function collectReaderOutlineItems() { 16049 if (!els.preview) return []; 16050 let headings = Array.from( 16051 els.preview.querySelectorAll(":scope > :is(h2, h3, h4)[id]"), 16052 ); 16053 if (!headings.length) { 16054 headings = Array.from( 16055 els.preview.querySelectorAll(":scope > :is(h1, h2, h3)[id]"), 16056 ); 16057 } 16058 return headings 16059 .map(function (heading) { 16060 return { 16061 id: heading.id, 16062 text: getReaderHeadingText(heading), 16063 level: Number(heading.tagName.slice(1)) || 2, 16064 }; 16065 }) 16066 .filter(function (item) { 16067 return item.id && item.text; 16068 }); 16069 } 16070 16071 function setReaderOutlineActive(id) { 16072 if (!els.readerConnections) return; 16073 const links = Array.from( 16074 els.readerConnections.querySelectorAll("[data-reader-outline-id]"), 16075 ); 16076 let activeText = ""; 16077 let activeLink = null; 16078 links.forEach(function (link) { 16079 const isActive = link.getAttribute("data-reader-outline-id") === id; 16080 if (isActive) { 16081 link.setAttribute("aria-current", "true"); 16082 activeText = link.textContent.trim(); 16083 activeLink = link; 16084 } else { 16085 link.removeAttribute("aria-current"); 16086 } 16087 }); 16088 const summary = els.readerConnections.querySelector( 16089 "[data-reader-outline-summary]", 16090 ); 16091 if (summary) { 16092 const count = links.length; 16093 const countText = count === 1 ? "1 section" : count + " sections"; 16094 summary.textContent = activeText 16095 ? activeText + " · " + countText 16096 : countText; 16097 } 16098 scrollReaderOutlineItemIntoView(activeLink); 16099 } 16100 16101 function scrollReaderOutlineItemIntoView(link) { 16102 if (!link) return; 16103 const list = link.closest(".reader-outline"); 16104 if (!list || list.scrollHeight <= list.clientHeight + 1) return; 16105 const linkTop = link.offsetTop; 16106 const linkBottom = linkTop + link.offsetHeight; 16107 const viewTop = list.scrollTop;
16108 const viewBottom = viewTop + list.clientHeight; 16109 const buffer = 18; 16110 16111 if (linkTop < viewTop + buffer) { 16112 list.scrollTop = Math.max(0, linkTop - buffer); 16113 } else if (linkBottom > viewBottom - buffer) { 16114 list.scrollTop = linkBottom - list.clientHeight + buffer; 16115 } 16116 } 16117 16118 function getReaderActiveHeading(headings) { 16119 const list = Array.from(headings || []).filter(function (heading) { 16120 return heading && document.contains(heading); 16121 }); 16122 if (!list.length) return null; 16123 const toolbarHeight = 16124 Number.parseFloat( 16125 getComputedStyle(document.documentElement).getPropertyValue( 16126 "--toolbar-h", 16127 ), 16128 ) || 52; 16129 const threshold = Math.max( 16130 toolbarHeight + 48, 16131 window.innerHeight * 0.42, 16132 ); 16133 let active = list[0]; 16134 for (const heading of list) { 16135 if (heading.getBoundingClientRect().top <= threshold) { 16136 active = heading; 16137 } else { 16138 break; 16139 } 16140 } 16141 return active; 16142 } 16143 16144 function updateReaderOutlineActive() { 16145 readerOutlineRaf = 0; 16146 if ( 16147 !els.readerConnections || 16148 els.readerConnections.hidden || 16149 !readerOutlineHeadings.length 16150 ) { 16151 return; 16152 } 16153 readerOutlineHeadings = readerOutlineHeadings.filter( 16154 function (heading) { 16155 return heading && document.contains(heading); 16156 }, 16157 ); 16158 if (!readerOutlineHeadings.length) return; 16159 const active = getReaderActiveHeading(readerOutlineHeadings); 16160 if (active) setReaderOutlineActive(active.id); 16161 } 16162 16163 function scheduleReaderOutlineActiveUpdate() { 16164 if (readerOutlineRaf) return; 16165 readerOutlineRaf = window.requestAnimationFrame( 16166 updateReaderOutlineActive, 16167 ); 16168 } 16169 16170 function renderReaderOutlineSection(items) { 16171 if (!items.length) return ""; 16172 const countText = 16173 items.length === 1 ? "1 section" : items.length + " sections"; 16174 const list = items 16175 .map(function (item, index) { 16176 const level = Math.min(4, Math.max(2, item.level || 2)); 16177 const text = String(item.text || ""); 16178 return ( 16179 '<li class="reader-outline__item">' + 16180 '<a class="reader-outline__link" href="#' + 16181 encodeURIComponent(item.id) + 16182 '" data-reader-outline-id="' + 16183 escapeHtml(item.id) + 16184 '" data-level="' + 16185 escapeHtml(String(level)) + 16186 '" title="' + 16187 escapeHtml(text) + 16188 '"' + 16189 (index === 0 ? ' aria-current="true"' : "") + 16190 ">" + 16191 '<span class="reader-outline__text">' + 16192 escapeHtml(text) + 16193 "</span>" + 16194 "</a></li>" 16195 ); 16196 }) 16197 .join(""); 16198 16199 return ( 16200 '<section class="reader-rail__section reader-outline-section" data-reader-outline-section aria-label="On this page">' + 16201 '<div class="reader-rail__header">' + 16202 '<h2 id="readerOutlineTitle" class="reader-rail__title">On this page</h2>' + 16203 "</div>" + 16204 '<button class="reader-outline-toggle" type="button" data-reader-outline-toggle aria-expanded="false" aria-controls="readerOutlineList">' + 16205 "<span>" + 16206 '<span class="reader-outline-toggle__kicker">On this page</span>' + 16207 '<span class="reader-outline-toggle__summary" data-reader-outline-summary>' + 16208 escapeHtml(countText) + 16209 "</span>" + 16210 "</span>" + 16211 '<span class="reader-outline-toggle__chevron" aria-hidden="true"></span>' + 16212 "</button>" + 16213 '<ol id="readerOutlineList" class="reader-outline">' + 16214 list + 16215 "</ol></section>" 16216 ); 16217 } 16218 16219 function clipReaderConnectionLabel(value, maxChars) { 16220 const text = String(value || "") 16221 .replace(/\s+/g, " ") 16222 .trim(); 16223 if (!text || text.length <= maxChars) return text; 16224 return text.slice(0, Math.max(0, maxChars - 1)).trim() + "â¦"; 16225 } 16226 16227 function getReaderConnectionLabel(item) { 16228 if (!item) return "Connection"; 16229 if (item.related) return "Related concept";
16230 if (item.direction === "inbound") { 16231 const count = Number(item.backlinkCount) || 1; 16232 return count === 1 ? "Backlink" : count + " backlinks"; 16233 } 16234 if (item.direction === "bidirectional") return "Two-way link"; 16235 return "Linked page"; 16236 } 16237 16238 function canOpenReaderConnection(path) { 16239 if (state.sourceMode !== "library" || !state.libraryActiveFolderId) { 16240 return false; 16241 } 16242 const folder = getLibraryFolderById(state.libraryActiveFolderId); 16243 return Boolean( 16244 folder && 16245 folder.fileHandlesByPath && 16246 typeof folder.fileHandlesByPath.has === "function" && 16247 folder.fileHandlesByPath.has(path), 16248 ); 16249 } 16250 16251 function renderReaderConnectionsGraph(model) { 16252 const nodes = model.neighbors || []; 16253 const count = nodes.length; 16254 const centerX = 110; 16255 const centerY = 76; 16256 const layoutNodes = 16257 typeof libraryCore.layoutReaderConnectionGraph === "function" 16258 ? libraryCore.layoutReaderConnectionGraph(nodes, { 16259 width: 220, 16260 height: 150, 16261 centerX: centerX, 16262 centerY: centerY, 16263 }) 16264 : nodes.map(function (item, index) { 16265 const radius = count <= 4 ? 42 : 53; 16266 const angle = 16267 -Math.PI / 2 + (index / Math.max(1, count)) * Math.PI * 2; 16268 return { 16269 item: item, 16270 index: index, 16271 radius: item && item.related ? 5.5 : 4.4, 16272 x: centerX + Math.cos(angle) * radius, 16273 y: centerY + Math.sin(angle) * radius * 0.82, 16274 }; 16275 }); 16276 const edgeParts = []; 16277 const nodeParts = []; 16278 16279 layoutNodes.forEach(function (layoutNode) { 16280 const item = layoutNode.item || nodes[layoutNode.index] || {}; 16281 const x = Number(layoutNode.x) || centerX; 16282 const y = Number(layoutNode.y) || centerY; 16283 const nodeRadius = 16284 Number(layoutNode.radius) || (item.related ? 5.5 : 4.4); 16285 const openable = canOpenReaderConnection(item.path); 16286 const itemTitle = item.title || item.path || "Connection"; 16287 const pointLabel = clipReaderConnectionLabel(itemTitle, 30); 16288 edgeParts.push( 16289 '<line class="reader-connections__edge" data-direction="' + 16290 escapeHtml(item.direction || "related") + 16291 '" x1="' + 16292 centerX + 16293 '" y1="' + 16294 centerY + 16295 '" x2="' + 16296 x.toFixed(1) + 16297 '" y2="' + 16298 y.toFixed(1) + 16299 '"></line>', 16300 ); 16301 nodeParts.push( 16302 '<g class="reader-connections__point" data-reader-graph-node data-openable="' + 16303 (openable ? "true" : "false") + 16304 '" data-x="' + 16305 x.toFixed(1) + 16306 '" data-y="' + 16307 y.toFixed(1) + 16308 '" data-reader-label="' + 16309 escapeHtml(pointLabel) + 16310 '"' + 16311 (openable 16312 ? ' role="link" tabindex="0" data-reader-path="' + 16313 escapeHtml(item.path) + 16314 '" aria-label="Open ' + 16315 escapeHtml(itemTitle) + 16316 '"' 16317 : ' aria-label="' + escapeHtml(itemTitle) + '"') + 16318 ">" + 16319 '<circle class="reader-connections__node" data-direction="' + 16320 escapeHtml(item.direction || "related") + 16321 '" data-related="' + 16322 (item.related ? "true" : "false") + 16323 '" cx="' + 16324 x.toFixed(1) + 16325 '" cy="' + 16326 y.toFixed(1) + 16327 '" r="' + 16328 nodeRadius + 16329 '"></circle></g>', 16330 ); 16331 }); 16332 16333 const label = clipReaderConnectionLabel(model.current.title, 24); 16334 return ( 16335 '<svg class="reader-connections__graph" viewBox="0 0 220 150" role="img" aria-label="Connections around the current document" data-reader-root-label="' + 16336 escapeHtml(label) + 16337 '">' + 16338 '<g aria-hidden="true" class="reader-connections__edges">' + 16339 edgeParts.join("") + 16340 "</g>" + 16341 '<g aria-hidden="true" class="reader-connections__root-group">' + 16342 '<circle class="reader-connections__root" cx="' + 16343 centerX + 16344 '" cy="' + 16345 centerY + 16346 '" r="7"></circle>' + 16347 '<text class="reader-connections__root-label" x="' + 16348 centerX + 16349 '" y="96" text-anchor="middle">' + 16350 escapeHtml(label) + 16351 "</text>" + 16352 "</g>" + 16353 '<g class="reader-connections__points">' + 16354 nodeParts.join("") + 16355 "</g></svg>" 16356 ); 16357 } 16358 16359 function renderReaderConnectionsSection(model) { 16360 if (!model) return ""; 16361 const stats = model.stats || {}; 16362 const categoryText = (model.current.categories || []) 16363 .slice(0, 2) 16364 .join(", "); 16365 const list = (model.neighbors || []) 16366 .slice(0, 4) 16367 .map(function (item) { 16368 const openable = canOpenReaderConnection(item.path); 16369 const tag = openable ? "button" : "div"; 16370 const attrs = openable 16371 ? ' type="button" data-reader-path="' + 16372 escapeHtml(item.path) + 16373 '"' 16374 : ""; 16375 return ( 16376 "<" + 16377 tag + 16378 ' class="reader-connection"' + 16379 attrs + 16380 ">" + 16381 '<span class="reader-connection__dot" data-direction="' + 16382 escapeHtml(item.direction || "related") + 16383 '"></span>' + 16384 "<span>" + 16385 '<span class="reader-connection__title">' + 16386 escapeHtml(item.title || item.path || "Connection") + 16387 "</span>" + 16388 '<span class="reader-connection__detail">' + 16389 escapeHtml(getReaderConnectionLabel(item)) + 16390 (item.summary 16391 ? " · " + 16392 escapeHtml(clipReaderConnectionLabel(item.summary, 56)) 16393 : "") + 16394 "</span>" + 16395 "</span>" + 16396 "</" + 16397 tag + 16398 ">" 16399 ); 16400 }) 16401 .join(""); 16402 16403 const more = 16404 stats.hiddenCount > 0 16405 ? '<p class="reader-connections__more">+' + 16406 escapeHtml(String(stats.hiddenCount)) + 16407 " more local connection" + 16408 (stats.hiddenCount === 1 ? "" : "s") + 16409 "</p>" 16410 : ""; 16411 16412 return ( 16413 '<section class="reader-rail__section reader-connections" aria-labelledby="readerConnectionsTitle">' + 16414 '<div class="reader-connections__header">' + 16415 '<h2 id="readerConnectionsTitle" class="reader-connections__title">Connections</h2>' + 16416 "</div>" + 16417 '<div class="reader-connections__panel">' + 16418 renderReaderConnectionsGraph(model) + 16419 '<div class="reader-connections__meta" aria-label="Connection summary">' + 16420 '<span class="reader-connections__stat" data-empty="' + 16421 (stats.outboundCount ? "false" : "true") + 16422 '"><strong>' + 16423 escapeHtml(String(stats.outboundCount || 0)) + 16424 "</strong><span>links</span></span>" + 16425 '<span class="reader-connections__stat" data-empty="' + 16426 (stats.backlinkCount ? "false" : "true") + 16427 '"><strong>' + 16428 escapeHtml(String(stats.backlinkCount || 0)) + 16429 "</strong><span>backlinks</span></span>" + 16430 '<span class="reader-connections__stat" data-empty="' + 16431 (stats.relatedCount ? "false" : "true") + 16432 '"><strong>' + 16433 escapeHtml(String(stats.relatedCount || 0)) + 16434 "</strong><span>related</span></span>" + 16435 "</div></div>" + 16436 (categoryText 16437 ? '<p class="reader-connections__more">Tags: ' + 16438 escapeHtml(categoryText) + 16439 "</p>" 16440 : "") + 16441 '<div class="reader-connections__list">' + 16442 list + 16443 "</div>" + 16444 more + 16445 "</section>" 16446 ); 16447 } 16448 16449 function renderReaderRail(outlineItems, connectionsModel) { 16450 if (!els.readerConnections || !els.previewPane) return; 16451 const outline = Array.isArray(outlineItems) ? outlineItems : [];
16452 const connectionsHtml = 16453 renderReaderConnectionsSection(connectionsModel); 16454 const hasConnections = Boolean(connectionsHtml); 16455 const html = renderReaderOutlineSection(outline) + connectionsHtml; 16456 if (!html) { 16457 hideReaderRail(); 16458 return; 16459 } 16460 readerOutlineHeadings = outline 16461 .map(function (item) { 16462 return els.preview.querySelector("#" + CSS.escape(item.id)); 16463 }) 16464 .filter(Boolean); 16465 els.readerConnections.innerHTML = html; 16466 els.readerConnections.hidden = false; 16467 els.readerConnections.classList.toggle( 16468 "has-reader-connections", 16469 hasConnections, 16470 ); 16471 els.previewPane.classList.add("has-reader-rail"); 16472 scheduleReaderOutlineActiveUpdate(); 16473 } 16474 16475 async function refreshReaderRail(markdown, generation) { 16476 const token = ++readerConnectionsRequestToken; 16477 if (!markdown.trim()) { 16478 hideReaderRail(); 16479 return; 16480 } 16481 16482 const outlineItems = collectReaderOutlineItems(); 16483 renderReaderRail(outlineItems, null); 16484 16485 if ( 16486 state.sourceMode !== "library" || 16487 !state.libraryActiveFolderId || 16488 typeof libraryCore.createReaderConnectionsModel !== "function" 16489 ) { 16490 return; 16491 } 16492 16493 try { 16494 const context = await buildActiveLibraryMindmapContext(markdown); 16495 if ( 16496 token !== readerConnectionsRequestToken || 16497 generation !== state.previewRenderGeneration 16498 ) { 16499 return; 16500 } 16501 const model = libraryCore.createReaderConnectionsModel(context, { 16502 maxNeighbors: READER_CONNECTIONS_MAX_NEIGHBORS, 16503 }); 16504 renderReaderRail(outlineItems, model); 16505 } catch (error) { 16506 if ( 16507 token !== readerConnectionsRequestToken || 16508 generation !== state.previewRenderGeneration 16509 ) { 16510 return; 16511 } 16512 console.warn("[reader-rail] connections failed", error); 16513 renderReaderRail(outlineItems, null); 16514 } 16515 } 16516 16517 async function openReaderConnectionPath(path) { 16518 if (!path || !canOpenReaderConnection(path)) return false; 16519 await loadFromLibrarySelection(state.libraryActiveFolderId, path, { 16520 userInitiated: true, 16521 }); 16522 return true; 16523 } 16524 16525 function resetReaderConnectionsGraphMotion(graph) { 16526 if (!graph) return; 16527 graph 16528 .querySelectorAll("[data-reader-graph-node]") 16529 .forEach(function (node) { 16530 node.style.transform = ""; 16531 }); 16532 } 16533 16534 function setReaderConnectionsGraphLabel(graph, label) { 16535 if (!graph) return; 16536 const labelEl = graph.querySelector( 16537 ".reader-connections__root-label", 16538 ); 16539 if (!labelEl) return; 16540 labelEl.textContent = 16541 label || graph.getAttribute("data-reader-root-label") || ""; 16542 } 16543 16544 function updateReaderConnectionsGraphMotion(event, graph) { 16545 if (!graph || prefersReducedMotion()) return; 16546 const rect = graph.getBoundingClientRect(); 16547 if (!rect.width || !rect.height) return; 16548 const pointerX = ((event.clientX - rect.left) / rect.width) * 220; 16549 const pointerY = ((event.clientY - rect.top) / rect.height) * 150; 16550 graph 16551 .querySelectorAll("[data-reader-graph-node]") 16552 .forEach(function (node) { 16553 const nodeX = Number(node.getAttribute("data-x")) || 0; 16554 const nodeY = Number(node.getAttribute("data-y")) || 0; 16555 const dx = pointerX - nodeX; 16556 const dy = pointerY - nodeY; 16557 const distance = Math.sqrt(dx * dx + dy * dy); 16558 const influence = Math.max(0, 1 - distance / 82); 16559 const maxShift = 16560 node.getAttribute("data-openable") === "true" ? 5 : 3; 16561 const x = clampNumber(dx * influence * 0.07, -maxShift, maxShift); 16562 const y = clampNumber(dy * influence * 0.07, -maxShift, maxShift); 16563 node.style.transform = 16564 "translate(" + x.toFixed(2) + "px, " + y.toFixed(2) + "px)"; 16565 }); 16566 } 16567 16568 function wireReaderRail() { 16569 if (!els.readerConnections) return; 16570 els.readerConnections.addEventListener( 16571 "click", 16572 async function (event) { 16573 const outlineToggle = event.target.closest( 16574 "[data-reader-outline-toggle]", 16575 ); 16576 if ( 16577 outlineToggle && 16578 els.readerConnections.contains(outlineToggle) 16579 ) { 16580 const section = outlineToggle.closest( 16581 "[data-reader-outline-section]", 16582 ); 16583 if (!section) return; 16584 const isOpen = !section.classList.contains("is-open"); 16585 section.classList.toggle("is-open", isOpen); 16586 outlineToggle.setAttribute( 16587 "aria-expanded", 16588 isOpen ? "true" : "false", 16589 ); 16590 return; 16591 } 16592 const outlineLink = event.target.closest( 16593 "[data-reader-outline-id]", 16594 ); 16595 if (outlineLink && els.readerConnections.contains(outlineLink)) { 16596 event.preventDefault(); 16597 const id = 16598 outlineLink.getAttribute("data-reader-outline-id") || ""; 16599 const target = id 16600 ? els.preview.querySelector("#" + CSS.escape(id)) 16601 : null; 16602 if (target) { 16603 target.scrollIntoView({ 16604 behavior: prefersReducedMotion() ? "auto" : "smooth", 16605 block: "start", 16606 }); 16607 setReaderOutlineActive(id); 16608 } 16609 if ( 16610 window.matchMedia && 16611 window.matchMedia("(max-width: 720px)").matches 16612 ) { 16613 const section = outlineLink.closest( 16614 "[data-reader-outline-section]", 16615 ); 16616 const toggle = section 16617 ? section.querySelector("[data-reader-outline-toggle]") 16618 : null; 16619 if (section && toggle) { 16620 section.classList.remove("is-open"); 16621 toggle.setAttribute("aria-expanded", "false"); 16622 } 16623 } 16624 return; 16625 } 16626 const trigger = event.target.closest("[data-reader-path]"); 16627 if (!trigger || !els.readerConnections.contains(trigger)) return; 16628 const path = trigger.getAttribute("data-reader-path") || ""; 16629 await openReaderConnectionPath(path); 16630 }, 16631 ); 16632 els.readerConnections.addEventListener( 16633 "keydown", 16634 async function (event) { 16635 if (event.key !== "Enter" && event.key !== " ") return; 16636 const trigger = event.target.closest("[data-reader-path]"); 16637 if (!trigger || !els.readerConnections.contains(trigger)) return; 16638 event.preventDefault(); 16639 const path = trigger.getAttribute("data-reader-path") || ""; 16640 await openReaderConnectionPath(path); 16641 }, 16642 ); 16643 els.readerConnections.addEventListener( 16644 "pointermove", 16645 function (event) { 16646 const graph = event.target.closest(".reader-connections__graph"); 16647 if (!graph || !els.readerConnections.contains(graph)) return; 16648 const point = event.target.closest("[data-reader-label]"); 16649 setReaderConnectionsGraphLabel( 16650 graph, 16651 point && graph.contains(point) 16652 ? point.getAttribute("data-reader-label") 16653 : "", 16654 ); 16655 updateReaderConnectionsGraphMotion(event, graph); 16656 }, 16657 ); 16658 els.readerConnections.addEventListener( 16659 "pointerout", 16660 function (event) { 16661 const graph = event.target.closest(".reader-connections__graph"); 16662 if ( 16663 !graph || 16664 !els.readerConnections.contains(graph) || 16665 graph.contains(event.relatedTarget) 16666 ) { 16667 return; 16668 } 16669 resetReaderConnectionsGraphMotion(graph); 16670 setReaderConnectionsGraphLabel(graph, ""); 16671 }, 16672 ); 16673 els.readerConnections.addEventListener("focusin", function (event) { 16674 const point = event.target.closest("[data-reader-label]"); 16675 if (!point || !els.readerConnections.contains(point)) return; 16676 const graph = point.closest(".reader-connections__graph"); 16677 setReaderConnectionsGraphLabel( 16678 graph, 16679 point.getAttribute("data-reader-label"), 16680 ); 16681 }); 16682 els.readerConnections.addEventListener("focusout", function (event) { 16683 const graph = event.target.closest(".reader-connections__graph"); 16684 if ( 16685 !graph || 16686 !els.readerConnections.contains(graph) || 16687 graph.contains(event.relatedTarget) 16688 ) { 16689 return; 16690 } 16691 setReaderConnectionsGraphLabel(graph, ""); 16692 }); 16693 window.addEventListener("scroll", scheduleReaderOutlineActiveUpdate, { 16694 passive: true, 16695 }); 16696 window.addEventListener("resize", scheduleReaderOutlineActiveUpdate, { 16697 passive: true, 16698 }); 16699 } 16700 16701 function markEditorContentChanged() { 16702 state.contentVersion += 1; 16703 clearSelectionController(); 16704 state.highlightTextModel = null; 16705 } 16706 16707 function setEditorMarkdown(markdown) { 16708 const next = String(markdown || ""); 16709 if (els.editor.value !== next) { 16710 els.editor.value = next; 16711 markEditorContentChanged(); 16712 return; 16713 } 16714 els.editor.value = next; 16715 } 16716
16717 function invalidateLastRenderedDocument() { 16718 state.lastRenderedContentVersion = -1; 16719 state.lastRenderedDocumentToken = ""; 16720 } 16721 16722 function readStorage(storage, key, fallback) { 16723 try { 16724 const value = storage.getItem(key); 16725 return value === null ? fallback : value; 16726 } catch (_error) { 16727 return fallback; 16728 } 16729 } 16730 16731 function writeStorage(storage, key, value) { 16732 try { 16733 storage.setItem(key, value); 16734 return true; 16735 } catch (_error) { 16736 return false; 16737 } 16738 } 16739 16740 function removeStorage(storage, key) { 16741 try { 16742 storage.removeItem(key); 16743 } catch (_error) {} 16744 } 16745 16746 // Session-content persistence is a best-effort restore buffer: 16747 // writing the whole editor string on every debounced render 16748 // was hot on the typing path for long docs. Defer to idle time, 16749 // cancelling the previous scheduled write so only the newest 16750 // snapshot lands. `pagehide` flushes synchronously if a write 16751 // is still pending when the tab is closing. 16752 var __pendingSessionWrite = null; 16753 var __latestSessionContent = null; 16754 function __flushSessionContent() { 16755 if (__latestSessionContent == null) return; 16756 writeStorage( 16757 window.sessionStorage, 16758 SESSION_CONTENT_KEY, 16759 __latestSessionContent, 16760 ); 16761 __latestSessionContent = null; 16762 __pendingSessionWrite = null; 16763 } 16764 function scheduleSessionContentWrite(markdown) { 16765 __latestSessionContent = markdown; 16766 if (__pendingSessionWrite != null) { 16767 if (window.cancelIdleCallback) { 16768 window.cancelIdleCallback(__pendingSessionWrite); 16769 } else { 16770 clearTimeout(__pendingSessionWrite); 16771 } 16772 } 16773 if (window.requestIdleCallback) { 16774 __pendingSessionWrite = window.requestIdleCallback( 16775 __flushSessionContent, 16776 { timeout: 1500 }, 16777 ); 16778 } else { 16779 __pendingSessionWrite = setTimeout(__flushSessionContent, 250); 16780 } 16781 } 16782 window.addEventListener("pagehide", __flushSessionContent); 16783 16784 var __pendingUntitledDraftWrites = new Map(); 16785 var __pendingUntitledDraftFlush = null; 16786 16787 function canUseUntitledDraftStorage() { 16788 return typeof indexedDB !== "undefined"; 16789 } 16790 16791 function createUntitledDraftId() { 16792 return ( 16793 "draft_" + 16794 Date.now().toString(36) + 16795 "_" + 16796 Math.random().toString(36).slice(2, 8) 16797 ); 16798 } 16799 16800 function normalizeUntitledDraftRecord(raw, fallbackTitle) { 16801 const source = raw && typeof raw === "object" ? raw : {}; 16802 const now = Date.now(); 16803 const id = 16804 typeof source.id === "string" && source.id 16805 ? source.id 16806 : createUntitledDraftId(); 16807 const title = 16808 typeof source.title === "string" && source.title.trim() 16809 ? source.title.trim() 16810 : fallbackTitle || "Untitled 1"; 16811 const createdAt = Number(source.createdAt) || now; 16812 const updatedAt = Number(source.updatedAt) || createdAt; 16813 const lastActive = Number(source.lastActive) || updatedAt; 16814 return { 16815 id: id, 16816 title: title, 16817 content: typeof source.content === "string" ? source.content : "", 16818 createdAt: createdAt, 16819 updatedAt: updatedAt, 16820 lastActive: lastActive, 16821 dirty: Boolean(source.dirty), 16822 }; 16823 } 16824 16825 function sortUntitledDrafts(list) { 16826 return (Array.isArray(list) ? list.slice() : []).sort( 16827 function (a, b) { 16828 return ( 16829 (Number(b.lastActive) || 0) - (Number(a.lastActive) || 0) || 16830 (Number(b.updatedAt) || 0) - (Number(a.updatedAt) || 0) || 16831 String(a.title || "").localeCompare(String(b.title || "")) 16832 ); 16833 }, 16834 ); 16835 } 16836 16837 function upsertUntitledDraftInState(record) { 16838 const normalized = normalizeUntitledDraftRecord(record); 16839 const next = state.untitledDrafts.slice(); 16840 const index = next.findIndex(function (draft) { 16841 return draft.id === normalized.id; 16842 }); 16843 if (index === -1) { 16844 next.push(normalized); 16845 } else { 16846 next[index] = normalized; 16847 } 16848 state.untitledDrafts = sortUntitledDrafts(next); 16849 return normalized; 16850 } 16851 16852 function getUntitledDraftById(id) { 16853 return ( 16854 state.untitledDrafts.find(function (draft) { 16855 return draft.id === id; 16856 }) || null 16857 ); 16858 } 16859 16860 function isUntitledDraftMode() { 16861 return ( 16862 state.sourceMode === "untitled" && Boolean(state.untitledDraftId) 16863 ); 16864 } 16865 16866 function getActiveUntitledDraft() { 16867 return isUntitledDraftMode() 16868 ? getUntitledDraftById(state.untitledDraftId) 16869 : null; 16870 } 16871 16872 function getNextUntitledDraftTitle() { 16873 let max = 0;
16874 state.untitledDrafts.forEach(function (draft) { 16875 const match = /^Untitled\s+(\d+)$/i.exec(String(draft.title || "")); 16876 if (!match) return; 16877 const value = Number(match[1]); 16878 if (Number.isFinite(value)) max = Math.max(max, value); 16879 }); 16880 return "Untitled " + (max + 1); 16881 } 16882 16883 function writeUntitledDraftBackup() { 16884 writeStorage( 16885 window.localStorage, 16886 UNTITLED_DRAFT_BACKUP_KEY, 16887 JSON.stringify({ 16888 drafts: state.untitledDrafts.map(function (draft) { 16889 return normalizeUntitledDraftRecord(draft); 16890 }), 16891 updatedAt: Date.now(), 16892 }), 16893 ); 16894 } 16895 16896 function readUntitledDraftBackup() { 16897 const raw = readStorage( 16898 window.localStorage, 16899 UNTITLED_DRAFT_BACKUP_KEY, 16900 "", 16901 ); 16902 if (!raw) return []; 16903 try { 16904 const parsed = JSON.parse(raw); 16905 const list = Array.isArray(parsed) 16906 ? parsed 16907 : Array.isArray(parsed && parsed.drafts) 16908 ? parsed.drafts 16909 : []; 16910 return list.map(function (item, index) { 16911 return normalizeUntitledDraftRecord( 16912 item, 16913 "Untitled " + (index + 1), 16914 ); 16915 }); 16916 } catch (_error) { 16917 return []; 16918 } 16919 } 16920 16921 function openUntitledDraftDb() { 16922 if (!canUseUntitledDraftStorage()) { 16923 return Promise.reject(new Error("IndexedDB is unavailable")); 16924 } 16925 if (state.untitledDraftDbPromise) return state.untitledDraftDbPromise; 16926 state.untitledDraftDbPromise = new Promise(function ( 16927 resolve, 16928 reject, 16929 ) { 16930 const request = indexedDB.open( 16931 UNTITLED_DRAFT_DB_NAME, 16932 UNTITLED_DRAFT_DB_VERSION, 16933 ); 16934 request.onupgradeneeded = function () { 16935 const db = request.result; 16936 if (!db.objectStoreNames.contains(UNTITLED_DRAFT_STORE)) { 16937 db.createObjectStore(UNTITLED_DRAFT_STORE, { keyPath: "id" }); 16938 } 16939 }; 16940 request.onsuccess = function () { 16941 resolve(request.result); 16942 }; 16943 request.onerror = function () { 16944 reject(request.error); 16945 }; 16946 }); 16947 return state.untitledDraftDbPromise; 16948 } 16949 16950 function untitledDraftGetAllFromDb() { 16951 return openUntitledDraftDb().then(function (db) { 16952 return new Promise(function (resolve, reject) { 16953 const tx = db.transaction(UNTITLED_DRAFT_STORE, "readonly"); 16954 const request = tx.objectStore(UNTITLED_DRAFT_STORE).getAll(); 16955 request.onsuccess = function () { 16956 resolve(request.result || []); 16957 }; 16958 request.onerror = function () { 16959 reject(request.error); 16960 }; 16961 }); 16962 }); 16963 } 16964 16965 function untitledDraftPutInDb(record) { 16966 const normalized = normalizeUntitledDraftRecord(record); 16967 return openUntitledDraftDb().then(function (db) { 16968 return new Promise(function (resolve, reject) { 16969 const tx = db.transaction(UNTITLED_DRAFT_STORE, "readwrite"); 16970 tx.objectStore(UNTITLED_DRAFT_STORE).put(normalized); 16971 tx.oncomplete = function () { 16972 resolve(normalized); 16973 }; 16974 tx.onerror = function () { 16975 reject(tx.error); 16976 }; 16977 }); 16978 }); 16979 } 16980 16981 function untitledDraftDeleteFromDb(id) { 16982 if (!canUseUntitledDraftStorage()) return Promise.resolve(); 16983 return openUntitledDraftDb().then(function (db) { 16984 return new Promise(function (resolve, reject) { 16985 const tx = db.transaction(UNTITLED_DRAFT_STORE, "readwrite"); 16986 tx.objectStore(UNTITLED_DRAFT_STORE).delete(id); 16987 tx.oncomplete = function () { 16988 resolve(); 16989 }; 16990 tx.onerror = function () { 16991 reject(tx.error); 16992 }; 16993 }); 16994 }); 16995 } 16996 16997 function mergeUntitledDraftLists(primary, backup) { 16998 const byId = new Map();
16999 (Array.isArray(primary) ? primary : []).forEach( 17000 function (item, index) { 17001 const draft = normalizeUntitledDraftRecord( 17002 item, 17003 "Untitled " + (index + 1), 17004 ); 17005 byId.set(draft.id, draft); 17006 }, 17007 ); 17008 (Array.isArray(backup) ? backup : []).forEach(function (item, index) { 17009 const draft = normalizeUntitledDraftRecord( 17010 item, 17011 "Untitled " + (index + 1), 17012 ); 17013 const existing = byId.get(draft.id); 17014 if (!existing || draft.updatedAt > existing.updatedAt) { 17015 byId.set(draft.id, draft); 17016 } 17017 }); 17018 return sortUntitledDrafts(Array.from(byId.values())); 17019 } 17020 17021 async function restoreUntitledDraftsFromStorage(sessionMeta) { 17022 const backupDrafts = readUntitledDraftBackup(); 17023 let storedDrafts = []; 17024 if (canUseUntitledDraftStorage()) { 17025 try { 17026 storedDrafts = await untitledDraftGetAllFromDb(); 17027 } catch (error) { 17028 console.warn("[drafts] restore failed", error); 17029 } 17030 } 17031 17032 state.untitledDrafts = mergeUntitledDraftLists( 17033 storedDrafts, 17034 backupDrafts, 17035 ); 17036 17037 if (state.untitledDrafts.length) { 17038 const preferredId = 17039 sessionMeta && 17040 typeof sessionMeta.untitledDraftId === "string" && 17041 sessionMeta.untitledDraftId 17042 ? sessionMeta.untitledDraftId 17043 : ""; 17044 state.untitledDraftId = getUntitledDraftById(preferredId) 17045 ? preferredId 17046 : state.untitledDrafts[0].id; 17047 writeUntitledDraftBackup(); 17048 } else { 17049 state.untitledDraftId = ""; 17050 } 17051 17052 renderLibraryDrawer(); 17053 return state.untitledDrafts.length > 0; 17054 } 17055 17056 function scheduleUntitledDraftWrite(record) { 17057 const normalized = normalizeUntitledDraftRecord(record); 17058 __pendingUntitledDraftWrites.set(normalized.id, normalized); 17059 writeUntitledDraftBackup(); 17060 if (__pendingUntitledDraftFlush != null) { 17061 window.clearTimeout(__pendingUntitledDraftFlush); 17062 } 17063 __pendingUntitledDraftFlush = window.setTimeout(function () { 17064 flushUntitledDraftWrites().catch(function (error) { 17065 console.warn("[drafts] persist failed", error); 17066 }); 17067 }, 180); 17068 } 17069 17070 function flushUntitledDraftWrites() { 17071 if (__pendingUntitledDraftFlush != null) { 17072 window.clearTimeout(__pendingUntitledDraftFlush); 17073 __pendingUntitledDraftFlush = null; 17074 } 17075 if (!__pendingUntitledDraftWrites.size) { 17076 return Promise.resolve(false); 17077 } 17078 17079 const records = Array.from(__pendingUntitledDraftWrites.values()); 17080 __pendingUntitledDraftWrites.clear(); 17081 writeUntitledDraftBackup(); 17082 if (!canUseUntitledDraftStorage()) { 17083 return Promise.resolve(false); 17084 } 17085 17086 return Promise.all( 17087 records.map(function (record) { 17088 return untitledDraftPutInDb(record); 17089 }), 17090 ).then(function () { 17091 return true; 17092 }); 17093 } 17094 17095 function captureActiveUntitledDraft(options) { 17096 options = options || {}; 17097 const draft = getActiveUntitledDraft(); 17098 if (!draft) return null; 17099 17100 const now = Date.now(); 17101 const next = upsertUntitledDraftInState( 17102 Object.assign({}, draft, { 17103 content: els.editor.value || "", 17104 updatedAt: now, 17105 lastActive: 17106 options.touchLastActive === false ? draft.lastActive : now, 17107 dirty: 17108 options.dirty == null 17109 ? Boolean(state.dirty) 17110 : Boolean(options.dirty), 17111 }), 17112 ); 17113 scheduleUntitledDraftWrite(next); 17114 renderLibraryDrawer(); 17115 return next; 17116 } 17117 17118 function flushActiveUntitledDraft() { 17119 captureActiveUntitledDraft({ touchLastActive: true }); 17120 return flushUntitledDraftWrites(); 17121 } 17122 17123 window.addEventListener("pagehide", function () { 17124 if (isUntitledDraftMode()) { 17125 captureActiveUntitledDraft({ touchLastActive: true }); 17126 } 17127 flushUntitledDraftWrites().catch(function () {}); 17128 }); 17129 17130 function canUsePersistentLibrary() { 17131 return Boolean(window.showDirectoryPicker && window.isSecureContext); 17132 } 17133 17134 function canUseLibraryStorage() { 17135 return typeof indexedDB !== "undefined"; 17136 } 17137 17138 function canUseLibraryFolderImport() { 17139 const input = 17140 els.libraryFolderInput || document.createElement("input"); 17141 return Boolean(input && "webkitdirectory" in input); 17142 } 17143 17144 function canUseAnyLibrary() { 17145 return canUsePersistentLibrary() || canUseLibraryFolderImport(); 17146 } 17147 17148 async function persistLibraryFolderBestEffort(record, label) { 17149 if (!canUseLibraryStorage()) return false;
17150 try { 17151 await libraryPutFolderInDb(record); 17152 await persistLibraryMeta(); 17153 return true; 17154 } catch (error) { 17155 console.warn( 17156 "[library] failed to persist " + (label || "folder"), 17157 error, 17158 ); 17159 return false; 17160 } 17161 } 17162 17163 function libraryIsSupportedFile(name) { 17164 if (typeof libraryCore.isSupportedLibraryFile === "function") { 17165 return libraryCore.isSupportedLibraryFile(name); 17166 } 17167 return /\.(md|markdown|mdown|mkdn|txt)$/i.test(String(name || "")); 17168 } 17169 17170 function normalizeLibraryMetaState(raw) { 17171 if (typeof libraryCore.normalizeLibraryMeta === "function") { 17172 return libraryCore.normalizeLibraryMeta(raw); 17173 } 17174 const source = raw && typeof raw === "object" ? raw : {}; 17175 return { 17176 folderOrder: Array.isArray(source.folderOrder) 17177 ? source.folderOrder.slice() 17178 : [], 17179 expandedPathsByFolder: 17180 source.expandedPathsByFolder && 17181 typeof source.expandedPathsByFolder === "object" 17182 ? source.expandedPathsByFolder 17183 : {}, 17184 collapsedFolderIds: Array.isArray(source.collapsedFolderIds) 17185 ? source.collapsedFolderIds.filter(function (item) { 17186 return typeof item === "string" && item.length > 0; 17187 }) 17188 : [], 17189 lastActiveFile: 17190 source.lastActiveFile && 17191 typeof source.lastActiveFile.folderId === "string" && 17192 typeof source.lastActiveFile.path === "string" 17193 ? { 17194 folderId: source.lastActiveFile.folderId, 17195 path: source.lastActiveFile.path, 17196 } 17197 : null, 17198 }; 17199 } 17200 17201 function shouldAutoRestoreLastFile(input) { 17202 if (typeof libraryCore.shouldAutoRestoreLastFile === "function") { 17203 return libraryCore.shouldAutoRestoreLastFile(input); 17204 } 17205 const source = input && typeof input === "object" ? input : {}; 17206 return Boolean(!source.hasSessionDraft && source.lastActiveFile); 17207 } 17208 17209 function shapeLibraryTree(entries, options) { 17210 if (typeof libraryCore.shapeLibraryTree === "function") { 17211 return libraryCore.shapeLibraryTree(entries, options); 17212 } 17213 return []; 17214 } 17215 17216 function sortLibraryEntries(entries) { 17217 if (typeof libraryCore.sortLibraryEntries === "function") { 17218 return libraryCore.sortLibraryEntries(entries); 17219 } 17220 return Array.isArray(entries) ? entries.slice() : []; 17221 } 17222 17223 function orderLibraryFolders(folders, folderOrder) { 17224 if (typeof libraryCore.orderLibraryFolders === "function") { 17225 return libraryCore.orderLibraryFolders(folders, folderOrder); 17226 } 17227 const order = Array.isArray(folderOrder) ? folderOrder.slice() : []; 17228 return (Array.isArray(folders) ? folders.slice() : []).sort( 17229 function (a, b) { 17230 const aIndex = order.indexOf(a.id); 17231 const bIndex = order.indexOf(b.id); 17232 if (aIndex === -1 && bIndex === -1) { 17233 return (b.addedAt || 0) - (a.addedAt || 0); 17234 } 17235 if (aIndex === -1) return -1; 17236 if (bIndex === -1) return 1; 17237 return aIndex - bIndex; 17238 }, 17239 ); 17240 } 17241 17242 function getLibraryDirectoryPaths(nodes) { 17243 if (typeof libraryCore.getLibraryDirectoryPaths === "function") { 17244 return libraryCore.getLibraryDirectoryPaths(nodes); 17245 } 17246 const paths = []; 17247 17248 function walk(list) { 17249 (Array.isArray(list) ? list : []).forEach(function (node) { 17250 if (!node || node.type !== "directory") return; 17251 if (typeof node.path === "string" && node.path) { 17252 paths.push(node.path); 17253 } 17254 walk(node.children); 17255 }); 17256 } 17257 17258 walk(nodes); 17259 return paths; 17260 } 17261 17262 function getClearedLibraryCollectionState(input) { 17263 if ( 17264 typeof libraryCore.getClearedLibraryCollectionState === "function" 17265 ) { 17266 return libraryCore.getClearedLibraryCollectionState(input); 17267 } 17268 const source = input && typeof input === "object" ? input : {}; 17269 const currentRevision = Number(source.libraryNextScanRevision); 17270 return { 17271 libraryFolders: [], 17272 libraryFolderOrder: [], 17273 libraryExpandedPathsByFolder: {}, 17274 libraryCollapsedFolderIds: [], 17275 libraryActiveFolderId: "", 17276 libraryActiveFilePath: "", 17277 libraryCurrentFileHandle: null, 17278 libraryCurrentFolderHandle: null, 17279 libraryNextScanRevision: Number.isFinite(currentRevision) 17280 ? currentRevision + 1 17281 : 1, 17282 }; 17283 } 17284 17285 function prepareImportedLibraryEntries(files) { 17286 if (typeof libraryCore.prepareImportedLibraryEntries === "function") { 17287 return libraryCore.prepareImportedLibraryEntries(files); 17288 } 17289 const list = Array.isArray(files) ? files : []; 17290 let rootName = ""; 17291 const entries = []; 17292 list.forEach(function (file) { 17293 if (!file) return; 17294 const rawPath = 17295 typeof file.webkitRelativePath === "string" && 17296 file.webkitRelativePath 17297 ? file.webkitRelativePath 17298 : String(file.name || "");
17299 const parts = rawPath.split("/").filter(Boolean); 17300 if (!parts.length) return; 17301 if (!rootName) rootName = parts[0]; 17302 const relativePath = 17303 parts.length > 1 ? parts.slice(1).join("/") : parts[0]; 17304 if (!relativePath || !libraryIsSupportedFile(relativePath)) return; 17305 entries.push({ 17306 path: relativePath, 17307 name: parts[parts.length - 1], 17308 }); 17309 }); 17310 return { 17311 rootName: rootName, 17312 entries: entries, 17313 }; 17314 } 17315 17316 function getActiveLibrarySourceType() { 17317 const folder = state.libraryActiveFolderId 17318 ? getLibraryFolderById(state.libraryActiveFolderId) 17319 : null; 17320 return folder && typeof folder.sourceType === "string" 17321 ? folder.sourceType 17322 : ""; 17323 } 17324 17325 function getReaderRefreshState() { 17326 if (typeof libraryCore.getReaderRefreshState === "function") { 17327 return libraryCore.getReaderRefreshState({ 17328 sourceMode: state.sourceMode, 17329 hasFileHandle: Boolean(state.fileHandle), 17330 hasLibraryFileHandle: Boolean(state.libraryCurrentFileHandle), 17331 librarySourceType: getActiveLibrarySourceType(), 17332 }); 17333 } 17334 17335 const sourceMode = String(state.sourceMode || "editor"); 17336 const hasFileHandle = Boolean(state.fileHandle); 17337 const hasLibraryFileHandle = Boolean(state.libraryCurrentFileHandle); 17338 const librarySourceType = getActiveLibrarySourceType(); 17339 let reloadAction = "none"; 17340 17341 if (hasFileHandle) { 17342 reloadAction = "file-handle"; 17343 } else if ( 17344 sourceMode === "library" && 17345 hasLibraryFileHandle && 17346 librarySourceType === "persistent" 17347 ) { 17348 reloadAction = "library-persistent"; 17349 } else if ( 17350 sourceMode === "library" && 17351 hasLibraryFileHandle && 17352 librarySourceType === "snapshot" 17353 ) { 17354 reloadAction = "library-snapshot"; 17355 } else if (sourceMode === "file") { 17356 reloadAction = "file-picker"; 17357 } 17358 17359 return { 17360 canReload: reloadAction !== "none", 17361 canAutoRefresh: 17362 hasFileHandle || 17363 (sourceMode === "library" && 17364 hasLibraryFileHandle && 17365 librarySourceType === "persistent"), 17366 reloadAction: reloadAction, 17367 }; 17368 } 17369 17370 function getReaderSaveState() { 17371 const hasContent = Boolean((els.editor.value || "").trim()); 17372 if (typeof libraryCore.getReaderSaveState === "function") { 17373 return libraryCore.getReaderSaveState({ 17374 sourceMode: state.sourceMode, 17375 hasFileHandle: Boolean(state.fileHandle), 17376 hasLibraryFileHandle: Boolean(state.libraryCurrentFileHandle), 17377 librarySourceType: getActiveLibrarySourceType(), 17378 hasContent: hasContent, 17379 currentName: state.currentName, 17380 }); 17381 } 17382 17383 if (state.sourceMode === "untitled") { 17384 return { 17385 canSave: true, 17386 saveAction: "pick-save-target", 17387 saveLabel: "Save", 17388 }; 17389 } 17390 17391 if (state.fileHandle) { 17392 return { 17393 canSave: true, 17394 saveAction: "overwrite-handle", 17395 saveLabel: "Save", 17396 }; 17397 } 17398 17399 if ( 17400 state.libraryCurrentFileHandle && 17401 getActiveLibrarySourceType() === "persistent" 17402 ) { 17403 return { 17404 canSave: true, 17405 saveAction: "overwrite-library-handle", 17406 saveLabel: "Save", 17407 }; 17408 } 17409 17410 if (hasContent || state.currentName) { 17411 return { 17412 canSave: true, 17413 saveAction: "pick-save-target", 17414 saveLabel: "Save", 17415 }; 17416 } 17417 17418 return { 17419 canSave: false,
17420 saveAction: "none", 17421 saveLabel: "Save", 17422 }; 17423 } 17424 17425 function getReaderAuthoringState() { 17426 const input = { 17427 sourceMode: state.sourceMode, 17428 hasFileHandle: Boolean(state.fileHandle), 17429 hasLibraryFileHandle: Boolean(state.libraryCurrentFileHandle), 17430 librarySourceType: getActiveLibrarySourceType(), 17431 hasContent: Boolean((els.editor.value || "").trim()), 17432 currentName: state.currentName, 17433 editMode: isEditModeActive(), 17434 dirty: state.dirty, 17435 hasSessionDraft: state.hasSessionDraft, 17436 }; 17437 17438 if (typeof libraryCore.getReaderAuthoringState === "function") { 17439 return libraryCore.getReaderAuthoringState(input); 17440 } 17441 17442 const saveState = getReaderSaveState(); 17443 return { 17444 showSaveButton: input.editMode && saveState.canSave, 17445 showDirtyCue: 17446 !input.editMode && 17447 saveState.canSave && 17448 (input.dirty || input.hasSessionDraft), 17449 saveAction: saveState.saveAction, 17450 saveLabel: saveState.saveLabel, 17451 }; 17452 } 17453 17454 function canUseNativeSavePicker() { 17455 return Boolean(window.showSaveFilePicker && window.isSecureContext); 17456 } 17457 17458 function getSuggestedSaveName() { 17459 const activePath = state.libraryActiveFilePath 17460 ? String(state.libraryActiveFilePath).split("/").pop() 17461 : ""; 17462 let name = String( 17463 state.currentName || activePath || "untitled.md", 17464 ).trim(); 17465 if (!/\.[a-z0-9]+$/i.test(name)) { 17466 name += ".md"; 17467 } 17468 return name; 17469 } 17470 17471 function createMarkdownFileTypes() { 17472 return [ 17473 { 17474 description: "Markdown files", 17475 accept: { 17476 "text/markdown": [".md", ".markdown", ".mdown", ".mkdn"], 17477 "text/plain": [".txt"], 17478 }, 17479 }, 17480 ]; 17481 } 17482 17483 async function queryWritableHandlePermission(handle) { 17484 if (!handle || typeof handle.createWritable !== "function") { 17485 return "denied"; 17486 } 17487 17488 if (typeof handle.queryPermission !== "function") { 17489 return "granted"; 17490 } 17491 17492 try { 17493 return await handle.queryPermission({ mode: "readwrite" }); 17494 } catch (_error) { 17495 return "prompt"; 17496 } 17497 } 17498 17499 async function ensureWritableHandle(handle) { 17500 if (!handle || typeof handle.createWritable !== "function") { 17501 return false; 17502 } 17503 17504 let permission = await queryWritableHandlePermission(handle); 17505 if ( 17506 permission !== "granted" && 17507 typeof handle.requestPermission === "function" 17508 ) { 17509 try { 17510 permission = await handle.requestPermission({ 17511 mode: "readwrite", 17512 }); 17513 } catch (_error) { 17514 permission = "denied"; 17515 } 17516 } 17517 17518 return permission === "granted"; 17519 } 17520 17521 async function writeMarkdownToHandle(handle, markdown) { 17522 const writable = await handle.createWritable(); 17523 await writable.write(markdown); 17524 await writable.close(); 17525 } 17526 17527 function downloadMarkdown(markdown, fileName) { 17528 const blob = new Blob([markdown], { 17529 type: "text/markdown;charset=utf-8", 17530 }); 17531 const url = URL.createObjectURL(blob); 17532 const link = document.createElement("a"); 17533 link.href = url; 17534 link.download = fileName; 17535 document.body.appendChild(link); 17536 link.click(); 17537 link.remove(); 17538 window.setTimeout(function () { 17539 URL.revokeObjectURL(url); 17540 }, 1000); 17541 } 17542 17543 function syncPrimaryFileHandlePersistence(handle) { 17544 if ( 17545 !window.MarkVPWA || 17546 typeof window.MarkVPWA.rememberPrimaryFileHandle !== "function" || 17547 typeof window.MarkVPWA.forgetPrimaryFileHandle !== "function" 17548 ) { 17549 return; 17550 } 17551 17552 if (handle) { 17553 window.MarkVPWA.rememberPrimaryFileHandle(handle); 17554 } else { 17555 window.MarkVPWA.forgetPrimaryFileHandle(); 17556 } 17557 } 17558 17559 function setPrimaryFileHandle(handle, options) { 17560 state.fileHandle = handle || null; 17561 if (!(options && options.skipPersistence)) { 17562 syncPrimaryFileHandlePersistence(state.fileHandle); 17563 } 17564 } 17565 17566 function getWritableSaveTarget(saveState) { 17567 if (saveState.saveAction === "overwrite-handle" && state.fileHandle) { 17568 return { 17569 handle: state.fileHandle, 17570 sourceMode: "handle", 17571 }; 17572 } 17573 17574 if ( 17575 saveState.saveAction === "overwrite-library-handle" && 17576 state.libraryCurrentFileHandle 17577 ) { 17578 return { 17579 handle: state.libraryCurrentFileHandle, 17580 sourceMode: "library", 17581 }; 17582 } 17583 17584 return null; 17585 } 17586 17587 async function flushWritableHandleIfPermitted() { 17588 const saveState = getReaderSaveState(); 17589 const target = getWritableSaveTarget(saveState); 17590 if (!target || (!state.dirty && !state.hasSessionDraft)) { 17591 return false;
17592 } 17593 17594 const permission = await queryWritableHandlePermission(target.handle); 17595 if (permission !== "granted") { 17596 return false; 17597 } 17598 17599 await writeMarkdownToHandle(target.handle, els.editor.value || ""); 17600 state.currentName = target.handle.name || state.currentName; 17601 markDocumentSaved(target.sourceMode, { rerender: false }); 17602 return true; 17603 } 17604 17605 function markDocumentSaved(nextSourceMode, options) { 17606 options = options || {}; 17607 if (nextSourceMode) { 17608 state.sourceMode = nextSourceMode; 17609 } 17610 state.dirty = false; 17611 state.hasSessionDraft = false; 17612 updateUiState(); 17613 17614 if (options.rerender === false) { 17615 writeSessionMeta(); 17616 return; 17617 } 17618 17619 if (nextSourceMode === "handle" || nextSourceMode === "library") { 17620 invalidateLastRenderedDocument(); 17621 render(nextSourceMode); 17622 } else { 17623 writeSessionMeta(); 17624 } 17625 } 17626 17627 function requestLibraryFolderImport(options) { 17628 options = options || {}; 17629 if (!els.libraryFolderInput) return; 17630 17631 state.libraryPendingImportTargetFolderId = String( 17632 options.replaceFolderId || "", 17633 ); 17634 state.libraryPendingImportToken = Date.now() + Math.random(); 17635 const token = state.libraryPendingImportToken; 17636 17637 window.addEventListener( 17638 "focus", 17639 function () { 17640 window.setTimeout(function () { 17641 if (state.libraryPendingImportToken !== token) return; 17642 state.libraryPendingImportTargetFolderId = ""; 17643 state.libraryPendingImportToken = 0; 17644 }, 150); 17645 }, 17646 { once: true }, 17647 ); 17648 17649 els.libraryFolderInput.click(); 17650 } 17651 17652 function readSessionMeta() { 17653 const raw = readStorage(window.sessionStorage, SESSION_META_KEY, ""); 17654 if (!raw) { 17655 return { 17656 hasSessionDraft: false, 17657 sourceMode: "editor", 17658 untitledDraftId: "", 17659 }; 17660 } 17661 try { 17662 const parsed = JSON.parse(raw); 17663 return { 17664 hasSessionDraft: Boolean(parsed && parsed.hasSessionDraft), 17665 sourceMode: 17666 parsed && typeof parsed.sourceMode === "string" 17667 ? parsed.sourceMode 17668 : "editor", 17669 untitledDraftId: 17670 parsed && typeof parsed.untitledDraftId === "string" 17671 ? parsed.untitledDraftId 17672 : "", 17673 }; 17674 } catch (_error) { 17675 return { 17676 hasSessionDraft: false, 17677 sourceMode: "editor", 17678 untitledDraftId: "", 17679 }; 17680 } 17681 } 17682 17683 function writeSessionMeta() { 17684 writeStorage( 17685 window.sessionStorage, 17686 SESSION_META_KEY, 17687 JSON.stringify({ 17688 hasSessionDraft: Boolean(state.hasSessionDraft), 17689 sourceMode: state.sourceMode, 17690 untitledDraftId: state.untitledDraftId || "", 17691 }), 17692 ); 17693 } 17694 17695 function nextLibraryFolderId() { 17696 return ( 17697 "folder_" + 17698 Date.now().toString(36) + 17699 "_" + 17700 Math.random().toString(36).slice(2, 7) 17701 ); 17702 } 17703 17704 function cloneExpandedPathsByFolder(map) { 17705 const output = {}; 17706 Object.keys(map || {}).forEach(function (key) { 17707 output[key] = Array.isArray(map[key]) ? map[key].slice() : []; 17708 }); 17709 return output; 17710 } 17711 17712 function cloneLibraryCollapsedIds(value) { 17713 if (typeof libraryCore.normalizeLibraryFolderIdSet === "function") { 17714 return libraryCore.normalizeLibraryFolderIdSet(value); 17715 } 17716 if (!Array.isArray(value)) return []; 17717 const seen = Object.create(null); 17718 const result = []; 17719 value.forEach(function (item) { 17720 if (typeof item !== "string" || !item) return; 17721 if (seen[item]) return; 17722 seen[item] = true; 17723 result.push(item); 17724 }); 17725 return result; 17726 } 17727 17728 function isLibraryFolderCollapsed(folderId) { 17729 if (!folderId) return false;
17730 const list = state.libraryCollapsedFolderIds; 17731 return Array.isArray(list) && list.indexOf(folderId) !== -1; 17732 } 17733 17734 function setLibraryFolderCollapsed(folderId, collapsed) { 17735 if (!folderId) return false; 17736 const current = Array.isArray(state.libraryCollapsedFolderIds) 17737 ? state.libraryCollapsedFolderIds.slice() 17738 : []; 17739 const index = current.indexOf(folderId); 17740 if (collapsed && index === -1) { 17741 current.push(folderId); 17742 state.libraryCollapsedFolderIds = current; 17743 return true; 17744 } 17745 if (!collapsed && index !== -1) { 17746 current.splice(index, 1); 17747 state.libraryCollapsedFolderIds = current; 17748 return true; 17749 } 17750 return false; 17751 } 17752 17753 function computeLibraryDisplayLabels() { 17754 const seen = Object.create(null); 17755 state.libraryFolders.forEach(function (folder) { 17756 const base = String(folder.name || "Untitled folder"); 17757 seen[base] = (seen[base] || 0) + 1;
17758 folder.displayLabel = 17759 seen[base] === 1 ? base : base + " (" + seen[base] + ")"; 17760 }); 17761 } 17762 17763 function applyLibraryFolderOrder() { 17764 const order = Array.isArray(state.libraryFolderOrder) 17765 ? state.libraryFolderOrder.slice() 17766 : []; 17767 state.libraryFolders = orderLibraryFolders( 17768 state.libraryFolders, 17769 order, 17770 ); 17771 state.libraryFolderOrder = state.libraryFolders.map( 17772 function (folder) { 17773 return folder.id; 17774 }, 17775 ); 17776 computeLibraryDisplayLabels(); 17777 } 17778 17779 function getLibraryFolderById(folderId) { 17780 return state.libraryFolders.find(function (folder) { 17781 return folder.id === folderId; 17782 }); 17783 } 17784 17785 function getLibraryExpandedPaths(folderId) { 17786 return Array.isArray(state.libraryExpandedPathsByFolder[folderId]) 17787 ? state.libraryExpandedPathsByFolder[folderId].slice() 17788 : []; 17789 } 17790 17791 async function clearActiveLibrarySelection(options) { 17792 options = options || {}; 17793 const hadActiveSelection = Boolean( 17794 state.libraryActiveFolderId || 17795 state.libraryActiveFilePath || 17796 state.libraryCurrentFileHandle || 17797 state.libraryCurrentFolderHandle, 17798 ); 17799 17800 state.libraryActiveFolderId = ""; 17801 state.libraryActiveFilePath = ""; 17802 state.libraryCurrentFileHandle = null; 17803 state.libraryCurrentFolderHandle = null; 17804 17805 if (options.renderDrawer) { 17806 renderLibraryDrawer(); 17807 } 17808 17809 if ( 17810 !options.persist || 17811 !hadActiveSelection || 17812 !canUseLibraryStorage() 17813 ) { 17814 return hadActiveSelection; 17815 } 17816 17817 try { 17818 await persistLibraryMeta(); 17819 } catch (error) { 17820 console.warn( 17821 "[library] failed to persist cleared active file", 17822 error, 17823 ); 17824 } 17825 17826 return hadActiveSelection; 17827 } 17828 17829 async function clearLibraryCollection(options) { 17830 options = options || {}; 17831 const nextState = getClearedLibraryCollectionState({ 17832 libraryFolders: state.libraryFolders, 17833 libraryFolderOrder: state.libraryFolderOrder, 17834 libraryExpandedPathsByFolder: state.libraryExpandedPathsByFolder, 17835 libraryCollapsedFolderIds: state.libraryCollapsedFolderIds, 17836 libraryActiveFolderId: state.libraryActiveFolderId, 17837 libraryActiveFilePath: state.libraryActiveFilePath, 17838 libraryCurrentFileHandle: state.libraryCurrentFileHandle, 17839 libraryCurrentFolderHandle: state.libraryCurrentFolderHandle, 17840 libraryNextScanRevision: state.libraryNextScanRevision, 17841 }); 17842 17843 state.libraryFolders = nextState.libraryFolders; 17844 state.libraryFolderOrder = nextState.libraryFolderOrder; 17845 state.libraryExpandedPathsByFolder = 17846 nextState.libraryExpandedPathsByFolder; 17847 state.libraryCollapsedFolderIds = Array.isArray( 17848 nextState.libraryCollapsedFolderIds, 17849 ) 17850 ? nextState.libraryCollapsedFolderIds.slice() 17851 : []; 17852 state.libraryActiveFolderId = nextState.libraryActiveFolderId; 17853 state.libraryActiveFilePath = nextState.libraryActiveFilePath; 17854 state.libraryCurrentFileHandle = nextState.libraryCurrentFileHandle; 17855 state.libraryCurrentFolderHandle = 17856 nextState.libraryCurrentFolderHandle; 17857 state.libraryNextScanRevision = nextState.libraryNextScanRevision; 17858 state.libraryPendingImportTargetFolderId = ""; 17859 state.libraryPendingImportToken = 0; 17860 17861 if (options.renderDrawer) { 17862 renderLibraryDrawer(); 17863 } 17864 17865 if (!options.persist || !canUseLibraryStorage()) { 17866 return; 17867 } 17868 17869 try { 17870 await libraryClearAllFromDb(); 17871 } catch (error) { 17872 console.warn("[library] failed to clear library from db", error); 17873 } 17874 } 17875 17876 function createLibraryFolderRecord(data) { 17877 const record = { 17878 id: data.id, 17879 name: data.name, 17880 displayLabel: data.name, 17881 handle: data.handle || null, 17882 sourceType: 17883 data.sourceType || (data.handle ? "persistent" : "snapshot"), 17884 snapshotFiles: Array.isArray(data.snapshotFiles) 17885 ? data.snapshotFiles.map(function (item) { 17886 return { 17887 path: String(item && item.path ? item.path : ""), 17888 name: String(item && item.name ? item.name : ""), 17889 text: String(item && item.text ? item.text : ""), 17890 lastModified: Number(item && item.lastModified) || 0, 17891 }; 17892 }) 17893 : [], 17894 addedAt: Number(data.addedAt) || Date.now(), 17895 status: "loading", 17896 note: "", 17897 tree: [], 17898 fileHandlesByPath: new Map(), 17899 scanRevision: 0, 17900 lastScannedAt: 0, 17901 }; 17902 return record; 17903 } 17904 17905 function openLibraryDb() { 17906 if (state.libraryDbPromise) return state.libraryDbPromise; 17907 state.libraryDbPromise = new Promise(function (resolve, reject) { 17908 const request = indexedDB.open(LIBRARY_DB_NAME, LIBRARY_DB_VERSION);
17909 request.onupgradeneeded = function () { 17910 const db = request.result; 17911 if (!db.objectStoreNames.contains(LIBRARY_FOLDER_STORE)) { 17912 db.createObjectStore(LIBRARY_FOLDER_STORE, { keyPath: "id" }); 17913 } 17914 if (!db.objectStoreNames.contains(LIBRARY_META_STORE)) { 17915 db.createObjectStore(LIBRARY_META_STORE, { keyPath: "key" }); 17916 } 17917 }; 17918 request.onsuccess = function () { 17919 resolve(request.result); 17920 }; 17921 request.onerror = function () { 17922 reject(request.error); 17923 }; 17924 }); 17925 return state.libraryDbPromise; 17926 } 17927 17928 function libraryGetAllFoldersFromDb() { 17929 return openLibraryDb().then(function (db) { 17930 return new Promise(function (resolve, reject) { 17931 const tx = db.transaction(LIBRARY_FOLDER_STORE, "readonly"); 17932 const request = tx.objectStore(LIBRARY_FOLDER_STORE).getAll(); 17933 request.onsuccess = function () { 17934 resolve(request.result || []); 17935 }; 17936 request.onerror = function () { 17937 reject(request.error); 17938 }; 17939 }); 17940 }); 17941 } 17942 17943 function libraryPutFolderInDb(record) { 17944 return openLibraryDb().then(function (db) { 17945 return new Promise(function (resolve, reject) { 17946 const tx = db.transaction(LIBRARY_FOLDER_STORE, "readwrite"); 17947 tx.objectStore(LIBRARY_FOLDER_STORE).put({ 17948 id: record.id, 17949 name: record.name, 17950 handle: 17951 record.sourceType === "persistent" ? record.handle : null, 17952 sourceType: record.sourceType, 17953 snapshotFiles: 17954 record.sourceType === "snapshot" 17955 ? record.snapshotFiles.map(function (item) { 17956 return { 17957 path: item.path, 17958 name: item.name, 17959 text: item.text, 17960 lastModified: item.lastModified, 17961 }; 17962 }) 17963 : [], 17964 addedAt: record.addedAt, 17965 }); 17966 tx.oncomplete = function () { 17967 resolve(); 17968 }; 17969 tx.onerror = function () { 17970 reject(tx.error); 17971 }; 17972 }); 17973 }); 17974 } 17975 17976 function libraryDeleteFolderFromDb(folderId) { 17977 return openLibraryDb().then(function (db) { 17978 return new Promise(function (resolve, reject) { 17979 const tx = db.transaction(LIBRARY_FOLDER_STORE, "readwrite"); 17980 tx.objectStore(LIBRARY_FOLDER_STORE).delete(folderId); 17981 tx.oncomplete = function () { 17982 resolve(); 17983 }; 17984 tx.onerror = function () { 17985 reject(tx.error); 17986 }; 17987 }); 17988 }); 17989 } 17990 17991 function libraryClearAllFromDb() { 17992 return openLibraryDb().then(function (db) { 17993 return new Promise(function (resolve, reject) { 17994 const tx = db.transaction( 17995 [LIBRARY_FOLDER_STORE, LIBRARY_META_STORE], 17996 "readwrite", 17997 ); 17998 tx.objectStore(LIBRARY_FOLDER_STORE).clear(); 17999 tx.objectStore(LIBRARY_META_STORE).clear(); 18000 tx.oncomplete = function () { 18001 resolve(); 18002 }; 18003 tx.onerror = function () { 18004 reject(tx.error); 18005 }; 18006 }); 18007 }); 18008 } 18009 18010 function libraryReadMetaFromDb() { 18011 return openLibraryDb().then(function (db) { 18012 return new Promise(function (resolve, reject) { 18013 const tx = db.transaction(LIBRARY_META_STORE, "readonly"); 18014 const request = tx.objectStore(LIBRARY_META_STORE).getAll(); 18015 request.onsuccess = function () { 18016 const meta = {};
18017 (request.result || []).forEach(function (item) { 18018 meta[item.key] = item.value; 18019 }); 18020 resolve( 18021 normalizeLibraryMetaState({ 18022 folderOrder: meta.folderOrder, 18023 expandedPathsByFolder: meta.expandedPathsByFolder, 18024 collapsedFolderIds: meta.collapsedFolderIds, 18025 lastActiveFile: meta.lastActiveFile, 18026 }), 18027 ); 18028 }; 18029 request.onerror = function () { 18030 reject(request.error); 18031 }; 18032 }); 18033 }); 18034 } 18035 18036 function persistLibraryMeta() { 18037 const payload = { 18038 folderOrder: state.libraryFolderOrder.slice(), 18039 expandedPathsByFolder: cloneExpandedPathsByFolder( 18040 state.libraryExpandedPathsByFolder, 18041 ), 18042 collapsedFolderIds: cloneLibraryCollapsedIds( 18043 state.libraryCollapsedFolderIds, 18044 ), 18045 lastActiveFile: 18046 state.libraryActiveFolderId && state.libraryActiveFilePath 18047 ? { 18048 folderId: state.libraryActiveFolderId, 18049 path: state.libraryActiveFilePath, 18050 } 18051 : null, 18052 }; 18053 18054 return openLibraryDb().then(function (db) { 18055 return new Promise(function (resolve, reject) { 18056 const tx = db.transaction(LIBRARY_META_STORE, "readwrite"); 18057 const store = tx.objectStore(LIBRARY_META_STORE); 18058 store.put({ key: "folderOrder", value: payload.folderOrder }); 18059 store.put({ 18060 key: "expandedPathsByFolder", 18061 value: payload.expandedPathsByFolder, 18062 }); 18063 store.put({ 18064 key: "collapsedFolderIds", 18065 value: payload.collapsedFolderIds, 18066 }); 18067 store.put({ 18068 key: "lastActiveFile", 18069 value: payload.lastActiveFile, 18070 }); 18071 tx.oncomplete = function () { 18072 resolve(payload); 18073 }; 18074 tx.onerror = function () { 18075 reject(tx.error); 18076 }; 18077 }); 18078 }); 18079 } 18080 18081 async function queryLibraryPermission(handle) { 18082 if (!handle || typeof handle.queryPermission !== "function") { 18083 return "denied"; 18084 } 18085 try { 18086 return await handle.queryPermission({ mode: "read" }); 18087 } catch (_error) { 18088 return "prompt"; 18089 } 18090 } 18091 18092 async function requestLibraryPermission(handle) { 18093 if (!handle || typeof handle.requestPermission !== "function") { 18094 return "denied"; 18095 } 18096 try { 18097 return await handle.requestPermission({ mode: "read" }); 18098 } catch (_error) { 18099 return "denied"; 18100 } 18101 } 18102 18103 function yieldToUi() { 18104 return new Promise(function (resolve) { 18105 window.setTimeout(resolve, 0); 18106 }); 18107 } 18108 18109 function attachLibraryFileHandles(nodes, byPath) { 18110 return nodes.map(function (node) { 18111 if (node.type === "file") { 18112 return Object.assign({}, node, { 18113 handle: byPath.get(node.path) || null, 18114 }); 18115 } 18116 return Object.assign({}, node, { 18117 children: attachLibraryFileHandles(node.children || [], byPath), 18118 }); 18119 }); 18120 } 18121 18122 function stripLibraryFileHandles(nodes) { 18123 return (nodes || []).map(function (node) { 18124 if (node.type === "file") { 18125 return Object.assign({}, node, { 18126 handle: null, 18127 }); 18128 } 18129 return Object.assign({}, node, { 18130 children: stripLibraryFileHandles(node.children || []), 18131 }); 18132 }); 18133 } 18134 18135 function createSnapshotLibraryFileHandle(snapshotFile) { 18136 return { 18137 kind: "file", 18138 name: snapshotFile.name, 18139 getFile: async function () { 18140 return new File([snapshotFile.text], snapshotFile.name, { 18141 type: "text/plain", 18142 lastModified: snapshotFile.lastModified || Date.now(), 18143 }); 18144 }, 18145 }; 18146 } 18147 18148 async function hydrateSnapshotLibraryFolder(folder) { 18149 const snapshotFiles = Array.isArray(folder.snapshotFiles) 18150 ? folder.snapshotFiles.filter(function (item) { 18151 return ( 18152 item && 18153 typeof item.path === "string" && 18154 item.path &&
18155 libraryIsSupportedFile(item.path) 18156 ); 18157 }) 18158 : []; 18159 18160 const fileHandlesByPath = new Map(); 18161 snapshotFiles.forEach(function (item) { 18162 fileHandlesByPath.set( 18163 item.path, 18164 createSnapshotLibraryFileHandle(item), 18165 ); 18166 }); 18167 18168 const tree = attachLibraryFileHandles( 18169 shapeLibraryTree( 18170 snapshotFiles.map(function (item) { 18171 return { 18172 kind: "file", 18173 path: item.path, 18174 }; 18175 }), 18176 { 18177 expandedPaths: getLibraryExpandedPaths(folder.id), 18178 }, 18179 ), 18180 fileHandlesByPath, 18181 ); 18182 18183 folder.tree = tree; 18184 folder.fileHandlesByPath = fileHandlesByPath; 18185 folder.lastScannedAt = Date.now(); 18186 folder.status = tree.length ? "ready" : "empty"; 18187 folder.note = tree.length 18188 ? "Imported snapshot" 18189 : "No markdown files found in this folder."; 18190 18191 if ( 18192 state.libraryActiveFolderId === folder.id && 18193 state.libraryActiveFilePath && 18194 !findLibraryFileNode(tree, state.libraryActiveFilePath) 18195 ) { 18196 await clearActiveLibrarySelection({ 18197 persist: canUseLibraryStorage(), 18198 }); 18199 if (state.sourceMode === "library") { 18200 state.sourceMode = "editor"; 18201 updateUiState(); 18202 } 18203 } 18204 18205 renderLibraryDrawer(); 18206 } 18207 18208 async function scanLibraryFolder(folder) { 18209 if (!folder) return; 18210 if (folder.sourceType === "snapshot") { 18211 await hydrateSnapshotLibraryFolder(folder); 18212 return; 18213 } 18214 18215 const revision = ++state.libraryNextScanRevision; 18216 folder.scanRevision = revision; 18217 folder.status = "loading"; 18218 folder.note = ""; 18219 renderLibraryDrawer(); 18220 18221 const permission = await queryLibraryPermission(folder.handle); 18222 if (permission !== "granted") { 18223 folder.status = "needs-permission"; 18224 folder.tree = stripLibraryFileHandles(folder.tree); 18225 folder.fileHandlesByPath = new Map(); 18226 folder.note = "Access needs to be granted again."; 18227 renderLibraryDrawer(); 18228 return; 18229 } 18230 18231 const result = { 18232 entries: [], 18233 fileHandlesByPath: new Map(), 18234 truncated: false, 18235 }; 18236 18237 async function walk(handle, parentPath, depth) { 18238 if (folder.scanRevision !== revision) { 18239 throw new Error("scan-stale"); 18240 } 18241 if (depth > LIBRARY_LIMITS.maxDepth) { 18242 result.truncated = true; 18243 return; 18244 } 18245 18246 let index = 0; 18247 for await (const [name, childHandle] of handle.entries()) { 18248 if (folder.scanRevision !== revision) { 18249 throw new Error("scan-stale"); 18250 } 18251 if (LIBRARY_IGNORED_NAMES.has(name)) continue; 18252 18253 const nextPath = parentPath ? parentPath + "/" + name : name; 18254 if (childHandle.kind === "directory") { 18255 await walk(childHandle, nextPath, depth + 1); 18256 } else { 18257 if (name.startsWith(".") && !libraryIsSupportedFile(name)) 18258 continue; 18259 if (!libraryIsSupportedFile(name)) continue; 18260 if (result.entries.length >= LIBRARY_LIMITS.maxFiles) { 18261 result.truncated = true; 18262 break; 18263 } 18264 result.entries.push({ 18265 kind: "file", 18266 path: nextPath, 18267 }); 18268 result.fileHandlesByPath.set(nextPath, childHandle); 18269 } 18270 18271 index += 1; 18272 if (index % LIBRARY_LIMITS.yieldEvery === 0) { 18273 await yieldToUi(); 18274 } 18275 } 18276 } 18277 18278 try { 18279 await walk(folder.handle, "", 0); 18280 } catch (error) { 18281 if (error && error.message === "scan-stale") { 18282 return; 18283 } 18284 folder.status = "needs-permission"; 18285 folder.tree = stripLibraryFileHandles(folder.tree); 18286 folder.fileHandlesByPath = new Map(); 18287 folder.note = "This folder could not be read."; 18288 console.warn("[library] folder scan failed", { 18289 folderId: folder.id,
18290 error: error && error.message ? error.message : String(error), 18291 }); 18292 renderLibraryDrawer(); 18293 return; 18294 } 18295 18296 const tree = attachLibraryFileHandles( 18297 shapeLibraryTree(result.entries, { 18298 expandedPaths: getLibraryExpandedPaths(folder.id), 18299 }), 18300 result.fileHandlesByPath, 18301 ); 18302 18303 folder.tree = tree; 18304 folder.fileHandlesByPath = result.fileHandlesByPath; 18305 folder.lastScannedAt = Date.now(); 18306 folder.status = tree.length ? "ready" : "empty"; 18307 folder.note = result.truncated 18308 ? "Showing a bounded slice of this folder." 18309 : tree.length 18310 ? "" 18311 : "No markdown files found in this folder."; 18312 18313 if ( 18314 state.libraryActiveFolderId === folder.id && 18315 state.libraryActiveFilePath && 18316 !findLibraryFileNode(tree, state.libraryActiveFilePath) 18317 ) { 18318 await clearActiveLibrarySelection({ persist: true }); 18319 if (state.sourceMode === "library") { 18320 state.sourceMode = "editor"; 18321 updateUiState(); 18322 } 18323 } 18324 18325 renderLibraryDrawer(); 18326 } 18327 18328 function clampNumber(value, min, max) { 18329 return Math.min(max, Math.max(min, value)); 18330 } 18331 18332 function roundToStep(value, step, min) { 18333 const rounded = Math.round((value - min) / step) * step + min; 18334 return Number(rounded.toFixed(2)); 18335 } 18336 18337 function findMatchingReadingPreset(settings) { 18338 return Object.keys(READING_PRESETS).find(function (name) { 18339 const preset = READING_PRESETS[name]; 18340 return ( 18341 Math.abs(preset.fontSize - settings.fontSize) < 0.001 && 18342 Math.abs(preset.lineHeight - settings.lineHeight) < 0.001 && 18343 Math.abs(preset.measure - settings.measure) < 0.001 18344 ); 18345 }); 18346 } 18347 18348 function normalizeReadingSettings(raw) { 18349 const source = raw && typeof raw === "object" ? raw : {}; 18350 const normalized = { 18351 fontSize: roundToStep( 18352 clampNumber( 18353 Number(source.fontSize) || READING_DEFAULTS.fontSize, 18354 16, 18355 22, 18356 ), 18357 0.5, 18358 16, 18359 ), 18360 lineHeight: roundToStep( 18361 clampNumber( 18362 Number(source.lineHeight) || READING_DEFAULTS.lineHeight, 18363 1.45, 18364 1.9, 18365 ), 18366 0.02, 18367 1.45, 18368 ), 18369 measure: roundToStep( 18370 clampNumber( 18371 Number(source.measure) || READING_DEFAULTS.measure, 18372 560, 18373 820, 18374 ), 18375 20, 18376 560, 18377 ), 18378 }; 18379 18380 // Trust an explicitly-provided named preset (set by preset buttons). 18381 // Only fall back to float-comparison when the source is "custom" or absent. 18382 const srcPreset = source.preset; 18383 if (srcPreset && READING_PRESETS[srcPreset]) { 18384 normalized.preset = srcPreset; 18385 } else { 18386 normalized.preset = 18387 findMatchingReadingPreset(normalized) || "custom"; 18388 } 18389 18390 return normalized; 18391 } 18392 18393 function readReadingSettings() { 18394 const saved = readStorage( 18395 window.localStorage, 18396 READING_SETTINGS_KEY, 18397 "", 18398 ); 18399 if (!saved) { 18400 return normalizeReadingSettings(READING_DEFAULTS); 18401 } 18402 try { 18403 return normalizeReadingSettings(JSON.parse(saved)); 18404 } catch (_error) { 18405 return normalizeReadingSettings(READING_DEFAULTS); 18406 } 18407 } 18408 18409 function saveReadingSettings(settings) { 18410 writeStorage( 18411 window.localStorage, 18412 READING_SETTINGS_KEY, 18413 JSON.stringify({ 18414 preset: settings.preset, 18415 fontSize: settings.fontSize, 18416 lineHeight: settings.lineHeight, 18417 measure: settings.measure, 18418 }), 18419 ); 18420 } 18421 18422 function normalizeReaderPalette(value) { 18423 return readerPaletteApi && 18424 typeof readerPaletteApi.normalizeKey === "function" 18425 ? readerPaletteApi.normalizeKey(value) 18426 : "original"; 18427 } 18428 18429 function readReaderPalette() { 18430 if ( 18431 readerPaletteApi && 18432 typeof readerPaletteApi.readSavedPalette === "function" 18433 ) { 18434 return readerPaletteApi.readSavedPalette(window.localStorage); 18435 } 18436 return normalizeReaderPalette( 18437 readStorage( 18438 window.localStorage, 18439 READER_PALETTE_STORAGE_KEY, 18440 "original", 18441 ), 18442 ); 18443 } 18444 18445 function writeReaderPalette(value) { 18446 if ( 18447 readerPaletteApi && 18448 typeof readerPaletteApi.writeSavedPalette === "function" 18449 ) { 18450 return readerPaletteApi.writeSavedPalette( 18451 window.localStorage, 18452 value, 18453 ); 18454 } 18455 return writeStorage( 18456 window.localStorage, 18457 READER_PALETTE_STORAGE_KEY, 18458 normalizeReaderPalette(value), 18459 ); 18460 } 18461 18462 function readPreferredLightReaderPalette() { 18463 return normalizeReaderPalette( 18464 readStorage( 18465 window.localStorage, 18466 READER_LIGHT_PALETTE_STORAGE_KEY, 18467 "original", 18468 ), 18469 ); 18470 } 18471 18472 function writePreferredLightReaderPalette(value) { 18473 writeStorage( 18474 window.localStorage, 18475 READER_LIGHT_PALETTE_STORAGE_KEY, 18476 normalizeReaderPalette(value), 18477 ); 18478 } 18479 18480 function getGlobalTheme() { 18481 return document.documentElement.getAttribute("data-theme") || "light"; 18482 } 18483 18484 function getEffectiveReaderScheme(palette) { 18485 if ( 18486 readerPaletteApi && 18487 typeof readerPaletteApi.getEffectiveScheme === "function" 18488 ) { 18489 return readerPaletteApi.getEffectiveScheme( 18490 palette || state.readerPalette, 18491 getGlobalTheme(), 18492 ); 18493 } 18494 return getGlobalTheme() === "dark" ? "dark" : "light"; 18495 } 18496 18497 function coerceReaderPaletteForTheme(value, theme) { 18498 const normalized = normalizeReaderPalette(value); 18499 if ( 18500 readerPaletteApi && 18501 typeof readerPaletteApi.coercePaletteForTheme === "function" 18502 ) { 18503 return readerPaletteApi.coercePaletteForTheme( 18504 normalized, 18505 theme, 18506 readPreferredLightReaderPalette(), 18507 ); 18508 } 18509 if (theme === "dark" && normalized !== "original") return "slate"; 18510 if (theme === "light" && normalized === "slate") { 18511 return readPreferredLightReaderPalette(); 18512 } 18513 return normalized; 18514 } 18515 18516 function syncReaderPaletteControls() { 18517 if (!els.readingPaletteButtons.length) return; 18518 const selected = normalizeReaderPalette(state.readerPalette); 18519 els.readingPaletteButtons.forEach(function (button) {
18520 const key = normalizeReaderPalette(button.dataset.readerPalette); 18521 const isSelected = key === selected; 18522 button.setAttribute("aria-checked", String(isSelected)); 18523 button.tabIndex = isSelected ? 0 : -1; 18524 }); 18525 } 18526 18527 function applyReaderPalette(value, options) { 18528 options = options || {}; 18529 const previousPalette = state.readerPalette; 18530 const previousScheme = state.readerPaletteScheme; 18531 const nextPalette = normalizeReaderPalette(value); 18532 state.readerPalette = nextPalette; 18533 state.readerPaletteInitialized = true; 18534 18535 if ( 18536 readerPaletteApi && 18537 typeof readerPaletteApi.applyToRoot === "function" 18538 ) { 18539 state.readerPaletteScheme = readerPaletteApi.applyToRoot( 18540 document.documentElement, 18541 nextPalette, 18542 { globalTheme: getGlobalTheme() }, 18543 ); 18544 } else { 18545 state.readerPaletteScheme = getEffectiveReaderScheme(nextPalette); 18546 document.documentElement.setAttribute( 18547 "data-reader-palette", 18548 nextPalette, 18549 ); 18550 document.documentElement.setAttribute( 18551 "data-reader-scheme", 18552 state.readerPaletteScheme, 18553 ); 18554 } 18555 18556 syncReaderPaletteControls(); 18557 18558 if (options.persist) { 18559 writeReaderPalette(nextPalette); 18560 if (state.readerPaletteScheme === "light") { 18561 writePreferredLightReaderPalette(nextPalette); 18562 } 18563 } 18564 if (options.announce) { 18565 const palette = 18566 readerPaletteApi && 18567 typeof readerPaletteApi.getPalette === "function" 18568 ? readerPaletteApi.getPalette(nextPalette) 18569 : null; 18570 announce( 18571 "Reader palette set to " + 18572 (palette && palette.label ? palette.label : nextPalette) + 18573 ".", 18574 ); 18575 } 18576 if (options.refresh !== false && previousScheme) { 18577 const schemeChanged = previousScheme !== state.readerPaletteScheme; 18578 const paletteChanged = previousPalette !== nextPalette; 18579 if (schemeChanged || paletteChanged) { 18580 scheduleReaderAssetRefresh(schemeChanged ? previousScheme : null); 18581 } 18582 } 18583 if ( 18584 options.syncTheme && 18585 state.readerPaletteScheme !== getGlobalTheme() 18586 ) { 18587 applyTheme(state.readerPaletteScheme, { 18588 persist: true, 18589 skipReaderPalette: true, 18590 }); 18591 } 18592 } 18593 18594 function updateRangeProgress(input) { 18595 const min = Number(input.min) || 0; 18596 const max = Number(input.max) || 100; 18597 const value = Number(input.value) || min; 18598 const percent = 18599 ((value - min) / Math.max(Number.EPSILON, max - min)) * 100; 18600 input.style.setProperty("--range-progress", percent + "%"); 18601 } 18602 18603 function formatReadingValue(key, value) { 18604 if (key === "fontSize") 18605 return value.toFixed(1).replace(".0", "") + " px"; 18606 if (key === "lineHeight") return value.toFixed(2); 18607 return Math.round(value) + " px"; 18608 } 18609 18610 function syncReadingControls() { 18611 const settings = state.readingSettings; 18612 if (!settings) return; 18613 18614 els.readingFontSizeInput.value = String(settings.fontSize); 18615 els.readingLineHeightInput.value = String(settings.lineHeight); 18616 els.readingMeasureInput.value = String(settings.measure); 18617 18618 els.readingFontSizeValue.value = formatReadingValue( 18619 "fontSize", 18620 settings.fontSize, 18621 ); 18622 els.readingFontSizeValue.textContent = els.readingFontSizeValue.value; 18623 els.readingLineHeightValue.value = formatReadingValue( 18624 "lineHeight", 18625 settings.lineHeight, 18626 ); 18627 els.readingLineHeightValue.textContent = 18628 els.readingLineHeightValue.value; 18629 els.readingMeasureValue.value = formatReadingValue( 18630 "measure", 18631 settings.measure, 18632 ); 18633 els.readingMeasureValue.textContent = els.readingMeasureValue.value; 18634 18635 els.readingFontSizeInput.setAttribute( 18636 "aria-valuetext", 18637 formatReadingValue("fontSize", settings.fontSize), 18638 ); 18639 els.readingLineHeightInput.setAttribute( 18640 "aria-valuetext", 18641 formatReadingValue("lineHeight", settings.lineHeight), 18642 ); 18643 els.readingMeasureInput.setAttribute( 18644 "aria-valuetext", 18645 formatReadingValue("measure", settings.measure), 18646 ); 18647 18648 updateRangeProgress(els.readingFontSizeInput); 18649 updateRangeProgress(els.readingLineHeightInput); 18650 updateRangeProgress(els.readingMeasureInput); 18651 18652 const selectedPreset = 18653 settings.preset === "custom" ? "" : settings.preset; 18654 const tabStopPreset = selectedPreset || "balanced"; 18655 18656 els.readingPresetButtons.forEach(function (button) { 18657 const isSelected = button.dataset.preset === selectedPreset; 18658 const isTabStop = button.dataset.preset === tabStopPreset; 18659 button.classList.toggle("is-active", isSelected); 18660 button.setAttribute("aria-checked", String(isSelected)); 18661 button.tabIndex = isTabStop ? 0 : -1; 18662 }); 18663 18664 els.readingPresetCustom.hidden = settings.preset !== "custom"; 18665 } 18666 18667 function applyReadingSettings(settings) { 18668 state.readingSettings = normalizeReadingSettings(settings); 18669 document.documentElement.style.setProperty( 18670 "--reading-font-size", 18671 state.readingSettings.fontSize + "px", 18672 ); 18673 document.documentElement.style.setProperty( 18674 "--reading-line-height", 18675 String(state.readingSettings.lineHeight), 18676 ); 18677 document.documentElement.style.setProperty( 18678 "--reading-measure", 18679 state.readingSettings.measure + "px", 18680 ); 18681 syncReadingControls(); 18682 } 18683 18684 function updateReadingSetting(key, rawValue, options) { 18685 options = options || {}; 18686 const next = Object.assign({}, state.readingSettings, { 18687 [key]: Number(rawValue), 18688 preset: "custom", 18689 }); 18690 applyReadingSettings(next); 18691 if (options.persist) { 18692 saveReadingSettings(state.readingSettings); 18693 } 18694 if (options.announce) { 18695 announce(options.announce); 18696 } 18697 } 18698 18699 function applyReadingPreset(name, options) { 18700 if (!READING_PRESETS[name]) return; 18701 options = options || {}; 18702 applyReadingSettings(
18703 Object.assign({ preset: name }, READING_PRESETS[name]), 18704 ); 18705 if (options.persist !== false) { 18706 saveReadingSettings(state.readingSettings); 18707 } 18708 if (options.announce) { 18709 announce("Reading preset set to " + name + "."); 18710 } 18711 } 18712 18713 function showReadingPanel() { 18714 if (!els.readingPanel.hidden) return; 18715 readingLastFocusedElement = 18716 document.activeElement instanceof HTMLElement 18717 ? document.activeElement 18718 : null; 18719 els.readingPanel.hidden = false; 18720 els.readingPanel.setAttribute("aria-hidden", "false"); 18721 els.readingToggleBtn.setAttribute("aria-expanded", "true"); 18722 18723 window.requestAnimationFrame(function () { 18724 const focusTarget = 18725 els.readingPresetButtons.find(function (button) { 18726 return button.tabIndex === 0; 18727 }) || els.readingFontSizeInput; 18728 focusTarget.focus(); 18729 }); 18730 } 18731 18732 function hideReadingPanel(options) { 18733 options = options || {}; 18734 if (els.readingPanel.hidden) return; 18735 els.readingPanel.hidden = true; 18736 els.readingPanel.setAttribute("aria-hidden", "true"); 18737 els.readingToggleBtn.setAttribute("aria-expanded", "false"); 18738 if (options.restoreFocus === false) return; 18739 if ( 18740 readingLastFocusedElement && 18741 typeof readingLastFocusedElement.focus === "function" 18742 ) { 18743 readingLastFocusedElement.focus(); 18744 } else { 18745 els.readingToggleBtn.focus(); 18746 } 18747 } 18748 18749 function announce(message) { 18750 if (!els.liveRegion) return; 18751 els.liveRegion.textContent = ""; 18752 window.requestAnimationFrame(function () { 18753 els.liveRegion.textContent = message; 18754 }); 18755 } 18756 18757 function isCoarsePointer() { 18758 return ( 18759 window.matchMedia && window.matchMedia("(pointer: coarse)").matches 18760 ); 18761 } 18762 18763 function isEditableElement(element) { 18764 return Boolean( 18765 element && 18766 (element.matches("input, textarea, select, option") || 18767 element.isContentEditable), 18768 ); 18769 } 18770 18771 function formatHighlightCount(count) { 18772 return count + (count === 1 ? " highlight" : " highlights"); 18773 } 18774 18775 function setHighlightStatus(message, options) { 18776 const count = state.currentHighlights.length; 18777 const suffix = 18778 count > 0 18779 ? " ⢠" + formatHighlightCount(count) 18780 : " ⢠no highlights"; 18781 setStatus(message + suffix, options); 18782 } 18783 18784 function randomId(prefix) { 18785 return ( 18786 prefix + 18787 "_" + 18788 Math.random().toString(36).slice(2, 10) + 18789 Date.now().toString(36).slice(-4) 18790 ); 18791 } 18792 18793 function hideHighlightUndoToast() { 18794 window.clearTimeout(state.highlightUndoTimer); 18795 state.highlightUndoTimer = null; 18796 state.highlightUndo = null; 18797 els.highlightUndoToast.hidden = true; 18798 els.highlightUndoToast.classList.add("hidden"); 18799 els.highlightUndoToast.setAttribute("aria-hidden", "true"); 18800 } 18801 18802 function showHighlightUndoToast(message, undoFn) { 18803 hideHighlightUndoToast(); 18804 state.highlightUndo = typeof undoFn === "function" ? undoFn : null; 18805 els.highlightUndoMessage.textContent = message; 18806 els.highlightUndoToast.hidden = false; 18807 els.highlightUndoToast.classList.remove("hidden"); 18808 els.highlightUndoToast.setAttribute("aria-hidden", "false"); 18809 state.highlightUndoTimer = window.setTimeout(function () { 18810 hideHighlightUndoToast(); 18811 }, 4000); 18812 } 18813 18814 function getHighlightStorageTarget() { 18815 if (state.sourceMode === "untitled" && state.untitledDraftId) { 18816 return { 18817 storage: window.localStorage, 18818 key: DRAFT_HIGHLIGHT_STORAGE_KEY, 18819 bucket: "untitled::" + state.untitledDraftId, 18820 scope: "local", 18821 }; 18822 } 18823 18824 if (state.currentName) { 18825 return { 18826 storage: window.localStorage, 18827 key: HIGHLIGHT_STORAGE_KEY,
18828 bucket: "named::" + state.currentName, 18829 scope: "local", 18830 }; 18831 } 18832 18833 return { 18834 storage: window.sessionStorage, 18835 key: DRAFT_HIGHLIGHT_STORAGE_KEY, 18836 bucket: "draft::current", 18837 scope: "session", 18838 }; 18839 } 18840 18841 function readHighlightBuckets(storage, key) { 18842 const raw = readStorage(storage, key, "{}"); 18843 if (!raw) return {}; 18844 try { 18845 const parsed = JSON.parse(raw); 18846 return parsed && typeof parsed === "object" ? parsed : {}; 18847 } catch (_error) { 18848 return {}; 18849 } 18850 } 18851 18852 function writeHighlightBuckets(storage, key, buckets) { 18853 return writeStorage(storage, key, JSON.stringify(buckets)); 18854 } 18855 18856 function readHighlightsForCurrentDocument() { 18857 const target = getHighlightStorageTarget(); 18858 const buckets = readHighlightBuckets(target.storage, target.key); 18859 const list = buckets[target.bucket]; 18860 if (Array.isArray(list)) return list.slice(); 18861 const memoryList = 18862 state.highlightMemoryBuckets[target.scope][target.bucket]; 18863 return Array.isArray(memoryList) ? memoryList.slice() : []; 18864 } 18865 18866 function saveHighlightsForCurrentDocument(records) { 18867 const target = getHighlightStorageTarget(); 18868 const buckets = readHighlightBuckets(target.storage, target.key); 18869 if (records.length) { 18870 buckets[target.bucket] = records; 18871 } else { 18872 delete buckets[target.bucket]; 18873 } 18874 const didPersist = writeHighlightBuckets( 18875 target.storage, 18876 target.key, 18877 buckets, 18878 ); 18879 if (didPersist) { 18880 delete state.highlightMemoryBuckets[target.scope][target.bucket]; 18881 return true; 18882 } 18883 18884 if (records.length) { 18885 state.highlightMemoryBuckets[target.scope][target.bucket] = 18886 records.slice(); 18887 } else { 18888 delete state.highlightMemoryBuckets[target.scope][target.bucket]; 18889 } 18890 return false; 18891 } 18892 18893 function isSelectionInPreview(range) { 18894 if (!range) return false; 18895 const container = 18896 range.commonAncestorContainer.nodeType === Node.TEXT_NODE 18897 ? range.commonAncestorContainer.parentElement 18898 : range.commonAncestorContainer; 18899 return Boolean(container && els.preview.contains(container)); 18900 } 18901 18902 function selectionIntersectsExcludedContent(range) { 18903 return Array.from( 18904 els.preview.querySelectorAll(HIGHLIGHT_EXCLUDED_SELECTOR), 18905 ).some(function (node) { 18906 try { 18907 return range.intersectsNode(node); 18908 } catch (_error) { 18909 return false; 18910 } 18911 }); 18912 } 18913 18914 function findTextModelOffset(model, container, offset) { 18915 if (container.nodeType === Node.TEXT_NODE) { 18916 for (var i = 0; i < model.segments.length; i++) { 18917 if (model.segments[i].node === container) { 18918 return ( 18919 model.segments[i].start + Math.min(offset, container.length) 18920 ); 18921 } 18922 } 18923 return -1; 18924 } 18925 if (offset < container.childNodes.length) { 18926 var child = container.childNodes[offset]; 18927 for (var i = 0; i < model.segments.length; i++) { 18928 if ( 18929 child === model.segments[i].node || 18930 child.contains(model.segments[i].node) 18931 ) { 18932 return model.segments[i].start; 18933 } 18934 } 18935 } 18936 if (offset > 0) { 18937 for (var i = model.segments.length - 1; i >= 0; i--) { 18938 if (container.contains(model.segments[i].node)) { 18939 return model.segments[i].end; 18940 } 18941 } 18942 } 18943 return -1; 18944 } 18945 18946 function getPreviewSelectionOffsets(range) { 18947 var model = state.highlightTextModel || getPreviewTextModel(); 18948 var selectedText = range.toString(); 18949 var startOff = findTextModelOffset( 18950 model, 18951 range.startContainer, 18952 range.startOffset, 18953 );
18954 var endOff = findTextModelOffset( 18955 model, 18956 range.endContainer, 18957 range.endOffset, 18958 ); 18959 18960 if (startOff !== -1 && endOff !== -1 && endOff > startOff) { 18961 return { start: startOff, end: endOff, text: selectedText }; 18962 } 18963 18964 var idx = model.text.indexOf(selectedText); 18965 if (idx !== -1) { 18966 return { 18967 start: idx, 18968 end: idx + selectedText.length, 18969 text: selectedText, 18970 }; 18971 } 18972 return null; 18973 } 18974 18975 function clearSelectionController() { 18976 state.selectionController = null; 18977 } 18978 18979 function captureSelectionController() { 18980 const selection = window.getSelection(); 18981 if (!selection || selection.rangeCount === 0) { 18982 clearSelectionController(); 18983 return null; 18984 } 18985 18986 const range = selection.getRangeAt(0); 18987 if (range.collapsed || !isSelectionInPreview(range)) { 18988 clearSelectionController(); 18989 return null; 18990 } 18991 18992 if (selectionIntersectsExcludedContent(range)) { 18993 clearSelectionController(); 18994 return null; 18995 } 18996 18997 const rawText = range.toString(); 18998 const trimmedText = rawText.trim(); 18999 if (trimmedText.length < MIN_HIGHLIGHT_SELECTION_LENGTH) { 19000 clearSelectionController(); 19001 return null; 19002 } 19003 19004 const offsets = getPreviewSelectionOffsets(range); 19005 if (!offsets) { 19006 clearSelectionController(); 19007 return null; 19008 } 19009 19010 const rect = range.getBoundingClientRect(); 19011 if (!rect || (!rect.width && !rect.height)) { 19012 clearSelectionController(); 19013 return null; 19014 } 19015 19016 state.selectionController = { 19017 text: rawText, 19018 trimmedText: trimmedText, 19019 range: range.cloneRange(), 19020 rect: rect, 19021 start: offsets.start, 19022 end: offsets.end, 19023 }; 19024 return state.selectionController; 19025 } 19026 19027 function hideSelectionBubble() { 19028 window.clearTimeout(state.selectionBubbleTimer); 19029 state.selectionBubbleTimer = null; 19030 els.selectionBubble.classList.remove("is-visible"); 19031 els.selectionBubble.classList.add("hidden"); 19032 els.selectionBubble.hidden = true; 19033 els.selectionBubble.setAttribute("aria-hidden", "true"); 19034 } 19035 19036 function hideContextMenu() { 19037 els.ctxMenu.classList.add("hidden"); 19038 els.ctxMenu.hidden = true; 19039 els.ctxMenu.setAttribute("aria-hidden", "true"); 19040 } 19041 19042 function hideActiveHighlight() { 19043 state.activeHighlightId = ""; 19044 els.highlightRemoveChip.classList.add("hidden"); 19045 els.highlightRemoveChip.hidden = true; 19046 els.highlightRemoveChip.setAttribute("aria-hidden", "true"); 19047 els.preview 19048 .querySelectorAll(".doc-highlight.is-active") 19049 .forEach(function (mark) { 19050 mark.classList.remove("is-active"); 19051 }); 19052 } 19053 19054 function dismissSelectionUi(options) { 19055 options = options || {}; 19056 hideSelectionBubble(); 19057 hideContextMenu(); 19058 hideActiveHighlight(); 19059 if (options.clearSelection !== false) { 19060 clearSelectionController(); 19061 } 19062 } 19063 19064 function clampToViewport(value, size, viewportSize, gutter) { 19065 const min = gutter; 19066 const max = Math.max(gutter, viewportSize - size - gutter); 19067 return Math.min(max, Math.max(min, value)); 19068 } 19069 19070 function positionBubbleAtRect(element, rect, options) { 19071 const opts = options || {}; 19072 const gutter = opts.gutter || 8; 19073 const offset = opts.offset || 10; 19074 const preferBelow = Boolean(opts.preferBelow); 19075 const width = element.offsetWidth; 19076 const height = element.offsetHeight; 19077 const centerX = rect.left + rect.width / 2; 19078 const topAbove = rect.top - height - offset; 19079 const topBelow = rect.bottom + offset; 19080 let top = preferBelow ? topBelow : topAbove; 19081 19082 if (!preferBelow && top < gutter) { 19083 top = topBelow; 19084 } else if ( 19085 preferBelow && 19086 top + height > window.innerHeight - gutter 19087 ) { 19088 top = topAbove; 19089 } 19090 19091 top = clampToViewport(top, height, window.innerHeight, gutter); 19092 const left = clampToViewport( 19093 centerX - width / 2, 19094 width, 19095 window.innerWidth, 19096 gutter, 19097 ); 19098 19099 element.style.left = Math.round(left) + "px"; 19100 element.style.top = Math.round(top) + "px"; 19101 } 19102 19103 function showSelectionBubble() { 19104 if (isCoarsePointer()) return; 19105 const controller = 19106 state.selectionController || captureSelectionController(); 19107 if (!controller) { 19108 hideSelectionBubble(); 19109 return; 19110 } 19111 19112 hideActiveHighlight(); 19113 hideContextMenu(); 19114 els.selectionBubble.hidden = false;
19115 els.selectionBubble.classList.remove("hidden"); 19116 els.selectionBubble.setAttribute("aria-hidden", "false"); 19117 19118 window.requestAnimationFrame(function () { 19119 positionBubbleAtRect(els.selectionBubble, controller.rect, { 19120 offset: 12, 19121 }); 19122 els.selectionBubble.classList.add("is-visible"); 19123 }); 19124 } 19125 19126 function scheduleSelectionBubble() { 19127 if (isCoarsePointer()) return; 19128 window.clearTimeout(state.selectionBubbleTimer); 19129 state.selectionBubbleTimer = window.setTimeout(function () { 19130 if (captureSelectionController()) { 19131 showSelectionBubble(); 19132 } else { 19133 hideSelectionBubble(); 19134 } 19135 }, 80); 19136 } 19137 19138 function showContextMenu(x, y) { 19139 const controller = 19140 state.selectionController || captureSelectionController(); 19141 if (!controller) { 19142 hideContextMenu(); 19143 return; 19144 } 19145 19146 hideActiveHighlight(); 19147 hideSelectionBubble(); 19148 els.ctxMenu.hidden = false; 19149 els.ctxMenu.classList.remove("hidden"); 19150 els.ctxMenu.setAttribute("aria-hidden", "false"); 19151 els.ctxMenu.style.left = Math.round(x) + "px"; 19152 els.ctxMenu.style.top = Math.round(y) + "px"; 19153 19154 window.requestAnimationFrame(function () { 19155 const rect = els.ctxMenu.getBoundingClientRect(); 19156 const left = clampToViewport( 19157 rect.left, 19158 rect.width, 19159 window.innerWidth, 19160 8, 19161 ); 19162 const top = clampToViewport( 19163 rect.top, 19164 rect.height, 19165 window.innerHeight, 19166 8, 19167 ); 19168 els.ctxMenu.style.left = Math.round(left) + "px"; 19169 els.ctxMenu.style.top = Math.round(top) + "px"; 19170 }); 19171 } 19172 19173 async function copySelectedTextToClipboard() { 19174 const controller = 19175 state.selectionController || captureSelectionController(); 19176 if (!controller) { 19177 setStatus("Select text to copy.", { announce: true }); 19178 return; 19179 } 19180 19181 const text = String(controller.text || controller.trimmedText || ""); 19182 const copied = await copyTextToClipboard(text); 19183 if (copied) { 19184 dismissSelectionUi({ clearSelection: false }); 19185 setStatus("Copied selection.", { announce: true }); 19186 return; 19187 } 19188 19189 setStatus("Copy failed. Use Cmd+C or the browser menu.", { 19190 announce: true, 19191 }); 19192 } 19193 19194 function getPreviewTextModel() { 19195 const walker = document.createTreeWalker( 19196 els.preview, 19197 NodeFilter.SHOW_TEXT, 19198 { 19199 acceptNode: function (node) { 19200 if (!node.nodeValue || !node.parentElement) { 19201 return NodeFilter.FILTER_REJECT; 19202 } 19203 if (node.parentElement.closest(HIGHLIGHT_EXCLUDED_SELECTOR)) { 19204 return NodeFilter.FILTER_REJECT; 19205 } 19206 return NodeFilter.FILTER_ACCEPT; 19207 }, 19208 }, 19209 ); 19210 19211 const segments = []; 19212 let text = ""; 19213 let node = walker.nextNode(); 19214 19215 while (node) { 19216 const value = node.nodeValue; 19217 const start = text.length; 19218 text += value; 19219 segments.push({ 19220 node: node, 19221 start: start, 19222 end: text.length, 19223 }); 19224 node = walker.nextNode(); 19225 } 19226 19227 state.highlightTextModel = { 19228 text: text, 19229 segments: segments, 19230 }; 19231 return state.highlightTextModel; 19232 } 19233 19234 function unwrapHighlights() { 19235 const marks = Array.from( 19236 els.preview.querySelectorAll("mark.doc-highlight"), 19237 ); 19238 if (!marks.length) return; 19239 19240 marks.forEach(function (mark) { 19241 const parent = mark.parentNode; 19242 if (!parent) return; 19243 while (mark.firstChild) { 19244 parent.insertBefore(mark.firstChild, mark); 19245 } 19246 parent.removeChild(mark); 19247 }); 19248 els.preview.normalize(); 19249 } 19250 19251 function textMatchesAt(text, record, start, end) { 19252 if (start < 0 || end > text.length || end <= start) return false;
19253 return text.slice(start, end) === record.quote; 19254 } 19255 19256 function findResolvedHighlight(record, model) { 19257 if (!record || !record.quote) return null; 19258 if (textMatchesAt(model.text, record, record.start, record.end)) { 19259 return { 19260 id: record.id, 19261 start: record.start, 19262 end: record.end, 19263 }; 19264 } 19265 19266 const occurrences = []; 19267 let fromIndex = 0; 19268 while (fromIndex <= model.text.length) { 19269 const idx = model.text.indexOf(record.quote, fromIndex); 19270 if (idx === -1) break; 19271 occurrences.push(idx); 19272 fromIndex = idx + Math.max(1, record.quote.length); 19273 } 19274 19275 if (!occurrences.length) { 19276 return null; 19277 } 19278 19279 let best = null; 19280 occurrences.forEach(function (start) { 19281 const end = start + record.quote.length; 19282 let score = 0; 19283 19284 if (record.prefix) { 19285 const actualPrefix = model.text.slice( 19286 Math.max(0, start - record.prefix.length), 19287 start, 19288 ); 19289 if (actualPrefix === record.prefix) score += 4; 19290 } 19291
19292 if (record.suffix) { 19293 const actualSuffix = model.text.slice( 19294 end, 19295 Math.min(model.text.length, end + record.suffix.length), 19296 ); 19297 if (actualSuffix === record.suffix) score += 4; 19298 } 19299 19300 score -= Math.abs(start - (record.start || 0)) / 1000; 19301 if (!best || score > best.score) { 19302 best = { 19303 score: score, 19304 start: start, 19305 end: end, 19306 }; 19307 } 19308 }); 19309 19310 if (!best) return null; 19311 return { 19312 id: record.id, 19313 start: best.start, 19314 end: best.end, 19315 }; 19316 } 19317 19318 function createWrapOperationsForRange(model, resolved) { 19319 const ops = []; 19320 model.segments.forEach(function (segment) { 19321 if ( 19322 resolved.end <= segment.start || 19323 resolved.start >= segment.end 19324 ) { 19325 return; 19326 } 19327 19328 const startOffset = Math.max(0, resolved.start - segment.start); 19329 const endOffset = Math.min( 19330 segment.end - segment.start, 19331 resolved.end - segment.start, 19332 ); 19333 19334 if (endOffset <= startOffset) return; 19335 ops.push({ 19336 node: segment.node, 19337 startOffset: startOffset, 19338 endOffset: endOffset, 19339 globalStart: segment.start + startOffset, 19340 id: resolved.id, 19341 }); 19342 }); 19343 return ops; 19344 } 19345 19346 function applyWrapOperations(operations) { 19347 operations 19348 .slice() 19349 .sort(function (a, b) { 19350 return b.globalStart - a.globalStart; 19351 }) 19352 .forEach(function (operation) { 19353 if (!operation.node || !operation.node.isConnected) return; 19354 const range = document.createRange(); 19355 range.setStart(operation.node, operation.startOffset); 19356 range.setEnd(operation.node, operation.endOffset); 19357 const mark = document.createElement("mark"); 19358 mark.className = "doc-highlight"; 19359 mark.dataset.highlightId = operation.id; 19360 range.surroundContents(mark); 19361 }); 19362 } 19363 19364 function syncActiveHighlightState() { 19365 els.preview 19366 .querySelectorAll(".doc-highlight") 19367 .forEach(function (mark) { 19368 mark.classList.toggle( 19369 "is-active", 19370 mark.dataset.highlightId === state.activeHighlightId, 19371 ); 19372 }); 19373 } 19374 19375 function refreshHighlightRemoveChip() { 19376 if (!state.activeHighlightId) { 19377 hideActiveHighlight(); 19378 return; 19379 } 19380 19381 const marks = Array.from( 19382 els.preview.querySelectorAll( 19383 '.doc-highlight[data-highlight-id="' + 19384 state.activeHighlightId + 19385 '"]', 19386 ), 19387 ); 19388 if (!marks.length) { 19389 hideActiveHighlight(); 19390 return; 19391 } 19392 19393 let union = null; 19394 marks.forEach(function (mark) { 19395 const rect = mark.getBoundingClientRect(); 19396 if (!union) { 19397 union = { 19398 left: rect.left, 19399 top: rect.top, 19400 right: rect.right, 19401 bottom: rect.bottom, 19402 width: rect.width, 19403 height: rect.height, 19404 }; 19405 return; 19406 } 19407 union.left = Math.min(union.left, rect.left); 19408 union.top = Math.min(union.top, rect.top); 19409 union.right = Math.max(union.right, rect.right); 19410 union.bottom = Math.max(union.bottom, rect.bottom); 19411 union.width = union.right - union.left; 19412 union.height = union.bottom - union.top; 19413 }); 19414 19415 els.highlightRemoveChip.hidden = false; 19416 els.highlightRemoveChip.classList.remove("hidden"); 19417 els.highlightRemoveChip.setAttribute("aria-hidden", "false"); 19418 19419 window.requestAnimationFrame(function () { 19420 positionBubbleAtRect(els.highlightRemoveChip, union, { 19421 offset: 12, 19422 }); 19423 }); 19424 } 19425 19426 function activateHighlight(id) { 19427 hideSelectionBubble(); 19428 hideContextMenu(); 19429 state.activeHighlightId = id || ""; 19430 syncActiveHighlightState(); 19431 refreshHighlightRemoveChip(); 19432 } 19433 19434 function applyHighlightsToPreview() {
19435 dismissSelectionUi({ clearSelection: false }); 19436 syncCurrentHighlights(); 19437 unwrapHighlights(); 19438 19439 const model = getPreviewTextModel(); 19440 const resolved = []; 19441 19442 state.currentHighlights.forEach(function (record) { 19443 const match = findResolvedHighlight(record, model); 19444 if (match) resolved.push(match); 19445 }); 19446 19447 resolved.sort(function (a, b) { 19448 return a.start - b.start; 19449 }); 19450 19451 const nonOverlapping = []; 19452 let lastEnd = -1; 19453 resolved.forEach(function (item) { 19454 if (item.start < lastEnd) return; 19455 nonOverlapping.push(item); 19456 lastEnd = item.end; 19457 }); 19458 19459 state.resolvedHighlights = nonOverlapping; 19460 19461 const operations = []; 19462 nonOverlapping.forEach(function (item) { 19463 operations.push.apply( 19464 operations, 19465 createWrapOperationsForRange(model, item), 19466 ); 19467 }); 19468 applyWrapOperations(operations); 19469 19470 if (state.activeHighlightId) { 19471 syncActiveHighlightState(); 19472 refreshHighlightRemoveChip(); 19473 } 19474 } 19475 19476 function syncCurrentHighlights() { 19477 const bucket = getHighlightStorageTarget().bucket; 19478 if (bucket !== state.highlightDocKey) { 19479 state.highlightDocKey = bucket; 19480 state.currentHighlights = readHighlightsForCurrentDocument(); 19481 hideHighlightUndoToast(); 19482 } else { 19483 state.currentHighlights = readHighlightsForCurrentDocument(); 19484 } 19485 } 19486 19487 function createHighlightRecordFromSelection(controller) { 19488 const model = state.highlightTextModel || getPreviewTextModel(); 19489 const start = controller.start; 19490 const end = controller.end; 19491 return { 19492 id: randomId("hl"), 19493 quote: controller.text, 19494 prefix: model.text.slice( 19495 Math.max(0, start - HIGHLIGHT_CONTEXT_WINDOW), 19496 start, 19497 ), 19498 suffix: model.text.slice( 19499 end, 19500 Math.min(model.text.length, end + HIGHLIGHT_CONTEXT_WINDOW), 19501 ), 19502 start: start, 19503 end: end, 19504 createdAt: new Date().toISOString(), 19505 updatedAt: new Date().toISOString(), 19506 }; 19507 } 19508 19509 function clearBrowserSelection() { 19510 const selection = window.getSelection(); 19511 if (selection) selection.removeAllRanges(); 19512 } 19513 19514 function selectionOverlapsHighlight(start, end) { 19515 return state.resolvedHighlights.some(function (item) { 19516 return start < item.end && end > item.start; 19517 }); 19518 } 19519 19520 function persistCurrentHighlights(records) { 19521 state.currentHighlights = records.slice(); 19522 return saveHighlightsForCurrentDocument(state.currentHighlights); 19523 } 19524 19525 function removeHighlightById(id, options) { 19526 options = options || {}; 19527 syncCurrentHighlights(); 19528 const removed = state.currentHighlights.find(function (record) { 19529 return record.id === id; 19530 }); 19531 if (!removed) return; 19532 19533 const next = state.currentHighlights.filter(function (record) { 19534 return record.id !== id; 19535 }); 19536 const didPersist = persistCurrentHighlights(next); 19537 hideActiveHighlight(); 19538 applyHighlightsToPreview(); 19539 19540 if (!options.skipUndo) { 19541 showHighlightUndoToast("Highlight removed.", function () { 19542 const restored = readHighlightsForCurrentDocument(); 19543 if ( 19544 restored.some(function (record) { 19545 return record.id === removed.id; 19546 }) 19547 ) { 19548 return; 19549 } 19550 restored.push(removed); 19551 const didPersist = persistCurrentHighlights(restored); 19552 applyHighlightsToPreview(); 19553 setHighlightStatus( 19554 didPersist 19555 ? "Highlight restored." 19556 : "Highlight restored. Saved only for this session.", 19557 { announce: true }, 19558 ); 19559 }); 19560 } 19561 19562 setHighlightStatus( 19563 didPersist 19564 ? "Highlight removed." 19565 : "Highlight removed. Saved only for this session.", 19566 { announce: true }, 19567 ); 19568 } 19569 19570 function addHighlightFromSelection() { 19571 const controller = 19572 state.selectionController || captureSelectionController(); 19573 if (!controller) return; 19574 19575 if (selectionIntersectsExcludedContent(controller.range)) {
19576 dismissSelectionUi(); 19577 setStatus("Highlighting code blocks is not supported yet.", { 19578 announce: true, 19579 kind: "error", 19580 }); 19581 return; 19582 } 19583 19584 syncCurrentHighlights(); 19585 if (state.currentHighlights.length >= MAX_HIGHLIGHTS_PER_DOCUMENT) { 19586 dismissSelectionUi(); 19587 setStatus("Highlight limit reached for this document.", { 19588 announce: true, 19589 kind: "error", 19590 }); 19591 return; 19592 } 19593 19594 if (selectionOverlapsHighlight(controller.start, controller.end)) { 19595 dismissSelectionUi(); 19596 setStatus("That selection overlaps an existing highlight.", { 19597 announce: true, 19598 kind: "error", 19599 }); 19600 return; 19601 } 19602 19603 const record = createHighlightRecordFromSelection(controller); 19604 const next = state.currentHighlights.concat(record); 19605 const didPersist = persistCurrentHighlights(next); 19606 dismissSelectionUi(); 19607 clearBrowserSelection(); 19608 applyHighlightsToPreview(); 19609 activateHighlight(record.id); 19610 showHighlightUndoToast("Highlight added.", function () { 19611 removeHighlightById(record.id, { skipUndo: true }); 19612 setHighlightStatus("Highlight undone.", { announce: true }); 19613 }); 19614 setHighlightStatus( 19615 didPersist 19616 ? "Highlight added." 19617 : "Highlight added. Saved only for this session.", 19618 { announce: true }, 19619 ); 19620 } 19621 19622 function dispatchSelectionAction(type) { 19623 const controller = 19624 state.selectionController || captureSelectionController(); 19625 if (!controller) return; 19626 dismissSelectionUi({ clearSelection: false }); 19627 document.dispatchEvent( 19628 new CustomEvent("markv:selection-action", { 19629 detail: { 19630 type: type, 19631 text: controller.trimmedText, 19632 }, 19633 }), 19634 ); 19635 clearBrowserSelection(); 19636 clearSelectionController(); 19637 } 19638 19639 function cleanUrl(href) { 19640 if (!href) return ""; 19641 const collapsed = String(href) 19642 .trim() 19643 .replace(/[\u0000-\u001F\u007F-\u009F\s]+/g, ""); 19644 const lower = collapsed.toLowerCase(); 19645 if ( 19646 lower.startsWith("javascript:") || 19647 lower.startsWith("vbscript:") || 19648 lower.startsWith("data:") 19649 ) { 19650 return ""; 19651 } 19652 return String(href).trim(); 19653 } 19654 19655 function isLoopbackHostname(hostname) { 19656 const normalized = String(hostname || "") 19657 .replace(/^\[|\]$/g, "") 19658 .toLowerCase(); 19659 return ( 19660 normalized === "localhost" || 19661 normalized === "127.0.0.1" || 19662 normalized === "::1" 19663 ); 19664 } 19665 19666 function isPrivateUrl(url) { 19667 if (!url) return false; 19668 if (url.origin === window.location.origin) return true; 19669 return ( 19670 (url.protocol === "http:" || url.protocol === "https:") && 19671 isLoopbackHostname(url.hostname) 19672 ); 19673 } 19674 19675 const TRUSTED_EXTERNAL_MEDIA_HOSTS = Object.freeze([ 19676 "github.com", 19677 "raw.githubusercontent.com", 19678 "user-images.githubusercontent.com", 19679 "private-user-images.githubusercontent.com", 19680 "media.githubusercontent.com", 19681 "avatars.githubusercontent.com", 19682 ]); 19683 const TRUSTED_EXTERNAL_MEDIA_HOST_SET = new Set( 19684 TRUSTED_EXTERNAL_MEDIA_HOSTS, 19685 ); 19686 const IMAGE_MEDIA_EXTENSION_PATTERN = 19687 /\.(?:avif|bmp|gif|ico|jpe?g|png|webp)$/i; 19688 const VIDEO_MEDIA_EXTENSION_PATTERN = 19689 /\.(?:m4v|mov|mp4|ogv|webm)$/i; 19690 const EXTERNAL_SVG_EXTENSION_PATTERN = /\.(?:svg|svgz)$/i; 19691 19692 function isTrustedExternalMediaUrl(url) { 19693 return Boolean( 19694 url && 19695 url.protocol === "https:" && 19696 TRUSTED_EXTERNAL_MEDIA_HOST_SET.has( 19697 String(url.hostname || "").toLowerCase(), 19698 ), 19699 ); 19700 } 19701 19702 function isTrustedMarkdownMediaUrl(url) { 19703 return isPrivateUrl(url) || isTrustedExternalMediaUrl(url); 19704 } 19705 19706 function getMarkdownMediaKind(url) { 19707 return VIDEO_MEDIA_EXTENSION_PATTERN.test(url.pathname || "") 19708 ? "video" 19709 : "image"; 19710 } 19711 19712 function resolveUrl(href) { 19713 const cleaned = cleanUrl(href); 19714 if (!cleaned) return null; 19715 try { 19716 return new URL(cleaned, state.baseHref || window.location.href); 19717 } catch (_error) { 19718 return null; 19719 } 19720 } 19721 19722 function resolveLinkHref(href) { 19723 var raw = String(href || "").trim(); 19724 // Keep fragment-only links as-is (anchor navigation) 19725 if (raw.startsWith("#")) return raw; 19726 const resolved = resolveUrl(href); 19727 if (resolved) return resolved.toString(); 19728 return cleanUrl(href); 19729 } 19730 19731 function resolveMarkdownMedia(href) { 19732 const resolved = resolveUrl(href); 19733 if (!resolved || !isTrustedMarkdownMediaUrl(resolved)) return null; 19734 const isExternal = isTrustedExternalMediaUrl(resolved); 19735 if ( 19736 isExternal && 19737 EXTERNAL_SVG_EXTENSION_PATTERN.test(resolved.pathname || "") 19738 ) { 19739 return null; 19740 } 19741 if ( 19742 isExternal && 19743 !IMAGE_MEDIA_EXTENSION_PATTERN.test(resolved.pathname || "") && 19744 !VIDEO_MEDIA_EXTENSION_PATTERN.test(resolved.pathname || "") 19745 ) { 19746 return null; 19747 } 19748 return { 19749 href: resolved.toString(), 19750 kind: getMarkdownMediaKind(resolved), 19751 }; 19752 } 19753 19754 function clampNumber(value, min, max) { 19755 return Math.min(max, Math.max(min, value)); 19756 } 19757 19758 function createIdentityMappedDocument(text) {
19759 var value = String(text || ""); 19760 return { 19761 text: value, 19762 segments: [ 19763 { 19764 outStart: 0, 19765 outEnd: value.length, 19766 srcStart: 0, 19767 srcEnd: value.length, 19768 }, 19769 ], 19770 }; 19771 } 19772 19773 function createMappedBuilder() { 19774 return { 19775 parts: [], 19776 segments: [], 19777 outLength: 0, 19778 }; 19779 } 19780 19781 function appendMappedText(builder, text, srcStart, srcEnd) { 19782 if (!text) return; 19783 builder.parts.push(text); 19784 builder.segments.push({ 19785 outStart: builder.outLength, 19786 outEnd: builder.outLength + text.length, 19787 srcStart: srcStart, 19788 srcEnd: srcEnd, 19789 }); 19790 builder.outLength += text.length; 19791 } 19792 19793 function finishMappedDocument(builder) { 19794 return { 19795 text: builder.parts.join(""), 19796 segments: builder.segments, 19797 }; 19798 } 19799 19800 function findMappedSegmentIndex(segments, offset) { 19801 var low = 0; 19802 var high = segments.length - 1; 19803 19804 while (low <= high) { 19805 var mid = Math.floor((low + high) / 2); 19806 var segment = segments[mid]; 19807 19808 if (offset < segment.outStart) { 19809 high = mid - 1; 19810 } else if (offset > segment.outEnd) { 19811 low = mid + 1; 19812 } else { 19813 return mid; 19814 } 19815 } 19816 19817 return clampNumber(low, 0, Math.max(0, segments.length - 1)); 19818 } 19819 19820 function mapSegmentOffsetToSource(segment, outOffset) { 19821 if (!segment) return 0; 19822 if ( 19823 segment.outEnd <= segment.outStart || 19824 segment.srcEnd <= segment.srcStart 19825 ) { 19826 return segment.srcStart; 19827 } 19828 19829 var clampedOffset = clampNumber( 19830 outOffset, 19831 segment.outStart, 19832 segment.outEnd, 19833 ); 19834 var fraction = 19835 (clampedOffset - segment.outStart) / 19836 Math.max(1, segment.outEnd - segment.outStart); 19837 19838 return Math.round( 19839 segment.srcStart + fraction * (segment.srcEnd - segment.srcStart), 19840 ); 19841 } 19842 19843 function mapDocumentOffsetToSource(doc, offset) { 19844 var segments = doc && doc.segments ? doc.segments : []; 19845 if (!segments.length) return 0; 19846 var maxOffset = segments[segments.length - 1].outEnd; 19847 var clampedOffset = clampNumber(offset, 0, maxOffset); 19848 return mapSegmentOffsetToSource( 19849 segments[findMappedSegmentIndex(segments, clampedOffset)], 19850 clampedOffset, 19851 ); 19852 } 19853 19854 function mapDocumentRangeToSource(doc, start, end) { 19855 return { 19856 start: mapDocumentOffsetToSource(doc, start), 19857 end: mapDocumentOffsetToSource(doc, end), 19858 }; 19859 } 19860 19861 function appendDocumentSlice(builder, doc, start, end) { 19862 if (!doc || end <= start) return; 19863 19864 var segments = doc.segments || []; 19865 if (!segments.length) return; 19866 19867 var index = findMappedSegmentIndex(segments, start); 19868 while (index < segments.length) { 19869 var segment = segments[index]; 19870 var sliceStart = Math.max(start, segment.outStart); 19871 var sliceEnd = Math.min(end, segment.outEnd); 19872 19873 if (sliceEnd > sliceStart) { 19874 appendMappedText( 19875 builder, 19876 doc.text.slice(sliceStart, sliceEnd), 19877 mapSegmentOffsetToSource(segment, sliceStart), 19878 mapSegmentOffsetToSource(segment, sliceEnd), 19879 ); 19880 } 19881 19882 if (segment.outEnd >= end) break; 19883 index += 1; 19884 } 19885 } 19886 19887 function getSourceAttrString(node) { 19888 if (!node || typeof node.__sourceStart !== "number") return ""; 19889 var end = 19890 typeof node.__sourceEnd === "number" 19891 ? node.__sourceEnd 19892 : node.__sourceStart; 19893 return ( 19894 ' data-source-start="' + 19895 node.__sourceStart + 19896 '" data-source-end="' + 19897 end + 19898 '"' 19899 ); 19900 } 19901 19902 function injectSourceAttrs(html, node) { 19903 var attrs = getSourceAttrString(node); 19904 if (!attrs) return html; 19905 return String(html || "").replace( 19906 /^<([a-z][a-z0-9:-]*)(?=[\s>])/i, 19907 "<$1" + attrs, 19908 ); 19909 } 19910 19911 const renderer = new marked.Renderer(); 19912 const defaultCodeRenderer = renderer.code.bind(renderer); 19913 renderer.code = function (code, infostring, escaped) { 19914 var csvSheet = window.MarkVCsvSheet; 19915 19916 if ( 19917 csvSheet && 19918 typeof csvSheet.isCsvInfoString === "function" && 19919 csvSheet.isCsvInfoString(infostring) 19920 ) { 19921 try { 19922 var rendered = csvSheet.renderCsvSheet( 19923 code, 19924 infostring, 19925 escapeHtml, 19926 ); 19927 if (rendered) return rendered; 19928 } catch (_error) { 19929 // Fall through to the normal code block renderer. 19930 } 19931 } 19932 19933 return defaultCodeRenderer(code, infostring, escaped); 19934 }; 19935 const MARKDOWN_ALERTS = { 19936 note: { 19937 label: "Note", 19938 icon: '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.5"/><path d="M8 7.1V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="4.7" r="0.9" fill="currentColor"/></svg>', 19939 }, 19940 tip: { 19941 label: "Tip", 19942 icon: '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M5.5 10.5C5.74 9.76 4 8.66 4 6.5a4 4 0 1 1 8 0c0 2.16-1.74 3.26-1.5 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
19942<path d="M6.1 12.25h3.8M6.55 14h2.9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>', 19943 }, 19944 important: { 19945 label: "Important", 19946 icon: '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M8 1.75 13 5v6L8 14.25 3 11V5L8 1.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M8 5.2v3.9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r="0.85" fill="currentColor"/></svg>', 19947 }, 19948 warning: { 19949 label: "Warning", 19950 icon: '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M8 2.2 14 13H2L8 2.2Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M8 6v3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.5" r="0.85" fill="currentColor"/></svg>', 19951 }, 19952 caution: { 19953 label: "Caution", 19954 icon: '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M5 1.75h6L14.25 5v6L11 14.25H5L1.75 11V5L5 1.75Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="m6.1 6.1 3.8 3.8M9.9 6.1l-3.8 3.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>', 19955 }, 19956 }; 19957 const MARKDOWN_ALERT_KINDS = Object.keys(MARKDOWN_ALERTS); 19958 const MARKDOWN_ALERT_KIND_GROUP = MARKDOWN_ALERT_KINDS.map( 19959 function (kind) { 19960 return kind.toUpperCase(); 19961 }, 19962 ).join("|"); 19963 const MARKDOWN_ALERT_START_PATTERN = new RegExp( 19964 "^ {0,3}>[ \\t]?\\[!(" + MARKDOWN_ALERT_KIND_GROUP + ")\\]", 19965 "im", 19966 ); 19967 const MARKDOWN_ALERT_MARKER_PATTERN = new RegExp( 19968 "^ {0,3}>[ \\t]?\\[!((?:" + 19969 MARKDOWN_ALERT_KIND_GROUP + 19970 "))\\][ \\t]*(.*)$", 19971 "i", 19972 ); 19973 const MARKDOWN_ALERT_BLOCK_PATTERN = /^(?: {0,3}>[^\n]*(?:\n|$))+/; 19974 19975 function renderAnchoredHeadingToken(token) { 19976 if (!token || typeof token.__sourceStart !== "number") return false; 19977 var parser = this.parser; 19978 return injectSourceAttrs( 19979 parser.renderer.heading( 19980 parser.parseInline(token.tokens || []), 19981 token.depth, 19982 unescape( 19983 parser.parseInline(token.tokens || [], parser.textRenderer), 19984 ), 19985 parser.slugger, 19986 ), 19987 token, 19988 ); 19989 } 19990 19991 function renderAnchoredCodeToken(token) { 19992 if (!token || typeof token.__sourceStart !== "number") return false; 19993 return injectSourceAttrs( 19994 this.parser.renderer.code(token.text, token.lang, token.escaped), 19995 token, 19996 ); 19997 } 19998 19999 function renderAnchoredHrToken(token) { 20000 if (!token || typeof token.__sourceStart !== "number") return false; 20001 return injectSourceAttrs(this.parser.renderer.hr(), token); 20002 } 20003 20004 function renderAnchoredTableToken(token) { 20005 if (!token || typeof token.__sourceStart !== "number") return false; 20006 var parser = this.parser; 20007 var header = ""; 20008 var body = ""; 20009 var cell = ""; 20010 var row = ""; 20011 var j = 0; 20012 var k = 0; 20013 20014 for (j = 0; j < token.header.length; j += 1) { 20015 cell += parser.renderer.tablecell( 20016 parser.parseInline(token.header[j].tokens), 20017 { 20018 header: true, 20019 align: token.align[j], 20020 }, 20021 ); 20022 } 20023 header += parser.renderer.tablerow(cell); 20024 20025 for (j = 0; j < token.rows.length; j += 1) { 20026 row = token.rows[j]; 20027 cell = ""; 20028 for (k = 0; k < row.length; k += 1) { 20029 cell += parser.renderer.tablecell( 20030 parser.parseInline(row[k].tokens), 20031 { 20032 header: false, 20033 align: token.align[k], 20034 }, 20035 ); 20036 } 20037 body += parser.renderer.tablerow(cell); 20038 } 20039 20040 return injectSourceAttrs(parser.renderer.table(header, body), token); 20041 } 20042 20043 function renderAnchoredBlockquoteToken(token) { 20044 if (!token || typeof token.__sourceStart !== "number") return false;
20045 return injectSourceAttrs( 20046 this.parser.renderer.blockquote( 20047 this.parser.parse(token.tokens || []), 20048 ), 20049 token, 20050 ); 20051 } 20052 20053 function renderAnchoredHtmlToken(token) { 20054 if (!token || typeof token.__sourceStart !== "number") return false; 20055 return injectSourceAttrs( 20056 this.parser.renderer.html(token.text), 20057 token, 20058 ); 20059 } 20060 20061 function renderAnchoredParagraphToken(token) { 20062 if (!token || typeof token.__sourceStart !== "number") return false; 20063 return injectSourceAttrs( 20064 this.parser.renderer.paragraph( 20065 this.parser.parseInline(token.tokens || []), 20066 ), 20067 token, 20068 ); 20069 } 20070 20071 function renderAnchoredTextToken(token) { 20072 if (!token || typeof token.__sourceStart !== "number") return false; 20073 var parser = this.parser; 20074 var body = token.tokens 20075 ? parser.parseInline(token.tokens) 20076 : token.text; 20077 return injectSourceAttrs(parser.renderer.paragraph(body), token); 20078 } 20079 20080 function renderAnchoredListToken(token) { 20081 if (!token || typeof token.__sourceStart !== "number") return false; 20082 var parser = this.parser; 20083 var ordered = token.ordered; 20084 var start = token.start; 20085 var loose = token.loose; 20086 var body = ""; 20087 20088 for (var j = 0; j < token.items.length; j += 1) { 20089 var item = token.items[j]; 20090 var checked = item.checked; 20091 var task = item.task; 20092 var itemBody = ""; 20093 20094 if (item.task) { 20095 var checkbox = parser.renderer.checkbox(checked); 20096 20097 if (loose) { 20098 if ( 20099 item.tokens.length > 0 && 20100 item.tokens[0].type === "paragraph" 20101 ) { 20102 item.tokens[0].text = checkbox + " " + item.tokens[0].text; 20103 20104 if ( 20105 item.tokens[0].tokens && 20106 item.tokens[0].tokens.length > 0 && 20107 item.tokens[0].tokens[0].type === "text" 20108 ) { 20109 item.tokens[0].tokens[0].text = 20110 checkbox + " " + item.tokens[0].tokens[0].text; 20111 } 20112 } else { 20113 item.tokens.unshift({ 20114 type: "text", 20115 text: checkbox, 20116 }); 20117 } 20118 } else { 20119 itemBody += checkbox; 20120 } 20121 } 20122 20123 itemBody += parser.parse(item.tokens, loose); 20124 body += injectSourceAttrs( 20125 parser.renderer.listitem(itemBody, task, checked), 20126 item, 20127 ); 20128 } 20129 20130 return injectSourceAttrs( 20131 parser.renderer.list(body, ordered, start), 20132 token, 20133 ); 20134 } 20135 20136 marked.use({ 20137 extensions: [ 20138 { 20139 name: "markdown-alert", 20140 level: "block", 20141 start: function (src) { 20142 const match = src.match(MARKDOWN_ALERT_START_PATTERN); 20143 return match ? match.index : undefined; 20144 }, 20145 tokenizer: function (src) { 20146 const blockMatch = MARKDOWN_ALERT_BLOCK_PATTERN.exec(src); 20147 if (!blockMatch) return; 20148 20149 const raw = blockMatch[0]; 20150 const lines = raw.replace(/\n$/, "").split("\n"); 20151 const markerMatch = MARKDOWN_ALERT_MARKER_PATTERN.exec( 20152 lines[0], 20153 ); 20154 if (!markerMatch) return; 20155 20156 const kind = markerMatch[1].toLowerCase(); 20157 const bodyLines = []; 20158 const leadingText = String(markerMatch[2] || "").trim(); 20159 20160 if (leadingText) bodyLines.push(leadingText); 20161 for (let i = 1; i < lines.length; i += 1) { 20162 bodyLines.push(lines[i].replace(/^ {0,3}> ?/, "")); 20163 } 20164 20165 const body = bodyLines.join("\n").replace(/^\n+|\n+$/g, ""); 20166 20167 return { 20168 type: "markdown-alert", 20169 raw: raw, 20170 kind: kind, 20171 tokens: body ? this.lexer.blockTokens(body) : [], 20172 }; 20173 }, 20174 renderer: function (token) { 20175 const config = 20176 MARKDOWN_ALERTS[token.kind] || MARKDOWN_ALERTS.note; 20177 const content = this.parser.parse(token.tokens || []); 20178 return ( 20179 '<aside class="markdown-alert" data-alert-kind="' + 20180 token.kind + 20181 '"' + 20182 getSourceAttrString(token) + 20183 ">" + 20184 '<p class="markdown-alert-title">' + 20185 config.icon + 20186 "<span>" + 20187 config.label + 20188 "</span></p>" + 20189 '<div class="markdown-alert-content">' + 20190 content + 20191 "</div></aside>\n" 20192 ); 20193 }, 20194 }, 20195 { 20196 name: "heading", 20197 renderer: renderAnchoredHeadingToken, 20198 }, 20199 { 20200 name: "code", 20201 renderer: renderAnchoredCodeToken, 20202 }, 20203 { 20204 name: "hr", 20205 renderer: renderAnchoredHrToken, 20206 }, 20207 { 20208 name: "table", 20209 renderer: renderAnchoredTableToken, 20210 }, 20211 { 20212 name: "blockquote", 20213 renderer: renderAnchoredBlockquoteToken, 20214 }, 20215 { 20216 name: "list", 20217 renderer: renderAnchoredListToken, 20218 }, 20219 { 20220 name: "html", 20221 renderer: renderAnchoredHtmlToken, 20222 }, 20223 { 20224 name: "paragraph", 20225 renderer: renderAnchoredParagraphToken, 20226 }, 20227 { 20228 name: "text", 20229 renderer: renderAnchoredTextToken, 20230 }, 20231 ], 20232 }); 20233 20234 // Safe inline & block HTML tags that are standard in markdown documents. 20235 // Anything not on this list gets escaped into a visible raw-html block. 20236 var SAFE_HTML_TAGS = [ 20237 // inline semantic 20238 "kbd", 20239 "sup", 20240 "sub", 20241 "mark", 20242 "abbr", 20243 "ins", 20244 "del", 20245 "small", 20246 "br", 20247 "wbr", 20248 "time", 20249 "cite", 20250 "dfn", 20251 "var", 20252 "samp", 20253 "ruby", 20254 "rt", 20255 "rp", 20256 "bdo", 20257 "bdi", 20258 "q", 20259 "s", 20260 "u", 20261 "b", 20262 "i", 20263 "em", 20264 "strong", 20265 "span", 20266 // disclosure 20267 "details", 20268 "summary", 20269 // description lists 20270 "dl", 20271 "dt", 20272 "dd", 20273 // figures 20274 "figure", 20275 "figcaption", 20276 // footnotes & lists 20277 "section", 20278 "ol", 20279 "ul", 20280 "li", 20281 "a", 20282 // misc safe block 20283 "div", 20284 "p", 20285 "hr", 20286 ]; 20287 var SAFE_TAG_PATTERN = new RegExp( 20288 "^\\s*<\\/?(" + SAFE_HTML_TAGS.join("|") + ")(\\s|>|\\/|$)", 20289 "i", 20290 ); 20291 var DANGEROUS_TAG_PATTERN = 20292 /<(script|style|iframe|object|embed|form|input|textarea|
20292select|button|link|meta|base)\b/i; 20293 var DANGEROUS_ATTR_PATTERN = /\b(on\w+|srcdoc|formaction)\s*=/i; 20294 20295 renderer.html = function (html) { 20296 if ( 20297 SAFE_TAG_PATTERN.test(html) && 20298 !DANGEROUS_TAG_PATTERN.test(html) && 20299 !DANGEROUS_ATTR_PATTERN.test(html) 20300 ) { 20301 return html; 20302 } 20303 return ( 20304 '<pre class="raw-html"><code>' + 20305 escapeHtml(html) + 20306 "</code></pre>\n" 20307 ); 20308 }; 20309 20310 renderer.link = function (href, title, text) { 20311 const resolved = resolveLinkHref(href); 20312 if (!resolved) return text; 20313 let out = '<a href="' + escapeHtml(resolved) + '"'; 20314 if (title) out += ' title="' + escapeHtml(title) + '"'; 20315 out += ' referrerpolicy="no-referrer"'; 20316 out += ">" + text + "</a>"; 20317 return out; 20318 }; 20319 20320 renderer.image = function (href, title, text) { 20321 const media = resolveMarkdownMedia(href); 20322 if (!media) { 20323 return ( 20324 '<span class="blocked-resource">Remote media blocked for privacy' + 20325 (text ? ": " + escapeHtml(text) : ".") + 20326 "</span>" 20327 ); 20328 } 20329 if (media.kind === "video") { 20330 let video = 20331 '<video src="' + 20332 escapeHtml(media.href) + 20333 '" controls preload="metadata" playsinline'; 20334 if (title) video += ' title="' + escapeHtml(title) + '"'; 20335 if (text) video += ' aria-label="' + escapeHtml(text) + '"'; 20336 video += "></video>"; 20337 return video; 20338 } 20339 let out = 20340 '<img src="' + 20341 escapeHtml(media.href) + 20342 '" alt="' + 20343 escapeHtml(text || "") + 20344 '"'; 20345 if (title) out += ' title="' + escapeHtml(title) + '"'; 20346 out += 20347 ' loading="lazy" decoding="async" referrerpolicy="no-referrer">'; 20348 return out; 20349 }; 20350 20351 renderer.listitem = function (text, task) { 20352 return task 20353 ? '<li class="task-list-item">' + text + "</li>\n" 20354 : "<li>" + text + "</li>\n"; 20355 }; 20356 20357 // Task markers are intentionally static glyphs. The preview is read-only, 20358 // so we avoid interactive form controls and expose only status semantics. 20359 renderer.checkbox = function (checked) { 20360 return ( 20361 '<span class="task-list-item-checkbox' + 20362 (checked ? " is-checked" : "") + 20363 '" role="img" aria-label="' + 20364 (checked ? "Checked" : "Not checked") + 20365 '">' + 20366 (checked 20367 ? '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M3.75 8.25L6.5 11L12.25 5.25" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' 20368 : "") + 20369 "</span>" 20370 ); 20371 }; 20372 20373 const markedOptions = { 20374 gfm: true, 20375 breaks: true, 20376 mangle: false, 20377 headerIds: true, 20378 smartLists: true, 20379 renderer: renderer, 20380 }; 20381 20382 function nowString() { 20383 return new Date().toLocaleTimeString([], { 20384 hour: "2-digit", 20385 minute: "2-digit", 20386 second: "2-digit", 20387 }); 20388 } 20389 20390 function setStatus(message, options) { 20391 options = options || {}; 20392 els.status.textContent = message; 20393 els.status.title = message; 20394 els.status.dataset.kind = options.kind || "default"; 20395 if (options.announce) announce(message); 20396 } 20397 20398 function hideAppToast(immediate) { 20399 if (!els.appToast) return; 20400 window.clearTimeout(state.appToastTimer); 20401 state.appToastTimer = null; 20402 els.appToast.classList.remove("is-visible"); 20403 els.appToast.setAttribute("aria-hidden", "true"); 20404 20405 if (immediate) { 20406 els.appToast.hidden = true; 20407 return; 20408 } 20409 20410 state.appToastTimer = window.setTimeout(function () { 20411 els.appToast.hidden = true; 20412 }, 240); 20413 } 20414 20415 function showAppToast(options) { 20416 options = options || {}; 20417 if (!els.appToast || !els.appToastTitle || !els.appToastMessage) { 20418 if (options.message) announce(options.message); 20419 return; 20420 } 20421
20422 const kind = options.kind || "default"; 20423 const eyebrow = options.eyebrow || "MarkV"; 20424 const title = options.title || ""; 20425 const message = options.message || ""; 20426 window.clearTimeout(state.appToastTimer); 20427 state.appToastTimer = null; 20428 20429 if (els.appToastEyebrow) { 20430 els.appToastEyebrow.textContent = eyebrow; 20431 } 20432 els.appToastTitle.textContent = title; 20433 els.appToastMessage.textContent = message; 20434 els.appToast.dataset.kind = kind; 20435 els.appToast.setAttribute( 20436 "role", 20437 kind === "error" ? "alert" : "status", 20438 ); 20439 els.appToast.setAttribute( 20440 "aria-live", 20441 kind === "error" ? "assertive" : "polite", 20442 ); 20443 els.appToast.setAttribute("aria-hidden", "false"); 20444 els.appToast.hidden = false; 20445 20446 window.requestAnimationFrame(function () { 20447 els.appToast.classList.add("is-visible"); 20448 }); 20449 20450 if (options.announce) { 20451 announce([title, message].filter(Boolean).join(". ")); 20452 } 20453 20454 state.appToastTimer = window.setTimeout(function () { 20455 hideAppToast(false); 20456 }, options.duration || 5200); 20457 } 20458 20459 function showShareFailureToast(message) { 20460 const detail = String(message || "Snapshot sharing failed.").trim(); 20461 setStatus("Share failed", { kind: "error" }); 20462 showAppToast({ 20463 eyebrow: "Share", 20464 title: "Share failed", 20465 message: detail, 20466 kind: "error", 20467 duration: 6500, 20468 announce: true, 20469 }); 20470 } 20471 20472 function waitForNextPaint() { 20473 return new Promise(function (resolve) { 20474 if (typeof requestAnimationFrame !== "function") { 20475 window.setTimeout(resolve, 0); 20476 return; 20477 } 20478 requestAnimationFrame(function () { 20479 requestAnimationFrame(resolve); 20480 }); 20481 }); 20482 } 20483 20484 function getSnapshotBaseUrl() { 20485 const url = new URL(window.location.href); 20486 url.search = ""; 20487 url.hash = ""; 20488 return url.toString(); 20489 } 20490 20491 function updateAddressBarHash(hash) { 20492 const nextHash = String(hash || ""); 20493 const url = new URL(window.location.href); 20494 url.hash = nextHash ? nextHash.slice(1) : ""; 20495 history.replaceState(null, "", url.toString()); 20496 } 20497 20498 function clearSnapshotHashIfPresent() { 20499 const legacyPrefix = 20500 typeof libraryCore.SHARE_FRAGMENT_PREFIX === "string" 20501 ? libraryCore.SHARE_FRAGMENT_PREFIX 20502 : "#mkv="; 20503 const shortPrefix = 20504 typeof libraryCore.SHARE_SHORT_FRAGMENT_PREFIX === "string" 20505 ? libraryCore.SHARE_SHORT_FRAGMENT_PREFIX 20506 : "#m:"; 20507 const hash = String(window.location.hash || ""); 20508 if (!hash.startsWith(legacyPrefix) && !hash.startsWith(shortPrefix)) { 20509 return; 20510 } 20511 updateAddressBarHash(""); 20512 } 20513 20514 function detachSharedSnapshotContext(options) { 20515 options = options || {}; 20516 state.sharedSnapshotMeta = null; 20517 if (!options.preserveHash) { 20518 clearSnapshotHashIfPresent(); 20519 } 20520 } 20521 20522 function legacyCopyText(text) { 20523 const input = document.createElement("textarea"); 20524 input.value = text; 20525 input.setAttribute("readonly", "readonly"); 20526 input.style.position = "fixed"; 20527 input.style.left = "-9999px"; 20528 input.style.top = "0"; 20529 document.body.appendChild(input); 20530 input.focus(); 20531 input.select(); 20532 20533 let copied = false; 20534 try { 20535 copied = document.execCommand("copy"); 20536 } catch (_error) { 20537 copied = false; 20538 } 20539 20540 input.remove(); 20541 return copied; 20542 } 20543 20544 async function copyTextToClipboard(text) { 20545 if ( 20546 navigator.clipboard && 20547 typeof navigator.clipboard.writeText === "function" && 20548 window.isSecureContext 20549 ) { 20550 try { 20551 await navigator.clipboard.writeText(text); 20552 return true; 20553 } catch (_error) {} 20554 } 20555 20556 return legacyCopyText(text); 20557 } 20558 20559 async function buildSnapshotShareUrl() { 20560 const text = String(els.editor.value || ""); 20561 if (!text.trim()) { 20562 throw new Error("There is no markdown to share yet."); 20563 } 20564 20565 if (typeof libraryCore.createShareSnapshotFragment !== "function") { 20566 throw new Error("Snapshot sharing is unavailable in this build."); 20567 } 20568 20569 const fragment = await libraryCore.createShareSnapshotFragment({ 20570 text: text, 20571 name: state.currentName, 20572 view: isEditModeActive() ? "edit" : "preview", 20573 }); 20574 const shareUrl = getSnapshotBaseUrl() + fragment; 20575 20576 if (shareUrl.length > MAX_SHARE_URL_LENGTH) { 20577 throw new Error( 20578 "This document is too large for link sharing. Use Save or Download instead.", 20579 ); 20580 } 20581 20582 return { 20583 fragment: fragment, 20584 url: shareUrl, 20585 }; 20586 } 20587 20588 async function applySharedSnapshotPayload(payload) { 20589 const next = payload && typeof payload === "object" ? payload : null; 20590 if (!next || typeof next.text !== "string") return false;
20591 20592 await flushActiveUntitledDraft().catch(function (error) { 20593 console.warn("[drafts] active draft flush failed", error); 20594 }); 20595 stopAutoRefresh(); 20596 els.autoRefreshToggle.checked = false; 20597 20598 setPrimaryFileHandle(null); 20599 state.untitledDraftId = ""; 20600 state.sourceMode = "editor"; 20601 state.currentName = String(next.name || "").trim(); 20602 state.baseHref = getSnapshotBaseUrl(); 20603 state.dirty = false; 20604 state.hasSessionDraft = true; 20605 detachSharedSnapshotContext({ preserveHash: true }); 20606 state.sharedSnapshotMeta = { 20607 loadedAt: Date.now(), 20608 view: next.view === "edit" ? "edit" : "preview", 20609 }; 20610 20611 await clearActiveLibrarySelection({ 20612 persist: true, 20613 renderDrawer: true, 20614 }); 20615 20616 setEditorMarkdown(next.text); 20617 updateUiState(); 20618 render("editor"); 20619 setMode(state.sharedSnapshotMeta.view); 20620 20621 setStatus( 20622 (state.currentName || "Shared snapshot") + 20623 " ⢠loaded from shared link ⢠use Share to create a fresh link", 20624 { announce: true }, 20625 ); 20626 return true; 20627 } 20628 20629 function clearLocationSearch() { 20630 const url = new URL(window.location.href); 20631 if (!url.search) return; 20632 url.search = ""; 20633 history.replaceState(null, "", url.toString()); 20634 } 20635 20636 async function applySharedIncomingContent(payload) { 20637 const next = payload && typeof payload === "object" ? payload : null; 20638 if (!next) return false; 20639 20640 const title = 20641 typeof next.title === "string" ? String(next.title).trim() : ""; 20642 const text = typeof next.text === "string" ? next.text : ""; 20643 const sharedUrl = 20644 typeof next.url === "string" ? String(next.url).trim() : ""; 20645 if (!title && !text && !sharedUrl) return false; 20646 20647 const sections = []; 20648 if (title) sections.push("# " + title); 20649 if (text) sections.push(text); 20650 if (sharedUrl) sections.push("<" + sharedUrl + ">"); 20651 20652 const markdown = sections.join("\n\n").trim(); 20653 if (!markdown) return false; 20654 20655 await flushActiveUntitledDraft().catch(function (error) { 20656 console.warn("[drafts] active draft flush failed", error); 20657 }); 20658 stopAutoRefresh(); 20659 els.autoRefreshToggle.checked = false; 20660 detachSharedSnapshotContext(); 20661 20662 setPrimaryFileHandle(null); 20663 state.untitledDraftId = ""; 20664 state.sourceMode = "editor"; 20665 state.currentName = title || "Shared note"; 20666 state.baseHref = getSnapshotBaseUrl(); 20667 state.dirty = false; 20668 state.hasSessionDraft = true; 20669 20670 await clearActiveLibrarySelection({ 20671 persist: true, 20672 renderDrawer: true, 20673 }); 20674 20675 setEditorMarkdown(markdown); 20676 updateUiState(); 20677 render("editor"); 20678 clearLocationSearch(); 20679 setStatus( 20680 (state.currentName || "Shared note") + 20681 " ⢠loaded from share target ⢠" + 20682 nowString(), 20683 { announce: true }, 20684 ); 20685 return true; 20686 } 20687 20688 async function shareCurrentSnapshot() { 20689 if (state.shareInFlight) return; 20690 20691 const shareState = 20692 typeof libraryCore.getShareSnapshotState === "function" 20693 ? libraryCore.getShareSnapshotState({ 20694 text: els.editor.value || "", 20695 currentName: state.currentName, 20696 view: isEditModeActive() ? "edit" : "preview", 20697 }) 20698 : { 20699 canShare: Boolean((els.editor.value || "").trim()), 20700 reason: (els.editor.value || "").trim() ? "ok" : "empty", 20701 mayTakeTime: false, 20702 }; 20703 20704 if (!shareState.canShare) { 20705 showShareFailureToast( 20706 shareState.reason === "unsupported" 20707 ? "Snapshot sharing is unavailable in this browser." 20708 : "There is no markdown to share yet.", 20709 ); 20710 return; 20711 } 20712 20713 state.shareInFlight = true; 20714 state.sharePendingMayTakeTime = Boolean(shareState.mayTakeTime); 20715 updateUiState(); 20716 setStatus( 20717 shareState.mayTakeTime 20718 ? "Preparing snapshot link for a large document..." 20719 : "Preparing snapshot link...", 20720 { 20721 announce: true, 20722 kind: "progress", 20723 }, 20724 ); 20725 20726 try { 20727 if (shareState.mayTakeTime) { 20728 await waitForNextPaint(); 20729 } 20730 20731 const snapshot = await buildSnapshotShareUrl(); 20732 const isLongShareUrl = 20733 snapshot.url.length > LONG_SHARE_URL_WARNING_LENGTH; 20734 updateAddressBarHash(snapshot.fragment); 20735 20736 const shareData = { 20737 title: state.currentName || "MarkV snapshot", 20738 text: "Open this markdown snapshot in MarkV", 20739 url: snapshot.url, 20740 }; 20741 let canUseNativeShare = Boolean(navigator.share); 20742 if (canUseNativeShare && typeof navigator.canShare === "function") { 20743 try { 20744 canUseNativeShare = 20745 navigator.canShare({ url: snapshot.url }) || 20746 navigator.canShare(shareData); 20747 } catch (_error) { 20748 canUseNativeShare = false;
20749 } 20750 } 20751 20752 if (canUseNativeShare) { 20753 try { 20754 await navigator.share(shareData); 20755 setStatus( 20756 (isLongShareUrl 20757 ? "Long snapshot link shared" 20758 : "Snapshot link shared") + 20759 " ⢠" + 20760 nowString(), 20761 { 20762 announce: true, 20763 }, 20764 ); 20765 return; 20766 } catch (error) { 20767 if (error && error.name === "AbortError") { 20768 setStatus("Share cancelled ⢠" + nowString(), { 20769 announce: true, 20770 }); 20771 return; 20772 } 20773 } 20774 } 20775 20776 const copied = await copyTextToClipboard(snapshot.url); 20777 if (!copied) { 20778 throw new Error( 20779 "Clipboard access is unavailable in this browser.", 20780 ); 20781 } 20782 20783 setStatus( 20784 (isLongShareUrl 20785 ? "Long snapshot link copied" 20786 : "Snapshot link copied ⢠share again after edits") + 20787 " ⢠" + 20788 nowString(), 20789 { 20790 announce: true, 20791 }, 20792 ); 20793 } catch (error) { 20794 if (error && error.name === "AbortError") { 20795 setStatus("Share cancelled ⢠" + nowString(), { 20796 announce: true, 20797 }); 20798 return; 20799 } 20800 showShareFailureToast( 20801 error && error.message ? error.message : String(error), 20802 ); 20803 } finally { 20804 state.shareInFlight = false; 20805 state.sharePendingMayTakeTime = false; 20806 updateUiState(); 20807 } 20808 } 20809 20810 function closeGlobalSettingsSurface() { 20811 const drawer = document.getElementById("globalSettingsDrawer"); 20812 const overlay = document.getElementById("globalSettingsOverlay"); 20813 const openBtn = document.getElementById("globalSettingsBtn"); 20814 if (!drawer || !overlay || !openBtn) return; 20815 drawer.classList.remove("is-open"); 20816 overlay.classList.remove("is-open"); 20817 drawer.setAttribute("aria-hidden", "true"); 20818 overlay.setAttribute("aria-hidden", "true"); 20819 openBtn.setAttribute("aria-expanded", "false"); 20820 } 20821 20822 function getLibraryFocusableElements() { 20823 if (!syncLibraryRefs()) return []; 20824 return Array.prototype.slice 20825 .call( 20826 els.libraryDrawer.querySelectorAll( 20827 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', 20828 ), 20829 ) 20830 .filter(function (element) { 20831 return !element.hidden && element.offsetParent !== null; 20832 }); 20833 } 20834 20835 function getLibraryCollapseSummary() { 20836 const summary = { 20837 hasFolders: false, 20838 hasCollapsed: false, 20839 hasUncollapsed: false, 20840 }; 20841 20842 state.libraryFolders.forEach(function (folder) { 20843 if (!folder || !folder.id) return; 20844 summary.hasFolders = true; 20845 if (isLibraryFolderCollapsed(folder.id)) { 20846 summary.hasCollapsed = true; 20847 } else { 20848 summary.hasUncollapsed = true; 20849 } 20850 }); 20851 20852 return summary; 20853 } 20854 20855 function updateLibraryToggleAllBtn(summary) { 20856 const btn = els.libraryToggleAllBtn; 20857 if (!btn) return; 20858 if (!summary.hasFolders) { 20859 btn.hidden = true; 20860 return; 20861 } 20862 btn.hidden = false; 20863 const allCollapsed = summary.hasCollapsed && !summary.hasUncollapsed; 20864 const label = allCollapsed ? "Expand all" : "Collapse all"; 20865 const path = allCollapsed ? "M3 4.5 6 7.5l3-3" : "M3 7.5 6 4.5l3 3"; 20866 btn.dataset.mode = allCollapsed ? "expand" : "collapse"; 20867 btn.setAttribute("aria-label", label); 20868 btn.setAttribute("title", label); 20869 const labelEl = btn.querySelector(".lib-toggle-all-label"); 20870 if (labelEl) labelEl.textContent = label; 20871 const pathEl = btn.querySelector("svg path"); 20872 if (pathEl) pathEl.setAttribute("d", path); 20873 } 20874 20875 function renderLibraryDraftsSection() { 20876 const drafts = sortUntitledDrafts(state.untitledDrafts
20876); 20877 if (!drafts.length) return ""; 20878 20879 const rows = drafts 20880 .map(function (draft) { 20881 const isActive = 20882 state.sourceMode === "untitled" && 20883 state.untitledDraftId === draft.id; 20884 const title = draft.title || "Untitled"; 20885 const discardLabel = "Discard " + title; 20886 return ( 20887 '<div class="lib-draft-node">' + 20888 '<button type="button" class="lib-draft-row' + 20889 (isActive ? " is-active" : "") + 20890 '" data-kind="draft" data-action="open-draft" data-draft-id="' + 20891 escapeHtml(draft.id) + 20892 '" data-dirty="' + 20893 (draft.dirty ? "true" : "false") + 20894 '" title="' + 20895 escapeHtml(title) + 20896 '">' + 20897 '<span class="lib-draft-dot" aria-hidden="true"></span>' + 20898 '<span class="lib-draft-label">' + 20899 escapeHtml(title) + 20900 "</span>" + 20901 "</button>" + 20902 '<button type="button" class="lib-draft-discard" data-action="discard-draft" data-draft-id="' + 20903 escapeHtml(draft.id) + 20904 '" aria-label="' + 20905 escapeHtml(discardLabel) + 20906 '" title="' + 20907 escapeHtml(discardLabel) + 20908 '">' + 20909 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>' + 20910 "</button>" + 20911 "</div>" 20912 ); 20913 }) 20914 .join(""); 20915 20916 return ( 20917 '<section class="lib-drafts" aria-label="Drafts">' + 20918 '<span class="lib-section-label">Drafts</span>' + 20919 rows + 20920 "</section>" 20921 ); 20922 } 20923 20924 function renderLibraryTreeNodes(nodes, folderId, depth) { 20925 return (nodes || []) 20926 .map(function (node) { 20927 const padding = 32 + depth * 18; 20928 if (node.type === "directory") { 20929 return ( 20930 '<div class="lib-tree-node">' + 20931 '<button type="button" class="lib-tree-row" data-kind="directory" data-action="toggle-dir" data-folder-id="' + 20932 escapeHtml(folderId) + 20933 '" data-path="' + 20934 escapeHtml(node.path) + 20935 '" aria-expanded="' + 20936 String(Boolean(node.expanded)) + 20937 '" style="padding-left:' + 20938 padding + 20939 'px">' + 20940 '<span class="lib-tree-caret" aria-hidden="true">
20940â¸</span>' + 20941 '<span class="lib-tree-label">' + 20942 escapeHtml(node.name) + 20943 "</span>" + 20944 "</button>" + 20945 (node.expanded 20946 ? '<div class="lib-tree-children">' + 20947 renderLibraryTreeNodes( 20948 node.children || [], 20949 folderId, 20950 depth + 1, 20951 ) + 20952 "</div>" 20953 : "") + 20954 "</div>" 20955 ); 20956 } 20957 20958 const isActive = 20959 state.libraryActiveFolderId === folderId && 20960 state.libraryActiveFilePath === node.path; 20961 20962 return ( 20963 '<div class="lib-tree-node">' + 20964 '<button type="button" class="lib-tree-row' + 20965 (isActive ? " is-active" : "") + 20966 '" data-kind="file" data-action="open-file" data-folder-id="' + 20967 escapeHtml(folderId) + 20968 '" data-path="' + 20969 escapeHtml(node.path) + 20970 '" style="padding-left:' + 20971 padding + 20972 'px">' + 20973 '<span class="lib-tree-caret" aria-hidden="true">·</span>' + 20974 '<span class="lib-tree-label">' + 20975 escapeHtml(node.name) + 20976 "</span>" + 20977 "</button>" + 20978 "</div>" 20979 ); 20980 }) 20981 .join(""); 20982 } 20983 20984 function renderLibraryDrawer() { 20985 if (!syncLibraryRefs()) return; 20986 if (!els.libraryBody) return; 20987 20988 const persistentSupported = canUsePersistentLibrary(); 20989 const importSupported = canUseLibraryFolderImport(); 20990 const supported = persistentSupported || importSupported; 20991 els.libraryAddBtn.disabled = 20992 !supported || 20993 state.libraryFolders.length >= LIBRARY_LIMITS.maxFolders; 20994 const collapseSummary = getLibraryCollapseSummary(); 20995 updateLibraryToggleAllBtn(collapseSummary); 20996 20997 if (!supported) { 20998 els.librarySupportedNote.hidden = false; 20999 els.librarySupportedNote.textContent = 21000 "This browser cannot open folders here yet."; 21001 } else if (state.libraryFolders.length >= LIBRARY_LIMITS.maxFolders) { 21002 els.librarySupportedNote.hidden = false; 21003 els.librarySupportedNote.textContent = 21004 "The v1 library is capped at " + 21005 LIBRARY_LIMITS.maxFolders + 21006 " folders."; 21007 } else if (!persistentSupported && importSupported) { 21008 els.librarySupportedNote.hidden = false; 21009 els.librarySupportedNote.textContent = 21010 "This browser imports a folder snapshot rather than keeping a live folder connection."; 21011 } else { 21012 els.librarySupportedNote.hidden = true; 21013 els.librarySupportedNote.textContent = ""; 21014 } 21015 21016 const draftsHtml = renderLibraryDraftsSection(); 21017 if (!state.libraryFolders.length) { 21018 els.libraryBody.innerHTML = 21019 draftsHtml || els.libraryEmpty.outerHTML; 21020 return; 21021 } 21022 21023 const html = state.libraryFolders 21024 .map(function (folder) { 21025 const statusLabel = 21026 folder.status === "loading" 21027 ? "Scanning folder" 21028 : folder.status === "needs-permission" 21029 ? "Permission needed" 21030 : folder.status === "empty" 21031 ? "Empty folder" 21032 : folder.sourceType === "snapshot" 21033 ? "Imported snapshot" 21034 : "Ready"; 21035 const collapsed = isLibraryFolderCollapsed(folder.id); 21036 const toggleLabel = 21037 (collapsed ? "Expand " : "Collapse ") + 21038 (folder.displayLabel || folder.name || "folder"); 21039 const toggleBtn = 21040 '<button type="button" class="lib-folder-toggle" data-action="toggle-folder" data-folder-id="' + 21041 escapeHtml(folder.id) + 21042 '" aria-expanded="' + 21043 (collapsed ? "false" : "true") + 21044 '" aria-label="' + 21045 escapeHtml(toggleLabel) + 21046 '" title="' + 21047 escapeHtml(toggleLabel) + 21048 '">' + 21049 '<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 3.5 8 6l-3.5 2.5"/></svg>
21049' + 21050 "</button>"; 21051 const actions = 21052 '<div class="lib-folder-actions">' + 21053 (folder.status === "needs-permission" 21054 ? '<button type="button" class="lib-action-btn" data-action="reauthorize-folder" data-folder-id="' + 21055 escapeHtml(folder.id) + 21056 '">Reauthorize</button>' 21057 : folder.sourceType === "persistent" 21058 ? '<button type="button" class="lib-action-btn" data-action="refresh-folder" data-folder-id="' + 21059 escapeHtml(folder.id) + 21060 '">Refresh</button>' 21061 : "") + 21062 '<button type="button" class="lib-action-btn" data-action="remove-folder" data-folder-id="' + 21063 escapeHtml(folder.id) + 21064 '">Remove</button>' + 21065 "</div>"; 21066 21067 const content = collapsed 21068 ? "" 21069 : folder.status === "ready" 21070 ? '<div class="lib-tree">' + 21071 renderLibraryTreeNodes(folder.tree || [], folder.id, 0
21071) + 21072 "</div>" 21073 : folder.status === "empty" 21074 ? '<div class="lib-tree-empty">' + 21075 escapeHtml( 21076 folder.note || 21077 "No markdown files found in this folder.", 21078 ) + 21079 "</div>" 21080 : folder.note 21081 ? '<div class="lib-tree-empty">' + 21082 escapeHtml(folder.note) + 21083 "</div>" 21084 : ""; 21085 21086 return ( 21087 '<section class="lib-folder" data-folder-id="' + 21088 escapeHtml(folder.id) + 21089 '" data-collapsed="' + 21090 (collapsed ? "true" : "false") + 21091 '" draggable="true">' + 21092 '<div class="lib-folder-head" data-action="toggle-folder" data-folder-id="' + 21093 escapeHtml(folder.id) + 21094 '">' + 21095 '<div class="lib-folder-top">' + 21096 toggleBtn + 21097 '<div class="lib-folder-meta">' + 21098 '<h3 class="lib-folder-name">' + 21099 escapeHtml(folder.displayLabel || folder.name) + 21100 "</h3>" + 21101 '<p class="lib-folder-sub">' + 21102 escapeHtml(folder.name) + 21103 "</p>" + 21104 '<span class="lib-folder-status" data-status="' + 21105 escapeHtml(folder.status) + 21106 '">' + 21107 escapeHtml(statusLabel) + 21108 "</span>" + 21109 actions + 21110 "</div>" + 21111 "</div>" + 21112 "</div>" + 21113 content + 21114 "</section>" 21115 ); 21116 }) 21117 .join(""); 21118 21119 els.libraryBody.innerHTML = draftsHtml + html; 21120 } 21121 21122 function showLibraryDrawer() { 21123 if (!syncLibraryRefs()) return; 21124 if (!els.libraryDrawer || state.libraryIsOpen) return; 21125 closeGlobalSettingsSurface(); 21126 state.libraryDrawerLastFocused = 21127 document.activeElement instanceof HTMLElement 21128 ? document.activeElement 21129 : null; 21130 state.libraryIsOpen = true; 21131 els.libraryDrawer.classList.add("is-open"); 21132 els.libraryOverlay.classList.add("is-open"); 21133 els.libraryDrawer.setAttribute("aria-hidden", "false"); 21134 els.libraryOverlay.setAttribute("aria-hidden", "false"); 21135 els.libraryToggleBtn.setAttribute("aria-expanded", "true"); 21136 document.body.classList.add("library-open"); 21137 renderLibraryDrawer(); 21138 window.requestAnimationFrame(function () { 21139 const focusTarget = 21140 els.openFileBtn || 21141 (els.libraryAddBtn.disabled 21142 ? els.libraryCloseBtn 21143 : els.libraryAddBtn); 21144 focusTarget.focus(); 21145 }); 21146 } 21147 21148 function hideLibraryDrawer(options) { 21149 options = options || {}; 21150 if (!syncLibraryRefs()) return; 21151 if (!els.libraryDrawer || !state.libraryIsOpen) return; 21152 let target = 21153 options.restoreFocus === false 21154 ? null 21155 : state.libraryDrawerLastFocused; 21156 if ( 21157 target && 21158 (!document.contains(target) || 21159 target === document.body || 21160 els.libraryDrawer.contains(target)) 21161 ) { 21162 target = null; 21163 } 21164 if (!target && options.restoreFocus !== false) { 21165 target = els.libraryToggleBtn; 21166 } 21167 if ( 21168 document.activeElement instanceof HTMLElement && 21169 els.libraryDrawer.contains(document.activeElement) 21170 ) { 21171 document.activeElement.blur(); 21172 } 21173 if (target && typeof target.focus === "function") { 21174 target.focus(); 21175 } 21176 state.libraryIsOpen = false; 21177 els.libraryDrawer.classList.remove("is-open"); 21178 els.libraryOverlay.classList.remove("is-open"); 21179 els.libraryDrawer.setAttribute("aria-hidden", "true"); 21180 els.libraryOverlay.setAttribute("aria-hidden", "true"); 21181 els.libraryToggleBtn.setAttribute("aria-expanded", "false"); 21182 document.body.classList.remove("library-open"); 21183 } 21184 21185 function wireLibraryUi() { 21186 if (libraryUiWired || !syncLibraryRefs()) return; 21187 libraryUiWired = true; 21188 21189 els.libraryToggleBtn.addEventListener("click", function () { 21190 if (state.libraryIsOpen) { 21191 hideLibraryDrawer(); 21192 return; 21193 } 21194 showLibraryDrawer(); 21195 }); 21196 els.libraryCloseBtn.addEventListener("click", function () { 21197 hideLibraryDrawer(); 21198 }); 21199 els.libraryOverlay.addEventListener("click", function () { 21200 hideLibraryDrawer(); 21201 }); 21202 els.openFileBtn.addEventListener("click", async function () { 21203 await openFile(); 21204 }); 21205 els.libraryAddBtn.addEventListener("click", async function () { 21206 await addLibraryFolder(); 21207 }); 21208 els.libraryToggleAllBtn.addEventListener("click", function () { 21209 const summary = getLibraryCollapseSummary(); 21210 const allCollapsed = 21211 summary.hasCollapsed && !summary.hasUncollapsed; 21212 setAllLibraryFoldersCollapsed(!allCollapsed); 21213 }); 21214 els.libraryBody.addEventListener("click", async function (event) { 21215 const trigger = event.target.closest("[data-action]"); 21216 if (!trigger || !els.libraryBody.contains(trigger)) return; 21217 21218 const action = trigger.getAttribute("data-action"); 21219 const folderId = trigger.getAttribute("data-folder-id") || ""; 21220 const path = trigger.getAttribute("data-path") || ""; 21221 const draftId = trigger.getAttribute("data-draft-id") || ""; 21222 21223 if (action === "open-draft") { 21224 await loadUntitledDraft(draftId, { 21225 userInitiated: true, 21226 }); 21227 return; 21228 } 21229 if (action === "discard-draft") { 21230 await discardUntitledDraft(draftId); 21231 return; 21232 } 21233 if (action === "toggle-folder") { 21234 toggleLibraryFolderCollapsed(folderId); 21235 return; 21236 } 21237 if (action === "toggle-dir") { 21238 toggleLibraryDirectory(folderId, path); 21239 return; 21240 } 21241 if (action === "open-file") { 21242 await loadFromLibrarySelection(folderId, path, { 21243 userInitiated: true, 21244 }); 21245 return; 21246 } 21247 if (action === "refresh-folder") { 21248 await refreshLibraryFolder(folderId); 21249 return; 21250 } 21251 if (action === "reauthorize-folder") { 21252 await reauthorizeLibraryFolder(folderId); 21253 return; 21254 } 21255 if (action === "remove-folder") { 21256 await removeLibraryFolder(folderId); 21257 } 21258 }); 21259 let libraryDragId = ""; 21260 function cleanupLibraryDragState() { 21261 libraryDragId = ""; 21262 els.libraryBody 21263 .querySelectorAll( 21264 ".lib-folder[data-dragging], .lib-folder[data-drop]", 21265 ) 21266 .forEach(function (s) { 21267 s.removeAttribute("data-dragging"); 21268 s.removeAttribute("data-drop"); 21269 }); 21270 } 21271 21272 els.libraryBody.addEventListener("dragstart", function (event) { 21273 const section = 21274 event.target.closest && event.target.closest(".lib-folder"); 21275 if (!section) return; 21276 // Don't start a drag from inner buttons; let them handle clicks.
21277 if (event.target.closest && event.target.closest("button")) { 21278 event.preventDefault(); 21279 return; 21280 } 21281 const id = section.getAttribute("data-folder-id") || ""; 21282 if (!id) return; 21283 libraryDragId = id; 21284 section.setAttribute("data-dragging", "true"); 21285 if (event.dataTransfer) { 21286 event.dataTransfer.effectAllowed = "move"; 21287 try { 21288 event.dataTransfer.setData("text/plain", id); 21289 } catch (_) {} 21290 } 21291 }); 21292 21293 els.libraryBody.addEventListener("dragover", function (event) { 21294 if (!libraryDragId) return; 21295 const section = 21296 event.target.closest && event.target.closest(".lib-folder"); 21297 if (!section) return; 21298 const targetId = section.getAttribute("data-folder-id"); 21299 if (!targetId || targetId === libraryDragId) return; 21300 event.preventDefault(); 21301 const rect = section.getBoundingClientRect(); 21302 const isAfter = event.clientY > rect.top + rect.height / 2; 21303 els.libraryBody 21304 .querySelectorAll(".lib-folder[data-drop]") 21305 .forEach(function (s) { 21306 if (s !== section) s.removeAttribute("data-drop"); 21307 }); 21308 section.setAttribute("data-drop", isAfter ? "after" : "before"); 21309 if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; 21310 }); 21311 21312 els.libraryBody.addEventListener("dragleave", function (event) { 21313 const section = 21314 event.target.closest && event.target.closest(".lib-folder"); 21315 if (!section) return; 21316 if (!section.contains(event.relatedTarget)) { 21317 section.removeAttribute("data-drop"); 21318 } 21319 }); 21320 21321 els.libraryBody.addEventListener("drop", function (event) { 21322 if (!libraryDragId) return; 21323 const section = 21324 event.target.closest && event.target.closest(".lib-folder"); 21325 if (!section) { 21326 cleanupLibraryDragState(); 21327 return; 21328 } 21329 const targetId = section.getAttribute("data-folder-id"); 21330 const position = section.getAttribute("data-drop") || "before"; 21331 event.preventDefault(); 21332 const fromId = libraryDragId; 21333 cleanupLibraryDragState(); 21334 if (targetId && targetId !== fromId) { 21335 if (moveLibraryFolder(fromId, targetId, position)) { 21336 setStatus("Library folders reordered", { announce: true }); 21337 } 21338 } 21339 }); 21340 21341 els.libraryBody.addEventListener("dragend", function () { 21342 cleanupLibraryDragState(); 21343 }); 21344 21345 els.libraryBody.addEventListener("keydown", function (event) { 21346 if (!event.altKey) return; 21347 if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; 21348 const head = 21349 event.target.closest && event.target.closest(".lib-folder-head"); 21350 if (!head) return; 21351 const folderId = head.getAttribute("data-folder-id"); 21352 if (!folderId) return; 21353 event.preventDefault(); 21354 const offset = event.key === "ArrowUp" ? -1 : 1; 21355 if (moveLibraryFolderByOffset(folderId, offset)) { 21356 setStatus("Library folders reordered", { announce: true }); 21357 window.requestAnimationFrame(function () { 21358 const next = els.libraryBody.querySelector( 21359 '.lib-folder[data-folder-id="' + 21360 folderId.replace(/"/g, '\\"') + 21361 '"] .lib-folder-toggle', 21362 ); 21363 if (next && typeof next.focus === "function") next.focus(); 21364 }); 21365 } 21366 }); 21367 21368 els.libraryDrawer.addEventListener("keydown", function (event) { 21369 if (event.key === "Escape") { 21370 event.preventDefault(); 21371 event.stopPropagation(); 21372 hideLibraryDrawer(); 21373 return; 21374 } 21375 if (event.key !== "Tab") return; 21376 21377 const focusables = getLibraryFocusableElements(); 21378 if (!focusables.length) return; 21379 21380 const first = focusables[0]; 21381 const last = focusables[focusables.length - 1]; 21382 if (event.shiftKey && document.activeElement === first) { 21383 event.preventDefault(); 21384 last.focus(); 21385 } else if (!event.shiftKey && document.activeElement === last) { 21386 event.preventDefault(); 21387 first.focus(); 21388 } 21389 }); 21390 21391 renderLibraryDrawer(); 21392 } 21393 21394 function rebuildLibraryFolderTree(folder, expandedPaths) { 21395 const fileHandlesByPath = 21396 folder && 21397 folder.fileHandlesByPath && 21398 typeof folder.fileHandlesByPath.keys === "function" 21399 ? folder.fileHandlesByPath 21400 : new Map(); 21401 21402 return attachLibraryFileHandles( 21403 shapeLibraryTree( 21404 Array.from(fileHandlesByPath.keys()).map(function (item) { 21405 return { kind: "file", path: item }; 21406 }), 21407 { expandedPaths: expandedPaths }, 21408 ), 21409 fileHandlesByPath, 21410 ); 21411 } 21412 21413 function setAllLibraryFoldersCollapsed(collapsed) { 21414 let changed = false;
21415 21416 state.libraryFolders.forEach(function (folder) { 21417 if (!folder || !folder.id) return; 21418 if (setLibraryFolderCollapsed(folder.id, collapsed)) { 21419 changed = true; 21420 } 21421 }); 21422 21423 if (!changed) return; 21424 renderLibraryDrawer(); 21425 21426 if (canUseLibraryStorage()) { 21427 persistLibraryMeta().catch(function (error) { 21428 console.warn( 21429 "[library] could not persist bulk collapse state", 21430 error, 21431 ); 21432 }); 21433 } 21434 setStatus( 21435 collapsed 21436 ? "Library folders collapsed" 21437 : "Library folders expanded", 21438 { announce: true }, 21439 ); 21440 } 21441 21442 function toggleLibraryFolderCollapsed(folderId) { 21443 if (!folderId) return; 21444 const next = !isLibraryFolderCollapsed(folderId); 21445 if (!setLibraryFolderCollapsed(folderId, next)) return; 21446 renderLibraryDrawer(); 21447 if (canUseLibraryStorage()) { 21448 persistLibraryMeta().catch(function (error) { 21449 console.warn( 21450 "[library] could not persist folder collapse state", 21451 error, 21452 ); 21453 }); 21454 } 21455 } 21456 21457 function moveLibraryFolder(fromId, toId, position) { 21458 if (!fromId || !toId || fromId === toId) return false; 21459 const ids = state.libraryFolders.map(function (f) { 21460 return f.id; 21461 }); 21462 const fromIdx = ids.indexOf(fromId); 21463 if (fromIdx < 0) return false; 21464 ids.splice(fromIdx, 1); 21465 let toIdx = ids.indexOf(toId); 21466 if (toIdx < 0) return false; 21467 if (position === "after") toIdx += 1; 21468 ids.splice(toIdx, 0, fromId); 21469 const byId = new Map( 21470 state.libraryFolders.map(function (f) { 21471 return [f.id, f]; 21472 }), 21473 ); 21474 state.libraryFolders = ids 21475 .map(function (id) { 21476 return byId.get(id); 21477 }) 21478 .filter(Boolean); 21479 state.libraryFolderOrder = ids.slice(); 21480 applyLibraryFolderOrder(); 21481 renderLibraryDrawer(); 21482 if (canUseLibraryStorage()) { 21483 persistLibraryMeta().catch(function (error) { 21484 console.warn("[library] could not persist folder reorder", error); 21485 }); 21486 } 21487 return true; 21488 } 21489 21490 function moveLibraryFolderByOffset(folderId, offset) { 21491 if (!folderId || !offset) return false; 21492 const ids = state.libraryFolders.map(function (f) { 21493 return f.id; 21494 }); 21495 const idx = ids.indexOf(folderId); 21496 if (idx < 0) return false; 21497 const targetIdx = idx + offset; 21498 if (targetIdx < 0 || targetIdx >= ids.length) return false; 21499 const targetId = ids[targetIdx]; 21500 const position = offset < 0 ? "before" : "after"; 21501 return moveLibraryFolder(folderId, targetId, position); 21502 } 21503 21504 function toggleLibraryDirectory(folderId, path) { 21505 const paths = getLibraryExpandedPaths(folderId); 21506 const next = paths.includes(path) 21507 ? paths.filter(function (item) { 21508 return item !== path; 21509 }) 21510 : paths.concat(path); 21511 state.libraryExpandedPathsByFolder[folderId] = next; 21512 const folder = getLibraryFolderById(folderId); 21513 if (!folder) return; 21514 folder.tree = rebuildLibraryFolderTree(folder, next); 21515 if (canUseLibraryStorage()) { 21516 persistLibraryMeta().catch(function (error) { 21517 console.warn("[library] could not persist expanded paths", error); 21518 }); 21519 } 21520 renderLibraryDrawer(); 21521 } 21522 21523 function findLibraryFileNode(nodes, path) { 21524 for (const node of nodes || []) { 21525 if (node.type === "file" && node.path === path) return node; 21526 if (node.type === "directory") { 21527 const found = findLibraryFileNode(node.children || [], path); 21528 if (found) return found; 21529 } 21530 } 21531 return null; 21532 } 21533 21534 async function loadFromLibrarySelection(folderId, path, options) { 21535 options = options || {}; 21536 const folder = getLibraryFolderById(folderId); 21537 if (!folder) return false;
21538 21539 await flushActiveUntitledDraft().catch(function (error) { 21540 console.warn("[drafts] active draft flush failed", error); 21541 }); 21542 21543 if (folder.status === "needs-permission" && folder.handle) { 21544 if (!options.userInitiated) return false; 21545 const permission = await requestLibraryPermission(folder.handle); 21546 if (permission !== "granted") { 21547 folder.note = "Access still needs to be granted."; 21548 renderLibraryDrawer(); 21549 setStatus("Library access was not granted.", { announce: true }); 21550 console.warn("[library] permission request denied", { 21551 folderId: folder.id, 21552 }); 21553 return false; 21554 } 21555 await scanLibraryFolder(folder); 21556 } 21557 21558 const node = findLibraryFileNode(folder.tree, path); 21559 if (!node || !node.handle) { 21560 setStatus("That library file is no longer available.", { 21561 announce: true, 21562 }); 21563 console.warn("[library] missing file in tree", { 21564 folderId: folder.id, 21565 path: path, 21566 }); 21567 return false; 21568 } 21569 21570 const file = await node.handle.getFile(); 21571 const markdown = await file.text(); 21572 21573 stopAutoRefresh(); 21574 els.autoRefreshToggle.checked = false; 21575 detachSharedSnapshotContext(); 21576 21577 setPrimaryFileHandle(null); 21578 state.untitledDraftId = ""; 21579 state.libraryCurrentFileHandle = node.handle; 21580 state.libraryCurrentFolderHandle = folder.handle || null; 21581 state.libraryActiveFolderId = folder.id; 21582 state.libraryActiveFilePath = path; 21583 state.sourceMode = "library"; 21584 state.currentName = file.name; 21585 state.baseHref = window.location.href; 21586 state.dirty = false; 21587 state.hasSessionDraft = false; 21588 21589 setEditorMarkdown(markdown); 21590 updateUiState(); 21591 render("library"); 21592 renderLibraryDrawer(); 21593 21594 if (canUseLibraryStorage()) { 21595 await persistLibraryMeta().catch(function (error) { 21596 console.warn("[library] failed to persist active file", error); 21597 }); 21598 } 21599 21600 if (!options.quiet) { 21601 setStatus("Loaded ⢠" + nowString(), { 21602 announce: true, 21603 }); 21604 } 21605 if (options.closeDrawerOnMobile !== false && isCoarsePointer()) { 21606 hideLibraryDrawer({ restoreFocus: false }); 21607 } 21608 return true; 21609 } 21610 21611 function clearUntitledDraftHighlightBucket(draftId) { 21612 if (!draftId) return; 21613 const buckets = readHighlightBuckets( 21614 window.localStorage, 21615 DRAFT_HIGHLIGHT_STORAGE_KEY, 21616 ); 21617 delete buckets["untitled::" + draftId]; 21618 writeHighlightBuckets( 21619 window.localStorage, 21620 DRAFT_HIGHLIGHT_STORAGE_KEY, 21621 buckets, 21622 ); 21623 delete state.highlightMemoryBuckets.local["untitled::" + draftId]; 21624 } 21625 21626 async function deleteUntitledDraftRecord(draftId, options) { 21627 options = options || {}; 21628 if (!draftId) return false; 21629 const previousLength = state.untitledDrafts.length; 21630 state.untitledDrafts = state.untitledDrafts.filter(function (draft) { 21631 return draft.id !== draftId; 21632 }); 21633 __pendingUntitledDraftWrites.delete(draftId); 21634 if (state.untitledDraftId === draftId) { 21635 state.untitledDraftId = ""; 21636 } 21637 writeUntitledDraftBackup(); 21638 clearUntitledDraftHighlightBucket(draftId); 21639 21640 if (canUseUntitledDraftStorage()) { 21641 await untitledDraftDeleteFromDb(draftId).catch(function (error) { 21642 console.warn("[drafts] delete failed", error); 21643 }); 21644 } 21645 21646 if (options.renderDrawer !== false) { 21647 renderLibraryDrawer(); 21648 } 21649 return state.untitledDrafts.length !== previousLength; 21650 } 21651 21652 async function detachUntitledDraftAfterSave() { 21653 if (!isUntitledDraftMode()) return; 21654 const draftId = state.untitledDraftId; 21655 if (!draftId) return; 21656 await deleteUntitledDraftRecord(draftId, { renderDrawer: true }); 21657 } 21658 21659 async function loadUntitledDraft(draftId, options) { 21660 options = options || {}; 21661 let draft = getUntitledDraftById(draftId); 21662 if (!draft) return false;
21663 21664 await flushActiveUntitledDraft().catch(function (error) { 21665 console.warn("[drafts] active draft flush failed", error); 21666 }); 21667 draft = getUntitledDraftById(draftId); 21668 if (!draft) return false; 21669 21670 stopAutoRefresh(); 21671 els.autoRefreshToggle.checked = false; 21672 detachSharedSnapshotContext(); 21673 setPrimaryFileHandle(null); 21674 await clearActiveLibrarySelection({ 21675 persist: true, 21676 renderDrawer: false, 21677 }); 21678 21679 const now = Date.now(); 21680 const next = upsertUntitledDraftInState( 21681 Object.assign({}, draft, { 21682 lastActive: now, 21683 updatedAt: draft.updatedAt || now, 21684 }), 21685 ); 21686 scheduleUntitledDraftWrite(next); 21687 21688 state.untitledDraftId = next.id; 21689 state.sourceMode = "untitled"; 21690 state.currentName = next.title; 21691 state.baseHref = window.location.href; 21692 state.dirty = Boolean(next.dirty); 21693 state.hasSessionDraft = false; 21694 removeStorage(window.sessionStorage, SESSION_CONTENT_KEY); 21695 21696 setEditorMarkdown(next.content); 21697 updateUiState(); 21698 render("untitled"); 21699 renderLibraryDrawer(); 21700 21701 if (!options.quiet) { 21702 setStatus(next.title + " ⢠loaded", { announce: true }); 21703 } 21704 if (options.closeDrawerOnMobile !== false && isCoarsePointer()) { 21705 hideLibraryDrawer({ restoreFocus: false }); 21706 } 21707 return true; 21708 } 21709 21710 async function createUntitledDocument() { 21711 await flushActiveUntitledDraft().catch(function (error) { 21712 console.warn("[drafts] active draft flush failed", error); 21713 }); 21714 21715 stopAutoRefresh(); 21716 els.autoRefreshToggle.checked = false; 21717 detachSharedSnapshotContext(); 21718 setPrimaryFileHandle(null); 21719 await clearActiveLibrarySelection({ 21720 persist: true, 21721 renderDrawer: false, 21722 }); 21723 21724 const now = Date.now(); 21725 const draft = upsertUntitledDraftInState({ 21726 id: createUntitledDraftId(), 21727 title: getNextUntitledDraftTitle(), 21728 content: "", 21729 createdAt: now, 21730 updatedAt: now, 21731 lastActive: now, 21732 dirty: false, 21733 }); 21734 21735 state.untitledDraftId = draft.id; 21736 state.sourceMode = "untitled"; 21737 state.currentName = draft.title; 21738 state.baseHref = window.location.href; 21739 state.dirty = false; 21740 state.hasSessionDraft = false; 21741 removeStorage(window.sessionStorage, SESSION_CONTENT_KEY); 21742 removeStorage(window.sessionStorage, SESSION_META_KEY); 21743 21744 setEditorMarkdown(""); 21745 updateUiState(); 21746 render("untitled"); 21747 renderLibraryDrawer(); 21748 scheduleUntitledDraftWrite(draft); 21749 flushUntitledDraftWrites().catch(function (error) { 21750 console.warn("[drafts] initial persist failed", error); 21751 }); 21752 21753 setMode("edit"); 21754 setStatus(draft.title + " ⢠created", { announce: true }); 21755 } 21756 21757 async function discardUntitledDraft(draftId) { 21758 const draft = getUntitledDraftById(draftId); 21759 if (!draft) return false; 21760 const wasActive = 21761 state.sourceMode === "untitled" && 21762 state.untitledDraftId === draftId; 21763 const contentToDiscard = wasActive ? els.editor.value : draft.content; 21764 21765 if (String(contentToDiscard || "").trim()) { 21766 const confirmed = window.confirm( 21767 "Discard " + draft.title + "? This cannot be undone.", 21768 ); 21769 if (!confirmed) return false; 21770 } 21771 21772 await deleteUntitledDraftRecord(draftId, { renderDrawer: false }); 21773 21774 if (wasActive) { 21775 const nextDraft = state.untitledDrafts[0]; 21776 if (nextDraft) { 21777 await loadUntitledDraft(nextDraft.id, { 21778 quiet: true, 21779 closeDrawerOnMobile: false, 21780 }); 21781 } else { 21782 stopAutoRefresh(); 21783 els.autoRefreshToggle.checked = false;
21784 detachSharedSnapshotContext(); 21785 setPrimaryFileHandle(null); 21786 state.sourceMode = "editor"; 21787 state.currentName = ""; 21788 state.baseHref = window.location.href; 21789 state.dirty = false; 21790 state.hasSessionDraft = false; 21791 state.highlightDocKey = ""; 21792 state.currentHighlights = []; 21793 state.resolvedHighlights = []; 21794 state.highlightTextModel = null; 21795 removeStorage(window.sessionStorage, SESSION_CONTENT_KEY); 21796 removeStorage(window.sessionStorage, SESSION_META_KEY); 21797 setEditorMarkdown(""); 21798 updateUiState(); 21799 render("force"); 21800 } 21801 } 21802 renderLibraryDrawer(); 21803 21804 setStatus(draft.title + " ⢠discarded", { announce: true }); 21805 return true; 21806 } 21807 21808 async function refreshLibraryFolder(folderId) { 21809 const folder = getLibraryFolderById(folderId); 21810 if (!folder) return; 21811 if (folder.sourceType === "snapshot") { 21812 setStatus( 21813 "This folder was imported as a snapshot ⢠open it again to capture newer changes.", 21814 { announce: true }, 21815 ); 21816 return; 21817 } 21818 await scanLibraryFolder(folder); 21819 renderLibraryDrawer(); 21820 setStatus((folder.displayLabel || folder.name) + " ⢠refreshed", { 21821 announce: true, 21822 }); 21823 } 21824 21825 async function reauthorizeLibraryFolder(folderId) { 21826 const folder = getLibraryFolderById(folderId); 21827 if (!folder || !folder.handle) return; 21828 const permission = await requestLibraryPermission(folder.handle); 21829 if (permission !== "granted") { 21830 folder.status = "needs-permission"; 21831 folder.note = "Access still needs to be granted."; 21832 renderLibraryDrawer(); 21833 setStatus("Library access was not granted.", { announce: true }); 21834 console.warn("[library] reauthorization denied", { 21835 folderId: folder.id, 21836 }); 21837 return; 21838 } 21839 await scanLibraryFolder(folder); 21840 setStatus((folder.displayLabel || folder.name) + " ⢠reauthorized", { 21841 announce: true, 21842 }); 21843 } 21844 21845 async function removeLibraryFolder(folderId) { 21846 const nextFolders = state.libraryFolders.filter(function (folder) { 21847 return folder.id !== folderId; 21848 }); 21849 const removed = getLibraryFolderById(folderId); 21850 state.libraryFolders = nextFolders; 21851 applyLibraryFolderOrder(); 21852 21853 delete state.libraryExpandedPathsByFolder[folderId]; 21854 setLibraryFolderCollapsed(folderId, false); 21855 if (state.libraryActiveFolderId === folderId) { 21856 state.libraryActiveFolderId = ""; 21857 state.libraryActiveFilePath = ""; 21858 state.libraryCurrentFileHandle = null; 21859 state.libraryCurrentFolderHandle = null; 21860 if (state.sourceMode === "library") { 21861 state.sourceMode = "editor"; 21862 updateUiState(); 21863 } 21864 } 21865 21866 if (canUseLibraryStorage()) { 21867 await libraryDeleteFolderFromDb(folderId).catch(function (error) { 21868 console.warn("[library] failed to delete folder from db", error); 21869 }); 21870 await persistLibraryMeta().catch(function (error) { 21871 console.warn("[library] failed to persist removal", error); 21872 }); 21873 } 21874 21875 renderLibraryDrawer(); 21876 setStatus( 21877 (removed ? removed.displayLabel || removed.name : "Folder") + 21878 " ⢠removed from library", 21879 { announce: true }, 21880 ); 21881 } 21882 21883 async function getLibraryHandleConflict(targetHandle) { 21884 for (const folder of state.libraryFolders) { 21885 if (!folder.handle || folder.sourceType !== "persistent") continue; 21886 try { 21887 if (await folder.handle.isSameEntry(targetHandle)) { 21888 return "duplicate"; 21889 } 21890 const childOfExisting = await folder.handle.resolve(targetHandle); 21891 if (Array.isArray(childOfExisting)) return "overlap"; 21892 const parentOfExisting = await targetHandle.resolve( 21893 folder.handle, 21894 ); 21895 if (Array.isArray(parentOfExisting)) return "overlap"; 21896 } catch (_error) { 21897 // Ignore unsupported relationship checks. 21898 } 21899 } 21900 return null; 21901 } 21902 21903 function getImportedLibraryRelativePath(file) { 21904 const rawPath = 21905 typeof file.webkitRelativePath === "string" && 21906 file.webkitRelativePath 21907 ? file.webkitRelativePath 21908 : String(file.name || "");
21909 const parts = rawPath.split("/").filter(Boolean); 21910 if (!parts.length) return ""; 21911 return parts.length > 1 ? parts.slice(1).join("/") : parts[0]; 21912 } 21913 21914 async function importLibraryFolderSnapshot(files, options) { 21915 options = options || {}; 21916 const replaceFolderId = String(options.replaceFolderId || ""); 21917 const activePath = 21918 replaceFolderId && state.libraryActiveFolderId === replaceFolderId 21919 ? state.libraryActiveFilePath 21920 : ""; 21921 const selectedFiles = Array.from(files || []); 21922 const prepared = prepareImportedLibraryEntries(selectedFiles); 21923 if (!prepared.rootName) { 21924 setStatus( 21925 "That folder could not be imported ⢠Safari needs at least one file to build the library snapshot.", 21926 { announce: true }, 21927 ); 21928 return; 21929 } 21930 21931 const allowedPaths = new Set( 21932 prepared.entries.map(function (entry) { 21933 return entry.path; 21934 }), 21935 ); 21936 const snapshotFiles = []; 21937 const seenPaths = new Set(); 21938 21939 for (const file of selectedFiles) { 21940 if (snapshotFiles.length >= LIBRARY_LIMITS.maxFiles) { 21941 break; 21942 } 21943 const relativePath = getImportedLibraryRelativePath(file); 21944 if ( 21945 !relativePath || 21946 !allowedPaths.has(relativePath) || 21947 seenPaths.has(relativePath) 21948 ) { 21949 continue; 21950 } 21951 seenPaths.add(relativePath); 21952 snapshotFiles.push({ 21953 path: relativePath, 21954 name: String( 21955 file.name || relativePath.split("/").pop() || "Untitled", 21956 ), 21957 text: await file.text(), 21958 lastModified: Number(file.lastModified) || Date.now(), 21959 }); 21960 if (snapshotFiles.length % LIBRARY_LIMITS.yieldEvery === 0) { 21961 await yieldToUi(); 21962 } 21963 } 21964 21965 let record = null; 21966 if (replaceFolderId) { 21967 record = getLibraryFolderById(replaceFolderId); 21968 } 21969 21970 if (record) { 21971 record.name = prepared.rootName; 21972 record.snapshotFiles = snapshotFiles; 21973 record.status = "loading"; 21974 record.note = ""; 21975 } else { 21976 record = createLibraryFolderRecord({ 21977 id: nextLibraryFolderId(), 21978 name: prepared.rootName, 21979 sourceType: "snapshot", 21980 snapshotFiles: snapshotFiles, 21981 addedAt: Date.now(), 21982 }); 21983 state.libraryFolders.push(record); 21984 } 21985 21986 applyLibraryFolderOrder(); 21987 const persisted = await persistLibraryFolderBestEffort( 21988 record, 21989 "folder snapshot", 21990 ); 21991 renderLibraryDrawer(); 21992 await scanLibraryFolder(record); 21993 21994 let restoredActiveFile = false; 21995 if (record.id === replaceFolderId && activePath) { 21996 restoredActiveFile = await loadFromLibrarySelection( 21997 record.id, 21998 activePath, 21999 { 22000 closeDrawerOnMobile: false, 22001 quiet: false, 22002 userInitiated: true, 22003 }, 22004 ); 22005 } 22006 22007 if (!restoredActiveFile) { 22008 setStatus( 22009 record.displayLabel + 22010 (replaceFolderId 22011 ? persisted 22012 ? " ⢠snapshot refreshed" 22013 : " ⢠snapshot refreshed for this session" 22014 : persisted 22015 ? " ⢠added as a folder snapshot" 22016 : " ⢠added as a folder snapshot for this session"), 22017 { 22018 announce: true, 22019 }, 22020 ); 22021 } 22022 } 22023 22024 async function addLibraryFolder() { 22025 if (!canUseAnyLibrary()) { 22026 renderLibraryDrawer(); 22027 return; 22028 } 22029 if (state.libraryFolders.length >= LIBRARY_LIMITS.maxFolders) { 22030 setStatus( 22031 "Library limit reached ⢠remove a folder before opening another.", 22032 { announce: true }, 22033 ); 22034 return; 22035 } 22036 22037 if (!canUsePersistentLibrary()) { 22038 requestLibraryFolderImport(); 22039 return; 22040 } 22041 22042 try { 22043 const handle = await window.showDirectoryPicker({ mode: "read" }); 22044 if (!handle) return; 22045 22046 const conflict = await getLibraryHandleConflict(handle); 22047 if (conflict) { 22048 setStatus( 22049 conflict === "duplicate" 22050 ? "That folder is already open in Files." 22051 : "That folder overlaps an existing Files root.", 22052 { announce: true }, 22053 ); 22054 console.warn("[library] rejected folder open", { 22055 conflict: conflict, 22056 name: handle.name, 22057 }); 22058 return; 22059 } 22060 22061 const record = createLibraryFolderRecord({ 22062 id: nextLibraryFolderId(), 22063 name: handle.name, 22064 handle: handle, 22065 sourceType: "persistent", 22066 addedAt: Date.now(), 22067 }); 22068 state.libraryFolders.push(record); 22069 applyLibraryFolderOrder(); 22070 const persisted = await persistLibraryFolderBestEffort( 22071 record, 22072 "folder open", 22073 ); 22074 renderLibraryDrawer(); 22075 await scanLibraryFolder(record); 22076 setStatus( 22077 record.displayLabel + 22078 (persisted 22079 ? " ⢠opened in Files" 22080 : " ⢠opened in Files for this session"), 22081 { 22082 announce: true, 22083 }, 22084 ); 22085 } catch (error) {
22086 if (error && error.name === "AbortError") return; 22087 setStatus( 22088 "Could not open folder ⢠" + 22089 (error && error.message ? error.message : String(error)), 22090 { announce: true }, 22091 ); 22092 console.warn("[library] open folder failed", error); 22093 } 22094 } 22095 22096 async function restoreLibraryFromStorage(sessionMeta) { 22097 renderLibraryDrawer(); 22098 if (!canUseLibraryStorage()) return; 22099 22100 let meta; 22101 let folders; 22102 try { 22103 meta = await libraryReadMetaFromDb(); 22104 folders = await libraryGetAllFoldersFromDb(); 22105 } catch (error) { 22106 console.warn("[library] restore failed", error); 22107 setStatus("Library restore skipped ⢠storage unavailable", { 22108 announce: true, 22109 }); 22110 return; 22111 } 22112 22113 state.libraryFolderOrder = meta.folderOrder.slice(); 22114 state.libraryExpandedPathsByFolder = cloneExpandedPathsByFolder( 22115 meta.expandedPathsByFolder, 22116 ); 22117 state.libraryCollapsedFolderIds = cloneLibraryCollapsedIds( 22118 meta.collapsedFolderIds, 22119 ); 22120 state.libraryFolders = folders.map(function (item) { 22121 return createLibraryFolderRecord(item); 22122 }); 22123 applyLibraryFolderOrder(); 22124 renderLibraryDrawer(); 22125 22126 for (const folder of state.libraryFolders) { 22127 await scanLibraryFolder(folder); 22128 } 22129 22130 if ( 22131 shouldAutoRestoreLastFile({ 22132 hasSessionDraft: Boolean( 22133 sessionMeta && sessionMeta.hasSessionDraft, 22134 ), 22135 lastActiveFile: meta.lastActiveFile, 22136 }) 22137 ) { 22138 await loadFromLibrarySelection( 22139 meta.lastActiveFile.folderId, 22140 meta.lastActiveFile.path, 22141 { quiet: true, userInitiated: false }, 22142 ); 22143 } 22144 } 22145 22146 function updateWordCount() { 22147 var text = els.editor.value.trim(); 22148 if (!text) { 22149 els.wordCount.hidden = true; 22150 return; 22151 } 22152 var count = text.split(/\s+/).length; 22153 els.wordCount.textContent = 22154 count.toLocaleString() + (count === 1 ? " word" : " words"); 22155 els.wordCount.hidden = false; 22156 } 22157 22158 function withShortcutLabel(label, shortcutId) { 22159 const shortcuts = window.MarkVShortcuts; 22160 const shortcut = 22161 shortcuts && typeof shortcuts.describe === "function" 22162 ? shortcuts.describe(shortcutId) 22163 : ""; 22164 return shortcut ? label + " (" + shortcut + ")" : label; 22165 } 22166 22167 function shortcutMatches(event, shortcutId) { 22168 const shortcuts = window.MarkVShortcuts; 22169 return Boolean( 22170 shortcuts && 22171 typeof shortcuts.matches === "function" && 22172 shortcuts.matches(event, shortcutId), 22173 ); 22174 } 22175 22176 function updateBrandDisplay() { 22177 if (!els.brandName) return; 22178 const rawName = String(state.currentName || "").trim(); 22179 const stripped = rawName.replace( 22180 /\.(md|markdown|mdown|mkdn|txt)$/i, 22181 "", 22182 ); 22183 const isUntitled = state.sourceMode === "untitled"; 22184 const hasFile = Boolean(stripped); 22185 const hasContent = Boolean((els.editor.value || "").trim()); 22186 22187 if (isUntitled) { 22188 els.brandName.textContent = rawName || "Untitled"; 22189 els.brandName.className = "brand-name is-untitled"; 22190 els.brandName.title = rawName || "Untitled"; 22191 document.title = rawName || "Untitled"; 22192 } else if (hasFile) { 22193 els.brandName.textContent = stripped; 22194 els.brandName.className = "brand-name is-file"; 22195 els.brandName.title = rawName; 22196 document.title = stripped; 22197 } else if (hasContent) { 22198 els.brandName.textContent = "Untitled"; 22199 els.brandName.className = "brand-name is-untitled"; 22200 els.brandName.removeAttribute("title"); 22201 document.title = "Untitled"; 22202 } else { 22203 els.brandName.textContent = "MarkV"; 22204 els.brandName.className = "brand-name"; 22205 els.brandName.removeAttribute("title"); 22206 document.title = "Markdown Viewer"; 22207 } 22208 22209 if (els.brandDirty) { 22210 els.brandDirty.hidden = !((hasFile || isUntitled) && state.dirty); 22211 } 22212 } 22213 22214 function updateUiState() { 22215 const refreshState = getReaderRefreshState(); 22216 const saveState = getReaderSaveState(); 22217 const authoringState = getReaderAuthoringState(); 22218 const editModeActive = isEditModeActive(); 22219 const hasContent = Boolean((els.editor.value || "").trim()); 22220 const canOpenMindmap = hasContent; 22221 updateBrandDisplay(); 22222 els.reloadBtn.disabled = !refreshState.canReload; 22223 const reloadLabel = 22224 refreshState.reloadAction === "file-handle" 22225 ? "Reload current file" 22226 : refreshState.reloadAction === "library-persistent" 22227 ? "Reload current library file" 22228 : refreshState.reloadAction === "library-snapshot" 22229 ? "Choose the folder again to refresh this imported snapshot" 22230 : refreshState.reloadAction === "file-picker" 22231 ? "Choose the file again" 22232 : "Reload current file"; 22233 els.reloadBtn.title = reloadLabel; 22234 els.reloadBtn.setAttribute("aria-label", reloadLabel); 22235 22236 els.saveBtn.hidden = !authoringState.showSaveButton; 22237 els.saveBtn.style.display = authoringState.showSaveButton 22238 ? "" 22239 : "none"; 22240 els.saveBtn.disabled = !saveState.canSave; 22241 els.saveBtn.textContent = saveState.saveLabel; 22242 const saveLabel = withShortcutLabel( 22243 "Save current document", 22244 "global.save", 22245 ); 22246 els.saveBtn.title = saveLabel; 22247 els.saveBtn.setAttribute("aria-label", saveLabel); 22248 22249 if (els.shareBtn) { 22250 const shareSupported = 22251 typeof libraryCore.createShareSnapshotFragment === "function"; 22252 const shareBusy = state.shareInFlight; 22253 const shareTitle = shareBusy 22254 ? state.sharePendingMayTakeTime 22255 ? "Preparing a snapshot link for this large markdown document" 22256 : "Preparing a snapshot link for the current markdown" 22257 : !shareSupported 22258 ? "Snapshot sharing is unavailable in this build" 22259 : hasContent 22260 ? "Share a snapshot link for the current markdown" 22261 : "Add markdown content before sharing"; 22262 els.shareBtn.disabled = !shareSupported || !hasContent || shareBusy; 22263 if (els.shareBtnLabel) { 22264 els.shareBtnLabel.textContent = shareBusy 22265 ? "Preparing..." 22266 : "Share"; 22267 } else { 22268 els.shareBtn.textContent = shareBusy ? "Preparing..." : "Share"; 22269 } 22270 els.shareBtn.classList.toggle("is-busy", shareBusy); 22271 els.shareBtn.setAttribute( 22272 "aria-busy", 22273 shareBusy ? "true" : "false", 22274 ); 22275 els.shareBtn.title = shareTitle; 22276 els.shareBtn.setAttribute("aria-label", shareTitle); 22277 } 22278 22279 if ( 22280 window.MarkVExportHost && 22281 typeof window.MarkVExportHost.syncUi === "function" 22282 ) { 22283 window.MarkVExportHost.syncUi(); 22284 } 22285 22286 if (els.modeToggleBtn) { 22287 // Detect the unsavedâsaved transition so the breathing 22288 // dot morphs into a calm checkmark confirmation for ~1.4s. 22289 // `.just-saved` is a transient class â added only when the 22290 // dirty cue goes away WHILE it was previously showing. 22291 const wasUnsaved = 22292 els.modeToggleBtn.classList.contains("has-unsaved"); 22293 if (wasUnsaved && !authoringState.showDirtyCue) { 22294 els.modeToggleBtn.classList.add("just-saved"); 22295 window.clearTimeout(state.justSavedTimer); 22296 state.justSavedTimer = window.setTimeout(function () { 22297 els.modeToggleBtn.classList.remove("just-saved"); 22298 }, 1400); 22299 } 22300 els.modeToggleBtn.classList.toggle( 22301 "has-unsaved", 22302 authoringState.showDirtyCue, 22303 ); 22304 const modeActionBase = editModeActive 22305 ? "Show preview" 22306 : authoringState.showDirtyCue 22307 ? "Edit markdown ⢠unsaved changes" 22308 : "Edit markdown"; 22309 const modeActionLabel = withShortcutLabel( 22310 modeActionBase, 22311 "global.mode", 22312 ); 22313 els.modeToggleBtn.title = modeActionLabel; 22314 els.modeToggleBtn.setAttribute("aria-label", modeActionLabel); 22315 } 22316 22317 const canAutoRefresh = refreshState.canAutoRefresh; 22318 els.autoRefreshToggle.disabled = !canAutoRefresh; 22319 if (els.autoRefreshToggle.parentElement) { 22320 els.autoRefreshToggle.parentElement.title = canAutoRefresh 22321 ? refreshState.reloadAction === "library-persistent" 22322 ? "Auto refresh works for live library files"
22323 : "Auto refresh works for opened file handles" 22324 : refreshState.reloadAction === "library-snapshot" 22325 ? "Auto refresh is unavailable for imported folder snapshots" 22326 : refreshState.reloadAction === "file-picker" 22327 ? "Auto refresh is unavailable for picked files without a live handle" 22328 : "Auto refresh works for opened file handles"; 22329 } 22330 const mindmapBtn = document.getElementById("mindmapBtn"); 22331 if (mindmapBtn) { 22332 mindmapBtn.disabled = !canOpenMindmap; 22333 } 22334 if (!canAutoRefresh) { 22335 els.autoRefreshToggle.checked = false; 22336 stopAutoRefresh(); 22337 } 22338 } 22339 22340 function wrapTables() { 22341 els.preview.querySelectorAll("table").forEach(function (table) { 22342 const parent = table.parentElement; 22343 if (parent && parent.classList.contains("table-wrap")) return; 22344 if (table.classList.contains("csv-sheet")) return; 22345 if (table.closest(".csv-sheet-wrap")) return; 22346 const wrapper = document.createElement("div"); 22347 wrapper.className = "table-wrap"; 22348 table.parentNode.insertBefore(wrapper, table); 22349 wrapper.appendChild(table); 22350 }); 22351 } 22352 22353 function decorateCodeBlocks() { 22354 els.preview 22355 .querySelectorAll('pre > code[class*="language-"]') 22356 .forEach(function (code) { 22357 const match = code.className.match(/language-([a-z0-9_+.-]+)/i); 22358 if (match && code.parentElement) { 22359 code.parentElement.setAttribute("data-lang", match[1]); 22360 } 22361 }); 22362 } 22363 22364 function decorateLinks() { 22365 els.preview.querySelectorAll("a[href]").forEach(function (link) { 22366 const href = link.getAttribute("href") || ""; 22367 link.referrerPolicy = "no-referrer"; 22368 if (href.startsWith("#")) { 22369 // Anchor link: scroll within the preview pane 22370 link.addEventListener("click", function (e) { 22371 e.preventDefault(); 22372 var targetId = href.slice(1); 22373 var target = els.preview.querySelector( 22374 "#" + CSS.escape(targetId), 22375 ); 22376 if (target) { 22377 target.scrollIntoView({ 22378 behavior: prefersReducedMotion() ? "auto" : "smooth", 22379 block: "start", 22380 }); 22381 } 22382 }); 22383 } else if (/^(https?:)?\/\//i.test(href)) { 22384 link.target = "_blank"; 22385 link.rel = "noreferrer noopener"; 22386 } 22387 }); 22388 } 22389 22390 function readReaderCssVar(name, fallback) { 22391 const value = getComputedStyle(document.documentElement) 22392 .getPropertyValue(name) 22393 .trim(); 22394 return value || fallback; 22395 } 22396 22397 function getMermaidPaletteColors() { 22398 const darkMode = getEffectiveReaderScheme() === "dark"; 22399 return { 22400 canvas: readReaderCssVar( 22401 "--reader-code-bg", 22402 darkMode ? "#111111" : "#f5f4f2", 22403 ), 22404 node: readReaderCssVar( 22405 "--reader-bg-raised", 22406 darkMode ? "#2f2f2d" : "#ffffff", 22407 ), 22408 nodeAlt: readReaderCssVar( 22409 "--reader-bg-subtle", 22410 darkMode ? "#262625" : "#f4f3f1", 22411 ), 22412 ink: readReaderCssVar( 22413 "--reader-code-fg", 22414 darkMode ? "#f2f0ea" : "#222221", 22415 ), 22416 muted: readReaderCssVar( 22417 "--reader-fg-muted", 22418 darkMode ? "#bfbfba" : "#71706e", 22419 ), 22420 border: readReaderCssVar( 22421 "--reader-border-strong", 22422 darkMode ? "rgba(255, 255, 255, 0.22)" : "rgba(0, 0, 0, 0.11)", 22423 ), 22424 accent: readReaderCssVar( 22425 "--reader-accent", 22426 darkMode ? "#61aaf2" : "#5f8f80", 22427 ), 22428 }; 22429 } 22430
22431 function getMermaidThemeConfig() { 22432 const darkMode = getEffectiveReaderScheme() === "dark"; 22433 const colors = getMermaidPaletteColors(); 22434 const fontFamily = readReaderCssVar( 22435 "--font-sans", 22436 "ui-sans-serif, system-ui, sans-serif", 22437 ); 22438 22439 return { 22440 theme: "base", 22441 themeVariables: { 22442 darkMode: darkMode, 22443 background: colors.canvas, 22444 mainBkg: colors.node, 22445 nodeBkg: colors.node, 22446 nodeTextColor: colors.ink, 22447 nodeBorder: colors.border, 22448 primaryColor: colors.node, 22449 primaryTextColor: colors.ink, 22450 primaryBorderColor: colors.border, 22451 secondaryColor: colors.nodeAlt, 22452 secondaryTextColor: colors.ink, 22453 secondaryBorderColor: colors.border, 22454 tertiaryColor: colors.canvas, 22455 tertiaryTextColor: colors.ink, 22456 tertiaryBorderColor: colors.border, 22457 lineColor: colors.muted, 22458 defaultLinkColor: colors.muted, 22459 arrowheadColor: colors.muted, 22460 textColor: colors.ink, 22461 titleColor: colors.ink, 22462 edgeLabelBackground: colors.canvas, 22463 labelBackground: colors.canvas, 22464 clusterBkg: colors.canvas, 22465 clusterBorder: colors.border, 22466 noteBkgColor: colors.node, 22467 noteTextColor: colors.ink, 22468 noteBorderColor: colors.border, 22469 actorBkg: colors.node, 22470 actorTextColor: colors.ink, 22471 actorBorder: colors.border, 22472 activationBkgColor: colors.nodeAlt, 22473 activationBorderColor: colors.border, 22474 labelBoxBkgColor: colors.node, 22475 labelBoxBorderColor: colors.border, 22476 labelTextColor: colors.ink, 22477 loopTextColor: colors.ink, 22478 signalColor: colors.muted, 22479 signalTextColor: colors.ink, 22480 sequenceNumberColor: darkMode ? "#111111" : "#ffffff", 22481 cScale0: colors.accent, 22482 cScale1: colors.nodeAlt, 22483 cScale2: colors.node, 22484 fontFamily: fontFamily, 22485 }, 22486 }; 22487 } 22488 22489 function getBeautifulMermaidRenderOptions() { 22490 const colors = getMermaidPaletteColors(); 22491 22492 return { 22493 bg: colors.canvas, 22494 fg: colors.ink, 22495 line: colors.muted, 22496 accent: colors.accent, 22497 muted: colors.muted, 22498 surface: colors.node, 22499 border: colors.border, 22500 transparent: true, 22501 }; 22502 } 22503 22504 function applyReadableMermaidPalette(svg) { 22505 if (!svg) return; 22506 const colors = getMermaidPaletteColors(); 22507 svg.dataset.markvMermaidPalette = "true"; 22508 svg 22509 .querySelectorAll("style[data-markv-mermaid-palette]") 22510 .forEach(function (style) { 22511 style.remove(); 22512 }); 22513 22514 const style = document.createElementNS( 22515 "http://www.w3.org/2000/svg", 22516 "style", 22517 ); 22518 style.setAttribute("data-markv-mermaid-palette", "true"); 22519 style.textContent = [ 22520 ".node rect, .node circle, .node ellipse, .node polygon, .node path,", 22521 ".flowchart-label rect, .er.entityBox, .actor, .note, .labelBox,", 22522 ".state-start, .state-end {", 22523 " fill: " + colors.node + " !important;", 22524 " stroke: " + colors.border + " !important;", 22525 "}", 22526 ".cluster rect, .cluster polygon {", 22527 " fill: " + colors.canvas + " !important;", 22528 " stroke: " + colors.border + " !important;", 22529 "}", 22530 ".edgeLabel, .edgeLabel p, .edgeLabel span {", 22531 " background-color: " + colors.canvas + " !important;", 22532 " color: " + colors.ink + " !important;", 22533 "}", 22534 ".edgeLabel rect, .labelBkg {", 22535 " fill: " + colors.canvas + " !important;", 22536 "}", 22537 ".label, .nodeLabel, .nodeLabel p, .nodeLabel span,", 22538 ".edgeLabel, .edgeLabel p, .edgeLabel span {", 22539 " color: " + colors.ink + " !important;", 22540 "}", 22541 ".label text, text, tspan {", 22542 " fill: " + colors.ink + " !important;", 22543 "}", 22544 ".edgePath .path, .flowchart-link, .messageLine0, .messageLine1,", 22545 ".relation, .transition, line {", 22546 " stroke: " + colors.muted + " !important;", 22547 "}", 22548 ".arrowheadPath, marker path {", 22549 " fill: " + colors.muted + " !important;", 22550 " stroke: " + colors.muted + " !important;", 22551 "}", 22552 ].join("\n"); 22553 svg.appendChild(style); 22554 } 22555 22556 function hasMermaidBlocks(markdown) { 22557 return /(^|\n)```mermaid(?:\s|$)/i.test(String(markdown || "")); 22558 } 22559 22560 function getMermaidFirstMeaningfulLine(source) { 22561 const lines = String(source || "").trim().split(/\r?\n/); 22562 for (const line of lines) { 22563 const trimmed = line.trim(); 22564 if (!trimmed || trimmed.startsWith("%%")) continue; 22565 return trimmed; 22566 } 22567 return ""; 22568 } 22569 22570 function getMermaidRendererType(source) { 22571 const firstLine = getMermaidFirstMeaningfulLine(source); 22572 if (
22573 /^(?:flowchart|graph)\s+(?:TD|TB|LR|BT|RL)$/i.test(firstLine) || 22574 /^stateDiagram(?:-v2)?$/i.test(firstLine) || 22575 /^sequenceDiagram$/i.test(firstLine) || 22576 /^classDiagram$/i.test(firstLine) || 22577 /^erDiagram$/i.test(firstLine) || 22578 /^xychart-beta$/i.test(firstLine) 22579 ) { 22580 return BEAUTIFUL_MERMAID_RENDERER_NAME; 22581 } 22582 return MERMAID_RENDERER_NAME; 22583 } 22584 22585 function getMermaidApi() { 22586 if (window.mermaid) { 22587 return Promise.resolve(window.mermaid); 22588 } 22589 if (state.mermaidApiPromise) { 22590 return state.mermaidApiPromise; 22591 } 22592 22593 state.mermaidApiPromise = new Promise(function (resolve, reject) { 22594 const script = document.createElement("script"); 22595 script.src = MERMAID_SCRIPT_URL; 22596 script.async = true; 22597 script.onload = function () { 22598 if (window.mermaid) { 22599 resolve(window.mermaid); 22600 return; 22601 } 22602 state.mermaidApiPromise = null; 22603 reject(new Error("Mermaid loaded but did not initialize.")); 22604 }; 22605 script.onerror = function () { 22606 state.mermaidApiPromise = null; 22607 reject(new Error("Failed to load Mermaid.")); 22608 }; 22609 document.head.appendChild(script); 22610 }); 22611 22612 return state.mermaidApiPromise; 22613 } 22614 22615 function getBeautifulMermaidApi() { 22616 if ( 22617 window.BeautifulMermaid && 22618 typeof window.BeautifulMermaid.renderMermaidSVG === "function" 22619 ) { 22620 return Promise.resolve(window.BeautifulMermaid); 22621 } 22622 if (state.beautifulMermaidApiPromise) { 22623 return state.beautifulMermaidApiPromise; 22624 } 22625 22626 state.beautifulMermaidApiPromise = new Promise(function ( 22627 resolve, 22628 reject, 22629 ) { 22630 const script = document.createElement("script"); 22631 script.src = BEAUTIFUL_MERMAID_SCRIPT_URL; 22632 script.async = true; 22633 script.onload = function () { 22634 if ( 22635 window.BeautifulMermaid && 22636 typeof window.BeautifulMermaid.renderMermaidSVG === "function" 22637 ) { 22638 resolve(window.BeautifulMermaid); 22639 return; 22640 } 22641 state.beautifulMermaidApiPromise = null; 22642 reject( 22643 new Error("Beautiful Mermaid loaded but did not initialize."), 22644 ); 22645 }; 22646 script.onerror = function () { 22647 state.beautifulMermaidApiPromise = null; 22648 reject(new Error("Failed to load Beautiful Mermaid.")); 22649 }; 22650 document.head.appendChild(script); 22651 }); 22652 22653 return state.beautifulMermaidApiPromise; 22654 } 22655 22656 function buildMermaidFallback(source, message, detail) { 22657 const detailHtml = detail 22658 ? '<pre class="mermaid-error-detail"><code>' + 22659 escapeHtml(detail) + 22660 "</code></pre>" 22661 : ""; 22662 return ( 22663 '<div class="mermaid-error">' + 22664 escapeHtml(message) + 22665 "</div>" + 22666 detailHtml + 22667 "<pre><code>" + 22668 escapeHtml(source) + 22669 "</code></pre>" 22670 ); 22671 } 22672
22673 function revokeMermaidViewerDownloadUrl() { 22674 if (!state.mermaidViewerDownloadUrl) return; 22675 URL.revokeObjectURL(state.mermaidViewerDownloadUrl); 22676 state.mermaidViewerDownloadUrl = ""; 22677 } 22678 22679 function createMermaidInlineAction() { 22680 const actions = document.createElement("div"); 22681 actions.className = "mermaid-inline-actions"; 22682 22683 const button = document.createElement("button"); 22684 button.type = "button"; 22685 button.className = "mermaid-inline-action"; 22686 button.title = "Open diagram viewer"; 22687 button.setAttribute("aria-label", "Open diagram viewer"); 22688 button.innerHTML = 22689 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + 22690 '<path d="M15 3h6v6"></path>' + 22691 '<path d="M9 21H3v-6"></path>' + 22692 '<path d="M21 3 14 10"></path>' + 22693 '<path d="m3 21 7-7"></path>' + 22694 "</svg>" + 22695 "<span>Open diagram</span>"; 22696 actions.appendChild(button); 22697 return actions; 22698 } 22699 22700 function ensureMermaidBlockActions(host) { 22701 if (!host || host.classList.contains("is-error")) return; 22702 if (host.querySelector(".mermaid-inline-actions")) return; 22703 host.prepend(createMermaidInlineAction()); 22704 } 22705 22706 function getRenderedMermaidSvgFromHost(host) { 22707 if (!host) return null; 22708 return ( 22709 Array.from(host.children).find(function (child) { 22710 return child.tagName === "svg" || child.tagName === "SVG"; 22711 }) || null 22712 ); 22713 } 22714 22715 function getMermaidViewerSizeFromSvg(svg) { 22716 const viewBox = svg && svg.viewBox ? svg.viewBox.baseVal : null; 22717 if (viewBox && viewBox.width && viewBox.height) { 22718 return { 22719 width: viewBox.width, 22720 height: viewBox.height, 22721 }; 22722 } 22723 22724 const rect = svg ? svg.getBoundingClientRect() : null; 22725 return { 22726 width: Math.max(1, rect ? rect.width : 1200), 22727 height: Math.max(1, rect ? rect.height : 800), 22728 }; 22729 } 22730 22731 function syncMermaidViewerZoomLabel() { 22732 els.mermaidViewerZoomLabel.textContent = 22733 Math.round(state.mermaidViewerScale * 100) + "%"; 22734 } 22735 22736 function applyMermaidViewerScale(nextScale, options) { 22737 options = options || {}; 22738 const stage = els.mermaidViewerStage; 22739 if (!stage || !state.mermaidViewerOpen) return; 22740 22741 const prevScale = state.mermaidViewerScale || 1; 22742 const scale = clampNumber(Number(nextScale) || 1, 0.2, 6); 22743 const contentX = 22744 options.contentX != null 22745 ? options.contentX 22746 : (stage.scrollLeft + stage.clientWidth / 2) / prevScale; 22747 const contentY = 22748 options.contentY != null 22749 ? options.contentY 22750 : (stage.scrollTop + stage.clientHeight / 2) / prevScale; 22751 22752 state.mermaidViewerScale = scale; 22753 els.mermaidViewerZoomable.style.transform = "scale(" + scale + ")"; 22754 syncMermaidViewerZoomLabel(); 22755 22756 window.requestAnimationFrame(function () { 22757 stage.scrollLeft = Math.max( 22758 0, 22759 contentX * scale - stage.clientWidth / 2, 22760 ); 22761 stage.scrollTop = Math.max( 22762 0, 22763 contentY * scale - stage.clientHeight / 2, 22764 ); 22765 }); 22766 } 22767 22768 function computeMermaidViewerFitScale() { 22769 const stage = els.mermaidViewerStage; 22770 if (!stage) return 1; 22771 22772 var padding = 48; 22773 var availableWidth = Math.max(1, stage.clientWidth - padding); 22774 var availableHeight = Math.max(1, stage.clientHeight - padding); 22775 var baseW = Math.max(1, state.mermaidViewerBaseWidth); 22776 var baseH = Math.max(1, state.mermaidViewerBaseHeight); 22777 var widthRatio = availableWidth / baseW; 22778 var heightRatio = availableHeight / baseH; 22779 var aspectRatio = baseH / baseW; 22780 22781 var fitScale; 22782 if (aspectRatio > 2.5) { 22783 // Tall narrow diagrams: fit to width so content is readable, 22784 // let the user scroll vertically. 22785 fitScale = Math.min(widthRatio, 1); 22786 } else { 22787 // Normal/landscape diagrams: fit entirely in view. 22788 fitScale = Math.min(widthRatio, heightRatio); 22789 } 22790 22791 return clampNumber(fitScale, 0.2, 6); 22792 } 22793 22794 function fitMermaidViewer() { 22795 var baseW = Math.max(1, state.mermaidViewerBaseWidth); 22796 var baseH = Math.max(1, state.mermaidViewerBaseHeight); 22797 var isTall = baseH / baseW > 2.5; 22798 applyMermaidViewerScale(computeMermaidViewerFitScale(), { 22799 contentX: baseW / 2, 22800 // For tall diagrams, anchor at top so user sees the start 22801 contentY: isTall ? 0 : baseH / 2, 22802 }); 22803 } 22804 22805 function closeMermaidViewer(options) { 22806 options = options || {}; 22807 if (els.mermaidViewer.hidden) return; 22808
22809 revokeMermaidViewerDownloadUrl(); 22810 els.mermaidViewer.hidden = true; 22811 els.mermaidViewer.setAttribute("aria-hidden", "true"); 22812 els.mermaidViewerZoomable.innerHTML = ""; 22813 els.mermaidViewerMeta.textContent = "No diagram selected."; 22814 els.mermaidViewerStage.classList.remove("is-dragging"); 22815 state.mermaidViewerOpen = false; 22816 state.mermaidViewerDrag = null; 22817 22818 if (options.restoreFocus === false) return; 22819 if ( 22820 state.mermaidViewerLastFocused && 22821 typeof state.mermaidViewerLastFocused.focus === "function" 22822 ) { 22823 state.mermaidViewerLastFocused.focus(); 22824 } 22825 } 22826 22827 function openMermaidViewerFromHost(host) { 22828 const svg = getRenderedMermaidSvgFromHost(host); 22829 if (!host || !svg) return; 22830 22831 revokeMermaidViewerDownloadUrl(); 22832 state.mermaidViewerLastFocused = 22833 document.activeElement instanceof HTMLElement 22834 ? document.activeElement 22835 : null; 22836 22837 const clone = svg.cloneNode(true); 22838 const size = getMermaidViewerSizeFromSvg(svg); 22839 clone.style.maxWidth = "none"; 22840 clone.style.width = size.width + "px"; 22841 clone.style.height = size.height + "px"; 22842 22843 state.mermaidViewerBaseWidth = size.width; 22844 state.mermaidViewerBaseHeight = size.height; 22845 state.mermaidViewerScale = 1; 22846 22847 els.mermaidViewerZoomable.innerHTML = ""; 22848 els.mermaidViewerZoomable.appendChild(clone); 22849 els.mermaidViewer.hidden = false; 22850 els.mermaidViewer.setAttribute("aria-hidden", "false"); 22851 els.mermaidViewerMeta.textContent = 22852 Math.round(size.width) + " à " + Math.round(size.height) + " px"; 22853 state.mermaidViewerOpen = true; 22854 22855 const blob = new Blob([clone.outerHTML], { 22856 type: "image/svg+xml;charset=utf-8", 22857 }); 22858 state.mermaidViewerDownloadUrl = URL.createObjectURL(blob); 22859 22860 syncMermaidViewerZoomLabel(); 22861 fitMermaidViewer(); 22862 22863 window.requestAnimationFrame(function () { 22864 els.mermaidViewerStage.scrollTop = 0; 22865 els.mermaidViewerStage.scrollLeft = 0; 22866 fitMermaidViewer(); 22867 els.mermaidViewerStage.focus(); 22868 }); 22869 } 22870 22871 function normalizeMermaidSource(source) { 22872 const text = String(source || ""); 22873 if (!/^\s*(?:flowchart|graph)\b/i.test(text)) { 22874 return text; 22875 } 22876 22877 // Mermaid 11 treats literal braces inside square-bracket node labels 22878 // as shape tokens. Escape common placeholder braces so flowchart labels 22879 // like "phase-{N}-spec.md" render as text instead of failing to parse. 22880 return text.replace(/\[(.*?)\]/gs, function (_match, inner) { 22881 return ( 22882 "[" + 22883 inner.replace(/\{/g, "{").replace(/\}/g, "}") + 22884 "]" 22885 ); 22886 }); 22887 } 22888 22889 function validateBeautifulMermaidSvg(svgText) { 22890 const template = document.createElement("template"); 22891 template.innerHTML = String(svgText || "").trim(); 22892 22893 if (template.content.children.length !== 1) { 22894 throw new Error("Beautiful Mermaid returned unexpected markup."); 22895 } 22896 22897 const svg = template.content.firstElementChild; 22898 if (!svg || svg.tagName.toLowerCase() !== "svg") { 22899 throw new Error("Beautiful Mermaid did not return an SVG."); 22900 } 22901 22902 if ( 22903 svg.querySelector( 22904 "script, foreignObject, iframe, object, embed, image", 22905 ) 22906 ) { 22907 throw new Error("Beautiful Mermaid returned unsafe SVG markup."); 22908 } 22909 22910 const elements = [svg].concat(Array.from(svg.querySelectorAll("*"))); 22911 for (const element of elements) { 22912 for (const attr of Array.from(element.attributes)) { 22913 const name = attr.name.toLowerCase(); 22914 const value = String(attr.value || "").trim().toLowerCase(); 22915 if (name.startsWith("on")) { 22916 throw new Error( 22917 "Beautiful Mermaid returned event handler attributes.", 22918 ); 22919 } 22920 if ( 22921 (name === "href" || name === "xlink:href") && 22922 !value.startsWith("#") 22923 ) { 22924 throw new Error("Beautiful Mermaid returned unsafe links."); 22925 } 22926 if (name === "style" && /(?:javascript:|@import)/i.test(value)) { 22927 throw new Error("Beautiful Mermaid returned unsafe styles."); 22928 } 22929 } 22930 } 22931 22932 svg.querySelectorAll("style").forEach(function (style) { 22933 style.textContent = String(style.textContent || "") 22934 .split(/\r?\n/) 22935 .filter(function (line) { 22936 return !/^\s*@import\b/i.test(line); 22937 }) 22938 .join("\n"); 22939 if (/(?:javascript:|@import)/i.test(style.textContent || "")) { 22940 throw new Error("Beautiful Mermaid returned unsafe styles."); 22941 } 22942 }); 22943 22944 return svg.outerHTML; 22945 } 22946 22947 async function renderBeautifulMermaidSource(source) { 22948 const api = await getBeautifulMermaidApi(); 22949 const svg = api.renderMermaidSVG( 22950 source, 22951 getBeautifulMermaidRenderOptions(), 22952 ); 22953 return { 22954 renderer: BEAUTIFUL_MERMAID_RENDERER_NAME, 22955 svg: validateBeautifulMermaidSvg(svg), 22956 }; 22957 } 22958 22959 function collectMermaidBlocks() { 22960 return Array.from( 22961 els.preview.querySelectorAll("pre > code.language-mermaid"), 22962 ) 22963 .map(function (code, index) { 22964 const pre = code.parentElement; 22965 if (!pre) return null; 22966 const source = String(code.textContent || "").replace(/\s+$/, ""); 22967 if (!source) return null; 22968 22969 const host = document.createElement("div"); 22970 host.className = "mermaid-block is-loading"; 22971 host.dataset.mermaidSource = source; 22972 host.dataset.mermaidIndex = String(index); 22973 host.textContent = "Rendering diagramâ¦"; 22974 pre.replaceWith(host); 22975 22976 return { 22977 host: host, 22978 source: source, 22979 index: index, 22980 }; 22981 }) 22982 .filter(Boolean); 22983 } 22984 22985 function collectRenderedMermaidBlocks() { 22986 return Array.from( 22987 els.preview.querySelectorAll(".mermaid-block[data-mermaid-source]"),
22988 ) 22989 .map(function (host, index) { 22990 const source = String(host.dataset.mermaidSource || "").replace( 22991 /\s+$/, 22992 "", 22993 ); 22994 if (!source) return null; 22995 const blockIndex = Number(host.dataset.mermaidIndex); 22996 22997 return { 22998 host: host, 22999 source: source, 23000 index: Number.isFinite(blockIndex) ? blockIndex : index, 23001 }; 23002 }) 23003 .filter(Boolean); 23004 } 23005 23006 function applyRenderedMermaidSvg(block, rendered) { 23007 const rendererName = rendered.renderer || MERMAID_RENDERER_NAME; 23008 block.host.classList.remove("is-loading", "is-error"); 23009 block.host.dataset.mermaidRenderer = rendererName; 23010 block.host.innerHTML = rendered.svg; 23011 23012 const svg = block.host.querySelector("svg"); 23013 if (svg) { 23014 svg.dataset.markvMermaidRenderer = rendererName; 23015 svg.setAttribute("role", "img"); 23016 svg.setAttribute("aria-label", "Mermaid diagram"); 23017 if (rendererName === MERMAID_RENDERER_NAME) { 23018 applyReadableMermaidPalette(svg); 23019 } 23020 } 23021 ensureMermaidBlockActions(block.host); 23022 23023 if (rendered.bindFunctions) { 23024 rendered.bindFunctions(block.host); 23025 } 23026 } 23027 23028 function applyMermaidRenderError(block, error) { 23029 block.host.className = "mermaid-block is-error"; 23030 block.host.innerHTML = buildMermaidFallback( 23031 block.source, 23032 "Diagram could not be rendered. Showing source instead.", 23033 error && error.message ? error.message : String(error), 23034 ); 23035 } 23036 23037 async function renderMermaidBlockCollection( 23038 blocks, 23039 generation, 23040 options, 23041 ) { 23042 options = options || {}; 23043 const keepExistingOnError = Boolean(options.keepExistingOnError); 23044 if (!blocks.length) return; 23045 23046 let originalMermaid = null; 23047 const renderWithOriginalMermaid = async function (id, source) { 23048 if (!originalMermaid) { 23049 originalMermaid = await getMermaidApi(); 23050 originalMermaid.initialize({ 23051 startOnLoad: false, 23052 securityLevel: "strict", 23053 ...getMermaidThemeConfig(), 23054 }); 23055 } 23056 const rendered = await originalMermaid.render(id, source); 23057 rendered.renderer = MERMAID_RENDERER_NAME; 23058 return rendered; 23059 }; 23060 23061 try { 23062 for (const block of blocks) { 23063 const normalizedSource = normalizeMermaidSource(block.source); 23064 const rendererName = getMermaidRendererType(normalizedSource); 23065 const renderId = 23066 "markv-mermaid-" + generation + "-" + block.index; 23067 const hadExistingContent = Boolean(block.host.innerHTML.trim()); 23068 try { 23069 let rendered; 23070 if (rendererName === BEAUTIFUL_MERMAID_RENDERER_NAME) { 23071 try { 23072 rendered = 23073 await renderBeautifulMermaidSource(normalizedSource); 23074 } catch (_beautifulError) { 23075 rendered = await renderWithOriginalMermaid( 23076 renderId, 23077 normalizedSource, 23078 ); 23079 } 23080 } else { 23081 rendered = await renderWithOriginalMermaid( 23082 renderId, 23083 normalizedSource, 23084 ); 23085 } 23086 if (generation !== state.previewRenderGeneration) return; 23087 applyRenderedMermaidSvg(block, rendered); 23088 } catch (_error) { 23089 if (generation !== state.previewRenderGeneration) return; 23090 if (keepExistingOnError && hadExistingContent) continue; 23091 applyMermaidRenderError(block, _error); 23092 } 23093 } 23094 } catch (_error) { 23095 if (generation !== state.previewRenderGeneration) return; 23096 blocks.forEach(function (block) { 23097 const hadExistingContent = Boolean(block.host.innerHTML.trim()); 23098 if (keepExistingOnError && hadExistingContent) return; 23099 applyMermaidRenderError(block, _error); 23100 }); 23101 } 23102 } 23103 23104 async function renderMermaidBlocks(generation) { 23105 const blocks = collectMermaidBlocks(); 23106 if (!blocks.length) return; 23107 await renderMermaidBlockCollection(blocks, generation); 23108 } 23109 23110 async function renderMermaidForExport() { 23111 const blocks = collectRenderedMermaidBlocks(); 23112 if (!blocks.length) return; 23113 const generation = state.previewRenderGeneration + 1; 23114 state.previewRenderGeneration = generation; 23115 await renderMermaidBlockCollection(blocks, generation, { 23116 keepExistingOnError: false, 23117 }); 23118 } 23119 23120 // ââ highlight.js lazy loader ââââââââââââââââââââââââââââââ 23121 function decorateRemoteAsset(element, href) { 23122 var integrity = REMOTE_ASSET_INTEGRITY[href]; 23123 if (!integrity) return; 23124 element.integrity = integrity; 23125 element.crossOrigin = "anonymous"; 23126 element.referrerPolicy = "no-referrer"; 23127 } 23128 23129 function loadCss(href) { 23130 if (document.querySelector('link[href="' + href + '"]')) return; 23131 var link = document.createElement("link"); 23132 link.rel = "stylesheet"; 23133 link.href = href; 23134 decorateRemoteAsset(link, href); 23135 document.head.appendChild(link); 23136 } 23137 23138 function ensureCssLink(href, attrs) { 23139 var link = 23140 (attrs && 23141 attrs.theme && 23142 document.querySelector( 23143 'link[data-markv-hljs-theme="' + attrs.theme + '"]', 23144 )) || 23145 document.querySelector('link[href="' + href + '"]'); 23146 if (!link) { 23147 link = document.createElement("link"); 23148 link.rel = "stylesheet"; 23149 link.href = href; 23150 decorateRemoteAsset(link, href); 23151 document.head.appendChild(link); 23152 } 23153 if (attrs && attrs.theme) { 23154 link.setAttribute("data-markv-hljs-theme", attrs.theme); 23155 } 23156 return link; 23157 } 23158 23159 function ensureHljsThemeLinks() { 23160 var darkActive = getEffectiveReaderScheme() === "dark"; 23161 var light = ensureCssLink(HLJS_LIGHT_THEME_URL, { theme: "light" }); 23162 var dark = ensureCssLink(HLJS_DARK_THEME_URL, { theme: "dark" }); 23163 light.disabled = darkActive; 23164 dark.disabled = !darkActive; 23165 } 23166 23167 function getHighlightApi() { 23168 if (window.hljs) return Promise.resolve(window.hljs); 23169 if (state.hljsPromise) return state.hljsPromise; 23170 23171 state.hljsPromise = new Promise(function (resolve, reject) { 23172 var script = document.createElement("script"); 23173 script.src = HLJS_CDN + "/highlight.min.js"; 23174 script.async = true; 23175 decorateRemoteAsset(script, script.src); 23176 script.onload = function () { 23177 if (window.hljs) { 23178 ensureHljsThemeLinks(); 23179 resolve(window.hljs); 23180 } else { 23181 state.hljsPromise = null; 23182 reject(new Error("highlight.js loaded but not initialized.")); 23183 } 23184 }; 23185 script.onerror = function () { 23186 state.hljsPromise = null; 23187 reject(new Error("Failed to load highlight.js.")); 23188 }; 23189 document.head.appendChild(script); 23190 }); 23191 return state.hljsPromise; 23192 } 23193 23194 function applyHighlightJs(generation) { 23195 var blocks = els.preview.querySelectorAll( 23196 'pre > code[class*="language-"]', 23197 ); 23198 if (!blocks.length) return; 23199 23200 getHighlightApi() 23201 .then(function (hljs) { 23202 if (generation !== state.previewRenderGeneration) return; 23203 blocks.forEach(function (block) { 23204 if (block.classList.contains("language-mermaid")) return; 23205 hljs.highlightElement(block); 23206 }); 23207 }) 23208 .catch(function () { 23209 // Syntax highlighting is best-effort 23210 }); 23211 } 23212 23213 function swapHljsTheme() { 23214 if (window.hljs) ensureHljsThemeLinks(); 23215 } 23216 23217 // ââ KaTeX lazy loader âââââââââââââââââââââââââââââââââââââ 23218 function hasLatex(markdown) { 23219 return /\$\$[\s\S]+?\$\$|\$[^\s$].*?[^\s$]\$/.test( 23220 String(markdown || ""), 23221 ); 23222 } 23223 23224 function getKatexApi() { 23225 if (window.katex) return Promise.resolve(window.katex); 23226 if (state.katexPromise) return state.katexPromise; 23227 23228 loadCss(KATEX_CDN + "/katex.min.css"); 23229 23230 state.katexPromise = new Promise(function (resolve, reject) { 23231 var script = document.createElement("script");
23232 script.src = KATEX_CDN + "/katex.min.js"; 23233 script.async = true; 23234 decorateRemoteAsset(script, script.src); 23235 script.onload = function () { 23236 if (window.katex) { 23237 resolve(window.katex); 23238 } else { 23239 state.katexPromise = null; 23240 reject(new Error("KaTeX loaded but not initialized.")); 23241 } 23242 }; 23243 script.onerror = function () { 23244 state.katexPromise = null; 23245 reject(new Error("Failed to load KaTeX.")); 23246 }; 23247 document.head.appendChild(script); 23248 }); 23249 return state.katexPromise; 23250 } 23251 23252 function renderKatexInPreview(generation) { 23253 getKatexApi() 23254 .then(function (katex) { 23255 if (generation !== state.previewRenderGeneration) return; 23256 23257 var walker = document.createTreeWalker( 23258 els.preview, 23259 NodeFilter.SHOW_TEXT, 23260 { 23261 acceptNode: function (node) { 23262 var parent = node.parentElement; 23263 if (!parent) return NodeFilter.FILTER_REJECT; 23264 var tag = parent.tagName; 23265 if ( 23266 tag === "CODE" || 23267 tag === "PRE" || 23268 tag === "SCRIPT" || 23269 tag === "STYLE" || 23270 tag === "TEXTAREA" 23271 ) 23272 return NodeFilter.FILTER_REJECT; 23273 if (parent.closest("pre, code, .mermaid-block")) 23274 return NodeFilter.FILTER_REJECT; 23275 if (/\$/.test(node.textContent)) 23276 return NodeFilter.FILTER_ACCEPT; 23277 return NodeFilter.FILTER_REJECT; 23278 }, 23279 }, 23280 ); 23281 23282 var textNodes = []; 23283 while (walker.nextNode()) textNodes.push(walker.currentNode); 23284 23285 textNodes.forEach(function (textNode) { 23286 var text = textNode.textContent; 23287 var parts = []; 23288 var re = /\$\$([\s\S]+?)\$\$|\$([^\s$](?:[^$]*[^\s$])?)\$/g; 23289 var lastIndex = 0; 23290 var match; 23291 23292 while ((match = re.exec(text)) !== null) { 23293 if (match.index > lastIndex) { 23294 parts.push({ 23295 type: "text", 23296 value: text.slice(lastIndex, match.index), 23297 }); 23298 } 23299 parts.push({ 23300 type: match[1] != null ? "display" : "inline", 23301 value: match[1] != null ? match[1] : match[2], 23302 }); 23303 lastIndex = re.lastIndex; 23304 } 23305 23306 if (!parts.length) return; 23307 if (lastIndex < text.length) { 23308 parts.push({ type: "text", value: text.slice(lastIndex) }); 23309 } 23310 23311 var frag = document.createDocumentFragment(); 23312 parts.forEach(function (part) { 23313 if (part.type === "text") { 23314 frag.appendChild(document.createTextNode(part.value)); 23315 return; 23316 } 23317 var wrapper = document.createElement( 23318 part.type === "display" ? "div" : "span", 23319 ); 23320 wrapper.className = 23321 part.type === "display" 23322 ? "katex-display-block" 23323 : "katex-inline"; 23324 try { 23325 katex.render(part.value, wrapper, { 23326 displayMode: part.type === "display", 23327 throwOnError: false, 23328 trust: false, 23329 }); 23330 } catch (_e) { 23331 wrapper.textContent = part.value; 23332 } 23333 frag.appendChild(wrapper); 23334 }); 23335 textNode.parentNode.replaceChild(frag, textNode); 23336 }); 23337 }) 23338 .catch(function () { 23339 // KaTeX is best-effort 23340 }); 23341 } 23342 23343 // ââ Markdown pre-processing (math + footnotes) ââââââââââ 23344 function preprocessMath(doc) { 23345 // Replace $$...$$ blocks with placeholder divs while preserving 23346 // a mapping back to the original markdown offsets. 23347 var source = doc.text; 23348 var match; 23349 var lastIndex = 0; 23350 var builder = createMappedBuilder(); 23351 var re = /\$\$([\s\S]+?)\$\$/g; 23352 var changed = false;
23353 23354 while ((match = re.exec(source)) !== null) { 23355 changed = true; 23356 appendDocumentSlice(builder, doc, lastIndex, match.index); 23357 23358 var encoded = match[1] 23359 .replace(/&/g, "&") 23360 .replace(/</g, "<") 23361 .replace(/>/g, ">") 23362 .replace(/"/g, """); 23363 var range = mapDocumentRangeToSource( 23364 doc, 23365 match.index, 23366 match.index + match[0].length, 23367 ); 23368 23369 appendMappedText( 23370 builder, 23371 '<div class="katex-display-block" data-katex-display="' + 23372 encoded + 23373 '"></div>', 23374 range.start, 23375 range.end, 23376 ); 23377 lastIndex = match.index + match[0].length; 23378 } 23379 23380 if (!changed) return doc; 23381 23382 appendDocumentSlice(builder, doc, lastIndex, source.length); 23383 return finishMappedDocument(builder); 23384 } 23385 23386 function preprocessFootnotes(doc) { 23387 // Collect footnote definitions, replace inline references, and keep a 23388 // source map so preview blocks can still point back to the editor. 23389 var source = doc.text; 23390 var definitionRegex = 23391 /^\[\^([^\]]+)\]:\s+([\s\S]*?)(?=\n\[\^|\n\n(?!\s)|$)/gm; 23392 var definitionMatch; 23393 var definitions = {}; 23394 var definitionList = []; 23395 23396 while ((definitionMatch = definitionRegex.exec(source)) !== null) { 23397 var mappedRange = mapDocumentRangeToSource( 23398 doc, 23399 definitionMatch.index, 23400 definitionMatch.index + definitionMatch[0].length, 23401 ); 23402 23403 definitions[definitionMatch[1]] = { 23404 text: definitionMatch[2].replace(/\n\s{2,}/g, " ").trim(), 23405 start: definitionMatch.index, 23406 end: definitionMatch.index + definitionMatch[0].length, 23407 srcStart: mappedRange.start, 23408 srcEnd: mappedRange.end, 23409 }; 23410 definitionList.push(definitions[definitionMatch[1]]); 23411 } 23412 23413 if (!definitionList.length) return doc; 23414 23415 var builder = createMappedBuilder(); 23416 var usedOrder = []; 23417 var cursor = 0; 23418 23419 function appendSliceWithReferences(start, end) { 23420 if (end <= start) return; 23421 23422 var localSource = source.slice(start, end); 23423 var localCursor = 0; 23424 var referenceRegex = /\[\^([^\]]+)\]/g; 23425 var referenceMatch; 23426 23427 while ( 23428 (referenceMatch = referenceRegex.exec(localSource)) !== null 23429 ) { 23430 var refStart = start + referenceMatch.index; 23431 var refEnd = refStart + referenceMatch[0].length; 23432 var id = referenceMatch[1]; 23433 23434 appendDocumentSlice(builder, doc, start + localCursor, refStart); 23435 23436 if (!definitions[id]) { 23437 appendDocumentSlice(builder, doc, refStart, refEnd); 23438 localCursor = referenceMatch.index + referenceMatch[0].length; 23439 continue; 23440 } 23441 23442 var idx = usedOrder.indexOf(id); 23443 if (idx === -1) { 23444 usedOrder.push(id); 23445 idx = usedOrder.length - 1; 23446 } 23447 23448 var num = idx + 1; 23449 appendMappedText( 23450 builder, 23451 '<sup class="footnote-ref"><a href="#fn-' + 23452 id + 23453 '" id="fnref-' + 23454 id + 23455 '">' + 23456 num + 23457 "</a></sup>", 23458 definitions[id].srcStart, 23459 definitions[id].srcEnd, 23460 ); 23461 localCursor = referenceMatch.index + referenceMatch[0].length; 23462 } 23463 23464 appendDocumentSlice(builder, doc, start + localCursor, end); 23465 } 23466 23467 definitionList.forEach(function (definition) { 23468 appendSliceWithReferences(cursor, definition.start); 23469 cursor = definition.end; 23470 }); 23471 appendSliceWithReferences(cursor, source.length); 23472 23473 if (usedOrder.length) { 23474 var sectionRange = definitionList[0]; 23475 appendMappedText( 23476 builder, 23477 "\n\n---\n\n", 23478 sectionRange.srcStart, 23479 sectionRange.srcEnd, 23480 ); 23481 appendMappedText( 23482 builder, 23483 '<section class="footnotes" role="doc-endnotes">\n<ol>\n', 23484 sectionRange.srcStart, 23485 sectionRange.srcEnd, 23486 ); 23487 usedOrder.forEach(function (id, i) { 23488 appendMappedText( 23489 builder, 23490 '<li id="fn-' + 23491 id + 23492 '" value="' + 23493 (i + 1) + 23494 '">' + 23495 definitions[id].text + 23496 ' <a href="#fnref-' + 23497 id + 23498 '" class="footnote-backref" aria-label="Back to reference">â©</a></li>\n', 23499 definitions[id].srcStart, 23500 definitions[id].srcEnd, 23501 ); 23502 }); 23503 appendMappedText( 23504 builder, 23505 "</ol>\n</section>", 23506 sectionRange.srcStart, 23507 sectionRange.srcEnd, 23508 ); 23509 } 23510 23511 return finishMappedDocument(builder); 23512 } 23513 23514 function preprocessMarkdownWithSourceMap(markdown) { 23515 var doc = createIdentityMappedDocument(markdown); 23516 doc = preprocessFootnotes(doc); 23517 doc = preprocessMath(doc); 23518 return doc; 23519 } 23520 23521 function annotateTokenSourceRanges(tokens, sourceDoc) { 23522 var cursor = 0; 23523 23524 tokens.forEach(function (token) { 23525 var raw = String(token.raw || ""); 23526 token.__preStart = cursor; 23527 token.__preEnd = cursor + raw.length; 23528 token.__sourceStart = mapDocumentOffsetToSource( 23529 sourceDoc, 23530 token.__preStart, 23531 ); 23532 token.__sourceEnd = mapDocumentOffsetToSource( 23533 sourceDoc, 23534 token.__preEnd, 23535 ); 23536 23537 if (token.type === "list" && Array.isArray(token.items)) { 23538 var itemCursor = token.__preStart;
23539 token.items.forEach(function (item) { 23540 var itemRaw = String(item.raw || ""); 23541 item.__preStart = itemCursor; 23542 item.__preEnd = itemCursor + itemRaw.length; 23543 item.__sourceStart = mapDocumentOffsetToSource( 23544 sourceDoc, 23545 item.__preStart, 23546 ); 23547 item.__sourceEnd = mapDocumentOffsetToSource( 23548 sourceDoc, 23549 item.__preEnd, 23550 ); 23551 itemCursor = item.__preEnd; 23552 }); 23553 } 23554 23555 cursor = token.__preEnd; 23556 }); 23557 23558 return tokens; 23559 } 23560 23561 function renderKatexPlaceholders(generation) { 23562 var placeholders = els.preview.querySelectorAll( 23563 "[data-katex-display]", 23564 ); 23565 if (!placeholders.length) return; 23566 23567 getKatexApi() 23568 .then(function (katex) { 23569 if (generation !== state.previewRenderGeneration) return; 23570 placeholders.forEach(function (el) { 23571 var latex = el.getAttribute("data-katex-display"); 23572 if (!latex) return; 23573 // Decode HTML entities 23574 var decoded = latex 23575 .replace(/</g, "<") 23576 .replace(/>/g, ">") 23577 .replace(/"/g, '"') 23578 .replace(/&/g, "&"); 23579 try { 23580 katex.render(decoded, el, { 23581 displayMode: true, 23582 throwOnError: false, 23583 trust: false, 23584 }); 23585 } catch (_e) { 23586 el.textContent = decoded; 23587 } 23588 el.removeAttribute("data-katex-display"); 23589 }); 23590 }) 23591 .catch(function () {}); 23592 } 23593 23594 function preservePreviewScroll(renderFn) { 23595 var shouldRestore = !isEditModeActive(); 23596 var previousOffset = shouldRestore 23597 ? getPreviewViewportSourceOffset() 23598 : null; 23599 var previousRatio = shouldRestore ? captureWindowScrollRatio() : 0; 23600 23601 renderFn(); 23602 23603 if (!shouldRestore) return; 23604 23605 if (typeof previousOffset === "number") { 23606 scrollPreviewToSourceOffset(previousOffset); 23607 } else { 23608 applyWindowScrollRatio(previousRatio); 23609 } 23610 } 23611 23612 function render(source) { 23613 const markdown = els.editor.value; 23614 const documentKey = 23615 state.sourceMode === "untitled" 23616 ? state.untitledDraftId || state.currentName 23617 : state.currentName; 23618 const documentToken = 23619 (documentKey || "__draft__") + "::" + state.sourceMode; 23620 const generation = state.previewRenderGeneration + 1; 23621 const editModeActive = isEditModeActive(); 23622 const preserveEditorFocus = 23623 source === "editor" && 23624 editModeActive && 23625 document.activeElement === els.editor; 23626 const editorSelectionStart = preserveEditorFocus 23627 ? els.editor.selectionStart 23628 : null; 23629 const editorSelectionEnd = preserveEditorFocus 23630 ? els.editor.selectionEnd 23631 : null; 23632 const editorScrollTop = preserveEditorFocus 23633 ? els.editor.scrollTop 23634 : 0; 23635 23636 if ( 23637 state.contentVersion === state.lastRenderedContentVersion && 23638 documentToken === state.lastRenderedDocumentToken && 23639 source !== "force" 23640 ) { 23641 syncCurrentHighlights(); 23642 setStatus( 23643 state.currentName ? state.currentName + " ⢠ready" : "Ready", 23644 ); 23645 return; 23646 } 23647 23648 let html = ""; 23649 if (markdown.trim()) { 23650 try { 23651 var preprocessed = preprocessMarkdownWithSourceMap(markdown); 23652 var parseOptions = Object.assign( 23653 {}, 23654 marked.defaults, 23655 markedOptions, 23656 ); 23657 var tokens = annotateTokenSourceRanges( 23658 marked.lexer(preprocessed.text, parseOptions), 23659 preprocessed, 23660 ); 23661 html = marked.parser(tokens, parseOptions); 23662 } catch (error) { 23663 html = 23664 "<pre><code>" + 23665 escapeHtml( 23666 String(error && error.message ? error.message : error), 23667 ) + 23668 "</code></pre>"; 23669 } 23670 } else { 23671 resetMindmapState({ closeViewer: false }); 23672 html = 23673 '<div class="empty-state">' + 23674 '<div class="empty-state-mark" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="currentColor"><path d="M46 0L46 46L0 46Q6 6 46 0Z"/><path d="M54 0L54 46L100 46Q94 6 54 0Z"/><path d="M46 100L46 54L0 54Q6 94 46 100Z"/><path d="M54 100L54 54L100 54Q94 94 54 100Z"/></svg></div>' + 23675 '<h2 class="empty-state-title">Begin reading</h2>' + 23676 '<p class="empty-state-hint">Drop a markdown file here, paste content, or open a .md file to start.</p>' + 23677 '<div class="empty-state-keys"><span><kbd>Ctrl</kbd><kbd>V</kbd> paste</span><span><kbd>Ctrl</kbd><kbd>E</kbd> edit</span></div>' + 23678 "</div>"; 23679 } 23680 23681 preservePreviewScroll(function () { 23682 if (!editModeActive) {
23683 dismissSelectionUi(); 23684 clearBrowserSelection(); 23685 } 23686 state.highlightTextModel = null; 23687 state.resolvedHighlights = []; 23688 els.preview.innerHTML = html; 23689 document.documentElement.classList.toggle( 23690 "empty-preview-state", 23691 !markdown.trim(), 23692 ); 23693 document.body.classList.toggle( 23694 "empty-preview-state", 23695 !markdown.trim(), 23696 ); 23697 els.previewPane.classList.toggle( 23698 "is-empty-state", 23699 !markdown.trim(), 23700 ); 23701 els.preview.dataset.documentLabel = 23702 state.currentName || "current document"; 23703 wrapTables(); 23704 decorateCodeBlocks(); 23705 decorateLinks(); 23706 applyHighlightsToPreview(); 23707 }); 23708 state.previewRenderGeneration = generation; 23709 refreshReaderRail(markdown, generation).catch(function (error) { 23710 console.warn("[reader-rail] refresh failed", error); 23711 hideReaderRail(); 23712 }); 23713 if (hasMermaidBlocks(markdown)) { 23714 renderMermaidBlocks(generation); 23715 } 23716 applyHighlightJs(generation); 23717 if (hasLatex(markdown)) { 23718 renderKatexPlaceholders(generation); 23719 renderKatexInPreview(generation); 23720 } 23721 23722 state.lastRenderedContentVersion = state.contentVersion; 23723 state.lastRenderedDocumentToken = documentToken; 23724 if (state.sourceMode === "untitled") { 23725 state.hasSessionDraft = false; 23726 removeStorage(window.sessionStorage, SESSION_CONTENT_KEY); 23727 captureActiveUntitledDraft({ touchLastActive: false }); 23728 } else { 23729 scheduleSessionContentWrite(markdown); 23730 } 23731 writeSessionMeta(); 23732 updateUiState(); 23733 23734 const label = state.currentName ? state.currentName + " ⢠" : ""; 23735 const action = 23736 source === "editor" 23737 ? state.dirty && 23738 (state.fileHandle || 23739 state.currentName || 23740 state.libraryCurrentFileHandle) 23741 ? "edited locally" 23742 : "edited" 23743 : source === "handle" 23744 ? "reloaded" 23745 : source === "file" 23746 ? "loaded" 23747 : source === "library" 23748 ? "loaded" 23749 : source === "untitled" 23750 ? "loaded" 23751 : "rendered"; 23752 23753 const detail = source === "editor" ? "" : " ⢠" + nowString(); 23754 setStatus(label + action + detail); 23755 updateWordCount(); 23756 if (preserveEditorFocus && document.activeElement !== els.editor) { 23757 requestAnimationFrame(function () { 23758 try { 23759 els.editor.focus({ preventScroll: true }); 23760 } catch (_error) { 23761 els.editor.focus(); 23762 } 23763 try { 23764 els.editor.setSelectionRange( 23765 editorSelectionStart, 23766 editorSelectionEnd, 23767 ); 23768 } catch (_error) {} 23769 els.editor.scrollTop = editorScrollTop; 23770 }); 23771 } 23772 document.dispatchEvent( 23773 new CustomEvent("markv:render-complete", { 23774 detail: { 23775 documentName: state.currentName || "", 23776 renderSource: source, 23777 timestamp: Date.now(), 23778 }, 23779 }), 23780 ); 23781 } 23782 23783 function scheduleRender() { 23784 window.clearTimeout(state.renderTimer); 23785 state.renderTimer = window.setTimeout(function () { 23786 if (isUntitledDraftMode()) { 23787 state.dirty = true; 23788 state.hasSessionDraft = false; 23789 captureActiveUntitledDraft({ dirty: true }); 23790 render("editor"); 23791 return; 23792 } 23793 23794 if ( 23795 state.fileHandle || 23796 state.sourceMode === "file" || 23797 state.sourceMode === "library" 23798 ) { 23799 state.dirty = true; 23800 if (els.autoRefreshToggle.checked) { 23801 els.autoRefreshToggle.checked = false;
23802 stopAutoRefresh(); 23803 } 23804 } 23805 state.hasSessionDraft = true; 23806 state.sourceMode = "editor"; 23807 state.currentName = state.currentName || ""; 23808 render("editor"); 23809 }, 90); 23810 } 23811 23812 async function loadFromHandle(quiet) { 23813 if (!state.fileHandle) return; 23814 23815 await flushActiveUntitledDraft().catch(function (error) { 23816 console.warn("[drafts] active draft flush failed", error); 23817 }); 23818 const file = await state.fileHandle.getFile(); 23819 const markdown = await file.text(); 23820 detachSharedSnapshotContext(); 23821 23822 state.untitledDraftId = ""; 23823 state.sourceMode = "handle"; 23824 state.currentName = file.name; 23825 state.baseHref = window.location.href; 23826 state.dirty = false; 23827 state.hasSessionDraft = false; 23828 await clearActiveLibrarySelection({ 23829 persist: true, 23830 renderDrawer: true, 23831 }); 23832 23833 setEditorMarkdown(markdown); 23834 updateUiState(); 23835 23836 render("handle"); 23837 23838 if (!quiet) { 23839 setStatus("Reloaded ⢠" + nowString(), { 23840 announce: true, 23841 }); 23842 } 23843 } 23844 23845 async function loadFromFile(file) { 23846 if (!file) return; 23847 23848 await flushActiveUntitledDraft().catch(function (error) { 23849 console.warn("[drafts] active draft flush failed", error); 23850 }); 23851 const markdown = await file.text(); 23852 23853 stopAutoRefresh(); 23854 els.autoRefreshToggle.checked = false; 23855 detachSharedSnapshotContext(); 23856 23857 setPrimaryFileHandle(null); 23858 state.untitledDraftId = ""; 23859 state.sourceMode = "file"; 23860 state.currentName = file.name; 23861 state.baseHref = window.location.href; 23862 state.dirty = false; 23863 state.hasSessionDraft = false; 23864 await clearActiveLibrarySelection({ 23865 persist: true, 23866 renderDrawer: true, 23867 }); 23868 23869 setEditorMarkdown(markdown); 23870 updateUiState(); 23871 23872 render("file"); 23873 setStatus("Loaded ⢠" + nowString(), { 23874 announce: true, 23875 }); 23876 } 23877 23878 function stopAutoRefresh() { 23879 if (state.autoRefreshTimer) { 23880 window.clearTimeout(state.autoRefreshTimer); 23881 state.autoRefreshTimer = null; 23882 } 23883 state.autoRefreshInFlight = false; 23884 } 23885 23886 function scheduleNextAutoRefresh() { 23887 if (!els.autoRefreshToggle.checked) return; 23888 if (!getReaderRefreshState().canAutoRefresh) return; 23889 state.autoRefreshTimer = window.setTimeout(runAutoRefreshTick, 1500); 23890 } 23891 23892 async function runAutoRefreshTick() { 23893 if (!els.autoRefreshToggle.checked) { 23894 stopAutoRefresh(); 23895 return; 23896 } 23897 if (state.autoRefreshInFlight) { 23898 scheduleNextAutoRefresh(); 23899 return; 23900 } 23901 23902 state.autoRefreshInFlight = true; 23903 try { 23904 const refreshState = getReaderRefreshState(); 23905 if (refreshState.reloadAction === "file-handle") { 23906 await loadFromHandle(true); 23907 } else if ( 23908 refreshState.reloadAction === "library-persistent" && 23909 state.libraryActiveFolderId && 23910 state.libraryActiveFilePath 23911 ) { 23912 await loadFromLibrarySelection( 23913 state.libraryActiveFolderId, 23914 state.libraryActiveFilePath, 23915 { 23916 closeDrawerOnMobile: false, 23917 quiet: true, 23918 userInitiated: false, 23919 }, 23920 ); 23921 } else { 23922 stopAutoRefresh(); 23923 els.autoRefreshToggle.checked = false; 23924 updateUiState(); 23925 return; 23926 } 23927 } catch (error) { 23928 stopAutoRefresh(); 23929 els.autoRefreshToggle.checked = false; 23930 updateUiState(); 23931 setStatus( 23932 "Auto refresh stopped ⢠" +
23933 (error && error.message ? error.message : String(error)), 23934 { announce: true }, 23935 ); 23936 return; 23937 } finally { 23938 state.autoRefreshInFlight = false; 23939 } 23940 23941 scheduleNextAutoRefresh(); 23942 } 23943 23944 function startAutoRefresh() { 23945 stopAutoRefresh(); 23946 23947 if (!els.autoRefreshToggle.checked) return; 23948 if (!getReaderRefreshState().canAutoRefresh) { 23949 els.autoRefreshToggle.checked = false; 23950 updateUiState(); 23951 return; 23952 } 23953 23954 scheduleNextAutoRefresh(); 23955 } 23956 23957 async function openFile() { 23958 if (window.showOpenFilePicker && window.isSecureContext) { 23959 try { 23960 const handles = await window.showOpenFilePicker({ 23961 multiple: false, 23962 types: [ 23963 { 23964 description: "Markdown files", 23965 accept: { 23966 "text/markdown": [".md", ".markdown", ".mdown", ".mkdn"], 23967 "text/plain": [".txt"], 23968 }, 23969 }, 23970 ], 23971 }); 23972 23973 if (handles && handles[0]) { 23974 stopAutoRefresh(); 23975 els.autoRefreshToggle.checked = false; 23976 23977 setPrimaryFileHandle(handles[0]); 23978 state.sourceMode = "handle"; 23979 state.currentName = handles[0].name; 23980 state.baseHref = window.location.href; 23981 23982 updateUiState(); 23983 await loadFromHandle(false); 23984 if (state.libraryIsOpen) { 23985 hideLibraryDrawer({ restoreFocus: false }); 23986 } 23987 return; 23988 } 23989 } catch (error) { 23990 if (error && error.name !== "AbortError") { 23991 els.fileInput.click(); 23992 } 23993 return; 23994 } 23995 } 23996 23997 els.fileInput.click(); 23998 } 23999 24000 async function reloadCurrent() { 24001 try { 24002 const refreshState = getReaderRefreshState(); 24003 24004 if (refreshState.reloadAction === "file-handle") { 24005 await loadFromHandle(false); 24006 return; 24007 } 24008 24009 if ( 24010 refreshState.reloadAction === "library-persistent" && 24011 state.libraryActiveFolderId && 24012 state.libraryActiveFilePath 24013 ) { 24014 await loadFromLibrarySelection( 24015 state.libraryActiveFolderId, 24016 state.libraryActiveFilePath, 24017 { 24018 closeDrawerOnMobile: false, 24019 quiet: false, 24020 userInitiated: true, 24021 }, 24022 ); 24023 return; 24024 } 24025 24026 if ( 24027 refreshState.reloadAction === "library-snapshot" && 24028 state.libraryActiveFolderId 24029 ) { 24030 requestLibraryFolderImport({ 24031 replaceFolderId: state.libraryActiveFolderId, 24032 }); 24033 setStatus( 24034 "Choose the folder again to refresh this imported snapshot.", 24035 { announce: true }, 24036 ); 24037 return; 24038 } 24039 24040 if (refreshState.reloadAction === "file-picker") { 24041 els.fileInput.click(); 24042 return; 24043 } 24044 24045 render("force"); 24046 } catch (error) { 24047 setStatus( 24048 "Reload failed ⢠" + 24049 (error && error.message ? error.message : String(error)), 24050 { announce: true }, 24051 ); 24052 } 24053 } 24054 24055 async function saveCurrentDocument() { 24056 const saveState = getReaderSaveState(); 24057 if (!saveState.canSave) return; 24058 24059 const markdown = els.editor.value; 24060 const suggestedName = getSuggestedSaveName(); 24061 24062 try { 24063 if (saveState.saveAction === "overwrite-handle") { 24064 const canWrite = await ensureWritableHandle(state.fileHandle); 24065 if (!canWrite) { 24066 setStatus("Save cancelled ⢠write access was not granted.", { 24067 announce: true, 24068 }); 24069 return; 24070 } 24071 await writeMarkdownToHandle(state.fileHandle, markdown); 24072 state.currentName = state.fileHandle.name || suggestedName; 24073 markDocumentSaved("handle"); 24074 setStatus("Saved ⢠" + nowString(), { 24075 announce: true, 24076 }); 24077 return; 24078 } 24079 24080 if (saveState.saveAction === "overwrite-library-handle") { 24081 const canWrite = await ensureWritableHandle( 24082 state.libraryCurrentFileHandle, 24083 ); 24084 if (!canWrite) { 24085 setStatus("Save cancelled ⢠write access was not granted.", { 24086 announce: true, 24087 }); 24088 return; 24089 } 24090 await writeMarkdownToHandle( 24091 state.libraryCurrentFileHandle, 24092 markdown, 24093 ); 24094 state.currentName = 24095 state.libraryCurrentFileHandle.name || suggestedName; 24096 markDocumentSaved("library"); 24097 setStatus("Saved ⢠" + nowString(), { 24098 announce: true, 24099 }); 24100 return; 24101 } 24102 24103 if (saveState.saveAction === "pick-save-target") { 24104 if (canUseNativeSavePicker()) { 24105 const handle = await window.showSaveFilePicker({ 24106 suggestedName: suggestedName, 24107 types: createMarkdownFileTypes(), 24108 }); 24109 const canWrite = await ensureWritableHandle(handle); 24110 if (!canWrite) { 24111 setStatus("Save cancelled ⢠write access was not granted.", { 24112 announce: true, 24113 }); 24114 return; 24115 } 24116 await writeMarkdownToHandle(handle, markdown); 24117 stopAutoRefresh(); 24118 els.autoRefreshToggle.checked = false;
24119 setPrimaryFileHandle(handle); 24120 await detachUntitledDraftAfterSave(); 24121 state.currentName = handle.name || suggestedName; 24122 state.baseHref = window.location.href; 24123 await clearActiveLibrarySelection({ 24124 persist: true, 24125 renderDrawer: true, 24126 }); 24127 markDocumentSaved("handle"); 24128 setStatus("Saved ⢠" + nowString(), { 24129 announce: true, 24130 }); 24131 return; 24132 } 24133 24134 downloadMarkdown(markdown, suggestedName); 24135 await detachUntitledDraftAfterSave(); 24136 state.sourceMode = "editor"; 24137 state.currentName = suggestedName; 24138 markDocumentSaved(null); 24139 setStatus(suggestedName + " ⢠downloaded ⢠" + nowString(), { 24140 announce: true, 24141 }); 24142 } 24143 } catch (error) { 24144 if (error && error.name === "AbortError") return; 24145 setStatus( 24146 "Save failed ⢠" + 24147 (error && error.message ? error.message : String(error)), 24148 { announce: true }, 24149 ); 24150 } 24151 } 24152 24153 async function clearAll() { 24154 const activeDraft = getActiveUntitledDraft(); 24155 if ( 24156 activeDraft && 24157 String(activeDraft.content || els.editor.value || "").trim() 24158 ) { 24159 const confirmed = window.confirm( 24160 "Discard " + activeDraft.title + "? This cannot be undone.", 24161 ); 24162 if (!confirmed) return; 24163 } 24164 24165 stopAutoRefresh(); 24166 els.autoRefreshToggle.checked = false; 24167 detachSharedSnapshotContext(); 24168 dismissSelectionUi(); 24169 hideHighlightUndoToast(); 24170 24171 setPrimaryFileHandle(null); 24172 state.sourceMode = "editor"; 24173 state.currentName = ""; 24174 state.baseHref = window.location.href; 24175 invalidateLastRenderedDocument(); 24176 state.dirty = false; 24177 state.hasSessionDraft = false; 24178 state.highlightDocKey = ""; 24179 state.currentHighlights = []; 24180 state.resolvedHighlights = []; 24181 state.highlightTextModel = null; 24182 if (activeDraft) { 24183 await deleteUntitledDraftRecord(activeDraft.id, { 24184 renderDrawer: false, 24185 }); 24186 } 24187 await clearLibraryCollection({ 24188 persist: true, 24189 renderDrawer: true, 24190 }); 24191 24192 setEditorMarkdown(""); 24193 resetMindmapState({ closeViewer: true }); 24194 24195 removeStorage(window.sessionStorage, SESSION_CONTENT_KEY); 24196 removeStorage(window.sessionStorage, SESSION_META_KEY); 24197 updateUiState(); 24198 render("force"); 24199 setStatus("Cleared", { announce: true }); 24200 } 24201 24202 function syncEditorSelectionState() { 24203 state.editorSelection = els.editor.selectionStart || 0; 24204 } 24205 24206 els.editor.addEventListener("input", function () { 24207 markEditorContentChanged(); 24208 syncEditorSelectionState(); 24209 updateUiState(); 24210 scheduleRender(); 24211 }); 24212 24213 function applyEditorMarkdownEdit(edit) { 24214 if (!edit) return false; 24215 els.editor.focus(); 24216 els.editor.setSelectionRange(edit.start, edit.end); 24217 let ok = false; 24218 try { 24219 ok = document.execCommand("insertText", false, edit.text); 24220 } catch (_error) { 24221 ok = false; 24222 } 24223 if (!ok) { 24224 els.editor.setRangeText(edit.text, edit.start, edit.end, "end"); 24225 els.editor.dispatchEvent(new Event("input", { bubbles: true })); 24226 } 24227 if (typeof edit.selectionStart === "number") { 24228 els.editor.setSelectionRange( 24229 edit.selectionStart,
24230 typeof edit.selectionEnd === "number" 24231 ? edit.selectionEnd 24232 : edit.selectionStart, 24233 ); 24234 } 24235 markEditorContentChanged(); 24236 syncEditorSelectionState(); 24237 updateUiState(); 24238 scheduleRender(); 24239 return true; 24240 } 24241 24242 function isEditorCompositionEvent(event) { 24243 return Boolean(event && (event.isComposing || event.keyCode === 229)); 24244 } 24245 24246 els.editor.addEventListener("keydown", function (event) { 24247 if (isEditorCompositionEvent(event)) return; 24248 const markdownActions = window.MarkVEditorMarkdownActions; 24249 if (!markdownActions) return; 24250 24251 if ( 24252 event.key === "Enter" && 24253 !event.metaKey && 24254 !event.ctrlKey && 24255 !event.altKey 24256 ) { 24257 const edit = markdownActions.handleEnter( 24258 els.editor.value, 24259 els.editor.selectionStart, 24260 els.editor.selectionEnd, 24261 ); 24262 if (edit && applyEditorMarkdownEdit(edit)) { 24263 event.preventDefault(); 24264 } 24265 return; 24266 } 24267 24268 if (event.key === "Tab") { 24269 const edit = markdownActions.handleTab( 24270 els.editor.value, 24271 els.editor.selectionStart, 24272 els.editor.selectionEnd, 24273 event.shiftKey, 24274 ); 24275 if (edit && applyEditorMarkdownEdit(edit)) { 24276 event.preventDefault(); 24277 return; 24278 } 24279 if (!event.shiftKey) event.preventDefault(); 24280 } 24281 }); 24282 24283 ["click", "keyup", "select"].forEach(function (eventName) { 24284 els.editor.addEventListener(eventName, syncEditorSelectionState); 24285 }); 24286 24287 document.addEventListener("selectionchange", function () { 24288 const controller = captureSelectionController(); 24289 if (!controller) { 24290 hideSelectionBubble(); 24291 hideContextMenu(); 24292 } 24293 }); 24294 24295 els.previewPane.addEventListener("mouseup", function () { 24296 scheduleSelectionBubble(); 24297 }); 24298 24299 document.addEventListener("contextmenu", function (event) { 24300 if (!event.target.closest(".preview-pane, .markdown-body")) return; 24301 hideSelectionBubble(); 24302 hideContextMenu(); 24303 }); 24304 24305 document.addEventListener("pointerdown", function (event) { 24306 const target = event.target; 24307 const insideMermaidViewer = 24308 state.mermaidViewerOpen && els.mermaidViewer.contains(target); 24309 const insideSelectionUi = 24310 els.selectionBubble.contains(target) || 24311 els.ctxMenu.contains(target) || 24312 els.highlightRemoveChip.contains(target); 24313 24314 if ( 24315 state.mermaidViewerOpen && 24316 !insideMermaidViewer && 24317 !target.closest(".mermaid-inline-action") 24318 ) { 24319 closeMermaidViewer({ restoreFocus: false }); 24320 } 24321 24322 if (insideMermaidViewer) { 24323 return; 24324 } 24325 24326 if (!insideSelectionUi) { 24327 hideSelectionBubble(); 24328 hideContextMenu(); 24329 } 24330 24331 if ( 24332 !insideSelectionUi && 24333 !target.closest(".doc-highlight") && 24334 !target.closest(".preview-pane, .markdown-body") 24335 ) { 24336 hideActiveHighlight(); 24337 } 24338 }); 24339 24340 window.addEventListener("scroll", function () { 24341 hideSelectionBubble(); 24342 hideContextMenu(); 24343 hideActiveHighlight(); 24344 }); 24345 24346 window.addEventListener("resize", function () { 24347 if (state.mermaidViewerOpen) { 24348 fitMermaidViewer(); 24349 } 24350 hideSelectionBubble(); 24351 hideContextMenu(); 24352 hideActiveHighlight(); 24353 }); 24354 24355 els.preview.addEventListener("click", function (event) { 24356 const mermaidAction = event.target.closest(".mermaid-inline-action"); 24357 if (mermaidAction) { 24358 event.preventDefault(); 24359 event.stopPropagation(); 24360 openMermaidViewerFromHost(mermaidAction.closest(".mermaid-block")); 24361 return; 24362 } 24363 24364 const mermaidSvg = event.target.closest(".mermaid-block svg"); 24365 if (mermaidSvg) { 24366 event.preventDefault(); 24367 openMermaidViewerFromHost(mermaidSvg.closest(".mermaid-block")); 24368 return; 24369 } 24370 24371 if (event.target.closest("a[href]")) return; 24372 24373 const mark = event.target.closest(".doc-highlight"); 24374 if (!mark) { 24375 hideActiveHighlight(); 24376 return; 24377 } 24378 24379 event.preventDefault(); 24380 event.stopPropagation(); 24381 activateHighlight(mark.dataset.highlightId || ""); 24382 }); 24383 24384 [ 24385 els.selectionCopyBtn, 24386 els.selectionHighlightBtn, 24387 els.selectionAskBtn, 24388 els.selectionSummarizeBtn, 24389 els.highlightRemoveChip, 24390 els.highlightUndoBtn, 24391 els.ctxHighlight, 24392 els.ctxAsk, 24393 els.ctxSummarize, 24394 ].forEach(function (button) { 24395 button.addEventListener("pointerdown", function (event) { 24396 event.preventDefault(); 24397 }); 24398 }); 24399 24400 els.selectionCopyBtn.addEventListener("click", function () { 24401 copySelectedTextToClipboard(); 24402 }); 24403 els.selectionHighlightBtn.addEventListener("click", function () { 24404 addHighlightFromSelection(); 24405 }); 24406 els.selectionAskBtn.addEventListener("click", function () { 24407 dispatchSelectionAction("ask"); 24408 }); 24409 els.selectionSummarizeBtn.addEventListener("click", function () { 24410 dispatchSelectionAction("summarize"); 24411 }); 24412 24413 els.ctxHighlight.addEventListener("click", function () { 24414 hideContextMenu(); 24415 addHighlightFromSelection(); 24416 }); 24417 els.ctxAsk.addEventListener("click", function () { 24418 hideContextMenu(); 24419 dispatchSelectionAction("ask"); 24420 }); 24421 els.ctxSummarize.addEventListener("click", function () { 24422 hideContextMenu(); 24423 dispatchSelectionAction("summarize"); 24424 }); 24425 24426 els.highlightRemoveChip.addEventListener("click", function () { 24427 if (!state.activeHighlightId) return; 24428 removeHighlightById(state.activeHighlightId); 24429 }); 24430 24431 els.highlightUndoBtn.addEventListener("click", function () { 24432 if (!state.highlightUndo) return; 24433 const undo = state.highlightUndo; 24434 hideHighlightUndoToast(); 24435 undo(); 24436 }); 24437 24438 els.mermaidViewerCloseBtn.addEventListener("click", function () { 24439 closeMermaidViewer(); 24440 }); 24441 els.mermaidViewerZoomInBtn.addEventListener("click", function () { 24442 applyMermaidViewerScale(state.mermaidViewerScale * 1.15); 24443 }); 24444 els.mermaidViewerZoomOutBtn.addEventListener("click", function () { 24445 applyMermaidViewerScale(state.mermaidViewerScale / 1.15); 24446 }); 24447 els.mermaidViewerResetBtn.addEventListener("click", function () { 24448 applyMermaidViewerScale(1, { 24449 contentX: state.mermaidViewerBaseWidth / 2, 24450 contentY: state.mermaidViewerBaseHeight / 2, 24451 }); 24452 }); 24453 els.mermaidViewerFitBtn.addEventListener("click", function () { 24454 fitMermaidViewer(); 24455 }); 24456 els.mermaidViewerDownloadBtn.addEventListener("click", function () { 24457 if (!state.mermaidViewerDownloadUrl) return; 24458 const link = document.createElement("a"); 24459 link.href = state.mermaidViewerDownloadUrl; 24460 link.download = "diagram.svg"; 24461 document.body.appendChild(link); 24462 link.click(); 24463 link.remove(); 24464 }); 24465 els.mermaidViewer.addEventListener("click", function (event) { 24466 if (event.target === els.mermaidViewer) { 24467 closeMermaidViewer(); 24468 } 24469 }); 24470 els.mermaidViewerStage.addEventListener("wheel", function (event) { 24471 if (!state.mermaidViewerOpen) return; 24472 event.preventDefault(); 24473 const rect = els.mermaidViewerStage.getBoundingClientRect(); 24474 const offsetX = event.clientX - rect.left; 24475 const offsetY = event.clientY - rect.top; 24476 const contentX = 24477 (els.mermaidViewerStage.scrollLeft + offsetX) / 24478 state.mermaidViewerScale; 24479 const contentY = 24480 (els.mermaidViewerStage.scrollTop + offsetY) / 24481 state.mermaidViewerScale; 24482 const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1; 24483 applyMermaidViewerScale(state.mermaidViewerScale * factor, { 24484 contentX: contentX, 24485 contentY: contentY, 24486 }); 24487 }); 24488 els.mermaidViewerStage.addEventListener( 24489 "pointerdown", 24490 function (event) { 24491 if (!state.mermaidViewerOpen) return; 24492 if (event.button !== 0) return; 24493 state.mermaidViewerDrag = { 24494 pointerId: event.pointerId, 24495 startX: event.clientX, 24496 startY: event.clientY, 24497 scrollLeft: els.mermaidViewerStage.scrollLeft, 24498 scrollTop: els.mermaidViewerStage.scrollTop, 24499 }; 24500 els.mermaidViewerStage.classList.add("is-dragging"); 24501 els.mermaidViewerStage.setPointerCapture(event.pointerId); 24502 }, 24503 ); 24504 els.mermaidViewerStage.addEventListener( 24505 "pointermove", 24506 function (event) { 24507 const drag = state.mermaidViewerDrag; 24508 if (!drag || drag.pointerId !== event.pointerId) return; 24509 els.mermaidViewerStage.scrollLeft = 24510 drag.scrollLeft - (event.clientX - drag.startX); 24511 els.mermaidViewerStage.scrollTop = 24512 drag.scrollTop - (event.clientY - drag.startY); 24513 }, 24514 ); 24515 function stopMermaidViewerDrag(event) { 24516 const drag = state.mermaidViewerDrag; 24517 if (!drag) return; 24518 if (event && drag.pointerId !== event.pointerId) return; 24519 state.mermaidViewerDrag = null; 24520 els.mermaidViewerStage.classList.remove("is-dragging"); 24521 } 24522 els.mermaidViewerStage.addEventListener( 24523 "pointerup", 24524 stopMermaidViewerDrag, 24525 ); 24526 els.mermaidViewerStage.addEventListener( 24527 "pointercancel", 24528 stopMermaidViewerDrag, 24529 ); 24530 24531 els.readingToggleBtn.addEventListener("click", function () { 24532 if (els.readingPanel.hidden) { 24533 showReadingPanel(); 24534 } else { 24535 hideReadingPanel(); 24536 } 24537 }); 24538 24539 els.readingCloseBtn.addEventListener("click", function () { 24540 hideReadingPanel(); 24541 }); 24542 24543 els.readingResetBtn.addEventListener("click", function () { 24544 applyReadingPreset("balanced", { announce: true }); 24545 }); 24546 24547 els.readingPresetButtons.forEach(function (button) { 24548 button.addEventListener("click", function () { 24549 applyReadingPreset(button.dataset.preset, { announce: true }); 24550 button.focus(); 24551 }); 24552 }); 24553 24554 els.readingPresetGroup.addEventListener("keydown", function (event) { 24555 const buttons = els.readingPresetButtons; 24556 const currentIndex = buttons.indexOf(document.activeElement); 24557 if (currentIndex === -1) return; 24558 24559 let nextIndex = currentIndex; 24560 if (event.key === "ArrowRight" || event.key === "ArrowDown") { 24561 nextIndex = (currentIndex + 1) % buttons.length; 24562 } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { 24563 nextIndex = (currentIndex - 1 + buttons.length) % buttons.length; 24564 } else if (event.key === "Home") { 24565 nextIndex = 0; 24566 } else if (event.key === "End") { 24567 nextIndex = buttons.length - 1; 24568 } else { 24569 return; 24570 } 24571 24572 event.preventDefault(); 24573 const nextButton = buttons[nextIndex]; 24574 applyReadingPreset(nextButton.dataset.preset); 24575 nextButton.focus(); 24576 }); 24577 24578 els.readingPaletteButtons.forEach(function (button) { 24579 button.addEventListener("click", function () { 24580 applyReaderPalette(button.dataset.readerPalette, { 24581 persist: true, 24582 announce: true, 24583 syncTheme: true, 24584 }); 24585 button.focus(); 24586 }); 24587 }); 24588 24589 els.readingPaletteGroup.addEventListener("keydown", function (event) { 24590 const buttons = els.readingPaletteButtons; 24591 const currentIndex = buttons.indexOf(document.activeElement); 24592 if (currentIndex === -1) return; 24593 24594 let nextIndex = currentIndex; 24595 if (event.key === "ArrowRight" || event.key === "ArrowDown") { 24596 nextIndex = (currentIndex + 1) % buttons.length; 24597 } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { 24598 nextIndex = (currentIndex - 1 + buttons.length) % buttons.length; 24599 } else if (event.key === "Home") { 24600 nextIndex = 0; 24601 } else if (event.key === "End") { 24602 nextIndex = buttons.length - 1; 24603 } else { 24604 return; 24605 } 24606 24607 event.preventDefault(); 24608 const nextButton = buttons[nextIndex]; 24609 applyReaderPalette(nextButton.dataset.readerPalette, { 24610 persist: true, 24611 syncTheme: true, 24612 }); 24613 nextButton.focus(); 24614 }); 24615 24616 [ 24617 { 24618 input: els.readingFontSizeInput, 24619 key: "fontSize", 24620 announcePrefix: "Text size", 24621 }, 24622 { 24623 input: els.readingLineHeightInput, 24624 key: "lineHeight", 24625 announcePrefix: "Line spacing", 24626 }, 24627 { 24628 input: els.readingMeasureInput, 24629 key: "measure", 24630 announcePrefix: "Reading width", 24631 }, 24632 ].forEach(function (control) { 24633 control.input.addEventListener("input", function () {
24634 updateReadingSetting(control.key, control.input.value, { 24635 persist: false, 24636 }); 24637 }); 24638 24639 control.input.addEventListener("change", function () { 24640 updateReadingSetting(control.key, control.input.value, { 24641 persist: true, 24642 announce: 24643 control.announcePrefix + 24644 " set to " + 24645 formatReadingValue( 24646 control.key, 24647 state.readingSettings[control.key], 24648 ) + 24649 ".", 24650 }); 24651 }); 24652 }); 24653 24654 document.addEventListener("pointerdown", function (event) { 24655 if (els.readingPanel.hidden) return; 24656 if ( 24657 els.readingPanel.contains(event.target) || 24658 els.readingToggleBtn.contains(event.target) 24659 ) { 24660 return; 24661 } 24662 hideReadingPanel({ restoreFocus: false }); 24663 }); 24664 24665 els.newDocumentBtn.addEventListener("click", createUntitledDocument); 24666 els.saveBtn.addEventListener("click", saveCurrentDocument); 24667 els.shareBtn.addEventListener("click", shareCurrentSnapshot); 24668 if (els.appToastDismiss) { 24669 els.appToastDismiss.addEventListener("click", function () { 24670 hideAppToast(false); 24671 }); 24672 } 24673 els.reloadBtn.addEventListener("click", reloadCurrent); 24674 els.clearBtn.addEventListener("click", clearAll); 24675 els.autoRefreshToggle.addEventListener("change", startAutoRefresh); 24676 if (document.readyState === "loading") { 24677 document.addEventListener("DOMContentLoaded", wireLibraryUi, { 24678 once: true, 24679 }); 24680 } else { 24681 wireLibraryUi(); 24682 } 24683 24684 wireMindmapInteractions(); 24685 wireMindmapNodeClicks(); 24686 wireMindmapHover(); 24687 wireReaderRail(); 24688 24689 function parseMindmapRetryDelayMs(value) { 24690 const raw = String(value || "").trim(); 24691 if (!raw) return 0; 24692 if (/^\d+(?:\.\d+)?$/.test(raw)) { 24693 return Math.max(0, Math.round(parseFloat(raw) * 1000)); 24694 } 24695 let total = 0; 24696 const regex = /(\d+(?:\.\d+)?)(ms|s|m|h)/gi; 24697 let match; 24698 while ((match = regex.exec(raw))) { 24699 const amount = parseFloat(match[1]); 24700 const unit = match[2].toLowerCase(); 24701 if (unit === "ms") total += amount; 24702 else if (unit === "s") total += amount * 1000; 24703 else if (unit === "m") total += amount * 60000; 24704 else if (unit === "h") total += amount * 3600000; 24705 } 24706 return Math.max(0, Math.round(total)); 24707 } 24708 24709 function formatMindmapRetryDelay(ms) { 24710 const seconds = Math.max(1, Math.ceil(ms / 1000)); 24711 if (seconds < 60) return `${seconds}s`; 24712 const minutes = Math.floor(seconds / 60); 24713 const remainder = seconds % 60; 24714 return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`; 24715 } 24716 24717 function isMindmapRateLimitError(err) { 24718 if (!err) return false; 24719 if (err.status === 429) return true; 24720 const message = String(err.message || "").toLowerCase(); 24721 return ( 24722 message.includes("rate limit") || 24723 message.includes("rate-limit") || 24724 message.includes("too many requests") 24725 ); 24726 } 24727 24728 function getMindmapRetryDelayMs(err) { 24729 if (!err) return 0; 24730 const fromRetryAfter = parseMindmapRetryDelayMs(err.retryAfter); 24731 if (fromRetryAfter > 0) return fromRetryAfter; 24732 const fromReset = parseMindmapRetryDelayMs(err.rateLimitResetTokens); 24733 if (fromReset > 0) return fromReset; 24734 return 15000; 24735 } 24736 24737 async function runMindmapPipeline(options) { 24738 const force = !!(options && options.force); 24739 const { loading, errorPane } = getMindmapEls(); 24740 const editor = document.getElementById("editor"); 24741 const rawMarkdown = editor ? editor.value : ""; 24742 24743 openMindmapViewer(); 24744 if (errorPane) errorPane.classList.add("is-hidden"); 24745 // Start every run with a clean loading pane so a prior run's spinner 24746 // can't linger into an early-return branch (empty doc / no client). 24747 if (loading) loading.classList.add("is-hidden"); 24748 24749 if (!rawMarkdown.trim()) { 24750 resetMindmapState({ closeViewer: false }); 24751 showMindmapError( 24752 "Load a markdown document first, then open the mindmap.", 24753 "empty_document", 24754 ); 24755 return; 24756 } 24757 24758 const libraryContext = 24759 await buildActiveLibraryMindmapContext(rawMarkdown); 24760 const libraryContextCacheKey = 24761 getMindmapLibraryContextCacheKey(libraryContext); 24762 const docHash = await hashString( 24763 rawMarkdown + "\n\n::library-context::" + libraryContextCacheKey, 24764 ); 24765 if (docHash !== mindmapState.currentDocHash) { 24766 resetMindmapState({ closeViewer: false }); 24767 } 24768 mindmapState.currentDocHash = docHash; 24769 24770 const cached = getCachedGraph(docHash); 24771 24772 // Cache check 24773 if (!force) { 24774 if (cached) { 24775 mindmapState.currentGraph = cached; 24776 mindmapState.laid = computeRadialLayout(cached); 24777 renderMindmap(mindmapState.laid); 24778 requestAnimationFrame(() => fitMindmapToView(false)); 24779 return; 24780 } 24781 } 24782 24783 if (mindmapState.isLoading) { 24784 return; 24785 } 24786 24787 if (mindmapState.rateLimitUntil > Date.now()) { 24788 if (cached) { 24789 mindmapState.currentGraph = cached; 24790 mindmapState.laid = computeRadialLayout(cached); 24791 renderMindmap(mindmapState.laid); 24792 requestAnimationFrame(() => fitMindmapToView(false)); 24793 } 24794 showMindmapError( 24795 `Rate limited by the LLM. Try again in about ${formatMindmapRetryDelay( 24796 mindmapState.rateLimitUntil - Date.now(), 24797 )}.`, 24798 "rate_limited", 24799 ); 24800 return; 24801 } 24802 24803 if (typeof window.markvLLM !== "function") { 24804 showMindmapError( 24805 "Configure an LLM client for AI-assisted concept extraction. Local deterministic connections are shown on the reading page when this document is opened from a library folder.", 24806 "no_client", 24807 ); 24808 return; 24809 } 24810 24811 // LLM path 24812 mindmapState.isLoading = true; 24813 if (loading) loading.classList.remove("is-hidden"); 24814 try { 24815 const graph = await extractConcepts(rawMarkdown, libraryContext); 24816 if (!graph.nodes || graph.nodes.length < 3) { 24817 throw new Error( 24818 "This document is too short or too abstract to map meaningfully. Try a document with at least a few sections.", 24819 ); 24820 } 24821 mindmapState.rateLimitUntil = 0; 24822 setCachedGraph(docHash, graph); 24823 mindmapState.currentGraph = graph; 24824 mindmapState.laid = computeRadialLayout(graph); 24825 renderMindmap(mindmapState.laid); 24826 requestAnimationFrame(() => fitMindmapToView(false)); 24827 } catch (err) { 24828 if (isMindmapRateLimitError(err)) { 24829 mindmapState.rateLimitUntil = 24830 Date.now() + getMindmapRetryDelayMs(err); 24831 const fallback = cached || mindmapState.currentGraph; 24832 if (fallback) { 24833 mindmapState.currentGraph = fallback; 24834 mindmapState.laid = computeRadialLayout(fallback); 24835 renderMindmap(mindmapState.laid); 24836 requestAnimationFrame(() => fitMindmapToView(false)); 24837 } 24838 } 24839 showMindmapError(humanizeMindmapError(err), "extract_failed"); 24840 } finally { 24841 mindmapState.isLoading = false;
24842 if (loading) loading.classList.add("is-hidden"); 24843 } 24844 } 24845 24846 function humanizeMindmapError(err) { 24847 const msg = (err && err.message) || ""; 24848 const lower = msg.toLowerCase(); 24849 if (lower.includes("groq api key")) { 24850 return "Add a Groq API key in AI settings, then try again."; 24851 } 24852 if (lower.includes("cors_blocked")) { 24853 return "Browser access to Ollama is blocked by CORS. Allow this viewer origin in OLLAMA_ORIGINS, then try again."; 24854 } 24855 if ( 24856 lower.includes("fetch") || 24857 lower.includes("network") || 24858 lower.includes("failed to fetch") 24859 ) { 24860 return "Couldn't reach the LLM. Check your connection and try again."; 24861 } 24862 if (isMindmapRateLimitError(err) || lower.includes("429")) { 24863 const retryMs = getMindmapRetryDelayMs(err); 24864 if (retryMs > 0) { 24865 return `Rate limited by the LLM. Try again in about ${formatMindmapRetryDelay(retryMs)}.`; 24866 } 24867 return "Rate limited by the LLM. Wait a moment and retry."; 24868 } 24869 if ( 24870 lower.includes("401") || 24871 lower.includes("unauthorized") || 24872 lower.includes("api key") 24873 ) { 24874 return "The LLM rejected the request. Check your API key in the chat settings."; 24875 } 24876 if (lower.includes("timeout") || lower.includes("timed out")) { 24877 return "The LLM took too long to respond. Try again, or regenerate with a smaller document."; 24878 } 24879 if (msg) return msg; 24880 return "Could not extract concepts. Try regenerating."; 24881 } 24882 24883 function showMindmapError(msg, kind) { 24884 const { errorPane, errorMsg } = getMindmapEls(); 24885 if (!errorPane || !errorMsg) return; 24886 errorMsg.textContent = msg; 24887 errorPane.dataset.kind = kind || "generic"; 24888 errorPane.classList.remove("is-hidden"); 24889 } 24890 24891 document.getElementById("mindmapBtn").addEventListener("click", () => { 24892 runMindmapPipeline({ force: false }); 24893 }); 24894 24895 document 24896 .getElementById("mindmapRegenerateBtn") 24897 .addEventListener("click", () => { 24898 runMindmapPipeline({ force: true }); 24899 }); 24900 24901 document 24902 .getElementById("mindmapRetryBtn") 24903 .addEventListener("click", () => { 24904 runMindmapPipeline({ force: true }); 24905 }); 24906 24907 els.fileInput.addEventListener("change", async function (event) { 24908 const file = event.target.files && event.target.files[0]; 24909 try { 24910 await loadFromFile(file); 24911 if (file && state.libraryIsOpen) { 24912 hideLibraryDrawer({ restoreFocus: false }); 24913 } 24914 } catch (error) { 24915 setStatus( 24916 "File load failed ⢠" + 24917 (error && error.message ? error.message : String(error)), 24918 { announce: true }, 24919 ); 24920 } finally { 24921 els.fileInput.value = ""; 24922 } 24923 }); 24924 24925 els.libraryFolderInput.addEventListener( 24926 "change", 24927 async function (event) { 24928 const replaceFolderId = state.libraryPendingImportTargetFolderId; 24929 state.libraryPendingImportTargetFolderId = ""; 24930 state.libraryPendingImportToken = 0; 24931 try { 24932 await importLibraryFolderSnapshot(event.target.files || [], { 24933 replaceFolderId: replaceFolderId, 24934 }); 24935 } catch (error) { 24936 setStatus( 24937 "Folder import failed ⢠" + 24938 (error && error.message ? error.message : String(error)), 24939 { announce: true }, 24940 ); 24941 console.warn("[library] folder import failed", error); 24942 } finally { 24943 els.libraryFolderInput.value = ""; 24944 } 24945 }, 24946 ); 24947 24948 window.addEventListener("dragenter", function (event) { 24949 event.preventDefault(); 24950 state.dragDepth += 1; 24951 document.body.classList.add("dragging"); 24952 }); 24953 24954 window.addEventListener("dragover", function (event) { 24955 event.preventDefault(); 24956 }); 24957 24958 window.addEventListener("dragleave", function (event) { 24959 event.preventDefault(); 24960 state.dragDepth = Math.max(0, state.dragDepth - 1); 24961 if (state.dragDepth === 0) { 24962 document.body.classList.remove("dragging"); 24963 } 24964 }); 24965 24966 window.addEventListener("drop", async function (event) { 24967 event.preventDefault(); 24968 state.dragDepth = 0; 24969 document.body.classList.remove("dragging"); 24970 24971 const files = Array.from( 24972 event.dataTransfer && event.dataTransfer.files 24973 ? event.dataTransfer.files 24974 : [], 24975 ); 24976 const file = files.find(function (item) { 24977 return /\.(md|markdown|mdown|mkdn|txt)$/i.test(item.name); 24978 }); 24979 24980 if (file) { 24981 try { 24982 await loadFromFile(file); 24983 return; 24984 } catch (error) { 24985 setStatus( 24986 "Drop failed ⢠" +
24987 (error && error.message ? error.message : String(error)), 24988 { announce: true }, 24989 ); 24990 return; 24991 } 24992 } 24993 24994 const text = event.dataTransfer 24995 ? event.dataTransfer.getData("text/plain") 24996 : ""; 24997 if (text) { 24998 await flushActiveUntitledDraft().catch(function (error) { 24999 console.warn("[drafts] active draft flush failed", error); 25000 }); 25001 stopAutoRefresh(); 25002 els.autoRefreshToggle.checked = false; 25003 state.untitledDraftId = ""; 25004 state.sourceMode = "editor"; 25005 state.currentName = ""; 25006 setPrimaryFileHandle(null); 25007 state.baseHref = window.location.href; 25008 state.dirty = false; 25009 state.hasSessionDraft = true; 25010 await clearActiveLibrarySelection({ 25011 persist: true, 25012 renderDrawer: true, 25013 }); 25014 updateUiState(); 25015 25016 setEditorMarkdown(text); 25017 render("editor"); 25018 } 25019 }); 25020 25021 async function readSharedSnapshotFromLocation() { 25022 if ( 25023 typeof libraryCore.parseShareSnapshotFragment !== "function" || 25024 !window.location.hash 25025 ) { 25026 return null; 25027 } 25028 25029 const parsed = await libraryCore.parseShareSnapshotFragment( 25030 window.location.hash, 25031 ); 25032 return parsed && parsed.payload ? parsed.payload : null; 25033 } 25034 25035 let pendingOpenerSnapshotPayload = null; 25036 let acceptsOpenerSnapshotPayloads = false; 25037 25038 function normalizeOpenerSnapshotPayload(data) { 25039 const message = data && typeof data === "object" ? data : null; 25040 if ( 25041 !message || 25042 message.type !== "markv:open-snapshot" || 25043 message.source !== "pagecrumb" 25044 ) { 25045 return null; 25046 } 25047 25048 const payload = 25049 message.payload && typeof message.payload === "object" 25050 ? message.payload 25051 : {}; 25052 const text = typeof payload.text === "string" ? payload.text : ""; 25053 if (!text.trim()) return null; 25054 25055 return { 25056 text: text, 25057 name: 25058 typeof payload.name === "string" 25059 ? payload.name.slice(0, 240) 25060 : "", 25061 view: payload.view === "edit" ? "edit" : "preview", 25062 }; 25063 } 25064 25065 async function applyOpenerSnapshotPayload(payload) { 25066 const applied = await applySharedSnapshotPayload(payload); 25067 if (applied) { 25068 setStatus( 25069 (state.currentName || "Pagecrumb snapshot") + 25070 " ⢠loaded from Pagecrumb ⢠use Share to create a link", 25071 { announce: true }, 25072 ); 25073 } 25074 return applied; 25075 } 25076 25077 function flushPendingOpenerSnapshotPayload() { 25078 if (!pendingOpenerSnapshotPayload) return Promise.resolve(false); 25079 const payload = pendingOpenerSnapshotPayload; 25080 pendingOpenerSnapshotPayload = null; 25081 return applyOpenerSnapshotPayload(payload); 25082 } 25083 25084 window.addEventListener("message", function (event) { 25085 if (!window.opener || event.source !== window.opener) return; 25086 25087 const payload = normalizeOpenerSnapshotPayload(event.data); 25088 if (!payload) return; 25089 25090 if (!acceptsOpenerSnapshotPayloads) { 25091 pendingOpenerSnapshotPayload = payload; 25092 return; 25093 } 25094 25095 applyOpenerSnapshotPayload(payload).catch(function (error) { 25096 console.warn("[share] opener snapshot failed", error); 25097 setStatus( 25098 "Pagecrumb import failed ⢠" + 25099 (error && error.message ? error.message : String(error)), 25100 { announce: true }, 25101 ); 25102 }); 25103 }); 25104 25105 async function init() { 25106 const sessionMeta = readSessionMeta(); 25107 const savedContent = readStorage( 25108 window.sessionStorage, 25109 SESSION_CONTENT_KEY, 25110 "", 25111 ); 25112 const pendingSharedContent = 25113 window.MarkVPWA && 25114 typeof window.MarkVPWA.consumePendingSharedContent === "function" 25115 ? window.MarkVPWA.consumePendingSharedContent() 25116 : null; 25117 const hasPendingPrimaryHandle = 25118 window.MarkVPWA && 25119 typeof window.MarkVPWA.shouldRestorePrimaryFileHandle === "function" 25120 ? window.MarkVPWA.shouldRestorePrimaryFileHandle() 25121 : false;
25122 const sharedSnapshot = await readSharedSnapshotFromLocation(); 25123 25124 state.hasSessionDraft = Boolean(sessionMeta.hasSessionDraft); 25125 const hasStoredUntitledDrafts = 25126 await restoreUntitledDraftsFromStorage(sessionMeta); 25127 applyReadingSettings(readReadingSettings()); 25128 applyReaderPalette( 25129 coerceReaderPaletteForTheme(readReaderPalette(), getGlobalTheme()), 25130 { refresh: false }, 25131 ); 25132 renderLibraryDrawer(); 25133 updateUiState(); 25134 25135 if (pendingSharedContent) { 25136 await applySharedIncomingContent(pendingSharedContent); 25137 await restoreLibraryFromStorage({ 25138 hasSessionDraft: true, 25139 }); 25140 return; 25141 } 25142 25143 if (sharedSnapshot) { 25144 await applySharedSnapshotPayload(sharedSnapshot); 25145 await restoreLibraryFromStorage({ 25146 hasSessionDraft: true, 25147 }); 25148 return; 25149 } 25150 25151 if (savedContent || state.hasSessionDraft) { 25152 setEditorMarkdown(savedContent || ""); 25153 render("force"); 25154 await restoreLibraryFromStorage({ 25155 hasSessionDraft: true, 25156 }); 25157 } else { 25158 setEditorMarkdown(""); 25159 render("force"); 25160 await restoreLibraryFromStorage({ 25161 hasSessionDraft: hasStoredUntitledDrafts, 25162 }); 25163 } 25164 25165 if ( 25166 state.sourceMode !== "library" && 25167 hasStoredUntitledDrafts && 25168 !hasPendingPrimaryHandle && 25169 !savedContent && 25170 !state.hasSessionDraft 25171 ) { 25172 await loadUntitledDraft(state.untitledDraftId, { 25173 quiet: true, 25174 closeDrawerOnMobile: false, 25175 }); 25176 } 25177 25178 if ( 25179 state.sourceMode !== "library" && 25180 state.sourceMode !== "untitled" && 25181 !state.hasSessionDraft && 25182 !els.editor.value.trim() && 25183 !hasPendingPrimaryHandle 25184 ) { 25185 setEditorMarkdown(await loadDefaultSample()); 25186 render("force"); 25187 } 25188 } 25189 25190 window.markvPWAHooks = { 25191 restorePrimaryFileHandle: function (handle) { 25192 if ( 25193 !handle || 25194 state.fileHandle || 25195 state.libraryCurrentFileHandle || 25196 state.sourceMode === "library" 25197 ) { 25198 return false; 25199 } 25200 25201 setPrimaryFileHandle(handle, { skipPersistence: true }); 25202 if (!state.currentName) { 25203 state.currentName = handle.name || ""; 25204 } 25205 updateUiState(); 25206 return true; 25207 }, 25208 flushSessionDraft: function () { 25209 __flushSessionContent(); 25210 }, 25211 flushPendingWrites: function () { 25212 return flushActiveUntitledDraft().then(function () { 25213 return flushWritableHandleIfPermitted(); 25214 }); 25215 }, 25216 }; 25217 25218 init() 25219 .catch(function (error) { 25220 console.warn("[markv] init failed", error); 25221 setStatus( 25222 "Startup failed ⢠" + 25223 (error && error.message ? error.message : String(error)), 25224 { announce: true }, 25225 ); 25226 }) 25227 .finally(function () { 25228 acceptsOpenerSnapshotPayloads = true; 25229 flushPendingOpenerSnapshotPayload().catch(function (error) { 25230 console.warn("[share] pending opener snapshot failed", error); 25231 }); 25232 if (window.opener && typeof window.opener.postMessage === "function") { 25233 try { 25234 window.opener.postMessage( 25235 { type: "markv:ready", source: "markv" }, 25236 "*", 25237 ); 25238 } catch (_error) {} 25239 } 25240 document.dispatchEvent(new CustomEvent("markv:ready")); 25241 }); 25242 25243 // ---------- Theme toggle ---------- 25244 const themeBtn = document.getElementById("themeToggleBtn"); 25245 const sunIcon = document.getElementById("themeIconSun"); 25246 const moonIcon = document.getElementById("themeIconMoon"); 25247 25248 function applyTheme(theme, options) { 25249 options = options || {}; 25250 document.documentElement.setAttribute("data-theme", theme); 25251 if (theme === "dark") { 25252 sunIcon.style.display = "none"; 25253 moonIcon.style.display = ""; 25254 } else { 25255 sunIcon.style.display = ""; 25256 moonIcon.style.display = "none"; 25257 } 25258 if (options.persist) { 25259 writeStorage(window.localStorage, THEME_KEY, theme); 25260 } 25261 if (options.skipReaderPalette) return; 25262 applyReaderPalette( 25263 coerceReaderPaletteForTheme( 25264 options.readerPalette || 25265 (state.readerPaletteInitialized 25266 ? state.readerPalette 25267 : readReaderPalette()), 25268 theme, 25269 ), 25270 { 25271 refresh: false, 25272 persist: options.persistReaderPalette, 25273 }, 25274 ); 25275 } 25276 25277 const savedTheme = readStorage(window.localStorage, THEME_KEY, ""); 25278 const systemDark = 25279 window.matchMedia && 25280 window.matchMedia("(prefers-color-scheme: dark)").matches; 25281 applyTheme(savedTheme || (systemDark ? "dark" : "light")); 25282 25283 function runAfterNextPaint(callback) { 25284 if (typeof requestAnimationFrame !== "function") { 25285 window.setTimeout(callback, 0); 25286 return; 25287 } 25288 requestAnimationFrame(function () { 25289 requestAnimationFrame(callback); 25290 }); 25291 } 25292 25293 function scheduleThemeDependentRefresh(token, previousScheme) { 25294 window.clearTimeout(state.themeMermaidRenderTimer); 25295 state.themeMermaidRenderTimer = window.setTimeout(function () { 25296 state.themeMermaidRenderTimer = null; 25297 if (token !== state.themeRefreshToken) return; 25298 25299 if ( 25300 previousScheme && 25301 previousScheme === getEffectiveReaderScheme() 25302 ) { 25303 swapHljsTheme(); 25304 return; 25305 } 25306 25307 swapHljsTheme(); 25308 25309 const blocks = collectRenderedMermaidBlocks(); 25310 if (!blocks.length) return; 25311 25312 const generation = state.previewRenderGeneration + 1; 25313 state.previewRenderGeneration = generation; 25314 renderMermaidBlockCollection(blocks, generation, { 25315 keepExistingOnError: true, 25316 }); 25317 }, 180); 25318 } 25319 25320 function queueThemeDependentRefreshAfter( 25321 transition, 25322 token, 25323 previousScheme, 25324 ) { 25325 const schedule = function () { 25326 if (token !== state.themeRefreshToken) return; 25327 runAfterNextPaint(function () { 25328 scheduleThemeDependentRefresh(token, previousScheme); 25329 }); 25330 }; 25331 25332 if ( 25333 transition && 25334 transition.finished && 25335 typeof transition.finished.finally === "function" 25336 ) { 25337 transition.finished.finally(schedule); 25338 return; 25339 } 25340 25341 schedule(); 25342 } 25343 25344 function scheduleReaderAssetRefresh(previousScheme) { 25345 const refreshToken = state.themeRefreshToken + 1; 25346 state.themeRefreshToken = refreshToken; 25347 scheduleThemeDependentRefresh(refreshToken, previousScheme); 25348 } 25349 25350 function animateThemeReveal(transition, originX, originY, endRadius) { 25351 if ( 25352 !transition || 25353 !transition.ready || 25354 typeof transition.ready.then !== "function" 25355 ) { 25356 return; 25357 } 25358 25359 transition.ready 25360 .then(function () { 25361 document.documentElement.animate( 25362 { 25363 clipPath: [ 25364 "circle(0px at " + originX + "px " + originY + "px)", 25365 "circle(" +
25366 endRadius + 25367 "px at " + 25368 originX + 25369 "px " + 25370 originY + 25371 "px)", 25372 ], 25373 }, 25374 { 25375 duration: 520, 25376 easing: "cubic-bezier(0.22, 1, 0.36, 1)", 25377 pseudoElement: "::view-transition-new(root)", 25378 }, 25379 ); 25380 }) 25381 .catch(function () { 25382 // If the custom pseudo-element animation fails, keep the 25383 // default view transition instead of breaking theme toggle. 25384 }); 25385 } 25386 25387 themeBtn.addEventListener("click", function (event) { 25388 const current = 25389 document.documentElement.getAttribute("data-theme") || "light"; 25390 const next = current === "dark" ? "light" : "dark"; 25391 const previousReaderScheme = getEffectiveReaderScheme(); 25392 const nextReaderPalette = coerceReaderPaletteForTheme( 25393 state.readerPaletteInitialized 25394 ? state.readerPalette 25395 : readReaderPalette(), 25396 next, 25397 ); 25398 25399 // Capture the button origin so the radial reveal can 25400 // emanate from the icon itself rather than the screen 25401 // center â feels causal, like the click spread the theme. 25402 const rect = themeBtn.getBoundingClientRect(); 25403 const originX = rect.left + rect.width / 2; 25404 const originY = rect.top + rect.height / 2; 25405 document.documentElement.style.setProperty( 25406 "--vt-origin-x", 25407 originX + "px", 25408 ); 25409 document.documentElement.style.setProperty( 25410 "--vt-origin-y", 25411 originY + "px", 25412 ); 25413 const maxDx = Math.max(originX, window.innerWidth - originX); 25414 const maxDy = Math.max(originY, window.innerHeight - originY); 25415 const endRadius = Math.hypot(maxDx, maxDy); 25416 document.documentElement.style.setProperty( 25417 "--vt-end-radius", 25418 endRadius + "px", 25419 ); 25420 const refreshToken = state.themeRefreshToken + 1; 25421 state.themeRefreshToken = refreshToken; 25422 25423 const commitTheme = function () { 25424 applyTheme(next, { 25425 persist: true, 25426 persistReaderPalette: true, 25427 readerPalette: nextReaderPalette, 25428 }); 25429 }; 25430 25431 // Progressive enhancement: browsers that implement the 25432 // View Transitions API (Chromium, Safari â¥18) get a 25433 // radial clip-path reveal from the button. Firefox and 25434 // older Safari fall through to the existing behavior â 25435 // no regression. 25436 const reduceMotion = 25437 window.matchMedia && 25438 window.matchMedia("(prefers-reduced-motion: reduce)").matches; 25439 if ( 25440 !reduceMotion && 25441 typeof document.startViewTransition === "function" 25442 ) { 25443 const transition = document.startViewTransition(commitTheme); 25444 animateThemeReveal(transition, originX, originY, endRadius); 25445 queueThemeDependentRefreshAfter( 25446 transition, 25447 refreshToken, 25448 previousReaderScheme, 25449 ); 25450 } else { 25451 commitTheme(); 25452 queueThemeDependentRefreshAfter( 25453 null, 25454 refreshToken, 25455 previousReaderScheme, 25456 ); 25457 } 25458 }); 25459 25460 // ---------- Mode toggle (preview <-> edit) ---------- 25461 const mainEl = document.getElementById("main"); 25462 const modeBtn = document.getElementById("modeToggleBtn"); 25463 const modeLabel = document.getElementById("modeToggleLabel"); 25464 const modeIconEdit = document.getElementById("modeIconEdit"); 25465 const modeIconPreview = document.getElementById("modeIconPreview"); 25466 25467 function isEditModeActive() { 25468 return Boolean( 25469 document.getElementById("main") && 25470 document.getElementById("main").classList.contains("edit-mode"), 25471 ); 25472 } 25473 25474 function getMaxWindowScrollY() { 25475 return Math.max( 25476 0, 25477 document.documentElement.scrollHeight - window.innerHeight, 25478 ); 25479 } 25480 25481 function captureWindowScrollRatio() { 25482 var maxY = getMaxWindowScrollY(); 25483 return maxY > 0 ? window.scrollY / maxY : 0; 25484 } 25485 25486 function applyWindowScrollRatio(ratio) { 25487 window.scrollTo( 25488 0, 25489 Math.round(clampNumber(ratio, 0, 1) * getMaxWindowScrollY()), 25490 ); 25491 } 25492 25493 function applyWindowScrollY(offset) { 25494 window.scrollTo( 25495 0, 25496 clampNumber(Math.round(offset), 0, getMaxWindowScrollY()), 25497 ); 25498 } 25499 25500 function interpolateSourceOffset(start, end, fraction) { 25501 if (typeof start !== "number") return 0; 25502 if (typeof end !== "number" || end <= start) return start; 25503 return Math.round( 25504 start + clampNumber(fraction, 0, 1) * (end - start), 25505 ); 25506 } 25507 25508 function getPreviewSourceAnchors() { 25509 return Array.from( 25510 els.preview.querySelectorAll("[data-source-start]"), 25511 ); 25512 } 25513 25514 function getSourceRangeFromElement(element) { 25515 if (!element) return null; 25516 var start = Number(element.getAttribute("data-source-start")); 25517 var end = Number(element.getAttribute("data-source-end")); 25518 25519 if (!Number.isFinite(start)) return null; 25520 if (!Number.isFinite(end)) end = start; 25521 25522 return { start: start, end: end }; 25523 } 25524 25525 function findBestPreviewAnchorForViewportY(viewportY) { 25526 var best = null; 25527
25528 getPreviewSourceAnchors().forEach(function (element) { 25529 var range = getSourceRangeFromElement(element); 25530 if (!range) return; 25531 25532 var rect = element.getBoundingClientRect(); 25533 if (rect.height <= 0 && rect.width <= 0) return; 25534 25535 var distance = 0; 25536 if (viewportY < rect.top) { 25537 distance = rect.top - viewportY; 25538 } else if (viewportY > rect.bottom) { 25539 distance = viewportY - rect.bottom; 25540 } 25541 25542 var height = Math.max(1, rect.height); 25543 25544 if ( 25545 !best || 25546 distance < best.distance || 25547 (distance === best.distance && height < best.height) 25548 ) { 25549 best = { 25550 element: element, 25551 range: range, 25552 rect: rect, 25553 distance: distance, 25554 height: height, 25555 }; 25556 } 25557 }); 25558 25559 return best; 25560 } 25561 25562 function getPreviewViewportSourceOffset() { 25563 var centerY = window.innerHeight / 2; 25564 var match = findBestPreviewAnchorForViewportY(centerY); 25565 if (!match) return null; 25566 25567 var fraction = 25568 match.rect.height > 1 25569 ? (centerY - match.rect.top) / match.rect.height 25570 : 0; 25571 25572 return interpolateSourceOffset( 25573 match.range.start, 25574 match.range.end, 25575 fraction, 25576 ); 25577 } 25578 25579 function findBestPreviewAnchorForSourceOffset(offset) { 25580 var bestContaining = null; 25581 var bestNearest = null; 25582
25583 getPreviewSourceAnchors().forEach(function (element) { 25584 var range = getSourceRangeFromElement(element); 25585 if (!range) return; 25586 25587 var span = Math.max(1, range.end - range.start); 25588 if (offset >= range.start && offset <= range.end) { 25589 if (!bestContaining || span < bestContaining.span) { 25590 bestContaining = { 25591 element: element, 25592 range: range, 25593 span: span, 25594 }; 25595 } 25596 return; 25597 } 25598 25599 var distance = Math.min( 25600 Math.abs(offset - range.start), 25601 Math.abs(offset - range.end), 25602 ); 25603 if ( 25604 !bestNearest || 25605 distance < bestNearest.distance || 25606 (distance === bestNearest.distance && span < bestNearest.span) 25607 ) { 25608 bestNearest = { 25609 element: element, 25610 range: range, 25611 distance: distance, 25612 span: span, 25613 }; 25614 } 25615 }); 25616 25617 return bestContaining || bestNearest; 25618 } 25619 25620 function scrollPreviewToSourceOffset(offset) { 25621 if (typeof offset !== "number") return; 25622 var match = findBestPreviewAnchorForSourceOffset(offset); 25623 if (!match) return; 25624 25625 var rect = match.element.getBoundingClientRect(); 25626 var pageTop = window.scrollY + rect.top; 25627 var fraction = 25628 match.range.end > match.range.start 25629 ? (offset - match.range.start) / 25630 Math.max(1, match.range.end - match.range.start) 25631 : 0; 25632 25633 applyWindowScrollY( 25634 pageTop + 25635 rect.height * clampNumber(fraction, 0, 1) - 25636 window.innerHeight / 2, 25637 ); 25638 } 25639 25640 function createTextareaMeasurementMirror(textarea) { 25641 var mirror = document.createElement("div"); 25642 var computed = window.getComputedStyle(textarea); 25643 var properties = [ 25644 "boxSizing", 25645 "borderTopWidth", 25646 "borderRightWidth", 25647 "borderBottomWidth", 25648 "borderLeftWidth", 25649 "paddingTop", 25650 "paddingRight", 25651 "paddingBottom", 25652 "paddingLeft", 25653 "fontStyle", 25654 "fontVariant", 25655 "fontWeight", 25656 "fontStretch", 25657 "fontSize", 25658 "fontFamily", 25659 "fontFeatureSettings", 25660 "fontVariationSettings", 25661 "lineHeight", 25662 "letterSpacing", 25663 "tabSize", 25664 "textIndent", 25665 "textTransform", 25666 "textRendering", 25667 ]; 25668 25669 mirror.setAttribute("aria-hidden", "true"); 25670 mirror.style.position = "absolute"; 25671 mirror.style.visibility = "hidden"; 25672 mirror.style.pointerEvents = "none"; 25673 mirror.style.left = "-99999px"; 25674 mirror.style.top = "0"; 25675 mirror.style.whiteSpace = "pre-wrap"; 25676 mirror.style.overflowWrap = "break-word"; 25677 mirror.style.wordWrap = "break-word"; 25678 mirror.style.width = textarea.clientWidth + "px"; 25679 25680 properties.forEach(function (property) { 25681 mirror.style[property] = computed[property]; 25682 }); 25683 25684 return mirror; 25685 } 25686 25687 function measureTextareaCaretTop(textarea, position) { 25688 var clampedPosition = clampNumber(position, 0, textarea.value.length); 25689 var mirror = createTextareaMeasurementMirror(textarea); 25690 var before = textarea.value.slice(0, clampedPosition); 25691 var after = textarea.value.slice(clampedPosition); 25692 var marker = document.createElement("span"); 25693 25694 marker.textContent = after || "\u200b"; 25695 mirror.textContent = before; 25696 mirror.appendChild(marker); 25697 document.body.appendChild(mirror); 25698 25699 var top = marker.offsetTop; 25700 mirror.remove(); 25701 return top; 25702 } 25703 25704 function getEditorViewportSourceOffset() { 25705 var editor = els.editor; 25706 var valueLength = editor.value.length; 25707 if (!valueLength || editor.clientHeight <= 0) { 25708 return state.editorSelection || 0; 25709 } 25710 25711 var targetY = editor.scrollTop + editor.clientHeight / 2; 25712 var cache = Object.create(null); 25713
25714 function topFor(position) { 25715 var clampedPosition = clampNumber(position, 0, valueLength); 25716 if (cache[clampedPosition] == null) { 25717 cache[clampedPosition] = measureTextareaCaretTop( 25718 editor, 25719 clampedPosition, 25720 ); 25721 } 25722 return cache[clampedPosition]; 25723 } 25724 25725 var low = 0; 25726 var high = valueLength; 25727 while (low < high) { 25728 var mid = Math.floor((low + high) / 2); 25729 if (topFor(mid) < targetY) { 25730 low = mid + 1; 25731 } else { 25732 high = mid; 25733 } 25734 } 25735 25736 var candidates = [low, Math.max(0, low - 1)]; 25737 var bestOffset = candidates[0]; 25738 var bestDistance = Infinity; 25739 25740 candidates.forEach(function (candidate) { 25741 var distance = Math.abs(topFor(candidate) - targetY); 25742 if (distance < bestDistance) { 25743 bestDistance = distance; 25744 bestOffset = candidate; 25745 } 25746 }); 25747 25748 return bestOffset; 25749 } 25750 25751 function scrollEditorToSourceOffset(offset) { 25752 var editor = els.editor; 25753 var clampedOffset = clampNumber(offset, 0, editor.value.length); 25754 var caretTop; 25755 var lineHeight = 25756 parseFloat(window.getComputedStyle(editor).lineHeight) || 20; 25757 25758 state.editorSelection = clampedOffset; 25759 25760 try { 25761 editor.setSelectionRange(clampedOffset, clampedOffset); 25762 } catch (_error) {} 25763 25764 caretTop = measureTextareaCaretTop(editor, clampedOffset); 25765 editor.scrollTop = clampNumber( 25766 Math.round(caretTop - editor.clientHeight / 2 + lineHeight / 2), 25767 0, 25768 Math.max(0, editor.scrollHeight - editor.clientHeight), 25769 ); 25770 } 25771 25772 function setMode(mode) { 25773 const wasEdit = mainEl.classList.contains("edit-mode"); 25774 25775 if (mode === "edit") { 25776 const sourceOffset = 25777 getPreviewViewportSourceOffset() ?? state.editorSelection ?? 0; 25778 dismissSelectionUi(); 25779 clearBrowserSelection(); 25780 mainEl.classList.add("edit-mode"); 25781 document.body.classList.add("edit-mode-active"); 25782 document.body.classList.remove("preview-mode"); 25783 modeLabel.textContent = "Preview"; 25784 modeBtn.setAttribute("aria-pressed", "true"); 25785 modeBtn.title = withShortcutLabel("Show preview", "global.mode"); 25786 if (modeIconEdit) modeIconEdit.style.display = "none"; 25787 if (modeIconPreview) modeIconPreview.style.display = ""; 25788 requestAnimationFrame(function () { 25789 try { 25790 els.editor.focus({ preventScroll: true }); 25791 } catch (_error) { 25792 els.editor.focus(); 25793 } 25794 scrollEditorToSourceOffset(sourceOffset); 25795 }); 25796 } else { 25797 const sourceOffset = wasEdit 25798 ? getEditorViewportSourceOffset() 25799 : (getPreviewViewportSourceOffset() ?? 25800 state.editorSelection ?? 25801 0); 25802 state.editorSelection = sourceOffset; 25803 mainEl.classList.remove("edit-mode"); 25804 document.body.classList.add("preview-mode"); 25805 document.body.classList.remove("edit-mode-active"); 25806 modeLabel.textContent = "Edit"; 25807 modeBtn.setAttribute("aria-pressed", "false"); 25808 modeBtn.title = withShortcutLabel("Edit markdown", "global.mode"); 25809 if (modeIconEdit) modeIconEdit.style.display = ""; 25810 if (modeIconPreview) modeIconPreview.style.display = "none"; 25811 requestAnimationFrame(function () { 25812 scrollPreviewToSourceOffset(sourceOffset); 25813 }); 25814 } 25815 25816 updateUiState(); 25817 } 25818 25819 modeBtn.addEventListener("click", function () { 25820 const isEdit = mainEl.classList.contains("edit-mode"); 25821 setMode(isEdit ? "preview" : "edit"); 25822 }); 25823 25824 window.addEventListener("keydown", function (e) { 25825 const hasSelectionUi = 25826 !els.selectionBubble.hidden || 25827 !els.ctxMenu.hidden || 25828 !els.highlightRemoveChip.hidden; 25829 25830 if (e.key === "Escape" && state.mermaidViewerOpen) { 25831 e.preventDefault(); 25832 closeMermaidViewer(); 25833 return; 25834 } 25835 25836 if ( 25837 state.mermaidViewerOpen && 25838 !isEditableElement(document.activeElement) && 25839 !e.metaKey && 25840 !e.ctrlKey && 25841 !e.altKey 25842 ) { 25843 if (e.key === "+" || e.key === "=") { 25844 e.preventDefault(); 25845 applyMermaidViewerScale(state.mermaidViewerScale * 1.15); 25846 return; 25847 } 25848 if (e.key === "-" || e.key === "_") { 25849 e.preventDefault(); 25850 applyMermaidViewerScale(state.mermaidViewerScale / 1.15); 25851 return; 25852 } 25853 if (e.key === "0") { 25854 e.preventDefault(); 25855 applyMermaidViewerScale(1, { 25856 contentX: state.mermaidViewerBaseWidth / 2, 25857 contentY: state.mermaidViewerBaseHeight / 2, 25858 }); 25859 return; 25860 } 25861 if (e.key.toLowerCase() === "f") { 25862 e.preventDefault(); 25863 fitMermaidViewer(); 25864 return; 25865 } 25866 } 25867 25868 if (e.key === "Escape" && hasSelectionUi) { 25869 e.preventDefault(); 25870 e.stopImmediatePropagation();
25871 dismissSelectionUi(); 25872 return; 25873 } 25874 25875 if (e.key === "Escape" && !els.readingPanel.hidden) { 25876 e.preventDefault(); 25877 hideReadingPanel(); 25878 return; 25879 } 25880 25881 if ( 25882 (e.metaKey || e.ctrlKey) && 25883 e.shiftKey && 25884 e.key.toLowerCase() === "h" && 25885 !isEditableElement(document.activeElement) 25886 ) { 25887 const controller = 25888 state.selectionController || captureSelectionController(); 25889 if (controller) { 25890 e.preventDefault(); 25891 addHighlightFromSelection(); 25892 } 25893 return; 25894 } 25895 25896 if (shortcutMatches(e, "global.mode")) { 25897 e.preventDefault(); 25898 modeBtn.click(); 25899 return; 25900 } 25901 25902 if (shortcutMatches(e, "global.save")) { 25903 e.preventDefault(); 25904 saveCurrentDocument(); 25905 } 25906 }); 25907 25908 if (window.MarkVExportHostFactory) { 25909 window.MarkVExportHost = window.MarkVExportHostFactory({ 25910 state, 25911 els, 25912 isEditModeActive, 25913 setMode, 25914 renderCurrentPreview: function () { 25915 render("force"); 25916 }, 25917 renderMermaidForExport, 25918 setStatus, 25919 }); 25920 document.dispatchEvent(new CustomEvent("markv:export-host-ready")); 25921 } 25922 25923 if (window.MarkVCommandPaletteHostFactory) { 25924 window.MarkVCommandPaletteHost = 25925 window.MarkVCommandPaletteHostFactory({ 25926 state, 25927 els, 25928 exportHost: window.MarkVExportHost, 25929 libraryCore, 25930 sortUntitledDrafts, 25931 getReaderSaveState, 25932 getReaderRefreshState, 25933 isEditModeActive, 25934 setMode, 25935 showLibraryDrawer, 25936 openFile, 25937 addLibraryFolder, 25938 saveCurrentDocument, 25939 reloadCurrent, 25940 startAutoRefresh, 25941 showReadingPanel, 25942 createUntitledDocument, 25943 loadFromLibrarySelection, 25944 loadUntitledDraft, 25945 shareCurrentSnapshot, 25946 runMindmapPipeline, 25947 clearAll, 25948 setStatus, 25949 }); 25950 document.dispatchEvent( 25951 new CustomEvent("markv:command-palette-host-ready"), 25952 ); 25953 } 25954 25955 document.addEventListener("markv:announce", function (e) { 25956 if (e.detail && e.detail.message) announce(e.detail.message); 25957 }); 25958 25959 window.addEventListener("storage", function (event) { 25960 if (event.key !== READER_PALETTE_STORAGE_KEY) return; 25961 applyReaderPalette(event.newValue, { 25962 persist: false, 25963 announce: false, 25964 }); 25965 }); 25966 25967 document.addEventListener( 25968 "markv:request-highlight-refresh", 25969 function () { 25970 applyHighlightsToPreview(); 25971 }, 25972 ); 25973 25974 // Start in preview mode. 25975 setMode("preview"); 25976 25977 // ââ Scroll-reveal typography ââââââââââââââââââââââââââââ 25978 // Gentle fade+rise on h1âh4 when they enter the viewport. 25979 // Honors prefers-reduced-motion via the global reset above. 25980 // Safe-by-default: the CSS rules are gated on 25981 // `html.js-scroll-reveal` which only applies when THIS 25982 // script wires things up â if JS or IO fails, headings 25983 // stay fully visible. 25984 (function setupScrollReveal() { 25985 if (!("IntersectionObserver" in window)) return; 25986 if (!("MutationObserver" in window)) return; 25987 const previewRoot = document.querySelector(".preview-pane"); 25988 if (!previewRoot) return; 25989 25990 const io = new IntersectionObserver( 25991 function (entries) { 25992 entries.forEach(function (e) { 25993 if (e.isIntersecting) { 25994 e.target.setAttribute("data-revealed", ""); 25995 io.unobserve(e.target); 25996 } 25997 }); 25998 }, 25999 { rootMargin: "0px 0px -8% 0px", threshold: 0.01 }, 26000 ); 26001 26002 function observeNew() { 26003 const headings = previewRoot.querySelectorAll( 26004 ".markdown-body h1:not([data-observing])," + 26005 " .markdown-body h2:not([data-observing])," + 26006 " .markdown-body h3:not([data-observing])," + 26007 " .markdown-body h4:not([data-observing])", 26008 );
26009 headings.forEach(function (h) { 26010 h.setAttribute("data-observing", ""); 26011 io.observe(h); 26012 }); 26013 } 26014 26015 observeNew(); 26016 const mo = new MutationObserver(observeNew); 26017 mo.observe(previewRoot, { childList: true, subtree: true }); 26018 26019 document.documentElement.classList.add("js-scroll-reveal"); 26020 })(); 26021 26022 // ââ Comprehension micro-features âââââââââââââââââââââââââ 26023 // Groups #1 progress gutter, #2 heading anchors, 26024 // #3 code-block chrome, #4 link previews, #5 breadcrumb. 26025 // Global prefers-reduced-motion reset collapses transitions 26026 // in CSS; JS logic still runs but transitions become 26027 // instantaneous, which is correct: users still get the 26028 // feature (copy, preview, progress) without motion. 26029 (function setupComprehensionFeatures() { 26030 const previewRoot = document.querySelector(".preview-pane"); 26031 if (!previewRoot) return; 26032 26033 // Small helper for building SVG icons without touching 26034 // innerHTML (stays XSS-safe and satisfies security scan). 26035 function makeSvg(pathSpec) { 26036 const svgNS = "http://www.w3.org/2000/svg"; 26037 const svg = document.createElementNS(svgNS, "svg"); 26038 svg.setAttribute("viewBox", "0 0 16 16"); 26039 svg.setAttribute("fill", "none"); 26040 svg.setAttribute("stroke", "currentColor"); 26041 svg.setAttribute("stroke-linecap", "round"); 26042 svg.setAttribute("stroke-linejoin", "round"); 26043 svg.setAttribute("aria-hidden", "true"); 26044 pathSpec.forEach(function (p) { 26045 const el = document.createElementNS(svgNS, p.tag); 26046 Object.keys(p.attrs).forEach(function (k) { 26047 el.setAttribute(k, p.attrs[k]); 26048 }); 26049 svg.appendChild(el); 26050 }); 26051 return svg; 26052 } 26053 function copyIconSvg() { 26054 const svg = makeSvg([ 26055 { 26056 tag: "rect", 26057 attrs: { x: "4", y: "4", width: "9", height: "9", rx: "1.5" }, 26058 }, 26059 { 26060 tag: "path", 26061 attrs: { 26062 d: "M10 4V3a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h1", 26063 }, 26064 }, 26065 ]); 26066 svg.setAttribute("stroke-width", "1.5"); 26067 return svg; 26068 } 26069 function checkIconSvg() { 26070 const svg = makeSvg([ 26071 { tag: "path", attrs: { d: "M3 8.5 L6.5 12 L13 4.5" } }, 26072 ]); 26073 svg.setAttribute("stroke-width", "2"); 26074 return svg; 26075 } 26076 26077 // ---------- #1 Reading progress gutter ---------- 26078 const gutter = document.createElement("div"); 26079 gutter.id = "readingProgressGutter"; 26080 gutter.setAttribute("aria-hidden", "true"); 26081 const gutterFill = document.createElement("div"); 26082 gutterFill.className = "fill"; 26083 gutter.appendChild(gutterFill); 26084 document.body.appendChild(gutter); 26085 26086 // ---------- #5 Breadcrumb trail ---------- 26087 // Publish the toolbar's rendered height as --toolbar-h so 26088 // the breadcrumb can sit just below it. Narrow viewports 26089 // wrap the toolbar (44Ã44px tap targets, status row on a 26090 // new line); a fixed `top` would then hide behind it. 26091 const toolbarEl = document.querySelector(".toolbar"); 26092 if (toolbarEl) { 26093 const syncToolbarHeight = function () { 26094 document.documentElement.style.setProperty( 26095 "--toolbar-h", 26096 toolbarEl.offsetHeight + "px", 26097 ); 26098 }; 26099 syncToolbarHeight(); 26100 // Listen on both: ResizeObserver catches content-driven size 26101 // changes (status row toggling, font load) without a viewport 26102 // change; resize covers breakpoint transitions and 26103 // environments where RO delivery is flaky. 26104 if (typeof ResizeObserver !== "undefined") { 26105 new ResizeObserver(syncToolbarHeight).observe(toolbarEl); 26106 } 26107 window.addEventListener("resize", syncToolbarHeight, { 26108 passive: true, 26109 }); 26110 } 26111 26112 const breadcrumb = document.createElement("div"); 26113 breadcrumb.id = "readingBreadcrumb"; 26114 breadcrumb.setAttribute("aria-hidden", "true"); 26115 document.body.appendChild(breadcrumb); 26116 let scrollTopBtn = null; 26117 let scrollTopButtonReady = false;
26118 26119 function getScrollTopButton() { 26120 if (!scrollTopBtn) { 26121 scrollTopBtn = document.getElementById("scrollTopBtn"); 26122 } 26123 return scrollTopBtn; 26124 } 26125 26126 function scrollToDocumentTop() { 26127 const btn = getScrollTopButton(); 26128 window.scrollTo({ 26129 top: 0, 26130 behavior: prefersReducedMotion() ? "auto" : "smooth", 26131 }); 26132 if (btn && typeof btn.blur === "function") { 26133 btn.blur(); 26134 } 26135 } 26136 26137 function syncScrollTopButton(maxY) { 26138 const btn = getScrollTopButton(); 26139 if (!btn) return; 26140 const blocked = 26141 document.body.classList.contains("library-open") || 26142 document.body.classList.contains("mindmap-open") || 26143 document.body.classList.contains("mv-more-open") || 26144 (document.body.classList.contains("chat-open") && 26145 window.matchMedia("(max-width: 600px)").matches); 26146 const scrollable = 26147 (typeof maxY === "number" 26148 ? maxY 26149 : document.documentElement.scrollHeight - window.innerHeight) > 26150 window.innerHeight * 0.75; 26151 const threshold = Math.min( 26152 720, 26153 Math.max(360, window.innerHeight * 0.55), 26154 ); 26155 const visible = 26156 !blocked && scrollable && window.scrollY > threshold; 26157 btn.classList.toggle("is-visible", visible); 26158 btn.tabIndex = visible ? 0 : -1; 26159 btn.setAttribute("aria-hidden", String(!visible)); 26160 } 26161 26162 function setupScrollTopButton() { 26163 const btn = getScrollTopButton(); 26164 if (!btn || scrollTopButtonReady) return; 26165 scrollTopButtonReady = true; 26166 btn.addEventListener("click", scrollToDocumentTop); 26167 new MutationObserver(function () { 26168 syncScrollTopButton(); 26169 }).observe(document.body, { 26170 attributes: true, 26171 attributeFilter: ["class"], 26172 }); 26173 syncScrollTopButton(); 26174 } 26175 26176 setupScrollTopButton(); 26177 if (!scrollTopButtonReady && document.readyState === "loading") { 26178 document.addEventListener( 26179 "DOMContentLoaded", 26180 setupScrollTopButton, 26181 { 26182 once: true, 26183 }, 26184 ); 26185 } else if (!scrollTopButtonReady) { 26186 window.requestAnimationFrame(setupScrollTopButton); 26187 } 26188 26189 function renderBreadcrumb(headings, activeHeading) { 26190 if (!activeHeading) { 26191 breadcrumb.classList.remove("is-visible"); 26192 return; 26193 } 26194 // Walk up the heading levels: for the active H3, find the 26195 // nearest preceding H2 and H1. Gives a contextual trail. 26196 const levels = { 1: null, 2: null, 3: null }; 26197 const activeLevel = Number(activeHeading.tagName[1]); 26198 for (const h of headings) { 26199 if (h === activeHeading) break; 26200 const l = Number(h.tagName[1]); 26201 if (l >= 1 && l <= 3) { 26202 levels[l] = h; 26203 for (let k = l + 1; k <= 3; k++) levels[k] = null; 26204 } 26205 } 26206 levels[activeLevel] = activeHeading; 26207 const chain = []; 26208 for (let l = 1; l <= activeLevel; l++) { 26209 if (levels[l]) chain.push(levels[l]); 26210 } 26211 // Rebuild via DOM methods (no innerHTML). 26212 while (breadcrumb.firstChild) 26213 breadcrumb.removeChild(breadcrumb.firstChild); 26214 chain.forEach(function (h, i) { 26215 if (i > 0) { 26216 const sep = document.createElement("span"); 26217 sep.className = "sep"; 26218 sep.textContent = "âº"; 26219 breadcrumb.appendChild(sep); 26220 } 26221 const c = document.createElement("span"); 26222 c.className = "crumb"; 26223 const text = h.textContent.replace(/^#\s*/, "").trim(); 26224 c.textContent = text.length > 50 ? text.slice(0, 48) + "â¦" : text; 26225 breadcrumb.appendChild(c); 26226 }); 26227 breadcrumb.classList.add("is-visible"); 26228 } 26229 26230 // Update progress + breadcrumb on scroll. rAF-coalesced so 26231 // rapid wheel/flick events never stall the main thread. 26232 let scrollScheduled = false;
26233 function updateOnScroll() { 26234 if (scrollScheduled) return; 26235 scrollScheduled = true; 26236 window.requestAnimationFrame(function () { 26237 scrollScheduled = false; 26238 26239 // Progress 26240 const maxY = Math.max( 26241 0, 26242 document.documentElement.scrollHeight - window.innerHeight, 26243 ); 26244 const progress = maxY > 0 ? window.scrollY / maxY : 0; 26245 gutter.style.setProperty("--progress", progress.toFixed(4)); 26246 gutter.classList.toggle( 26247 "is-active", 26248 window.scrollY > 40 && maxY > window.innerHeight * 0.4, 26249 ); 26250 syncScrollTopButton(maxY); 26251 26252 // Breadcrumb â use the same reading anchor line as the 26253 // right rail so both current-section cues stay in sync. 26254 const headings = Array.from( 26255 previewRoot.querySelectorAll(".markdown-body :is(h1, h2, h3)"), 26256 ); 26257 if (!headings.length) { 26258 breadcrumb.classList.remove("is-visible"); 26259 return; 26260 } 26261 const active = getReaderActiveHeading(headings); 26262 if ( 26263 active && 26264 (active !== headings[0] || 26265 headings[0].getBoundingClientRect().top < -20) 26266 ) { 26267 renderBreadcrumb(headings, active); 26268 } else { 26269 breadcrumb.classList.remove("is-visible"); 26270 } 26271 }); 26272 } 26273 window.addEventListener("scroll", updateOnScroll, { passive: true }); 26274 window.addEventListener("resize", updateOnScroll, { passive: true }); 26275 updateOnScroll(); 26276 26277 // ---------- #2 Heading anchors ---------- 26278 function addHeadingAnchors() { 26279 const headings = previewRoot.querySelectorAll( 26280 ".markdown-body :is(h1, h2, h3, h4, h5, h6)[id]:not([data-anchor-ready])", 26281 );
26282 headings.forEach(function (h) { 26283 h.setAttribute("data-anchor-ready", ""); 26284 const a = document.createElement("a"); 26285 a.className = "heading-anchor"; 26286 a.href = "#" + h.id; 26287 a.setAttribute("aria-label", "Link to " + h.textContent.trim()); 26288 a.textContent = "#"; 26289 h.insertBefore(a, h.firstChild); 26290 }); 26291 } 26292 addHeadingAnchors(); 26293 26294 // ---------- #3 Code block chrome ---------- 26295 function addCodeChrome() { 26296 const pres = previewRoot.querySelectorAll( 26297 ".markdown-body pre:not([data-chrome-ready])", 26298 ); 26299 pres.forEach(function (pre) { 26300 pre.setAttribute("data-chrome-ready", ""); 26301 const code = pre.querySelector("code"); 26302 if (!code) return; 26303 const lang = (code.className.match(/language-(\w+)/) || [])[1]; 26304 const chrome = document.createElement("div"); 26305 chrome.className = "code-chrome"; 26306 if (lang) { 26307 const badge = document.createElement("span"); 26308 badge.className = "code-lang-badge"; 26309 badge.textContent = lang; 26310 chrome.appendChild(badge); 26311 } 26312 const btn = document.createElement("button"); 26313 btn.type = "button"; 26314 btn.className = "code-copy-btn"; 26315 btn.setAttribute("aria-label", "Copy code"); 26316 btn.title = "Copy code"; 26317 btn.appendChild(copyIconSvg()); 26318 btn.addEventListener("click", function () { 26319 const text = code.textContent || ""; 26320 const done = function () { 26321 btn.classList.add("is-copied"); 26322 while (btn.firstChild) btn.removeChild(btn.firstChild); 26323 btn.appendChild(checkIconSvg()); 26324 btn.setAttribute("aria-label", "Copied"); 26325 btn.title = "Copied"; 26326 window.clearTimeout(btn.__copyTimer); 26327 btn.__copyTimer = window.setTimeout(function () { 26328 btn.classList.remove("is-copied"); 26329 while (btn.firstChild) btn.removeChild(btn.firstChild); 26330 btn.appendChild(copyIconSvg()); 26331 btn.setAttribute("aria-label", "Copy code"); 26332 btn.title = "Copy code"; 26333 }, 1400); 26334 }; 26335 const fail = function () { 26336 btn.setAttribute("aria-label", "Copy failed"); 26337 btn.title = "Copy failed"; 26338 }; 26339 if (navigator.clipboard && navigator.clipboard.writeText) { 26340 navigator.clipboard.writeText(text).then(done).catch(fail); 26341 } else { 26342 try { 26343 const ta = document.createElement("textarea"); 26344 ta.value = text; 26345 ta.setAttribute("readonly", ""); 26346 ta.style.position = "fixed"; 26347 ta.style.opacity = "0"; 26348 document.body.appendChild(ta); 26349 ta.select(); 26350 document.execCommand("copy"); 26351 document.body.removeChild(ta); 26352 done(); 26353 } catch (e) { 26354 fail(); 26355 } 26356 } 26357 }); 26358 chrome.appendChild(btn); 26359 pre.appendChild(chrome); 26360 }); 26361 } 26362 addCodeChrome(); 26363 26364 // Re-run anchor + chrome wiring when preview content changes. 26365 const contentMo = new MutationObserver(function () { 26366 addHeadingAnchors(); 26367 addCodeChrome(); 26368 }); 26369 contentMo.observe(previewRoot, { childList: true, subtree: true }); 26370 26371 // ---------- #4 Link preview on long hover ---------- 26372 const preview = document.createElement("div"); 26373 preview.className = "link-preview"; 26374 preview.setAttribute("role", "tooltip"); 26375 preview.setAttribute("aria-hidden", "true"); 26376 document.body.appendChild(preview); 26377 26378 function buildPreviewCard(kindText) { 26379 while (preview.firstChild) preview.removeChild(preview.firstChild);
26380 const kind = document.createElement("span"); 26381 kind.className = "lp-kind"; 26382 kind.textContent = kindText; 26383 preview.appendChild(kind); 26384 const title = document.createElement("div"); 26385 title.className = "lp-title"; 26386 preview.appendChild(title); 26387 return { kind: kind, title: title }; 26388 } 26389 26390 let hoverTimer = 0; 26391 let activeLink = null; 26392 26393 function positionPreview(link) { 26394 const linkRect = link.getBoundingClientRect(); 26395 const cardW = Math.min(340, window.innerWidth - 24); 26396 let left = Math.max( 26397 12, 26398 Math.min(linkRect.left, window.innerWidth - cardW - 12), 26399 ); 26400 let top = linkRect.bottom + 8; 26401 const approxH = 120; 26402 if (top + approxH > window.innerHeight - 12) { 26403 top = linkRect.top - approxH - 8; 26404 if (top < 12) top = 12; 26405 } 26406 preview.style.left = left + "px"; 26407 preview.style.top = top + "px"; 26408 preview.style.maxWidth = cardW + "px"; 26409 } 26410 26411 function hidePreview() { 26412 preview.classList.remove("is-visible"); 26413 preview.setAttribute("aria-hidden", "true"); 26414 } 26415 26416 function showInternalPreview(link, targetId) { 26417 const target = document.getElementById(targetId); 26418 if (!target) return showExternalPreview(link); 26419 const titleText = target.textContent.replace(/^#\s*/, "").trim(); 26420 let excerptText = ""; 26421 let next = target.nextElementSibling; 26422 while (next && /^H[1-6]$/.test(next.tagName)) { 26423 next = next.nextElementSibling; 26424 } 26425 if (next) { 26426 excerptText = (next.textContent || "").trim().slice(0, 200); 26427 } 26428 const refs = buildPreviewCard("Section"); 26429 refs.title.textContent = titleText; 26430 const excerpt = document.createElement("div"); 26431 excerpt.className = "lp-excerpt"; 26432 excerpt.textContent = excerptText; 26433 preview.appendChild(excerpt); 26434 positionPreview(link); 26435 preview.classList.add("is-visible"); 26436 preview.setAttribute("aria-hidden", "false"); 26437 } 26438 26439 function showExternalPreview(link) { 26440 let url; 26441 try { 26442 url = new URL(link.href, location.href); 26443 } catch (e) { 26444 return; 26445 } 26446 const domain = url.hostname; 26447 const path = (url.pathname + url.search).replace(/\/$/, ""); 26448 const label = link.textContent.trim();
26449 const refs = buildPreviewCard("External link"); 26450 refs.title.textContent = label || domain; 26451 const urlEl = document.createElement("div"); 26452 urlEl.className = "lp-url"; 26453 urlEl.textContent = domain + (path || ""); 26454 preview.appendChild(urlEl); 26455 positionPreview(link); 26456 preview.classList.add("is-visible"); 26457 preview.setAttribute("aria-hidden", "false"); 26458 } 26459 26460 previewRoot.addEventListener("mouseover", function (e) { 26461 const link = e.target.closest("a[href]"); 26462 if (!link || !previewRoot.contains(link)) return; 26463 if (link.classList.contains("heading-anchor")) return; 26464 activeLink = link; 26465 window.clearTimeout(hoverTimer); 26466 hoverTimer = window.setTimeout(function () { 26467 if (activeLink !== link) return; 26468 const href = link.getAttribute("href") || ""; 26469 const hashMatch = href.match(/#([^\s?]+)$/); 26470 const sameDoc = 26471 href.startsWith("#") || link.pathname === location.pathname; 26472 if (hashMatch && sameDoc) { 26473 showInternalPreview(link, hashMatch[1]); 26474 } else if (link.href && !sameDoc) { 26475 showExternalPreview(link); 26476 } 26477 }, 500); 26478 }); 26479 previewRoot.addEventListener("mouseout", function (e) { 26480 const link = e.target.closest("a[href]"); 26481 if (!link) return; 26482 if (activeLink === link) activeLink = null; 26483 window.clearTimeout(hoverTimer); 26484 hidePreview(); 26485 }); 26486 window.addEventListener("scroll", hidePreview, { passive: true }); 26487 })(); 26488 })(); 26489 </script>
26489 26490 26491 <!-- Document Ontology --> 26492
26492<script> 26493 (function () { 26494 "use strict"; 26495 26496 function prefersReducedMotion() { 26497 return !!( 26498 window.matchMedia && 26499 window.matchMedia("(prefers-reduced-motion: reduce)").matches 26500 ); 26501 } 26502 26503 // ââ Constants ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 26504 var EXCLUDED_SELECTOR = 26505 "pre, code, .raw-html, .blocked-resource, .mermaid-block"; 26506 var LLM_STORAGE_KEY = "md-viewer-llm"; 26507 var LEGACY_OLLAMA_STORAGE_KEY = "md-viewer-ollama"; 26508 var CACHE_PREFIX = "ont-classify::"; 26509 var CACHE_VERSION = 2; 26510 26511 var KNOWN_TYPES = [ 26512 "introduction", 26513 "definition", 26514 "explanation", 26515 "instruction", 26516 "argument", 26517 "narrative", 26518 "example", 26519 ]; 26520 26521 var TYPE_COLORS = { 26522 introduction: "var(--ont-intro, #6366f1)", 26523 definition: "var(--ont-definition, #0ea5e9)", 26524 explanation: "var(--ont-explanation, #10b981)", 26525 instruction: "var(--ont-instruction, #f59e0b)", 26526 argument: "var(--ont-argument, #ef4444)", 26527 narrative: "var(--ont-narrative, #8b5cf6)", 26528 example: "var(--ont-example, #14b8a6)", 26529 unknown: "var(--ont-unknown, #94a3b8)", 26530 }; 26531 26532 var STOPWORDS = new Set([ 26533 "a", 26534 "about", 26535 "above", 26536 "after", 26537 "again", 26538 "against", 26539 "all", 26540 "am", 26541 "an", 26542 "and", 26543 "any", 26544 "are", 26545 "aren", 26546 "as", 26547 "at", 26548 "be", 26549 "because", 26550 "been", 26551 "before", 26552 "being", 26553 "below", 26554 "between", 26555 "both", 26556 "but", 26557 "by", 26558 "can", 26559 "cannot", 26560 "could", 26561 "did", 26562 "do", 26563 "does", 26564 "doing", 26565 "down", 26566 "during", 26567 "each", 26568 "few", 26569 "for", 26570 "from", 26571 "further", 26572 "get", 26573 "got", 26574 "had", 26575 "has", 26576 "have", 26577 "having", 26578 "he", 26579 "her", 26580 "here", 26581 "hers", 26582 "herself", 26583 "him", 26584 "himself", 26585 "his", 26586 "how", 26587 "i", 26588 "if", 26589 "in", 26590 "into", 26591 "is", 26592 "it", 26593 "its", 26594 "itself", 26595 "just", 26596 "ll", 26597 "me", 26598 "might", 26599 "more", 26600 "most", 26601 "must", 26602 "my", 26603 "myself", 26604 "no", 26605 "nor", 26606 "not", 26607 "now", 26608 "of", 26609 "off", 26610 "on", 26611 "once", 26612 "only", 26613 "or", 26614 "other", 26615 "our", 26616 "ours", 26617 "ourselves", 26618 "out", 26619 "over", 26620 "own", 26621 "re", 26622 "s", 26623 "same", 26624 "she", 26625 "should", 26626 "so", 26627 "some", 26628 "such", 26629 "t", 26630 "than", 26631 "that", 26632 "the", 26633 "their", 26634 "theirs", 26635 "them", 26636 "themselves", 26637 "then", 26638 "there", 26639 "these", 26640 "they", 26641 "this", 26642 "those", 26643 "through", 26644 "to", 26645 "too", 26646 "under", 26647 "until", 26648 "up", 26649 "us", 26650 "ve", 26651 "very", 26652 "was", 26653 "we", 26654 "were", 26655 "what", 26656 "when", 26657 "where", 26658 "which", 26659 "while", 26660 "who", 26661 "whom", 26662 "why", 26663 "will", 26664 "with", 26665 "won", 26666 "would", 26667 "you", 26668 "your", 26669 "yours", 26670 "yourself", 26671 "yourselves", 26672 "also", 26673 "use", 26674 "used", 26675 "using", 26676 "via", 26677 "etc", 26678 "may", 26679 "can", 26680 "way", 26681 "ways", 26682 "new", 26683 "well", 26684 "one", 26685 "two", 26686 "three", 26687 "four", 26688 "five", 26689 "let", 26690 "see", 26691 "look", 26692 "make", 26693 "made", 26694 "need", 26695 "needs", 26696 "able", 26697 "want", 26698 "wants", 26699 "set", 26700 "sets", 26701 "include", 26702 "includes", 26703 "included", 26704 ]); 26705 26706 // ââ State âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 26707 var sections = []; 26708 var termData = []; 26709 var classifications = {}; 26710 var documentShape = {}; 26711 var classifyGeneration = 0; 26712 var classifyStale = false;
26713 var panelOpen = false; 26714 var currentDocumentId = null; 26715 var lastTextHash = null; 26716 26717 // ââ Utility âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 26718 function releaseDocumentAnalysis(options) { 26719 options = options || {}; 26720 sections = []; 26721 termData = []; 26722 lastTextHash = null; 26723 if (!options.keepClassifications) { 26724 classifications = {}; 26725 documentShape = {}; 26726 } 26727 } 26728 26729 function simpleHash(text) { 26730 var h = 5381; 26731 for (var i = 0; i < text.length; i++) { 26732 h = ((h << 5) + h) ^ text.charCodeAt(i); 26733 h = h >>> 0; 26734 } 26735 return h.toString(36); 26736 } 26737 26738 function escapeHtml(text) { 26739 var d = document.createElement("div"); 26740 d.textContent = text; 26741 return d.innerHTML; 26742 } 26743 26744 function emitAnnounce(message) { 26745 document.dispatchEvent( 26746 new CustomEvent("markv:announce", { detail: { message: message } }), 26747 ); 26748 } 26749 26750 function getDocumentId(name) { 26751 if (name && name !== "Untitled" && name !== "") { 26752 return "named::" + name; 26753 } 26754 return "draft::current"; 26755 } 26756 26757 var TYPE_LABELS = { 26758 introduction: "intro", 26759 definition: "define", 26760 explanation: "explain", 26761 instruction: "how-to", 26762 argument: "argument", 26763 narrative: "narrative", 26764 example: "example", 26765 unknown: "other", 26766 }; 26767 26768 function getTypeLabel(type) { 26769 return TYPE_LABELS[type] || type; 26770 } 26771 26772 function getTypeColor(type) { 26773 return TYPE_COLORS[type] || TYPE_COLORS["unknown"]; 26774 } 26775 26776 function getSectionId(index) { 26777 return "section::" + index; 26778 } 26779 26780 function normalizeProvider(value) { 26781 return value === "groq" ? "groq" : "ollama"; 26782 } 26783 26784 function getLlmConfig() { 26785 try { 26786 var raw = localStorage.getItem(LLM_STORAGE_KEY); 26787 if (raw) { 26788 var parsed = JSON.parse(raw); 26789 if (parsed && typeof parsed === "object") { 26790 parsed.provider = normalizeProvider(parsed.provider); 26791 return parsed; 26792 } 26793 } 26794 } catch (e) {} 26795 try { 26796 var legacyRaw = localStorage.getItem(LEGACY_OLLAMA_STORAGE_KEY); 26797 if (legacyRaw) { 26798 var legacy = JSON.parse(legacyRaw); 26799 if (legacy && typeof legacy === "object") { 26800 return { 26801 provider: "ollama", 26802 endpoint: legacy.endpoint || "", 26803 ollamaModel: legacy.model || "", 26804 }; 26805 } 26806 } 26807 } catch (e) {} 26808 return null; 26809 } 26810 26811 function hasConfiguredLlm() { 26812 if (typeof window.markvHasConfiguredLlm === "function") { 26813 return window.markvHasConfiguredLlm(); 26814 } 26815 var config = getLlmConfig(); 26816 if (!config) return false; 26817 var provider = normalizeProvider(config.provider); 26818 if (provider === "groq") { 26819 return Boolean( 26820 String(config.groqApiKey || "").trim() && 26821 String(config.groqModel || config.model || "").trim(), 26822 ); 26823 } 26824 return Boolean( 26825 String(config.endpoint || "").trim() && 26826 String(config.ollamaModel || config.model || "").trim(), 26827 ); 26828 } 26829 26830 // ââ Stemmer (lightweight suffix stripping) ââââââââââââââââââââââââââââ 26831 var SUFFIXES = [ 26832 "tion", 26833 "ing", 26834 "ed", 26835 "ly", 26836 "ness", 26837 "ment", 26838 "er", 26839 "ful", 26840 "ous", 26841 "ive", 26842 "able", 26843 "ible", 26844 "al", 26845 "ial", 26846 ]; 26847 26848 function stem(word) { 26849 if (word.length < 6) return word; 26850 for (var i = 0; i < SUFFIXES.length; i++) { 26851 var s = SUFFIXES[i]; 26852 if (word.length > s.length + 3 && word.slice(-s.length) === s) { 26853 return word.slice(0, -s.length); 26854 } 26855 } 26856 return word; 26857 } 26858 26859 // ââ Text extraction âââââââââââââââââââââââââââââââââââââââââââââââââââ 26860 function isExcluded(node) { 26861 var el = node.nodeType === 1 ? node : node.parentElement; 26862 while (el) { 26863 if (el.matches && el.matches(EXCLUDED_SELECTOR)) return true; 26864 el = el.parentElement; 26865 } 26866 return false;
26867 } 26868 26869 function extractTextFromElement(container) { 26870 var text = ""; 26871 var walker = document.createTreeWalker( 26872 container, 26873 NodeFilter.SHOW_TEXT, 26874 { 26875 acceptNode: function (node) { 26876 if (isExcluded(node)) return NodeFilter.FILTER_REJECT; 26877 return NodeFilter.FILTER_ACCEPT; 26878 }, 26879 }, 26880 ); 26881 var node; 26882 while ((node = walker.nextNode())) { 26883 text += node.nodeValue + " "; 26884 } 26885 return text; 26886 } 26887 26888 function tokenize(text) { 26889 return text 26890 .toLowerCase() 26891 .split(/[^a-z]+/) 26892 .filter(function (w) { 26893 return w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w); 26894 }); 26895 } 26896 26897 // ââ Phase 1: Section Model âââââââââââââââââââââââââââââââââââââââââââââ 26898 function buildSectionModel() { 26899 var preview = document.getElementById("preview"); 26900 if (!preview) return; 26901 26902 var headings = Array.from( 26903 preview.querySelectorAll("h1,h2,h3,h4,h5,h6"), 26904 ); 26905 sections = []; 26906 26907 if (headings.length === 0) { 26908 var allText = extractTextFromElement(preview); 26909 var words = tokenize(allText); 26910 sections.push({ 26911 id: getSectionId(0), 26912 index: 0, 26913 heading: "Document", 26914 level: 0, 26915 headingElement: null, 26916 text: allText, 26917 previewText: allText.slice(0, 500), 26918 wordCount: words.length, 26919 words: words, 26920 }); 26921 } else { 26922 for (var i = 0; i < headings.length; i++) { 26923 var hEl = headings[i]; 26924 var level = parseInt(hEl.tagName.slice(1), 10); 26925 var nextH = headings[i + 1] || null; 26926 26927 var sectionText = hEl.textContent + " "; 26928 var node = hEl.nextSibling; 26929 while (node) { 26930 if (node === nextH) break; 26931 if (node.nodeType === 1) { 26932 if (!node.matches(EXCLUDED_SELECTOR)) { 26933 sectionText += extractTextFromElement(node) + " "; 26934 } 26935 } else if (node.nodeType === 3) { 26936 sectionText += node.nodeValue + " "; 26937 } 26938 node = node.nextSibling; 26939 } 26940 26941 var words = tokenize(sectionText); 26942 sections.push({ 26943 id: getSectionId(i), 26944 index: i, 26945 heading: hEl.textContent.trim(), 26946 level: level, 26947 headingElement: hEl, 26948 text: sectionText, 26949 previewText: sectionText.slice(0, 500), 26950 wordCount: words.length, 26951 words: words, 26952 }); 26953 } 26954 } 26955 } 26956 26957 // ââ Phase 1: TF-IDF Term Extraction ââââââââââââââââââââââââââââââââââââ 26958 function extractTerms() { 26959 if (sections.length === 0) { 26960 termData = []; 26961 return; 26962 } 26963 26964 var totalWords = 0; 26965 sections.forEach(function (s) { 26966 totalWords += s.wordCount; 26967 }); 26968 26969 if (totalWords === 0) { 26970 termData = []; 26971 return; 26972 } 26973 26974 var globalFreq = {}; 26975 var sectionFreqs = sections.map(function (s) { 26976 var freq = {}; 26977 s.words.forEach(function (w) { 26978 var stemmed = stem(w); 26979 freq[stemmed] = (freq[stemmed] || 0) + 1; 26980 if (!globalFreq[stemmed]) { 26981 globalFreq[stemmed] = { count: 0, surface: w }; 26982 } 26983 globalFreq[stemmed].count += 1; 26984 }); 26985 return freq; 26986 }); 26987 26988 var numSections = sections.length; 26989 var scores = {}; 26990 26991 Object.keys(globalFreq).forEach(function (stemmed) { 26992 var containingCount = sectionFreqs.filter(function (f) { 26993 return !!f[stemmed]; 26994 }).length; 26995 var idf = Math.log((numSections + 1) / (containingCount + 1)); 26996
26997 sectionFreqs.forEach(function (freq, idx) { 26998 if (!freq[stemmed]) return; 26999 var tf = freq[stemmed] / Math.max(sections[idx].wordCount, 1); 27000 var score = tf * (idf + 1); 27001 if (!scores[stemmed] || scores[stemmed].score < score) { 27002 scores[stemmed] = { 27003 score: score, 27004 count: globalFreq[stemmed].count, 27005 surface: globalFreq[stemmed].surface, 27006 }; 27007 } 27008 }); 27009 }); 27010 27011 var topN = Math.min(12, Math.max(4, Math.floor(totalWords / 100))); 27012 var sorted = Object.keys(scores) 27013 .filter(function (stemmed) { 27014 return scores[stemmed].count >= 2; 27015 }) 27016 .sort(function (a, b) { 27017 return scores[b].score - scores[a].score; 27018 }); 27019 27020 termData = sorted 27021 .filter(function (stemmed) { 27022 return scores[stemmed].count >= 2; 27023 }) 27024 .slice(0, topN) 27025 .map(function (stemmed) { 27026 return { 27027 stemmed: stemmed, 27028 surface: scores[stemmed].surface, 27029 count: scores[stemmed].count, 27030 score: scores[stemmed].score, 27031 }; 27032 }); 27033 } 27034 27035 // ââ Hash helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 27036 function computeDocumentTextHash() { 27037 var combined = sections 27038 .map(function (s) { 27039 return s.text; 27040 }) 27041 .join("|"); 27042 return simpleHash(combined); 27043 } 27044 27045 function computeDocumentShape(classifs, sects) { 27046 var typeTotals = {}; 27047 var total = 0; 27048 sects.forEach(function (s) { 27049 var type = classifs[s.id] || "explanation";
27050 typeTotals[type] = (typeTotals[type] || 0) + s.wordCount; 27051 total += s.wordCount; 27052 }); 27053 if (total === 0) return {}; 27054 var shape = {}; 27055 Object.keys(typeTotals).forEach(function (t) { 27056 shape[t] = typeTotals[t] / total; 27057 }); 27058 return shape; 27059 } 27060 27061 // ââ Cache âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 27062 function loadCache(docId) { 27063 try { 27064 var raw = localStorage.getItem(CACHE_PREFIX + docId); 27065 if (!raw) return null; 27066 var parsed = JSON.parse(raw); 27067 if (parsed && parsed.cacheVersion === CACHE_VERSION) { 27068 return parsed; 27069 } 27070 } catch (e) {} 27071 return null; 27072 } 27073 27074 function saveCache(docId, textHash, classifs, shape) { 27075 try { 27076 localStorage.setItem( 27077 CACHE_PREFIX + docId, 27078 JSON.stringify({ 27079 cacheVersion: CACHE_VERSION, 27080 textHash: textHash, 27081 classifications: classifs, 27082 documentShape: shape, 27083 cachedAt: Date.now(), 27084 }), 27085 ); 27086 } catch (e) {} 27087 } 27088 27089 // ââ Phase 2: LLM Classification ââââââââââââââââââââââââââââââââââââââââ 27090 function classifySections(docId, textHash, generation) { 27091 if ( 27092 !hasConfiguredLlm() || 27093 typeof window.markvCallTextLlm !== "function" 27094 ) { 27095 return; 27096 } 27097 27098 var cached = loadCache(docId); 27099 if (cached && cached.textHash === textHash) { 27100 classifications = cached.classifications || {}; 27101 documentShape = cached.documentShape || {}; 27102 classifyStale = false; 27103 if (panelOpen) { 27104 renderPanel(); 27105 applyPreviewHints(); 27106 } 27107 return; 27108 } 27109 27110 // Build single batched prompt â one LLM call for all sections 27111 var listing = sections 27112 .map(function (s, idx) { 27113 var snippet = (s.previewText || "") 27114 .replace(/"/g, "'") 27115 .slice(0, 150); 27116 return ( 27117 idx + 1 + ". " + s.heading.replace(/"/g, "'") + ": " + snippet 27118 ); 27119 }) 27120 .join("\n"); 27121 27122 var systemPrompt = 27123 "Classify each document section into exactly one category. " + 27124 "Allowed categories: introduction, definition, explanation, instruction, argument, narrative, example. " + 27125 "Respond with ONLY a numbered list. Each line must be: number. category"; 27126 var userPrompt = "Sections:\n" + listing; 27127 27128 window 27129 .markvCallTextLlm( 27130 [ 27131 { role: "system", content: systemPrompt }, 27132 { role: "user", content: userPrompt }, 27133 ], 27134 { stream: false }, 27135 ) 27136 .then(function (content) { 27137 if (generation !== classifyGeneration) return; 27138 27139 var localClassifs = {}; 27140 var lines = String(content || "") 27141 .trim() 27142 .split("\n"); 27143 27144 lines.forEach(function (line) { 27145 var m = line.match(/^\s*(\d+)\.\s*(\w+)/); 27146 if (!m) return; 27147 var idx = parseInt(m[1], 10) - 1; 27148 var raw = m[2].toLowerCase(); 27149 if ( 27150 idx >= 0 && 27151 idx < sections.length && 27152 KNOWN_TYPES.indexOf(raw) >= 0 27153 ) { 27154 localClassifs[sections[idx].id] = raw; 27155 } 27156 }); 27157 27158 // Fill unmatched sections 27159 sections.forEach(function (s) { 27160 if (!localClassifs[s.id]) localClassifs[s.id] = "explanation"; 27161 }); 27162 27163 classifications = localClassifs; 27164 documentShape = computeDocumentShape(localClassifs, sections); 27165 classifyStale = false;
27166 saveCache(docId, textHash, classifications, documentShape); 27167 if (panelOpen) { 27168 renderPanel(); 27169 applyPreviewHints(); 27170 } 27171 }) 27172 .catch(function (err) { 27173 if (err && err.name === "AbortError") return; 27174 if (generation !== classifyGeneration) return; 27175 // Fallback: all explanation 27176 var localClassifs = {}; 27177 sections.forEach(function (s) { 27178 localClassifs[s.id] = "explanation"; 27179 }); 27180 classifications = localClassifs; 27181 documentShape = computeDocumentShape(localClassifs, sections); 27182 if (panelOpen) renderPanel(); 27183 }); 27184 } 27185 27186 // ââ Panel Rendering âââââââââââââââââââââââââââââââââââââââââââââââââââ 27187 function renderPanel() { 27188 var body = document.getElementById("ontBody"); 27189 var summary = document.getElementById("ontSummary"); 27190 if (!body) return; 27191 27192 var totalWords = sections.reduce(function (acc, s) { 27193 return acc + s.wordCount; 27194 }, 0); 27195 27196 if (sections.length === 0 || totalWords === 0) { 27197 body.innerHTML = '<p class="ont-empty">No content to analyze.</p>'; 27198 if (summary) summary.textContent = ""; 27199 return; 27200 } 27201 27202 if (summary) { 27203 summary.textContent = 27204 sections.length + 27205 " section" + 27206 (sections.length !== 1 ? "s" : "") + 27207 " \u00b7 " + 27208 totalWords + 27209 " words"; 27210 } 27211 27212 // All values inserted into HTML are escaped via escapeHtml() 27213 var html = ""; 27214 27215 // Heuristic dot colors when no classification available 27216 var LEVEL_COLORS = [ 27217 "var(--ont-intro)", 27218 "var(--ont-explanation)", 27219 "var(--ont-instruction)", 27220 "var(--ont-argument)", 27221 "var(--ont-narrative)", 27222 "var(--ont-example)", 27223 "var(--ont-definition)", 27224 ]; 27225 var llmConfigured = hasConfiguredLlm(); 27226 var classifying = 27227 llmConfigured && 27228 Object.keys(classifications).length < sections.length && 27229 !classifyStale; 27230 27231 // Sections 27232 html += '<div class="ont-section-label">Sections</div>'; 27233 sections.forEach(function (s, idx) { 27234 var type = classifications[s.id]; 27235 var color = type 27236 ? getTypeColor(type) 27237 : LEVEL_COLORS[idx % LEVEL_COLORS.length]; 27238 var badge = ""; 27239 27240 if (type) { 27241 badge = 27242 '<span class="ont-section-badge" style="background:color-mix(in srgb, ' + 27243 color + 27244 ' 15%, transparent)">' + 27245 escapeHtml(getTypeLabel(type)) + 27246 "</span>"; 27247 } else if (classifying) { 27248 badge = '<span class="ont-section-badge is-loading"></span>'; 27249 } 27250 27251 var indent = Math.max(0, (s.level - 1) * 12); 27252 html += 27253 '<button class="ont-section-row" data-section-id="' + 27254 escapeHtml(s.id) + 27255 '" style="padding-left:' + 27256 (14 + indent) + 27257 'px">' + 27258 '<span class="ont-section-dot" style="background:' + 27259 color + 27260 '"></span>' + 27261 '<span class="ont-section-name">' + 27262 escapeHtml(s.heading) + 27263 "</span>" + 27264 badge + 27265 "</button>"; 27266 }); 27267 27268 // Divider 27269 html += '<div class="ont-divider"></div>'; 27270 27271 // Key Concepts 27272 html += '<div class="ont-section-label">Key Concepts</div>'; 27273 html += '<div class="ont-concepts">'; 27274 if (termData.length > 0) { 27275 termData.forEach(function (t) { 27276 html += 27277 '<button class="ont-concept-pill" data-term="' + 27278 escapeHtml(t.stemmed) + 27279 '" data-surface="' + 27280 escapeHtml(t.surface) + 27281 '" aria-label="' + 27282 escapeHtml(t.surface) + 27283 ", " + 27284 t.count + 27285 ' occurrences">' + 27286 escapeHtml(t.surface) + 27287 ' <span class="ont-concept-count">\u00d7' + 27288 t.count + 27289 "</span></button>"; 27290 }); 27291 } else { 27292 html += 27293 '<span style="color:var(--fg-subtle);font-size:11px;">No key terms extracted.</span>'; 27294 } 27295 html += "</div>"; 27296 27297 // Divider 27298 html += '<div class="ont-divider"></div>'; 27299 27300 // Document Shape 27301 html += '<div class="ont-shape-section">'; 27302 html += 27303 '<div class="ont-section-label" style="padding-top:8px;">Document Shape</div>'; 27304 27305 if (!llmConfigured) { 27306 html += 27307 '<div class="ont-fallback" style="padding:6px 0 12px;text-align:left;">Open AI settings for section analysis.</div>'; 27308 } else if (classifyStale) { 27309 html += 27310 '<div style="padding:6px 0 12px;"><button class="ont-stale-link ont-reanalyze-btn">Stale \u2014 re-analyze</button></div>'; 27311 } else if (Object.keys(documentShape).length === 0) { 27312 html += 27313 '<div style="padding:6px 0 12px;color:var(--fg-subtle);font-size:10px;">Analyzing\u2026</div>'; 27314 } else { 27315 var shapeKeys = Object.keys(documentShape) 27316 .filter(function (k) { 27317 return documentShape[k] > 0.01; 27318 }) 27319 .sort(function (a, b) { 27320 return documentShape[b] - documentShape[a]; 27321 }); 27322 var shapeLabel = shapeKeys 27323 .map(function (type) { 27324 return Math.round(documentShape[type] * 100) + "% " + type; 27325 }) 27326 .join(", "); 27327 html += 27328 '<div class="ont-shape-bar" role="img" aria-label="' + 27329 escapeHtml("Document composition: " + shapeLabel) + 27330 '">'; 27331 shapeKeys.forEach(function (type) { 27332 html += 27333 '<div class="ont-shape-segment" style="flex:' + 27334 documentShape[type] + 27335 ";background:" + 27336 getTypeColor(type) + 27337 '"></div>'; 27338 }); 27339 html += "</div>"; 27340 html += '<div class="ont-shape-labels">'; 27341 shapeKeys.forEach(function (type) { 27342 html += "<span>" + escapeHtml(getTypeLabel(type)) + "</span>"; 27343 }); 27344 html += "</div>"; 27345 } 27346 html += "</div>"; 27347 27348 body.innerHTML = html; // all values are escaped via escapeHtml() 27349 bindPanelEvents(); 27350 } 27351 27352 function bindPanelEvents() { 27353 var body = document.getElementById("ontBody"); 27354 if (!body) return; 27355 27356 body.querySelectorAll(".ont-section-row").forEach(function (btn) { 27357 btn.addEventListener("click", function () { 27358 var sectionId = this.getAttribute("data-section-id"); 27359 var section = sections.find(function (s) { 27360 return s.id === sectionId; 27361 }); 27362 if (section && section.headingElement) { 27363 section.headingElement.scrollIntoView({ 27364 behavior: prefersReducedMotion() ? "auto" : "smooth", 27365 block: "start", 27366 }); 27367 } 27368 }); 27369 }); 27370 27371 body.querySelectorAll(".ont-concept-pill").forEach(function (pill) { 27372 pill.addEventListener("click", function () { 27373 var surface = this.getAttribute("data-surface"); 27374 flashTerm(surface); 27375 }); 27376 }); 27377 27378 var reanalyzeBtn = body.querySelector(".ont-reanalyze-btn"); 27379 if (reanalyzeBtn) { 27380 reanalyzeBtn.addEventListener("click", function () { 27381 classifyStale = false;
27382 classifications = {}; 27383 documentShape = {}; 27384 classifyGeneration++; 27385 renderPanel(); 27386 var docId = currentDocumentId; 27387 var hash = computeDocumentTextHash(); 27388 classifySections(docId, hash, classifyGeneration); 27389 }); 27390 } 27391 } 27392 27393 function flashTerm(surface) { 27394 var spans = Array.from(document.querySelectorAll(".ont-term")).filter( 27395 function (s) { 27396 return ( 27397 s.textContent.toLowerCase() === surface.toLowerCase() || 27398 s.getAttribute("data-surface") === surface 27399 ); 27400 }, 27401 ); 27402 if (spans.length === 0) return; 27403 spans.forEach(function (s) { 27404 s.classList.add("ont-term-flash"); 27405 setTimeout(function () { 27406 s.classList.remove("ont-term-flash"); 27407 }, 1200); 27408 }); 27409 spans[0].scrollIntoView({ 27410 behavior: prefersReducedMotion() ? "auto" : "smooth", 27411 block: "nearest", 27412 }); 27413 } 27414 27415 // ââ Preview Hints âââââââââââââââââââââââââââââââââââââââââââââââââââââ 27416 function applyPreviewHints() { 27417 var preview = document.getElementById("preview"); 27418 if (!preview) return; 27419 27420 preview.classList.add("ontology-active"); 27421 27422 sections.forEach(function (s) { 27423 if (!s.headingElement) return; 27424 if (s.headingElement.querySelector(".ont-preview-dot")) return; 27425 var type = classifications[s.id] || null; 27426 var color = type ? getTypeColor(type) : "#94a3b8"; 27427 var dot = document.createElement("span"); 27428 dot.className = "ont-preview-dot"; 27429 dot.style.background = color; 27430 dot.setAttribute("aria-hidden", "true"); 27431 s.headingElement.prepend(dot); 27432 }); 27433 27434 applyTermUnderlines(); 27435 } 27436 27437 function applyTermUnderlines() { 27438 var preview = document.getElementById("preview"); 27439 if (!preview || termData.length === 0) return; 27440 27441 var surfaceForms = termData.map(function (t) { 27442 return t.surface; 27443 }); 27444 27445 var walker = document.createTreeWalker( 27446 preview, 27447 NodeFilter.SHOW_TEXT, 27448 { 27449 acceptNode: function (node) { 27450 if (isExcluded(node)) return NodeFilter.FILTER_REJECT; 27451 var p = node.parentElement; 27452 while (p && p !== preview) { 27453 if ( 27454 p.classList && 27455 (p.classList.contains("doc-highlight") || 27456 p.classList.contains("ont-term")) 27457 ) { 27458 return NodeFilter.FILTER_REJECT; 27459 } 27460 p = p.parentElement; 27461 } 27462 return NodeFilter.FILTER_ACCEPT; 27463 }, 27464 }, 27465 ); 27466 27467 var textNodes = []; 27468 var node; 27469 while ((node = walker.nextNode())) { 27470 textNodes.push(node); 27471 } 27472 27473 textNodes.forEach(function (textNode) { 27474 wrapTermsInNode(textNode, surfaceForms); 27475 }); 27476 } 27477 27478 function wrapTermsInNode(textNode, surfaceForms) { 27479 var text = textNode.nodeValue; 27480 if (!text || text.trim() === "") return; 27481 27482 var matches = []; 27483 surfaceForms.forEach(function (surface) { 27484 var escaped = surface.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 27485 var re = new RegExp("\\b" + escaped + "\\b", "gi"); 27486 var m; 27487 while ((m = re.exec(text)) !== null) { 27488 matches.push({ 27489 start: m.index, 27490 end: m.index + m[0].length, 27491 surface: surface, 27492 matched: m[0], 27493 }); 27494 } 27495 }); 27496 27497 if (matches.length === 0) return; 27498 27499 // Sort descending by start position 27500 matches.sort(function (a, b) { 27501 return b.start - a.start; 27502 }); 27503 27504 // Deduplicate overlapping matches 27505 var deduped = []; 27506 var lastStart = Infinity; 27507 for (var i = 0; i < matches.length; i++) { 27508 if (matches[i].end <= lastStart) { 27509 deduped.push(matches[i]); 27510 lastStart = matches[i].start; 27511 } 27512 } 27513 27514 if (deduped.length === 0) return; 27515 27516 var parent = textNode.parentNode; 27517 if (!parent) return; 27518 27519 var currentNode = textNode; 27520 deduped.forEach(function (match) { 27521 if (!currentNode || currentNode.nodeType !== 3) return; 27522 var nodeText = currentNode.nodeValue; 27523 if (match.start >= nodeText.length) return; 27524 27525 var after = currentNode.splitText(match.end); 27526 var matchNode = currentNode.splitText(match.start); 27527 27528 var span = document.createElement("span"); 27529 span.className = "ont-term"; 27530 span.setAttribute("data-surface", match.surface); 27531 parent.insertBefore(span, after); 27532 span.appendChild(matchNode); 27533 // currentNode is now the left prefix â correct for next right-to-left match 27534 }); 27535 } 27536 27537 function removePreviewHints() { 27538 var preview = document.getElementById("preview"); 27539 if (!preview) return; 27540 27541 preview.classList.remove("ontology-active"); 27542 27543 Array.from(preview.querySelectorAll(".ont-preview-dot")).forEac
27543h( 27544 function (dot) { 27545 dot.remove(); 27546 }, 27547 ); 27548 27549 var termSpans = Array.from(preview.querySelectorAll(".ont-term")); 27550 var unwrapped = termSpans.length > 0; 27551 termSpans.forEach(function (span) { 27552 var par = span.parentNode; 27553 if (!par) return; 27554 while (span.firstChild) { 27555 par.insertBefore(span.firstChild, span); 27556 } 27557 par.removeChild(span); 27558 }); 27559 27560 if (unwrapped) { 27561 preview.normalize(); 27562 document.dispatchEvent( 27563 new CustomEvent("markv:request-highlight-refresh"), 27564 ); 27565 } 27566 } 27567 27568 // ââ Panel Show / Hide âââââââââââââââââââââââââââââââââââââââââââââââââ 27569 function showPanel() { 27570 var panel = document.getElementById("ontologyPanel"); 27571 var toggleBtn = document.getElementById("ontologyToggleBtn"); 27572 if (!panel) return; 27573 27574 panel.hidden = false; 27575 panel.classList.remove("hidden"); 27576 panel.setAttribute("aria-hidden", "false"); 27577 panelOpen = true; 27578 if (toggleBtn) toggleBtn.setAttribute("aria-expanded", "true"); 27579 27580 // While the panel was closed, the render listener skipped 27581 // the section/term build. Rebuild now so the UI reflects 27582 // the current document. 27583 if (sections.length === 0 || classifyStale) { 27584 buildSectionModel(); 27585 extractTerms(); 27586 lastTextHash = computeDocumentTextHash(); 27587 } 27588 27589 renderPanel(); 27590 applyPreviewHints(); 27591 27592 if (sections.length > 0) { 27593 var hash = computeDocumentTextHash(); 27594 var docId = currentDocumentId; 27595 var cached = loadCache(docId); 27596 if (cached && cached.textHash === hash) { 27597 classifications = cached.classifications || {}; 27598 documentShape = cached.documentShape || {}; 27599 classifyStale = false; 27600 renderPanel(); 27601 applyPreviewHints(); 27602 } else if ( 27603 Object.keys(classifications).length === 0 || 27604 classifyStale 27605 ) { 27606 classifyGeneration++; 27607 classifySections(docId, hash, classifyGeneration); 27608 } 27609 } 27610 27611 emitAnnounce("Document ontology panel opened."); 27612 } 27613 27614 function hidePanel(options) { 27615 options = options || {}; 27616 var panel = document.getElementById("ontologyPanel"); 27617 var toggleBtn = document.getElementById("ontologyToggleBtn"); 27618 if (!panel) return; 27619 27620 panel.hidden = true; 27621 panel.classList.add("hidden"); 27622 panel.setAttribute("aria-hidden", "true"); 27623 panelOpen = false; 27624 if (!options.skipGenerationBump) classifyGeneration++; 27625 if (toggleBtn) toggleBtn.setAttribute("aria-expanded", "false"); 27626 removePreviewHints(); 27627 releaseDocumentAnalysis(); 27628 if (options.announce !== false) { 27629 emitAnnounce("Document ontology panel closed."); 27630 } 27631 } 27632 27633 // ââ Toolbar toggle button âââââââââââââââââââââââââââââââââââââââââââââ 27634 var toggleBtn = document.getElementById("ontologyToggleBtn"); 27635 if (toggleBtn) { 27636 toggleBtn.addEventListener("click", function () { 27637 if (panelOpen) { 27638 hidePanel(); 27639 } else { 27640 showPanel(); 27641 } 27642 }); 27643 } 27644 27645 // ââ Close button ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 27646 var closeBtn = document.getElementById("ontCloseBtn"); 27647 if (closeBtn) { 27648 closeBtn.addEventListener("click", function () { 27649 hidePanel(); 27650 }); 27651 } 27652 27653 // ââ Click outside to close ââââââââââââââââââââââââââââââââââââââââââââ 27654 document.addEventListener("click", function (e) { 27655 if (!panelOpen) return; 27656 var panel = document.getElementById("ontologyPanel"); 27657 var tBtn = document.getElementById("ontologyToggleBtn"); 27658 if (!panel || !tBtn) return; 27659 if ( 27660 !panel.contains(e.target) && 27661 tBtn !== e.target && 27662 !tBtn.contains(e.target) 27663 ) { 27664 hidePanel(); 27665 } 27666 }); 27667 27668 // ââ Escape key ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 27669 document.addEventListener("keydown", function (e) { 27670 if (e.key !== "Escape" || !panelOpen) return; 27671 var bubble = document.getElementById("selectionBubble"); 27672 var ctx = document.getElementById("ctxMenu"); 27673 var chip = document.getElementById("highlightRemoveChip"); 27674 function isVisible(el) { 27675 return el && !el.hidden && el.style.display !== "none"; 27676 } 27677 if (isVisible(bubble) || isVisible(ctx) || isVisible(chip)) return; 27678 hidePanel(); 27679 }); 27680 27681 // ââ markv:render-complete listener ââââââââââââââââââââââââââââââââââââ 27682 document.addEventListener("markv:render-complete", function (e) { 27683 var detail = (e && e.detail) || {}; 27684 var docName = detail.documentName || ""; 27685 var newDocId = getDocumentId(docName); 27686 27687 if (newDocId !== currentDocumentId) { 27688 currentDocumentId = newDocId; 27689 releaseDocumentAnalysis(); 27690 classifyStale = false;
27691 classifyGeneration++; 27692 if (panelOpen) { 27693 hidePanel({ announce: false, skipGenerationBump: true }); 27694 } 27695 } 27696 27697 // When the panel is closed, skip walking/tokenizing the 27698 // whole preview on every keystroke â the data only feeds 27699 // the ontology UI. Mark stale so the next `showPanel()` 27700 // rebuilds from current DOM. 27701 if (!panelOpen) { 27702 classifyStale = true; 27703 releaseDocumentAnalysis(); 27704 return; 27705 } 27706 27707 buildSectionModel(); 27708 extractTerms(); 27709 27710 var newHash = computeDocumentTextHash(); 27711 if (lastTextHash !== null && newHash !== lastTextHash) { 27712 classifyStale = true; 27713 classifyGeneration++; 27714 } 27715 lastTextHash = newHash; 27716 27717 removePreviewHints(); 27718 renderPanel(); 27719 applyPreviewHints(); 27720 }); 27721 27722 // ââ Initial build (the first markv:render-complete fires before this script loads) ââ 27723 if ( 27724 document.getElementById("preview") && 27725 document.getElementById("preview").textContent.trim() 27726 ) { 27727 var docName = 27728 document.getElementById("preview").dataset.documentLabel || ""; 27729 if (docName === "current document") docName = ""; 27730 currentDocumentId = getDocumentId(docName); 27731 classifyStale = true; 27732 } 27733 })(); 27734 </script>
27734 27735 27736 <!-- Scroll to top --> 27737 <button 27738 id="scrollTopBtn" 27739 class="scroll-top-btn" 27740 type="button" 27741 title="Scroll to top" 27742 aria-label="Scroll to top" 27743 aria-hidden="true" 27744 tabindex="-1" 27745 > 27746 <svg 27747 viewBox="0 0 24 24" 27748 fill="none" 27749 stroke="currentColor" 27750 stroke-width="2" 27751 stroke-linecap="round" 27752 stroke-linejoin="round" 27753 aria-hidden="true" 27754 > 27755 <path d="M12 19V5" /> 27756 <path d="M5 12l7-7 7 7" /> 27757 </svg> 27758 </button> 27759 27760 <!-- Chat FAB --> 27761 <button 27762 id="chatFab" 27763 class="chat-fab" 27764 title="Ask about this document" 27765 aria-label="Open document Q&A" 27766 aria-haspopup="dialog" 27767 aria-controls="chatPanel" 27768 > 27769 <svg 27770 width="22" 27771 height="22" 27772 viewBox="0 0 24 24" 27773 fill="none" 27774 stroke="currentColor" 27775 stroke-width="2" 27776 stroke-linecap="round" 27777 stroke-linejoin="round" 27778 > 27779 <path 27780 d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" 27781 /> 27782 </svg> 27783 </button> 27784 27785 <!-- Chat Panel --> 27786 <div 27787 id="chatPanel" 27788 class="chat-panel hidden" 27789 role="dialog" 27790 aria-modal="true" 27791 aria-labelledby="chatTitle" 27792 aria-describedby="chatHelp" 27793 aria-hidden="true" 27794 hidden 27795 > 27796 <div class="chat-header"> 27797 <svg 27798 width="16" 27799 height="16"
27800 viewBox="0 0 24 24" 27801 fill="none" 27802 stroke="currentColor" 27803 stroke-width="2" 27804 stroke-linecap="round" 27805 stroke-linejoin="round" 27806 > 27807 <path 27808 d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" 27809 /> 27810 </svg> 27811 <span id="chatTitle" class="chat-header-title">Document Q&A</span> 27812 <button 27813 id="chatSettingsBtn" 27814 class="chat-header-btn" 27815 title="AI settings" 27816 aria-label="Settings" 27817 aria-controls="chatSettings" 27818 aria-expanded="false" 27819 > 27820 <svg 27821 width="16" 27822 height="16" 27823 viewBox="0 0 24 24" 27824 fill="none" 27825 stroke="currentColor" 27826 stroke-width="2" 27827 stroke-linecap="round" 27828 stroke-linejoin="round" 27829 > 27830 <circle cx="12" cy="12" r="3" /> 27831 <path 27832 d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" 27833 /> 27834 </svg> 27835 </button> 27836 <button 27837 id="chatCloseBtn" 27838 class="chat-header-btn" 27839 title="Close" 27840 aria-label="Close chat" 27841 > 27842 <svg 27843 width="16" 27844 height="16"
27845 viewBox="0 0 24 24" 27846 fill="none" 27847 stroke="currentColor" 27848 stroke-width="2" 27849 stroke-linecap="round" 27850 stroke-linejoin="round" 27851 > 27852 <line x1="18" y1="6" x2="6" y2="18" /> 27853 <line x1="6" y1="6" x2="18" y2="18" /> 27854 </svg> 27855 </button> 27856 </div> 27857 <div 27858 id="chatMessages" 27859 class="chat-messages" 27860 role="log" 27861 aria-label="Document Q&A conversation" 27862 aria-live="off" 27863 aria-relevant="additions text" 27864 > 27865 <div id="chatHelp" class="chat-msg system"> 27866 Load a document, then ask questions about it. Powered by Ollama or 27867 Groq. 27868 </div> 27869 </div> 27870 <div 27871 id="chatLiveRegion" 27872 class="visually-hidden" 27873 aria-live="polite" 27874 aria-atomic="true" 27875 ></div> 27876 <div 27877 id="chatSettings" 27878 class="chat-settings hidden" 27879 aria-hidden="true" 27880 hidden 27881 > 27882 <label> 27883 <span>Provider</span> 27884 <select id="llmProvider" aria-label="AI provider"> 27885 <option value="ollama">Ollama</option> 27886 <option value="groq">Groq</option> 27887 </select> 27888 </label> 27889 <label id="ollamaEndpointField"> 27890 <span>Local Ollama Endpoint</span> 27891 <input 27892 id="ollamaEndpoint" 27893 type="text" 27894 value="http://localhost:11434" 27895 placeholder="http://localhost:11434" 27896 /> 27897 </label> 27898 <label id="ollamaModelField"> 27899 <span>Ollama Model</span> 27900 <input 27901 id="ollamaModel" 27902 type="text" 27903 value="gemma4:26b" 27904 placeholder="gemma4:26b" 27905 /> 27906 </label> 27907 <label id="groqApiKeyField" hidden> 27908 <span>Groq API Key</span> 27909 <input 27910 id="groqApiKey" 27911 type="password" 27912 placeholder="gsk_..." 27913 autocomplete="off" 27914 spellcheck="false" 27915 /> 27916 </label> 27917 <label id="groqModelField" hidden> 27918 <span>Groq Model</span> 27919 <input 27920 id="groqModel" 27921 type="text" 27922 value="qwen/qwen3-32b" 27923 placeholder="qwen/qwen3-32b" 27924 /> 27925 </label> 27926 <p id="chatSettingsNote" class="chat-settings-note"> 27927 Groq needs an API key. Ollama stays local and needs a loopback 27928 endpoint. 27929 </p> 27930 </div> 27931 <div class="chat-input-area"> 27932 <div class="chat-input-card"> 27933 <textarea 27934 id="chatInput" 27935 class="chat-input" 27936 rows="2" 27937 placeholder="Ask about this document..." 27938 aria-label="Ask about the current document" 27939 ></textarea> 27940 <div class="chat-input-actions"> 27941 <span class="chat-input-hint">ⵠsend · shift+ⵠnewline</span> 27942 <button 27943 id="chatSendBtn" 27944 class="chat-send-btn" 27945 title="Send" 27946 aria-label="Send" 27947 disabled 27948 > 27949 <svg 27950 width="14" 27951 height="14"
27952 viewBox="0 0 24 24" 27953 fill="none" 27954 stroke="currentColor" 27955 stroke-width="2.5" 27956 stroke-linecap="round" 27957 stroke-linejoin="round" 27958 > 27959 <line x1="22" y1="2" x2="11" y2="13" /> 27960 <polygon points="22 2 15 22 11 13 2 9 22 2" /> 27961 </svg> 27962 </button> 27963 </div> 27964 </div> 27965 </div> 27966 </div> 27967 27968 <!-- Files Drawer --> 27969 <div id="libraryOverlay" class="lib-overlay" aria-hidden="true"></div> 27970 <aside 27971 id="libraryDrawer" 27972 class="lib-drawer" 27973 role="dialog" 27974 aria-modal="true" 27975 aria-labelledby="libraryDrawerTitle" 27976 aria-hidden="true" 27977 tabindex="-1" 27978 > 27979 <header class="lib-header"> 27980 <div class="lib-header-copy"> 27981 <h2 id="libraryDrawerTitle" class="lib-header-title">Files</h2> 27982 </div> 27983 <button 27984 id="libraryCloseBtn" 27985 type="button" 27986 class="lib-close" 27987 aria-label="Close files" 27988 > 27989 <svg 27990 width="16" 27991 height="16"
27992 viewBox="0 0 24 24" 27993 fill="none" 27994 stroke="currentColor" 27995 stroke-width="2.2" 27996 stroke-linecap="round" 27997 stroke-linejoin="round" 27998 aria-hidden="true" 27999 > 28000 <line x1="18" y1="6" x2="6" y2="18" /> 28001 <line x1="6" y1="6" x2="18" y2="18" /> 28002 </svg> 28003 </button> 28004 </header> 28005 <div class="lib-actions"> 28006 <button id="openFileBtn" type="button" class="lib-file-action"> 28007 <span class="lib-file-action-icon" aria-hidden="true"> 28008 <svg 28009 viewBox="0 0 24 24" 28010 fill="none" 28011 stroke="currentColor" 28012 stroke-width="1.8" 28013 stroke-linecap="round" 28014 stroke-linejoin="round" 28015 > 28016 <path 28017 d="M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7z" 28018 /> 28019 <path d="M14 2v5h5" /> 28020 </svg> 28021 </span> 28022 <span>Open file</span> 28023 </button> 28024 <button id="libraryAddBtn" type="button" class="lib-file-action"> 28025 <span class="lib-file-action-icon" aria-hidden="true"> 28026 <svg
28027 viewBox="0 0 24 24" 28028 fill="none" 28029 stroke="currentColor" 28030 stroke-width="1.8" 28031 stroke-linecap="round" 28032 stroke-linejoin="round" 28033 > 28034 <path 28035 d="M3 7.5A2.5 2.5 0 0 1 5.5 5H10l2 2h6.5A2.5 2.5 0 0 1 21 9.5v7A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5Z" 28036 /> 28037 </svg> 28038 </span> 28039 <span>Open folder</span> 28040 </button> 28041 <button 28042 id="libraryToggleAllBtn" 28043 type="button" 28044 class="lib-toggle-all" 28045 data-mode="collapse" 28046 title="Collapse all" 28047 aria-label="Collapse all" 28048 hidden 28049 > 28050 <svg 28051 viewBox="0 0 12 12" 28052 fill="none" 28053 stroke="currentColor" 28054 stroke-width="1.4" 28055 stroke-linecap="round" 28056 stroke-linejoin="round" 28057 aria-hidden="true" 28058 > 28059 <path d="M3 7.5 6 4.5l3 3" /> 28060 </svg> 28061 <span class="lib-toggle-all-label">Collapse all</span> 28062 </button> 28063 </div> 28064 <div id="librarySupportedNote" class="lib-supported-note" hidden></div> 28065 <div id="libraryBody" class="lib-body"> 28066 <div id="libraryEmpty" class="lib-empty"> 28067 <p class="lib-empty-title">Nothing here yet</p> 28068 <p class="lib-empty-copy">Open a folder to begin reading.</p> 28069 </div> 28070 </div> 28071 </aside> 28072 28073 <!-- Global Settings Drawer --> 28074 <div id="globalSettingsOverlay" class="gs-overlay" aria-hidden="true"></div> 28075 <aside 28076 id="globalSettingsDrawer" 28077 class="gs-drawer" 28078 role="dialog" 28079 aria-modal="true" 28080 aria-labelledby="globalSettingsTitle" 28081 aria-hidden="true" 28082 tabindex="-1" 28083 > 28084 <header class="gs-header"> 28085 <h2 id="globalSettingsTitle" class="gs-header-title">Settings</h2> 28086 <button 28087 type="button" 28088 id="globalSettingsCloseBtn" 28089 class="gs-close" 28090 aria-label="Close settings" 28091 > 28092 <svg 28093 width="16" 28094 height="16"
28095 viewBox="0 0 24 24" 28096 fill="none" 28097 stroke="currentColor" 28098 stroke-width="2.2" 28099 stroke-linecap="round" 28100 stroke-linejoin="round" 28101 aria-hidden="true" 28102 > 28103 <line x1="18" y1="6" x2="6" y2="18" /> 28104 <line x1="6" y1="6" x2="18" y2="18" /> 28105 </svg> 28106 </button> 28107 </header> 28108 28109 <div class="gs-body"> 28110 <section aria-labelledby="gsAiProviderLabel"> 28111 <p id="gsAiProviderLabel" class="gs-section-label">AI Provider</p> 28112 28113 <div 28114 id="gsStatusChip" 28115 class="gs-status" 28116 role="status" 28117 aria-live="polite" 28118 > 28119 <span class="gs-status-dot" aria-hidden="true"></span> 28120 <span id="gsStatusText">Not configured</span> 28121 </div> 28122 28123 <div 28124 class="gs-segmented" 28125 role="tablist" 28126 aria-label="AI provider" 28127 style="margin-top: 12px" 28128 > 28129 <button 28130 type="button" 28131 class="gs-seg-btn" 28132 id="gsSegOllama" 28133 role="tab" 28134 aria-pressed="true" 28135 aria-controls="gsCardOllama" 28136 data-provider="ollama" 28137 > 28138 <svg 28139 class="gs-seg-icon"
28140 viewBox="0 0 24 24" 28141 fill="none" 28142 stroke="currentColor" 28143 stroke-width="2" 28144 stroke-linecap="round" 28145 stroke-linejoin="round" 28146 aria-hidden="true" 28147 > 28148 <rect x="3" y="4" width="18" height="12" rx="2" /> 28149 <line x1="8" y1="20" x2="16" y2="20" /> 28150 <line x1="12" y1="16" x2="12" y2="20" /> 28151 </svg> 28152 Ollama 28153 </button> 28154 <button 28155 type="button" 28156 class="gs-seg-btn" 28157 id="gsSegGroq" 28158 role="tab" 28159 aria-pressed="false" 28160 aria-controls="gsCardGroq" 28161 data-provider="groq" 28162 > 28163 <svg 28164 class="gs-seg-icon" 28165 viewBox="0 0 24 24" 28166 fill="none" 28167 stroke="currentColor" 28168 stroke-width="2" 28169 stroke-linecap="round" 28170 stroke-linejoin="round" 28171 aria-hidden="true" 28172 > 28173 <path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" /> 28174 </svg> 28175 Groq 28176 </button> 28177 </div> 28178 28179 <!-- Ollama card --> 28180 <div 28181 id="gsCardOllama" 28182 class="gs-card" 28183 role="tabpanel" 28184 aria-labelledby="gsSegOllama" 28185 style="margin-top: 14px" 28186 > 28187 <div class="gs-card-head"> 28188 <p class="gs-card-sub"> 28189 Runs on your machine â your document never leaves it. 28190 </p> 28191 <span id="gsOllamaInUse" class="gs-in-use-badge" hidden 28192 >In use</span 28193 > 28194 <button 28195 type="button" 28196 id="gsActivateOllama" 28197 class="gs-activate-btn" 28198 > 28199 Use Ollama 28200 </button> 28201 </div> 28202 <div class="gs-field"> 28203 <label class="gs-field-label" for="gsOllamaEndpoint"> 28204 Endpoint 28205 </label> 28206 <div class="gs-input-wrap"> 28207 <input 28208 id="gsOllamaEndpoint" 28209 class="gs-input" 28210 type="text" 28211 placeholder="http://localhost:11434" 28212 autocomplete="off" 28213 spellcheck="false" 28214 /> 28215 </div> 28216 <p class="gs-field-hint"> 28217 Must be a loopback address. Ollama runs on your machine â your 28218 document never leaves it. 28219 </p> 28220 </div> 28221 28222 <div class="gs-field"> 28223 <label class="gs-field-label" for="gsOllamaModel"> Model </label> 28224 <div class="gs-input-wrap"> 28225 <input 28226 id="gsOllamaModel" 28227 class="gs-input" 28228 type="text" 28229 placeholder="gemma4:26b" 28230 autocomplete="off" 28231 spellcheck="false" 28232 list="gsOllamaModelList" 28233 /> 28234 </div> 28235 <datalist id="gsOllamaModelList"> 28236 <option value="gemma4:26b"></option> 28237 <option value="llama3.1:8b"></option> 28238 <option value="llama3.1:70b"></option> 28239 <option value="qwen2.5:14b"></option> 28240 <option value="qwen2.5:32b"></option> 28241 <option value="mistral:7b"></option> 28242 <option value="phi3.5:3.8b"></option> 28243 </datalist> 28244 <p class="gs-field-hint"> 28245 Any model you've pulled with <code>ollama pull</code>. 28246 </p> 28247 </div> 28248 28249 <div class="gs-actions-row"> 28250 <button type="button" id="gsTestOllama" class="gs-test-btn"> 28251 Test connection 28252 </button> 28253 <span id="gsTestOllamaResult" class="gs-test-result"></span> 28254 </div> 28255 </div> 28256 28257 <!-- Groq card --> 28258 <div 28259 id="gsCardGroq" 28260 class="gs-card" 28261 role="tabpanel" 28262 aria-labelledby="gsSegGroq" 28263 hidden 28264 style="margin-top: 14px" 28265 > 28266 <div class="gs-card-head"> 28267 <p class="gs-card-sub">Hosted by Groq â fast cloud inference.</p>
28268 <span id="gsGroqInUse" class="gs-in-use-badge" hidden 28269 >In use</span 28270 > 28271 <button type="button" id="gsActivateGroq" class="gs-activate-btn"> 28272 Use Groq 28273 </button> 28274 </div> 28275 <div class="gs-field"> 28276 <label class="gs-field-label" for="gsGroqApiKey"> API Key </label> 28277 <div class="gs-input-wrap"> 28278 <input 28279 id="gsGroqApiKey" 28280 class="gs-input has-reveal" 28281 type="password" 28282 placeholder="gsk_..." 28283 autocomplete="off" 28284 spellcheck="false" 28285 /> 28286 <button 28287 type="button" 28288 id="gsGroqRevealBtn" 28289 class="gs-reveal-btn" 28290 aria-label="Show API key" 28291 aria-pressed="false" 28292 > 28293 <svg 28294 id="gsRevealIconShow" 28295 width="15" 28296 height="15"
28297 viewBox="0 0 24 24" 28298 fill="none" 28299 stroke="currentColor" 28300 stroke-width="2" 28301 stroke-linecap="round" 28302 stroke-linejoin="round" 28303 aria-hidden="true" 28304 > 28305 <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> 28306 <circle cx="12" cy="12" r="3" /> 28307 </svg> 28308 <svg 28309 id="gsRevealIconHide" 28310 width="15" 28311 height="15" 28312 viewBox="0 0 24 24" 28313 fill="none" 28314 stroke="currentColor" 28315 stroke-width="2" 28316 stroke-linecap="round" 28317 stroke-linejoin="round" 28318 aria-hidden="true" 28319 style="display: none" 28320 > 28321 <path 28322 d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-7 0-11-8-11-8a19.77 19.77 0 0 1 4.22-5.19M9.9 4.24A10.94 10.94 0 0 1 12 4c7 0 11 8 11 8a19.86 19.86 0 0 1-3.17 4.19M9.53 9.53a3 3 0 1 0 4.24 4.24" 28323 /> 28324 <line x1="1" y1="1" x2="23" y2="23" /> 28325 </svg> 28326 </button> 28327 </div> 28328 <p class="gs-field-hint"> 28329 Encrypted at rest with AES-GCM and stored only in this browser. 28330 Get a key at 28331 <a 28332 href="https://console.groq.com/keys" 28333 target="_blank" 28334 rel="noopener noreferrer" 28335 >console.groq.com/keys</a 28336 >. 28337 </p> 28338 </div> 28339 28340 <div class="gs-field"> 28341 <label class="gs-field-label" for="gsGroqModel"> Model </label> 28342 <div class="gs-input-wrap"> 28343 <input 28344 id="gsGroqModel" 28345 class="gs-input" 28346 type="text" 28347 placeholder="qwen/qwen3-32b" 28348 autocomplete="off" 28349 spellcheck="false" 28350 list="gsGroqModelList" 28351 /> 28352 </div> 28353 <datalist id="gsGroqModelList"> 28354 <option value="qwen/qwen3-32b"></option> 28355 <option value="llama-3.3-70b-versatile"></option> 28356 <option value="llama-3.1-8b-instant"></option> 28357 <option value="llama-3.1-70b-versatile"></option> 28358 <option value="mixtral-8x7b-32768"></option> 28359 <option value="gemma2-9b-it"></option> 28360 <option value="deepseek-r1-distill-llama-70b"></option> 28361 </datalist> 28362 <p class="gs-field-hint"> 28363 Pick a curated model or type any Groq-hosted model id. 28364 </p> 28365 </div> 28366 28367 <div class="gs-actions-row"> 28368 <button type="button" id="gsTestGroq" class="gs-test-btn"> 28369 Test connection 28370 </button> 28371 <span id="gsTestGroqResult" class="gs-test-result"></span> 28372 </div> 28373 </div> 28374 </section> 28375 </div> 28376 28377 <footer class="gs-foot"> 28378 Changes save automatically. AI config is shared across chat, mindmap, 28379 ontology and summary. 28380 <div style="margin-top: 12px; font-size: 11px; opacity: 0.5"> 28381 Built by 28382 <a 28383 href="https://anees.xyz" 28384 target="_blank" 28385 rel="noopener" 28386 style="color: inherit; text-decoration: underline" 28387 >Anees</a 28388 > 28389 </div> 28390 </footer> 28391 </aside> 28392 28393
28393<script> 28394 /* ============================================================ 28395 Global Settings Drawer â AI provider config, unified across 28396 chat, mindmap, ontology and summary features. 28397 ============================================================ */ 28398 (function () { 28399 "use strict"; 28400 28401 const drawer = document.getElementById("globalSettingsDrawer"); 28402 const overlay = document.getElementById("globalSettingsOverlay"); 28403 const openBtn = document.getElementById("globalSettingsBtn"); 28404 const closeBtn = document.getElementById("globalSettingsCloseBtn"); 28405 28406 const statusChip = document.getElementById("gsStatusChip"); 28407 const statusText = document.getElementById("gsStatusText"); 28408 28409 const segOllama = document.getElementById("gsSegOllama"); 28410 const segGroq = document.getElementById("gsSegGroq"); 28411 const cardOllama = document.getElementById("gsCardOllama"); 28412 const cardGroq = document.getElementById("gsCardGroq"); 28413 28414 const gsOllamaEndpoint = document.getElementById("gsOllamaEndpoint"); 28415 const gsOllamaModel = document.getElementById("gsOllamaModel"); 28416 const gsGroqApiKey = document.getElementById("gsGroqApiKey"); 28417 const gsGroqModel = document.getElementById("gsGroqModel"); 28418 28419 const activateOllamaBtn = document.getElementById("gsActivateOllama"); 28420 const activateGroqBtn = document.getElementById("gsActivateGroq"); 28421 const inUseOllamaBadge = document.getElementById("gsOllamaInUse"); 28422 const inUseGroqBadge = document.getElementById("gsGroqInUse"); 28423 28424 // The drawer tracks two separate concepts: which provider card 28425 // is currently being VIEWED (purely a navigation state inside 28426 // the drawer) and which provider is ACTIVE (the real app 28427 // config, read from the legacy hidden input). Keeping them 28428 // separate lets a user peek at Groq's settings without 28429 // inadvertently switching their active AI. 28430 let viewedProvider = "ollama"; 28431 28432 const revealBtn = document.getElementById("gsGroqRevealBtn"); 28433 const revealIconShow = document.getElementById("gsRevealIconShow"); 28434 const revealIconHide = document.getElementById("gsRevealIconHide"); 28435 28436 const testOllamaBtn = document.getElementById("gsTestOllama"); 28437 const testOllamaResult = document.getElementById("gsTestOllamaResult"); 28438 const testGroqBtn = document.getElementById("gsTestGroq"); 28439 const testGroqResult = document.getElementById("gsTestGroqResult"); 28440 28441 // Legacy inline panel inputs â the existing chat/mindmap/ontology 28442 // code reads from these. The drawer mirrors values both ways so 28443 // we don't have to touch any downstream call sites. 28444 const legacyProvider = document.getElementById("llmProvider"); 28445 const legacyEndpoint = document.getElementById("ollamaEndpoint"); 28446 const legacyOllamaModel = document.getElementById("ollamaModel"); 28447 const legacyGroqKey = document.getElementById("groqApiKey"); 28448 const legacyGroqModel = document.getElementById("groqModel"); 28449 28450 if (!drawer || !openBtn || !legacyProvider) return; 28451 28452 let lastFocused = null; 28453 let returnFocusEl = null; 28454 28455 function currentProvider() { 28456 return legacyProvider.value === "groq" ? "groq" : "ollama"; 28457 } 28458 28459 function fireLegacyInput(el) { 28460 // The existing chat IIFE listens for 'change' + 'input' on the 28461 // legacy inputs to persist LLM settings. Dispatching these 28462 // events lets the mirror update re-run its save pipeline. 28463 el.dispatchEvent(new Event("input", { bubbles: true })); 28464 el.dispatchEvent(new Event("change", { bubbles: true })); 28465 } 28466 28467 function pullFromLegacy() { 28468 gsOllamaEndpoint.value = legacyEndpoint.value || ""; 28469 gsOllamaModel.value = legacyOllamaModel.value || ""; 28470 gsGroqApiKey.value = legacyGroqKey.value || ""; 28471 gsGroqModel.value = legacyGroqModel.value || ""; 28472 // When the drawer opens, default the viewed card to the 28473 // provider that's actually active so the user lands on 28474 // what they're using right now. 28475 viewedProvider = currentProvider(); 28476 renderAll(); 28477 } 28478 28479 function pushProvider(provider) { 28480 if (legacyProvider.value !== provider) { 28481 legacyProvider.value = provider; 28482 fireLegacyInput(legacyProvider); 28483 } 28484 } 28485 28486 function pushField(legacyEl, value) { 28487 if (legacyEl.value !== value) { 28488 legacyEl.value = value; 28489 fireLegacyInput(legacyEl); 28490 } 28491 } 28492 28493 function isProviderReady(provider) { 28494 if (provider === "groq") { 28495 return Boolean( 28496 gsGroqApiKey.value.trim() && gsGroqModel.value.trim(), 28497 ); 28498 } 28499 return Boolean( 28500 gsOllamaEndpoint.value.trim() && gsOllamaModel.value.trim(), 28501 ); 28502 } 28503 28504 function updateSegmented() { 28505 const isGroq = viewedProvider === "groq"; 28506 segOllama.setAttribute("aria-pressed", String(!isGroq)); 28507 segGroq.setAttribute("aria-pressed", String(isGroq)); 28508 cardOllama.hidden = isGroq; 28509 cardGroq.hidden = !isGroq; 28510 // Restart the card intro animation so the swap feels deliberate. 28511 const activeCard = isGroq ? cardGroq : cardOllama; 28512 activeCard.style.animation = "none"; 28513 // eslint-disable-next-line no-unused-expressions 28514 activeCard.offsetHeight; 28515 activeCard.style.animation = ""; 28516 } 28517 28518 function updateActivationUI() { 28519 // Show the "In use" badge on whichever card is the active 28520 // provider, and the "Use X" button on the other. Both 28521 // controls live in the same slot so they never coexist. 28522 const active = currentProvider(); 28523 const ollamaActive = active === "ollama"; 28524 inUseOllamaBadge.hidden = !ollamaActive; 28525 activateOllamaBtn.hidden = ollamaActive; 28526 inUseGroqBadge.hidden = ollamaActive; 28527 activateGroqBtn.hidden = !ollamaActive; 28528 // Disable activate buttons until the target provider has 28529 // the minimum config it needs to actually run. 28530 activateOllamaBtn.disabled = !isProviderReady("ollama"); 28531 activateGroqBtn.disabled = !isProviderReady("groq"); 28532 activateOllamaBtn.title = activateOllamaBtn.disabled 28533 ? "Add endpoint and model first" 28534 : "Switch the app to Ollama"; 28535 activateGroqBtn.title = activateGroqBtn.disabled 28536 ? "Add an API key first" 28537 : "Switch the app to Groq"; 28538 } 28539 28540 function updateStatus() { 28541 // The chip ALWAYS describes the active provider, regardless 28542 // of which card is currently being viewed. That's the whole 28543 // point of separating viewed from active: the chip is the 28544 // single source of truth for "what is this app using?". 28545 const active = currentProvider(); 28546 const ready = isProviderReady(active); 28547 let label; 28548 if (active === "groq") { 28549 label = ready ? "Groq is ready" : "Groq needs an API key"; 28550 } else { 28551 label = ready 28552 ? "Ollama is ready" 28553 : "Ollama needs endpoint and model"; 28554 } 28555 statusChip.classList.toggle("is-ready", ready); 28556 statusText.textContent = label; 28557 } 28558 28559 function renderAll() { 28560 updateSegmented(); 28561 updateActivationUI(); 28562 updateStatus(); 28563 } 28564 28565 // ---- Segmented control: navigation only, no commit ---- 28566 function viewProvider(provider) { 28567 viewedProvider = provider; 28568 renderAll(); 28569 } 28570 segOllama.addEventListener("click", function () { 28571 viewProvider("ollama"); 28572 }); 28573 segGroq.addEventListener("click", function () { 28574 viewProvider("groq"); 28575 }); 28576 28577 // ---- Activation: the only thing that flips the active provider ---- 28578 function activateProvider(provider) { 28579 if (!isProviderReady(provider)) return; 28580 pushProvider(provider); 28581 viewedProvider = provider; 28582 renderAll(); 28583 } 28584 activateOllamaBtn.addEventListener("click", function () { 28585 activateProvider("ollama"); 28586 }); 28587 activateGroqBtn.addEventListener("click", function () { 28588 activateProvider("groq"); 28589 }); 28590 28591 // ---- Field mirroring ---- 28592 function bindMirror(drawerEl, legacyEl) { 28593 drawerEl.addEventListener("input", function () { 28594 pushField(legacyEl, drawerEl.value); 28595 renderAll(); 28596 }); 28597 // If something else in the app edits the legacy input, reflect it 28598 // back into the drawer on next open. 28599 legacyEl.addEventListener("input", function () { 28600 if (drawerEl.value !== legacyEl.value) { 28601 drawerEl.value = legacyEl.value; 28602 renderAll(); 28603 } 28604 }); 28605 } 28606 bindMirror(gsOllamaEndpoint, legacyEndpoint); 28607 bindMirror(gsOllamaModel, legacyOllamaModel); 28608 bindMirror(gsGroqApiKey, legacyGroqKey); 28609 bindMirror(gsGroqModel, legacyGroqModel); 28610 28611 // ---- Reveal toggle for API key ---- 28612 revealBtn.addEventListener("click", function () { 28613 const showing = gsGroqApiKey.type === "text"; 28614 gsGroqApiKey.type = showing ? "password" : "text"; 28615 revealBtn.setAttribute("aria-pressed", String(!showing)); 28616 revealBtn.setAttribute( 28617 "aria-label", 28618 showing ? "Show API key" : "Hide API key", 28619 );
28620 revealIconShow.style.display = showing ? "" : "none"; 28621 revealIconHide.style.display = showing ? "none" : ""; 28622 }); 28623 28624 // ---- Test connection ---- 28625 function setTestResult(el, state, text) { 28626 el.classList.remove("is-ok", "is-err"); 28627 if (state) el.classList.add(state); 28628 el.textContent = text; 28629 } 28630 28631 testOllamaBtn.addEventListener("click", async function () { 28632 const endpoint = gsOllamaEndpoint.value.trim(); 28633 if (!endpoint) { 28634 setTestResult( 28635 testOllamaResult, 28636 "is-err", 28637 "Enter an endpoint first", 28638 ); 28639 return; 28640 } 28641 testOllamaBtn.disabled = true; 28642 setTestResult(testOllamaResult, null, "Testingâ¦"); 28643 try { 28644 const base = endpoint.replace(/\/+$/, ""); 28645 const res = await fetch(base + "/api/tags", { method: "GET" }); 28646 if (!res.ok) throw new Error("HTTP " + res.status); 28647 setTestResult(testOllamaResult, "is-ok", "â Reachable"); 28648 } catch (err) { 28649 setTestResult( 28650 testOllamaResult, 28651 "is-err", 28652 "â " + (err && err.message ? err.message : "Unreachable"), 28653 ); 28654 } finally { 28655 testOllamaBtn.disabled = false; 28656 } 28657 }); 28658 28659 testGroqBtn.addEventListener("click", async function () { 28660 const key = gsGroqApiKey.value.trim(); 28661 const model = gsGroqModel.value.trim() || "llama-3.1-8b-instant"; 28662 if (!key) { 28663 setTestResult(testGroqResult, "is-err", "Enter an API key first"); 28664 return; 28665 } 28666 testGroqBtn.disabled = true; 28667 setTestResult(testGroqResult, null, "Testingâ¦"); 28668 try { 28669 const res = await fetch( 28670 "https://api.groq.com/openai/v1/chat/completions", 28671 { 28672 method: "POST", 28673 headers: { 28674 "Content-Type": "application/json", 28675 Authorization: "Bearer " + key, 28676 }, 28677 body: JSON.stringify({ 28678 model: model, 28679 messages: [{ role: "user", content: "ping" }], 28680 max_tokens: 1, 28681 }), 28682 }, 28683 ); 28684 if (res.status === 401) { 28685 setTestResult(testGroqResult, "is-err", "â Invalid API key"); 28686 } else if (!res.ok) { 28687 const body = await res.text().catch(function () { 28688 return ""; 28689 }); 28690 setTestResult( 28691 testGroqResult, 28692 "is-err", 28693 "â HTTP " + res.status + (body ? " â check model id" : ""), 28694 ); 28695 } else { 28696 setTestResult(testGroqResult, "is-ok", "â Key + model valid"); 28697 } 28698 } catch (err) { 28699 setTestResult( 28700 testGroqResult, 28701 "is-err", 28702 "â " + (err && err.message ? err.message : "Network error"), 28703 ); 28704 } finally { 28705 testGroqBtn.disabled = false; 28706 } 28707 }); 28708 28709 // ---- Open / close ---- 28710 function getFocusableInside(root) { 28711 return Array.prototype.slice 28712 .call( 28713 root.querySelectorAll( 28714 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', 28715 ), 28716 ) 28717 .filter(function (el) { 28718 return !el.hidden && el.offsetParent !== null; 28719 }); 28720 } 28721 28722 function openDrawer(opts) { 28723 const options = opts || {}; 28724 returnFocusEl = options.returnFocusTo || openBtn; 28725 lastFocused = 28726 document.activeElement instanceof HTMLElement 28727 ? document.activeElement 28728 : null; 28729 pullFromLegacy(); 28730 drawer.classList.add("is-open"); 28731 overlay.classList.add("is-open"); 28732 drawer.setAttribute("aria-hidden", "false"); 28733 overlay.setAttribute("aria-hidden", "false"); 28734 openBtn.setAttribute("aria-expanded", "true"); 28735 // Clear any stale test results from the last session. 28736 setTestResult(testOllamaResult, null, ""); 28737 setTestResult(testGroqResult, null, ""); 28738 // Focus the first meaningful control (the active segment). 28739 window.requestAnimationFrame(function () { 28740 const first = currentProvider() === "groq" ? segGroq : segOllama; 28741 first.focus(); 28742 }); 28743 } 28744 28745 function closeDrawer() { 28746 drawer.classList.remove("is-open"); 28747 overlay.classList.remove("is-open"); 28748 drawer.setAttribute("aria-hidden", "true"); 28749 overlay.setAttribute("aria-hidden", "true"); 28750 openBtn.setAttribute("aria-expanded", "false"); 28751 const target = returnFocusEl || lastFocused || openBtn;
28752 if (target && typeof target.focus === "function") target.focus(); 28753 } 28754 28755 openBtn.addEventListener("click", function () { 28756 openDrawer({ returnFocusTo: openBtn }); 28757 }); 28758 closeBtn.addEventListener("click", closeDrawer); 28759 overlay.addEventListener("click", closeDrawer); 28760 28761 // ESC + focus trap 28762 drawer.addEventListener("keydown", function (e) { 28763 if (e.key === "Escape") { 28764 e.stopPropagation(); 28765 closeDrawer(); 28766 return; 28767 } 28768 if (e.key !== "Tab") return; 28769 const focusables = getFocusableInside(drawer); 28770 if (!focusables.length) return; 28771 const first = focusables[0]; 28772 const last = focusables[focusables.length - 1]; 28773 if (e.shiftKey && document.activeElement === first) { 28774 e.preventDefault(); 28775 last.focus(); 28776 } else if (!e.shiftKey && document.activeElement === last) { 28777 e.preventDefault(); 28778 first.focus(); 28779 } 28780 }); 28781 28782 // Expose for the chat panel's legacy settings button. 28783 window.__openGlobalSettings = openDrawer; 28784 28785 // Initial hydration so status chip reflects saved state even 28786 // before the drawer has ever been opened. 28787 pullFromLegacy(); 28788 })(); 28789 </script>
28789 28790 28791
28791<script> 28792 /* ============================================================ 28793 Document Q&A â RAG with Ollama or Groq 28794 ============================================================ */ 28795 (function () { 28796 "use strict"; 28797 28798 // ---- DOM refs ---- 28799 const fab = document.getElementById("chatFab"); 28800 const panel = document.getElementById("chatPanel"); 28801 const closeBtn = document.getElementById("chatCloseBtn"); 28802 const settingsBtn = document.getElementById("chatSettingsBtn"); 28803 const settingsDiv = document.getElementById("chatSettings"); 28804 const messagesDiv = document.getElementById("chatMessages"); 28805 const chatLiveRegion = document.getElementById("chatLiveRegion"); 28806 const chatInput = document.getElementById("chatInput"); 28807 const sendBtn = document.getElementById("chatSendBtn"); 28808 const providerInput = document.getElementById("llmProvider"); 28809 const endpointInput = document.getElementById("ollamaEndpoint"); 28810 const ollamaModelInput = document.getElementById("ollamaModel"); 28811 const groqApiKeyInput = document.getElementById("groqApiKey"); 28812 const groqModelInput = document.getElementById("groqModel"); 28813 const ollamaEndpointField = document.getElementById( 28814 "ollamaEndpointField", 28815 ); 28816 const ollamaModelField = document.getElementById("ollamaModelField"); 28817 const groqApiKeyField = document.getElementById("groqApiKeyField"); 28818 const groqModelField = document.getElementById("groqModelField"); 28819 const CHAT_HISTORY_LIMIT = 8; 28820 const HISTORY_CHAR_LIMIT = 1200; 28821 const CHAT_MESSAGE_DOM_LIMIT = 40; 28822 const chatHistory = []; 28823 let lastDocumentFingerprint = ""; 28824 let lastFocusedElement = null; 28825 28826 // ---- AI config ---- 28827 const LLM_SETTINGS_KEY = "md-viewer-llm"; 28828 const LEGACY_OLLAMA_SETTINGS_KEY = "md-viewer-ollama"; 28829 const LEGACY_GROQ_DEFAULT_MODEL = "llama-3.3-70b-versatile"; 28830 const DEFAULT_LLM_SETTINGS = { 28831 provider: "ollama", 28832 endpoint: "http://localhost:11434", 28833 ollamaModel: "gemma4:26b", 28834 groqApiKey: "", 28835 groqModel: "qwen/qwen3-32b", 28836 }; 28837 function readChatPreference(key, fallback) { 28838 try { 28839 const value = localStorage.getItem(key); 28840 return value === null ? fallback : value; 28841 } catch (_error) { 28842 return fallback; 28843 } 28844 } 28845 function writeChatPreference(key, value) { 28846 try { 28847 localStorage.setItem(key, value); 28848 } catch (_error) {} 28849 } 28850 function normalizeLlmProvider(value) { 28851 return value === "groq" ? "groq" : "ollama"; 28852 } 28853 function readStoredLlmSettings() { 28854 let saved = null; 28855 try { 28856 saved = JSON.parse(readChatPreference(LLM_SETTINGS_KEY, "null")); 28857 } catch (_error) { 28858 saved = null; 28859 } 28860 if (!saved || typeof saved !== "object") { 28861 try { 28862 const legacy = JSON.parse( 28863 readChatPreference(LEGACY_OLLAMA_SETTINGS_KEY, "null"), 28864 ); 28865 if (legacy && typeof legacy === "object") { 28866 saved = { 28867 provider: "ollama", 28868 endpoint: legacy.endpoint || "", 28869 ollamaModel: legacy.model || "", 28870 }; 28871 } 28872 } catch (_error) { 28873 saved = null; 28874 } 28875 } 28876 return Object.assign({}, DEFAULT_LLM_SETTINGS, saved || {}, { 28877 provider: normalizeLlmProvider(saved && saved.provider), 28878 }); 28879 } 28880 function migrateLlmSettings(settings) { 28881 const next = Object.assign({}, DEFAULT_LLM_SETTINGS, settings || {}); 28882 if ( 28883 next.provider === "groq" && 28884 (!next.groqModel || next.groqModel === LEGACY_GROQ_DEFAULT_MODEL) 28885 ) { 28886 next.groqModel = DEFAULT_LLM_SETTINGS.groqModel; 28887 } 28888 return next; 28889 } 28890 function getCurrentLlmSettings() { 28891 return { 28892 provider: normalizeLlmProvider(providerInput.value), 28893 endpoint: endpointInput.value.trim(), 28894 ollamaModel: ollamaModelInput.value.trim(), 28895 groqApiKey: groqApiKeyInput.value.trim(), 28896 groqModel: groqModelInput.value.trim(), 28897 }; 28898 } 28899 function applyLlmSettings(settings) { 28900 const next = migrateLlmSettings(settings); 28901 providerInput.value = normalizeLlmProvider(next.provider); 28902 endpointInput.value = next.endpoint || DEFAULT_LLM_SETTINGS.endpoint; 28903 ollamaModelInput.value = 28904 next.ollamaModel || DEFAULT_LLM_SETTINGS.ollamaModel; 28905 groqApiKeyInput.value = next.groqApiKey || ""; 28906 groqModelInput.value = 28907 next.groqModel || DEFAULT_LLM_SETTINGS.groqModel; 28908 } 28909 function syncLlmSettingsUi() { 28910 const provider = normalizeLlmProvider(providerInput.value); 28911 const showOllama = provider === "ollama"; 28912 ollamaEndpointField.hidden = !showOllama; 28913 ollamaModelField.hidden = !showOllama; 28914 groqApiKeyField.hidden = showOllama; 28915 groqModelField.hidden = showOllama; 28916 } 28917 function saveLlmSettings() { 28918 const settings = getCurrentLlmSettings(); 28919 writeChatPreference(LLM_SETTINGS_KEY, JSON.stringify(settings)); 28920 writeChatPreference( 28921 LEGACY_OLLAMA_SETTINGS_KEY, 28922 JSON.stringify({ 28923 endpoint: settings.endpoint, 28924 model: settings.ollamaModel, 28925 }), 28926 ); 28927 syncLlmSettingsUi(); 28928 } 28929 function hasConfiguredLlm() { 28930 const settings = getCurrentLlmSettings(); 28931 if (settings.provider === "groq") { 28932 return Boolean(settings.groqApiKey && settings.groqModel); 28933 } 28934 return Boolean(settings.endpoint && settings.ollamaModel); 28935 } 28936 applyLlmSettings(migrateLlmSettings(readStoredLlmSettings())); 28937 syncLlmSettingsUi(); 28938 saveLlmSettings(); 28939 [ 28940 providerInput, 28941 endpointInput, 28942 ollamaModelInput, 28943 groqApiKeyInput, 28944 groqModelInput, 28945 ].forEach(function (input) { 28946 input.addEventListener("change", saveLlmSettings); 28947 input.addEventListener("input", function () { 28948 if (input === providerInput) return; 28949 syncLlmSettingsUi(); 28950 }); 28951 }); 28952 28953 function isLoopbackHostname(hostname) { 28954 const normalized = String(hostname || "") 28955 .replace(/^\[|\]$/g, "") 28956 .toLowerCase(); 28957 return ( 28958 normalized === "localhost" || 28959 normalized === "127.0.0.1" || 28960 normalized === "::1" 28961 ); 28962 } 28963 28964 function normalizeOllamaEndpoint(rawValue) { 28965 let parsed; 28966 try { 28967 parsed = new URL(String(rawValue || "").trim()); 28968 } catch (_error) { 28969 throw new Error( 28970 "Enter a valid local Ollama endpoint, such as http://localhost:11434", 28971 ); 28972 } 28973 28974 if ( 28975 !/^https?:$/.test(parsed.protocol) || 28976 !isLoopbackHostname(parsed.hostname) 28977 ) { 28978 throw new Error( 28979 "Privacy mode only allows local Ollama endpoints on localhost, 127.0.0.1, or ::1", 28980 ); 28981 } 28982 28983 return parsed.toString().replace(/\/+$/, ""); 28984 } 28985 28986 window.markvHasConfiguredLlm = hasConfiguredLlm; 28987 window.markvGetLlmSettings = getCurrentLlmSettings; 28988 28989 function escapeChatHtml(value) { 28990 const div = document.createElement("div"); 28991 div.textContent = String(value == null ? "" : value); 28992 return div.innerHTML; 28993 } 28994 28995 function cleanChatUrl(value) { 28996 const raw = String(value || "").trim(); 28997 if (!raw) return ""; 28998 try { 28999 const parsed = new URL(raw, window.location.href); 29000 if (/^(javascript|vbscript|data):$/i.test(parsed.protocol)) { 29001 return ""; 29002 } 29003 return parsed.toString(); 29004 } catch (_error) { 29005 return ""; 29006 } 29007 } 29008
29009 function resolveChatUrl(href) { 29010 const cleaned = cleanChatUrl(href); 29011 if (!cleaned) return null; 29012 try { 29013 return new URL(cleaned, window.location.href); 29014 } catch (_error) { 29015 return null; 29016 } 29017 } 29018 29019 function isPrivateChatUrl(url) { 29020 if (!url) return false; 29021 if (url.origin === window.location.origin) return true; 29022 return ( 29023 (url.protocol === "http:" || url.protocol === "https:") && 29024 isLoopbackHostname(url.hostname) 29025 ); 29026 } 29027 29028 function resolveChatLinkHref(href) { 29029 const resolved = resolveChatUrl(href); 29030 if (resolved) return resolved.toString(); 29031 return cleanChatUrl(href); 29032 } 29033 29034 function resolveChatImageHref(href) { 29035 const resolved = resolveChatUrl(href); 29036 if (!resolved || !isPrivateChatUrl(resolved)) return ""; 29037 return resolved.toString(); 29038 } 29039 29040 const chatRenderer = new marked.Renderer(); 29041 29042 chatRenderer.html = function (html) { 29043 return ( 29044 '<pre class="raw-html"><code>' + 29045 escapeChatHtml(html) + 29046 "</code></pre>\n" 29047 ); 29048 }; 29049 29050 chatRenderer.link = function (href, title, text) { 29051 const resolved = resolveChatLinkHref(href); 29052 if (!resolved) return text; 29053 let out = '<a href="' + escapeChatHtml(resolved) + '"'; 29054 if (title) out += ' title="' + escapeChatHtml(title) + '"'; 29055 out += ' referrerpolicy="no-referrer"'; 29056 out += ">" + text + "</a>"; 29057 return out; 29058 }; 29059 29060 chatRenderer.image = function (href, title, text) { 29061 const resolved = resolveChatImageHref(href); 29062 if (!resolved) { 29063 return ( 29064 '<span class="blocked-resource">Remote image blocked for privacy' + 29065 (text ? ": " + escapeChatHtml(text) : ".") + 29066 "</span>" 29067 ); 29068 } 29069 let out = 29070 '<img src="' + 29071 escapeChatHtml(resolved) + 29072 '" alt="' + 29073 escapeChatHtml(text || "") + 29074 '"'; 29075 if (title) out += ' title="' + escapeChatHtml(title) + '"'; 29076 out += 29077 ' loading="lazy" decoding="async" referrerpolicy="no-referrer">'; 29078 return out; 29079 }; 29080 29081 const chatMarkedOptions = { 29082 gfm: true, 29083 breaks: true, 29084 mangle: false, 29085 headerIds: false, 29086 smartLists: true, 29087 renderer: chatRenderer, 29088 }; 29089 29090 function decorateChatMessageContent(container) { 29091 if (!container) return; 29092 container 29093 .querySelectorAll('pre > code[class*="language-"]') 29094 .forEach(function (code) { 29095 const match = code.className.match(/language-([a-z0-9_+.-]+)/i); 29096 if (match && code.parentElement) { 29097 code.parentElement.setAttribute("data-lang", match[1]); 29098 } 29099 }); 29100 container.querySelectorAll("a[href]").forEach(function (link) { 29101 const href = link.getAttribute("href") || ""; 29102 link.referrerPolicy = "no-referrer"; 29103 if (/^(https?:)?\/\//i.test(href)) { 29104 link.target = "_blank"; 29105 link.rel = "noreferrer noopener"; 29106 } 29107 }); 29108 } 29109 29110 function renderChatMarkdown(text) { 29111 const source = String(text || ""); 29112 if (!source.trim()) return ""; 29113 try { 29114 return marked.parse(source, chatMarkedOptions); 29115 } catch (error) { 29116 return ( 29117 "<pre><code>" + 29118 escapeChatHtml( 29119 String(error && error.message ? error.message : error), 29120 ) + 29121 "</code></pre>" 29122 ); 29123 } 29124 } 29125 29126 function setAssistantMessageContent(messageEl, text) { 29127 if (!messageEl) return; 29128 messageEl.classList.add("markdown-rendered"); 29129 messageEl.innerHTML = renderChatMarkdown(text); 29130 decorateChatMessageContent(messageEl); 29131 } 29132 29133 const pendingAssistantRenders = new WeakMap(); 29134 29135 function scheduleAssistantMessageContent(messageEl, text) { 29136 if (!messageEl) return; 29137 29138 let pending = pendingAssistantRenders.get(messageEl); 29139 if (!pending) { 29140 pending = { 29141 text: "", 29142 frameId: 0, 29143 }; 29144 pendingAssistantRenders.set(messageEl, pending); 29145 } 29146 29147 pending.text = text; 29148 if (pending.frameId) return; 29149 29150 pending.frameId = window.requestAnimationFrame(function () { 29151 pending.frameId = 0; 29152 setAssistantMessageContent(messageEl, pending.text); 29153 }); 29154 } 29155 29156 function flushAssistantMessageContent(messageEl, text) { 29157 if (!messageEl) return; 29158 29159 const pending = pendingAssistantRenders.get(messageEl); 29160 if (pending) { 29161 pending.text = text; 29162 if (pending.frameId) { 29163 window.cancelAnimationFrame(pending.frameId); 29164 pending.frameId = 0; 29165 } 29166 } 29167 29168 setAssistantMessageContent(messageEl, text); 29169 } 29170 29171 function getFocusableElements(container) { 29172 return Array.from( 29173 container.querySelectorAll(
29174 'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', 29175 ), 29176 ).filter(function (element) { 29177 if (element.hidden) return false; 29178 if (element.closest("[hidden]")) return false; 29179 if (element.getAttribute("aria-hidden") === "true") return false; 29180 return element.offsetParent !== null; 29181 }); 29182 } 29183 29184 function announceChatUpdate(text) { 29185 if (!chatLiveRegion) return; 29186 chatLiveRegion.textContent = ""; 29187 window.requestAnimationFrame(function () { 29188 chatLiveRegion.textContent = trimForHistory(text, 220); 29189 }); 29190 } 29191 29192 function setSettingsOpen(isOpen) { 29193 settingsDiv.classList.toggle("hidden", !isOpen); 29194 settingsDiv.hidden = !isOpen; 29195 settingsDiv.setAttribute("aria-hidden", String(!isOpen)); 29196 settingsBtn.setAttribute("aria-expanded", String(isOpen)); 29197 } 29198 29199 function showChatPanel(options) { 29200 const opts = options || {}; 29201 const shouldFocusInput = opts.focusInput !== false; 29202 if (panel.hidden) { 29203 lastFocusedElement = 29204 document.activeElement instanceof HTMLElement 29205 ? document.activeElement 29206 : null; 29207 } 29208 panel.classList.remove("hidden"); 29209 panel.hidden = false; 29210 panel.setAttribute("aria-hidden", "false"); 29211 fab.classList.add("hidden"); 29212 fab.setAttribute("aria-hidden", "true"); 29213 document.body.classList.add("chat-open"); 29214 if (shouldFocusInput) { 29215 chatInput.focus(); 29216 } 29217 } 29218 29219 function hideChatPanel() { 29220 panel.classList.add("hidden"); 29221 panel.hidden = true; 29222 panel.setAttribute("aria-hidden", "true"); 29223 setSettingsOpen(false); 29224 document.body.classList.remove("chat-open"); 29225 fab.classList.remove("hidden"); 29226 fab.removeAttribute("aria-hidden"); 29227 if ( 29228 lastFocusedElement && 29229 typeof lastFocusedElement.focus === "function" 29230 ) { 29231 lastFocusedElement.focus(); 29232 } else { 29233 fab.focus(); 29234 } 29235 } 29236 29237 // ---- Chat panel toggle ---- 29238 fab.addEventListener("click", function () { 29239 showChatPanel(); 29240 }); 29241 closeBtn.addEventListener("click", function () { 29242 hideChatPanel(); 29243 }); 29244 settingsBtn.addEventListener("click", function () { 29245 // Old inline panel is retired. The chat gear now opens the 29246 // global settings drawer, scrolled to the AI Provider section. 29247 if (typeof window.__openGlobalSettings === "function") { 29248 window.__openGlobalSettings({ returnFocusTo: settingsBtn }); 29249 } 29250 }); 29251 panel.addEventListener("keydown", function (e) { 29252 if (e.key !== "Tab" || panel.hidden) return; 29253 const focusable = getFocusableElements(panel); 29254 if (!focusable.length) return; 29255 const first = focusable[0]; 29256 const last = focusable[focusable.length - 1]; 29257 29258 if (e.shiftKey && document.activeElement === first) { 29259 e.preventDefault(); 29260 last.focus(); 29261 } else if (!e.shiftKey && document.activeElement === last) { 29262 e.preventDefault(); 29263 first.focus(); 29264 } 29265 }); 29266 29267 // ---- Chat input ---- 29268 function syncChatInputUi() { 29269 sendBtn.disabled = !chatInput.value.trim(); 29270 chatInput.style.height = "auto"; 29271 chatInput.style.height = Math.min(chatInput.scrollHeight, 140) + "px"; 29272 } 29273 29274 chatInput.addEventListener("input", function () { 29275 syncChatInputUi(); 29276 }); 29277 chatInput.addEventListener("keydown", function (e) { 29278 if (e.key === "Enter" && !e.shiftKey) { 29279 e.preventDefault(); 29280 if (!sendBtn.disabled) sendBtn.click(); 29281 } 29282 }); 29283 29284 // ---- Message helpers ---- 29285 function pruneChatMessageDom(keepNode) { 29286 const nodes = Array.from(messagesDiv.querySelectorAll(".chat-msg")); 29287 let overflow = nodes.length - CHAT_MESSAGE_DOM_LIMIT; 29288 if (overflow <= 0) return; 29289 29290 for (const node of nodes) { 29291 if (overflow <= 0) break; 29292 if (node === keepNode) continue; 29293 node.remove(); 29294 overflow -= 1; 29295 } 29296 } 29297 29298 function appendChatMessage(div) { 29299 messagesDiv.appendChild(div); 29300 pruneChatMessageDom(div); 29301 messagesDiv.scrollTop = messagesDiv.scrollHeight; 29302 return div; 29303 } 29304 29305 function addMessage(text, role) { 29306 const div = document.createElement("div"); 29307 div.className = "chat-msg " + role; 29308 if (role === "assistant") { 29309 setAssistantMessageContent(div, text); 29310 div.setAttribute("aria-label", "Assistant reply"); 29311 } else if (role === "user") { 29312 div.textContent = text; 29313 div.setAttribute("aria-label", "Your message"); 29314 } else { 29315 div.textContent = text; 29316 } 29317 return appendChatMessage(div); 29318 } 29319 function addTyping() { 29320 const div = document.createElement("div"); 29321 div.className = "chat-msg assistant"; 29322 div.innerHTML = '<span class="typing-dots">Thinking</span>'; 29323 return appendChatMessage(div); 29324 } 29325 29326 function trimForHistory(text, maxChars) { 29327 const clean = (text || "").trim().replace(/\s+/g, " "); 29328 if (clean.length <= maxChars) return clean; 29329 return clean.slice(0, maxChars - 1) + "â¦"; 29330 } 29331 29332 function addHistory(role, text) { 29333 if (role !== "user" && role !== "assistant") return; 29334 chatHistory.push({ 29335 role: role, 29336 content: trimForHistory(text, HISTORY_CHAR_LIMIT), 29337 }); 29338 if (chatHistory.length > CHAT_HISTORY_LIMIT) { 29339 chatHistory.splice(0, chatHistory.length - CHAT_HISTORY_LIMIT); 29340 } 29341 } 29342 29343 function getRecentHistory() { 29344 return chatHistory.slice(); 29345 } 29346 29347 function getDocumentText() { 29348 const preview = document.getElementById("preview"); 29349 return (preview && preview.innerText) || ""; 29350 } 29351 29352 function getDocumentFingerprint() { 29353 const label = getDocumentLabel(); 29354 const text = getDocumentText(); 29355 return label + "::" + text.length + "::" + text.slice(0, 400); 29356 } 29357 29358 function syncHistoryWithCurrentDocument() { 29359 const fingerprint = getDocumentFingerprint(); 29360 if (fingerprint !== lastDocumentFingerprint) { 29361 chatHistory.length = 0; 29362 lastDocumentFingerprint = fingerprint; 29363 } 29364 } 29365 29366 function getDocumentLabel() { 29367 const preview = document.getElementById("preview"); 29368 const label = preview ? preview.dataset.documentLabel || "" : ""; 29369 return label.trim() || "current document"; 29370 } 29371 29372 function isGreetingMessage(text) { 29373 const normalized = text.trim().toLowerCase(); 29374 return /^(hi|hiya|hello|hey|yo|good morning|good afternoon|good evening|sup|what'?s up|howdy)[!. ]*$/.test( 29375 normalized, 29376 ); 29377 } 29378 29379 function isThanksMessage(text) { 29380 const normalized = text.trim().toLowerCase(); 29381 return /^(thanks|thank you|thx|ty|cheers|nice one)[!. ]*$/.test( 29382 normalized, 29383 ); 29384 } 29385 29386 function isCapabilitiesMessage(text) { 29387 const normalized = text.trim().toLowerCase(); 29388 return /^(help|what can you do|how can you help|what do you do|how does this work|what can i ask|who are you)[?.! ]*$/.test( 29389 normalized, 29390 ); 29391 } 29392 29393 function isBroadDocumentRequest(text) { 29394 const normalized = text.trim().toLowerCase(); 29395 return /(summari[sz]e|summary|overview|main idea|main ideas|key points|takeaways|what is this document about|what is this about|tl;dr|tldr|abstract|outline)/.test( 29396 normalized, 29397 ); 29398 } 29399 29400 function looksLikeDocumentTask(text) { 29401 const normalized = text.trim().toLowerCase(); 29402 return /(document|file|text|section|chapter|paragraph|excerpt|passage|summary|summari[sz]e|explain|what does|where does|find|list|extract|compare|key points|main idea|quote)/.test( 29403 normalized, 29404 ); 29405 } 29406 29407 function getLocalAssistantReply(question, hasDocument) { 29408 if (isGreetingMessage(question)) { 29409 return hasDocument 29410 ? "Hi. I can answer questions about the loaded document, summarize sections, explain passages, and pull out key points." 29411 : "Hi. Load a document and I can answer questions about it, summarize sections, explain passages, and help find key details."; 29412 } 29413 if (isThanksMessage(question)) { 29414 return hasDocument 29415 ? "Youâre welcome. Ask about the document whenever youâre ready." 29416 : "Youâre welcome. Load a document whenever you want to start."; 29417 } 29418 if (isCapabilitiesMessage(question)) { 29419 return hasDocument 29420 ? "I can answer questions about this document, summarize it, explain specific sections, compare passages, and help locate important details." 29421 : "I can answer questions about a loaded document, summarize it, explain specific sections, compare passages, and help locate important details."; 29422 } 29423 if (!hasDocument && looksLikeDocumentTask(question)) { 29424 return "No document is loaded yet. Load one first, then ask a question, request a summary, or select a passage to discu
29424ss."; 29425 } 29426 return ""; 29427 } 29428 29429 // ---- Simple chunking ---- 29430 function chunkText(text, chunkSize, overlap) { 29431 chunkSize = chunkSize || 800; 29432 overlap = overlap || 200; 29433 const chunks = []; 29434 let i = 0; 29435 while (i < text.length) { 29436 chunks.push(text.slice(i, i + chunkSize)); 29437 i += chunkSize - overlap; 29438 } 29439 return chunks; 29440 } 29441 29442 // ---- TF-IDF-ish retrieval ---- 29443 function tokenize(text) { 29444 return text 29445 .toLowerCase() 29446 .replace(/[^a-z0-9\s]/g, "") 29447 .split(/\s+/) 29448 .filter(function (w) { 29449 return w.length > 2; 29450 }); 29451 } 29452 29453 function buildTfIdf(chunks) { 29454 const df = {}; 29455 const tfs = []; 29456 const N = chunks.length; 29457 for (let i = 0; i < N; i++) { 29458 const words = tokenize(chunks[i]); 29459 const tf = {}; 29460 const seen = {}; 29461 for (const w of words) { 29462 tf[w] = (tf[w] || 0) + 1; 29463 if (!seen[w]) { 29464 df[w] = (df[w] || 0) + 1; 29465 seen[w] = true; 29466 } 29467 } 29468 // normalize tf 29469 const maxTf = Math.max(...Object.values(tf), 1); 29470 for (const w in tf) tf[w] = tf[w] / maxTf; 29471 tfs.push(tf); 29472 } 29473 return { tfs, df, N }; 29474 } 29475 29476 function scoreChunks(query, chunks, index) { 29477 const qWords = tokenize(query); 29478 const scores = []; 29479 for (let i = 0; i < chunks.length; i++) { 29480 let score = 0; 29481 for (const w of qWords) { 29482 const tf = index.tfs[i][w] || 0; 29483 const idf = Math.log((index.N + 1) / ((index.df[w] || 0) + 1)); 29484 score += tf * idf; 29485 } 29486 scores.push({ idx: i, score }); 29487 } 29488 scores.sort(function (a, b) { 29489 return b.score - a.score; 29490 }); 29491 return scores; 29492 } 29493 29494 function buildRepresentativeContext(chunks, topK) { 29495 const limit = Math.max(1, Math.min(topK || 4, chunks.length)); 29496 if (chunks.length <= limit) return chunks.join("\n\n---\n\n"); 29497 29498 const chosen = []; 29499 for (let i = 0; i < limit; i++) { 29500 const idx = Math.round( 29501 (i * (chunks.length - 1)) / Math.max(1, limit - 1), 29502 ); 29503 if (chosen[chosen.length - 1] !== idx) chosen.push(idx); 29504 } 29505 return chosen 29506 .map(function (idx) { 29507 return chunks[idx]; 29508 }) 29509 .join("\n\n---\n\n"); 29510 } 29511 29512 function getRelevantContext(query, topK) { 29513 topK = topK || 4; 29514 const text = getDocumentText(); 29515 if (!text.trim()) { 29516 return { 29517 hasDocument: false, 29518 context: "", 29519 strategy: "none", 29520 strongestScore: 0, 29521 }; 29522 } 29523 const chunks = chunkText(text); 29524 if (chunks.length === 0) { 29525 return { 29526 hasDocument: false, 29527 context: "", 29528 strategy: "none", 29529 strongestScore: 0, 29530 }; 29531 } 29532 if (isBroadDocumentRequest(query)) { 29533 return { 29534 hasDocument: true, 29535 context: buildRepresentativeContext(chunks, topK), 29536 strategy: "overview", 29537 strongestScore: 1, 29538 }; 29539 } 29540 29541 const index = buildTfIdf(chunks); 29542 const ranked = scoreChunks(query, chunks, index); 29543 const top = ranked.slice(0, topK).filter(function (s) { 29544 return s.score > 0; 29545 }); 29546 if (top.length === 0) { 29547 return { 29548 hasDocument: true, 29549 context: "", 29550 strategy: "none", 29551 strongestScore: 0, 29552 }; 29553 } 29554 return { 29555 hasDocument: true, 29556 context: top 29557 .map(function (s) { 29558 return chunks[s.idx]; 29559 }) 29560 .join("\n\n---\n\n"), 29561 strategy: "retrieved", 29562 strongestScore: top[0].score, 29563 }; 29564 } 29565 29566 // ---- LLM API calls ---- 29567 async function buildProviderError(response, providerLabel) { 29568 let message = ""; 29569 try { 29570 const payload = await response.clone().json(); 29571 message = 29572 (payload && 29573 payload.error && 29574 typeof payload.error.message === "string" && 29575 payload.error.message) || 29576 (payload && 29577 typeof payload.message === "string" && 29578 payload.message) || 29579 ""; 29580 } catch (_error) {} 29581 if (!message) { 29582 message = await response.text().catch(function () { 29583 return ""; 29584 }); 29585 } 29586 const error = new Error( 29587 providerLabel + 29588 " error " + 29589 response.status + 29590 ": " + 29591 (message || response.statusText), 29592 ); 29593 error.status = response.status; 29594 error.provider = providerLabel; 29595 error.retryAfter = response.headers.get("retry-after") || ""; 29596 error.rateLimitResetTokens = 29597 response.headers.get("x-ratelimit-reset-tokens") || ""; 29598 return error; 29599 } 29600 29601 // Try a single endpoint; returns the fetch Response on success. 29602 async function tryFetch(url, body, headers, providerLabel) { 29603 const response = await fetch(url, { 29604 method: "POST", 29605 headers: Object.assign( 29606 { "Content-Type": "application/json" }, 29607 headers || {}, 29608 ), 29609 body: JSON.stringify(body), 29610 }); 29611 if (!response.ok) { 29612 throw await buildProviderError(response, providerLabel || "LLM"); 29613 } 29614 return response; 29615 } 29616 29617 async function callOllama(messages, options) { 29618 const opts = options || {}; 29619 const raw = endpointInput.value.trim().replace(/\/+$/, ""); 29620 const endpoint = normalizeOllamaEndpoint(raw); 29621 const model = 29622 ollamaModelInput.value.trim() || DEFAULT_LLM_SETTINGS.ollamaModel; 29623 const body = { 29624 model: model, 29625 messages: messages, 29626 stream: opts.stream !== false, 29627 }; 29628 if (opts.format) { 29629 body.format = opts.format; 29630 } 29631 29632 // Build a list of endpoints to try. 29633 // Browsers treat "localhost" and "127.0.0.1" as different origins; 29634 // Ollama may accept one but not the other depending on OLLAMA_ORIGINS. 29635 const endpoints = [endpoint]; 29636 if (endpoint.includes("localhost")) { 29637 endpoints.push(endpoint.replace("localhost", "127.0.0.1")); 29638 } else if (endpoint.includes("127.0.0.1")) { 29639 endpoints.push(endpoint.replace("127.0.0.1", "localhost")); 29640 } 29641 29642 let lastErr = null; 29643 for (const ep of endpoints) { 29644 try { 29645 const resp = await tryFetch( 29646 ep + "/api/chat", 29647 body, 29648 null, 29649 "Ollama", 29650 ); 29651 // If we succeeded with an alternate endpoint, persist it 29652 if (ep !== raw) { 29653 endpointInput.value = ep; 29654 saveLlmSettings(); 29655 } 29656 return resp; 29657 } catch (err) { 29658 lastErr = err;
29659 } 29660 } 29661 29662 // If we get here, all endpoints failed â it's almost certainly CORS. 29663 const isCors = 29664 lastErr && 29665 (lastErr.message === "Failed to fetch" || 29666 lastErr.message === "Load failed" || 29667 lastErr.message.includes("NetworkError") || 29668 lastErr.message.includes("network")); 29669 29670 if (isCors) { 29671 throw new Error("CORS_BLOCKED"); 29672 } 29673 throw lastErr; 29674 } 29675 29676 async function parseOllamaContent(response) { 29677 let parsed; 29678 try { 29679 parsed = await response.json(); 29680 } catch (error) { 29681 throw new Error("LLM returned an unreadable response"); 29682 } 29683 const content = 29684 parsed && 29685 parsed.message && 29686 typeof parsed.message.content === "string" 29687 ? parsed.message.content 29688 : ""; 29689 if (!content.trim()) { 29690 throw new Error("LLM returned no message content"); 29691 } 29692 return content; 29693 } 29694 29695 async function callGroq(messages, options) { 29696 const opts = options || {}; 29697 const apiKey = groqApiKeyInput.value.trim(); 29698 if (!apiKey) { 29699 throw new Error("Groq API key is missing. Add it in AI settings."); 29700 } 29701 const model = 29702 groqModelInput.value.trim() || DEFAULT_LLM_SETTINGS.groqModel; 29703 const body = { 29704 model: model, 29705 messages: messages, 29706 stream: opts.stream !== false, 29707 }; 29708 if (/qwen3/i.test(model)) { 29709 body.reasoning_effort = "none"; 29710 } 29711 if ( 29712 Number.isFinite(opts.maxCompletionTokens) && 29713 opts.maxCompletionTokens > 0 29714 ) { 29715 body.max_completion_tokens = Math.floor(opts.maxCompletionTokens); 29716 } 29717 if (opts.responseFormat) { 29718 body.response_format = opts.responseFormat; 29719 } 29720 return tryFetch( 29721 "https://api.groq.com/openai/v1/chat/completions", 29722 body, 29723 { Authorization: "Bearer " + apiKey }, 29724 "Groq", 29725 ); 29726 } 29727 29728 async function parseGroqContent(response) { 29729 let parsed; 29730 try { 29731 parsed = await response.json(); 29732 } catch (error) { 29733 throw new Error("LLM returned an unreadable response"); 29734 } 29735 const content = 29736 parsed && 29737 Array.isArray(parsed.choices) && 29738 parsed.choices[0] && 29739 parsed.choices[0].message && 29740 typeof parsed.choices[0].message.content === "string" 29741 ? parsed.choices[0].message.content 29742 : ""; 29743 if (!content.trim()) { 29744 throw new Error("LLM returned no message content"); 29745 } 29746 return content; 29747 } 29748 29749 async function callConfiguredLlmResponse(messages, options) { 29750 const settings = getCurrentLlmSettings(); 29751 return settings.provider === "groq" 29752 ? callGroq(messages, options) 29753 : callOllama(messages, options); 29754 } 29755 29756 async function callConfiguredLlmText(messages, options) { 29757 const settings = getCurrentLlmSettings(); 29758 const response = await callConfiguredLlmResponse( 29759 messages, 29760 Object.assign({}, options || {}, { stream: false }), 29761 ); 29762 return settings.provider === "groq" 29763 ? parseGroqContent(response) 29764 : parseOllamaContent(response); 29765 } 29766 29767 async function callConfiguredLlmJson(messages, options) { 29768 const settings = getCurrentLlmSettings(); 29769 const opts = options || {}; 29770 if (settings.provider === "groq") { 29771 return callConfiguredLlmText( 29772 messages, 29773 Object.assign({}, opts, { 29774 responseFormat: opts.responseFormat || { type: "json_object" }, 29775 }), 29776 ); 29777 } 29778 const response = await callOllama(messages, { 29779 stream: false, 29780 format: "json", 29781 }); 29782 return parseOllamaContent(response); 29783 } 29784 29785 window.markvCallTextLlm = callConfiguredLlmText; 29786 29787 if (typeof window.markvLLM !== "function") { 29788 window.markvLLM = async function markvMindmapLLM(payload) { 29789 const request = payload || {};
29790 const system = String(request.system || "").trim(); 29791 const user = String(request.user || "").trim(); 29792 if (!system || !user) { 29793 throw new Error("Mindmap extraction request was incomplete"); 29794 } 29795 const settings = getCurrentLlmSettings(); 29796 const groqFormat = 29797 settings.provider === "groq" && 29798 typeof window.markvGetMindmapGroqResponseFormat === "function" 29799 ? window.markvGetMindmapGroqResponseFormat( 29800 settings.groqModel || DEFAULT_LLM_SETTINGS.groqModel, 29801 ) 29802 : null; 29803 try { 29804 return await callConfiguredLlmJson( 29805 [ 29806 { role: "system", content: system }, 29807 { role: "user", content: user }, 29808 ], 29809 { 29810 maxCompletionTokens: request.maxCompletionTokens, 29811 responseFormat: groqFormat, 29812 }, 29813 ); 29814 } catch (error) { 29815 const usedStructuredOutput = 29816 groqFormat && groqFormat.type === "json_schema"; 29817 const shouldFallback = 29818 settings.provider === "groq" && 29819 usedStructuredOutput && 29820 error && 29821 error.status === 400; 29822 if (!shouldFallback) throw error; 29823 return callConfiguredLlmJson( 29824 [ 29825 { role: "system", content: system }, 29826 { role: "user", content: user }, 29827 ], 29828 { 29829 maxCompletionTokens: request.maxCompletionTokens, 29830 responseFormat: { type: "json_object" }, 29831 }, 29832 ); 29833 } 29834 }; 29835 } 29836 29837 function appendStreamText(msgDiv, fullText, chunk) { 29838 fullText += chunk; 29839 scheduleAssistantMessageContent(msgDiv, fullText); 29840 messagesDiv.scrollTop = messagesDiv.scrollHeight; 29841 return fullText; 29842 } 29843 29844 async function streamOllamaResponse(response, msgDiv) { 29845 const reader = response.body.getReader(); 29846 const decoder = new TextDecoder(); 29847 let fullText = ""; 29848 let buffer = ""; 29849 29850 while (true) { 29851 const { done, value } = await reader.read(); 29852 if (done) break; 29853 buffer += decoder.decode(value, { stream: true }); 29854 // Ollama streams newline-delimited JSON 29855 const lines = buffer.split("\n"); 29856 buffer = lines.pop() || ""; 29857 for (const line of lines) { 29858 if (!line.trim()) continue; 29859 try { 29860 const parsed = JSON.parse(line); 29861 if (parsed.message && parsed.message.content) { 29862 fullText = appendStreamText( 29863 msgDiv, 29864 fullText, 29865 parsed.message.content, 29866 ); 29867 } 29868 } catch (error) { 29869 console.debug("Skipping malformed streaming chunk.", error); 29870 } 29871 } 29872 } 29873 29874 if (buffer.trim()) { 29875 try { 29876 const parsed = JSON.parse(buffer); 29877 if (parsed.message && parsed.message.content) { 29878 fullText = appendStreamText( 29879 msgDiv, 29880 fullText, 29881 parsed.message.content, 29882 ); 29883 } 29884 } catch (error) { 29885 console.debug( 29886 "Skipping malformed trailing streaming chunk.", 29887 error, 29888 ); 29889 } 29890 } 29891 if (fullText.trim()) { 29892 flushAssistantMessageContent(msgDiv, fullText); 29893 return fullText; 29894 } 29895 flushAssistantMessageContent(msgDiv, "No reply received."); 29896 return "No reply received."; 29897 } 29898 29899 async function streamGroqResponse(response, msgDiv) { 29900 const reader = response.body.getReader(); 29901 const decoder = new TextDecoder(); 29902 let fullText = ""; 29903 let buffer = ""; 29904 29905 while (true) { 29906 const { done, value } = await reader.read(); 29907 if (done) break; 29908 buffer += decoder.decode(value, { stream: true }); 29909 const events = buffer.split(/\n\n/); 29910 buffer = events.pop() || ""; 29911 for (const eventChunk of events) { 29912 const lines = eventChunk.split(/\r?\n/); 29913 for (const line of lines) { 29914 if (!line.startsWith("data:")) continue; 29915 const data = line.slice(5).trim(); 29916 if (!data || data === "[DONE]") continue; 29917 try { 29918 const parsed = JSON.parse(data); 29919 const delta = 29920 parsed && 29921 Array.isArray(parsed.choices) && 29922 parsed.choices[0] && 29923 parsed.choices[0].delta &&
29924 parsed.choices[0].delta.content; 29925 if (typeof delta === "string" && delta) { 29926 fullText = appendStreamText(msgDiv, fullText, delta); 29927 } 29928 } catch (error) { 29929 console.debug("Skipping malformed SSE chunk.", error); 29930 } 29931 } 29932 } 29933 } 29934 29935 if (buffer.trim()) { 29936 const trailingLines = buffer.split(/\r?\n/); 29937 for (const line of trailingLines) { 29938 if (!line.startsWith("data:")) continue; 29939 const data = line.slice(5).trim(); 29940 if (!data || data === "[DONE]") continue; 29941 try { 29942 const parsed = JSON.parse(data); 29943 const delta = 29944 parsed && 29945 Array.isArray(parsed.choices) && 29946 parsed.choices[0] && 29947 parsed.choices[0].delta && 29948 parsed.choices[0].delta.content; 29949 if (typeof delta === "string" && delta) { 29950 fullText = appendStreamText(msgDiv, fullText, delta); 29951 } 29952 } catch (error) { 29953 console.debug("Skipping malformed trailing SSE chunk.", error); 29954 } 29955 } 29956 } 29957 if (fullText.trim()) { 29958 flushAssistantMessageContent(msgDiv, fullText); 29959 return fullText; 29960 } 29961 flushAssistantMessageContent(msgDiv, "No reply received."); 29962 return "No reply received."; 29963 } 29964 29965 async function streamResponse(response, msgDiv) { 29966 return getCurrentLlmSettings().provider === "groq" 29967 ? streamGroqResponse(response, msgDiv) 29968 : streamOllamaResponse(response, msgDiv); 29969 } 29970 29971 function renderChatError(err) { 29972 if (err.message === "CORS_BLOCKED") { 29973 return ( 29974 "<b>Connection blocked (CORS)</b><br><br>" + 29975 "Ollama is running but the browser is blocking the request. " + 29976 "You need to restart Ollama with CORS allowed:<br><br>" + 29977 '<code style="background:var(--code-bg);padding:3px 6px;border-radius:4px;font-size:12px;">' + 29978 "OLLAMA_ORIGINS=* ollama serve" + 29979 "</code><br><br>" + 29980 "Or on macOS with the Ollama app:<br>" + 29981 '<code style="background:var(--code-bg);padding:3px 6px;border-radius:4px;font-size:12px;">' + 29982 'launchctl setenv OLLAMA_ORIGINS "*"' + 29983 "</code><br>" + 29984 "then restart the Ollama app." 29985 ); 29986 } 29987 const settings = getCurrentLlmSettings(); 29988 if (settings.provider === "groq") { 29989 return escapeChatHtml( 29990 "Error: " + 29991 err.message + 29992 "\n\nCheck your Groq API key and model in AI settings.", 29993 ).replace(/\n/g, "<br>"); 29994 } 29995 return escapeChatHtml( 29996 "Error: " + 29997 err.message + 29998 "\n\nMake sure Ollama is running at " + 29999 endpointInput.value.trim(), 30000 ).replace(/\n/g, "<br>"); 30001 } 30002 30003 function buildSystemPrompt(question, retrieval) { 30004 const docStatus = retrieval.hasDocument 30005 ? "loaded (" + getDocumentLabel() + ")" 30006 : "not loaded"; 30007 const contextStatus = retrieval.context 30008 ? retrieval.strategy + " context available" 30009 : "no relevant retrieved context"; 30010 30011 const parts = [ 30012 "You are Document Q&A, a production-grade assistant embedded in a markdown viewer.", 30013 "Your job is to be genuinely helpful in conversation while staying accurate and grounded in the loaded document whenever the user asks about it.", 30014 "", 30015 "Operating rules:", 30016 "1. Handle greetings, thanks, help requests, and simple c
30016onversational turns naturally. Do not claim the document is required for small talk.", 30017 "2. For substantive questions that are unrelated to the document, politely redirect the user back to document-focused help instead of acting like a general-purpose web assistant.", 30018 "3. When the user asks about the document and relevant context is provided, answer from that context first and keep the explanation clear and direct.", 30019 "4. When context is partial, answer only the supported part and explicitly say what is missing.", 30020 "5. When no relevant context is available for a document question, say you could not find support for that in the loaded document. Ask the user to rephrase, point you to a section, or load the right file.", 30021 "6. If no document is loaded and the user asks a document question, say so plainly and briefly explain what you can do once one is loaded.", 30022 '7. Use recent chat history to resolve follow-up questions like "what about chapter 2?" or "explain that more".', 30023 "8. If the user asks for a summary, rewrite, extraction, comparison, or explanation, perform that task using the document when available.", 30024 "9. Never invent document facts, quotations, sections, numbers, or conclusions that are not supported.", 30025 "10. Keep answers concise by default, but provide structure when it improves clarity.", 30026 "", 30027 "Current document status: " + docStatus + ".", 30028 "Current retrieval status: " + contextStatus + ".", 30029 ]; 30030 30031 if (retrieval.context) { 30032 parts.push( 30033 "", 30034 "--- DOCUMENT CONTEXT ---", 30035 retrieval.context, 30036 "--- END CONTEXT ---", 30037 ); 30038 } 30039 30040 return parts.join("\n"); 30041 } 30042 30043 // ---- Send question ---- 30044 let isSending = false; 30045 30046 async function sendQuestion(question) { 30047 if (isSending || !question.trim()) return; 30048 isSending = true; 30049 sendBtn.disabled = true; 30050 syncHistoryWithCurrentDocument(); 30051 30052 addMessage(question, "user"); 30053 30054 const retrieval = getRelevantContext(question, 5); 30055 const localReply = getLocalAssistantReply( 30056 question, 30057 retrieval.hasDocument, 30058 ); 30059 if (localReply) { 30060 addMessage(localReply, "assistant"); 30061 addHistory("user", question); 30062 addHistory("assistant", localReply); 30063 announceChatUpdate(localReply); 30064 isSending = false; 30065 sendBtn.disabled = !chatInput.value.trim(); 30066 return; 30067 } 30068 30069 const systemPrompt = buildSystemPrompt(question, retrieval); 30070 const conversation = [ 30071 { role: "system", content: systemPrompt }, 30072 ].concat(getRecentHistory(), [{ role: "user", content: question }]); 30073 30074 const typingDiv = addTyping(); 30075 30076 try { 30077 const response = await callConfiguredLlmResponse(conversation); 30078 const answer = await streamResponse(response, typingDiv); 30079 addHistory("user", question); 30080 addHistory("assistant", answer || typingDiv.textContent || ""); 30081 announceChatUpdate( 30082 answer || typingDiv.textContent || "Assistant replied.", 30083 ); 30084 } catch (err) { 30085 typingDiv.className = "chat-msg system"; 30086 typingDiv.innerHTML = renderChatError(err); 30087 announceChatUpdate(typingDiv.textContent || "Chat error"); 30088 } 30089 30090 isSending = false; 30091 sendBtn.disabled = !chatInput.value.trim(); 30092 } 30093 30094 sendBtn.addEventListener("click", function () { 30095 const q = chatInput.value.trim(); 30096 chatInput.value = ""; 30097 chatInput.style.height = "auto"; 30098 sendBtn.disabled = true; 30099 sendQuestion(q); 30100 }); 30101 document.addEventListener("keydown", function (e) { 30102 if (e.key === "Escape") { 30103 if (!panel.hidden) { 30104 e.preventDefault(); 30105 hideChatPanel(); 30106 } 30107 } 30108 }); 30109 30110 document.addEventListener("markv:selection-action", function (event) { 30111 const detail = event.detail || {}; 30112 const selectedText = String(detail.text || "").trim(); 30113 if (!selectedText) return; 30114 30115 if (detail.type === "summarize") { 30116 showChatPanel({ focusInput: false }); 30117 30118 const truncated = 30119 selectedText.length > 500 30120 ? selectedText.slice(0, 500) + "..." 30121 : selectedText; 30122 30123 sendQuestion( 30124 'Summarize this section from the document:\n\n"' + 30125 truncated + 30126 '"', 30127 ); 30128 return; 30129 } 30130 30131 if (detail.type === "ask") { 30132 showChatPanel(); 30133 30134 const truncated = 30135 selectedText.length > 300 30136 ? selectedText.slice(0, 300) + "..." 30137 : selectedText; 30138 30139 chatInput.value = 'Regarding this section: "' + truncated + '"\n\n'; 30140 chatInput.focus(); 30141 chatInput.setSelectionRange( 30142 chatInput.value.length, 30143 chatInput.value.length, 30144 ); 30145 syncChatInputUi(); 30146 } 30147 }); 30148 30149 // ---- Keyboard shortcut: Ctrl+/ to toggle chat ---- 30150 window.addEventListener("keydown", function (e) { 30151 if ((e.metaKey || e.ctrlKey) && e.key === "/") { 30152 e.preventDefault(); 30153 if (panel.hidden) { 30154 showChatPanel(); 30155 } else { 30156 hideChatPanel(); 30157 } 30158 } 30159 }); 30160 })(); 30161 </script>
30161 30162 30163
30163<script> 30164 /* ============================================================ 30165 Secure Groq API key storage (WebCrypto + IndexedDB) 30166 30167 Strategy: the Groq key is encrypted with a non-extractable 30168 AES-GCM CryptoKey stored inside IndexedDB. IDB can hold a 30169 CryptoKey handle directly without ever exposing its raw bytes, 30170 so even with disk access an attacker cannot recover the 30171 encryption key. The encrypted Groq key lives in the same DB. 30172 30173 Plaintext never touches localStorage after first migration. 30174 The existing chat module still reads `groqApiKey` from its 30175 legacy hidden input â this script injects the decrypted value 30176 on load and scrubs any plaintext that other code writes. 30177 30178 Threat model: protects the key at-rest from casual disk 30179 inspection, backup leaks, and shoulder-surfing via devtools 30180 Application tab. It does NOT protect against active XSS on 30181 this origin â any script that can call crypto.subtle.decrypt 30182 can decrypt the key. For that level of protection, proxy 30183 Groq calls through a server-side endpoint. 30184 ============================================================ */ 30185 (function () { 30186 "use strict"; 30187 30188 const LLM_KEY = "md-viewer-llm"; 30189 const DB_NAME = "md-viewer-secrets"; 30190 const DB_VERSION = 1; 30191 const STORE = "secrets"; 30192 const KEY_ID = "aes-key"; 30193 const SECRET_ID = "groq-api-key"; 30194 30195 const supported = 30196 typeof indexedDB !== "undefined" && 30197 typeof crypto !== "undefined" && 30198 crypto.subtle && 30199 typeof TextEncoder !== "undefined"; 30200 30201 if (!supported) { 30202 console.warn( 30203 "[secure-store] WebCrypto/IndexedDB unavailable â " + 30204 "Groq key stays in localStorage plaintext.", 30205 ); 30206 return; 30207 } 30208 30209 // ---- IndexedDB helpers ---- 30210 let dbPromise = null; 30211 function openDb() { 30212 if (dbPromise) return dbPromise; 30213 dbPromise = new Promise(function (resolve, reject) { 30214 const req = indexedDB.open(DB_NAME, DB_VERSION); 30215 req.onupgradeneeded = function () { 30216 const db = req.result; 30217 if (!db.objectStoreNames.contains(STORE)) { 30218 db.createObjectStore(STORE, { keyPath: "id" }); 30219 } 30220 }; 30221 req.onsuccess = function () { 30222 resolve(req.result); 30223 }; 30224 req.onerror = function () { 30225 reject(req.error); 30226 }; 30227 }); 30228 return dbPromise; 30229 } 30230 30231 function idbGet(id) { 30232 return openDb().then(function (db) { 30233 return new Promise(function (resolve, reject) { 30234 const tx = db.transaction(STORE, "readonly"); 30235 const req = tx.objectStore(STORE).get(id); 30236 req.onsuccess = function () { 30237 resolve(req.result || null); 30238 }; 30239 req.onerror = function () { 30240 reject(req.error); 30241 }; 30242 }); 30243 }); 30244 } 30245 30246 function idbPut(value) { 30247 return openDb().then(function (db) { 30248 return new Promise(function (resolve, reject) { 30249 const tx = db.transaction(STORE, "readwrite"); 30250 tx.objectStore(STORE).put(value); 30251 tx.oncomplete = function () { 30252 resolve(); 30253 }; 30254 tx.onerror = function () { 30255 reject(tx.error); 30256 }; 30257 }); 30258 }); 30259 } 30260 30261 function idbDelete(id) { 30262 return openDb().then(function (db) { 30263 return new Promise(function (resolve, reject) { 30264 const tx = db.transaction(STORE, "readwrite"); 30265 tx.objectStore(STORE).delete(id); 30266 tx.oncomplete = function () { 30267 resolve(); 30268 }; 30269 tx.onerror = function () { 30270 reject(tx.error); 30271 }; 30272 }); 30273 }); 30274 } 30275 30276 // ---- AES-GCM key management ---- 30277 async function getOrCreateAesKey() { 30278 const existing = await idbGet(KEY_ID); 30279 if (existing && existing.key) return existing.key;
30280 const key = await crypto.subtle.generateKey( 30281 { name: "AES-GCM", length: 256 }, 30282 // extractable=false â the raw bytes cannot be read back, 30283 // even by our own code. Only encrypt/decrypt ops work. 30284 false, 30285 ["encrypt", "decrypt"], 30286 ); 30287 await idbPut({ id: KEY_ID, key: key }); 30288 return key; 30289 } 30290 30291 async function encryptString(plaintext) { 30292 const key = await getOrCreateAesKey(); 30293 const iv = crypto.getRandomValues(new Uint8Array(12)); 30294 const bytes = new TextEncoder().encode(plaintext); 30295 const cipher = await crypto.subtle.encrypt( 30296 { name: "AES-GCM", iv: iv }, 30297 key, 30298 bytes, 30299 ); 30300 return { iv: iv, ct: new Uint8Array(cipher) }; 30301 } 30302 30303 async function decryptPayload(payload) { 30304 const key = await getOrCreateAesKey(); 30305 const plain = await crypto.subtle.decrypt( 30306 { name: "AES-GCM", iv: payload.iv }, 30307 key, 30308 payload.ct, 30309 ); 30310 return new TextDecoder().decode(plain); 30311 } 30312 30313 async function saveGroqKey(plaintext) { 30314 if (!plaintext) { 30315 await idbDelete(SECRET_ID); 30316 return; 30317 } 30318 const payload = await encryptString(plaintext); 30319 await idbPut({ id: SECRET_ID, iv: payload.iv, ct: payload.ct }); 30320 } 30321 30322 async function loadGroqKey() { 30323 const row = await idbGet(SECRET_ID); 30324 if (!row || !row.iv || !row.ct) return null; 30325 try { 30326 return await decryptPayload({ iv: row.iv, ct: row.ct }); 30327 } catch (err) { 30328 console.warn("[secure-store] decrypt failed:", err); 30329 return null; 30330 } 30331 } 30332 30333 // ---- localStorage plaintext scrubbing ---- 30334 function stripPlaintextFromLocalStorage() { 30335 try { 30336 const raw = localStorage.getItem(LLM_KEY); 30337 if (!raw) return; 30338 const obj = JSON.parse(raw); 30339 if (!obj || typeof obj !== "object") return; 30340 if (!obj.groqApiKey) return; 30341 obj.groqApiKey = ""; 30342 localStorage.setItem(LLM_KEY, JSON.stringify(obj)); 30343 } catch (_err) { 30344 /* ignore â corrupt JSON is the chat module's problem */ 30345 } 30346 } 30347 30348 function extractPlaintextFromLocalStorage() { 30349 try { 30350 const raw = localStorage.getItem(LLM_KEY); 30351 if (!raw) return ""; 30352 const obj = JSON.parse(raw); 30353 return obj && obj.groqApiKey ? String(obj.groqApiKey) : ""; 30354 } catch (_err) { 30355 return ""; 30356 } 30357 } 30358 30359 // ---- Migration ---- 30360 // If this is the first page load after the secure-store 30361 // feature ships, the chat IIFE has just re-written a JSON 30362 // blob containing the plaintext key. Encrypt and scrub. 30363 function migrateIfNeeded() { 30364 const plaintext = extractPlaintextFromLocalStorage(); 30365 if (!plaintext) return Promise.resolve(); 30366 return saveGroqKey(plaintext) 30367 .then(function () { 30368 stripPlaintextFromLocalStorage(); 30369 console.info( 30370 "[secure-store] migrated Groq key to encrypted storage.", 30371 ); 30372 }) 30373 .catch(function (err) { 30374 console.warn("[secure-store] migration failed:", err); 30375 }); 30376 } 30377 30378 // ---- Hydration ---- 30379 // Load the encrypted key and inject it into the legacy 30380 // hidden input. Fire input+change so the drawer mirrors it 30381 // and the chat module refreshes its "configured" state. 30382 function hydrateInput() { 30383 const input = document.getElementById("groqApiKey"); 30384 if (!input) return Promise.resolve(); 30385 return loadGroqKey().then(function (plaintext) { 30386 if (!plaintext) return; 30387 if (input.value === plaintext) return; 30388 input.value = plaintext; 30389 input.dispatchEvent(new Event("input", { bubbles: true })); 30390 input.dispatchEvent(new Event("change", { bubbles: true })); 30391 // The change event will have made chat IIFE write the 30392 // plaintext back into localStorage â scrub it again.
30393 stripPlaintextFromLocalStorage(); 30394 }); 30395 } 30396 30397 // ---- Forward-save interception ---- 30398 // When the user edits the Groq key (via the drawer, which 30399 // mirrors into the legacy input), the chat IIFE's change 30400 // listener runs first and writes plaintext to localStorage. 30401 // Our change listener runs right after and: 30402 // 1. Strips plaintext from localStorage immediately 30403 // 2. Encrypts the new value into IndexedDB 30404 function attachForwardSaveGuard() { 30405 const input = document.getElementById("groqApiKey"); 30406 if (!input) return; 30407 input.addEventListener("change", function () { 30408 const value = input.value.trim(); 30409 stripPlaintextFromLocalStorage(); 30410 saveGroqKey(value).catch(function (err) { 30411 console.warn("[secure-store] save failed:", err); 30412 }); 30413 }); 30414 } 30415 30416 // ---- Boot ---- 30417 // Ordering: the chat IIFE has already run (this script 30418 // follows it in document order), so `groqApiKey` input is 30419 // already populated with whatever was in localStorage. 30420 // Kick off migration + hydration in parallel, then attach 30421 // the save guard for future edits. 30422 attachForwardSaveGuard(); 30423 migrateIfNeeded() 30424 .then(hydrateInput) 30425 .catch(function (err) { 30426 console.warn("[secure-store] boot failed:", err); 30427 }); 30428 })(); 30429 </script>
30429 30430 30431 <!-- ============================================================ 30432 Floating editor formatting toolbar. 30433 Medium-style, selection-driven, edit-mode only. 30434 Self-contained IIFE â no external dependencies. 30435 ============================================================ --> 30436
30436<script> 30437 (function setupEditorToolbar() { 30438 const editor = document.getElementById("editor"); 30439 const toolbar = document.getElementById("editorToolbar"); 30440 const mainEl = document.getElementById("main"); 30441 if (!editor || !toolbar || !mainEl) return; 30442 30443 const markdownActions = window.MarkVEditorMarkdownActions; 30444 const shortcuts = window.MarkVShortcuts; 30445 const linkInput = toolbar.querySelector(".et-link-input"); 30446 const arrow = toolbar.querySelector(".et-arrow"); 30447 let pendingLinkRange = null; 30448 let repositionRaf = null; 30449 let suppressNextBlurHide = false; 30450 30451 if (shortcuts && typeof shortcuts.applyTitles === "function") { 30452 shortcuts.applyTitles(toolbar); 30453 } 30454 30455 function shortcutMatches(event, shortcutId) { 30456 return Boolean( 30457 shortcuts && 30458 typeof shortcuts.matches === "function" && 30459 shortcuts.matches(event, shortcutId), 30460 ); 30461 } 30462 30463 // ---------- Mirror div for textarea caret coordinates ---------- 30464 const mirror = document.createElement("div"); 30465 mirror.setAttribute("aria-hidden", "true"); 30466 mirror.style.position = "absolute"; 30467 mirror.style.visibility = "hidden"; 30468 mirror.style.top = "0"; 30469 mirror.style.left = "-9999px"; 30470 mirror.style.whiteSpace = "pre-wrap"; 30471 mirror.style.wordWrap = "break-word"; 30472 mirror.style.overflowWrap = "break-word"; 30473 mirror.style.pointerEvents = "none"; 30474 document.body.appendChild(mirror); 30475 30476 const MIRROR_PROPS = [ 30477 "direction", 30478 "boxSizing", 30479 "width", 30480 "height", 30481 "overflowX", 30482 "overflowY", 30483 "borderTopWidth", 30484 "borderRightWidth", 30485 "borderBottomWidth", 30486 "borderLeftWidth", 30487 "borderStyle", 30488 "paddingTop", 30489 "paddingRight", 30490 "paddingBottom", 30491 "paddingLeft", 30492 "fontStyle", 30493 "fontVariant", 30494 "fontWeight", 30495 "fontStretch", 30496 "fontSize", 30497 "fontSizeAdjust", 30498 "lineHeight", 30499 "fontFamily", 30500 "textAlign", 30501 "textTransform", 30502 "textIndent", 30503 "textDecoration", 30504 "letterSpacing", 30505 "wordSpacing", 30506 "tabSize", 30507 "MozTabSize", 30508 ]; 30509 30510 function syncMirrorStyles() { 30511 const cs = window.getComputedStyle(editor); 30512 for (let i = 0; i < MIRROR_PROPS.length; i++) { 30513 const p = MIRROR_PROPS[i]; 30514 mirror.style[p] = cs[p]; 30515 } 30516 mirror.style.width = editor.offsetWidth + "px"; 30517 mirror.style.height = "auto"; 30518 } 30519 30520 // Returns viewport-relative caret coordinates for a given char offset. 30521 function getCaretViewportRect(position) { 30522 syncMirrorStyles(); 30523 mirror.textContent = editor.value.substring(0, position); 30524 const span = document.createElement("span"); 30525 // Use remainder so the span lays out at the correct baseline even 30526 // when the caret is at the very end of the content. 30527 span.textContent = editor.value.substring(position) || "."; 30528 mirror.appendChild(span); 30529 30530 const cs = window.getComputedStyle(editor); 30531 const borderTop = parseFloat(cs.borderTopWidth) || 0; 30532 const borderLeft = parseFloat(cs.borderLeftWidth) || 0; 30533 const lineHeight = 30534 parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2 || 20; 30535 30536 const eRect = editor.getBoundingClientRect(); 30537 const caretX = 30538 eRect.left + borderLeft + span.offsetLeft - editor.scrollLeft; 30539 const caretY = 30540 eRect.top + borderTop + span.offsetTop - editor.scrollTop; 30541 30542 return { x: caretX, y: caretY, lineHeight: lineHeight }; 30543 } 30544 30545 // ---------- Visibility helpers ---------- 30546 function isEditMode() { 30547 return mainEl.classList.contains("edit-mode"); 30548 } 30549 30550 function showToolbar() { 30551 if (toolbar.hidden) { 30552 toolbar.hidden = false;
30553 // Force layout so the transition runs. 30554 void toolbar.offsetWidth; 30555 } 30556 toolbar.classList.add("visible"); 30557 } 30558 30559 function hideToolbar() { 30560 toolbar.classList.remove("visible"); 30561 toolbar.classList.remove("et-link-mode"); 30562 toolbar.classList.remove("et-arrow-up"); 30563 pendingLinkRange = null; 30564 } 30565 30566 toolbar.addEventListener("transitionend", function (e) { 30567 if ( 30568 e.propertyName === "opacity" && 30569 !toolbar.classList.contains("visible") 30570 ) { 30571 toolbar.hidden = true; 30572 } 30573 }); 30574 30575 function positionToolbarForSelection() { 30576 const start = editor.selectionStart; 30577 const end = editor.selectionEnd; 30578 if (start === end) { 30579 hideToolbar(); 30580 return; 30581 } 30582 30583 const startPos = getCaretViewportRect(start); 30584 const endPos = getCaretViewportRect(end); 30585 const sameLine = 30586 Math.abs(startPos.y - endPos.y) < startPos.lineHeight * 0.6; 30587 30588 const anchorY = startPos.y; 30589 const anchorX = sameLine ? (startPos.x + endPos.x) / 2 : startPos.x; 30590 30591 // Reveal the toolbar (opacity 0 still paints, so we can measure). 30592 showToolbar(); 30593 const tbRect = toolbar.getBoundingClientRect(); 30594 30595 const gap = 10; 30596 const margin = 8; 30597 let tx = anchorX - tbRect.width / 2; 30598 let ty = anchorY - tbRect.height - gap; 30599 30600 // Horizontal clamp 30601 const maxX = window.innerWidth - tbRect.width - margin; 30602 if (tx < margin) tx = margin; 30603 if (tx > maxX) tx = Math.max(margin, maxX); 30604 30605 // Vertical flip if near top of viewport 30606 let flipped = false; 30607 if (ty < margin) { 30608 ty = anchorY + startPos.lineHeight + gap; 30609 flipped = true; 30610 } 30611 toolbar.classList.toggle("et-arrow-up", flipped); 30612 30613 // Absolute positioning relative to document 30614 toolbar.style.left = Math.round(tx + window.scrollX) + "px"; 30615 toolbar.style.top = Math.round(ty + window.scrollY) + "px"; 30616 30617 // Re-anchor the arrow horizontally so it still points at the caret 30618 if (arrow) { 30619 const arrowPx = Math.max( 30620 14, 30621 Math.min(tbRect.width - 14, anchorX - tx), 30622 ); 30623 arrow.style.left = arrowPx + "px"; 30624 arrow.style.marginLeft = "-6px"; 30625 } 30626 } 30627 30628 function updateToolbar() { 30629 if (!isEditMode()) return hideToolbar(); 30630 const activeIsEditor = document.activeElement === editor; 30631 const activeIsInToolbar = toolbar.contains(document.activeElement); 30632 if (!activeIsEditor && !activeIsInToolbar) return hideToolbar(); 30633 if (toolbar.classList.contains("et-link-mode")) return; // don't reposition during link entry 30634 if (editor.selectionStart === editor.selectionEnd) { 30635 return hideToolbar(); 30636 } 30637 positionToolbarForSelection(); 30638 } 30639 30640 function scheduleUpdate() { 30641 if (repositionRaf) cancelAnimationFrame(repositionRaf); 30642 repositionRaf = requestAnimationFrame(function () { 30643 repositionRaf = null; 30644 updateToolbar(); 30645 }); 30646 } 30647 30648 // ---------- Text replacement (preserves undo) ---------- 30649 function replaceRange(start, end, replacement, selStart, selEnd) { 30650 editor.focus(); 30651 editor.setSelectionRange(start, end); 30652 let ok = false; 30653 try { 30654 ok = document.execCommand("insertText", false, replacement); 30655 } catch (err) { 30656 ok = false; 30657 } 30658 if (!ok) { 30659 editor.setRangeText(replacement, start, end, "end"); 30660 editor.dispatchEvent(new Event("input", { bubbles: true })); 30661 } 30662 if (typeof selStart === "number") { 30663 editor.setSelectionRange( 30664 selStart, 30665 typeof selEnd === "number" ? selEnd : selStart, 30666 ); 30667 } 30668 } 30669 30670 function applyMarkdownEdit(edit) { 30671 if (!edit) return false;
30672 replaceRange( 30673 edit.start, 30674 edit.end, 30675 edit.text, 30676 edit.selectionStart, 30677 edit.selectionEnd, 30678 ); 30679 return true; 30680 } 30681 30682 function runMarkdownEdit(factory) { 30683 if (!markdownActions || typeof factory !== "function") return false; 30684 return applyMarkdownEdit( 30685 factory(editor.value, editor.selectionStart, editor.selectionEnd), 30686 ); 30687 } 30688 30689 function isCompositionKeyEvent(event) { 30690 return Boolean(event && (event.isComposing || event.keyCode === 229)); 30691 } 30692 30693 // ---------- Inline wrap toggle (bold, italic) ---------- 30694 function wrapInline(prefix, suffix) { 30695 const start = editor.selectionStart; 30696 const end = editor.selectionEnd; 30697 if (start === end) return false; 30698 const v = editor.value; 30699 const sel = v.slice(start, end); 30700 30701 // Case A: selection INCLUDES the wrappers -> unwrap. 30702 if ( 30703 sel.length >= prefix.length + suffix.length && 30704 sel.slice(0, prefix.length) === prefix && 30705 sel.slice(sel.length - suffix.length) === suffix 30706 ) { 30707 const inner = sel.slice(prefix.length, sel.length - suffix.length); 30708 replaceRange(start, end, inner, start, start + inner.length); 30709 return true; 30710 } 30711 30712 // Case B: wrappers are IMMEDIATELY outside the selection -> unwrap. 30713 if ( 30714 start >= prefix.length && 30715 end + suffix.length <= v.length && 30716 v.slice(start - prefix.length, start) === prefix && 30717 v.slice(end, end + suffix.length) === suffix 30718 ) { 30719 replaceRange( 30720 start - prefix.length, 30721 end + suffix.length, 30722 sel, 30723 start - prefix.length, 30724 start - prefix.length + sel.length, 30725 ); 30726 return true; 30727 } 30728 30729 // Case C: wrap it. 30730 const wrapped = prefix + sel + suffix; 30731 replaceRange( 30732 start, 30733 end, 30734 wrapped, 30735 start + prefix.length, 30736 start + prefix.length + sel.length, 30737 ); 30738 return true; 30739 } 30740 30741 // ---------- Line-level toggles (headings, quotes) ---------- 30742 function expandToLineBounds(start, end) { 30743 const v = editor.value; 30744 const lineStart = v.lastIndexOf("\n", start - 1) + 1; 30745 let lineEnd = v.indexOf("\n", end); 30746 if (lineEnd === -1) lineEnd = v.length; 30747 return { lineStart: lineStart, lineEnd: lineEnd }; 30748 } 30749 30750 function toggleHeading(level) { 30751 const { lineStart, lineEnd } = expandToLineBounds( 30752 editor.selectionStart, 30753 editor.selectionStart, 30754 ); 30755 let line = editor.value.slice(lineStart, lineEnd); 30756 const existing = line.match(/^(#{1,6})\s+/); 30757 const targetPrefix = "#".repeat(level) + " "; 30758 if (existing) { 30759 line = line.slice(existing[0].length); 30760 if (existing[1].length === level) { 30761 // Same level -> remove (toggle off) 30762 replaceRange( 30763 lineStart, 30764 lineEnd, 30765 line, 30766 lineStart, 30767 lineStart + line.length, 30768 ); 30769 return; 30770 } 30771 } 30772 const newLine = targetPrefix + line; 30773 replaceRange( 30774 lineStart, 30775 lineEnd, 30776 newLine, 30777 lineStart, 30778 lineStart + newLine.length, 30779 ); 30780 } 30781 30782 function toggleQuote() { 30783 return runMarkdownEdit(function (value, start, end) { 30784 return markdownActions.toggleLinePrefix(value, start, end, "quote"); 30785 }); 30786 } 30787 30788 // ---------- Link flow ---------- 30789 function startLinkFlow() { 30790 pendingLinkRange = { 30791 start: editor.selectionStart, 30792 end: editor.selectionEnd, 30793 }; 30794 if (pendingLinkRange.start === pendingLinkRange.end) return false;
30795 toolbar.classList.add("et-link-mode"); 30796 linkInput.value = ""; 30797 suppressNextBlurHide = true; 30798 setTimeout(function () { 30799 linkInput.focus(); 30800 suppressNextBlurHide = false; 30801 }, 0); 30802 return true; 30803 } 30804 30805 function confirmLink() { 30806 const url = linkInput.value.trim(); 30807 toolbar.classList.remove("et-link-mode"); 30808 if (!pendingLinkRange) { 30809 editor.focus(); 30810 return; 30811 } 30812 if (!url) { 30813 const r = pendingLinkRange; 30814 pendingLinkRange = null; 30815 editor.focus(); 30816 editor.setSelectionRange(r.start, r.end); 30817 return; 30818 } 30819 const r = pendingLinkRange; 30820 pendingLinkRange = null; 30821 const text = editor.value.slice(r.start, r.end) || "link"; 30822 const replacement = "[" + text + "](" + url + ")"; 30823 replaceRange( 30824 r.start, 30825 r.end, 30826 replacement, 30827 r.start + replacement.length, 30828 r.start + replacement.length, 30829 ); 30830 editor.focus(); 30831 hideToolbar(); 30832 } 30833 30834 function cancelLink() { 30835 toolbar.classList.remove("et-link-mode"); 30836 const r = pendingLinkRange; 30837 pendingLinkRange = null; 30838 editor.focus(); 30839 if (r) editor.setSelectionRange(r.start, r.end); 30840 } 30841 30842 // ---------- Action dispatch ---------- 30843 const actions = { 30844 bold: function () { 30845 return wrapInline("**", "**"); 30846 }, 30847 italic: function () { 30848 return wrapInline("*", "*"); 30849 }, 30850 "inline-code": function () { 30851 return runMarkdownEdit(function (value, start, end) { 30852 return markdownActions.toggleInline( 30853 value, 30854 start, 30855 end, 30856 "`", 30857 "`", 30858 "code", 30859 ); 30860 }); 30861 }, 30862 link: startLinkFlow, 30863 h1: function () { 30864 toggleHeading(1); 30865 }, 30866 h2: function () { 30867 toggleHeading(2); 30868 }, 30869 quote: toggleQuote, 30870 "code-block": function () { 30871 return runMarkdownEdit( 30872 markdownActions && markdownActions.toggleCodeBlock, 30873 ); 30874 }, 30875 "bullet-list": function () { 30876 return runMarkdownEdit(function (value, start, end) { 30877 return markdownActions.toggleLinePrefix( 30878 value, 30879 start, 30880 end, 30881 "unordered-list", 30882 ); 30883 }); 30884 }, 30885 "numbered-list": function () { 30886 return runMarkdownEdit(function (value, start, end) { 30887 return markdownActions.toggleLinePrefix( 30888 value, 30889 start, 30890 end, 30891 "ordered-list", 30892 ); 30893 }); 30894 }, 30895 image: function () { 30896 return runMarkdownEdit( 30897 markdownActions && markdownActions.insertImage, 30898 ); 30899 }, 30900 }; 30901 30902 function runAction(action) { 30903 if (!isEditMode()) return false; 30904 if (!actions[action]) return false; 30905 editor.focus(); 30906 const result = actions[action](); 30907 if (result === false) return false; 30908 if (!toolbar.classList.contains("et-link-mode")) { 30909 requestAnimationFrame(updateToolbar); 30910 } 30911 return true; 30912 } 30913 30914 window.MarkVEditorCommands = { 30915 run: runAction, 30916 }; 30917 30918 // ---------- Event wiring ---------- 30919 toolbar.addEventListener("mousedown", function (e) { 30920 const btn = e.target.closest("button[data-et-action]"); 30921 if (btn) { 30922 e.preventDefault(); // keep editor focus 30923 const action = btn.dataset.etAction; 30924 if (action === "link-cancel") { 30925 cancelLink(); 30926 return; 30927 } 30928 runAction(action); 30929 return; 30930 } 30931 // Clicks on the input or elsewhere in link mode should not steal focus 30932 // from the input; clicks on the toolbar background should not blur editor. 30933 if (e.target !== linkInput) { 30934 e.preventDefault(); 30935 } 30936 }); 30937 30938 linkInput.addEventListener("keydown", function (e) { 30939 if (e.key === "Enter") { 30940 e.preventDefault(); 30941 confirmLink(); 30942 } else if (e.key === "Escape") { 30943 e.preventDefault(); 30944 cancelLink(); 30945 } 30946 }); 30947 30948 linkInput.addEventListener("blur", function () { 30949 // If focus leaves the input without Enter/Escape, treat as cancel. 30950 setTimeout(function () { 30951 if ( 30952 toolbar.classList.contains("et-link-mode") && 30953 document.activeElement !== linkInput 30954 ) { 30955 cancelLink(); 30956 } 30957 }, 0); 30958 }); 30959 30960 document.addEventListener("selectionchange", function () { 30961 if (document.activeElement === editor) { 30962 scheduleUpdate(); 30963 } 30964 }); 30965 30966 editor.addEventListener("blur", function () { 30967 setTimeout(function () { 30968 if (suppressNextBlurHide) return; 30969 if (toolbar.contains(document.activeElement)) return; 30970 hideToolbar(); 30971 }, 60); 30972 }); 30973 30974 window.addEventListener( 30975 "scroll", 30976 function () { 30977 if (toolbar.classList.contains("visible")) scheduleUpdate(); 30978 }, 30979 { passive: true }, 30980 ); 30981 30982 window.addEventListener("resize", function () { 30983 if (toolbar.classList.contains("visible")) scheduleUpdate(); 30984 }); 30985 30986 // Keyboard shortcuts + Escape 30987 document.addEventListener( 30988 "keydown", 30989 function (e) { 30990 if (e.key === "Escape" && toolbar.classList.contains("visible")) { 30991 hideToolbar(); 30992 editor.focus(); 30993 return; 30994 } 30995 if (!isEditMode()) return; 30996 if (document.activeElement !== editor) return; 30997 if (isCompositionKeyEvent(e)) return; 30998 let action = ""; 30999 if (shortcutMatches(e, "editor.bold")) { 31000 action = "bold"; 31001 } else if (shortcutMatches(e, "editor.italic")) { 31002 action = "italic"; 31003 } else if (shortcutMatches(e, "editor.link")) { 31004 action = "link"; 31005 } else if (shortcutMatches(e, "editor.inline-code")) { 31006 action = "inline-code"; 31007 } else if (shortcutMatches(e, "editor.code-block")) { 31008 action = "code-block"; 31009 } else if (shortcutMatches(e, "editor.bullet-list")) { 31010 action = "bullet-list"; 31011 } else if (shortcutMatches(e, "editor.numbered-list")) { 31012 action = "numbered-list"; 31013 } 31014 if (action) { 31015 e.preventDefault(); 31016 runAction(action); 31017 } 31018 }, 31019 true, 31020 ); 31021 31022 // Hide immediately when leaving edit mode. 31023 const modeObserver = new MutationObserver(function () { 31024 if (!isEditMode()) hideToolbar(); 31025 }); 31026 modeObserver.observe(mainEl, { 31027 attributes: true, 31028 attributeFilter: ["class"], 31029 }); 31030 })(); 31031 </script>
31031 31032 31033
31033<script src="assets/visual-block-menu.js?v=16"></script>
31033 31034 <!-- Keeps the toolbar's segmented View-mode pill visually in 31035 sync with body.edit-mode-active / body.mindmap-open. --> 31036
31036<script src="assets/toolbar-sync.js"></script>
31036 31037 <!-- â¤640px: teleports secondary toolbar groups into a bottom 31038 sheet ("⯠More") so the mobile header collapses to one row. --> 31039
31039<script src="assets/toolbar-mobile.js?v=2"></script>
31039 31040 </body> 31041</html>
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.