@leftoff/cli 0.9.8-beta.9 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/navhide.js ADDED
@@ -0,0 +1,35 @@
1
+ // Hide the nav on scroll (phones, 0.9.8). Under 700px wide the top row and the
2
+ // bottom tab bar slide away while you scroll down a page and come back when
3
+ // you scroll up, reach the top or the very bottom, Safari style. Never while
4
+ // you're typing, a menu or sheet is open, or the page barely scrolls. Pure
5
+ // functions here (shared with the unit tests); app.js wires the scroll.
6
+
7
+ export const NAVHIDE_DOWN = 10 // px scrolled down in one go before the bars go
8
+ export const NAVHIDE_UP = 10 // px scrolled up in one go before they come back
9
+ export const NAVHIDE_TOP = 56 // this near the top they always show
10
+ export const NAVHIDE_BOTTOM = 8 // this near the very bottom they always show
11
+
12
+ // What counts as typing: a focused field the keyboard is up for.
13
+ export const NAVHIDE_TYPING = 'textarea, [contenteditable=""], [contenteditable="true"], input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="range"]):not([type="color"]):not([type="file"]):not([type="hidden"])'
14
+
15
+ // One scroll reading → the next state. state: { hidden, anchor } where anchor
16
+ // is where the current run of scrolling in one direction started. reading:
17
+ // { y: scrollY, max: how far the page can scroll, blocked: typing / a menu
18
+ // open / the bars held (a route change) }.
19
+ export function navHideStep(state, { y = 0, max = 0, blocked = false } = {}) {
20
+ const at = Number(y) || 0
21
+ const prev = state && typeof state === 'object' ? state : { hidden: false, anchor: at } // no state yet: resting here
22
+ const room = Number(max) || 0
23
+ const show = { hidden: false, anchor: at }
24
+ if (blocked) return show
25
+ if (room < NAVHIDE_TOP + NAVHIDE_DOWN + NAVHIDE_BOTTOM) return show // too short to bother
26
+ if (at <= NAVHIDE_TOP) return show
27
+ if (at >= room - NAVHIDE_BOTTOM) return show
28
+ const anchor = Number.isFinite(prev.anchor) ? prev.anchor : at
29
+ if (prev.hidden) {
30
+ if (at > anchor) return { hidden: true, anchor: at } // still going down: follow it
31
+ return anchor - at >= NAVHIDE_UP ? show : { hidden: true, anchor }
32
+ }
33
+ if (at < anchor) return { hidden: false, anchor: at } // going up while shown: follow it
34
+ return at - anchor >= NAVHIDE_DOWN ? { hidden: true, anchor: at } : { hidden: false, anchor }
35
+ }
package/ui/pdfview.js ADDED
@@ -0,0 +1,510 @@
1
+ // A full-screen PDF preview (0.9.8), in the image preview's family: a dark
2
+ // backdrop, a top bar (name, size, "page 3 of 12", Download, ×), the pages
3
+ // stacked and scrolled, fit to the width, drawn to canvases with pdf.js only
4
+ // when they're near the screen, crisp on a retina screen. Pinch, ctrl+wheel,
5
+ // + / − and a double tap (fit ↔ 2x) zoom. Esc or × closes; focus goes back to
6
+ // what opened it.
7
+ //
8
+ // pdf.js (ui/vendor/pdfjs, Apache-2.0; scripts/vendor-pdfjs.mjs) loads from the
9
+ // board's own origin the first time a PDF is opened, never with the board, and
10
+ // within the board's CSP: its worker is a same-origin file (worker-src 'self'),
11
+ // no WebAssembly, no eval, no blob: or data: URLs (the pages are canvases).
12
+ // The pure parts first (unit-tested: test/unit/pdfview-098.test.mjs); the DOM
13
+ // part only runs in a browser.
14
+
15
+ export const PV_MIN = 1 // fit to the width
16
+ export const PV_MAX = 5
17
+ export const PV_ZOOM = 2 // a double tap: fit ↔ 2x
18
+ export const PV_STEP = 1.25 // + / −
19
+ export const PV_GAP = 12 // px between pages, and around them
20
+ export const PV_MAX_W = 960 // a page never fits wider than this (a big desktop screen)
21
+ export const PV_MAX_PX = 12e6 // canvas pixels per page (iOS Safari refuses ~16.7M, and its canvas memory is small)
22
+ export const PV_KEEP = 3 // pages a canvas is kept for either side of the screen
23
+ export const PV_DOUBLE_MS = 300
24
+ export const PV_DOUBLE_DIST = 30
25
+ export const PDFJS_URL = '/vendor/pdfjs/pdf.min.js'
26
+ export const PDFJS_WORKER_URL = '/vendor/pdfjs/pdf.worker.min.js'
27
+
28
+ const num = (v, d) => (typeof v === 'number' && isFinite(v) ? v : d)
29
+ export function clampZoom(z) { return Math.min(PV_MAX, Math.max(PV_MIN, num(z, PV_MIN))) }
30
+ export const pageText = (i, n) => (n > 0 ? 'page ' + (Math.min(n, Math.max(1, i + 1))) + ' of ' + n : '')
31
+
32
+ // The width a page fits to at zoom 1: the view less the gaps, never wider than PV_MAX_W.
33
+ export function fitWidth(viewW, { gap = PV_GAP, maxW = PV_MAX_W } = {}) {
34
+ return Math.max(40, Math.min(maxW, num(viewW, 0) - gap * 2))
35
+ }
36
+
37
+ // Where every page sits: sizes are the pages' own (points), all scaled by one
38
+ // factor so the widest page fills `fitW * zoom`; centred across `viewW`.
39
+ // -> { scale (CSS px per point), w: [], h: [], top: [], left: [], width, height }
40
+ export function layoutPages(sizes, { fitW, zoom = 1, viewW = 0, gap = PV_GAP, top = 0 } = {}) {
41
+ const maxW = Math.max(1, ...sizes.map((s) => num(s && s.w, 0)))
42
+ const scale = (num(fitW, 1) * clampZoom(zoom)) / maxW
43
+ const w = [], h = [], tops = [], lefts = []
44
+ const width = Math.max(num(viewW, 0), maxW * scale + gap * 2)
45
+ let y = top + gap
46
+ for (const s of sizes) {
47
+ const pw = num(s && s.w, maxW) * scale, ph = num(s && s.h, maxW * 1.294) * scale
48
+ w.push(pw); h.push(ph); tops.push(y); lefts.push((width - pw) / 2)
49
+ y += ph + gap
50
+ }
51
+ return { scale, w, h, top: tops, left: lefts, width, height: y }
52
+ }
53
+
54
+ // The pages any part of which is within [y0 - margin, y1 + margin]: [first, last], or [-1, -1].
55
+ export function visibleRange(tops, heights, scrollTop, viewH, margin = 0) {
56
+ const y0 = scrollTop - margin, y1 = scrollTop + viewH + margin
57
+ let first = -1, last = -1
58
+ for (let i = 0; i < tops.length; i++) {
59
+ if (tops[i] + heights[i] > y0 && tops[i] < y1) { if (first < 0) first = i; last = i } else if (first >= 0) break
60
+ }
61
+ return [first, last]
62
+ }
63
+
64
+ // The page "page N of M" names: the one that fills most of the view; scrolled
65
+ // to the very end, the last one (a short last page never gets most of the view).
66
+ export function currentPage(tops, heights, scrollTop, viewH, { atEnd = false } = {}) {
67
+ const n = tops.length
68
+ if (!n) return 0
69
+ if (atEnd) return n - 1
70
+ let best = 0, area = -1
71
+ for (let i = 0; i < n; i++) {
72
+ const a = Math.min(tops[i] + heights[i], scrollTop + viewH) - Math.max(tops[i], scrollTop)
73
+ if (a > area + 0.5) { area = a; best = i }
74
+ if (tops[i] > scrollTop + viewH) break
75
+ }
76
+ return best
77
+ }
78
+
79
+ // Canvas pixels per CSS pixel: the screen's own ratio, less when the page
80
+ // would pass PV_MAX_PX (then the canvas is stretched a little; zoomed far in).
81
+ export function renderRatio(cssW, cssH, dpr = 1, maxPx = PV_MAX_PX) {
82
+ const r = Math.max(1, num(dpr, 1)), a = Math.max(1, num(cssW, 1) * num(cssH, 1))
83
+ return Math.max(0.25, Math.min(r, Math.sqrt(maxPx / a)))
84
+ }
85
+
86
+ // A point in the view -> which page it's on and where (0..1 across, down),
87
+ // so a zoom keeps the same spot of the same page under the fingers.
88
+ export function anchorAt(L, x, y) {
89
+ const n = L.top.length
90
+ if (!n) return { i: 0, fx: 0.5, fy: 0 }
91
+ let i = 0
92
+ while (i < n - 1 && y > L.top[i] + L.h[i] + PV_GAP / 2) i++
93
+ const fx = (x - L.left[i]) / Math.max(1, L.w[i]), fy = (y - L.top[i]) / Math.max(1, L.h[i])
94
+ return { i, fx: Math.min(1, Math.max(0, fx)), fy: Math.min(1, Math.max(0, fy)) }
95
+ }
96
+ // …and back: the scroll that puts that spot at (px, py) of the view, clamped.
97
+ export function scrollFor(L, a, px, py, viewW, viewH) {
98
+ const i = Math.min(L.top.length - 1, Math.max(0, a.i))
99
+ if (i < 0) return { left: 0, top: 0 }
100
+ const left = L.left[i] + a.fx * L.w[i] - px, top = L.top[i] + a.fy * L.h[i] - py
101
+ return { left: Math.round(Math.min(Math.max(0, L.width - viewW), Math.max(0, left))), top: Math.round(Math.min(Math.max(0, L.height - viewH), Math.max(0, top))) }
102
+ }
103
+
104
+ // ctrl+wheel (a trackpad pinch, or ctrl + mouse wheel): a smooth factor.
105
+ export function wheelZoom(deltaY, { lines = false } = {}) {
106
+ const d = num(deltaY, 0) * (lines ? 16 : 1)
107
+ return Math.exp(-Math.max(-120, Math.min(120, d)) * 0.01)
108
+ }
109
+ export function isDoubleTap(prev, cur, { ms = PV_DOUBLE_MS, dist = PV_DOUBLE_DIST } = {}) {
110
+ return !!(prev && cur && cur.t - prev.t <= ms && Math.hypot(cur.x - prev.x, cur.y - prev.y) <= dist)
111
+ }
112
+ // A PDF pdf.js can't show: encrypted (it asks for a password) or not a PDF.
113
+ export function cantPreview(e) {
114
+ const n = String((e && e.name) || '')
115
+ return n === 'PasswordException' || n === 'InvalidPDFException' || n === 'FormatError' || n === 'UnknownErrorException' || n === 'MissingPDFException' || /password|invalid pdf|corrupt/i.test(String((e && e.message) || ''))
116
+ }
117
+
118
+ // ------------------------------------------------------------- pdf.js, lazily
119
+ let libP = null
120
+ export function loadPdfjs() {
121
+ if (!libP) {
122
+ libP = import(PDFJS_URL).then((lib) => { lib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; return lib })
123
+ libP.catch(() => { libP = null }) // a failed load (offline) tries again next time
124
+ }
125
+ return libP
126
+ }
127
+ // Everything the board's CSP can't take is off: no WebAssembly decoders, no
128
+ // eval, no font or CMap fetches (missing fonts come from the system).
129
+ const DOC_OPTS = { useWasm: false, isEvalSupported: false, enableXfa: false, useSystemFonts: true, disableFontFace: false, stopAtErrors: false, isOffscreenCanvasSupported: true }
130
+ export async function openPdf(bytes) {
131
+ const lib = await loadPdfjs()
132
+ return lib.getDocument({ data: bytes, ...DOC_OPTS }).promise
133
+ }
134
+
135
+ // The first page as a small JPEG (base64, under maxBytes), for a sent PDF's
136
+ // bubble; null when it can't be drawn.
137
+ export async function pdfThumb(file, { edge = 320, maxBytes = 60 * 1024 } = {}) {
138
+ let doc = null
139
+ try {
140
+ doc = await openPdf(new Uint8Array(await file.arrayBuffer()))
141
+ const page = await doc.getPage(1)
142
+ const v1 = page.getViewport({ scale: 1 })
143
+ const k = edge / Math.max(v1.width, v1.height)
144
+ const vp = page.getViewport({ scale: k })
145
+ const c = document.createElement('canvas')
146
+ c.width = Math.max(1, Math.round(vp.width)); c.height = Math.max(1, Math.round(vp.height))
147
+ const ctx = c.getContext('2d')
148
+ ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, c.width, c.height)
149
+ await page.render({ canvas: c, canvasContext: ctx, viewport: vp, background: '#fff' }).promise
150
+ for (const q of [0.8, 0.65, 0.5, 0.35]) {
151
+ const b = await new Promise((r) => c.toBlob(r, 'image/jpeg', q))
152
+ if (b && b.size <= maxBytes) {
153
+ const u8 = new Uint8Array(await b.arrayBuffer())
154
+ let s = ''; for (let i = 0; i < u8.length; i += 0x8000) s += String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000))
155
+ c.width = 0; c.height = 0
156
+ return btoa(s)
157
+ }
158
+ }
159
+ return null
160
+ } catch { return null } finally { if (doc) try { doc.destroy() } catch {} }
161
+ }
162
+
163
+ // ------------------------------------------------------------------ the DOM
164
+ const DL_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>'
165
+ const X_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M6 6l12 12M18 6 6 18"/></svg>'
166
+ const MINUS_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M5 12h14"/></svg>'
167
+ const PLUS_IC = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M5 12h14M12 5v14"/></svg>'
168
+
169
+ let ui = null
170
+ const reduced = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
171
+ const finePointer = () => !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches)
172
+
173
+ function build() {
174
+ const root = document.createElement('div')
175
+ root.className = 'pdfview'
176
+ root.id = 'pdfview'
177
+ root.hidden = true
178
+ root.tabIndex = -1
179
+ root.setAttribute('role', 'dialog')
180
+ root.setAttribute('aria-modal', 'true')
181
+ root.setAttribute('aria-label', 'PDF preview')
182
+ root.innerHTML =
183
+ '<div class="pv-scroll" tabindex="0" aria-label="Pages"><div class="pv-stack"></div></div>' +
184
+ '<div class="pv-msg" hidden><span class="pv-spin spin" aria-hidden="true"></span><span class="pv-msg-text"></span><a class="lb-btn pv-msg-dl" role="button" hidden>' + DL_IC + '<span>Download</span></a></div>' +
185
+ '<div class="pv-bar">' +
186
+ '<div class="lb-meta"><span class="lb-name pv-name"></span><span class="lb-sub"><span class="lb-size pv-size"></span><span class="lb-count pv-count" aria-live="polite"></span></span></div>' +
187
+ '<button type="button" class="lb-btn pv-zoom pv-out" aria-label="Zoom out" title="Zoom out (−)">' + MINUS_IC + '</button>' +
188
+ '<button type="button" class="lb-btn pv-zoom pv-in" aria-label="Zoom in" title="Zoom in (+)">' + PLUS_IC + '</button>' +
189
+ '<a class="lb-btn lb-dl pv-dl" role="button" aria-disabled="true">' + DL_IC + '<span class="lb-dl-label">Download</span></a>' +
190
+ '<button type="button" class="lb-btn lb-x pv-x" aria-label="Close" title="Close (Esc)">' + X_IC + '</button>' +
191
+ '</div>'
192
+ document.body.appendChild(root)
193
+ const q = (s) => root.querySelector(s)
194
+ ui = { root, scroll: q('.pv-scroll'), stack: q('.pv-stack'), msg: q('.pv-msg'), msgText: q('.pv-msg-text'), msgDl: q('.pv-msg-dl'), spin: q('.pv-spin'), bar: q('.pv-bar'), name: q('.pv-name'), size: q('.pv-size'), count: q('.pv-count'), dl: q('.pv-dl'), x: q('.pv-x'), zin: q('.pv-in'), zout: q('.pv-out'),
195
+ doc: null, sizes: [], pages: [], L: null, zoom: 1, token: 0, opts: null, closing: 0, raf: 0, renderT: 0, running: 0, pinch: null, lastTap: null, cur: -1 }
196
+ wire()
197
+ return ui
198
+ }
199
+
200
+ // The view the pages scroll in: under the bar.
201
+ function barH() { return ui.bar.getBoundingClientRect().height || 56 }
202
+ function relayout(anchor) {
203
+ if (!ui.sizes.length) return
204
+ const vw = ui.scroll.clientWidth, vh = ui.scroll.clientHeight
205
+ const a = anchor || (ui.L ? anchorAt(ui.L, ui.scroll.scrollLeft + vw / 2, ui.scroll.scrollTop + barH()) : null)
206
+ const L = layoutPages(ui.sizes, { fitW: fitWidth(vw), zoom: ui.zoom, viewW: vw, top: barH() })
207
+ ui.L = L
208
+ ui.stack.style.width = L.width + 'px'
209
+ ui.stack.style.height = L.height + 'px'
210
+ for (let i = 0; i < ui.pages.length; i++) {
211
+ const p = ui.pages[i]
212
+ p.el.style.left = L.left[i] + 'px'; p.el.style.top = L.top[i] + 'px'
213
+ p.el.style.width = L.w[i] + 'px'; p.el.style.height = L.h[i] + 'px'
214
+ }
215
+ if (a) {
216
+ const px = anchor && anchor.px != null ? anchor.px : vw / 2, py = anchor && anchor.py != null ? anchor.py : barH()
217
+ const s = scrollFor(L, a, px, py, vw, vh)
218
+ ui.scroll.scrollLeft = s.left; ui.scroll.scrollTop = s.top
219
+ }
220
+ ui.root.classList.toggle('pv-zoomed', ui.zoom > PV_MIN + 0.01)
221
+ ui.root.dataset.zoom = ui.zoom.toFixed(3)
222
+ ui.zout.disabled = ui.zoom <= PV_MIN + 0.001
223
+ ui.zin.disabled = ui.zoom >= PV_MAX - 0.001
224
+ schedule()
225
+ }
226
+
227
+ function schedule() { if (!ui.raf) ui.raf = requestAnimationFrame(() => { ui.raf = 0; update() }) }
228
+ function update() {
229
+ if (!ui || ui.root.hidden || !ui.L) return
230
+ const L = ui.L, st = ui.scroll.scrollTop, vh = ui.scroll.clientHeight
231
+ const atEnd = st + vh >= ui.scroll.scrollHeight - 2 && st > 0
232
+ const bh = barH() // the bar sits over the top of the view
233
+ const cur = currentPage(L.top, L.h, st + bh, Math.max(1, vh - bh), { atEnd })
234
+ if (cur !== ui.cur) { ui.cur = cur; ui.count.textContent = pageText(cur, ui.pages.length); ui.root.dataset.page = String(cur + 1) }
235
+ // draw what's on screen first, then a screen's worth either side
236
+ const [a, b] = visibleRange(L.top, L.h, st, vh, vh)
237
+ const [s0, s1] = visibleRange(L.top, L.h, st, vh, 0)
238
+ for (let i = 0; i < ui.pages.length; i++) {
239
+ const p = ui.pages[i]
240
+ if (a < 0 || i < a - PV_KEEP || i > b + PV_KEEP) release(p)
241
+ }
242
+ clearTimeout(ui.renderT)
243
+ ui.renderT = setTimeout(() => {
244
+ if (!ui || ui.root.hidden || a < 0) return
245
+ const order = []
246
+ for (let i = s0; i <= s1 && i >= 0; i++) order.push(i)
247
+ for (let i = a; i <= b; i++) if (!order.includes(i)) order.push(i)
248
+ ui.want = order
249
+ pump()
250
+ }, ui.pinch ? 400 : 60)
251
+ }
252
+ function release(p) {
253
+ if (p.task) { try { p.task.cancel() } catch {} p.task = null }
254
+ if (p.canvas) { p.canvas.width = 0; p.canvas.height = 0; p.canvas.remove(); p.canvas = null; p.key = null; p.el.classList.remove('pv-drawn') }
255
+ }
256
+ // Two at a time; each page drawn at the zoom it's shown at, swapped in when done.
257
+ function pump() {
258
+ while (ui.running < 2 && ui.want && ui.want.length) {
259
+ const i = ui.want.shift(), p = ui.pages[i]
260
+ const w = ui.L.w[i], h = ui.L.h[i], r = renderRatio(w, h, window.devicePixelRatio || 1)
261
+ const key = Math.round(w * r) + 'x' + Math.round(h * r)
262
+ if (p.key === key || p.task) continue
263
+ ui.running++
264
+ draw(p, i, key, r).finally(() => { if (ui) { ui.running--; pump() } })
265
+ }
266
+ }
267
+ async function draw(p, i, key, r) {
268
+ const tok = ui.token
269
+ try {
270
+ const page = p.page || (p.page = await ui.doc.getPage(i + 1))
271
+ if (tok !== ui.token) return
272
+ const vp = page.getViewport({ scale: ui.L.scale * r })
273
+ const c = document.createElement('canvas')
274
+ c.width = Math.max(1, Math.round(vp.width)); c.height = Math.max(1, Math.round(vp.height))
275
+ c.className = 'pv-canvas'
276
+ c.setAttribute('aria-hidden', 'true')
277
+ const task = page.render({ canvas: c, canvasContext: c.getContext('2d'), viewport: vp, background: '#fff' })
278
+ p.task = task
279
+ await task.promise
280
+ p.task = null
281
+ if (tok !== ui.token) { c.width = 0; return }
282
+ if (p.canvas) { p.canvas.width = 0; p.canvas.remove() }
283
+ p.canvas = c; p.key = key
284
+ p.el.appendChild(c)
285
+ p.el.classList.add('pv-drawn')
286
+ } catch (e) {
287
+ p.task = null
288
+ if (e && e.name === 'RenderingCancelledException') return
289
+ if (tok === ui.token) p.el.classList.add('pv-failed')
290
+ }
291
+ }
292
+
293
+ function setZoom(z, px, py) {
294
+ const next = clampZoom(z)
295
+ if (Math.abs(next - ui.zoom) < 0.001 || !ui.L) return
296
+ const vw = ui.scroll.clientWidth, vh = ui.scroll.clientHeight
297
+ if (px == null) { px = vw / 2; py = vh / 2 }
298
+ const a = anchorAt(ui.L, ui.scroll.scrollLeft + px, ui.scroll.scrollTop + py)
299
+ ui.zoom = next
300
+ relayout({ ...a, px, py })
301
+ }
302
+ const viewPoint = (cx, cy) => { const r = ui.scroll.getBoundingClientRect(); return [cx - r.left, cy - r.top] }
303
+
304
+ function wire() {
305
+ const { root, scroll } = ui
306
+ ui.x.addEventListener('click', () => close())
307
+ ui.zin.addEventListener('click', () => setZoom(ui.zoom * PV_STEP))
308
+ ui.zout.addEventListener('click', () => setZoom(ui.zoom / PV_STEP))
309
+ scroll.addEventListener('scroll', () => schedule(), { passive: true })
310
+ // ctrl+wheel: a trackpad pinch on a Mac, or ctrl + the mouse wheel
311
+ scroll.addEventListener('wheel', (e) => {
312
+ if (!e.ctrlKey && !e.metaKey) return
313
+ e.preventDefault()
314
+ const [px, py] = viewPoint(e.clientX, e.clientY)
315
+ setZoom(ui.zoom * wheelZoom(e.deltaY, { lines: e.deltaMode === 1 }), px, py)
316
+ }, { passive: false })
317
+ scroll.addEventListener('dblclick', (e) => {
318
+ if (e.sourceCapabilities && e.sourceCapabilities.firesTouchEvents) return
319
+ const [px, py] = viewPoint(e.clientX, e.clientY)
320
+ setZoom(ui.zoom > PV_MIN + 0.01 ? PV_MIN : PV_ZOOM, px, py)
321
+ })
322
+ // Touch: one finger scrolls (the browser's own, with momentum); two pinch
323
+ // (our own, so the page itself never zooms); a double tap toggles 2x.
324
+ const dist = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY)
325
+ const mid = (t) => [(t[0].clientX + t[1].clientX) / 2, (t[0].clientY + t[1].clientY) / 2]
326
+ scroll.addEventListener('touchstart', (e) => {
327
+ if (e.touches.length === 2 && ui.L) {
328
+ e.preventDefault()
329
+ const [cx, cy] = mid(e.touches), [px, py] = viewPoint(cx, cy)
330
+ ui.pinch = { d0: Math.max(10, dist(e.touches)), z0: ui.zoom, px, py, cx, cy, k: 1, a: anchorAt(ui.L, scroll.scrollLeft + px, scroll.scrollTop + py) }
331
+ ui.lastTap = null
332
+ const sr = ui.stack.getBoundingClientRect()
333
+ ui.stack.style.transformOrigin = (cx - sr.left) + 'px ' + (cy - sr.top) + 'px'
334
+ } else if (e.touches.length === 1) ui.touch = { x: e.touches[0].clientX, y: e.touches[0].clientY, t: Date.now(), moved: false }
335
+ else ui.touch = null
336
+ }, { passive: false })
337
+ scroll.addEventListener('touchmove', (e) => {
338
+ if (ui.pinch && e.touches.length >= 2) {
339
+ e.preventDefault()
340
+ const k = clampZoom(ui.pinch.z0 * (dist(e.touches) / ui.pinch.d0)) / ui.pinch.z0
341
+ const [cx, cy] = mid(e.touches)
342
+ ui.pinch.k = k; ui.pinch.mx = cx; ui.pinch.my = cy
343
+ ui.stack.style.transform = 'translate(' + (cx - ui.pinch.cx) + 'px, ' + (cy - ui.pinch.cy) + 'px) scale(' + k + ')'
344
+ return
345
+ }
346
+ if (ui.touch && e.touches.length === 1 && Math.hypot(e.touches[0].clientX - ui.touch.x, e.touches[0].clientY - ui.touch.y) > 10) ui.touch.moved = true
347
+ }, { passive: false })
348
+ const endPinch = () => {
349
+ const p = ui.pinch; ui.pinch = null
350
+ ui.stack.style.transform = ''; ui.stack.style.transformOrigin = ''
351
+ const [px, py] = p.mx != null ? viewPoint(p.mx, p.my) : [p.px, p.py]
352
+ ui.zoom = clampZoom(p.z0 * p.k)
353
+ relayout({ ...p.a, px, py })
354
+ }
355
+ scroll.addEventListener('touchend', (e) => {
356
+ if (ui.pinch) { if (e.touches.length < 2) { e.preventDefault(); endPinch() } return }
357
+ const t = ui.touch; ui.touch = null
358
+ if (!t || t.moved || e.touches.length || Date.now() - t.t > 400) { ui.lastTap = null; return }
359
+ const cur = { t: Date.now(), x: t.x, y: t.y }
360
+ if (isDoubleTap(ui.lastTap, cur)) {
361
+ e.preventDefault(); ui.lastTap = null
362
+ const [px, py] = viewPoint(cur.x, cur.y)
363
+ setZoom(ui.zoom > PV_MIN + 0.01 ? PV_MIN : PV_ZOOM, px, py)
364
+ } else ui.lastTap = cur
365
+ }, { passive: false })
366
+ scroll.addEventListener('touchcancel', () => { if (ui.pinch) endPinch(); ui.touch = null })
367
+ // Safari's own pinch on the page underneath: not while this is open
368
+ for (const t of ['gesturestart', 'gesturechange']) root.addEventListener(t, (e) => e.preventDefault(), { passive: false })
369
+ window.addEventListener('resize', () => { if (ui && !root.hidden) relayout() })
370
+ window.addEventListener('keydown', (e) => {
371
+ if (root.hidden) return
372
+ if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); close(); return }
373
+ if (!e.metaKey && !e.ctrlKey && !e.altKey) {
374
+ if (e.key === '+' || e.key === '=') { e.preventDefault(); e.stopImmediatePropagation(); setZoom(ui.zoom * PV_STEP); return }
375
+ if (e.key === '-' || e.key === '_') { e.preventDefault(); e.stopImmediatePropagation(); setZoom(ui.zoom / PV_STEP); return }
376
+ if (e.key === '0') { e.preventDefault(); e.stopImmediatePropagation(); setZoom(PV_MIN); return }
377
+ }
378
+ if (e.key === 'Tab') {
379
+ const f = [ui.scroll, ...root.querySelectorAll('.pv-bar a[href], .pv-bar button, .pv-msg a[href]')].filter((b) => !b.hidden && !b.disabled && b.offsetParent !== null)
380
+ const i = f.indexOf(document.activeElement)
381
+ e.preventDefault(); e.stopImmediatePropagation()
382
+ const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i < 0 || i >= f.length - 1 ? 0 : i + 1)
383
+ if (f[n]) f[n].focus()
384
+ }
385
+ }, true)
386
+ document.addEventListener('focusin', (e) => { if (!root.hidden && !ui.closing && !root.contains(e.target)) ui.scroll.focus({ preventScroll: true }) })
387
+ }
388
+
389
+ function message(text, { spin = false, download = false } = {}) {
390
+ ui.msg.hidden = !text
391
+ ui.msgText.textContent = text || ''
392
+ ui.spin.hidden = !spin
393
+ const href = ui.dl.getAttribute('href')
394
+ ui.msgDl.hidden = !(download && href)
395
+ if (download && href) { ui.msgDl.setAttribute('href', href); ui.msgDl.setAttribute('download', ui.dl.getAttribute('download') || 'file.pdf') }
396
+ }
397
+ function setDownload(url, name) {
398
+ if (url) { ui.dl.setAttribute('href', url); ui.dl.setAttribute('download', name || 'file.pdf'); ui.dl.removeAttribute('aria-disabled') }
399
+ else { ui.dl.removeAttribute('href'); ui.dl.removeAttribute('download'); ui.dl.setAttribute('aria-disabled', 'true') }
400
+ }
401
+
402
+ // opts: { name, sizeLabel, load: () => Promise<{ bytes: Uint8Array, url? }>,
403
+ // download (false: nothing to download yet, a file still in the box),
404
+ // from (what opened it), refocus: () => element to focus after }
405
+ export async function openPdfView(opts) {
406
+ if (!ui) build()
407
+ const { root } = ui
408
+ clearTimeout(ui.closing); ui.closing = 0
409
+ teardown()
410
+ const tok = ++ui.token
411
+ ui.opts = opts
412
+ ui.zoom = PV_MIN; ui.cur = -1; ui.L = null; ui.sizes = []; ui.pages = []
413
+ ui.stack.innerHTML = ''; ui.stack.style.width = ''; ui.stack.style.height = ''
414
+ ui.name.textContent = opts.name || 'PDF'
415
+ ui.size.textContent = opts.sizeLabel || ''
416
+ ui.count.textContent = ''
417
+ ui.dl.hidden = opts.download === false
418
+ setDownload(null)
419
+ root.classList.remove('pv-out', 'pv-in', 'pv-zoomed', 'pv-failed-doc')
420
+ root.classList.toggle('pv-fine', finePointer())
421
+ root.hidden = false
422
+ document.documentElement.classList.add('pv-open')
423
+ void root.offsetWidth
424
+ root.classList.add('pv-in')
425
+ ui.scroll.focus({ preventScroll: true })
426
+ message('Opening…', { spin: true })
427
+ let got, phase = 'load'
428
+ const lib = loadPdfjs() // the file and the renderer come at the same time
429
+ lib.catch(() => {})
430
+ try {
431
+ got = await opts.load()
432
+ if (tok !== ui.token) return
433
+ if (got && got.url && opts.download !== false) setDownload(got.url, opts.name)
434
+ phase = 'lib'
435
+ await lib
436
+ phase = 'pdf'
437
+ const doc = await openPdf(got.bytes)
438
+ if (tok !== ui.token) { try { doc.destroy() } catch {} return }
439
+ ui.doc = doc
440
+ const n = doc.numPages
441
+ const p1 = await doc.getPage(1)
442
+ if (tok !== ui.token) return
443
+ const v1 = p1.getViewport({ scale: 1 })
444
+ ui.sizes = Array.from({ length: n }, () => ({ w: v1.width, h: v1.height }))
445
+ ui.pages = ui.sizes.map((_, i) => {
446
+ const el = document.createElement('div')
447
+ el.className = 'pv-page'
448
+ el.dataset.page = String(i + 1)
449
+ ui.stack.appendChild(el)
450
+ return { el, page: i === 0 ? p1 : null, canvas: null, task: null, key: null }
451
+ })
452
+ message('')
453
+ ui.scroll.scrollTop = 0; ui.scroll.scrollLeft = 0
454
+ relayout({ i: 0, fx: 0.5, fy: 0, px: ui.scroll.clientWidth / 2, py: barH() + PV_GAP })
455
+ root.dataset.pages = String(n)
456
+ // the other pages' own sizes, in the background (a mixed-size PDF re-lays itself out once)
457
+ if (n > 1) sizesLater(doc, tok, n)
458
+ } catch (e) {
459
+ if (tok !== ui.token) return
460
+ root.classList.add('pv-failed-doc')
461
+ // the file didn't come (the Mac is away) vs. pdf.js can't read it (encrypted, broken)
462
+ message(phase === 'pdf' || cantPreview(e) ? 'Can’t preview this PDF' : phase === 'lib' ? 'Couldn’t load the PDF viewer. Check your connection.' : 'Couldn’t open it: ' + ((e && e.message) || 'unknown error'), { download: true })
463
+ }
464
+ }
465
+ async function sizesLater(doc, tok, n) {
466
+ let changed = false
467
+ for (let i = 2; i <= n; i++) {
468
+ let pg
469
+ try { pg = await doc.getPage(i) } catch { continue }
470
+ if (tok !== ui.token) return
471
+ const v = pg.getViewport({ scale: 1 })
472
+ ui.pages[i - 1].page = pg
473
+ if (Math.abs(v.width - ui.sizes[i - 1].w) > 0.5 || Math.abs(v.height - ui.sizes[i - 1].h) > 0.5) { ui.sizes[i - 1] = { w: v.width, h: v.height }; changed = true }
474
+ if (changed && (i % 20 === 0 || i === n)) { changed = false; relayout() }
475
+ }
476
+ }
477
+ function teardown() {
478
+ if (!ui) return
479
+ for (const p of ui.pages || []) release(p)
480
+ clearTimeout(ui.renderT); ui.want = []; ui.pinch = null; ui.touch = null; ui.lastTap = null
481
+ if (ui.raf) { cancelAnimationFrame(ui.raf); ui.raf = 0 }
482
+ if (ui.doc) { try { ui.doc.destroy() } catch {} ui.doc = null }
483
+ ui.stack.style.transform = ''
484
+ }
485
+
486
+ export function closePdfView() { if (ui && !ui.root.hidden) close() }
487
+ export function pdfViewOpen() { return !!(ui && !ui.root.hidden && !ui.closing) }
488
+
489
+ function close() {
490
+ if (!ui || ui.root.hidden || ui.closing) return
491
+ const { root } = ui
492
+ const opts = ui.opts || {}
493
+ ui.token++
494
+ const finish = () => {
495
+ ui.closing = 0
496
+ teardown()
497
+ root.hidden = true
498
+ root.classList.remove('pv-out', 'pv-in')
499
+ ui.stack.innerHTML = ''; ui.pages = []; ui.sizes = []; ui.L = null; ui.opts = null
500
+ setDownload(null)
501
+ document.documentElement.classList.remove('pv-open')
502
+ let t = null
503
+ try { t = opts.refocus ? opts.refocus() : null } catch {}
504
+ if (!t || !t.isConnected) t = opts.from && opts.from.isConnected ? opts.from : null
505
+ if (t) t.focus({ preventScroll: true })
506
+ }
507
+ if (reduced()) return finish()
508
+ root.classList.add('pv-out')
509
+ ui.closing = setTimeout(finish, 160)
510
+ }
package/ui/prs.js CHANGED
@@ -47,17 +47,22 @@ export function cardPrsHtml(prs) {
47
47
  }
