oxedyne/fe2o3/fe2o3_austenite/web/pearl-reader/index.html
16.4 KiB, 55 runs
created by r1870400018:38478, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 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 | <title>Pearl Reader</title> |
| 7 | <style> |
| 8 | :root { |
| 9 | --bg: #6b7280; |
| 10 | --panel: #ffffff; |
| 11 | --ink: #1f2937; |
| 12 | --line: #d1d5db; |
| 13 | } |
| 14 | * { box-sizing: border-box; } |
| 15 | html, body { margin: 0; padding: 0; } |
| 16 | body { |
| 17 | font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; |
| 18 | color: var(--ink); |
| 19 | background: var(--bg); |
| 20 | } |
| 21 | header { |
| 22 | position: sticky; top: 0; z-index: 5; |
| 23 | display: flex; align-items: center; gap: 12px; |
| 24 | padding: 10px 16px; |
| 25 | background: var(--panel); |
| 26 | border-bottom: 1px solid var(--line); |
| 27 | } |
| 28 | header h1 { font-size: 15px; margin: 0; font-weight: 600; } |
| 29 | header .sp { flex: 1; } |
| 30 | select, button { |
| 31 | font: inherit; padding: 4px 8px; |
| 32 | border: 1px solid var(--line); border-radius: 6px; background: #fff; |
| 33 | } |
| 34 | #status { color: #4b5563; font-variant-numeric: tabular-nums; } |
| 35 | #tocToggle { cursor: pointer; } |
| 36 | #tocToggle[aria-pressed="true"] { background: #2563eb; color: #fff; border-color: #2563eb; } |
| 37 | #pages { padding: 24px 16px 64px; display: flex; flex-direction: column; align-items: center; gap: 24px; } |
| 38 | |
| 39 | /* The contents outline: a sticky left rail below the header, shown when a document carries an outline |
| 40 | and the reader has it open. It flows the page column right on a wide screen and overlays on a narrow |
| 41 | one, so the reading area is never wedged. */ |
| 42 | #toc { |
| 43 | position: fixed; top: 49px; left: 0; bottom: 0; width: 264px; z-index: 4; |
| 44 | overflow-y: auto; padding: 12px 10px 40px; |
| 45 | background: var(--panel); border-right: 1px solid var(--line); |
| 46 | } |
| 47 | #toc[hidden] { display: none; } |
| 48 | .toc-head { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; |
| 49 | color: #6b7280; padding: 4px 6px 8px; } |
| 50 | .toc-tree, .toc-children { list-style: none; margin: 0; padding: 0; } |
| 51 | .toc-children { padding-left: 14px; } |
| 52 | .toc-item.has-children:not(.open) > .toc-children { display: none; } |
| 53 | .toc-row { display: flex; align-items: flex-start; gap: 2px; } |
| 54 | .toc-caret { |
| 55 | flex: 0 0 auto; width: 18px; height: 22px; padding: 0; margin: 0; |
| 56 | border: none; background: none; color: #9ca3af; cursor: pointer; |
| 57 | font-size: 11px; line-height: 22px; text-align: center; border-radius: 4px; |
| 58 | } |
| 59 | .toc-caret.empty { cursor: default; visibility: hidden; } |
| 60 | .toc-caret:hover { background: #f3f4f6; color: #4b5563; } |
| 61 | .toc-link { |
| 62 | flex: 1 1 auto; min-width: 0; text-align: left; padding: 3px 6px; margin: 0; |
| 63 | border: none; background: none; border-radius: 5px; cursor: pointer; |
| 64 | color: var(--ink); font: inherit; line-height: 1.35; |
| 65 | } |
| 66 | .toc-link:hover { background: #eff6ff; color: #1d4ed8; } |
| 67 | .toc-item.lvl-1 > .toc-row > .toc-link { font-weight: 600; } |
| 68 | .toc-item.lvl-2 > .toc-row > .toc-link { font-size: 13px; } |
| 69 | .toc-item.lvl-3 > .toc-row > .toc-link, |
| 70 | .toc-item.lvl-4 > .toc-row > .toc-link { font-size: 12.5px; color: #374151; } |
| 71 | .toc-link .toc-num { color: #9ca3af; margin-right: 6px; font-variant-numeric: tabular-nums; } |
| 72 | .toc-link.dead { color: #b91c1c; cursor: default; } |
| 73 | .toc-link.dead:hover { background: none; } |
| 74 | body.toc-open #pages { padding-left: 280px; } |
| 75 | |
| 76 | @media (max-width: 720px) { |
| 77 | #toc { width: min(80vw, 300px); box-shadow: 2px 0 16px rgba(0,0,0,.25); } |
| 78 | body.toc-open #pages { padding-left: 16px; } |
| 79 | } |
| 80 | .pearl-page { |
| 81 | position: relative; /* the overlay layer is absolute within the page */ |
| 82 | background: #fff; |
| 83 | box-shadow: 0 2px 10px rgba(0,0,0,.35); |
| 84 | } |
| 85 | .pearl-page svg { display: block; } |
| 86 | |
| 87 | /* The overlay sits exactly over the SVG (1 user unit = 1 px), carrying links and annotations. */ |
| 88 | .pearl-overlay { position: absolute; inset: 0; pointer-events: none; } |
| 89 | .pearl-overlay > * { pointer-events: auto; } |
| 90 | |
| 91 | /* A link hotspot: a subtle box while idle, a clear underline and tint on hover, so a reader sees it. */ |
| 92 | .pearl-link { |
| 93 | position: absolute; display: block; |
| 94 | border-bottom: 1.5px solid rgba(37,99,235,.55); |
| 95 | border-radius: 2px; |
| 96 | background: rgba(37,99,235,.06); |
| 97 | cursor: pointer; text-decoration: none; |
| 98 | } |
| 99 | .pearl-link:hover { background: rgba(37,99,235,.18); border-bottom-color: rgba(37,99,235,.95); } |
| 100 | .pearl-link.int { border-bottom-style: dashed; } |
| 101 | .pearl-link.dead { border-bottom-color: rgba(220,38,38,.7); background: rgba(220,38,38,.08); } |
| 102 | |
| 103 | /* A highlight: a translucent amber wash over the annotated region. */ |
| 104 | .pearl-highlight { |
| 105 | position: absolute; |
| 106 | background: rgba(250,204,21,.38); |
| 107 | mix-blend-mode: multiply; |
| 108 | border-radius: 2px; |
| 109 | } |
| 110 | |
| 111 | /* A note marker in the left margin, with a bubble that opens on click. */ |
| 112 | .pearl-note { |
| 113 | position: absolute; left: -14px; |
| 114 | width: 26px; height: 26px; padding: 0; |
| 115 | border: 1px solid #c084fc; border-radius: 50%; |
| 116 | background: #f5f3ff; color: #6b21a8; |
| 117 | font-size: 13px; line-height: 1; cursor: pointer; |
| 118 | } |
| 119 | .pearl-note:hover { background: #ede9fe; } |
| 120 | .pearl-note-bubble { |
| 121 | display: none; position: absolute; left: 32px; top: 0; |
| 122 | width: 220px; padding: 8px 10px; z-index: 10; |
| 123 | background: #fff; border: 1px solid #c084fc; border-radius: 8px; |
| 124 | box-shadow: 0 4px 14px rgba(0,0,0,.2); |
| 125 | text-align: left; cursor: default; |
| 126 | } |
| 127 | .pearl-note-bubble.open { display: block; } |
| 128 | .pearl-note-text { color: #1f2937; } |
| 129 | .pearl-note-meta { margin-top: 6px; color: #6b7280; font-size: 12px; } |
| 130 | |
| 131 | /* Authoring toolbar. */ |
| 132 | #author { width: 90px; } |
| 133 | #annotate.on { background: #2563eb; color: #fff; border-color: #2563eb; } |
| 134 | #pages.annotating .pearl-page { cursor: crosshair; } |
| 135 | /* In annotate mode existing overlay items must not swallow a drag that starts over them. */ |
| 136 | #pages.annotating .pearl-link, |
| 137 | #pages.annotating .pearl-highlight, |
| 138 | #pages.annotating .pearl-note { pointer-events: none; } |
| 139 | |
| 140 | /* The live selection while dragging out a region. */ |
| 141 | .pearl-draw { |
| 142 | position: absolute; pointer-events: none; |
| 143 | background: rgba(37,99,235,.14); |
| 144 | border: 1.5px dashed rgba(37,99,235,.85); |
| 145 | border-radius: 2px; |
| 146 | } |
| 147 | |
| 148 | /* The composer popover shown after a drag. */ |
| 149 | .pearl-composer { |
| 150 | position: absolute; z-index: 30; width: 240px; padding: 10px; |
| 151 | background: #fff; border: 1px solid #94a3b8; border-radius: 8px; |
| 152 | box-shadow: 0 6px 20px rgba(0,0,0,.28); |
| 153 | text-align: left; cursor: default; |
| 154 | } |
| 155 | .pearl-composer .pc-row { display: flex; align-items: center; gap: 8px; } |
| 156 | .pearl-composer .pc-kind { margin-bottom: 6px; } |
| 157 | .pearl-composer label { font-size: 13px; } |
| 158 | .pearl-composer .pc-payload { |
| 159 | width: 100%; margin-bottom: 8px; font: inherit; padding: 4px 6px; |
| 160 | border: 1px solid var(--line); border-radius: 6px; resize: vertical; |
| 161 | } |
| 162 | .pearl-composer .pc-author { flex: 1; min-width: 0; padding: 3px 6px; |
| 163 | border: 1px solid var(--line); border-radius: 6px; font: inherit; } |
| 164 | .pearl-composer .pc-sp { flex: 1; } |
| 165 | .pearl-composer .pc-save { background: #2563eb; color: #fff; border-color: #2563eb; } |
| 166 | |
| 167 | /* Drag-and-drop affordance covering the viewport while a file hovers. */ |
| 168 | #drop { |
| 169 | position: fixed; inset: 0; z-index: 20; display: none; |
| 170 | align-items: center; justify-content: center; |
| 171 | background: rgba(37,99,235,.12); |
| 172 | border: 3px dashed rgba(37,99,235,.7); |
| 173 | } |
| 174 | #drop.on { display: flex; } |
| 175 | #drop span { |
| 176 | padding: 16px 24px; border-radius: 10px; |
| 177 | background: #fff; font-size: 16px; font-weight: 600; color: #1e3a8a; |
| 178 | } |
| 179 | </style> |
| 180 | </head> |
| 181 | <body> |
| 182 | <header> |
| 183 | <button id="tocToggle" type="button" title="Show or hide the contents outline" aria-pressed="false" hidden>☰ Contents</button> |
| 184 | <h1>Pearl Reader <span style="font-weight:400;color:#6b7280">— .prl v1</span></h1> |
| 185 | <label>Document |
| 186 | <select id="doc"> |
| 187 | <option value="samples/hierarchy.prl">hierarchy (outline + lists)</option> |
| 188 | <option value="samples/demo.prl">demo (links + annotations)</option> |
| 189 | <option value="samples/keystone.prl">keystone (1 page)</option> |
| 190 | <option value="samples/manuscript.prl">manuscript (3 pages)</option> |
| 191 | <option value="samples/maths.prl">maths (1 page)</option> |
| 192 | <option value="samples/raster.prl">raster (1 page)</option> |
| 193 | </select> |
| 194 | </label> |
| 195 | <button id="open" type="button">Open file…</button> |
| 196 | <input id="file" type="file" accept=".prl" hidden> |
| 197 | <button id="annotate" type="button" title="Toggle annotation authoring">Annotate</button> |
| 198 | <label>as <input id="author" type="text" value="me"></label> |
| 199 | <button id="save" type="button" title="Download the .prl with your annotations">Save .prl</button> |
| 200 | <span class="sp"></span> |
| 201 | <span id="status"></span> |
| 202 | </header> |
| 203 | <div id="drop"><span>Drop a .prl to open</span></div> |
| 204 | <nav id="toc" aria-label="Document contents" hidden> |
| 205 | <div class="toc-head">Contents</div> |
| 206 | <div id="tocBody"></div> |
| 207 | </nav> |
| 208 | <main id="pages"></main> |
| 209 | |
| 210 | <script src="jdat.js"></script> |
| 211 | <script src="pearl.js"></script> |
| 212 | <script src="authoring.js"></script> |
| 213 | <script> |
| 214 | const sel = document.getElementById("doc"); |
| 215 | const pages = document.getElementById("pages"); |
| 216 | const statusEl = document.getElementById("status"); |
| 217 | const fileInput = document.getElementById("file"); |
| 218 | const openBtn = document.getElementById("open"); |
| 219 | const drop = document.getElementById("drop"); |
| 220 | const annotateBtn = document.getElementById("annotate"); |
| 221 | const authorInput = document.getElementById("author"); |
| 222 | const saveBtn = document.getElementById("save"); |
| 223 | const toc = document.getElementById("toc"); |
| 224 | const tocBody = document.getElementById("tocBody"); |
| 225 | const tocToggle = document.getElementById("tocToggle"); |
| 226 | |
| 227 | // The current document and its display name, so authoring knows what it is editing and saving. |
| 228 | let currentDoc = null; |
| 229 | let currentName = "document.prl"; |
| 230 | |
| 231 | // Reports what was rendered: page and glyph counts, plus any links and annotations, so the header |
| 232 | // confirms the demo features are present. |
| 233 | function report(doc, name) { |
| 234 | const g = Object.keys(doc.glyphs).length; |
| 235 | let links = 0; |
| 236 | for (let i = 0; i < doc.index.length; i++) links += Pearl.linksOnPage(doc, i).length; |
| 237 | const anns = (doc.annotations || []).length; |
| 238 | const extra = (links ? ` · ${links} link(s)` : "") + (anns ? ` · ${anns} annotation(s)` : ""); |
| 239 | statusEl.textContent = `${name ? name + " · " : ""}${doc.index.length} page(s) · ${g} glyphs${extra}`; |
| 240 | } |
| 241 | |
| 242 | // Registers a freshly rendered document with the authoring layer and reports it. |
| 243 | function adopt(doc, name) { |
| 244 | currentDoc = doc; |
| 245 | currentName = name || currentName; |
| 246 | Authoring.setDocument(doc, currentName); |
| 247 | report(doc, name); |
| 248 | buildToc(doc); |
| 249 | } |
| 250 | |
| 251 | // Scrolls a heading into view: the page it landed on, offset by its own y so the jump lands on the |
| 252 | // heading's line rather than the page top. The header is sticky, so a little clearance is left above. |
| 253 | function jumpTo(entry) { |
| 254 | if (entry.page == null) return; |
| 255 | const pageEl = pages.querySelectorAll(".pearl-page")[entry.page - 1]; |
| 256 | if (!pageEl) return; |
| 257 | const yOffset = entry.y != null ? Pearl.sp(entry.y) : 0; |
| 258 | const top = pageEl.getBoundingClientRect().top + window.scrollY + yOffset - 64; |
| 259 | window.scrollTo({ top, behavior: "smooth" }); |
| 260 | } |
| 261 | |
| 262 | // Builds the collapsible outline tree from `doc.outline`, nesting each entry under the nearest |
| 263 | // shallower one by its level. A document with no outline hides the rail and its toggle; one with an |
| 264 | // outline shows both, open by default. The tree is rebuilt whole for each document. |
| 265 | function buildToc(doc) { |
| 266 | const entries = Pearl.outlineEntries(doc); |
| 267 | tocBody.innerHTML = ""; |
| 268 | if (entries.length === 0) { |
| 269 | setTocOpen(false); |
| 270 | tocToggle.hidden = true; |
| 271 | toc.hidden = true; |
| 272 | return; |
| 273 | } |
| 274 | tocToggle.hidden = false; |
| 275 | |
| 276 | const root = document.createElement("ul"); |
| 277 | root.className = "toc-tree"; |
| 278 | const stack = [{ level: 0, ul: root }]; |
| 279 | for (const e of entries) { |
| 280 | while (stack.length > 1 && stack[stack.length - 1].level >= e.level) stack.pop(); |
| 281 | const parentUl = stack[stack.length - 1].ul; |
| 282 | |
| 283 | const li = document.createElement("li"); |
| 284 | li.className = "toc-item lvl-" + e.level; |
| 285 | |
| 286 | const row = document.createElement("div"); |
| 287 | row.className = "toc-row"; |
| 288 | const caret = document.createElement("button"); |
| 289 | caret.type = "button"; |
| 290 | caret.className = "toc-caret empty"; |
| 291 | row.appendChild(caret); |
| 292 | |
| 293 | const link = document.createElement("button"); |
| 294 | link.type = "button"; |
| 295 | link.className = "toc-link"; |
| 296 | if (e.number) { |
| 297 | const num = document.createElement("span"); |
| 298 | num.className = "toc-num"; |
| 299 | num.textContent = e.number; |
| 300 | link.appendChild(num); |
| 301 | } |
| 302 | link.appendChild(document.createTextNode(e.title)); |
| 303 | if (e.page == null) { |
| 304 | link.classList.add("dead"); |
| 305 | link.title = "not placed in this document"; |
| 306 | } else { |
| 307 | link.title = "page " + e.page; |
| 308 | link.addEventListener("click", () => jumpTo(e)); |
| 309 | } |
| 310 | row.appendChild(link); |
| 311 | li.appendChild(row); |
| 312 | parentUl.appendChild(li); |
| 313 | |
| 314 | const childUl = document.createElement("ul"); |
| 315 | childUl.className = "toc-children"; |
| 316 | li.appendChild(childUl); |
| 317 | stack.push({ level: e.level, ul: childUl, li, caret }); |
| 318 | } |
| 319 | |
| 320 | // Turn a caret into a working toggle only where the item actually has children; leave it hidden |
| 321 | // (but space-holding) otherwise, so the titles line up whether or not a heading nests others. |
| 322 | root.querySelectorAll("li.toc-item").forEach(li => { |
| 323 | const childUl = li.querySelector(":scope > ul.toc-children"); |
| 324 | const caret = li.querySelector(":scope > .toc-row > .toc-caret"); |
| 325 | if (childUl && childUl.children.length > 0) { |
| 326 | li.classList.add("has-children", "open"); |
| 327 | caret.classList.remove("empty"); |
| 328 | caret.textContent = "▾"; // a down-pointing triangle: open |
| 329 | caret.addEventListener("click", () => { |
| 330 | const open = li.classList.toggle("open"); |
| 331 | caret.textContent = open ? "▾" : "▸"; |
| 332 | }); |
| 333 | } |
| 334 | }); |
| 335 | |
| 336 | tocBody.appendChild(root); |
| 337 | setTocOpen(true); |
| 338 | } |
| 339 | |
| 340 | // Opens or closes the contents rail, keeping the toggle's pressed state and the page column's shift in |
| 341 | // step. The rail is only meaningful when a document carries an outline, so the toggle is hidden otherwise. |
| 342 | function setTocOpen(open) { |
| 343 | toc.hidden = !open; |
| 344 | document.body.classList.toggle("toc-open", open); |
| 345 | tocToggle.setAttribute("aria-pressed", open ? "true" : "false"); |
| 346 | } |
| 347 | |
| 348 | tocToggle.addEventListener("click", () => setTocOpen(toc.hidden)); |
| 349 | |
| 350 | // Renders a document already in memory as .prl text (a dropped or picked file). |
| 351 | function showText(text, name) { |
| 352 | statusEl.textContent = "rendering…"; |
| 353 | try { |
| 354 | adopt(Pearl.renderText(text, pages), name); |
| 355 | } catch (e) { |
| 356 | statusEl.textContent = "error: " + e.message; |
| 357 | console.error(e); |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | // Renders a document fetched from a URL (the sample picker). It goes through text, not |
| 362 | // `loadAndRender`, so the source is captured for the authoring splice. |
| 363 | async function show(url) { |
| 364 | statusEl.textContent = "loading…"; |
| 365 | try { |
| 366 | const res = await fetch(url, { cache: "no-store" }); |
| 367 | if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`); |
| 368 | const name = url.split("/").pop(); |
| 369 | adopt(Pearl.renderText(await res.text(), pages), name); |
| 370 | } catch (e) { |
| 371 | statusEl.textContent = "error: " + e.message; |
| 372 | console.error(e); |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | // A local file, read as text through FileReader -- no server, no upload, so any .prl on disk opens. |
| 377 | function openFile(file) { |
| 378 | if (!file) return; |
| 379 | const reader = new FileReader(); |
| 380 | reader.onload = () => showText(reader.result, file.name); |
| 381 | reader.onerror = () => { statusEl.textContent = "error: could not read " + file.name; }; |
| 382 | reader.readAsText(file); |
| 383 | } |
| 384 | |
| 385 | openBtn.addEventListener("click", () => fileInput.click()); |
| 386 | fileInput.addEventListener("change", () => openFile(fileInput.files[0])); |
| 387 | |
| 388 | // Drag-and-drop anywhere on the window opens the dropped .prl. |
| 389 | let dragDepth = 0; |
| 390 | window.addEventListener("dragenter", (e) => { e.preventDefault(); if (++dragDepth === 1) drop.classList.add("on"); }); |
| 391 | window.addEventListener("dragover", (e) => { e.preventDefault(); }); |
| 392 | window.addEventListener("dragleave", (e) => { e.preventDefault(); if (--dragDepth <= 0) { dragDepth = 0; drop.classList.remove("on"); } }); |
| 393 | window.addEventListener("drop", (e) => { |
| 394 | e.preventDefault(); |
| 395 | dragDepth = 0; drop.classList.remove("on"); |
| 396 | if (e.dataTransfer.files.length) openFile(e.dataTransfer.files[0]); |
| 397 | }); |
| 398 | |
| 399 | // A ?doc= query overrides the picker, so a headless driver can name the file. |
| 400 | const q = new URLSearchParams(location.search).get("doc"); |
| 401 | if (q) { |
| 402 | const opt = [...sel.options].find(o => o.value.endsWith(q) || o.value === q); |
| 403 | if (opt) sel.value = opt.value; |
| 404 | else { sel.insertAdjacentHTML("beforeend", `<option value="${q}" selected>${q}</option>`); } |
| 405 | } |
| 406 | sel.addEventListener("change", () => show(sel.value)); |
| 407 | |
| 408 | // Wire the authoring layer: the toggle, the author field and the save button, and re-report the count |
| 409 | // after each new annotation so the header reflects it live. |
| 410 | Authoring.init({ |
| 411 | container: pages, |
| 412 | toggle: annotateBtn, |
| 413 | authorInput: authorInput, |
| 414 | save: saveBtn, |
| 415 | onChange: () => { if (currentDoc) report(currentDoc, currentName); }, |
| 416 | }); |
| 417 | |
| 418 | show(sel.value); |
| 419 | </script> |
| 420 | </body> |
| 421 | </html> |