Oregami
Repositories/oxedyne/fe2o3

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>&#9776; Contents</button>
184 <h1>Pearl Reader <span style="font-weight:400;color:#6b7280">&mdash; .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>