oxedyne/fe2o3/fe2o3_austenite/web/pearl-reader/authoring.js
11.6 KiB, 1 run
created by r1870400018:38607, 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 | // authoring.js -- in-browser annotation authoring for the Pearl reader. |
| 2 | // |
| 3 | // This closes the annotations loop the reader only read before: with "Annotate" on, a drag over a page |
| 4 | // creates a highlight or a note, it renders at once through the reader's own `Pearl.renderAnnotation`, and |
| 5 | // "Save .prl" writes the document back with the new annotations so a reload shows them. |
| 6 | // |
| 7 | // Persistence is an updated `.prl`, not a sidecar. The reader's jdat parser strips a scalar's type tag, so |
| 8 | // re-serialising the whole document from the parsed model could not know each number's tag and the Rust |
| 9 | // decoder would reject the wrong one. Annotations are the one section authored here, with every atom's tag |
| 10 | // known, so `jdat.js`'s serialiser re-emits just the `annotations` list and this layer splices it back |
| 11 | // into the original document text byte for byte -- the glyph, block and geometry bytes are never touched, |
| 12 | // and the Rust reader (`PearlDoc::annotations`) reads the result back unchanged. |
| 13 | |
| 14 | "use strict"; |
| 15 | |
| 16 | const Authoring = (() => { |
| 17 | const SP_PER_PT = 65536; |
| 18 | |
| 19 | const state = { |
| 20 | on: false, // is annotate mode engaged? |
| 21 | container: null, // the #pages element |
| 22 | doc: null, // the current parsed document model |
| 23 | name: "document.prl", |
| 24 | author: "me", |
| 25 | onChange: null, // called with the live annotation count after an edit |
| 26 | }; |
| 27 | |
| 28 | // ---- geometry: a page's on-screen box back to scaled points on that page ------------------------- |
| 29 | |
| 30 | // The points-per-pixel scale for a page's SVG: its viewBox width (points) over its rendered CSS width. |
| 31 | // It is 1 when the page is shown at natural size, and corrects for any responsive shrink. |
| 32 | function pageScale(svg) { |
| 33 | const box = svg.getBoundingClientRect(); |
| 34 | const vb = svg.viewBox && svg.viewBox.baseVal ? svg.viewBox.baseVal.width : 0; |
| 35 | if (!vb || !box.width) return 1; |
| 36 | return vb / box.width; |
| 37 | } |
| 38 | |
| 39 | // A client-space rectangle over a page converted to `[x, y, w, h]` scaled points on that page: px -> |
| 40 | // pt (x scale) -> x SP_PER_PT, the exact inverse of the reader's `sp()` render transform. |
| 41 | function rectToSp(svg, left, top, width, height) { |
| 42 | const box = svg.getBoundingClientRect(); |
| 43 | const scale = pageScale(svg); |
| 44 | const toSp = px => Math.round(px * scale * SP_PER_PT); |
| 45 | return [ |
| 46 | toSp(left - box.left), |
| 47 | toSp(top - box.top), |
| 48 | toSp(width), |
| 49 | toSp(height), |
| 50 | ]; |
| 51 | } |
| 52 | |
| 53 | // ---- drawing a selection over a page ------------------------------------------------------------- |
| 54 | |
| 55 | let drag = null; // { page, svg, overlay, startX, startY, preview } while a drag is in flight |
| 56 | |
| 57 | function onMouseDown(ev) { |
| 58 | if (!state.on || ev.button !== 0) return; |
| 59 | if (!ev.target.closest) return; |
| 60 | // A press inside the composer is the user filling the form, not the start of a new region. |
| 61 | if (ev.target.closest(".pearl-composer")) return; |
| 62 | const page = ev.target.closest(".pearl-page"); |
| 63 | if (!page) return; |
| 64 | const svg = page.querySelector("svg"); |
| 65 | const overlay = page.querySelector(".pearl-overlay"); |
| 66 | if (!svg || !overlay) return; |
| 67 | |
| 68 | ev.preventDefault(); |
| 69 | const preview = document.createElement("div"); |
| 70 | preview.className = "pearl-draw"; |
| 71 | overlay.appendChild(preview); |
| 72 | drag = { page, svg, overlay, startX: ev.clientX, startY: ev.clientY, preview }; |
| 73 | updatePreview(ev.clientX, ev.clientY); |
| 74 | |
| 75 | window.addEventListener("mousemove", onMouseMove); |
| 76 | window.addEventListener("mouseup", onMouseUp); |
| 77 | } |
| 78 | |
| 79 | function boxFrom(d, x, y) { |
| 80 | return { |
| 81 | left: Math.min(d.startX, x), |
| 82 | top: Math.min(d.startY, y), |
| 83 | width: Math.abs(x - d.startX), |
| 84 | height: Math.abs(y - d.startY), |
| 85 | }; |
| 86 | } |
| 87 | |
| 88 | function updatePreview(x, y) { |
| 89 | const ov = drag.overlay.getBoundingClientRect(); |
| 90 | const b = boxFrom(drag, x, y); |
| 91 | drag.preview.style.left = (b.left - ov.left) + "px"; |
| 92 | drag.preview.style.top = (b.top - ov.top) + "px"; |
| 93 | drag.preview.style.width = b.width + "px"; |
| 94 | drag.preview.style.height = b.height + "px"; |
| 95 | } |
| 96 | |
| 97 | function onMouseMove(ev) { |
| 98 | if (drag) updatePreview(ev.clientX, ev.clientY); |
| 99 | } |
| 100 | |
| 101 | function onMouseUp(ev) { |
| 102 | window.removeEventListener("mousemove", onMouseMove); |
| 103 | window.removeEventListener("mouseup", onMouseUp); |
| 104 | if (!drag) return; |
| 105 | const d = drag; |
| 106 | drag = null; |
| 107 | d.preview.remove(); |
| 108 | |
| 109 | const b = boxFrom(d, ev.clientX, ev.clientY); |
| 110 | // A tiny gesture is a click, not a drag: offer a note placed at that point rather than a highlight. |
| 111 | const tiny = b.width < 5 && b.height < 5; |
| 112 | openComposer(d, b, tiny); |
| 113 | } |
| 114 | |
| 115 | // ---- the composer popover ------------------------------------------------------------------------ |
| 116 | |
| 117 | let composer = null; |
| 118 | |
| 119 | function closeComposer() { |
| 120 | if (composer) { composer.remove(); composer = null; } |
| 121 | } |
| 122 | |
| 123 | // Offers kind, payload and author for the drawn region, then commits the annotation on Save. `tiny` |
| 124 | // steers the default kind to a note (a click) rather than a highlight (a drag). |
| 125 | function openComposer(d, box, tiny) { |
| 126 | closeComposer(); |
| 127 | const ov = d.overlay.getBoundingClientRect(); |
| 128 | |
| 129 | const el = document.createElement("div"); |
| 130 | el.className = "pearl-composer"; |
| 131 | el.style.left = Math.max(4, box.left - ov.left) + "px"; |
| 132 | el.style.top = (box.top - ov.top + box.height + 8) + "px"; |
| 133 | el.innerHTML = ` |
| 134 | <div class="pc-row pc-kind"> |
| 135 | <label><input type="radio" name="pc-kind" value="highlight" ${tiny ? "" : "checked"}> Highlight</label> |
| 136 | <label><input type="radio" name="pc-kind" value="note" ${tiny ? "checked" : ""}> Note</label> |
| 137 | </div> |
| 138 | <textarea class="pc-payload" rows="2" placeholder="Text (a note's words, or a highlight's label)"></textarea> |
| 139 | <div class="pc-row"> |
| 140 | <input class="pc-author" type="text" placeholder="author"> |
| 141 | <span class="pc-sp"></span> |
| 142 | <button class="pc-cancel" type="button">Cancel</button> |
| 143 | <button class="pc-save" type="button">Save</button> |
| 144 | </div>`; |
| 145 | d.overlay.appendChild(el); |
| 146 | composer = el; |
| 147 | |
| 148 | const authorInput = el.querySelector(".pc-author"); |
| 149 | authorInput.value = state.author; |
| 150 | const payload = el.querySelector(".pc-payload"); |
| 151 | payload.focus(); |
| 152 | |
| 153 | el.querySelector(".pc-cancel").addEventListener("click", closeComposer); |
| 154 | el.querySelector(".pc-save").addEventListener("click", () => { |
| 155 | const kind = el.querySelector('input[name="pc-kind"]:checked').value; |
| 156 | const author = authorInput.value.trim() || "me"; |
| 157 | state.author = author; |
| 158 | const ann = { |
| 159 | anchor: d.page.dataset.block, |
| 160 | kind, |
| 161 | payload: payload.value, |
| 162 | author, |
| 163 | created: new Date().toISOString(), |
| 164 | }; |
| 165 | // A highlight carries its region; a note lives in the margin, so it stores no rect. |
| 166 | if (kind === "highlight") { |
| 167 | ann.rect = rectToSp(d.svg, box.left, box.top, box.width, box.height); |
| 168 | } |
| 169 | commit(ann, d.overlay); |
| 170 | closeComposer(); |
| 171 | }); |
| 172 | } |
| 173 | |
| 174 | // Adds the annotation to the model and paints it at once through the reader's own render path. |
| 175 | function commit(ann, overlay) { |
| 176 | if (!state.doc.annotations) state.doc.annotations = []; |
| 177 | state.doc.annotations.push(ann); |
| 178 | if (window.Pearl && Pearl.renderAnnotation) Pearl.renderAnnotation(ann, overlay); |
| 179 | if (state.onChange) state.onChange(state.doc.annotations.length); |
| 180 | } |
| 181 | |
| 182 | // ---- persistence: re-emit the annotations section and splice it into the original text ----------- |
| 183 | |
| 184 | // One annotation as a tagged jdat value, its atom tags matching the Rust `Annotation::to_dat`: strings |
| 185 | // for the five text fields, and a rect of four `(i32|..)` scaled lengths when present. |
| 186 | function annToDat(ann) { |
| 187 | const m = { |
| 188 | anchor: ann.anchor, |
| 189 | kind: ann.kind, |
| 190 | payload: ann.payload || "", |
| 191 | author: ann.author || "me", |
| 192 | created: ann.created || "", |
| 193 | }; |
| 194 | if (ann.rect) { |
| 195 | m.rect = ann.rect.map(Jdat.i32); |
| 196 | } |
| 197 | return Jdat.omap(m); |
| 198 | } |
| 199 | |
| 200 | // The scan skips over the contents of a jdat string so a `"` or bracket inside a payload cannot fool |
| 201 | // the bracket matcher. Returns the index just past the `]` that closes the list opened at `start`. |
| 202 | function matchBracket(src, start) { |
| 203 | let i = start, depth = 0, inStr = false, esc = false; |
| 204 | while (i < src.length) { |
| 205 | const c = src[i]; |
| 206 | if (inStr) { |
| 207 | if (esc) esc = false; |
| 208 | else if (c === "\\") esc = true; |
| 209 | else if (c === '"') inStr = false; |
| 210 | } else { |
| 211 | if (c === '"') inStr = true; |
| 212 | else if (c === "[" || c === "{") depth++; |
| 213 | else if (c === "]" || c === "}") { depth--; if (depth === 0) return i + 1; } |
| 214 | } |
| 215 | i++; |
| 216 | } |
| 217 | throw new Error("authoring: unterminated list while splicing annotations"); |
| 218 | } |
| 219 | |
| 220 | // Replaces the top-level `annotations` list in `src` with `listText`, leaving every other byte as it |
| 221 | // was. The key is found at brace-depth 1 -- directly inside the top omap -- so an `"annotations"` that |
| 222 | // happened to sit inside a payload string or a nested map is never mistaken for it. |
| 223 | function spliceAnnotations(src, listText) { |
| 224 | const KEY = '"annotations"'; |
| 225 | let i = 0, depth = 0, inStr = false, esc = false; |
| 226 | while (i < src.length) { |
| 227 | const c = src[i]; |
| 228 | if (inStr) { |
| 229 | if (esc) esc = false; |
| 230 | else if (c === "\\") esc = true; |
| 231 | else if (c === '"') inStr = false; |
| 232 | i++; |
| 233 | continue; |
| 234 | } |
| 235 | if (c === '"') { |
| 236 | if (depth === 1 && src.startsWith(KEY, i)) { |
| 237 | let j = i + KEY.length; |
| 238 | while (j < src.length && /\s/.test(src[j])) j++; |
| 239 | if (src[j] === ":") { |
| 240 | j++; |
| 241 | while (j < src.length && /\s/.test(src[j])) j++; |
| 242 | if (src[j] === "[") { |
| 243 | const end = matchBracket(src, j); |
| 244 | return src.slice(0, j) + listText + src.slice(end); |
| 245 | } |
| 246 | } |
| 247 | } |
| 248 | inStr = true; i++; continue; |
| 249 | } |
| 250 | if (c === "[" || c === "{") depth++; |
| 251 | else if (c === "]" || c === "}") depth--; |
| 252 | i++; |
| 253 | } |
| 254 | // No annotations section (a file older than the field): insert one before the top omap's close. |
| 255 | const close = src.lastIndexOf("}"); |
| 256 | if (close < 0) throw new Error("authoring: no document map to splice into"); |
| 257 | return src.slice(0, close) + `, "annotations": ${listText}` + src.slice(close); |
| 258 | } |
| 259 | |
| 260 | // The current document re-serialised with its live annotations, as `.prl` text. Exposed so a headless |
| 261 | // check can read the bytes a download would write, without a real file dialog. |
| 262 | function buildUpdatedSource() { |
| 263 | if (!state.doc || !state.doc.__source) throw new Error("authoring: no document loaded"); |
| 264 | const anns = state.doc.annotations || []; |
| 265 | const listText = Jdat.encode(anns.map(annToDat)); |
| 266 | return spliceAnnotations(state.doc.__source, listText); |
| 267 | } |
| 268 | |
| 269 | function save() { |
| 270 | const text = buildUpdatedSource(); |
| 271 | const blob = new Blob([text], { type: "application/octet-stream" }); |
| 272 | const url = URL.createObjectURL(blob); |
| 273 | const a = document.createElement("a"); |
| 274 | a.href = url; |
| 275 | a.download = state.name; |
| 276 | document.body.appendChild(a); |
| 277 | a.click(); |
| 278 | a.remove(); |
| 279 | setTimeout(() => URL.revokeObjectURL(url), 1000); |
| 280 | } |
| 281 | |
| 282 | // ---- wiring -------------------------------------------------------------------------------------- |
| 283 | |
| 284 | function setMode(on) { |
| 285 | state.on = on; |
| 286 | if (state.container) state.container.classList.toggle("annotating", on); |
| 287 | if (!on) closeComposer(); |
| 288 | } |
| 289 | |
| 290 | function setDocument(doc, name) { |
| 291 | state.doc = doc; |
| 292 | if (name) state.name = name; |
| 293 | } |
| 294 | |
| 295 | function init(opts) { |
| 296 | state.container = opts.container; |
| 297 | state.onChange = opts.onChange || null; |
| 298 | state.author = (opts.authorInput && opts.authorInput.value) || "me"; |
| 299 | |
| 300 | if (opts.authorInput) { |
| 301 | opts.authorInput.value = state.author; |
| 302 | opts.authorInput.addEventListener("input", () => { |
| 303 | state.author = opts.authorInput.value.trim() || "me"; |
| 304 | }); |
| 305 | } |
| 306 | if (opts.toggle) { |
| 307 | opts.toggle.addEventListener("click", () => { |
| 308 | setMode(!state.on); |
| 309 | opts.toggle.classList.toggle("on", state.on); |
| 310 | opts.toggle.textContent = state.on ? "Annotating…" : "Annotate"; |
| 311 | }); |
| 312 | } |
| 313 | if (opts.save) opts.save.addEventListener("click", save); |
| 314 | |
| 315 | state.container.addEventListener("mousedown", onMouseDown); |
| 316 | } |
| 317 | |
| 318 | return { init, setDocument, setMode, save, buildUpdatedSource, |
| 319 | get annotations() { return (state.doc && state.doc.annotations) || []; } }; |
| 320 | })(); |
| 321 | |
| 322 | window.Authoring = Authoring; |