@aseity/idocs-preview 0.0.0-stage → 0.2.0
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 +50 -0
- package/LICENSE +21 -0
- package/README.md +206 -2
- package/dist/core/display.d.ts +180 -0
- package/dist/core/display.js +4 -0
- package/dist/core/engine.d.ts +9 -0
- package/dist/core/engine.js +81 -0
- package/dist/core/fonts.d.ts +27 -0
- package/dist/core/fonts.js +135 -0
- package/dist/core/index.d.ts +47 -0
- package/dist/core/index.js +156 -0
- package/dist/core/protocol.d.ts +137 -0
- package/dist/core/protocol.js +1 -0
- package/dist/core/wire.d.ts +14 -0
- package/dist/core/wire.js +100 -0
- package/dist/core/worker.d.ts +1 -0
- package/dist/core/worker.js +5 -0
- package/dist/view/find-bar.d.ts +26 -0
- package/dist/view/find-bar.js +117 -0
- package/dist/view/find.d.ts +46 -0
- package/dist/view/find.js +121 -0
- package/dist/view/index.d.ts +27 -0
- package/dist/view/index.js +274 -0
- package/dist/view/selection-layer.d.ts +27 -0
- package/dist/view/selection-layer.js +269 -0
- package/dist/view/selection.d.ts +131 -0
- package/dist/view/selection.js +350 -0
- package/dist/view/shell.d.ts +77 -0
- package/dist/view/shell.js +452 -0
- package/dist/view/viewport.d.ts +100 -0
- package/dist/view/viewport.js +145 -0
- package/package.json +49 -4
- package/wasm/hyphenation-licenses/LICENSE-APACHE +201 -0
- package/wasm/hyphenation-licenses/LICENSE-MIT +21 -0
- package/wasm/hyphenation-licenses/README.md +26 -0
- package/wasm/hyphenation-licenses/en-gb.tex +8615 -0
- package/wasm/hyphenation-licenses/en-us.tex +5018 -0
- package/wasm/hyphenation-licenses/ushyphex.tex +1454 -0
- package/wasm/idocs_wasm.d.ts +45 -0
- package/wasm/idocs_wasm.js +405 -0
- package/wasm/idocs_wasm_bg.wasm +0 -0
- package/wasm/idocs_wasm_bg.wasm.d.ts +10 -0
- package/wasm/package.json +1 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// The web preview's view shell (#448): a scrolled column of pages, one Canvas
|
|
2
|
+
// each, kept only for the visible pages and one page either side. Geometry
|
|
3
|
+
// lives in viewport.ts (pure, tested in Node); this file is the DOM side.
|
|
4
|
+
import { createSession } from '../core/index.js';
|
|
5
|
+
import { createFindBar } from './find-bar.js';
|
|
6
|
+
import { Finder, matchSelection } from './find.js';
|
|
7
|
+
import { decodeImages, paintPage } from './index.js';
|
|
8
|
+
import { createSelectionLayer } from './selection-layer.js';
|
|
9
|
+
import { anchorAt, canvasResolution, currentPage, fitWidthZoom, layoutPages, PX_PER_PT, pagesToKeep, revealScroll, scrollTopFor, } from './viewport.js';
|
|
10
|
+
const SPACING = { gap: 16, padding: 16 };
|
|
11
|
+
function clampZoom(zoom) {
|
|
12
|
+
return Number.isFinite(zoom) && zoom > 0 ? Math.min(10, Math.max(0.1, zoom)) : 1;
|
|
13
|
+
}
|
|
14
|
+
function element(tag, className) {
|
|
15
|
+
const node = document.createElement(tag);
|
|
16
|
+
node.className = className;
|
|
17
|
+
return node;
|
|
18
|
+
}
|
|
19
|
+
/** Empty the backing store before dropping a canvas, so its memory goes now, not at GC. */
|
|
20
|
+
function freeCanvas(canvas) {
|
|
21
|
+
canvas.width = 0;
|
|
22
|
+
canvas.height = 0;
|
|
23
|
+
canvas.remove();
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Mount `doc` into `container`, which must have a height: the view fills it
|
|
27
|
+
* and scrolls inside it.
|
|
28
|
+
*/
|
|
29
|
+
export function mountPreview(container, doc, options = {}) {
|
|
30
|
+
let zoom = clampZoom(options.zoom);
|
|
31
|
+
let layout = layoutPages(doc.pages, zoom, SPACING);
|
|
32
|
+
let current = -1;
|
|
33
|
+
let frame = 0;
|
|
34
|
+
let destroyed = false;
|
|
35
|
+
const slots = new Map();
|
|
36
|
+
// Decoded once per document; canvases released by zoom or virtualization
|
|
37
|
+
// are repainted from these bitmaps.
|
|
38
|
+
let images = [];
|
|
39
|
+
// The view box holds the scroller and what floats over it: the find bar and
|
|
40
|
+
// the selection's hidden textarea.
|
|
41
|
+
const view = element('div', 'idocs-preview-view');
|
|
42
|
+
Object.assign(view.style, { height: '100%', position: 'relative' });
|
|
43
|
+
const scroller = element('div', 'idocs-preview-scroller');
|
|
44
|
+
Object.assign(scroller.style, { height: '100%', overflow: 'auto', position: 'relative' });
|
|
45
|
+
// Focusable, so the preview has focus (for Ctrl+F) when tabbed into, not only once clicked.
|
|
46
|
+
scroller.tabIndex = 0;
|
|
47
|
+
const content = element('div', 'idocs-preview-pages');
|
|
48
|
+
content.style.position = 'relative';
|
|
49
|
+
scroller.append(content);
|
|
50
|
+
view.append(scroller);
|
|
51
|
+
container.append(view);
|
|
52
|
+
const selection = createSelectionLayer({
|
|
53
|
+
scroller,
|
|
54
|
+
content,
|
|
55
|
+
doc,
|
|
56
|
+
layout: () => layout,
|
|
57
|
+
zoom: () => zoom,
|
|
58
|
+
repaint,
|
|
59
|
+
marks: (page) => finder.marks(page),
|
|
60
|
+
});
|
|
61
|
+
function repaint() {
|
|
62
|
+
for (const [index, slot] of slots)
|
|
63
|
+
selection.paint(index, slot.overlay, slot.pixelsPerPoint);
|
|
64
|
+
}
|
|
65
|
+
const finder = new Finder(selection.selector.index);
|
|
66
|
+
const find = createFindBar({
|
|
67
|
+
frame: view,
|
|
68
|
+
finder,
|
|
69
|
+
currentPage: () => current,
|
|
70
|
+
reveal: (bounds) => {
|
|
71
|
+
// From the layout alone: the page may have no canvas yet, scrolling mounts it.
|
|
72
|
+
const { top, left } = revealScroll(layout, zoom, bounds, { top: scroller.scrollTop, left: scroller.scrollLeft }, { width: scroller.clientWidth, height: scroller.clientHeight });
|
|
73
|
+
scroller.scrollTop = top;
|
|
74
|
+
scroller.scrollLeft = left;
|
|
75
|
+
},
|
|
76
|
+
repaint,
|
|
77
|
+
closed: () => {
|
|
78
|
+
const match = finder.match;
|
|
79
|
+
selection.select(match && matchSelection(match));
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
// Ctrl+F anywhere in the preview (the scroller, the pages' hidden
|
|
83
|
+
// textarea, the find bar) opens the find bar; Escape closes it.
|
|
84
|
+
const onKey = (event) => {
|
|
85
|
+
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'f') {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
find.open();
|
|
88
|
+
}
|
|
89
|
+
else if (event.key === 'Escape' && find.isOpen) {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
find.close();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
view.addEventListener('keydown', onKey);
|
|
95
|
+
// Zoom keeping the spot at (x, y), CSS px from the scroller's top-left,
|
|
96
|
+
// where it is: the viewport top for setZoom, the pointer for Ctrl+wheel.
|
|
97
|
+
const zoomAround = (next, x, y) => {
|
|
98
|
+
next = clampZoom(next);
|
|
99
|
+
if (next === zoom)
|
|
100
|
+
return false;
|
|
101
|
+
const anchor = anchorAt(layout, scroller.scrollTop + y);
|
|
102
|
+
const across = (scroller.scrollLeft + x) / Math.max(content.offsetWidth, 1);
|
|
103
|
+
zoom = next;
|
|
104
|
+
layout = layoutPages(doc.pages, zoom, SPACING);
|
|
105
|
+
releaseAll();
|
|
106
|
+
sizeContent();
|
|
107
|
+
scroller.scrollTop = scrollTopFor(layout, anchor) - y;
|
|
108
|
+
scroller.scrollLeft = across * content.offsetWidth - x;
|
|
109
|
+
update();
|
|
110
|
+
return true;
|
|
111
|
+
};
|
|
112
|
+
// Ctrl+wheel and pinch zoom, the wheel's deltas summed per frame so a
|
|
113
|
+
// fast pinch relays the pages once a frame.
|
|
114
|
+
let wheelFrame = 0;
|
|
115
|
+
let wheelDelta = 0;
|
|
116
|
+
let wheelPoint = { x: 0, y: 0 };
|
|
117
|
+
const onWheel = (event) => {
|
|
118
|
+
if (!event.ctrlKey)
|
|
119
|
+
return;
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
const unit = event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
|
122
|
+
? 16
|
|
123
|
+
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
|
124
|
+
? scroller.clientHeight
|
|
125
|
+
: 1;
|
|
126
|
+
wheelDelta += event.deltaY * unit;
|
|
127
|
+
const box = scroller.getBoundingClientRect();
|
|
128
|
+
wheelPoint = { x: event.clientX - box.left, y: event.clientY - box.top };
|
|
129
|
+
if (wheelFrame)
|
|
130
|
+
return;
|
|
131
|
+
wheelFrame = requestAnimationFrame(() => {
|
|
132
|
+
wheelFrame = 0;
|
|
133
|
+
const delta = wheelDelta;
|
|
134
|
+
wheelDelta = 0;
|
|
135
|
+
if (!destroyed && zoomAround(zoom * Math.exp(-delta / 500), wheelPoint.x, wheelPoint.y))
|
|
136
|
+
options.onZoomChange?.(zoom);
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
scroller.addEventListener('wheel', onWheel, { passive: false });
|
|
140
|
+
const sizeContent = () => {
|
|
141
|
+
content.style.width = `max(100%, ${layout.width}px)`;
|
|
142
|
+
content.style.height = `${layout.height}px`;
|
|
143
|
+
};
|
|
144
|
+
const paint = (index, slot) => {
|
|
145
|
+
const placed = layout.pages[index];
|
|
146
|
+
const res = canvasResolution(placed.width, placed.height, globalThis.devicePixelRatio || 1, options.maxCanvasPixels);
|
|
147
|
+
const { canvas } = slot;
|
|
148
|
+
canvas.width = res.width;
|
|
149
|
+
canvas.height = res.height;
|
|
150
|
+
// The backing store is rounded up; show it at exactly scale px per CSS px.
|
|
151
|
+
canvas.style.width = `${res.width / res.scale}px`;
|
|
152
|
+
canvas.style.height = `${res.height / res.scale}px`;
|
|
153
|
+
const ctx = canvas.getContext('2d');
|
|
154
|
+
slot.pixelsPerPoint = PX_PER_PT * zoom * res.scale;
|
|
155
|
+
if (ctx)
|
|
156
|
+
paintPage(ctx, doc.pages[index], slot.pixelsPerPoint, images, doc.glyphOutlines);
|
|
157
|
+
const { overlay } = slot;
|
|
158
|
+
overlay.width = res.width;
|
|
159
|
+
overlay.height = res.height;
|
|
160
|
+
overlay.style.width = canvas.style.width;
|
|
161
|
+
overlay.style.height = canvas.style.height;
|
|
162
|
+
selection.paint(index, overlay, slot.pixelsPerPoint);
|
|
163
|
+
};
|
|
164
|
+
const mountSlot = (index) => {
|
|
165
|
+
const placed = layout.pages[index];
|
|
166
|
+
const box = element('div', 'idocs-preview-page');
|
|
167
|
+
box.dataset.page = String(doc.pages[index].number);
|
|
168
|
+
Object.assign(box.style, {
|
|
169
|
+
position: 'absolute',
|
|
170
|
+
top: `${placed.top}px`,
|
|
171
|
+
left: `calc(50% - ${placed.width / 2}px)`,
|
|
172
|
+
width: `${placed.width}px`,
|
|
173
|
+
height: `${placed.height}px`,
|
|
174
|
+
overflow: 'hidden',
|
|
175
|
+
background: '#fff',
|
|
176
|
+
});
|
|
177
|
+
const canvas = element('canvas', 'idocs-preview-content');
|
|
178
|
+
canvas.style.display = 'block';
|
|
179
|
+
const overlay = element('canvas', 'idocs-preview-selection');
|
|
180
|
+
Object.assign(overlay.style, { position: 'absolute', left: '0', top: '0', pointerEvents: 'none' });
|
|
181
|
+
box.append(canvas, overlay);
|
|
182
|
+
const slot = { box, canvas, overlay, pixelsPerPoint: 1 };
|
|
183
|
+
paint(index, slot);
|
|
184
|
+
content.append(box);
|
|
185
|
+
return slot;
|
|
186
|
+
};
|
|
187
|
+
const release = (index) => {
|
|
188
|
+
const slot = slots.get(index);
|
|
189
|
+
if (!slot)
|
|
190
|
+
return;
|
|
191
|
+
freeCanvas(slot.canvas);
|
|
192
|
+
freeCanvas(slot.overlay);
|
|
193
|
+
slot.box.remove();
|
|
194
|
+
slots.delete(index);
|
|
195
|
+
};
|
|
196
|
+
const releaseAll = () => {
|
|
197
|
+
for (const index of [...slots.keys()])
|
|
198
|
+
release(index);
|
|
199
|
+
};
|
|
200
|
+
const update = () => {
|
|
201
|
+
frame = 0;
|
|
202
|
+
if (destroyed)
|
|
203
|
+
return;
|
|
204
|
+
const { scrollTop, clientHeight } = scroller;
|
|
205
|
+
const keep = pagesToKeep(layout, scrollTop, clientHeight);
|
|
206
|
+
for (const index of [...slots.keys()])
|
|
207
|
+
if (index < keep.first || index > keep.last)
|
|
208
|
+
release(index);
|
|
209
|
+
for (let index = keep.first; index <= keep.last; index++)
|
|
210
|
+
if (!slots.has(index))
|
|
211
|
+
slots.set(index, mountSlot(index));
|
|
212
|
+
const now = currentPage(layout, scrollTop, clientHeight);
|
|
213
|
+
if (now !== current) {
|
|
214
|
+
current = now;
|
|
215
|
+
if (now >= 0)
|
|
216
|
+
options.onPageChange?.(now, doc);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const schedule = () => {
|
|
220
|
+
if (!frame && !destroyed)
|
|
221
|
+
frame = requestAnimationFrame(update);
|
|
222
|
+
};
|
|
223
|
+
// Redraw everything at the new resolution: zoom, or devicePixelRatio
|
|
224
|
+
// changing (browser zoom, moving the window to another screen).
|
|
225
|
+
const redraw = () => {
|
|
226
|
+
releaseAll();
|
|
227
|
+
update();
|
|
228
|
+
};
|
|
229
|
+
let resolution;
|
|
230
|
+
const watchResolution = () => {
|
|
231
|
+
resolution?.removeEventListener('change', onResolution);
|
|
232
|
+
resolution = globalThis.matchMedia?.(`(resolution: ${globalThis.devicePixelRatio || 1}dppx)`);
|
|
233
|
+
resolution?.addEventListener('change', onResolution);
|
|
234
|
+
};
|
|
235
|
+
function onResolution() {
|
|
236
|
+
watchResolution();
|
|
237
|
+
redraw();
|
|
238
|
+
}
|
|
239
|
+
const resize = new ResizeObserver(schedule);
|
|
240
|
+
scroller.addEventListener('scroll', schedule, { passive: true });
|
|
241
|
+
resize.observe(scroller);
|
|
242
|
+
watchResolution();
|
|
243
|
+
sizeContent();
|
|
244
|
+
update();
|
|
245
|
+
if (doc.images.length > 0) {
|
|
246
|
+
decodeImages(doc.images).then((decoded) => {
|
|
247
|
+
if (destroyed)
|
|
248
|
+
return decoded.forEach((bitmap) => bitmap?.close());
|
|
249
|
+
images = decoded;
|
|
250
|
+
// Pages mounted before decoding finished were painted without images.
|
|
251
|
+
for (const [index, slot] of slots)
|
|
252
|
+
paint(index, slot);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
get canvases() {
|
|
257
|
+
return [...slots.entries()].sort((a, b) => a[0] - b[0]).map(([, slot]) => slot.canvas);
|
|
258
|
+
},
|
|
259
|
+
get zoom() {
|
|
260
|
+
return zoom;
|
|
261
|
+
},
|
|
262
|
+
get currentPage() {
|
|
263
|
+
return current;
|
|
264
|
+
},
|
|
265
|
+
get selector() {
|
|
266
|
+
return selection.selector;
|
|
267
|
+
},
|
|
268
|
+
find,
|
|
269
|
+
get fitWidthZoom() {
|
|
270
|
+
return fitWidthZoom(doc.pages, scroller.clientWidth, SPACING);
|
|
271
|
+
},
|
|
272
|
+
setZoom(next) {
|
|
273
|
+
if (destroyed)
|
|
274
|
+
return;
|
|
275
|
+
// The same spot at the viewport top, and horizontally in the middle.
|
|
276
|
+
zoomAround(next, scroller.clientWidth / 2, 0);
|
|
277
|
+
},
|
|
278
|
+
goToPage(index, options) {
|
|
279
|
+
if (destroyed || layout.pages.length === 0)
|
|
280
|
+
return;
|
|
281
|
+
const page = Math.min(Math.max(0, Math.trunc(index)), layout.pages.length - 1);
|
|
282
|
+
scroller.scrollTo({
|
|
283
|
+
top: scrollTopFor(layout, { page, fraction: 0, above: SPACING.padding }),
|
|
284
|
+
behavior: options?.behavior,
|
|
285
|
+
});
|
|
286
|
+
},
|
|
287
|
+
destroy() {
|
|
288
|
+
if (destroyed)
|
|
289
|
+
return;
|
|
290
|
+
destroyed = true;
|
|
291
|
+
if (frame)
|
|
292
|
+
cancelAnimationFrame(frame);
|
|
293
|
+
frame = 0;
|
|
294
|
+
if (wheelFrame)
|
|
295
|
+
cancelAnimationFrame(wheelFrame);
|
|
296
|
+
wheelFrame = 0;
|
|
297
|
+
scroller.removeEventListener('wheel', onWheel);
|
|
298
|
+
resize.disconnect();
|
|
299
|
+
scroller.removeEventListener('scroll', schedule);
|
|
300
|
+
resolution?.removeEventListener('change', onResolution);
|
|
301
|
+
view.removeEventListener('keydown', onKey);
|
|
302
|
+
find.destroy();
|
|
303
|
+
selection.destroy();
|
|
304
|
+
releaseAll();
|
|
305
|
+
for (const bitmap of images)
|
|
306
|
+
bitmap?.close();
|
|
307
|
+
images = [];
|
|
308
|
+
view.remove();
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The host interface: mount into `container` (which must have a height), open
|
|
314
|
+
* documents, set the zoom, hear about page changes, destroy.
|
|
315
|
+
*/
|
|
316
|
+
export function createPreview(container, options = {}) {
|
|
317
|
+
const session = createSession(options.session);
|
|
318
|
+
let zoom = clampZoom(options.zoom);
|
|
319
|
+
let state = 'empty';
|
|
320
|
+
let doc;
|
|
321
|
+
let view;
|
|
322
|
+
let latest = 0;
|
|
323
|
+
let destroyed = false;
|
|
324
|
+
let firstPage;
|
|
325
|
+
let firstImages = [];
|
|
326
|
+
const clearFirstPage = () => {
|
|
327
|
+
if (firstPage)
|
|
328
|
+
freeCanvas(firstPage);
|
|
329
|
+
firstPage = undefined;
|
|
330
|
+
for (const image of firstImages)
|
|
331
|
+
image?.close();
|
|
332
|
+
firstImages = [];
|
|
333
|
+
};
|
|
334
|
+
const root = element('div', 'idocs-preview');
|
|
335
|
+
Object.assign(root.style, { height: '100%', position: 'relative' });
|
|
336
|
+
const status = element('div', 'idocs-preview-status');
|
|
337
|
+
status.setAttribute('role', 'status');
|
|
338
|
+
Object.assign(status.style, { padding: '16px', textAlign: 'center' });
|
|
339
|
+
root.append(status);
|
|
340
|
+
container.append(root);
|
|
341
|
+
const setState = (next, message = '') => {
|
|
342
|
+
state = next;
|
|
343
|
+
root.dataset.state = next;
|
|
344
|
+
status.textContent = message;
|
|
345
|
+
status.hidden = message === '';
|
|
346
|
+
};
|
|
347
|
+
setState('empty');
|
|
348
|
+
return {
|
|
349
|
+
async open(bytes, openOptions) {
|
|
350
|
+
if (destroyed)
|
|
351
|
+
throw new Error('the preview was destroyed');
|
|
352
|
+
const ticket = ++latest;
|
|
353
|
+
clearFirstPage();
|
|
354
|
+
view?.destroy();
|
|
355
|
+
view = undefined;
|
|
356
|
+
doc = undefined;
|
|
357
|
+
setState('loading', options.loadingText ?? '正在排版…');
|
|
358
|
+
try {
|
|
359
|
+
const opened = await session.open(bytes, {
|
|
360
|
+
...openOptions,
|
|
361
|
+
onPage: options.progressive ? (part) => {
|
|
362
|
+
openOptions?.onPage?.(part);
|
|
363
|
+
if (ticket !== latest || destroyed || part.page.number !== 1)
|
|
364
|
+
return;
|
|
365
|
+
clearFirstPage();
|
|
366
|
+
const canvas = element('canvas', 'idocs-preview-first-page');
|
|
367
|
+
firstPage = canvas;
|
|
368
|
+
const scale = Math.min(zoom * PX_PER_PT, Math.max(1, root.clientWidth - 32) / part.page.width);
|
|
369
|
+
const resolution = canvasResolution(part.page.width * scale, part.page.height * scale, globalThis.devicePixelRatio || 1, options.maxCanvasPixels);
|
|
370
|
+
canvas.width = resolution.width;
|
|
371
|
+
canvas.height = resolution.height;
|
|
372
|
+
Object.assign(canvas.style, {
|
|
373
|
+
width: `${resolution.width / resolution.scale}px`,
|
|
374
|
+
height: `${resolution.height / resolution.scale}px`,
|
|
375
|
+
display: 'block', margin: '0 auto',
|
|
376
|
+
});
|
|
377
|
+
const paint = (images) => {
|
|
378
|
+
const context = canvas.getContext('2d');
|
|
379
|
+
if (context)
|
|
380
|
+
paintPage(context, part.page, scale * resolution.scale, images, part.glyphOutlines);
|
|
381
|
+
};
|
|
382
|
+
paint([]);
|
|
383
|
+
root.append(canvas);
|
|
384
|
+
void decodeImages(part.images).then((images) => {
|
|
385
|
+
if (firstPage !== canvas || ticket !== latest || destroyed) {
|
|
386
|
+
for (const image of images)
|
|
387
|
+
image?.close();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
firstImages = images;
|
|
391
|
+
paint(images);
|
|
392
|
+
});
|
|
393
|
+
} : openOptions?.onPage,
|
|
394
|
+
});
|
|
395
|
+
if (ticket === latest && !destroyed) {
|
|
396
|
+
clearFirstPage();
|
|
397
|
+
setState('ready');
|
|
398
|
+
doc = opened;
|
|
399
|
+
view = mountPreview(root, opened, {
|
|
400
|
+
...options,
|
|
401
|
+
zoom,
|
|
402
|
+
onZoomChange: (next) => {
|
|
403
|
+
zoom = next;
|
|
404
|
+
options.onZoomChange?.(next);
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return opened;
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
if (ticket === latest && !destroyed) {
|
|
412
|
+
clearFirstPage();
|
|
413
|
+
setState('error', error.message);
|
|
414
|
+
}
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
get state() {
|
|
419
|
+
return state;
|
|
420
|
+
},
|
|
421
|
+
get document() {
|
|
422
|
+
return doc;
|
|
423
|
+
},
|
|
424
|
+
get view() {
|
|
425
|
+
return view;
|
|
426
|
+
},
|
|
427
|
+
get zoom() {
|
|
428
|
+
return zoom;
|
|
429
|
+
},
|
|
430
|
+
get currentPage() {
|
|
431
|
+
return view?.currentPage ?? -1;
|
|
432
|
+
},
|
|
433
|
+
setZoom(next) {
|
|
434
|
+
zoom = clampZoom(next);
|
|
435
|
+
view?.setZoom(zoom);
|
|
436
|
+
},
|
|
437
|
+
goToPage(index, options) {
|
|
438
|
+
view?.goToPage(index, options);
|
|
439
|
+
},
|
|
440
|
+
destroy() {
|
|
441
|
+
if (destroyed)
|
|
442
|
+
return;
|
|
443
|
+
destroyed = true;
|
|
444
|
+
clearFirstPage();
|
|
445
|
+
view?.destroy();
|
|
446
|
+
view = undefined;
|
|
447
|
+
doc = undefined;
|
|
448
|
+
session.dispose();
|
|
449
|
+
root.remove();
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** CSS pixels per point. */
|
|
2
|
+
export declare const PX_PER_PT: number;
|
|
3
|
+
export interface PageSize {
|
|
4
|
+
/** Points. */
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
export interface Spacing {
|
|
9
|
+
/** CSS px between pages; not zoomed. */
|
|
10
|
+
gap: number;
|
|
11
|
+
/** CSS px around the column; not zoomed. */
|
|
12
|
+
padding: number;
|
|
13
|
+
}
|
|
14
|
+
export interface PlacedPage {
|
|
15
|
+
/** CSS px from the top of the scrolled content. */
|
|
16
|
+
top: number;
|
|
17
|
+
/** CSS px, zoomed. */
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
}
|
|
21
|
+
export interface PageLayout {
|
|
22
|
+
pages: PlacedPage[];
|
|
23
|
+
/** CSS px of the whole scrolled content, padding included. */
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The zoom at which the widest page, with the padding either side, fills
|
|
29
|
+
* `width` CSS px; 1 when there are no pages or no room.
|
|
30
|
+
*/
|
|
31
|
+
export declare function fitWidthZoom(sizes: readonly PageSize[], width: number, spacing: Spacing): number;
|
|
32
|
+
/** A run of page indexes, both ends included. */
|
|
33
|
+
export interface PageRange {
|
|
34
|
+
first: number;
|
|
35
|
+
last: number;
|
|
36
|
+
}
|
|
37
|
+
/** Stack the pages top to bottom at `zoom` (1 = 100%). */
|
|
38
|
+
export declare function layoutPages(sizes: readonly PageSize[], zoom: number, spacing: Spacing): PageLayout;
|
|
39
|
+
/** Pages that intersect the viewport; empty (`first > last`) when it only shows a gap. */
|
|
40
|
+
export declare function visiblePages(layout: PageLayout, scrollTop: number, viewportHeight: number): PageRange;
|
|
41
|
+
/**
|
|
42
|
+
* Pages that keep a canvas: the visible ones and one either side. When the
|
|
43
|
+
* viewport shows only a gap, the pages on both sides of it.
|
|
44
|
+
*/
|
|
45
|
+
export declare function pagesToKeep(layout: PageLayout, scrollTop: number, viewportHeight: number): PageRange;
|
|
46
|
+
/**
|
|
47
|
+
* The page that fills most of the viewport (the earlier one on a tie); when
|
|
48
|
+
* the viewport shows only a gap, the page below it. -1 without pages.
|
|
49
|
+
*/
|
|
50
|
+
export declare function currentPage(layout: PageLayout, scrollTop: number, viewportHeight: number): number;
|
|
51
|
+
/** A scroll position that survives a zoom: a spot on a page, or a gap above one. */
|
|
52
|
+
export interface ScrollAnchor {
|
|
53
|
+
page: number;
|
|
54
|
+
/** How far down the page, 0..1. */
|
|
55
|
+
fraction: number;
|
|
56
|
+
/** CSS px of gap or padding between the viewport top and the page; not zoomed. */
|
|
57
|
+
above: number;
|
|
58
|
+
}
|
|
59
|
+
/** Where the viewport top is, in terms of pages. */
|
|
60
|
+
export declare function anchorAt(layout: PageLayout, scrollTop: number): ScrollAnchor;
|
|
61
|
+
/** The scroll position of `anchor` in `layout` (clamping is left to the browser). */
|
|
62
|
+
export declare function scrollTopFor(layout: PageLayout, anchor: ScrollAnchor): number;
|
|
63
|
+
/** A box on one page, in points from its top-left corner (a find match, #451). */
|
|
64
|
+
export interface PageRect {
|
|
65
|
+
page: number;
|
|
66
|
+
left: number;
|
|
67
|
+
right: number;
|
|
68
|
+
top: number;
|
|
69
|
+
bottom: number;
|
|
70
|
+
}
|
|
71
|
+
/** A scroll position, CSS px. */
|
|
72
|
+
export interface ScrollPosition {
|
|
73
|
+
top: number;
|
|
74
|
+
left: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The scroll position that shows `rect`: along each axis unchanged while it
|
|
78
|
+
* is wholly in the viewport, else with it in the middle. Pages are centred
|
|
79
|
+
* in a column at least as wide as the viewport, as the view lays them out.
|
|
80
|
+
* Works from the layout alone, so the page need not have a canvas yet.
|
|
81
|
+
*/
|
|
82
|
+
export declare function revealScroll(layout: PageLayout, zoom: number, rect: PageRect, scroll: ScrollPosition, viewport: {
|
|
83
|
+
width: number;
|
|
84
|
+
height: number;
|
|
85
|
+
}): ScrollPosition;
|
|
86
|
+
/** Default device-pixel budget of one page canvas (32 Mpx, as pdf.js on desktop). */
|
|
87
|
+
export declare const MAX_CANVAS_PIXELS: number;
|
|
88
|
+
export interface CanvasResolution {
|
|
89
|
+
/** Backing store size in device pixels. */
|
|
90
|
+
width: number;
|
|
91
|
+
height: number;
|
|
92
|
+
/** Device pixels per CSS pixel actually used. */
|
|
93
|
+
scale: number;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Backing store of a canvas shown at `cssWidth × cssHeight`: `ratio` device
|
|
97
|
+
* pixels per CSS pixel (devicePixelRatio), reduced when that would exceed
|
|
98
|
+
* the pixel budget.
|
|
99
|
+
*/
|
|
100
|
+
export declare function canvasResolution(cssWidth: number, cssHeight: number, ratio: number, maxPixels?: number): CanvasResolution;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Scroll geometry for the web preview (#448): where each page sits in the
|
|
2
|
+
// scrolled column, which pages keep a canvas, which page is current, and how
|
|
3
|
+
// many device pixels a page canvas gets. Pure: no DOM, tested in Node.
|
|
4
|
+
/** CSS pixels per point. */
|
|
5
|
+
export const PX_PER_PT = 96 / 72;
|
|
6
|
+
/**
|
|
7
|
+
* The zoom at which the widest page, with the padding either side, fills
|
|
8
|
+
* `width` CSS px; 1 when there are no pages or no room.
|
|
9
|
+
*/
|
|
10
|
+
export function fitWidthZoom(sizes, width, spacing) {
|
|
11
|
+
const widest = sizes.reduce((max, size) => Math.max(max, size.width), 0) * PX_PER_PT;
|
|
12
|
+
const room = width - 2 * spacing.padding;
|
|
13
|
+
return widest > 0 && room > 0 ? room / widest : 1;
|
|
14
|
+
}
|
|
15
|
+
/** Stack the pages top to bottom at `zoom` (1 = 100%). */
|
|
16
|
+
export function layoutPages(sizes, zoom, spacing) {
|
|
17
|
+
const scale = PX_PER_PT * zoom;
|
|
18
|
+
let top = spacing.padding;
|
|
19
|
+
let widest = 0;
|
|
20
|
+
const pages = sizes.map((size, i) => {
|
|
21
|
+
if (i > 0)
|
|
22
|
+
top += spacing.gap;
|
|
23
|
+
const page = { top, width: size.width * scale, height: size.height * scale };
|
|
24
|
+
top += page.height;
|
|
25
|
+
widest = Math.max(widest, page.width);
|
|
26
|
+
return page;
|
|
27
|
+
});
|
|
28
|
+
return { pages, width: widest + 2 * spacing.padding, height: top + spacing.padding };
|
|
29
|
+
}
|
|
30
|
+
/** Index of the first page whose bottom edge is below `y`; `pages.length` if none. */
|
|
31
|
+
function firstEndingAfter(pages, y) {
|
|
32
|
+
let lo = 0;
|
|
33
|
+
let hi = pages.length;
|
|
34
|
+
while (lo < hi) {
|
|
35
|
+
const mid = (lo + hi) >> 1;
|
|
36
|
+
if (pages[mid].top + pages[mid].height > y)
|
|
37
|
+
hi = mid;
|
|
38
|
+
else
|
|
39
|
+
lo = mid + 1;
|
|
40
|
+
}
|
|
41
|
+
return lo;
|
|
42
|
+
}
|
|
43
|
+
/** Pages that intersect the viewport; empty (`first > last`) when it only shows a gap. */
|
|
44
|
+
export function visiblePages(layout, scrollTop, viewportHeight) {
|
|
45
|
+
const { pages } = layout;
|
|
46
|
+
const bottom = scrollTop + viewportHeight;
|
|
47
|
+
const first = firstEndingAfter(pages, scrollTop);
|
|
48
|
+
let last = first - 1;
|
|
49
|
+
while (last + 1 < pages.length && pages[last + 1].top < bottom)
|
|
50
|
+
last++;
|
|
51
|
+
return { first, last };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Pages that keep a canvas: the visible ones and one either side. When the
|
|
55
|
+
* viewport shows only a gap, the pages on both sides of it.
|
|
56
|
+
*/
|
|
57
|
+
export function pagesToKeep(layout, scrollTop, viewportHeight) {
|
|
58
|
+
const count = layout.pages.length;
|
|
59
|
+
if (count === 0)
|
|
60
|
+
return { first: 0, last: -1 };
|
|
61
|
+
const visible = visiblePages(layout, scrollTop, viewportHeight);
|
|
62
|
+
return { first: Math.max(0, visible.first - 1), last: Math.min(count - 1, visible.last + 1) };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The page that fills most of the viewport (the earlier one on a tie); when
|
|
66
|
+
* the viewport shows only a gap, the page below it. -1 without pages.
|
|
67
|
+
*/
|
|
68
|
+
export function currentPage(layout, scrollTop, viewportHeight) {
|
|
69
|
+
const { pages } = layout;
|
|
70
|
+
if (pages.length === 0)
|
|
71
|
+
return -1;
|
|
72
|
+
const bottom = scrollTop + viewportHeight;
|
|
73
|
+
const visible = visiblePages(layout, scrollTop, viewportHeight);
|
|
74
|
+
if (visible.first > visible.last)
|
|
75
|
+
return Math.min(visible.first, pages.length - 1);
|
|
76
|
+
let best = visible.first;
|
|
77
|
+
let bestShown = -1;
|
|
78
|
+
for (let i = visible.first; i <= visible.last; i++) {
|
|
79
|
+
const page = pages[i];
|
|
80
|
+
const shown = Math.min(bottom, page.top + page.height) - Math.max(scrollTop, page.top);
|
|
81
|
+
if (shown > bestShown) {
|
|
82
|
+
best = i;
|
|
83
|
+
bestShown = shown;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return best;
|
|
87
|
+
}
|
|
88
|
+
/** Where the viewport top is, in terms of pages. */
|
|
89
|
+
export function anchorAt(layout, scrollTop) {
|
|
90
|
+
const { pages } = layout;
|
|
91
|
+
const page = Math.min(firstEndingAfter(pages, scrollTop), pages.length - 1);
|
|
92
|
+
if (page < 0)
|
|
93
|
+
return { page: 0, fraction: 0, above: 0 };
|
|
94
|
+
const { top, height } = pages[page];
|
|
95
|
+
if (scrollTop < top)
|
|
96
|
+
return { page, fraction: 0, above: top - scrollTop };
|
|
97
|
+
return { page, fraction: Math.min(1, (scrollTop - top) / height), above: 0 };
|
|
98
|
+
}
|
|
99
|
+
/** The scroll position of `anchor` in `layout` (clamping is left to the browser). */
|
|
100
|
+
export function scrollTopFor(layout, anchor) {
|
|
101
|
+
const page = layout.pages[Math.min(anchor.page, layout.pages.length - 1)];
|
|
102
|
+
if (!page)
|
|
103
|
+
return 0;
|
|
104
|
+
return Math.max(0, page.top + anchor.fraction * page.height - anchor.above);
|
|
105
|
+
}
|
|
106
|
+
/** Where `[start, end]` sits in a viewport at `scroll` of `size`: unchanged when it shows, else centred. */
|
|
107
|
+
function reveal(start, end, scroll, size) {
|
|
108
|
+
if (start >= scroll && end <= scroll + size)
|
|
109
|
+
return scroll;
|
|
110
|
+
return Math.max(0, (start + end) / 2 - size / 2);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The scroll position that shows `rect`: along each axis unchanged while it
|
|
114
|
+
* is wholly in the viewport, else with it in the middle. Pages are centred
|
|
115
|
+
* in a column at least as wide as the viewport, as the view lays them out.
|
|
116
|
+
* Works from the layout alone, so the page need not have a canvas yet.
|
|
117
|
+
*/
|
|
118
|
+
export function revealScroll(layout, zoom, rect, scroll, viewport) {
|
|
119
|
+
const page = layout.pages[rect.page];
|
|
120
|
+
if (!page)
|
|
121
|
+
return scroll;
|
|
122
|
+
const scale = PX_PER_PT * zoom;
|
|
123
|
+
const left = Math.max(viewport.width, layout.width) / 2 - page.width / 2;
|
|
124
|
+
return {
|
|
125
|
+
top: reveal(page.top + rect.top * scale, page.top + rect.bottom * scale, scroll.top, viewport.height),
|
|
126
|
+
left: reveal(left + rect.left * scale, left + rect.right * scale, scroll.left, viewport.width),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Default device-pixel budget of one page canvas (32 Mpx, as pdf.js on desktop). */
|
|
130
|
+
export const MAX_CANVAS_PIXELS = 2 ** 25;
|
|
131
|
+
/** No canvas side beyond this many device pixels (Safari's limit). */
|
|
132
|
+
const MAX_CANVAS_SIDE = 16384;
|
|
133
|
+
/**
|
|
134
|
+
* Backing store of a canvas shown at `cssWidth × cssHeight`: `ratio` device
|
|
135
|
+
* pixels per CSS pixel (devicePixelRatio), reduced when that would exceed
|
|
136
|
+
* the pixel budget.
|
|
137
|
+
*/
|
|
138
|
+
export function canvasResolution(cssWidth, cssHeight, ratio, maxPixels = MAX_CANVAS_PIXELS) {
|
|
139
|
+
let scale = ratio;
|
|
140
|
+
const area = cssWidth * cssHeight * scale * scale;
|
|
141
|
+
if (area > maxPixels)
|
|
142
|
+
scale *= Math.sqrt(maxPixels / area) * 0.999;
|
|
143
|
+
scale = Math.min(scale, MAX_CANVAS_SIDE / Math.max(cssWidth, cssHeight, 1));
|
|
144
|
+
return { width: Math.ceil(cssWidth * scale), height: Math.ceil(cssHeight * scale), scale };
|
|
145
|
+
}
|