oxedyne/daimond/www/js/typstwatch.js
111 KiB, 1 run
created by r2519314175:1465, 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 | /* ============================================================ |
| 2 | Daimond — the watched live document |
| 3 | ------------------------------------------------------------ |
| 4 | `typst watch` and a document viewer, inside the page. The |
| 5 | author edits a `.typ` — or a daimon does — and the pages he is |
| 6 | looking at become the new ones, in place, without him asking. |
| 7 | |
| 8 | window.DaimondTypstWatch = { |
| 9 | began, stop, touched, rebuild, budgetMB, zoom, dark, |
| 10 | state, pageBox, goToPage, rail, sections, fitPage |
| 11 | } |
| 12 | |
| 13 | Nine things decide the shape of this file, and each of them is |
| 14 | a measurement rather than a preference. The measurements are |
| 15 | in `dev/TYPST_WATCH.md` and in `dev/probe_typstsvg.mjs`; what |
| 16 | follows is what they cost. |
| 17 | |
| 18 | 1. THE PAGES ARE DRAWN HERE, AND ARE NOT A PDF. |
| 19 | On the author's 281-page book, on one warm compiler and one |
| 20 | layout, `compile → 'pdf'` is 1834-2069 ms and |
| 21 | `compile → 'vector'` is 235-272 ms. Writing the document |
| 22 | out costs about eight times what laying it out does, and the |
| 23 | reader is looking at a screen, not at a file. So the live |
| 24 | view asks for the layout and draws it, and the PDF stays |
| 25 | exactly where it was: the Compile button still writes one, |
| 26 | `file_show` still opens one, export is untouched. |
| 27 | |
| 28 | The second reason is position. Chrome's PDF viewer answers |
| 29 | NOTHING through an `<embed>` — no `contentDocument`, no |
| 30 | `contentWindow`, no reply to a `postMessage` — so where the |
| 31 | reader has scrolled to cannot be read back, only set. These |
| 32 | pages are ours, so their scroll is ours, and "put it back |
| 33 | where he was" stops being a guess. |
| 34 | |
| 35 | 2. IT IS THE SAME COMPILER, THE SAME FONTS AND THE SAME LAYOUT |
| 36 | as the PDF, and that is checked rather than asserted. |
| 37 | `dev/verify_typstwatch.mjs` compiles one page both ways, |
| 38 | rasterises the PDF with poppler — which has nothing to do |
| 39 | with typst — rasterises these pages in the browser, and |
| 40 | compares the ink. Anti-aliasing differs. Nothing else may. |
| 41 | |
| 42 | 3. A REBUILD MUST NEVER BLANK THE VIEW. The new pages are |
| 43 | built in a detached node and swapped in one operation, with |
| 44 | the scroll restored in the same turn. There is no moment at |
| 45 | which the host holds nothing: the old document stays on |
| 46 | screen, at the reader's place, while the new one is compiled |
| 47 | and parsed, and a failed compile leaves it there and puts |
| 48 | the compiler's own words underneath. |
| 49 | |
| 50 | 3b. AND ONLY THE PAGES IN VIEW ARE EVER DRAWN. The whole book |
| 51 | at once costs seven seconds of DOM on a 281-page document |
| 52 | and, drawn as one element the height of the book, comes out |
| 53 | a pale wash from about page forty. Neither is the |
| 54 | compiler's fault and neither is visible from the code. The |
| 55 | three measurements that settle it are at "Drawing" below; |
| 56 | they are the reason this file is as long as it is. |
| 57 | |
| 58 | 4. CROSSING THE WASM HEAP CEILING BRICKS THE COMPILER. |
| 59 | Measured: the heap climbed to 3171 MB, the call trapped, and |
| 60 | EVERY SUBSEQUENT COMPILE failed with `recursive use of an |
| 61 | object detected which would lead to unsafe aliasing in rust` |
| 62 | — a wasm-bindgen borrow left held by the trapped call — and |
| 63 | the memory was never returned. Only a reload recovers. A |
| 64 | one-shot button hides this because a person who sees one |
| 65 | failure reloads. A LOOP DOES NOT: it will eventually fire on |
| 66 | a document that is too big and the compiler is dead from |
| 67 | then on. So this budgets the heap and refuses to START a |
| 68 | rebuild that might not fit, falling back to a Rebuild button |
| 69 | the user presses knowingly. See `holdCheck` below. |
| 70 | |
| 71 | 5. THE COMPILER IS MEMOISED IN `typst.js` AND THAT IS WHY THIS |
| 72 | WORKS AT ALL. One `TypstCompiler` for the life of the page, |
| 73 | whose comemo cache survives `reset_shadow()`, is the whole |
| 74 | difference between 0.4 s and 3 s per rebuild. The note is at |
| 75 | the top of `typst.js`, where the memo is. |
| 76 | |
| 77 | VENDORED, NOT FETCHED. The renderer is |
| 78 | `typst_ts_renderer_bg.wasm`, from `@myriaddreamin/typst-ts- |
| 79 | renderer@0.7.0`, sitting beside the compiler it came out with: |
| 80 | the compiler here is byte-identical to |
| 81 | `@myriaddreamin/typst-ts-web-compiler@0.7.0`, and both wasm |
| 82 | modules carry the SAME typst checkout in their own bytes — |
| 83 | `checkouts/typst-a1cd3ade704ca26e/951788c`, typst 0.14.2 with |
| 84 | typst-assets-0.14.2. That is a stronger pairing than matching |
| 85 | version numbers: it is the same typst source tree, read out of |
| 86 | the binaries rather than off a filename. Nothing is fetched at |
| 87 | run time and nothing leaves the origin. |
| 88 | |
| 89 | SECURITY. The renderer returns markup, and markup goes into |
| 90 | this origin's DOM. Three things follow, and none is optional. The |
| 91 | `<script>` typst.ts embeds for its own hover and link behaviour |
| 92 | is CUT OUT of the string before it is parsed — it is not wanted, |
| 93 | and a document an agent wrote after reading a web page is not |
| 94 | something to run scripts from. The `onclick=` attributes that |
| 95 | script's helpers were the target of go with it, for the same |
| 96 | reason and because they now name a function nobody defines. And |
| 97 | the pages live in a SHADOW ROOT, because the stylesheet typst.ts |
| 98 | emits carries a bare `svg { fill: none; }` rule that would |
| 99 | otherwise blank every icon in the app, and because the app's own |
| 100 | CSS must not reach in and change what the book looks like. |
| 101 | |
| 102 | 6. AND THE STYLESHEET HAS TO BE PUT BACK, because `render_in_window` |
| 103 | DOES NOT EMIT IT. Measured, `dev/probe_typstsvg.mjs`: the whole |
| 104 | document through `svg_data` carries a 1.4 KB `<style>`; the same |
| 105 | document through `render_in_window` carries `<style></style>`, |
| 106 | empty. Everything in that sheet that MATTERS is therefore |
| 107 | missing from every page this view has ever drawn, and three |
| 108 | reported faults are the one omission: |
| 109 | |
| 110 | * typst.ts lays an INVISIBLE TEXT LAYER over the glyph |
| 111 | outlines — a `<foreignObject>` per run holding a |
| 112 | `div.tsel` at `font-size: 62px` — so a reader can select |
| 113 | and search. `.tsel { color: transparent }` is the only |
| 114 | thing making it invisible. Without it every line is drawn |
| 115 | twice: GHOSTED, DOUBLE-STRUCK TEXT. |
| 116 | * every link becomes `<rect class="pseudo-link">`, and an SVG |
| 117 | rect with no fill is BLACK. `.pseudo-link { fill: |
| 118 | transparent }` is the only thing making it not. Without it |
| 119 | a contents entry is a SOLID BLACK BAR the width of the |
| 120 | line and an inline `#link` is a FILLED BLACK BOX. |
| 121 | |
| 122 | So the rules are adopted into the shadow root once, at mount, |
| 123 | where `replaceChildren` cannot take them away again. Taken |
| 124 | from typst.ts's own sheet — `TYPST_CSS` below says which parts |
| 125 | and why the rest is left out. |
| 126 | |
| 127 | 7. A DOCUMENT IS A STACK OF SHEETS, NOT A SCROLL. The first |
| 128 | version drew the visible band as ONE SVG, so a page ran into |
| 129 | the next with nothing between them and the author said so: |
| 130 | "shows as a continuous page, not distinct pages". Each page |
| 131 | is now its own sheet — its own SVG, its own white paper, its |
| 132 | own shadow — with `PAGE_GAP` points of the panel's ground |
| 133 | between them, which is what Chrome's PDF viewer does and what |
| 134 | a reader expects of a document. |
| 135 | |
| 136 | It costs LESS than what it replaced, because the band is |
| 137 | still ONE session and ONE call — the sheets are cut out of the |
| 138 | one answer afterwards, since the renderer hands back a |
| 139 | `<g class="typst-page">` per page and says in its transform |
| 140 | where each sits. A call PER PAGE was tried first and is the |
| 141 | trap: the session's diff is by CONTENT, not by window, so four |
| 142 | pages carrying the same `#lorem(60)` came back as 428, 5, 7 |
| 143 | and 5 marks and three quarters of the document was simply |
| 144 | missing. `dev/TYPST_WATCH.md` §12 has that table and the two |
| 145 | other things the renderer does not say. |
| 146 | |
| 147 | The glyph outlines are emitted once, into the first sheet, and |
| 148 | the later sheets `<use>` them — which works because every |
| 149 | sheet of one band goes into the one shadow root in the one |
| 150 | `replaceChildren`, so an `href="#g…"` never points outside the |
| 151 | tree it is in. The band is replaced whole or not at all, so a |
| 152 | def can never outlive its user. |
| 153 | |
| 154 | 8. THE SECTION RAIL IS THE COMPILER'S ANSWER, THE PAGE IS THE |
| 155 | RENDERER'S. The entries — the words, the level and the order |
| 156 | — come from `query('heading')` on the compiled document, which |
| 157 | is exact and costs 6 ms on the fixture here and 9-14 ms on the |
| 158 | author's 281-page book. THE PAGE CANNOT COME FROM THERE. |
| 159 | Measured on this vendored 0.14.2: `query('heading', 'location')` |
| 160 | and `query('heading', 'page')` both answer `[]`, and so does |
| 161 | the CLI (`dev/TYPST_WATCH.md` §6 tried it first). Typst does |
| 162 | not put an element's location among its fields. |
| 163 | |
| 164 | So each heading is found in the LAID-OUT pages instead, by its |
| 165 | own words, page by page, in document order — see `locate`. It |
| 166 | runs only while the rail is open, it renders and never |
| 167 | compiles, and it yields every `SCAN_CHUNK` pages so a long |
| 168 | book fills the rail in rather than freezing it. |
| 169 | |
| 170 | 9. THE RENDERER'S COORDINATES ARE NOT OURS, AND THE DIFFERENCE |
| 171 | ACCUMULATES. `S.tops` is the exact running sum of the page |
| 172 | heights the compiler reports; typst.ts places each page at a |
| 173 | running sum of those heights ROUNDED TO A WHOLE POINT. On a |
| 174 | 160 mm page — 453.5433 pt — that is 0.4567 pt a page and it |
| 175 | never comes back: 11 pt by page 26, 45 pt by page 100, 105 pt |
| 176 | by page 230. Cropping a sheet at `tops` therefore left the |
| 177 | paper exactly where it belonged and slid the INK down inside |
| 178 | it, which is what the author reported as a gradual violation |
| 179 | of the margins. Every page's own origin comes back in its |
| 180 | group's `transform`, so `S.rtops` keeps what the renderer |
| 181 | said and the sheets are cropped, and the windows asked, in |
| 182 | the renderer's own numbers. THE SEQUENCE NAMES THE PAGE AND |
| 183 | THE TRANSFORM PLACES IT — never the other way round. |
| 184 | ============================================================ */ |
| 185 | |
| 186 | const VENDOR = new URL('../vendor/typst/', import.meta.url); |
| 187 | const R_GLUE = new URL('typst_ts_renderer.mjs', VENDOR); |
| 188 | const R_WASM = new URL('typst_ts_renderer_bg.wasm', VENDOR); |
| 189 | const PKG = new URL('../pkg/oxedyne_daimond.js', import.meta.url); |
| 190 | |
| 191 | /// The app's string for `k`, or `en` where the catalogue has none yet. |
| 192 | /// |
| 193 | /// Called `tOr` on purpose and not as a preference: `dev/i18nfallback.mjs` finds the |
| 194 | /// English written beside a key by looking for `tOr(`, `tf(` and `tr(`, so a helper |
| 195 | /// called anything else keeps its sentences OUT of the check that holds them to the |
| 196 | /// catalogue — and a fallback nobody checks is a sentence free to drift from the one |
| 197 | /// the reader is supposed to see. Thirty-nine of them drifted in one afternoon once. |
| 198 | function tOr(k, en, v) { |
| 199 | const s = window.DaimondI18n ? window.DaimondI18n.t(k, v) : null; |
| 200 | if (s == null || s === k) { |
| 201 | return String(en).replace(/\{(\w+)\}/g, (whole, n) => (v && v[n] != null) ? String(v[n]) : whole); |
| 202 | } |
| 203 | return s; |
| 204 | } |
| 205 | |
| 206 | // ── How long to wait, and why that long ───────────────────────────────────── |
| 207 | // |
| 208 | // A debounce longer than the rebuild is time the reader spends waiting for |
| 209 | // nothing; one shorter than the rebuild queues work that is thrown away when the |
| 210 | // next keystroke lands. So the wait is THE LAST REBUILD'S OWN DURATION, clamped, |
| 211 | // which is the only figure that is right for both of the author's books at once: |
| 212 | // the 281-page one rebuilds in about 0.4 s and the 665-page one in about 1.2 s, |
| 213 | // and no single constant serves both. |
| 214 | // |
| 215 | // The floor is 400 ms because that is what the smaller book costs and there is no |
| 216 | // point waiting less than a rebuild. The ceiling is 2 s because past that the |
| 217 | // preview stops feeling connected to the typing, and a book that slow is better |
| 218 | // served by pressing Rebuild than by a loop that fires every two seconds. |
| 219 | |
| 220 | /// The shortest wait after the last write before rebuilding. |
| 221 | const DEBOUNCE_MIN = 400; |
| 222 | |
| 223 | /// The longest, however slow the last rebuild was. |
| 224 | const DEBOUNCE_MAX = 2000; |
| 225 | |
| 226 | /// How often the watched files are asked whether they have changed. |
| 227 | /// |
| 228 | /// Sixty-three files answered in 13 ms, three runs — so a second between polls is |
| 229 | /// about one part in eighty of one core, and the loop feels immediate. The File |
| 230 | /// System Access API has no change events, so for a real folder this is the only |
| 231 | /// mechanism there is; a writer that KNOWS it wrote calls `touched` and skips it. |
| 232 | const POLL_MS = 1000; |
| 233 | |
| 234 | /// The wasm heap, in MB, above which no further rebuild is started. |
| 235 | /// |
| 236 | /// The wall is about 4 GB — a module with no declared maximum grew to 4081 MB and |
| 237 | /// then refused — and one over-large document past it leaves the compiler dead for |
| 238 | /// the life of the page. 2500 MB leaves room for the largest single document |
| 239 | /// measured here and stays a whole gigabyte clear. |
| 240 | const BUDGET_DEFAULT = 2500; |
| 241 | |
| 242 | /// The least headroom assumed for the next rebuild before any has been measured. |
| 243 | const HEADROOM_MIN = 128; |
| 244 | |
| 245 | /// What a trapped compiler says. Either of these means the wasm-bindgen borrow is |
| 246 | /// held and nothing short of a reload will free it. |
| 247 | const TRAPPED = /recursive use of an object|unreachable/i; |
| 248 | |
| 249 | /// How many rebuilds may run in a row on a change nothing could confirm before the |
| 250 | /// loop stops and says so. |
| 251 | /// |
| 252 | /// Everything a page can read back is checked against its own contents, so this is |
| 253 | /// the backstop for the one case that cannot be: a file over `DIGEST_MAX` reporting |
| 254 | /// that it moved, again and again. Three of those in a row is a fault to report |
| 255 | /// rather than a state to sit in, because the heap ceiling is what comes next. |
| 256 | const SPIN_MAX = 3; |
| 257 | |
| 258 | /// The space left between two sheets of paper, in DOCUMENT POINTS. |
| 259 | /// |
| 260 | /// In points rather than pixels so that it is part of the same geometry as |
| 261 | /// everything else: the reader's place is a page and an offset in points, and a gap |
| 262 | /// measured in pixels would make that arithmetic depend on the zoom. Twelve points |
| 263 | /// is about four millimetres at 100% — enough that the eye reads two sheets rather |
| 264 | /// than one long one, and little enough that a page turn is not a journey. |
| 265 | const PAGE_GAP = 12; |
| 266 | |
| 267 | /// How many pages either side of the visible ones are drawn. |
| 268 | /// |
| 269 | /// FOUR, BECAUSE THE COST OF A BAND IS THE SESSION AND NOT THE PAGES IN IT. A render |
| 270 | /// builds a fresh session from the whole artifact — 27-32 ms on the author's 281-page |
| 271 | /// book — and then answers a page in well under a millisecond, so nine pages cost |
| 272 | /// about what three did. What one margin page bought was a redraw every second page |
| 273 | /// of scrolling, and every one of those redraws was the whole 30 ms; four buys a |
| 274 | /// redraw every fifth page, which an ordinary reader crossing a page never reaches at |
| 275 | /// all. |
| 276 | /// |
| 277 | /// It is not raised further because the band is also what is held in the DOM, and a |
| 278 | /// document is read a page at a time: past a few pages either side the sheets are |
| 279 | /// memory nobody is looking at. |
| 280 | const MARGIN_PAGES = 4; |
| 281 | |
| 282 | /// How long the scroll must be still before the band under it is redrawn, in ms. |
| 283 | /// |
| 284 | /// A BAND IS NEVER BUILT INSIDE THE SCROLL FRAME. It used to be, and that is what the |
| 285 | /// author felt as "very slow and laboured": a dragged scrollbar fails the band's own |
| 286 | /// guard on EVERY frame, so every frame built a session, rendered, parsed and swapped |
| 287 | /// the whole band — and then threw those sheets away when the next frame did it |
| 288 | /// again. A sheet that arrives a tenth of a second late is not noticeable; a frame |
| 289 | /// that is blocked for thirty milliseconds is the only thing that is. |
| 290 | /// |
| 291 | /// Long enough that a fling costs one render rather than one per frame, and shorter |
| 292 | /// than the eye takes to settle on a page it has landed on. |
| 293 | const SETTLE_MS = 120; |
| 294 | |
| 295 | /// How far inside its own edges a page is asked for, in points. |
| 296 | /// |
| 297 | /// `render_in_window` TAKES A CLOSED RECTANGLE, which cost an afternoon: asking for |
| 298 | /// exactly `[top, top + height]` returns the page whose top is exactly at `hi` as |
| 299 | /// well, and since the session answers with a DIFF, the next page's call then had |
| 300 | /// nothing left to give. Every sheet drew the page after it and the last one drew |
| 301 | /// nothing — a whole document off by one, with a blank sheet at the end. |
| 302 | /// |
| 303 | /// A twentieth of a point is a fiftieth of a millimetre, which is smaller than the |
| 304 | /// resolution of anything that will ever be printed, and it is comfortably above the |
| 305 | /// f32 rounding at the foot of a 281-page book. |
| 306 | const PAGE_INSET = 0.05; |
| 307 | |
| 308 | /// How many pages the outline scan lays out before it hands the frame back. |
| 309 | /// |
| 310 | /// THIRTY-TWO, BECAUSE THE CHUNK IS A SESSION AND THE SESSION IS THE COST. A page |
| 311 | /// answers in well under a millisecond once the first one has emitted the glyphs, but |
| 312 | /// the session in front of it costs 27-32 ms on the author's 281-page book — so eight |
| 313 | /// pages a chunk spent thirty-six sessions and better than a second of session |
| 314 | /// building on that book, in a task of at least thirty milliseconds in EVERY frame |
| 315 | /// while the rail was open. Thirty-two pages is nine sessions for the same book, and |
| 316 | /// what the chunk holds is still under about three megabytes of markup where the |
| 317 | /// whole document is 23.7 MB. |
| 318 | const SCAN_CHUNK = 32; |
| 319 | |
| 320 | /// How long the build loop must be quiet before the outline scan starts, in ms. |
| 321 | /// |
| 322 | /// The scan reads every page of the document, and every good build makes the pages it |
| 323 | /// read the wrong ones — `refreshToc` asks for it again, and on a book being typed in |
| 324 | /// that is a walk of the whole document abandoned and restarted for every keystroke |
| 325 | /// that compiles. So it waits for the typing to stop, which is the same reason the |
| 326 | /// rebuild itself is debounced, and a rail entry that fills in a moment after the |
| 327 | /// pages costs the reader nothing. |
| 328 | const SCAN_IDLE = 800; |
| 329 | |
| 330 | // ── Where the last good pages live, and what bounds them ──────────────────── |
| 331 | // |
| 332 | // A failed build keeps the document that was on screen, so something has to be |
| 333 | // holding pages that the current source can no longer produce. Two things are, |
| 334 | // and neither of them is the compiler's heap. |
| 335 | // |
| 336 | // * The MARKS are an SVG in the shadow root — ordinary browser DOM, reclaimed |
| 337 | // when it is replaced, and never more than about three screens of it because |
| 338 | // only the band in view is drawn. |
| 339 | // * The DOCUMENT is the VECTOR ARTIFACT, kept as bytes on the JavaScript heap: |
| 340 | // 11.1 MB for the author's 281-page book, one document's worth, replaced |
| 341 | // whole by the next good build. It is kept because a scroll has to be able to |
| 342 | // draw a page the current source may no longer produce. |
| 343 | // |
| 344 | // NO RENDER SESSION IS HELD BETWEEN DRAWS. Each render builds one from those bytes, |
| 345 | // uses it and frees it, which is forced by `render_in_window` being a diff (see |
| 346 | // below) and is the right thing anyway: the renderer's wasm heap is a SECOND |
| 347 | // `WebAssembly.Memory`, in a second module, and it never shrinks either. Holding |
| 348 | // nothing between draws settles it at one document's high-water mark instead of |
| 349 | // letting it climb with the session. It also means a preview that grew unboundedly |
| 350 | // still could not brick the COMPILER, which is the failure worth engineering |
| 351 | // against. `state().rheap` reports it, so it is measured rather than assumed. |
| 352 | let mod = null; // the wasm package namespace, imported once |
| 353 | let renderer = null; // the typst.ts renderer, built lazily |
| 354 | let vec = null; // the vector artifact on screen, kept so a scroll can draw |
| 355 | let rinit = null; // the renderer's wasm exports, for `rheap` |
| 356 | |
| 357 | /// Everything the loop knows, in one object so `state()` cannot drift from it. |
| 358 | const S = { |
| 359 | path: '', // the `.typ` being watched, or '' when idle |
| 360 | files: [], // every real path the last gather read |
| 361 | stamps: null, // what those files said last time, by path; null before any |
| 362 | mode: 'idle', // idle | live | held | dead |
| 363 | builds: 0, // rebuilds STARTED since `began` |
| 364 | drawn: 0, // rebuilds that reached the screen |
| 365 | failed: 0, // rebuilds the compiler refused |
| 366 | debounce: DEBOUNCE_MIN, |
| 367 | budget: BUDGET_DEFAULT, |
| 368 | headroom: HEADROOM_MIN, // the biggest heap growth one rebuild has cost |
| 369 | building: false, |
| 370 | queued: false, |
| 371 | seen: false, // the panel has been on screen at least once |
| 372 | error: '', // the compiler's own words, while a build is broken |
| 373 | reason: '', // why the loop is held or dead |
| 374 | cause: '', // 'write' or 'poll' or 'user': what asked for the rebuild |
| 375 | why: '', // and which file it named, for a report from the field |
| 376 | digests: {}, // what each watched file said, the last time it was read |
| 377 | blind: false, // the rebuild running now could not be confirmed from contents |
| 378 | same: 0, // unconfirmable rebuilds in a row, which is a spin nobody sees |
| 379 | bandErr: '', // why the last band render threw, so a swallowed one is visible |
| 380 | pages: 0, |
| 381 | scale: 1, // rendered px per pt, for putting the scroll back |
| 382 | docW: 0, // the document's own width, in points |
| 383 | docH: 0, // and its whole height, which the scroller is as tall as |
| 384 | tops: [], // each page's top IN THE DOCUMENT, in pt: the exact sum |
| 385 | rtops: [], // and where the RENDERER puts it, which is not the same number |
| 386 | drift: 0, // the renderer's own total height less the exact sum, in pt |
| 387 | heights: [], // each page's height, in pt |
| 388 | lays: [], // each page's top ON SCREEN, in pt, gaps included |
| 389 | laid: 0, // the whole stack's height on screen, in pt |
| 390 | zoom: 1, // how much bigger than fitting the panel's width |
| 391 | fit: 'width', // 'width' or 'page': what the fit button last did |
| 392 | dark: false, // the paper turned over, for reading at night |
| 393 | rail: false, // the section rail is open |
| 394 | toc: [], // { text, level, page } per heading; page 0 = not found yet |
| 395 | scanned: 0, // the build serial the pages in `toc` were found in |
| 396 | }; |
| 397 | |
| 398 | let timer = null; // the debounce |
| 399 | let poller = null; // the interval that asks the files |
| 400 | let host = null; // the live view's root element |
| 401 | |
| 402 | /// The wasm package, imported once. |
| 403 | async function wasm() { |
| 404 | if (!mod) mod = await import(PKG.href); |
| 405 | return mod; |
| 406 | } |
| 407 | |
| 408 | /// The renderer, built once, lazily. |
| 409 | /// |
| 410 | /// A megabyte of wasm that a reader who never compiles anything should not pay |
| 411 | /// for, so it is not touched until the first live view is drawn. |
| 412 | async function getRenderer() { |
| 413 | if (renderer) return renderer; |
| 414 | const g = await import(R_GLUE.href); |
| 415 | rinit = await g.default(R_WASM.href); |
| 416 | renderer = await new g.TypstRendererBuilder().build(); |
| 417 | return renderer; |
| 418 | } |
| 419 | |
| 420 | /// The RENDERER's wasm heap in MB, which is not the compiler's. |
| 421 | /// |
| 422 | /// Two modules, two `WebAssembly.Memory` objects, two ceilings. Worth reading |
| 423 | /// separately: pages held for a failed build are held HERE, and confusing the two |
| 424 | /// would have the budget guarding the wrong heap. |
| 425 | function rheapMB() { |
| 426 | if (!rinit || !rinit.memory) return 0; |
| 427 | return rinit.memory.buffer.byteLength / 1048576; |
| 428 | } |
| 429 | |
| 430 | |
| 431 | /// The part of typst.ts's own stylesheet a windowed render needs and does not get. |
| 432 | /// |
| 433 | /// Verbatim from the sheet `svg_data` emits, which is a string literal in |
| 434 | /// `typst_ts_renderer_bg.wasm` and can be read out of it — `dev/probe_typstsvg.mjs` |
| 435 | /// prints both. Only the rules that decide what is ON THE PAGE are here, and the |
| 436 | /// three that are left out are left out on purpose: |
| 437 | /// |
| 438 | /// * `.typst-text { pointer-events: bounding-box }` and `.hover .typst-text` are |
| 439 | /// for the hover highlight the embedded `<script>` drives, and that script is |
| 440 | /// cut out before anything parses it. |
| 441 | /// * `.outline_glyph { fill: var(--glyph_fill) }` WOULD BLANK THE BOOK. A CSS |
| 442 | /// declaration beats a presentation attribute, so that rule overrides the |
| 443 | /// `fill="#000"` typst.ts puts on every text group; typst.ts's own host page |
| 444 | /// defines `--glyph_fill`, and an undefined `var()` on an inherited property |
| 445 | /// falls back to the inherited value, which the same sheet sets to `none`. |
| 446 | /// Leaving both out keeps each run the colour the compiler gave it, which is |
| 447 | /// also the only way a coloured heading stays coloured. |
| 448 | /// * `svg { fill: none }` goes with it, for the same reason: it is that rule the |
| 449 | /// glyph rule exists to undo. |
| 450 | /// |
| 451 | /// `.pseudo-link` is `pointer-events: none` rather than typst.ts's `all`. Its |
| 452 | /// anchors are `xlink:href="#"` plus an `onclick` naming a function this page does |
| 453 | /// not have, and the external ones open a window from a document a daimon may have |
| 454 | /// written. Inert matches the decision that cut the script out. |
| 455 | const TYPST_CSS = |
| 456 | '.tsel span,\n' |
| 457 | + '.tsel {\n' |
| 458 | + ' left: 0;\n' |
| 459 | + ' position: fixed;\n' |
| 460 | + ' text-align: justify;\n' |
| 461 | + ' white-space: nowrap;\n' |
| 462 | + ' width: 100%;\n' |
| 463 | + ' height: 100%;\n' |
| 464 | + ' text-align-last: justify;\n' |
| 465 | + ' color: transparent;\n' |
| 466 | + ' white-space: pre;\n' |
| 467 | + '}\n' |
| 468 | + '.tsel span::-moz-selection,\n' |
| 469 | + '.tsel::-moz-selection {\n' |
| 470 | + ' color: transparent;\n' |
| 471 | + ' background: #7db9dea0;\n' |
| 472 | + '}\n' |
| 473 | + '.tsel span::selection,\n' |
| 474 | + '.tsel::selection {\n' |
| 475 | + ' color: transparent;\n' |
| 476 | + ' background: #7db9dea0;\n' |
| 477 | + '}\n' |
| 478 | + '.pseudo-link {\n' |
| 479 | + ' fill: transparent;\n' |
| 480 | + ' pointer-events: none;\n' |
| 481 | + '}\n'; |
| 482 | |
| 483 | /// What makes a page look like a sheet of paper, inside the shadow root. |
| 484 | /// |
| 485 | /// It lives HERE and not in `www/css/viewer.css` for the same reason the rules above |
| 486 | /// do: the pages are in a shadow root, and the app's own stylesheet does not reach |
| 487 | /// into one. The rest of the live view — the bar, the rail, the scroller — is in |
| 488 | /// `viewer.css` where it belongs. |
| 489 | /// |
| 490 | /// The sheet carries the paper and the shadow; the SVG inside it carries only the |
| 491 | /// marks. Keeping the white on the CONTAINER rather than on the SVG is what makes a |
| 492 | /// page that failed to draw look like a blank sheet instead of a hole. |
| 493 | const SHEET_CSS = |
| 494 | '.tl-band { position: absolute; inset: 0; }\n' |
| 495 | + '.tl-sheet {\n' |
| 496 | + ' position: absolute;\n' |
| 497 | + ' left: 0;\n' |
| 498 | + ' background: #fff;\n' |
| 499 | + ' box-shadow: 0 1px 5px rgba(0, 0, 0, 0.35);\n' |
| 500 | + ' overflow: hidden;\n' |
| 501 | + '}\n' |
| 502 | + '.tl-sheet > svg { display: block; }\n'; |
| 503 | |
| 504 | let sheetEl = null; // the fallback <style>, where sheets cannot be adopted |
| 505 | |
| 506 | /// Put `TYPST_CSS` into `root` so that `replaceChildren` cannot remove it. |
| 507 | /// |
| 508 | /// `adoptedStyleSheets` is not a child, so it survives the swap that puts each new |
| 509 | /// band up; where it is missing the same rules go in as a `<style>` element that |
| 510 | /// `paint` re-inserts alongside the pages. Either way the rules are in place BEFORE |
| 511 | /// the first band is drawn, because a first frame of black bars is still a frame of |
| 512 | /// black bars. |
| 513 | function adopt(root) { |
| 514 | try { |
| 515 | const s = new CSSStyleSheet(); |
| 516 | s.replaceSync(TYPST_CSS + SHEET_CSS); |
| 517 | root.adoptedStyleSheets = [s]; |
| 518 | sheetEl = null; |
| 519 | return; |
| 520 | } catch (e) { /* an engine without constructable sheets */ } |
| 521 | sheetEl = document.createElement('style'); |
| 522 | sheetEl.textContent = TYPST_CSS + SHEET_CSS; |
| 523 | } |
| 524 | |
| 525 | /// Put `node` on screen as the whole of the pages, rules included. |
| 526 | /// |
| 527 | /// The one place the shadow root's children are replaced, so the fallback sheet |
| 528 | /// cannot be forgotten by a caller that swaps the pages some other way. |
| 529 | function paint(node) { |
| 530 | const pages = host && host.querySelector('.tl-pages'); |
| 531 | if (!pages) return; |
| 532 | if (sheetEl) pages.shadowRoot.replaceChildren(sheetEl, node); |
| 533 | else pages.shadowRoot.replaceChildren(node); |
| 534 | } |
| 535 | |
| 536 | |
| 537 | // ── The view ──────────────────────────────────────────────────────────────── |
| 538 | |
| 539 | /// The Preview panel's other two renderings, and the display they had before the |
| 540 | /// live view stood in for them. |
| 541 | /// |
| 542 | /// The panel holds an `<embed>` for a compiled PDF and `#pv-view` for anything the |
| 543 | /// file viewer draws; whichever is showing, the other is hidden. The live view is a |
| 544 | /// THIRD rendering of the same panel, so it takes its turn rather than sitting over |
| 545 | /// the top — a sheet over the panel covered its own header, and with it the ✕ that |
| 546 | /// closes it. |
| 547 | /// |
| 548 | /// THE SOURCE IS NOT ONE OF THEM ANY MORE, and that is the whole reason the preview |
| 549 | /// was split out of the Doc panel. While the two shared a panel, opening the `.typ` |
| 550 | /// that this loop is FOLLOWING put the editor over the pages the loop was rebuilding |
| 551 | /// — the panel had to decide whose turn it was, and every fix for it broke the other |
| 552 | /// case. Now the source sits in the Doc panel and the pages sit here, side by side, |
| 553 | /// and there is no turn to take. |
| 554 | let stood = []; // [element, previous inline display] |
| 555 | |
| 556 | /// Build the live view's elements, or return the ones already there. |
| 557 | /// |
| 558 | /// It sits INSIDE the Preview panel, in the same column as the `<embed>` and the |
| 559 | /// viewer's own host, and takes nothing away: closing it puts them back exactly as |
| 560 | /// they were, down to the inline `display` each one carried. |
| 561 | function mount() { |
| 562 | if (host && host.isConnected) return host; |
| 563 | const panel = document.getElementById('panel-preview'); |
| 564 | if (!panel) return null; |
| 565 | host = document.createElement('div'); |
| 566 | host.className = 'typst-live'; |
| 567 | host.id = 'typst-live'; |
| 568 | host.innerHTML = |
| 569 | '<div class="tl-bar">' |
| 570 | + '<button type="button" class="tl-toc" aria-pressed="false" aria-controls="tl-rail">' |
| 571 | + '\u2261</button>' |
| 572 | + '<span class="tl-mark" aria-hidden="true"></span>' |
| 573 | + '<span class="tl-says" role="status" aria-live="polite"></span>' |
| 574 | + '<button type="button" class="tl-rebuild" style="display:none"></button>' |
| 575 | + '<span class="tl-set">' |
| 576 | + '<input class="tl-page" type="text" inputmode="numeric" autocomplete="off" size="3">' |
| 577 | + '<span class="tl-of"></span>' |
| 578 | + '<button type="button" class="tl-out">\u2212</button>' |
| 579 | + '<button type="button" class="tl-fit"></button>' |
| 580 | + '<button type="button" class="tl-in">+</button>' |
| 581 | + '<button type="button" class="tl-night" aria-pressed="false"></button>' |
| 582 | + '</span>' |
| 583 | + '</div>' |
| 584 | + '<div class="tl-body">' |
| 585 | + '<nav class="tl-rail" id="tl-rail" hidden><ol class="tl-toclist"></ol>' |
| 586 | + '<p class="tl-tocnone"></p></nav>' |
| 587 | + '<div class="tl-scroll"><div class="tl-pages"></div></div>' |
| 588 | + '</div>' |
| 589 | + '<pre class="tl-err" style="display:none"></pre>'; |
| 590 | panel.appendChild(host); |
| 591 | host.querySelector('.tl-rebuild').addEventListener('click', function () { rebuild(); }); |
| 592 | controls(); |
| 593 | // Scrolling out of the drawn band draws the next one. Throttled to a frame, |
| 594 | // because a scroll fires far more often than a screen is painted and the work |
| 595 | // only has to be done once per frame to be invisible. |
| 596 | // |
| 597 | // AND WHAT RUNS IN THAT FRAME IS ONLY THE CHEAP HALF. `sayWhere` is arithmetic and |
| 598 | // a few writes that are skipped when nothing changed; `ensureWindow` decides |
| 599 | // whether a band is wanted and, if one is, waits for the scroll to settle before |
| 600 | // building it. The band used to be built here, and a dragged scrollbar therefore |
| 601 | // built one per frame and threw each away — see `SETTLE_MS`. |
| 602 | let pending = false; |
| 603 | host.querySelector('.tl-scroll').addEventListener('scroll', function () { |
| 604 | if (pending) return; |
| 605 | pending = true; |
| 606 | requestAnimationFrame(function () { pending = false; ensureWindow(); sayWhere(); }); |
| 607 | }, { passive: true }); |
| 608 | // A resized panel is a different scale, and the scale is the only thing a resize |
| 609 | // changes: the layout is the compiler's. So it repaints rather than rebuilding, |
| 610 | // and the reader's page and offset — which are in points — survive it. |
| 611 | if (window.ResizeObserver) { |
| 612 | let sizing = false; |
| 613 | new ResizeObserver(function () { |
| 614 | if (sizing) return; |
| 615 | sizing = true; |
| 616 | requestAnimationFrame(function () { sizing = false; resized(); }); |
| 617 | }).observe(host.querySelector('.tl-scroll')); |
| 618 | } |
| 619 | // The pages get a shadow root of their own: typst.ts's stylesheet carries a |
| 620 | // bare `svg { fill: none; }` that would blank every icon in the app, and the |
| 621 | // app's own CSS must not reach in and change what the book looks like. |
| 622 | const pages = host.querySelector('.tl-pages'); |
| 623 | adopt(pages.attachShadow({ mode: 'open' })); |
| 624 | return host; |
| 625 | } |
| 626 | |
| 627 | // ── The settings the reader was not offered ───────────────────────────────── |
| 628 | // |
| 629 | // The live view shipped with a status bar and nothing else, which is one control |
| 630 | // fewer than the `<embed>` it stands in for: Chrome's PDF viewer at least has a |
| 631 | // zoom and a page box. So the same three, done properly, plus the one Chrome's |
| 632 | // viewer cannot be given at all. |
| 633 | // |
| 634 | // THEY ARE ALL VIEWING, NOT TYPESETTING. The layout is the compiler's and does not |
| 635 | // depend on any of them — the page count, the line breaks and where a footnote |
| 636 | // falls are the same at 40% as at 400% — so none of these starts a compile. That is |
| 637 | // why the zoom is a repaint (`resized`) and the reader's place, which is a page and |
| 638 | // an offset in POINTS, survives every one of them untouched. |
| 639 | // |
| 640 | // DARK PAPER IS A FILTER, and deliberately not a recompile. Asking typst for a dark |
| 641 | // document would change the document; inverting the drawn page changes only what |
| 642 | // the reader is looking at, so what is exported, printed and read by anybody else |
| 643 | // is unaffected. `hue-rotate(180deg)` after the inversion puts colours back roughly |
| 644 | // where they were, so a red figure stays red rather than turning cyan. |
| 645 | // |
| 646 | // The `<embed>` cannot have this one: there is no CSS, no API and no message that |
| 647 | // reaches inside Chrome's PDF plugin (measured — `dev/TYPST_WATCH.md` §6), which is |
| 648 | // the whole reason these pages are drawn here rather than handed to it. |
| 649 | |
| 650 | /// The largest and smallest the pages may be drawn, as a multiple of fitting the |
| 651 | /// panel's width. Past either the reader is no longer reading a page. |
| 652 | const ZOOM_MIN = 0.4, ZOOM_MAX = 4; |
| 653 | |
| 654 | /// Whether the language hook has been registered. Once per page: `onChange` keeps |
| 655 | /// what it is given for the life of the document, so registering it per mount would |
| 656 | /// leave a hook for every document the reader has opened. |
| 657 | let relabels = false; |
| 658 | |
| 659 | /// Put this language's words on the bar's controls. |
| 660 | function labels() { |
| 661 | if (!host) return; |
| 662 | const q = (c) => host.querySelector(c); |
| 663 | const page = q('.tl-page'), out = q('.tl-out'), inn = q('.tl-in'); |
| 664 | page.setAttribute('aria-label', tOr('typst.watch.page', 'Page')); |
| 665 | out.setAttribute('title', tOr('typst.watch.zoom_out', 'Smaller')); |
| 666 | out.setAttribute('aria-label', out.getAttribute('title')); |
| 667 | inn.setAttribute('title', tOr('typst.watch.zoom_in', 'Bigger')); |
| 668 | inn.setAttribute('aria-label', inn.getAttribute('title')); |
| 669 | fitLabel(); |
| 670 | const toc = q('.tl-toc'); |
| 671 | toc.setAttribute('title', tOr('typst.watch.sections', 'Sections')); |
| 672 | toc.setAttribute('aria-label', toc.getAttribute('title')); |
| 673 | q('.tl-tocnone').textContent = tOr('typst.watch.sections_none', |
| 674 | 'This document has no headings to list.'); |
| 675 | dark(S.dark); // the one whose LABEL is its state |
| 676 | offerRebuild(host.querySelector('.tl-rebuild').style.display !== 'none'); |
| 677 | drawRail(); |
| 678 | } |
| 679 | |
| 680 | /// Wire the bar's controls, once, at mount. |
| 681 | function controls() { |
| 682 | const q = (c) => host.querySelector(c); |
| 683 | const page = q('.tl-page'), out = q('.tl-out'), inn = q('.tl-in'), |
| 684 | fit = q('.tl-fit'), night = q('.tl-night'); |
| 685 | labels(); |
| 686 | out.addEventListener('click', function () { S.fit = 'width'; zoom(S.zoom / 1.25); }); |
| 687 | inn.addEventListener('click', function () { S.fit = 'width'; zoom(S.zoom * 1.25); }); |
| 688 | // One button for both fits, because they are one question — how much of the page |
| 689 | // do I want — and Chrome's PDF viewer asks it with one control too. It shows the |
| 690 | // percentage, so the title is what says which way it will go next. |
| 691 | fit.addEventListener('click', function () { fitPage(S.fit !== 'page'); }); |
| 692 | night.addEventListener('click', function () { dark(!S.dark); }); |
| 693 | q('.tl-toc').addEventListener('click', function () { rail(!S.rail); }); |
| 694 | // Enter commits; blur commits too, because a number typed and then clicked away |
| 695 | // from is a number the reader meant. |
| 696 | const go = function () { |
| 697 | const n = parseInt(page.value, 10); |
| 698 | if (Number.isFinite(n)) goToPage(n); |
| 699 | sayWhere(); |
| 700 | }; |
| 701 | page.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); go(); } }); |
| 702 | page.addEventListener('blur', go); |
| 703 | sayWhere(); |
| 704 | // A SURFACE ALREADY ON SCREEN WHEN THE LANGUAGE CHANGES has to repaint itself: |
| 705 | // every label above was resolved once, at mount, and the reader may well have a |
| 706 | // document open while switching. `onChange` is the app's own hook for exactly |
| 707 | // that, and it only ever puts words on a view that is still standing. |
| 708 | if (!relabels && window.DaimondI18n && window.DaimondI18n.onChange) { |
| 709 | relabels = true; |
| 710 | window.DaimondI18n.onChange(function () { |
| 711 | if (host && host.isConnected) labels(); |
| 712 | }); |
| 713 | } |
| 714 | } |
| 715 | |
| 716 | /// Draw the pages at `z` times the width that fits, and put the reader back. |
| 717 | function zoom(z) { |
| 718 | S.zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z)); |
| 719 | resized(); |
| 720 | fitLabel(); |
| 721 | sayWhere(); |
| 722 | } |
| 723 | |
| 724 | /// Say which way the one fit button will go next. |
| 725 | function fitLabel() { |
| 726 | if (!host) return; |
| 727 | const fit = host.querySelector('.tl-fit'); |
| 728 | fit.setAttribute('title', S.fit === 'page' |
| 729 | ? tOr('typst.watch.fit_width', 'Fit the width') |
| 730 | : tOr('typst.watch.fit_page', 'Fit the whole page')); |
| 731 | fit.setAttribute('aria-label', fit.getAttribute('title')); |
| 732 | } |
| 733 | |
| 734 | /// Fit a whole page in the view, or go back to fitting its width. |
| 735 | /// |
| 736 | /// The zoom is already a multiple of "as wide as the panel", so fitting the page is |
| 737 | /// arithmetic on the page the reader is looking at and nothing else — pages need not |
| 738 | /// all be the same shape, and a landscape plate in a portrait book should fit as |
| 739 | /// itself. Like every other control here it is a REPAINT: the layout is the |
| 740 | /// compiler's and does not depend on how much of a page is on screen. |
| 741 | function fitPage(on) { |
| 742 | S.fit = on ? 'page' : 'width'; |
| 743 | const sc = scroller(); |
| 744 | const w = where(); |
| 745 | const i = w ? Math.min(w.page, S.heights.length - 1) : 0; |
| 746 | const h = S.heights[i] || 0; |
| 747 | if (!on || !sc || !h || !S.docW) { |
| 748 | zoom(1); |
| 749 | return; |
| 750 | } |
| 751 | const cs = getComputedStyle(sc); |
| 752 | const inner = sc.clientWidth - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0); |
| 753 | const deep = sc.clientHeight - (parseFloat(cs.paddingTop) || 0) - (parseFloat(cs.paddingBottom) || 0); |
| 754 | // `scale` is `(inner / docW) * zoom`, so the zoom that puts `h` points into `deep` |
| 755 | // pixels is this and no search is needed. |
| 756 | // |
| 757 | // AND NEVER PAST FITTING THE WIDTH, which is what `1` is. A page has two |
| 758 | // dimensions and the whole of it fits only at the SMALLER of the two fits; taking |
| 759 | // the height alone in a panel that is tall and narrow gave 330% and cut the page |
| 760 | // off down both sides, which is not a fit by any reading of the word. |
| 761 | zoom(inner > 0 ? Math.min(1, (deep * S.docW) / (h * inner)) : 1); |
| 762 | } |
| 763 | |
| 764 | /// Dark paper on or off. |
| 765 | /// |
| 766 | /// The pages are inverted where they are DRAWN, so the scroller keeps the app's own |
| 767 | /// ground and only the paper turns over. |
| 768 | function dark(on) { |
| 769 | S.dark = !!on; |
| 770 | if (!host) return; |
| 771 | host.setAttribute('data-night', S.dark ? '1' : '0'); |
| 772 | const b = host.querySelector('.tl-night'); |
| 773 | b.setAttribute('aria-pressed', S.dark ? 'true' : 'false'); |
| 774 | // ONE WORD, because the bar is a strip and the word "paper" was doing no work in |
| 775 | // it: the button sits beside a page, and nothing else in the view is light or |
| 776 | // dark. The title still says what it turns over, for anybody who hovers. |
| 777 | b.textContent = S.dark ? tOr('typst.watch.paper_light', 'Light') |
| 778 | : tOr('typst.watch.paper_dark', 'Dark'); |
| 779 | b.setAttribute('title', S.dark ? tOr('typst.watch.paper_light_why', 'Light paper') |
| 780 | : tOr('typst.watch.paper_dark_why', 'Dark paper, for reading at night')); |
| 781 | b.setAttribute('aria-label', b.getAttribute('title')); |
| 782 | } |
| 783 | |
| 784 | /// Say which page the reader is on, and how big the pages are drawn. |
| 785 | /// |
| 786 | /// NOTHING IS WRITTEN THAT DOES NOT DIFFER FROM WHAT IS THERE, and on a scroll almost |
| 787 | /// nothing does: the zoom cannot change by scrolling, the page count cannot, and the |
| 788 | /// page number changes once a page rather than once a frame. This is called from |
| 789 | /// every scroll frame, and a write into the bar followed by `markHere` reading |
| 790 | /// `scrollTop` again is a layout the browser is forced to do twice in one frame, |
| 791 | /// sixty times a second, for a percentage that says the same thing every time. |
| 792 | /// |
| 793 | /// The comparison is against THE ELEMENT rather than against a remembered value, so |
| 794 | /// there is no second copy to drift from the bar — and reading `value`, `textContent` |
| 795 | /// or an inline `display` costs no layout, which is the whole point. The reader's |
| 796 | /// place is worked out once here and handed to `markHere`, so the frame reads |
| 797 | /// `scrollTop` once. |
| 798 | function sayWhere() { |
| 799 | if (!host) return; |
| 800 | const w = where(); |
| 801 | const page = host.querySelector('.tl-page'); |
| 802 | const at = S.pages ? String((w ? w.page : 0) + 1) : ''; |
| 803 | if (page && document.activeElement !== page && page.value !== at) page.value = at; |
| 804 | const tot = S.pages ? '/ ' + S.pages : ''; // how many there are, beside it |
| 805 | const of = host.querySelector('.tl-of'); |
| 806 | if (of.textContent !== tot) of.textContent = tot; |
| 807 | const pc = Math.round(S.zoom * 100) + '%'; |
| 808 | const fit = host.querySelector('.tl-fit'); |
| 809 | if (fit.textContent !== pc) fit.textContent = pc; |
| 810 | const set = host.querySelector('.tl-set'); |
| 811 | const show = S.pages ? '' : 'none'; |
| 812 | if (set.style.display !== show) set.style.display = show; |
| 813 | markHere(w); |
| 814 | } |
| 815 | |
| 816 | |
| 817 | /// Take the live view down, leaving the panel exactly as it was. |
| 818 | function unmount() { |
| 819 | if (host && host.parentNode) host.parentNode.removeChild(host); |
| 820 | host = null; |
| 821 | band = null; |
| 822 | vec = null; |
| 823 | for (const [el, was] of stood) el.style.display = was; |
| 824 | stood = []; |
| 825 | } |
| 826 | |
| 827 | /// Stand in for the panel's other two renderings, remembering what they had. |
| 828 | /// |
| 829 | /// Only once the first pages are actually on screen: until then the PDF the button |
| 830 | /// produced is what the reader is looking at, and taking it away to show nothing is |
| 831 | /// the blank this whole file exists to avoid. |
| 832 | function standIn() { |
| 833 | for (const id of ['pv-view', 'doc-embed']) { |
| 834 | const el = document.getElementById(id); |
| 835 | if (!el) continue; |
| 836 | // What it had is recorded ONCE, the first time; the hiding happens every |
| 837 | // time. Pressing ⚙ Compile again while the view is live writes a fresh PDF |
| 838 | // and shows the `<embed>` again, so a `standIn` that returned early left the |
| 839 | // panel holding the live pages AND the PDF, one under the other. |
| 840 | if (!stood.some(function (p) { return p[0] === el; })) stood.push([el, el.style.display]); |
| 841 | el.style.display = 'none'; |
| 842 | } |
| 843 | } |
| 844 | |
| 845 | /// The scroller, or null when nothing is mounted. |
| 846 | function scroller() { |
| 847 | return host ? host.querySelector('.tl-scroll') : null; |
| 848 | } |
| 849 | |
| 850 | /// Say what the loop is doing, in the bar over the document. |
| 851 | /// |
| 852 | /// Deliberately quiet: a mark and a few words, because the thing worth looking at |
| 853 | /// is the document underneath. It is never a spinner over the pages, and it never |
| 854 | /// replaces them. |
| 855 | function says(text, mark) { |
| 856 | if (!host) return; |
| 857 | host.querySelector('.tl-says').textContent = text || ''; |
| 858 | host.querySelector('.tl-mark').className = 'tl-mark' + (mark ? ' tl-' + mark : ''); |
| 859 | host.setAttribute('data-mode', S.mode + (S.building ? ' building' : '')); |
| 860 | } |
| 861 | |
| 862 | /// Offer the Rebuild button, or take it away. |
| 863 | function offerRebuild(on) { |
| 864 | if (!host) return; |
| 865 | const b = host.querySelector('.tl-rebuild'); |
| 866 | b.style.display = on ? '' : 'none'; |
| 867 | b.textContent = tOr('typst.watch.rebuild', 'Rebuild'); |
| 868 | } |
| 869 | |
| 870 | /// Put the compiler's own words over the document, or clear them. |
| 871 | /// |
| 872 | /// THE DOCUMENT STAYS. A failed build is the ordinary state of a document being |
| 873 | /// written, and losing the last good pages every time a brace is unbalanced would |
| 874 | /// make the preview useless exactly when it is most wanted. The words are typst's, |
| 875 | /// naming the file and the line, composed by `typst.js` — not reworded here, since |
| 876 | /// the file and the line are the only part anybody acts on. |
| 877 | function showError(text) { |
| 878 | if (!host) return; |
| 879 | const e = host.querySelector('.tl-err'); |
| 880 | S.error = text || ''; |
| 881 | e.textContent = S.error; |
| 882 | e.style.display = S.error ? '' : 'none'; |
| 883 | } |
| 884 | |
| 885 | |
| 886 | // ── Where the reader was ──────────────────────────────────────────────────── |
| 887 | |
| 888 | /// Which page the reader is on, and how far into it, in points. |
| 889 | /// |
| 890 | /// A fraction of the scroll height would be the easy answer and the wrong one: a |
| 891 | /// paragraph added to chapter two makes the whole book longer, and the same |
| 892 | /// fraction of a longer book is a different page. A page and an offset within it |
| 893 | /// survives the document growing above the reader, which is the case that actually |
| 894 | /// happens while somebody is writing. |
| 895 | /// TWO COORDINATE SYSTEMS, AND KEEPING THEM APART IS THE WHOLE OF THIS SECTION. |
| 896 | /// `tops` is where a page sits IN THE DOCUMENT and is what the renderer is asked |
| 897 | /// about; `lays` is where its sheet sits ON SCREEN, which is the same thing plus the |
| 898 | /// gaps above it. The reader's place is a page and an offset in points, so it is the |
| 899 | /// one quantity that means the same in both. |
| 900 | function where() { |
| 901 | const sc = scroller(); |
| 902 | if (!sc || !S.lays.length) return null; |
| 903 | const y = sc.scrollTop / (S.scale || 1); // px back into pt |
| 904 | // A HAIR OF SLACK AT THE PAGE EDGE, and it is not cosmetic. `goToPage` sets the |
| 905 | // scroll to a page's own top; the browser hands that number back rounded to a |
| 906 | // fraction of a pixel, which divided by the scale is a whisper BELOW the top — |
| 907 | // and without the slack the reader is told they are on the page before the one |
| 908 | // they were just taken to. A twentieth of a point is a fiftieth of a millimetre. |
| 909 | const EDGE = 0.05; |
| 910 | let i = 0; |
| 911 | while (i + 1 < S.lays.length && S.lays[i + 1] <= y + EDGE) i++; |
| 912 | return { page: i, into: Math.max(0, y - S.lays[i]) }; |
| 913 | } |
| 914 | |
| 915 | /// Where each sheet sits on screen, in points, and how tall the stack is. |
| 916 | /// |
| 917 | /// Recomputed from `tops` and `heights` rather than carried alongside them, so the |
| 918 | /// gap cannot end up counted twice or not at all. |
| 919 | function layOut() { |
| 920 | S.lays = []; |
| 921 | let acc = 0; |
| 922 | for (let i = 0; i < S.heights.length; i++) { |
| 923 | S.lays.push(acc); |
| 924 | acc += S.heights[i] + PAGE_GAP; |
| 925 | } |
| 926 | S.laid = S.heights.length ? acc - PAGE_GAP : 0; |
| 927 | } |
| 928 | |
| 929 | /// Put the reader back where `w` says, in the document now on screen. |
| 930 | /// |
| 931 | /// A page that no longer exists — a chapter deleted while it was being read — |
| 932 | /// clamps to the last one there is, rather than snapping to the top. |
| 933 | function goTo(w) { |
| 934 | const sc = scroller(); |
| 935 | if (!sc || !w || !S.lays.length) return; |
| 936 | const i = Math.min(w.page, S.lays.length - 1); |
| 937 | const into = Math.min(w.into, S.heights[i] || 0); |
| 938 | sc.scrollTop = (S.lays[i] + into) * (S.scale || 1); |
| 939 | } |
| 940 | |
| 941 | |
| 942 | // ── Drawing: only the pages the reader can see ────────────────────────────── |
| 943 | // |
| 944 | // THE WHOLE BOOK AT ONCE IS THE ONE THING THAT DOES NOT SCALE, AND IT IS NOT THE |
| 945 | // COMPILER. Measured on the author's 281-page book, laying the pages out costs |
| 946 | // 376-564 ms and everything after it costs seven seconds: |
| 947 | // |
| 948 | // gather + lay out 376-564 ms |
| 949 | // session 27 ms |
| 950 | // the whole book to SVG ~250 ms → 23.7 MB of markup |
| 951 | // parsing that markup 1300-1900 ms |
| 952 | // putting it in the DOM 5100-8400 ms ← the loop, dead |
| 953 | // |
| 954 | // So the pages are drawn a WINDOW at a time. `RenderSession.render_in_window` takes |
| 955 | // a rectangle in document points and returns an SVG carrying only what falls inside |
| 956 | // it. Measured on a 33-page document, whole against a window of six pages: |
| 957 | // |
| 958 | // whole: 47 ms to SVG, 3.01 MB, 202 ms to parse, 159 ms into the DOM |
| 959 | // window: 7 ms to SVG, 0.65 MB, 1 ms to parse, 1 ms into the DOM |
| 960 | // |
| 961 | // AND THE ELEMENT MUST NOT BE THE HEIGHT OF THE BOOK EITHER, which is the second |
| 962 | // half of the same lesson and cost a second measurement to find. `render_in_window` |
| 963 | // hands back its window inside the WHOLE DOCUMENT'S viewBox, so the obvious thing — |
| 964 | // keep the element as tall as the book, put only the visible band in it — leaves an |
| 965 | // SVG 192,000 pixels tall. Chrome rasterises that far from its origin at a reduced |
| 966 | // resolution, and from about page forty onward the page comes out as a PALE WASH: |
| 967 | // measured on a 251-page document, page 1 was 5.6% dark pixels and page 40 was |
| 968 | // 0.00%, with the words still legible in outline. It looks exactly like a font or a |
| 969 | // colour bug and is neither. |
| 970 | // |
| 971 | // So the container carries the height and the SVGs carry only the band: a plain |
| 972 | // div as tall as the whole stack, with an absolutely-positioned SHEET per page in |
| 973 | // it, each cropped to its own page by its own viewBox. The scrollbar still measures |
| 974 | // the book, the reader's place still means a page and an offset, and nothing is ever |
| 975 | // asked to rasterise more than one page at a time. |
| 976 | // |
| 977 | // ONE SHEET PER PAGE RATHER THAN ONE SVG PER BAND, because a document is a stack of |
| 978 | // sheets and the author reported the previous drawing as "a continuous page". A |
| 979 | // SESSION MAY BE ASKED MORE THAN ONCE: measured on a six-page fixture, one session, |
| 980 | // one call per page — |
| 981 | // |
| 982 | // page 1 6.0 ms 80 KB 55 glyph outlines ← the defs, once |
| 983 | // page 2 1.5 ms 27 KB 0 |
| 984 | // page 3 0.7 ms 26 KB 0 |
| 985 | // page 4 0.3 ms 27 KB 0 |
| 986 | // |
| 987 | // — so the whole band costs about what one call for the same range cost, and the |
| 988 | // glyph outlines are emitted into whichever sheet needed them first. The later |
| 989 | // sheets `<use href="#g…">` them across the SVG boundary, which resolves because |
| 990 | // every sheet of one band goes into the ONE shadow root in the ONE `replaceChildren` |
| 991 | // and an id lookup is per tree, not per element. The band is replaced whole or not at |
| 992 | // all, so a def can never be taken away from a sheet still using it. |
| 993 | |
| 994 | // AND `render_in_window` IS A DIFF, WHICH IS THE THIRD THING THIS COST TO LEARN. |
| 995 | // It answers with what has changed since THAT SESSION last drew, not with what is in |
| 996 | // the window. Measured on a 12-page document, one session, four calls: |
| 997 | // |
| 998 | // first call, pages 1-3 381 KB, 7215 marks |
| 999 | // the SAME window again 2 KB, 0 marks ← an empty document |
| 1000 | // move to pages 11-13 114 KB, 2252 marks |
| 1001 | // back to pages 1-3 354 KB, 7162 marks ← 53 marks it still held |
| 1002 | // a FRESH session, pages 1-3 381 KB, 7215 marks |
| 1003 | // |
| 1004 | // So a repaint of an unchanged window replaces the pages with nothing, and a repaint |
| 1005 | // of an overlapping one silently drops whatever the session thinks is already on |
| 1006 | // screen. Both are the blank this file exists to prevent, arriving through the |
| 1007 | // renderer rather than through the DOM. |
| 1008 | // |
| 1009 | // The fix is to give every render its own session, built from the vector bytes that |
| 1010 | // are kept for exactly that purpose, and freed the moment the pages are up. A |
| 1011 | // session costs 27-32 ms on the 281-page book, which is a scroll of one screenful, |
| 1012 | // and the alternative — keeping one session and applying its diffs — means owning |
| 1013 | // typst.ts's incremental DOM protocol to save thirty milliseconds nobody can feel. |
| 1014 | // (`render_svg_diff` and `mount_dom` are that protocol, if it is ever worth it.) |
| 1015 | |
| 1016 | let band = null; // the pages currently drawn, as `{ p0, p1 }` inclusive |
| 1017 | |
| 1018 | /// Which sheet the point `y` — in LAID-OUT points — falls on or nearest to. |
| 1019 | function pageAt(y) { |
| 1020 | if (!S.lays.length) return 0; |
| 1021 | let i = 0; |
| 1022 | while (i + 1 < S.lays.length && S.lays[i + 1] <= y) i++; |
| 1023 | return i; |
| 1024 | } |
| 1025 | |
| 1026 | /// The markup for pages `p0` to `p1` inclusive, out of a session of its own. |
| 1027 | /// |
| 1028 | /// ONE CALL PER SESSION AND NEVER TWO, which cost the afternoon that the inset above |
| 1029 | /// only half explains. Asking one session for page after page LOOKED right — each |
| 1030 | /// call did answer with its own page — until a document repeated itself. Measured on |
| 1031 | /// four pages each holding the same `#lorem(60)`: |
| 1032 | /// |
| 1033 | /// one session, a call per page 428, 5, 7, 5 `<use>` ← the body vanished |
| 1034 | /// a session per page 428, 428, 430, 428 |
| 1035 | /// one session, ONE call for all 428, 428, 430, 428 ← and 7 ms for four |
| 1036 | /// |
| 1037 | /// The diff is by CONTENT, not by page: a group the session has already drawn is not |
| 1038 | /// drawn again, wherever it is. So the band is one call, and the pages are separated |
| 1039 | /// afterwards out of the one answer — which is also the cheapest of the three, since |
| 1040 | /// a session costs 27-32 ms on the author's 281-page book and this builds one. |
| 1041 | /// |
| 1042 | /// AND IT IS ASKED IN THE RENDERER'S OWN COORDINATES, which are not the exact sum of |
| 1043 | /// the page heights. The renderer rounds each height to a whole point before it |
| 1044 | /// accumulates, so on a 453.543 pt page it is 0.457 pt further down every page: by |
| 1045 | /// page 26 the window asked for begins a tenth of a page above where the renderer |
| 1046 | /// thinks page 26 begins, so the band drew a page more than it kept, and by the point |
| 1047 | /// where the drift passes a whole page height — about page 992 for this page size — |
| 1048 | /// the window stopped reaching page `p1` at all and the last sheet of every band came |
| 1049 | /// back blank. `S.rtops` is what the renderer said, learned from any band already |
| 1050 | /// drawn; `S.tops` is the exact sum, and is the best that can be done before one has. |
| 1051 | /// |
| 1052 | /// WHICH LEAVES EXACTLY ONE BAND ASKED WITHOUT THEM: the first of a new document, |
| 1053 | /// drawn before anything has said where its pages are. That one is asked for a window |
| 1054 | /// widened by the whole document's `drift` at both ends — the free measurement taken |
| 1055 | /// in `draw`, which is the largest the accumulation can have reached anywhere in the |
| 1056 | /// book — so it cannot fall short of page `p1` however deep in the document it is. |
| 1057 | /// The pages that widening pulls in are dropped a few lines below, as the pages |
| 1058 | /// outside the window always were, and the band it draws fills `rtops` in for every |
| 1059 | /// page of the document, so no band after it is asking with the wrong number. |
| 1060 | /// |
| 1061 | /// It is not simply re-asked once the origins are known, because a second session on |
| 1062 | /// every rebuild is 30 ms of every rebuild spent redrawing sheets that are right. |
| 1063 | function windowOf(ses, p0, p1) { |
| 1064 | const pad = (S.rtops[p0] != null && S.rtops[p1] != null) ? 0 : Math.abs(S.drift); |
| 1065 | const lo = ((S.rtops[p0] != null) ? S.rtops[p0] : S.tops[p0]) - pad; |
| 1066 | const hi = ((S.rtops[p1] != null) ? S.rtops[p1] : S.tops[p1]) + S.heights[p1] + pad; |
| 1067 | return ses.render_in_window(0, Math.max(0, lo) + PAGE_INSET, S.docW, |
| 1068 | hi - PAGE_INSET); |
| 1069 | } |
| 1070 | |
| 1071 | /// The renderer's answer, parsed, with what must not run taken out of it first. |
| 1072 | function parseSvg(svg) { |
| 1073 | // The script typst.ts embeds goes before anything parses it — it is not wanted, |
| 1074 | // and its minified `&&` is not well-formed XML either, so leaving it in makes |
| 1075 | // `DOMParser` refuse the whole document and draw a parser-error banner instead |
| 1076 | // of a book. (Measured, and it looked exactly like a rendering bug.) |
| 1077 | // |
| 1078 | // The `onclick="handleTypstLocation(…)"` on every internal link goes with it: |
| 1079 | // that function was DEFINED in the script just removed, so what is left is a |
| 1080 | // handler naming nothing, on an `<a xlink:href="#">` that would move the app's |
| 1081 | // own URL if it were ever reached. Removed rather than relied upon to be |
| 1082 | // blocked, because "the policy will stop it" is not a reason to ship it. |
| 1083 | const clean = svg |
| 1084 | .replace(/<script\b[\s\S]*?<\/script>/gi, '') |
| 1085 | .replace(/\sonclick="[^"]*"/gi, ''); |
| 1086 | const doc = new DOMParser().parseFromString(clean, 'image/svg+xml'); |
| 1087 | const el = doc.documentElement; |
| 1088 | if (!el || el.nodeName === 'parsererror' || el.querySelector('parsererror')) { |
| 1089 | throw new Error('The laid-out pages did not parse: ' |
| 1090 | + String(el && el.textContent).slice(0, 200)); |
| 1091 | } |
| 1092 | el.removeAttribute('style'); |
| 1093 | return document.importNode(el, true); |
| 1094 | } |
| 1095 | |
| 1096 | /// The sheets for the pages around the reader, as one node ready to insert. |
| 1097 | /// |
| 1098 | /// # Arguments |
| 1099 | /// * `bytes` - The `vector` artifact this document was laid out to. |
| 1100 | /// * `top` - The top of the visible area, in LAID-OUT points. |
| 1101 | /// * `deep` - How tall the visible area is, in points. |
| 1102 | /// * `scale` - Rendered pixels per document point. |
| 1103 | function bandNode(bytes, top, deep, scale) { |
| 1104 | const last = S.heights.length - 1; |
| 1105 | if (last < 0) throw new Error('There are no pages to draw.'); |
| 1106 | const p0 = Math.max(0, pageAt(Math.max(0, top)) - MARGIN_PAGES); |
| 1107 | const p1 = Math.min(last, pageAt(Math.max(0, top + deep)) + MARGIN_PAGES); |
| 1108 | // A session of its own, so what comes back is the WINDOW and not a diff against |
| 1109 | // whatever was drawn last. Freed before anything is parsed, whatever happens. |
| 1110 | const ses = renderer.session_from_artifact(bytes, 'vector'); |
| 1111 | let markup; |
| 1112 | try { |
| 1113 | markup = windowOf(ses, p0, p1); |
| 1114 | } finally { |
| 1115 | try { ses.free(); } catch (e) { /* already gone */ } |
| 1116 | } |
| 1117 | const root = parseSvg(markup); |
| 1118 | // The answer is `<defs class="glyph">`, `<defs class="clip-path">`, `<style>` and |
| 1119 | // then one `<g class="typst-page">` per page. The pages become sheets; everything |
| 1120 | // else is SHARED and rides in the first of them, where an `href="#g…"` from any |
| 1121 | // other sheet still finds it — an id is looked up per TREE, and every sheet of one |
| 1122 | // band goes into the one shadow root in the one `replaceChildren`. |
| 1123 | const shared = [], groups = []; |
| 1124 | for (const el of Array.from(root.children)) { |
| 1125 | const c = el.getAttribute('class') || ''; |
| 1126 | if (el.nodeName === 'g' && c.indexOf('typst-page') >= 0) groups.push(el); |
| 1127 | else shared.push(el); |
| 1128 | } |
| 1129 | const wrap = document.createElement('div'); |
| 1130 | wrap.className = 'tl-band'; |
| 1131 | for (let k = 0; k < groups.length; k++) { |
| 1132 | const m = /translate\(\s*[-\d.]+\s*,\s*([-\d.]+)/ |
| 1133 | .exec(groups[k].getAttribute('transform') || ''); |
| 1134 | // THE SEQUENCE NAMES THE PAGE; THE TRANSFORM IS WHERE ITS INK IS. Two questions |
| 1135 | // that look like one, and answering the second with the first is the defect this |
| 1136 | // paragraph exists for. Which page a group is, is its position in the answer — |
| 1137 | // `pageOfGroup` says why, and why the y cannot be trusted for it. Where that |
| 1138 | // page's ink was PUT is the group's own `translate`, and nothing else knows. |
| 1139 | const gy = m ? parseFloat(m[1]) : NaN; |
| 1140 | const i = pageOfGroup(Number.isFinite(gy) ? gy : 0, k, groups.length, p0, p1); |
| 1141 | // AND WHAT IT SAYS IS KEPT FOR THE WHOLE DOCUMENT, WHICH IS WHY THIS IS ABOVE THE |
| 1142 | // LINE THAT DROPS THE REST. A window emits a group for EVERY page of the |
| 1143 | // document, not for the pages in it — the ones outside it come back empty, but |
| 1144 | // they still say where they are — so one band's answer names every page's origin |
| 1145 | // in the book and no later band, window or scan has to ask again. |
| 1146 | if (Number.isFinite(gy) && i >= 0 && i <= last) S.rtops[i] = gy; |
| 1147 | // The groups outside the window came back empty, and an empty sheet is a hole |
| 1148 | // in the document. They are dropped rather than drawn. |
| 1149 | if (i < p0 || i > p1 || i > last) continue; |
| 1150 | // A shallow clone of the root keeps its namespaces and its own class; the |
| 1151 | // viewBox is what crops it to this page and nothing else — no transform, no |
| 1152 | // second coordinate system to get wrong, and no element taller than a page for |
| 1153 | // Chrome to rasterise badly. |
| 1154 | // |
| 1155 | // CROPPED WHERE THE RENDERER PUT THE PAGE, NOT AT THE EXACT SUM OF THE HEIGHTS |
| 1156 | // ABOVE IT, and the two are not the same number. `S.tops` accumulates the page |
| 1157 | // heights as they are — 453.5433 pt on a 160 mm page — and typst.ts rounds each |
| 1158 | // one to a whole point and accumulates THAT, so the two separate by 0.4567 pt a |
| 1159 | // page and never meet again: 11 pt by page 26, 45 pt by page 100, 105 pt by page |
| 1160 | // 230. The sheet is placed and sized correctly either way, so the PAPER stays |
| 1161 | // right and the INK slides down inside it — a widening white band at the head and |
| 1162 | // the foot of the type walking off the bottom, which is the "gradual violation of |
| 1163 | // the margins" the author reported and which page one, where the drift is zero by |
| 1164 | // construction, cannot show. |
| 1165 | const svg = root.cloneNode(false); |
| 1166 | const y = Number.isFinite(gy) ? gy : S.tops[i]; |
| 1167 | svg.setAttribute('viewBox', '0 ' + y + ' ' + S.docW + ' ' + S.heights[i]); |
| 1168 | svg.setAttribute('width', String(S.docW * scale)); |
| 1169 | svg.setAttribute('height', String(S.heights[i] * scale)); |
| 1170 | svg.setAttribute('preserveAspectRatio', 'xMidYMin meet'); |
| 1171 | // The shared parts ride in THE FIRST SHEET MADE, which is not the first group |
| 1172 | // answered: the groups outside the window were dropped just above, and hanging |
| 1173 | // the glyph outlines on one of those would have thrown them away with it. |
| 1174 | if (!wrap.childElementCount) for (const sh of shared) svg.appendChild(sh); |
| 1175 | svg.appendChild(groups[k]); |
| 1176 | const sheet = document.createElement('div'); |
| 1177 | sheet.className = 'tl-sheet'; |
| 1178 | sheet.setAttribute('data-page', String(i + 1)); |
| 1179 | sheet.style.top = (S.lays[i] * scale) + 'px'; |
| 1180 | sheet.style.width = (S.docW * scale) + 'px'; |
| 1181 | sheet.style.height = (S.heights[i] * scale) + 'px'; |
| 1182 | sheet.appendChild(svg); |
| 1183 | wrap.appendChild(sheet); |
| 1184 | } |
| 1185 | band = { p0, p1 }; |
| 1186 | return wrap; |
| 1187 | } |
| 1188 | |
| 1189 | /// How wide the pages are drawn, and therefore how big everything is. |
| 1190 | /// |
| 1191 | /// Read off the SCROLLER's content box rather than off the pages themselves, because |
| 1192 | /// the pages now carry a width of their own: measuring the thing this sets would |
| 1193 | /// compound, and one zoom in would become a zoom in per repaint. The panel is |
| 1194 | /// resizable and its width is the app's business; `S.zoom` is the reader's. |
| 1195 | function scaleFor(docW) { |
| 1196 | const sc = scroller(); |
| 1197 | if (!sc || !docW) return S.scale || 1; |
| 1198 | const cs = getComputedStyle(sc); |
| 1199 | const w = sc.clientWidth - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0); |
| 1200 | return w > 0 ? (w / docW) * S.zoom : (S.scale || 1); |
| 1201 | } |
| 1202 | |
| 1203 | let settling = null; // the timer that draws the band once the scroll has stopped |
| 1204 | let sick = null; // the visible window whose render threw, until it is left |
| 1205 | |
| 1206 | /// Redraw the window if the reader has scrolled out of the one that is drawn. |
| 1207 | /// |
| 1208 | /// IT IS NOT CHEAP AND IT DOES NOT RUN IN THE SCROLL FRAME. The note that used to be |
| 1209 | /// here said it was a few milliseconds; a band is a fresh session out of the whole |
| 1210 | /// artifact (27-32 ms on the author's 281-page book), a render, two regular |
| 1211 | /// expressions over the markup, a `DOMParser`, a deep `importNode` and one group |
| 1212 | /// examined per page OF THE WHOLE DOCUMENT. Deciding whether a band is wanted IS |
| 1213 | /// cheap — it is arithmetic on `lays` — so that part stays here and the band itself |
| 1214 | /// waits `SETTLE_MS` for the scroll to stop. A dragged scrollbar failed the guard |
| 1215 | /// below on every frame, and every frame paid the whole of that and then threw the |
| 1216 | /// sheets away. |
| 1217 | /// |
| 1218 | /// # Arguments |
| 1219 | /// * `now` - Draw it in this turn rather than when the scroll settles. For a jump the |
| 1220 | /// reader asked for, where the wait would be a wait for nothing. |
| 1221 | function ensureWindow(now) { |
| 1222 | const sc = scroller(); |
| 1223 | if (!sc || !vec || !band || !renderer) return; |
| 1224 | const scale = S.scale || 1; |
| 1225 | const top = sc.scrollTop / scale, deep = sc.clientHeight / scale; |
| 1226 | // In PAGES, because a sheet is what gets drawn. Clamped to the stack, so a |
| 1227 | // viewport taller than the book cannot ask for a page past the end and repaint on |
| 1228 | // every frame for ever. |
| 1229 | const a = pageAt(Math.max(0, top)), b = pageAt(Math.max(0, top + deep)); |
| 1230 | if (a >= band.p0 && b <= band.p1) return; |
| 1231 | // A WINDOW WHOSE RENDER THREW IS NOT ASKED AGAIN UNTIL THE READER LEAVES IT. The |
| 1232 | // guard above is only satisfied by a band that was drawn, so a render that failed |
| 1233 | // left it false for ever: every later scroll frame built another session, threw, |
| 1234 | // and swallowed it — the failure was invisible and it was paid for sixty times a |
| 1235 | // second. `S.bandErr` keeps the reason where `state()` will show it. |
| 1236 | if (sick && a >= sick.p0 && b <= sick.p1) return; |
| 1237 | if (now) { drawBand(top, deep, scale, a, b); return; } |
| 1238 | if (settling) clearTimeout(settling); |
| 1239 | settling = setTimeout(function () { |
| 1240 | settling = null; |
| 1241 | ensureWindow(true); |
| 1242 | }, SETTLE_MS); |
| 1243 | } |
| 1244 | |
| 1245 | /// Draw the band around `top`, remembering a failure rather than repeating it. |
| 1246 | /// |
| 1247 | /// # Arguments |
| 1248 | /// * `a`, `b` - The first and last page actually in view, which is the window that is |
| 1249 | /// marked as bad if this throws. Not the band: the band reaches further |
| 1250 | /// either side, and a reader who has left the pages that failed should be |
| 1251 | /// tried again. |
| 1252 | function drawBand(top, deep, scale, a, b) { |
| 1253 | try { |
| 1254 | paint(bandNode(vec, top, deep, scale)); |
| 1255 | sick = null; |
| 1256 | S.bandErr = ''; |
| 1257 | } catch (e) { |
| 1258 | // The pages that are up stay up, and the reason is kept rather than swallowed. |
| 1259 | // Not `showError`: that strip is the COMPILER's words, and a render fault |
| 1260 | // underneath a document that compiled cleanly would be a lie about the source. |
| 1261 | sick = { p0: a, p1: b }; |
| 1262 | S.bandErr = (e && e.message) ? e.message : String(e); |
| 1263 | console.warn('typstwatch: pages ' + (a + 1) + '-' + (b + 1) |
| 1264 | + ' could not be drawn: ' + S.bandErr); |
| 1265 | } |
| 1266 | } |
| 1267 | |
| 1268 | /// Redraw at a new size, because the panel was resized. |
| 1269 | /// |
| 1270 | /// The scale is the only thing that changes; the layout is the compiler's and does |
| 1271 | /// not depend on how wide the panel is. So this is a repaint, not a rebuild — the |
| 1272 | /// reader's page and offset are in points and survive it untouched. |
| 1273 | function resized() { |
| 1274 | if (!vec || !host || !renderer) return; |
| 1275 | const was = where(); |
| 1276 | S.scale = scaleFor(S.docW); |
| 1277 | const pages = host.querySelector('.tl-pages'); |
| 1278 | pages.style.width = (S.docW * S.scale) + 'px'; |
| 1279 | pages.style.height = (S.laid * S.scale) + 'px'; |
| 1280 | band = null; |
| 1281 | // A new size is a new attempt: a band that could not be drawn at the old one is |
| 1282 | // asked for again rather than being held against the reader for ever. |
| 1283 | sick = null; |
| 1284 | const sc = scroller(); |
| 1285 | const top = was ? (S.lays[Math.min(was.page, S.lays.length - 1)] + was.into) |
| 1286 | : (sc ? sc.scrollTop / S.scale : 0); |
| 1287 | const deep = sc ? sc.clientHeight / S.scale : S.laid; |
| 1288 | try { |
| 1289 | paint(bandNode(vec, top, deep, S.scale)); |
| 1290 | } catch (e) { return; } |
| 1291 | if (was) goTo(was); |
| 1292 | sayWhere(); |
| 1293 | } |
| 1294 | |
| 1295 | /// Draw `bytes` — one `vector` artifact — into the live view. |
| 1296 | /// |
| 1297 | /// Everything expensive happens on a DETACHED node, and the swap is a single |
| 1298 | /// `replaceChildren` with the scroll put back in the same turn. There is no frame |
| 1299 | /// in which the host is empty; `dev/verify_typstwatch.mjs` counts the marks on |
| 1300 | /// screen on every animation frame across a rebuild to prove it. |
| 1301 | async function draw(bytes) { |
| 1302 | await getRenderer(); |
| 1303 | // One session, for the geometry only, and freed before anything is drawn: the |
| 1304 | // page tops and heights are all that is wanted out of the document, and every |
| 1305 | // render after this builds its own (see the note on the diff, above). |
| 1306 | const probe = renderer.session_from_artifact(bytes, 'vector'); |
| 1307 | let n, docW, docH; |
| 1308 | const heights = [], tops = []; |
| 1309 | try { |
| 1310 | const info = probe.pages_info; |
| 1311 | n = info.page_count; |
| 1312 | docW = info.width(); |
| 1313 | docH = info.height(); |
| 1314 | let acc = 0; |
| 1315 | for (let i = 0; i < n; i++) { |
| 1316 | const h = info.page(i).height_pt; |
| 1317 | tops.push(acc); |
| 1318 | heights.push(h); |
| 1319 | acc += h; |
| 1320 | } |
| 1321 | } finally { |
| 1322 | try { probe.free(); } catch (e) { /* already gone */ } |
| 1323 | } |
| 1324 | |
| 1325 | const sc = scroller(); |
| 1326 | const was = where(); |
| 1327 | const pages = host.querySelector('.tl-pages'); |
| 1328 | // The geometry has to be in place before a band can be asked for, since |
| 1329 | // `bandNode` measures against it. ALL OF IT, `lays` included: the sheets are |
| 1330 | // placed from `lays` and the pages are rendered from `tops`, and a band built |
| 1331 | // with one of them stale would draw the right pages in the wrong places. |
| 1332 | const wasDoc = { w: S.docW, h: S.docH, s: S.scale, t: S.tops, hh: S.heights, |
| 1333 | l: S.lays, ld: S.laid, p: S.pages, rt: S.rtops, d: S.drift }; |
| 1334 | S.docW = docW; |
| 1335 | S.docH = docH; |
| 1336 | S.tops = tops; |
| 1337 | // EMPTIED WITH THE REST OF THE GEOMETRY, and this is not tidiness. `rtops` is where |
| 1338 | // THE DOCUMENT ON SCREEN was put; an entry left over from the one before it would |
| 1339 | // crop this document's page at a position belonging to another book, which is worse |
| 1340 | // than the drift it exists to correct. The first band of this document fills it in |
| 1341 | // again for every page, in one answer. |
| 1342 | S.rtops = []; |
| 1343 | // And a band that could not be drawn out of the LAST document says nothing about |
| 1344 | // this one, so the window that failed is offered again and its reason goes with it. |
| 1345 | sick = null; |
| 1346 | S.bandErr = ''; |
| 1347 | S.heights = heights; |
| 1348 | // THE DRIFT, FOR NOTHING, BEFORE A SINGLE PAGE IS DRAWN. `info.height()` is the |
| 1349 | // renderer's own total; `tops[n-1] + heights[n-1]` is the exact sum of the same |
| 1350 | // pages. Where the renderer accumulates whole points the two disagree by the whole |
| 1351 | // of the accumulated rounding — 105 pt on a 231-page book of 453.543 pt pages — |
| 1352 | // which is exactly the distance a page's ink was cropped adrift by. It costs two |
| 1353 | // subtractions and it is the cheapest thing in this file that says the crop is |
| 1354 | // following the right coordinates. |
| 1355 | S.drift = n ? (docH - (tops[n - 1] + heights[n - 1])) : 0; |
| 1356 | layOut(); |
| 1357 | const scale = scaleFor(docW); |
| 1358 | // The band to draw is the one the reader is about to be put back at, not the one |
| 1359 | // they are looking at now: the document may have grown above them, and drawing |
| 1360 | // where they are and then scrolling elsewhere would show a screen of blank paper |
| 1361 | // until the repaint caught up. |
| 1362 | let top = sc ? sc.scrollTop / (wasDoc.s || scale) : 0; |
| 1363 | if (was) { |
| 1364 | const i = Math.min(was.page, S.lays.length - 1); |
| 1365 | top = S.lays[i] + Math.min(was.into, heights[i] || 0); |
| 1366 | } |
| 1367 | const deep = sc ? sc.clientHeight / scale : (S.laid || 1); |
| 1368 | let node; |
| 1369 | try { |
| 1370 | node = bandNode(bytes, top, deep, scale); |
| 1371 | } catch (e) { |
| 1372 | // Nothing has been touched on screen yet, so putting the geometry back |
| 1373 | // leaves the pages that are up exactly as they were. |
| 1374 | S.docW = wasDoc.w; S.docH = wasDoc.h; |
| 1375 | S.tops = wasDoc.t; S.heights = wasDoc.hh; |
| 1376 | S.lays = wasDoc.l; S.laid = wasDoc.ld; |
| 1377 | S.rtops = wasDoc.rt; S.drift = wasDoc.d; |
| 1378 | throw e; |
| 1379 | } |
| 1380 | |
| 1381 | // The container carries the whole STACK's height so the scrollbar measures the |
| 1382 | // book and the gaps between its sheets; each sheet inside it carries one page. |
| 1383 | // Both are set in the same turn as the swap, so there is no frame where the two |
| 1384 | // disagree and the reader is bounced by a scroller that briefly forgot how long |
| 1385 | // the document was. |
| 1386 | pages.style.width = (docW * scale) + 'px'; |
| 1387 | pages.style.height = (S.laid * scale) + 'px'; |
| 1388 | paint(node); |
| 1389 | S.scale = scale; |
| 1390 | S.pages = n; |
| 1391 | if (was) goTo(was); else if (sc) sc.scrollTop = 0; |
| 1392 | sayWhere(); |
| 1393 | |
| 1394 | // THE LAST GOOD PAGES ARE THESE BYTES AND THE MARKS ON SCREEN, AND NOTHING ELSE. |
| 1395 | // No render session is held between draws — each one is built, used and freed — |
| 1396 | // so the renderer's heap settles at one document's worth rather than growing with |
| 1397 | // the session. What is kept is the vector artifact itself (11.1 MB for the |
| 1398 | // 281-page book), on the JavaScript heap, because a scroll has to be able to draw |
| 1399 | // a page the current source may no longer produce. |
| 1400 | vec = bytes; |
| 1401 | S.drawn++; |
| 1402 | // The scale may have moved a hair between builds, so the band is confirmed |
| 1403 | // against where the reader actually landed rather than where we aimed. In this |
| 1404 | // turn: the pages have just been replaced, and a band left for the scroll to |
| 1405 | // settle would be a band nobody is scrolling. |
| 1406 | ensureWindow(true); |
| 1407 | } |
| 1408 | |
| 1409 | |
| 1410 | // ── The sections, and which page each one is on ───────────────────────────── |
| 1411 | // |
| 1412 | // WHAT THE COMPILER WILL ANSWER, AND WHAT IT WILL NOT. `query('heading')` hands back |
| 1413 | // every heading in document order with its level and its words, in 6 ms on the |
| 1414 | // fixture and 9-14 ms on the author's 281-page book. That is the rail's contents, and |
| 1415 | // it is exact: it is the compiled document's own answer, not a reading of the source |
| 1416 | // and not a guess off the drawn page. |
| 1417 | // |
| 1418 | // IT WILL NOT ANSWER WHICH PAGE. Measured on this vendored typst 0.14.2 through |
| 1419 | // `dev/probe_typstoutline.mjs`: |
| 1420 | // |
| 1421 | // query('heading') 6 headings, level and body, 5.8 ms |
| 1422 | // query('heading', 'body') the words alone, 0.2 ms |
| 1423 | // query('heading', 'location') [] |
| 1424 | // query('heading', 'page') [] |
| 1425 | // |
| 1426 | // Typst does not put an element's location among its fields, so there is nothing to |
| 1427 | // ask for. `dev/TYPST_WATCH.md` §6 reached the same wall from the CLI and concluded |
| 1428 | // that a marker in the book was the way out; a rail that only worked for books that |
| 1429 | // had been altered to carry markers is not a rail. |
| 1430 | // |
| 1431 | // So the page comes from the LAID-OUT PAGES instead, which is the other half of the |
| 1432 | // same compile: each page is rendered on its own and its text read back, and each |
| 1433 | // heading is matched to its own words, in order, going forward and never back. |
| 1434 | // |
| 1435 | // TWO THINGS MAKE THAT HONEST RATHER THAN A GUESS. |
| 1436 | // |
| 1437 | // * IN ORDER. A heading is looked for at or after where the previous one was found, |
| 1438 | // so the rail cannot come out shuffled even when two chapters share a title. |
| 1439 | // * BY SIZE. A document with an `#outline()` prints every heading's words on its |
| 1440 | // first pages, and the naive match puts the whole rail on page 2. So each run |
| 1441 | // carries the size it was set at — typst.ts writes it as the group's own |
| 1442 | // `scale(0.0126,-0.0126)`, which is 12.6 pt — and where a heading's words appear |
| 1443 | // more than once, THE LARGEST SETTING WINS. A contents entry is set at body size |
| 1444 | // and the heading it points at is not. |
| 1445 | // |
| 1446 | // The one case this cannot separate is a heading set at exactly body size in a |
| 1447 | // document that also lists it in a contents; that rail entry lands on the contents |
| 1448 | // page. It is named here rather than hidden, and `dev/verify_typstwatch.mjs` proves |
| 1449 | // the ordinary case against a fixture that has a contents in it. |
| 1450 | |
| 1451 | /// The text runs of one rendered page, as `{ text, size }` in the order drawn. |
| 1452 | /// |
| 1453 | /// Read out of the markup with a regular expression rather than parsed: this is |
| 1454 | /// called once per page of the document and a `DOMParser` per page is the cost that |
| 1455 | /// made the whole-book draw untenable. The two things wanted are adjacent in the |
| 1456 | /// markup — the group's `scale(…)` is the type size in thousandths, and the `div.tsel` |
| 1457 | /// inside it is the run's words. |
| 1458 | function runsOf(svg) { |
| 1459 | const out = []; |
| 1460 | const re = /class="typst-text"[^>]*transform="scale\(([-\d.]+)[^"]*"[\s\S]*?class="tsel"[^>]*>([^<]*)</g; |
| 1461 | let m; |
| 1462 | while ((m = re.exec(svg)) !== null) { |
| 1463 | out.push({ size: Math.abs(parseFloat(m[1]) || 0) * 1000, text: unxml(m[2]) }); |
| 1464 | } |
| 1465 | return out; |
| 1466 | } |
| 1467 | |
| 1468 | /// The rendered markup, cut into pages: `[{ page, runs }]` in the order drawn. |
| 1469 | function pagesOfMarkup(markup, p0, p1) { |
| 1470 | const re = /<g[^>]*class="typst-page"[^>]*transform="translate\(\s*[-\d.]+\s*,\s*([-\d.]+)/g; |
| 1471 | const at = []; |
| 1472 | let m; |
| 1473 | while ((m = re.exec(markup)) !== null) at.push({ y: parseFloat(m[1]), from: m.index }); |
| 1474 | const out = []; |
| 1475 | for (let k = 0; k < at.length; k++) { |
| 1476 | const to = (k + 1 < at.length) ? at[k + 1].from : markup.length; |
| 1477 | const page = pageOfGroup(at[k].y, k, at.length, p0, p1); |
| 1478 | if (page >= 0) out.push({ page: page, runs: runsOf(markup.slice(at[k].from, to)) }); |
| 1479 | } |
| 1480 | return out; |
| 1481 | } |
| 1482 | |
| 1483 | /// Which page the `k`th of `n` groups returned for the window `p0`-`p1` is. |
| 1484 | /// |
| 1485 | /// BY ORDER, AND ONLY BY POSITION WHEN THE ORDER CANNOT BE TRUSTED. |
| 1486 | /// |
| 1487 | /// Two things about the renderer's answer have to be known here and neither is |
| 1488 | /// obvious. A WINDOW EMITS A GROUP FOR EVERY PAGE OF THE DOCUMENT, not for the pages |
| 1489 | /// in it — the ones outside simply come back empty — so the count to expect is the |
| 1490 | /// whole page count and a band of three pages arrives as two hundred and eighty-one |
| 1491 | /// groups. And READING THE GROUP'S OWN `translate(0, y)` IS WRONG: the renderer |
| 1492 | /// rounds that number to a whole point and then accumulates it, so on a page 453.543 |
| 1493 | /// pt tall the groups come back at 0, 454, 908, 1362 against tops of 0, 453.54, |
| 1494 | /// 907.09, 1360.63 — adrift by 0.46 pt a page, which by page two hundred names a page |
| 1495 | /// a fifth of a page away from the right one. That cost a rail every entry of which |
| 1496 | /// pointed at the contents. |
| 1497 | /// |
| 1498 | /// So the answer is the position in the sequence, which is exact for either count the |
| 1499 | /// renderer might answer with; the y is used only to make the best of it when the |
| 1500 | /// count says something has changed underfoot. |
| 1501 | /// |
| 1502 | /// AND WHEN IT COMES TO THAT, THE COMPARISON IS AGAINST WHAT THE RENDERER SAID, not |
| 1503 | /// against the exact sum: `S.rtops` holds the origin the renderer gave each page the |
| 1504 | /// last time a band was drawn, and comparing one of the renderer's own numbers with |
| 1505 | /// another of them is the only comparison that is not off by the accumulation |
| 1506 | /// described above. Before any band has said — the first draw of a document — the |
| 1507 | /// exact sum is all there is, and near the front of a book the two agree anyway. |
| 1508 | function pageOfGroup(y, k, n, p0, p1) { |
| 1509 | if (n === S.tops.length) return k; // every page, which is what it does |
| 1510 | if (n === p1 - p0 + 1) return p0 + k; // or just the window, one day |
| 1511 | let best = -1, off = Infinity; |
| 1512 | for (let i = 0; i < S.tops.length; i++) { |
| 1513 | const at = (S.rtops[i] != null) ? S.rtops[i] : S.tops[i]; |
| 1514 | const e = Math.abs(at - y); |
| 1515 | if (e < off) { off = e; best = i; } |
| 1516 | } |
| 1517 | return best; |
| 1518 | } |
| 1519 | |
| 1520 | /// The five XML entities typst.ts's markup can carry, back as themselves. |
| 1521 | function unxml(s) { |
| 1522 | return String(s).replace(/</g, '<').replace(/>/g, '>') |
| 1523 | .replace(/"/g, '"').replace(/'/g, '\'').replace(/&/g, '&'); |
| 1524 | } |
| 1525 | |
| 1526 | /// One string, for comparing what was typeset with what was asked for. |
| 1527 | /// |
| 1528 | /// Typst breaks a heading across lines wherever the measure runs out, and hyphenates |
| 1529 | /// while it is at it, so the words come back in pieces that do not line up with the |
| 1530 | /// source. Spaces go, and so does everything that is not a letter or a digit: what is |
| 1531 | /// left is the same for `Chapter one` and for `Chap- ter one` on two lines. |
| 1532 | function fold(s) { |
| 1533 | return String(s).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ''); |
| 1534 | } |
| 1535 | |
| 1536 | /// Pull the words out of whatever `query` answered for one heading. |
| 1537 | /// |
| 1538 | /// A heading's `body` is content, not a string: `= *Bold* title` comes back as a tree |
| 1539 | /// of `text` nodes. Everything with a `text` is taken, in order, and everything else |
| 1540 | /// is walked through — which is the same answer typst would print and needs no |
| 1541 | /// knowledge of which element types exist. |
| 1542 | function wordsOf(v) { |
| 1543 | if (v == null) return ''; |
| 1544 | if (typeof v === 'string') return v; |
| 1545 | if (Array.isArray(v)) return v.map(wordsOf).join(''); |
| 1546 | if (typeof v === 'object') { |
| 1547 | if (typeof v.text === 'string') return v.text; |
| 1548 | let out = ''; |
| 1549 | for (const k of ['body', 'children', 'child']) if (k in v) out += wordsOf(v[k]); |
| 1550 | return out; |
| 1551 | } |
| 1552 | return ''; |
| 1553 | } |
| 1554 | |
| 1555 | /// Ask the compiler what this document's headings are, and start finding their pages. |
| 1556 | /// |
| 1557 | /// Called from `build`, in the same turn as the compile that produced these pages, so |
| 1558 | /// the compiler still holds this project and answers out of the layout it has just |
| 1559 | /// memoised. IT IS NOT CALLED FROM THE VIEW: nothing a reader does to the rail, the |
| 1560 | /// zoom or the paper reaches the compiler, which is the rule the whole panel is built |
| 1561 | /// on. |
| 1562 | async function refreshToc() { |
| 1563 | const q = window.DaimondTypst && window.DaimondTypst.queryProject; |
| 1564 | if (!q) { S.toc = []; drawRail(); return; } // an older driver in this page |
| 1565 | let list = null; |
| 1566 | try { |
| 1567 | list = await q('heading'); |
| 1568 | } catch (e) { |
| 1569 | list = null; |
| 1570 | } |
| 1571 | S.toc = Array.isArray(list) ? list.map(function (h) { |
| 1572 | return { |
| 1573 | text: wordsOf(h && h.body).replace(/\s+/g, ' ').trim(), |
| 1574 | level: Math.max(1, Math.min(6, Number(h && (h.level || h.depth)) || 1)), |
| 1575 | page: 0, |
| 1576 | }; |
| 1577 | }).filter(function (e) { return e.text; }) : []; |
| 1578 | S.scanned = 0; |
| 1579 | drawRail(); |
| 1580 | locate(); |
| 1581 | } |
| 1582 | |
| 1583 | /// Find the page each heading is on, a chunk of pages at a time. |
| 1584 | /// |
| 1585 | /// ONLY WHILE THE RAIL IS OPEN, because it is the rail that wants the answer and a |
| 1586 | /// reader who never opens it should not pay for one. It renders and never compiles: |
| 1587 | /// on the compiler's side nothing happens at all, and on the renderer's side one |
| 1588 | /// session is built, asked for each page in turn, and freed. |
| 1589 | /// |
| 1590 | /// It abandons itself the moment another build lands — `S.drawn` moves — because the |
| 1591 | /// pages it is halfway through reading are no longer the pages on screen. |
| 1592 | /// |
| 1593 | /// AND IT WAITS FOR THE TYPING TO STOP FIRST. Every good build calls this again, and |
| 1594 | /// every call throws away the walk in progress, so a book being edited with the rail |
| 1595 | /// open scanned the whole document over and over and finished none of them — all of |
| 1596 | /// it in `SCAN_CHUNK`-sized tasks in the animation frames the reader is scrolling |
| 1597 | /// with. Re-arming a timer is what "the last one wins" costs here, and it is the same |
| 1598 | /// shape as the rebuild's own debounce. |
| 1599 | function locate() { |
| 1600 | if (waiting) clearTimeout(waiting); |
| 1601 | waiting = setTimeout(function () { |
| 1602 | waiting = null; |
| 1603 | startScan(); |
| 1604 | }, SCAN_IDLE); |
| 1605 | } |
| 1606 | |
| 1607 | /// Begin the walk, if there is one to begin. |
| 1608 | async function startScan() { |
| 1609 | // ONE SCAN AT A TIME. `S.scanned` only says a scan has FINISHED, so without this |
| 1610 | // a reader who shuts the rail and opens it again starts a second walk of the book |
| 1611 | // beside the first — twice the renderer's work for one answer, and on a 281-page |
| 1612 | // document that is the difference between a rail that fills in and a tab that |
| 1613 | // stutters. |
| 1614 | if (scanning || !S.rail || !vec || !S.toc.length || S.scanned === S.drawn) return; |
| 1615 | scanning = true; |
| 1616 | try { |
| 1617 | await scan(); |
| 1618 | } finally { |
| 1619 | scanning = false; |
| 1620 | } |
| 1621 | } |
| 1622 | |
| 1623 | let waiting = null; // the timer that starts the scan once the builds stop |
| 1624 | let scanning = false; // a page scan is walking the document right now |
| 1625 | |
| 1626 | /// The walk itself. `locate` owns the wait, `startScan` the guard; this owns the |
| 1627 | /// answer. |
| 1628 | async function scan() { |
| 1629 | const serial = S.drawn; |
| 1630 | await getRenderer(); |
| 1631 | if (S.drawn !== serial || !vec) return; |
| 1632 | // Every occurrence of every heading's words, with the size it was set at, so the |
| 1633 | // contents entry and the heading itself can be told apart afterwards. |
| 1634 | const want = S.toc.map(function (e) { return fold(e.text); }); |
| 1635 | const seen = S.toc.map(function () { return []; }); // [{ page, size }] |
| 1636 | // A CHUNK AT A TIME, ONE SESSION AND ONE CALL EACH. One call, because a session |
| 1637 | // asked twice answers with a diff and a repeated paragraph comes back empty; a |
| 1638 | // chunk rather than the whole book, because the whole of the author's 281-page |
| 1639 | // document is 23.7 MB of markup to hold at once and `SCAN_CHUNK` pages of it is |
| 1640 | // about three. |
| 1641 | try { |
| 1642 | for (let a = 0; a < S.pages; a += SCAN_CHUNK) { |
| 1643 | const b = Math.min(S.pages - 1, a + SCAN_CHUNK - 1); |
| 1644 | const ses = renderer.session_from_artifact(vec, 'vector'); |
| 1645 | let markup; |
| 1646 | try { |
| 1647 | markup = windowOf(ses, a, b); |
| 1648 | } finally { |
| 1649 | try { ses.free(); } catch (e) { /* already gone */ } |
| 1650 | } |
| 1651 | for (const pg of pagesOfMarkup(markup, a, b)) { |
| 1652 | // A heading that broke across lines is several runs, so a page is |
| 1653 | // folded into one string PER SIZE and each heading looked for in each. |
| 1654 | const bySize = new Map(); |
| 1655 | for (const r of pg.runs) { |
| 1656 | const k = Math.round(r.size * 10); |
| 1657 | bySize.set(k, (bySize.get(k) || '') + fold(r.text)); |
| 1658 | } |
| 1659 | for (const [k, text] of bySize) { |
| 1660 | for (let j = 0; j < want.length; j++) { |
| 1661 | if (want[j] && text.indexOf(want[j]) >= 0) { |
| 1662 | seen[j].push({ page: pg.page + 1, size: k / 10 }); |
| 1663 | } |
| 1664 | } |
| 1665 | } |
| 1666 | } |
| 1667 | await frame(); |
| 1668 | if (S.drawn !== serial || !vec || !S.rail) return; |
| 1669 | } |
| 1670 | } catch (e) { |
| 1671 | return; // the rail keeps whatever it had; nothing on screen moved |
| 1672 | } |
| 1673 | if (S.drawn !== serial) return; |
| 1674 | // THE LARGEST SETTING WINS, AND NEVER BEFORE THE ONE ABOVE IT. The first rule |
| 1675 | // separates a heading from its own contents entry; the second keeps the rail in |
| 1676 | // the document's order when two chapters share a title. |
| 1677 | let floor = 0; |
| 1678 | for (let j = 0; j < S.toc.length; j++) { |
| 1679 | const forward = seen[j].filter(function (c) { return c.page >= floor; }); |
| 1680 | const pool = forward.length ? forward : seen[j]; |
| 1681 | let best = null; |
| 1682 | for (const c of pool) { |
| 1683 | if (!best || c.size > best.size + 0.05) best = c; |
| 1684 | } |
| 1685 | S.toc[j].page = best ? best.page : 0; |
| 1686 | if (best) floor = best.page; |
| 1687 | } |
| 1688 | S.scanned = serial; |
| 1689 | drawRail(); |
| 1690 | } |
| 1691 | |
| 1692 | /// The next animation frame, so a long scan gives the page back between chunks. |
| 1693 | function frame() { |
| 1694 | return new Promise(function (res) { |
| 1695 | if (typeof requestAnimationFrame === 'function') requestAnimationFrame(function () { res(); }); |
| 1696 | else setTimeout(res, 0); |
| 1697 | }); |
| 1698 | } |
| 1699 | |
| 1700 | /// Open or close the section rail. |
| 1701 | /// |
| 1702 | /// A VIEW CHANGE AND NOTHING ELSE. It shows what the last build already answered; the |
| 1703 | /// only work it can start is the page scan, which is the renderer's and never the |
| 1704 | /// compiler's. |
| 1705 | function rail(on) { |
| 1706 | S.rail = !!on; |
| 1707 | if (!host) return S.rail; |
| 1708 | const nav = host.querySelector('.tl-rail'); |
| 1709 | const b = host.querySelector('.tl-toc'); |
| 1710 | nav.hidden = !S.rail; |
| 1711 | b.setAttribute('aria-pressed', S.rail ? 'true' : 'false'); |
| 1712 | b.setAttribute('aria-expanded', S.rail ? 'true' : 'false'); |
| 1713 | host.setAttribute('data-rail', S.rail ? '1' : '0'); |
| 1714 | if (S.rail) locate(); |
| 1715 | // The pages are drawn to the scroller's width, and the rail just took some of it. |
| 1716 | resized(); |
| 1717 | return S.rail; |
| 1718 | } |
| 1719 | |
| 1720 | /// Put the sections in the rail, and mark the one the reader is in. |
| 1721 | function drawRail() { |
| 1722 | if (!host) return; |
| 1723 | const ol = host.querySelector('.tl-toclist'); |
| 1724 | const none = host.querySelector('.tl-tocnone'); |
| 1725 | none.style.display = S.toc.length ? 'none' : ''; |
| 1726 | if (ol.childElementCount !== S.toc.length) { |
| 1727 | const frag = document.createDocumentFragment(); |
| 1728 | for (let i = 0; i < S.toc.length; i++) { |
| 1729 | const li = document.createElement('li'); |
| 1730 | const a = document.createElement('button'); |
| 1731 | a.type = 'button'; |
| 1732 | a.className = 'tl-tocgo'; |
| 1733 | a.appendChild(document.createTextNode('')); |
| 1734 | const n = document.createElement('span'); |
| 1735 | n.className = 'tl-tocpage'; |
| 1736 | a.appendChild(n); |
| 1737 | li.appendChild(a); |
| 1738 | frag.appendChild(li); |
| 1739 | a.addEventListener('click', function () { |
| 1740 | const e = S.toc[i]; |
| 1741 | if (e && e.page) goToPage(e.page); |
| 1742 | }); |
| 1743 | } |
| 1744 | ol.replaceChildren(frag); |
| 1745 | } |
| 1746 | const kids = ol.children; |
| 1747 | for (let i = 0; i < S.toc.length; i++) { |
| 1748 | const e = S.toc[i]; |
| 1749 | const li = kids[i]; |
| 1750 | const a = li.firstElementChild; |
| 1751 | li.setAttribute('data-level', String(e.level)); |
| 1752 | a.firstChild.nodeValue = e.text; |
| 1753 | a.lastElementChild.textContent = e.page ? String(e.page) : ''; |
| 1754 | a.disabled = !e.page; |
| 1755 | } |
| 1756 | hereNow = -2; // whatever was marked, the list under it is new |
| 1757 | markHere(); |
| 1758 | } |
| 1759 | |
| 1760 | let hereNow = -2; // the rail entry last marked, so a scroll costs nothing |
| 1761 | |
| 1762 | /// Mark the section the reader is inside, and only when it changes. |
| 1763 | /// |
| 1764 | /// Called from every scroll, so it does the least work that says the truth: the |
| 1765 | /// entry is the last one that STARTS at or before the page in view, and an entry |
| 1766 | /// whose page is not known yet cannot be it. |
| 1767 | /// |
| 1768 | /// # Arguments |
| 1769 | /// * `w` - The reader's place, when the caller has already worked it out. Asking for |
| 1770 | /// it again is a `scrollTop` read after the bar has been written, which makes |
| 1771 | /// the browser lay the panel out a second time in the same frame. |
| 1772 | function markHere(w) { |
| 1773 | if (!host || !S.rail) return; |
| 1774 | const at = (w === undefined) ? where() : w; |
| 1775 | const page = at ? at.page + 1 : 1; |
| 1776 | let cur = -1; |
| 1777 | for (let i = 0; i < S.toc.length; i++) if (S.toc[i].page && S.toc[i].page <= page) cur = i; |
| 1778 | if (cur === hereNow) return; |
| 1779 | const kids = host.querySelector('.tl-toclist').children; |
| 1780 | if (hereNow >= 0 && kids[hereNow]) { |
| 1781 | kids[hereNow].setAttribute('data-here', '0'); |
| 1782 | kids[hereNow].firstElementChild.removeAttribute('aria-current'); |
| 1783 | } |
| 1784 | if (cur >= 0 && kids[cur]) { |
| 1785 | kids[cur].setAttribute('data-here', '1'); |
| 1786 | kids[cur].firstElementChild.setAttribute('aria-current', 'true'); |
| 1787 | } |
| 1788 | hereNow = cur; |
| 1789 | } |
| 1790 | |
| 1791 | |
| 1792 | // ── The loop ──────────────────────────────────────────────────────────────── |
| 1793 | |
| 1794 | // ── A stamp that moved is not yet a change ────────────────────────────────── |
| 1795 | // |
| 1796 | // A file says it was written; that is not the same as saying it says something |
| 1797 | // different. The author watched the bar cycle `Live` / `Rebuilding` every one to |
| 1798 | // two seconds with nothing edited, and by the end of it the compiler was holding |
| 1799 | // 2427 MB of the 2500 it is allowed. A rebuild on bytes that have not changed is |
| 1800 | // FREE — comemo hashes the text and hands back the layout it already has, measured |
| 1801 | // at 0 MB on the author's 281-page book — so a spin costs nothing until the moment |
| 1802 | // something really does differ, and then about 10 MB a time. Two hundred of those |
| 1803 | // is the wall. `dev/probe_typstloop.mjs` has both figures. |
| 1804 | // |
| 1805 | // So a moved stamp is CONFIRMED against the file's contents before it counts. That |
| 1806 | // is one small read of one file, and only of the file that moved. |
| 1807 | // |
| 1808 | // THE OUTPUT CANNOT BE USED FOR THIS, which was tried first and is worth writing |
| 1809 | // down: compiling the same three-line document three times gives three vector |
| 1810 | // artifacts of identical length in which 5,624 of 7,816 bytes differ, and the SVG |
| 1811 | // rendered from them differs too. typst.ts's format carries per-item fingerprints |
| 1812 | // that are not stable between compiles, so "the pages came out the same" is not a |
| 1813 | // question this side can ask. The INPUT is, and it is the honest question anyway. |
| 1814 | |
| 1815 | /// The largest file whose contents are read back to confirm a change. |
| 1816 | /// |
| 1817 | /// Sources are kilobytes and get checked. A font is eleven megabytes and does not |
| 1818 | /// rewrite itself, so above this a moved stamp is taken at its word rather than |
| 1819 | /// paying to read a typeface on every poll. |
| 1820 | const DIGEST_MAX = 1048576; |
| 1821 | |
| 1822 | /// A cheap digest of a file's BYTES, for telling a rewrite from a rewording. |
| 1823 | /// |
| 1824 | /// Bytes and not text, through `read_bytes` rather than `read_file`: the latter ends |
| 1825 | /// in `from_utf8_lossy`, so every byte of a picture that is not valid UTF-8 becomes |
| 1826 | /// the same replacement character and two different pictures can digest alike. A |
| 1827 | /// watcher that stopped following a figure because two versions of it decoded to the |
| 1828 | /// same mush would be a very hard afternoon. |
| 1829 | function digest(bytes) { |
| 1830 | let h = bytes.length >>> 0; |
| 1831 | for (let i = 0; i < bytes.length; i++) h = (h * 31 + bytes[i]) >>> 0; |
| 1832 | return String(h) + ':' + bytes.length; |
| 1833 | } |
| 1834 | |
| 1835 | /// Whether `path` still says exactly what it said when it was last read. |
| 1836 | /// |
| 1837 | /// `false` whenever the answer is not known — the file is too big to check, it |
| 1838 | /// could not be read, it is gone, or this is the first time it has been asked. An |
| 1839 | /// unknown is a change, because the alternative is a preview that quietly stops |
| 1840 | /// following a file. |
| 1841 | /// |
| 1842 | /// # Arguments |
| 1843 | /// * `path` - The workspace-relative path whose stamp moved. |
| 1844 | /// * `stamp` - What it just said about itself, `"<lastModified>:<size>"`. |
| 1845 | async function confirmed(path, stamp) { |
| 1846 | const size = Number(String(stamp).split(':')[1]); |
| 1847 | if (!(size >= 0) || size > DIGEST_MAX) return false; |
| 1848 | let bytes; |
| 1849 | try { |
| 1850 | const m = await wasm(); |
| 1851 | bytes = await m.read_bytes(path, 0, size); |
| 1852 | } catch (e) { |
| 1853 | return false; |
| 1854 | } |
| 1855 | if (!bytes || bytes.length !== size) return false; |
| 1856 | const d = digest(bytes); |
| 1857 | const was = S.digests[path]; |
| 1858 | S.digests[path] = d; |
| 1859 | return was !== undefined && was === d; |
| 1860 | } |
| 1861 | |
| 1862 | /// Whether the heap has room for another rebuild, and the sentence if it has not. |
| 1863 | /// |
| 1864 | /// The heap is MONOTONIC — a wasm32 `Memory` grows and never shrinks — so this is |
| 1865 | /// a high-water mark, not a sample, and once it is over the budget it stays over. |
| 1866 | /// That is why the honest remedy is a reload and not "wait a moment". |
| 1867 | /// |
| 1868 | /// `headroom` is the biggest growth any one rebuild has cost so far, so the |
| 1869 | /// question asked is "would ANOTHER one like the last fit", which is the question |
| 1870 | /// that matters. Before anything has been measured it is `HEADROOM_MIN`. |
| 1871 | function holdCheck() { |
| 1872 | const heap = (window.DaimondTypst && window.DaimondTypst.heapMB) |
| 1873 | ? window.DaimondTypst.heapMB() : 0; |
| 1874 | if (heap + S.headroom <= S.budget) return ''; |
| 1875 | return tOr('typst.watch.heap', |
| 1876 | 'The compiler is holding {heap} MB and another rebuild could need {more} MB more, ' |
| 1877 | + 'which is past the {budget} MB it is allowed on this page. Rebuilding on every save ' |
| 1878 | + 'has stopped, and the pages below are the last ones that built. The compiler cannot ' |
| 1879 | + 'give that memory back — reload the page to start it fresh, or press Rebuild to try ' |
| 1880 | + 'once anyway.', |
| 1881 | { heap: Math.round(heap), more: Math.round(S.headroom), budget: Math.round(S.budget) }); |
| 1882 | } |
| 1883 | |
| 1884 | /// Stop rebuilding on every save, and say why. |
| 1885 | function hold(why) { |
| 1886 | S.mode = 'held'; |
| 1887 | S.reason = why; |
| 1888 | if (timer) { clearTimeout(timer); timer = null; } |
| 1889 | S.queued = false; |
| 1890 | says(tOr('typst.watch.held', 'Rebuilding stopped'), 'held'); |
| 1891 | showError(why); |
| 1892 | offerRebuild(true); |
| 1893 | } |
| 1894 | |
| 1895 | /// The compiler is gone until the page is reloaded, and nothing pretends otherwise. |
| 1896 | function dead(why) { |
| 1897 | S.mode = 'dead'; |
| 1898 | S.reason = why; |
| 1899 | if (timer) { clearTimeout(timer); timer = null; } |
| 1900 | if (poller) { clearInterval(poller); poller = null; } |
| 1901 | S.queued = false; |
| 1902 | says(tOr('typst.watch.dead', 'The compiler has stopped'), 'dead'); |
| 1903 | showError(tOr('typst.watch.dead_why', |
| 1904 | 'The compiler ran out of memory on this document and cannot be restarted without ' |
| 1905 | + 'reloading the page. The pages below are the last ones that built. Reload, and open ' |
| 1906 | + 'the same file again.') + '\n\n' + why); |
| 1907 | offerRebuild(false); |
| 1908 | } |
| 1909 | |
| 1910 | /// Rebuild now, coalescing anything already in flight. |
| 1911 | /// |
| 1912 | /// ONE COMPILE AT A TIME AND ONE QUEUED, and a third edit replaces the queued one. |
| 1913 | /// Without that a fast typist queues twenty compiles and the preview runs minutes |
| 1914 | /// behind the text, which is worse than no preview at all because it looks like one. |
| 1915 | /// |
| 1916 | /// # Arguments |
| 1917 | /// * `force` - Skip the heap budget once, because the user pressed Rebuild knowing |
| 1918 | /// what it said. The loop never does this to itself. |
| 1919 | async function build(force) { |
| 1920 | if (S.mode === 'dead') return; |
| 1921 | if (S.building) { S.queued = true; return; } |
| 1922 | if (!force) { |
| 1923 | const why = holdCheck(); |
| 1924 | if (why) { hold(why); return; } |
| 1925 | } |
| 1926 | S.building = true; |
| 1927 | S.builds++; |
| 1928 | says(tOr('typst.watch.building', 'Rebuilding…'), 'building'); |
| 1929 | // The files as they stand NOW, taken before the compile reads them. |
| 1930 | // |
| 1931 | // Without this the poll counts the same edit twice: `touched` fires on the write |
| 1932 | // and rebuilds, then the next poll finds the stamps different from ITS last |
| 1933 | // reading and rebuilds again — one keystroke, two compiles, and a burst that |
| 1934 | // coalesced correctly still ran twice. Taken BEFORE rather than after, so a write |
| 1935 | // landing mid-compile is a change the next poll still sees. |
| 1936 | // |
| 1937 | // KEYED BY PATH, NOT BY POSITION. The list itself moves — an edit that adds an |
| 1938 | // `#import` brings a file in, one that removes it takes a file out — and a |
| 1939 | // comparison by index reads every entry after the change as changed, rebuilds, |
| 1940 | // records the new list against the OLD list's readings, and finds them all |
| 1941 | // changed again. That is a rebuild loop with nothing edited, and it costs about |
| 1942 | // 10 MB of wasm heap every time the bytes really do differ (measured, on the |
| 1943 | // author's 281-page book, in `dev/probe_typstloop.mjs`), so a spin nobody |
| 1944 | // notices walks the compiler into the ceiling in a few hundred rebuilds. |
| 1945 | S.stamps = (await stamps()) || S.stamps; |
| 1946 | const heapBefore = (window.DaimondTypst && window.DaimondTypst.heapMB) |
| 1947 | ? window.DaimondTypst.heapMB() : 0; |
| 1948 | const t0 = Date.now(); |
| 1949 | let out = null; |
| 1950 | try { |
| 1951 | const m = await wasm(); |
| 1952 | out = await m.typst_compile_project_vector(S.path); |
| 1953 | } catch (e) { |
| 1954 | out = { error: (e && e.message) ? e.message : String(e) }; |
| 1955 | } |
| 1956 | const took = Date.now() - t0; |
| 1957 | const heapAfter = (window.DaimondTypst && window.DaimondTypst.heapMB) |
| 1958 | ? window.DaimondTypst.heapMB() : 0; |
| 1959 | if (heapAfter - heapBefore > S.headroom) S.headroom = heapAfter - heapBefore; |
| 1960 | |
| 1961 | // The watch list is re-read from every compile, failed or not: an edit that adds |
| 1962 | // an `#import` brings a file into the project, and a watcher still polling |
| 1963 | // yesterday's list would never see it change. |
| 1964 | if (out && out.watch && out.watch.length) { |
| 1965 | S.files = Array.from(out.watch).map(String); |
| 1966 | } |
| 1967 | |
| 1968 | if (out && out.vector && out.vector.length) { |
| 1969 | // A REBUILD NOBODY COULD CONFIRM is counted, because it is the only kind left |
| 1970 | // that can spin. Everything small enough to read back is checked against its |
| 1971 | // own contents and never gets here twice for nothing; what remains is a file |
| 1972 | // too big to check saying it moved, over and over, which is a loop with no |
| 1973 | // evidence behind it and the heap ceiling at the end of it. |
| 1974 | S.same = S.blind ? S.same + 1 : 0; |
| 1975 | try { |
| 1976 | await draw(out.vector); |
| 1977 | // The sections, asked of the compiler in the same turn as the compile that |
| 1978 | // produced these pages — see `refreshToc`. Not awaited on purpose: the |
| 1979 | // pages are up and the rail filling in a moment later costs the reader |
| 1980 | // nothing, whereas a rail that held the loop would. |
| 1981 | refreshToc(); |
| 1982 | // A good build after a bad one clears the error SILENTLY. Nothing |
| 1983 | // announces the fix: the reader is looking at the page they were |
| 1984 | // trying to get back, and a banner saying so is in the way. |
| 1985 | showError(''); |
| 1986 | // A HELD LOOP IS NOT UNHELD BY A BUILD SUCCEEDING. The user pressing |
| 1987 | // Rebuild proves the compile fits today, not that the heap has room for |
| 1988 | // the next one — and the heap only ever grows, so it usually has less. |
| 1989 | // Resuming on a success would quietly put the loop back on the path to |
| 1990 | // the wall, which is the one thing the budget exists to prevent. It |
| 1991 | // resumes only when the budget itself says there is room, which after a |
| 1992 | // reload it does. |
| 1993 | if (S.mode === 'held' && !holdCheck()) { |
| 1994 | S.mode = 'live'; |
| 1995 | S.reason = ''; |
| 1996 | offerRebuild(false); |
| 1997 | } else if (S.mode === 'held') { |
| 1998 | showError(S.reason); |
| 1999 | says(tOr('typst.watch.held', 'Rebuilding stopped'), 'held'); |
| 2000 | } |
| 2001 | } catch (e) { |
| 2002 | S.failed++; |
| 2003 | showError((e && e.message) ? e.message : String(e)); |
| 2004 | } |
| 2005 | } else { |
| 2006 | S.failed++; |
| 2007 | const why = (out && out.error) ? String(out.error) : tOr('typst.watch.nothing', |
| 2008 | 'The compiler produced nothing and gave no reason.'); |
| 2009 | showError(why); |
| 2010 | if (TRAPPED.test(why)) { |
| 2011 | S.building = false; |
| 2012 | dead(why); |
| 2013 | return; |
| 2014 | } |
| 2015 | } |
| 2016 | |
| 2017 | // The wait is the last rebuild's own length, so it fits whatever is being |
| 2018 | // written rather than whatever was guessed when this was written. |
| 2019 | S.debounce = Math.max(DEBOUNCE_MIN, Math.min(DEBOUNCE_MAX, took)); |
| 2020 | S.building = false; |
| 2021 | if (S.mode === 'live' && S.same >= SPIN_MAX) { |
| 2022 | S.same = 0; |
| 2023 | hold(tOr('typst.watch.spin', |
| 2024 | 'A watched file keeps reporting that it has changed, and it is too large ' |
| 2025 | + 'to read back and check: the pages have been laid out {n} times in a row ' |
| 2026 | + 'for it. Rebuilding on every save has stopped, so that it cannot fill ' |
| 2027 | + 'the compiler\u2019s memory. Press Rebuild when you want the pages ' |
| 2028 | + 'again. The file was {what}.', |
| 2029 | { n: SPIN_MAX + 1, what: S.why.replace(/^(poll|write): /, '').split(' ')[0] })); |
| 2030 | return; |
| 2031 | } |
| 2032 | if (S.mode === 'live') { |
| 2033 | says(S.error |
| 2034 | ? tOr('typst.watch.stale', 'Showing the last build that worked') |
| 2035 | : tOr('typst.watch.live_preview', 'Live preview'), S.error ? 'stale' : 'live'); |
| 2036 | } |
| 2037 | if (S.queued) { S.queued = false; if (S.mode === 'live') build(false); } |
| 2038 | } |
| 2039 | |
| 2040 | /// Something was written; rebuild when the writing stops. |
| 2041 | /// |
| 2042 | /// WHAT ASKED FOR THE REBUILD IS RECORDED, and comes back out through `state()`. |
| 2043 | /// A loop that rebuilds when nothing has been edited is the one fault a reader |
| 2044 | /// cannot diagnose from the screen — the bar says `Rebuilding…` and that is all — |
| 2045 | /// so the answer to "what did it think had changed" is kept where it can be read |
| 2046 | /// back and reported, instead of being worked out again from scratch each time. |
| 2047 | /// |
| 2048 | /// # Arguments |
| 2049 | /// * `cause` - `'write'` or `'poll'`: how the change was noticed. |
| 2050 | /// * `detail` - The path, and for a poll the two readings that differ. |
| 2051 | function nudge(cause, detail) { |
| 2052 | if (S.mode !== 'live') return; |
| 2053 | S.cause = cause || ''; |
| 2054 | S.why = (cause || '') + ': ' + (detail || ''); |
| 2055 | if (timer) clearTimeout(timer); |
| 2056 | timer = setTimeout(function () { timer = null; build(false); }, S.debounce); |
| 2057 | } |
| 2058 | |
| 2059 | /// What every watched file says about itself right now, as `{ path: stamp }`. |
| 2060 | /// |
| 2061 | /// One question, asked from two places — the poll, and the moment before a rebuild |
| 2062 | /// — so the loop cannot end up with two ideas of what it has already seen. |
| 2063 | /// |
| 2064 | /// BY PATH, because the list is not stable and the answer has to survive it moving. |
| 2065 | /// `null` when the question could not be asked, which is not the same as "nothing |
| 2066 | /// changed" and must not be recorded as a reading. |
| 2067 | async function stamps() { |
| 2068 | if (!S.files.length) return null; |
| 2069 | try { |
| 2070 | const m = await wasm(); |
| 2071 | const got = Array.from(await m.typst_watch_stamps(S.files)).map(String); |
| 2072 | const out = {}; |
| 2073 | for (let i = 0; i < S.files.length && i < got.length; i++) out[S.files[i]] = got[i]; |
| 2074 | return out; |
| 2075 | } catch (e) { |
| 2076 | return null; // a question that could not be asked is not an answer |
| 2077 | } |
| 2078 | } |
| 2079 | |
| 2080 | /// Ask the watched files whether they have changed, and nudge if any has. |
| 2081 | async function poll() { |
| 2082 | if (S.mode === 'dead' || !S.path || !S.files.length) return; |
| 2083 | // The view being gone is what ends the watch. There is no toggle and no second |
| 2084 | // concept: the reader closed the document, so nothing more is compiled for it. |
| 2085 | // |
| 2086 | // "Gone" and "not there yet" are different, and telling them apart is the whole |
| 2087 | // of `seen`. The watch is armed by a compile that finished BEFORE the panel was |
| 2088 | // shown -- `began` is called from Rust, and the page opens the panel a moment |
| 2089 | // later, in the same turn -- so a poll landing in that gap would find the panel |
| 2090 | // invisible and stop a watch that had never started. |
| 2091 | const shown = host && host.isConnected && visible(document.getElementById('panel-preview')); |
| 2092 | if (shown) S.seen = true; |
| 2093 | if (S.seen && !shown) { |
| 2094 | stop(); |
| 2095 | return; |
| 2096 | } |
| 2097 | if (!shown) return; |
| 2098 | const before = S.stamps; |
| 2099 | const now = await stamps(); |
| 2100 | if (!now) return; // the question could not be asked |
| 2101 | S.stamps = now; |
| 2102 | if (!before) return; // nothing to compare a first reading against |
| 2103 | // A path in ONE of the two readings is not a change. A file the last compile |
| 2104 | // brought into the project has never been read before, and one it dropped is no |
| 2105 | // longer part of the document; either would be reported as a change by a |
| 2106 | // comparison that walked positions, and neither is one. A watched file DELETED |
| 2107 | // from disk still answers, as `0:-1`, so a chapter removed under the reader is |
| 2108 | // still a rebuild — which is the case the old comparison was written for. |
| 2109 | for (const p in now) { |
| 2110 | if (!(p in before) || now[p] === before[p]) continue; |
| 2111 | // It said it was written. Whether it says anything DIFFERENT is another |
| 2112 | // question, and the one that decides whether there is anything to lay out. |
| 2113 | if (await confirmed(p, now[p])) continue; |
| 2114 | S.blind = (Number(String(now[p]).split(':')[1]) > DIGEST_MAX); |
| 2115 | nudge('poll', p + ' ' + before[p] + ' \u2192 ' + now[p]); |
| 2116 | return; |
| 2117 | } |
| 2118 | } |
| 2119 | |
| 2120 | /// Whether an element is on screen at all. |
| 2121 | function visible(el) { |
| 2122 | if (!el) return false; |
| 2123 | return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); |
| 2124 | } |
| 2125 | |
| 2126 | |
| 2127 | // ── The doors ─────────────────────────────────────────────────────────────── |
| 2128 | |
| 2129 | /// A document has just been compiled from the page: follow it from here. |
| 2130 | /// |
| 2131 | /// Called by `src/wasm/typst.rs` at the page's own Compile door, after a compile |
| 2132 | /// that WORKED. That is the whole of how the watch starts — no toggle, no setting, |
| 2133 | /// and nothing that reaches for a compiler on its own. A vector compile does not |
| 2134 | /// call this, or every rebuild would re-arm the loop that produced it. |
| 2135 | /// |
| 2136 | /// # Arguments |
| 2137 | /// * `path` - The `.typ` that was compiled, workspace-relative. |
| 2138 | /// * `watch` - Every real path that compile read. |
| 2139 | function began(path, watch) { |
| 2140 | const p = String(path || ''); |
| 2141 | if (!p) return; |
| 2142 | const files = watch ? Array.from(watch).map(String) : []; |
| 2143 | if (S.path === p && S.mode !== 'idle' && host && host.isConnected) { |
| 2144 | S.files = files.length ? files : S.files; |
| 2145 | // Compiling again wrote a fresh PDF and put the `<embed>` back on screen, so |
| 2146 | // the live view takes its place again rather than sitting beside it. |
| 2147 | if (S.drawn) standIn(); |
| 2148 | return; // already following this one |
| 2149 | } |
| 2150 | stop(); |
| 2151 | S.path = p; |
| 2152 | S.files = files; |
| 2153 | S.stamps = null; |
| 2154 | S.cause = 'began'; |
| 2155 | S.why = 'began: ' + p; |
| 2156 | S.digests = {}; |
| 2157 | S.blind = false; |
| 2158 | S.same = 0; |
| 2159 | S.mode = 'live'; |
| 2160 | S.builds = 0; |
| 2161 | S.drawn = 0; |
| 2162 | S.failed = 0; |
| 2163 | S.debounce = DEBOUNCE_MIN; |
| 2164 | S.headroom = HEADROOM_MIN; |
| 2165 | S.seen = false; |
| 2166 | S.toc = []; |
| 2167 | S.scanned = 0; |
| 2168 | if (!mount()) { S.mode = 'idle'; S.path = ''; return; } |
| 2169 | says(tOr('typst.watch.starting', 'Laying out the pages…'), 'building'); |
| 2170 | // The first live view is drawn BEFORE anything is hidden, so the PDF the button |
| 2171 | // just produced stays on screen until there are pages to put in its place. |
| 2172 | build(false).then(function () { |
| 2173 | if (S.mode !== 'idle' && S.drawn) standIn(); |
| 2174 | }); |
| 2175 | poll(); |
| 2176 | if (poller) clearInterval(poller); |
| 2177 | poller = setInterval(function () { poll(); }, POLL_MS); |
| 2178 | } |
| 2179 | |
| 2180 | /// Stop watching and put the panel back as it was. |
| 2181 | /// |
| 2182 | /// Called when the reader closes the document, and by `began` before it follows a |
| 2183 | /// different one. Nothing is compiled after this: the poll is cleared, the pending |
| 2184 | /// debounce is cleared, and a build already in flight finds `mode` no longer `live`. |
| 2185 | function stop() { |
| 2186 | if (timer) { clearTimeout(timer); timer = null; } |
| 2187 | if (poller) { clearInterval(poller); poller = null; } |
| 2188 | // The two timers the VIEW owns go with them: a band waiting for the scroll to |
| 2189 | // settle and a scan waiting for the builds to stop are both work for a document |
| 2190 | // nobody is looking at any more. |
| 2191 | if (settling) { clearTimeout(settling); settling = null; } |
| 2192 | if (waiting) { clearTimeout(waiting); waiting = null; } |
| 2193 | unmount(); |
| 2194 | S.path = ''; |
| 2195 | S.files = []; |
| 2196 | S.stamps = null; |
| 2197 | S.mode = 'idle'; |
| 2198 | S.queued = false; |
| 2199 | S.seen = false; |
| 2200 | S.error = ''; |
| 2201 | S.reason = ''; |
| 2202 | S.cause = ''; |
| 2203 | S.why = ''; |
| 2204 | S.digests = {}; |
| 2205 | S.blind = false; |
| 2206 | S.same = 0; |
| 2207 | S.toc = []; |
| 2208 | S.scanned = 0; |
| 2209 | S.tops = []; |
| 2210 | // WITH `tops`, ALWAYS. An origin left behind by the document that was open is an |
| 2211 | // origin from another book, and the next document's pages would be cropped at it. |
| 2212 | S.rtops = []; |
| 2213 | S.drift = 0; |
| 2214 | S.heights = []; |
| 2215 | S.lays = []; |
| 2216 | S.laid = 0; |
| 2217 | S.pages = 0; |
| 2218 | S.bandErr = ''; |
| 2219 | sick = null; |
| 2220 | hereNow = -2; |
| 2221 | } |
| 2222 | |
| 2223 | /// A writer that KNOWS it wrote says so, rather than waiting to be polled. |
| 2224 | /// |
| 2225 | /// Both actors drive this loop and they are known in different ways. A daimon's |
| 2226 | /// write and the panel's own Save both go through the one Rust door for bytes, and |
| 2227 | /// that door says so here; an external editor writing into a folder the user marked |
| 2228 | /// in cannot say anything at all, and is caught by the poll. Naming a path that is |
| 2229 | /// not in the project is not a change: a turn writing a log file beside a book must |
| 2230 | /// not rebuild the book. |
| 2231 | /// |
| 2232 | /// # Arguments |
| 2233 | /// * `path` - The workspace-relative path just written. |
| 2234 | async function touched(path) { |
| 2235 | if (S.mode !== 'live') return; |
| 2236 | const p = String(path || ''); |
| 2237 | if (!p) return; |
| 2238 | if (p !== S.path && S.files.indexOf(p) < 0) return; |
| 2239 | // Told rather than polled, but the question is the same one: a Save that wrote |
| 2240 | // back exactly what was there is a write, not an edit, and laying the book out |
| 2241 | // again for it would cost the reader a rebuild to see what is already up. |
| 2242 | let stamp = ''; |
| 2243 | try { |
| 2244 | const m = await wasm(); |
| 2245 | stamp = String((await m.typst_watch_stamps([p]))[0] || ''); |
| 2246 | } catch (e) { /* ask the file's contents anyway */ } |
| 2247 | if (stamp && await confirmed(p, stamp)) return; |
| 2248 | S.blind = !!stamp && Number(String(stamp).split(':')[1]) > DIGEST_MAX; |
| 2249 | nudge('write', p); |
| 2250 | } |
| 2251 | |
| 2252 | /// Rebuild now, because the user asked. |
| 2253 | /// |
| 2254 | /// The one place the heap budget is skipped, and only ever by a person who has just |
| 2255 | /// read the sentence saying why it stopped. The loop never does this to itself. |
| 2256 | function rebuild() { |
| 2257 | if (S.mode === 'dead' || !S.path) return; |
| 2258 | if (timer) { clearTimeout(timer); timer = null; } |
| 2259 | // The user asking is not the loop spinning, whatever the last few builds did. |
| 2260 | S.cause = 'user'; |
| 2261 | S.why = 'user: Rebuild'; |
| 2262 | S.blind = false; |
| 2263 | S.same = 0; |
| 2264 | build(true); |
| 2265 | } |
| 2266 | |
| 2267 | /// Read or set the heap ceiling, in MB. |
| 2268 | /// |
| 2269 | /// A real setting rather than a test hook: this machine is not every machine, and |
| 2270 | /// an operator with headroom may want a bigger one. Setting it does not change the |
| 2271 | /// wall, which is the browser's and is about 4 GB — it changes how far from the wall |
| 2272 | /// this stops. |
| 2273 | function budgetMB(n) { |
| 2274 | const v = Number(n); |
| 2275 | if (Number.isFinite(v) && v > 0) S.budget = v; |
| 2276 | // Raising the ceiling on a loop that stopped because of it starts it again. |
| 2277 | // Anything else would leave the setting looking broken: the number changed, the |
| 2278 | // sentence still says there is no room, and nothing moves until a reload. |
| 2279 | if (S.mode === 'held' && !holdCheck()) { |
| 2280 | S.mode = 'live'; |
| 2281 | S.reason = ''; |
| 2282 | showError(''); |
| 2283 | offerRebuild(false); |
| 2284 | says(tOr('typst.watch.live_preview', 'Live preview'), 'live'); |
| 2285 | } |
| 2286 | return S.budget; |
| 2287 | } |
| 2288 | |
| 2289 | /// Where page `n` sits in the scroller, in CSS pixels: `{ top, height }`. |
| 2290 | /// |
| 2291 | /// The scroller's own height is the whole document's and the SVG in it is only the |
| 2292 | /// band in view, so nothing outside can work this out by measuring elements. It is |
| 2293 | /// published because the answer is wanted — by a verifier photographing one page, |
| 2294 | /// and by any control that jumps to a page. |
| 2295 | /// |
| 2296 | /// # Arguments |
| 2297 | /// * `n` - The 1-based page number. |
| 2298 | function pageBox(n) { |
| 2299 | const i = Math.max(0, Math.min(Math.floor(n) - 1, S.lays.length - 1)); |
| 2300 | if (!S.lays.length) return null; |
| 2301 | return { top: S.lays[i] * S.scale, height: S.heights[i] * S.scale }; |
| 2302 | } |
| 2303 | |
| 2304 | /// Scroll so that page `n` is at the top of the view, and draw it. |
| 2305 | /// |
| 2306 | /// DRAWN IN THIS TURN, not when the scroll settles: the reader asked for this page by |
| 2307 | /// name, and the wait a fling is worth waiting is a wait for nothing here — there is |
| 2308 | /// no second jump coming to make this one's work wasted. |
| 2309 | function goToPage(n) { |
| 2310 | const sc = scroller(); |
| 2311 | const b = pageBox(n); |
| 2312 | if (!sc || !b) return; |
| 2313 | sc.scrollTop = Math.min(sc.scrollHeight - sc.clientHeight, b.top); |
| 2314 | ensureWindow(true); |
| 2315 | } |
| 2316 | |
| 2317 | /// What the loop is doing, for the panel and for a verifier alike. |
| 2318 | /// |
| 2319 | /// ONE answer, read out of the same object the loop acts on, so a check cannot |
| 2320 | /// pass against a mirror of the state that has drifted from the state. |
| 2321 | function state() { |
| 2322 | return { |
| 2323 | path: S.path, |
| 2324 | mode: S.mode, |
| 2325 | files: S.files.length, |
| 2326 | builds: S.builds, |
| 2327 | drawn: S.drawn, |
| 2328 | failed: S.failed, |
| 2329 | pages: S.pages, |
| 2330 | debounce: S.debounce, |
| 2331 | budget: S.budget, |
| 2332 | headroom: S.headroom, |
| 2333 | building: S.building, |
| 2334 | error: S.error, |
| 2335 | reason: S.reason, |
| 2336 | why: S.why, |
| 2337 | same: S.same, |
| 2338 | heap: (window.DaimondTypst && window.DaimondTypst.heapMB) |
| 2339 | ? window.DaimondTypst.heapMB() : 0, |
| 2340 | rheap: rheapMB(), |
| 2341 | scroll: scroller() ? scroller().scrollTop : 0, |
| 2342 | at: where(), |
| 2343 | zoom: S.zoom, |
| 2344 | fit: S.fit, |
| 2345 | dark: S.dark, |
| 2346 | rail: S.rail, |
| 2347 | gap: PAGE_GAP, |
| 2348 | sheets: band ? (band.p1 - band.p0 + 1) : 0, |
| 2349 | band: band ? { p0: band.p0, p1: band.p1 } : null, |
| 2350 | bandErr: S.bandErr, |
| 2351 | // THE TWO WAYS THE RENDERER'S ROUNDING SHOWS, both in points, so a check can |
| 2352 | // hold one against the other. `drift` is the renderer's own total height less |
| 2353 | // the exact sum of the page heights, taken in `draw` before anything is drawn; |
| 2354 | // `rdrift` is how far the origin it gave the LAST page has walked from the exact |
| 2355 | // sum above it, which is the same accumulation counted a second time and by a |
| 2356 | // different route. They can only disagree by the last page's own rounding. |
| 2357 | drift: S.drift, |
| 2358 | rdrift: (S.pages && S.rtops[S.pages - 1] != null && S.tops[S.pages - 1] != null) |
| 2359 | ? S.rtops[S.pages - 1] - S.tops[S.pages - 1] : 0, |
| 2360 | toc: S.toc.map(function (e) { |
| 2361 | return { text: e.text, level: e.level, page: e.page }; |
| 2362 | }), |
| 2363 | located: S.scanned === S.drawn && S.drawn > 0, |
| 2364 | }; |
| 2365 | } |
| 2366 | |
| 2367 | /// The sections as the rail has them: `{ text, level, page }`, in document order. |
| 2368 | function sections() { |
| 2369 | return state().toc; |
| 2370 | } |
| 2371 | |
| 2372 | if (typeof window !== 'undefined' && !window.DaimondTypstWatch) { |
| 2373 | // A write that Daimond itself made is known the moment it lands: every byte the |
| 2374 | // app writes goes through one Rust door (`opfs::write_file`), and that door says |
| 2375 | // so on `window`. So a daimon editing a chapter refreshes the view immediately, |
| 2376 | // while an external editor writing into a marked folder waits for the next poll |
| 2377 | // — the File System Access API has no change events, so there is nothing else it |
| 2378 | // could wait for. |
| 2379 | window.addEventListener('daimond-file-written', function (ev) { |
| 2380 | touched(ev && ev.detail ? ev.detail.path : ''); |
| 2381 | }); |
| 2382 | window.DaimondTypstWatch = { |
| 2383 | began: began, |
| 2384 | stop: stop, |
| 2385 | touched: touched, |
| 2386 | rebuild: rebuild, |
| 2387 | budgetMB: budgetMB, |
| 2388 | zoom: zoom, |
| 2389 | dark: dark, |
| 2390 | state: state, |
| 2391 | pageBox: pageBox, |
| 2392 | goToPage: goToPage, |
| 2393 | rail: rail, |
| 2394 | sections: sections, |
| 2395 | fitPage: fitPage, |
| 2396 | }; |
| 2397 | } |
| 2398 | |
| 2399 | export { began, stop, touched, rebuild, budgetMB, zoom, dark, state, pageBox, goToPage, |
| 2400 | rail, sections, fitPage }; |