@leftoff/cli 0.9.8-beta.9 → 0.9.9-beta.1
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/CHANGELOG.md +39 -1
- package/bin/leftoff.js +405 -361
- package/docs/SECURITY.md +14 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +10 -0
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +458 -18
- package/ui/app.js +1088 -94
- package/ui/attach.js +211 -0
- package/ui/composer.js +84 -0
- package/ui/diff-view.js +278 -0
- package/ui/lightbox.js +452 -0
- package/ui/live-status.js +2 -1
- package/ui/live-view.js +22 -5
- package/ui/live-wake.js +96 -0
- package/ui/machines.js +17 -0
- package/ui/navhide.js +35 -0
- package/ui/needs.js +16 -11
- package/ui/pdfview.js +510 -0
- package/ui/prs.js +10 -5
- package/ui/relay-client.js +14 -4
- package/ui/restore-gate.js +20 -0
- package/ui/tabs.js +9 -5
- package/ui/tour.css +63 -0
- package/ui/tour.js +364 -0
- package/ui/update-bar.js +158 -0
- package/ui/vendor/PDFJS-LICENSE.txt +179 -0
- package/ui/vendor/pdfjs/pdf.min.js +27 -0
- package/ui/vendor/pdfjs/pdf.worker.min.js +27 -0
- package/ui/what-it-did.js +149 -0
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/needs.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { mdInline } from './changelog-md.js'
|
|
11
11
|
import { tipHtml, tipId } from './tips.js'
|
|
12
|
+
import { composerBoxHtml } from './composer.js'
|
|
12
13
|
|
|
13
14
|
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
14
15
|
export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
@@ -41,7 +42,7 @@ export function needsLine(n) {
|
|
|
41
42
|
// Dismiss (0.6.5): hides this one need on every board; a new question shows again.
|
|
42
43
|
export function dismissButton(n) {
|
|
43
44
|
if (!n || !n.key) return ''
|
|
44
|
-
return '<button type="button" class="need-dismiss" data-action="need-dismiss" data-need="' + esc(n.key) + '" aria-label="Dismiss"
|
|
45
|
+
return '<button type="button" class="need-dismiss" data-action="need-dismiss" data-need="' + esc(n.key) + '" aria-label="Dismiss" data-tip="Dismiss: hide this one (a new question shows again)">✕</button>'
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
const KIND_LABEL = { permission: 'Needs your OK', question: 'Question', terminal: 'Waiting in the terminal', reply: 'Asked you', agent: 'Waiting on you' }
|
|
@@ -100,9 +101,9 @@ export function needPanel(n, opts = {}) {
|
|
|
100
101
|
let h = '<article class="need k-' + esc(n.kind) + (n.blocking ? ' blocking' : '') + '" data-id="' + esc(s.id) + '"' + (askId ? ' data-ask="' + esc(askId) + '"' : '') + ' data-key="' + esc(key) + '"' + (n.key ? ' data-need="' + esc(n.key) + '"' : '') + '>'
|
|
101
102
|
h += '<div class="need-head"><span class="need-dot" aria-hidden="true"></span>' +
|
|
102
103
|
(opts.compact ? '' : '<a class="need-title" href="#/s/' + encodeURIComponent(s.id) + '">' + esc(s.title || 'Untitled session') + '</a>' + (s.repoLabel ? '<span class="chip mono">' + esc(s.repoLabel) + '</span>' : '')) +
|
|
103
|
-
(n.peer || (s.isLocal === false && s.machine) ? '<span class="chip machine remote"
|
|
104
|
+
(n.peer || (s.isLocal === false && s.machine) ? '<span class="chip machine remote" data-tip="answered on that machine, through its Leftoff">⌂ ' + esc(n.peer || s.machine) + '</span>' : '') +
|
|
104
105
|
'<span class="need-kind">' + esc(n.label || KIND_LABEL[n.kind] || '') + '</span>' +
|
|
105
|
-
(n.waitSec != null ? '<span class="need-wait"
|
|
106
|
+
(n.waitSec != null ? '<span class="need-wait"' + (n.since ? ' data-tip="since ' + esc(n.since) + '"' : '') + '>' + esc(waitLabel(n.waitSec)) + '</span>' : '') + dismissButton(n) + '</div>'
|
|
106
107
|
h += needContextHtml(n, opts) + prCardHtml(n.pr)
|
|
107
108
|
if (n.kind === 'permission' && n.ask) {
|
|
108
109
|
const d = n.ask.describe || {}
|
|
@@ -112,7 +113,7 @@ export function needPanel(n, opts = {}) {
|
|
|
112
113
|
h += '<div class="need-actions">' +
|
|
113
114
|
'<button type="button" class="btn allow" data-action="ask-answer" data-choice="allow"' + dis + '>Allow once</button>' +
|
|
114
115
|
alwaysButton(n.ask, dis, { session: s }) +
|
|
115
|
-
(n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + '
|
|
116
|
+
(n.ask.canAllowSession === false ? '' : '<button type="button" class="btn" data-action="ask-answer" data-choice="allow_session"' + dis + ' data-tip="Allow this and calls like it until this Claude Code session ends (kept to the session, never saved to settings)">Allow for this session</button>') + // 0.9.8: Live link allows one call at a time
|
|
116
117
|
'<button type="button" class="btn deny" data-action="ask-deny-open"' + dis + '>Deny…</button>' + go + '</div>' +
|
|
117
118
|
'<div class="deny-form" hidden><input type="text" class="deny-note" maxlength="500" placeholder="Tell Claude why or what to do instead (optional)" value="' + esc(drafts['deny:' + askId] || '') + '" data-draft="deny:' + esc(askId) + '" data-submit="deny" aria-keyshortcuts="' + KBD_SUBMIT + '">' +
|
|
118
119
|
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny" data-submit-for="deny"' + dis + '>Deny' + kbdHint(opts) + '</button><button type="button" class="btn" data-action="ask-deny-close">Cancel</button></div>'
|
|
@@ -123,14 +124,14 @@ export function needPanel(n, opts = {}) {
|
|
|
123
124
|
h += '<div class="need-q" data-q="' + qi + '" data-multi="' + (q.multiSelect ? '1' : '') + '">' +
|
|
124
125
|
'<div class="qtext">' + (q.header ? '<span class="qhead">' + esc(q.header) + '</span>' : '') + esc(q.question) + '</div>'
|
|
125
126
|
if (q.options.length) {
|
|
126
|
-
h += '<div class="opts">' + q.options.map((o) => '<button type="button" class="btn opt" data-action="' + (oneTap ? 'ask-pick-send' : 'ask-pick') + '" data-label="' + esc(o.label) + '" aria-pressed="false"' + dis +
|
|
127
|
+
h += '<div class="opts">' + q.options.map((o) => '<button type="button" class="btn opt" data-action="' + (oneTap ? 'ask-pick-send' : 'ask-pick') + '" data-label="' + esc(o.label) + '" aria-pressed="false"' + dis + '>' + esc(o.label) + (o.description ? '<small>' + esc(o.description) + '</small>' : '') + '</button>').join('') + '</div>'
|
|
127
128
|
}
|
|
128
129
|
h += '<input type="text" class="qother" maxlength="500" placeholder="' + (q.options.length ? 'Or type your own answer' : 'Your answer') + '" value="' + esc(drafts['q:' + askId + ':' + qi] || '') + '" data-draft="q:' + esc(askId) + ':' + qi + '" data-submit="answer" aria-keyshortcuts="' + KBD_SUBMIT + '"' + (may ? '' : ' disabled') + '>'
|
|
129
130
|
h += '</div>'
|
|
130
131
|
})
|
|
131
132
|
h += quickRepliesHtml(n, dis)
|
|
132
133
|
h += '<div class="need-actions">' + (oneTap ? '' : '') + '<button type="button" class="btn primary" data-action="ask-answer" data-choice="answer" data-submit-for="answer"' + dis + '>' + (oneTap ? 'Send typed answer' : 'Send answers') + kbdHint(opts) + '</button>' +
|
|
133
|
-
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + '
|
|
134
|
+
'<button type="button" class="btn deny" data-action="ask-answer" data-choice="deny"' + dis + ' data-tip="Decline to answer; Claude carries on without it">Skip</button>' + go + '</div>'
|
|
134
135
|
} else if (n.kind === 'terminal') {
|
|
135
136
|
h += '<div class="need-what"><span class="verb">Claude Code says</span><span>' + esc(n.message || '') + '</span></div>' +
|
|
136
137
|
'<div class="need-note">This one can only be answered in the terminal.' + (n.peerNote ? ' ' + esc(n.peerNote) + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
|
|
@@ -138,8 +139,12 @@ export function needPanel(n, opts = {}) {
|
|
|
138
139
|
h += '<blockquote class="need-question">' + esc(n.question || '') + '</blockquote>'
|
|
139
140
|
if (n.replyPending) h += '<div class="need-note ok">Reply sent ' + esc(opts.agoFn ? opts.agoFn(n.replyPending.at) : '') + ': “' + esc(n.replyPending.text) + '”. Claude reads it as its next prompt.</div>'
|
|
140
141
|
if (n.canReply) {
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
// 0.9.8: the same composer as Live (ui/composer.js): rounded box, round ↑,
|
|
143
|
+
// Enter sends on a Mac (⌘↵ everywhere). A reply is text only (Claude reads
|
|
144
|
+
// it as its next prompt), so there is no 📎 here.
|
|
145
|
+
h += composerBoxHtml({ submit: 'reply', sendAction: 'reply-send', sid: s.id, draftKey: 'reply:' + s.id, draft: drafts['reply:' + s.id] || '',
|
|
146
|
+
may, busy: !!busy, placeholder: 'Reply to Claude…', label: 'Reply to Claude', boxClass: 'reply-composer', inputClass: 'reply-text',
|
|
147
|
+
attach: false, keys: opts.composerKeys || KBD_SUBMIT }) + quickRepliesHtml(n, dis) + '<div class="need-actions">' + go + '</div>'
|
|
143
148
|
} else {
|
|
144
149
|
h += '<div class="need-note">' + esc(n.why ? 'No reply box: ' + n.why + '.' : '') + '</div><div class="need-actions">' + go + '</div>'
|
|
145
150
|
}
|
|
@@ -191,8 +196,8 @@ export function agentChipHtml(a = {}, { title } = {}) {
|
|
|
191
196
|
const inner = esc(a.icon || '◆') + ' ' + esc(who)
|
|
192
197
|
const t = esc(title || 'external agent · read-only')
|
|
193
198
|
const url = httpsOnly(a.url)
|
|
194
|
-
return url ? '<a class="chip machine agent" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer"
|
|
195
|
-
: '<span class="chip machine agent"
|
|
199
|
+
return url ? '<a class="chip machine agent" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="' + t + '">' + inner + '</a>'
|
|
200
|
+
: '<span class="chip machine agent" data-tip="' + t + '">' + inner + '</span>'
|
|
196
201
|
}
|
|
197
202
|
// "From Tab · reply by texting Tab", or "From Tab · Read-only here".
|
|
198
203
|
export function agentReplyNote(a = {}) {
|
|
@@ -408,7 +413,7 @@ export function prCardHtml(pr) {
|
|
|
408
413
|
const inner = '<span class="pr-top"><span class="pr-num">#' + esc(pr.number) + '</span><span class="pr-state ' + esc(pr.state || 'open') + '">' + (pr.state === 'draft' ? 'Draft' : 'Open') + '</span>' +
|
|
409
414
|
(ck.summary ? '<span class="pr-checks ' + cls + '">' + (icon ? '<span aria-hidden="true">' + icon + '</span> ' : '') + esc(ck.summary) + '</span>' : '') + '</span>' +
|
|
410
415
|
'<span class="pr-title">' + esc(pr.title || '') + '</span>'
|
|
411
|
-
return url ? '<a class="need-pr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer"
|
|
416
|
+
return url ? '<a class="need-pr" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-tip="Open the pull request on GitHub">' + inner + '<span class="pr-go" aria-hidden="true">↗</span></a>'
|
|
412
417
|
: '<div class="need-pr">' + inner + '</div>'
|
|
413
418
|
}
|
|
414
419
|
|
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
|
+
}
|