48
48
 
49
49
  // The session page: its PRs with Unlink, and "Link a PR" (a text box: a URL or #number).
50
- export function sessionPrsSection(s, { mayEdit = true } = {}) {
50
+ export function sessionPrsSection(s, { mayEdit = true, open = null, details = {} } = {}) {
51
51
  const list = (s.prs || []).filter((p) => p && p.number)
52
52
  let h = '<h2>Pull requests' + (list.length ? ' <span class="count">' + list.length + '</span>' : '') + '</h2>'
53
53
  if (list.length) {
54
54
  h += '<div class="prlist">' + list.map((p) => {
55
55
  const url = safeUrl(p.url)
56
- return '<div class="prrow slim"><div class="pr-main">' +
56
+ // 0.9.8: tap the title (or Details) for the same details as the PRs page, in place; GitHub ↗ still goes out
57
+ const isOpen = !!(open && open.has && open.has(p.url))
58
+ return '<article class="prrow slim' + (isOpen ? ' open' : '') + '" data-pr="' + esc(p.url) + '"><div class="pr-main">' +
57
59
  '<div class="pr-line1"><span class="pr-num">#' + esc(p.number) + '</span>' + stateHtml(p.state) + checksHtml(p.checks) + '</div>' +
58
- '<div class="pr-title">' + (url ? '<a href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + esc(p.title || 'Pull request #' + p.number) + ' ↗</a>' : esc(p.title || '')) + '</div>' +
59
- '<div class="pr-meta">' + esc(p.repo || '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + '</div></div>' +
60
- (mayEdit ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(s.id) + '" data-url="' + esc(p.url) + '">Unlink</button>' : '') + '</div>'
60
+ '<div class="pr-title"><button type="button" class="linkish pr-title-btn" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + isOpen + '">' + esc(p.title || 'Pull request #' + p.number) + '</button></div>' +
61
+ '<div class="pr-meta">' + esc(p.repo || '') + (whyText(p) ? ' · <span class="pr-why">' + esc(whyText(p)) + '</span>' : '') + '</div>' +
62
+ (isOpen ? prDetailHtml(p, details && details[p.url], {}) : '') + '</div>' +
63
+ '<div class="pr-actions"><button type="button" class="btn sm pr-toggle" data-action="pr-expand" data-url="' + esc(p.url) + '" aria-expanded="' + isOpen + '">' + (isOpen ? 'Less ▴' : 'Details ▾') + '</button>' +
64
+ (url ? '<a class="btn sm" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">GitHub ↗</a>' : '') +
65
+ (mayEdit ? '<button type="button" class="btn sm" data-action="pr-unlink" data-id="' + esc(s.id) + '" data-url="' + esc(p.url) + '">Unlink</button>' : '') + '</div></article>'
61
66
  }).join('') + '</div>'
62
67
  } else h += '<div class="nocp">No pull requests linked yet. They link on their own from gh pr create, PR links in the chat, or the branch.</div>'
63
68
  if (mayEdit) h += '<form class="prlink addform sess-add" data-pr-link="' + esc(s.id) + '" autocomplete="off"><input name="ref" type="text" inputmode="url" placeholder="Link a PR: paste its link or #number" aria-label="Link a PR: paste its link or #number" autocapitalize="off" spellcheck="false" enterkeyhint="done"><button type="submit" class="btn sm">Link</button></form>'
@@ -248,6 +248,7 @@ function patchedFetch(input, init) {
248
248
  }
249
249
 
250
250
  // EventSource('/v1/stream') -> long-polls of GET /v1/events through the tunnel.
251
+ export const POLL_WAIT_MS = 20000, POLL_MARGIN_MS = 12000
251
252
  class RelayEventSource extends EventTarget {
252
253
  constructor(url) {
253
254
  super()
@@ -259,11 +260,20 @@ class RelayEventSource extends EventTarget {
259
260
  let fails = 0
260
261
  while (this.readyState !== 2) {
261
262
  try {
262
- this.ctrl = new AbortController()
263
- const res = await patchedFetch('/v1/events?after=' + (this.seq == null ? -1 : this.seq) + '&ms=20000', { signal: this.ctrl.signal })
264
- if (!res.ok) throw new Error('HTTP ' + res.status)
265
- const j = await res.json()
263
+ // 0.9.8: a poll that outlives its wait plus a margin is dead (iOS
264
+ // suspended the page mid-request, or the relay dropped it silently):
265
+ // abort it and poll again instead of waiting forever.
266
+ const ctrl = this.ctrl = new AbortController()
267
+ const timer = setTimeout(() => ctrl.abort(), POLL_WAIT_MS + POLL_MARGIN_MS)
268
+ let j
269
+ try {
270
+ const res = await patchedFetch('/v1/events?after=' + (this.seq == null ? -1 : this.seq) + '&ms=' + POLL_WAIT_MS, { signal: ctrl.signal })
271
+ if (!res.ok) throw new Error('HTTP ' + res.status)
272
+ j = await res.json()
273
+ } finally { clearTimeout(timer) }
274
+ if (this.readyState === 2) return
266
275
  if (this.readyState === 0) { this.readyState = 1; if (this.onopen) this.onopen(new Event('open')) }
276
+ this.dispatchEvent(new MessageEvent('ping', { data: '{}' })) // a poll that answered is the stream's heartbeat
267
277
  const first = this.seq == null
268
278
  this.seq = j.seq
269
279
  if (!first) for (const e of j.events || []) {
@@ -62,3 +62,23 @@ export function restoreWhen(r = {}, { short = false } = {}) {
62
62
  if (r.restartKind === 'boot') return 'before ' + (r.ghostty === false ? 'this machine' : 'your Mac') + ' restarted'
63
63
  return 'before Leftoff restarted'
64
64
  }
65
+
66
+ // 0.9.8: the folded banner names the sessions, so you can tell which ones
67
+ // before you open the list. Each title in <b> (escaped; "(untitled)" when it
68
+ // has none); one session also gets its repo; four or more: the first two and
69
+ // "N more".
70
+ // [{ title, repoLabel }], esc -> "<b>Fix login flow</b> (leftoff) was"
71
+ export function restoreNames(sessions = [], esc = (x) => String(x)) {
72
+ const name = (s) => '<b>' + esc((s && s.title) || '(untitled)') + '</b>'
73
+ const n = sessions.length
74
+ if (!n) return ''
75
+ if (n === 1) return name(sessions[0]) + (sessions[0].repoLabel ? ' (' + esc(sessions[0].repoLabel) + ')' : '') + ' was'
76
+ const shown = n <= 3 ? sessions.map(name) : [name(sessions[0]), name(sessions[1]), (n - 2) + ' more']
77
+ return shown.slice(0, -1).join(', ') + ' and ' + shown[shown.length - 1] + ' were'
78
+ }
79
+
80
+ // The folded banner's sentence: who, and when.
81
+ // -> '<b>Fix login flow</b> (leftoff) was open before your Mac restarted (Oct 14 at 2:50 PM).'
82
+ export function restoreHeadline(r = {}, { esc = (x) => String(x), clock = (iso) => iso } = {}) {
83
+ return restoreNames(r.sessions || [], esc) + ' open ' + esc(restoreWhen(r)) + (r.restartAt ? ' (' + esc(clock(r.restartAt)) + ')' : '') + '.'
84
+ }