Oregami
Repositories/oxedyne/fe2o3

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
16const 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
322window.Authoring = Authoring;