@uurtech/jdf 0.1.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/LICENSE +21 -0
- package/README.md +152 -0
- package/dist/jdfjs.cjs +12 -0
- package/dist/jdfjs.cjs.map +1 -0
- package/dist/jdfjs.css +225 -0
- package/dist/jdfjs.d.cts +113 -0
- package/dist/jdfjs.d.ts +113 -0
- package/dist/jdfjs.js +12 -0
- package/dist/jdfjs.js.map +1 -0
- package/package.json +61 -0
- package/src/auto-init.ts +128 -0
- package/src/index.ts +19 -0
- package/src/jdfjs.css +225 -0
- package/src/renderers/element.ts +513 -0
- package/src/utils/link.ts +38 -0
- package/src/utils/style.ts +53 -0
- package/src/utils/template.ts +14 -0
- package/src/viewer.ts +460 -0
package/src/viewer.ts
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import type { JdfDocument, Element, Page, Style, StyleRef, HeaderFooter } from "@jdf/core";
|
|
2
|
+
import { getPageDimensions, unitToPx, DEFAULT_MARGINS } from "@jdf/core";
|
|
3
|
+
import { renderElement } from "./renderers/element";
|
|
4
|
+
import { resolveStyle } from "./utils/style";
|
|
5
|
+
import { resolveTemplate } from "./utils/template";
|
|
6
|
+
|
|
7
|
+
export interface JDFViewerOptions {
|
|
8
|
+
/** Initial zoom level. 1 = 100%. Default: 1 */
|
|
9
|
+
zoom?: number;
|
|
10
|
+
/** Show the page sidebar. Default: false (most embeds want a clean look) */
|
|
11
|
+
sidebar?: boolean;
|
|
12
|
+
/** Show the toolbar (zoom, page nav, search). Default: true */
|
|
13
|
+
toolbar?: boolean;
|
|
14
|
+
/** Use dark mode. Default: follows `prefers-color-scheme` */
|
|
15
|
+
darkMode?: "auto" | "light" | "dark";
|
|
16
|
+
/** Initial page index (0-based). Default: 0 */
|
|
17
|
+
initialPage?: number;
|
|
18
|
+
/** Container width. Number = pixels. String = any CSS length ("100%", "60ch", "640px"). */
|
|
19
|
+
width?: number | string;
|
|
20
|
+
/** Container height. Number = pixels. String = any CSS length ("80vh", "600px"). Default: "600px". */
|
|
21
|
+
height?: number | string;
|
|
22
|
+
/** Page-fit strategy:
|
|
23
|
+
* "manual" — exact zoom from `zoom` option (default)
|
|
24
|
+
* "fit-width" — auto-zoom each page to fill container width
|
|
25
|
+
* "fit-page" — auto-zoom so a whole page is visible
|
|
26
|
+
*/
|
|
27
|
+
fit?: "manual" | "fit-width" | "fit-page";
|
|
28
|
+
/** Called when the user navigates to a different page */
|
|
29
|
+
onPageChange?: (pageIndex: number) => void;
|
|
30
|
+
/** Called once the document finishes rendering */
|
|
31
|
+
onLoad?: (doc: JdfDocument) => void;
|
|
32
|
+
/** Called on any rendering error */
|
|
33
|
+
onError?: (err: Error) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface JDFViewerInstance {
|
|
37
|
+
/** The container element */
|
|
38
|
+
container: HTMLElement;
|
|
39
|
+
/** The current document */
|
|
40
|
+
document: JdfDocument;
|
|
41
|
+
/** Get / set zoom (1 = 100%) */
|
|
42
|
+
setZoom: (zoom: number) => void;
|
|
43
|
+
getZoom: () => number;
|
|
44
|
+
/** Navigate to a page (0-based) */
|
|
45
|
+
goToPage: (pageIndex: number) => void;
|
|
46
|
+
getCurrentPage: () => number;
|
|
47
|
+
/** Replace the document */
|
|
48
|
+
setDocument: (doc: JdfDocument) => void;
|
|
49
|
+
/** Tear down — removes DOM and event listeners */
|
|
50
|
+
destroy: () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Embed a JDF document into a container by URL.
|
|
55
|
+
* The simplest "PDF.js-like" usage.
|
|
56
|
+
*/
|
|
57
|
+
export async function embed(
|
|
58
|
+
container: HTMLElement | string,
|
|
59
|
+
url: string,
|
|
60
|
+
options: JDFViewerOptions = {}
|
|
61
|
+
): Promise<JDFViewerInstance> {
|
|
62
|
+
const el = resolveContainer(container);
|
|
63
|
+
el.classList.add("jdfjs-loading");
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(url, { headers: { Accept: "application/json,application/jdf+json" } });
|
|
66
|
+
if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
|
|
67
|
+
const doc = (await res.json()) as JdfDocument;
|
|
68
|
+
if (!doc?.$jdf) throw new Error("Not a valid JDF document (missing $jdf field)");
|
|
69
|
+
el.classList.remove("jdfjs-loading");
|
|
70
|
+
return render(el, doc, options);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
el.classList.remove("jdfjs-loading");
|
|
73
|
+
el.classList.add("jdfjs-error");
|
|
74
|
+
el.innerHTML = `<div class="jdfjs-error-msg">${escapeHtml((err as Error).message)}</div>`;
|
|
75
|
+
options.onError?.(err as Error);
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Render a JDF document directly into a container. No fetch.
|
|
82
|
+
*/
|
|
83
|
+
export function render(
|
|
84
|
+
container: HTMLElement | string,
|
|
85
|
+
document: JdfDocument,
|
|
86
|
+
options: JDFViewerOptions = {}
|
|
87
|
+
): JDFViewerInstance {
|
|
88
|
+
const el = resolveContainer(container);
|
|
89
|
+
return new JDFViewer(el, document, options).getInstance();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Class form for advanced consumers (event subscriptions, custom toolbar wiring, etc).
|
|
94
|
+
*/
|
|
95
|
+
export class JDFViewer {
|
|
96
|
+
private container: HTMLElement;
|
|
97
|
+
private doc: JdfDocument;
|
|
98
|
+
private options: Required<Pick<JDFViewerOptions, "zoom" | "sidebar" | "toolbar" | "darkMode" | "initialPage" | "fit">> & JDFViewerOptions;
|
|
99
|
+
private zoom: number;
|
|
100
|
+
private currentPage: number;
|
|
101
|
+
private resizeObs: ResizeObserver | null = null;
|
|
102
|
+
private pagesEl!: HTMLDivElement;
|
|
103
|
+
private toolbarEl: HTMLDivElement | null = null;
|
|
104
|
+
private sidebarEl: HTMLDivElement | null = null;
|
|
105
|
+
private root!: HTMLDivElement;
|
|
106
|
+
private observer: IntersectionObserver | null = null;
|
|
107
|
+
|
|
108
|
+
constructor(container: HTMLElement, doc: JdfDocument, options: JDFViewerOptions = {}) {
|
|
109
|
+
this.container = container;
|
|
110
|
+
this.doc = doc;
|
|
111
|
+
this.options = {
|
|
112
|
+
zoom: options.zoom ?? 1,
|
|
113
|
+
sidebar: options.sidebar ?? false,
|
|
114
|
+
toolbar: options.toolbar ?? true,
|
|
115
|
+
darkMode: options.darkMode ?? "auto",
|
|
116
|
+
initialPage: options.initialPage ?? 0,
|
|
117
|
+
fit: options.fit ?? "manual",
|
|
118
|
+
...options,
|
|
119
|
+
};
|
|
120
|
+
this.zoom = this.options.zoom;
|
|
121
|
+
this.currentPage = this.options.initialPage;
|
|
122
|
+
this.applyContainerSize();
|
|
123
|
+
this.mount();
|
|
124
|
+
queueMicrotask(() => this.options.onLoad?.(this.doc));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private applyContainerSize() {
|
|
128
|
+
const w = this.options.width;
|
|
129
|
+
const h = this.options.height;
|
|
130
|
+
if (w != null) {
|
|
131
|
+
this.container.style.width = typeof w === "number" ? `${w}px` : w;
|
|
132
|
+
}
|
|
133
|
+
if (h != null) {
|
|
134
|
+
this.container.style.height = typeof h === "number" ? `${h}px` : h;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private mount() {
|
|
139
|
+
this.container.innerHTML = "";
|
|
140
|
+
this.container.classList.add("jdfjs");
|
|
141
|
+
if (this.options.darkMode === "dark") this.container.classList.add("jdfjs-dark");
|
|
142
|
+
else if (this.options.darkMode === "auto") {
|
|
143
|
+
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
|
|
144
|
+
this.container.classList.add("jdfjs-dark");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.root = document.createElement("div");
|
|
149
|
+
this.root.className = "jdfjs-root";
|
|
150
|
+
|
|
151
|
+
if (this.options.toolbar) {
|
|
152
|
+
this.toolbarEl = this.buildToolbar();
|
|
153
|
+
this.root.appendChild(this.toolbarEl);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const body = document.createElement("div");
|
|
157
|
+
body.className = "jdfjs-body";
|
|
158
|
+
|
|
159
|
+
if (this.options.sidebar) {
|
|
160
|
+
this.sidebarEl = this.buildSidebar();
|
|
161
|
+
body.appendChild(this.sidebarEl);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.pagesEl = document.createElement("div");
|
|
165
|
+
this.pagesEl.className = "jdfjs-pages";
|
|
166
|
+
body.appendChild(this.pagesEl);
|
|
167
|
+
|
|
168
|
+
this.root.appendChild(body);
|
|
169
|
+
this.container.appendChild(this.root);
|
|
170
|
+
this.renderAllPages();
|
|
171
|
+
this.setupScrollObserver();
|
|
172
|
+
this.setupResizeObserver();
|
|
173
|
+
this.applyFit();
|
|
174
|
+
if (this.currentPage > 0) this.scrollToPage(this.currentPage);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private setupResizeObserver() {
|
|
178
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
179
|
+
this.resizeObs?.disconnect();
|
|
180
|
+
this.resizeObs = new ResizeObserver(() => this.applyFit());
|
|
181
|
+
this.resizeObs.observe(this.pagesEl);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Auto-zoom for fit modes. */
|
|
185
|
+
private applyFit() {
|
|
186
|
+
if (this.options.fit === "manual") return;
|
|
187
|
+
const firstPage = this.pagesEl.querySelector<HTMLElement>(".jdfjs-page");
|
|
188
|
+
if (!firstPage) return;
|
|
189
|
+
// Read intrinsic page size from the inline width/min-height in px (set in renderPage)
|
|
190
|
+
const pageWidth = parseFloat(firstPage.style.width || "0");
|
|
191
|
+
const pageHeight = parseFloat(firstPage.style.minHeight || "0");
|
|
192
|
+
if (!pageWidth || !pageHeight) return;
|
|
193
|
+
const containerWidth = this.pagesEl.clientWidth - 32; // margin
|
|
194
|
+
const containerHeight = this.pagesEl.clientHeight - 32;
|
|
195
|
+
if (this.options.fit === "fit-width") {
|
|
196
|
+
this.zoom = Math.max(0.25, Math.min(3, containerWidth / pageWidth));
|
|
197
|
+
} else if (this.options.fit === "fit-page") {
|
|
198
|
+
this.zoom = Math.max(0.25, Math.min(3, Math.min(containerWidth / pageWidth, containerHeight / pageHeight)));
|
|
199
|
+
}
|
|
200
|
+
this.applyZoom();
|
|
201
|
+
this.updateIndicators();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private buildToolbar(): HTMLDivElement {
|
|
205
|
+
const tb = document.createElement("div");
|
|
206
|
+
tb.className = "jdfjs-toolbar";
|
|
207
|
+
tb.innerHTML = `
|
|
208
|
+
<span class="jdfjs-title"></span>
|
|
209
|
+
<div class="jdfjs-spacer"></div>
|
|
210
|
+
<button class="jdfjs-btn" data-act="prev" title="Previous page">‹</button>
|
|
211
|
+
<span class="jdfjs-page-indicator"></span>
|
|
212
|
+
<button class="jdfjs-btn" data-act="next" title="Next page">›</button>
|
|
213
|
+
<span class="jdfjs-divider"></span>
|
|
214
|
+
<button class="jdfjs-btn" data-act="zoom-out" title="Zoom out">−</button>
|
|
215
|
+
<span class="jdfjs-zoom-indicator"></span>
|
|
216
|
+
<button class="jdfjs-btn" data-act="zoom-in" title="Zoom in">+</button>
|
|
217
|
+
`;
|
|
218
|
+
tb.querySelector(".jdfjs-title")!.textContent = this.doc.meta?.title ?? "Document";
|
|
219
|
+
this.updateIndicators(tb);
|
|
220
|
+
|
|
221
|
+
tb.addEventListener("click", (e) => {
|
|
222
|
+
const target = (e.target as HTMLElement).closest("[data-act]");
|
|
223
|
+
if (!target) return;
|
|
224
|
+
const act = target.getAttribute("data-act");
|
|
225
|
+
if (act === "prev") this.goToPage(this.currentPage - 1);
|
|
226
|
+
else if (act === "next") this.goToPage(this.currentPage + 1);
|
|
227
|
+
else if (act === "zoom-in") this.setZoom(this.zoom + 0.1);
|
|
228
|
+
else if (act === "zoom-out") this.setZoom(this.zoom - 0.1);
|
|
229
|
+
});
|
|
230
|
+
return tb;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private buildSidebar(): HTMLDivElement {
|
|
234
|
+
const sb = document.createElement("div");
|
|
235
|
+
sb.className = "jdfjs-sidebar";
|
|
236
|
+
this.doc.pages.forEach((_, idx) => {
|
|
237
|
+
const btn = document.createElement("button");
|
|
238
|
+
btn.className = "jdfjs-sidebar-thumb";
|
|
239
|
+
btn.setAttribute("data-page", String(idx));
|
|
240
|
+
btn.innerHTML = `<span class="jdfjs-sidebar-num">${idx + 1}</span>`;
|
|
241
|
+
btn.addEventListener("click", () => this.goToPage(idx));
|
|
242
|
+
sb.appendChild(btn);
|
|
243
|
+
});
|
|
244
|
+
return sb;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private updateIndicators(tb: HTMLElement = this.toolbarEl!) {
|
|
248
|
+
if (!tb) return;
|
|
249
|
+
const pageInd = tb.querySelector(".jdfjs-page-indicator");
|
|
250
|
+
if (pageInd) pageInd.textContent = `${this.currentPage + 1} / ${this.doc.pages.length}`;
|
|
251
|
+
const zoomInd = tb.querySelector(".jdfjs-zoom-indicator");
|
|
252
|
+
if (zoomInd) zoomInd.textContent = `${Math.round(this.zoom * 100)}%`;
|
|
253
|
+
if (this.sidebarEl) {
|
|
254
|
+
this.sidebarEl.querySelectorAll(".jdfjs-sidebar-thumb").forEach((el) => {
|
|
255
|
+
const idx = Number(el.getAttribute("data-page"));
|
|
256
|
+
el.classList.toggle("jdfjs-sidebar-thumb-active", idx === this.currentPage);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private renderAllPages() {
|
|
262
|
+
this.pagesEl.innerHTML = "";
|
|
263
|
+
const styles = this.doc.styles ?? {};
|
|
264
|
+
this.doc.pages.forEach((page, idx) => {
|
|
265
|
+
const pageEl = this.renderPage(page, idx, styles);
|
|
266
|
+
this.pagesEl.appendChild(pageEl);
|
|
267
|
+
});
|
|
268
|
+
this.applyZoom();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private renderPage(page: Page, pageIndex: number, styles: Record<string, Style>): HTMLDivElement {
|
|
272
|
+
const dim = getPageDimensions(
|
|
273
|
+
page.pageSize ?? this.doc.meta?.pageSize ?? "A4",
|
|
274
|
+
page.pageOrientation ?? this.doc.meta?.pageOrientation ?? "portrait"
|
|
275
|
+
);
|
|
276
|
+
const margins = { ...DEFAULT_MARGINS, ...(this.doc.meta?.margins || {}), ...(page.margins || {}) };
|
|
277
|
+
|
|
278
|
+
const wrapper = document.createElement("div");
|
|
279
|
+
wrapper.className = "jdfjs-page-wrapper";
|
|
280
|
+
wrapper.setAttribute("data-page-index", String(pageIndex));
|
|
281
|
+
|
|
282
|
+
const pageEl = document.createElement("div");
|
|
283
|
+
pageEl.className = "jdfjs-page";
|
|
284
|
+
pageEl.style.width = `${unitToPx(dim.width)}px`;
|
|
285
|
+
pageEl.style.minHeight = `${unitToPx(dim.height)}px`;
|
|
286
|
+
if (page.background) pageEl.style.backgroundColor = page.background;
|
|
287
|
+
|
|
288
|
+
const header = page.header ?? this.doc.header;
|
|
289
|
+
const footer = page.footer ?? this.doc.footer;
|
|
290
|
+
const headerH = header?.height ?? 0;
|
|
291
|
+
const footerH = footer?.height ?? 0;
|
|
292
|
+
|
|
293
|
+
if (header) {
|
|
294
|
+
const h = this.renderHeaderFooter(header, pageIndex, this.doc.pages.length, styles);
|
|
295
|
+
h.classList.add("jdfjs-header");
|
|
296
|
+
h.style.paddingTop = `${unitToPx(margins.top! / 2)}px`;
|
|
297
|
+
h.style.paddingLeft = `${unitToPx(margins.left!)}px`;
|
|
298
|
+
h.style.paddingRight = `${unitToPx(margins.right!)}px`;
|
|
299
|
+
pageEl.appendChild(h);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const content = document.createElement("div");
|
|
303
|
+
content.className = "jdfjs-page-content";
|
|
304
|
+
content.style.position = "relative";
|
|
305
|
+
content.style.paddingTop = `${unitToPx((margins.top || 0) + headerH)}px`;
|
|
306
|
+
content.style.paddingRight = `${unitToPx(margins.right || 0)}px`;
|
|
307
|
+
content.style.paddingBottom = `${unitToPx((margins.bottom || 0) + footerH)}px`;
|
|
308
|
+
content.style.paddingLeft = `${unitToPx(margins.left || 0)}px`;
|
|
309
|
+
|
|
310
|
+
page.elements.forEach((el, elIdx) => {
|
|
311
|
+
const node = renderElement(el, {
|
|
312
|
+
styles,
|
|
313
|
+
resources: this.doc.resources,
|
|
314
|
+
document: this.doc,
|
|
315
|
+
path: ["pages", pageIndex, "elements", elIdx],
|
|
316
|
+
onNavigatePage: (idx) => this.goToPage(idx),
|
|
317
|
+
});
|
|
318
|
+
if (node) content.appendChild(node);
|
|
319
|
+
});
|
|
320
|
+
pageEl.appendChild(content);
|
|
321
|
+
|
|
322
|
+
if (footer) {
|
|
323
|
+
const f = this.renderHeaderFooter(footer, pageIndex, this.doc.pages.length, styles);
|
|
324
|
+
f.classList.add("jdfjs-footer");
|
|
325
|
+
f.style.paddingBottom = `${unitToPx(margins.bottom! / 2)}px`;
|
|
326
|
+
f.style.paddingLeft = `${unitToPx(margins.left!)}px`;
|
|
327
|
+
f.style.paddingRight = `${unitToPx(margins.right!)}px`;
|
|
328
|
+
pageEl.appendChild(f);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
wrapper.appendChild(pageEl);
|
|
332
|
+
return wrapper;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private renderHeaderFooter(hf: HeaderFooter, pageIndex: number, totalPages: number, styles: Record<string, Style>): HTMLDivElement {
|
|
336
|
+
const div = document.createElement("div");
|
|
337
|
+
if (hf.elements?.length) {
|
|
338
|
+
hf.elements.forEach((el, idx) => {
|
|
339
|
+
const node = renderElement(el, {
|
|
340
|
+
styles,
|
|
341
|
+
resources: this.doc.resources,
|
|
342
|
+
document: this.doc,
|
|
343
|
+
path: ["__hf__", pageIndex, idx],
|
|
344
|
+
onNavigatePage: (i) => this.goToPage(i),
|
|
345
|
+
});
|
|
346
|
+
if (node) div.appendChild(node);
|
|
347
|
+
});
|
|
348
|
+
} else if (hf.content) {
|
|
349
|
+
const text = resolveTemplate(hf.content, { pageNumber: pageIndex + 1, totalPages, title: this.doc.meta?.title ?? "", author: this.doc.meta?.author ?? "" });
|
|
350
|
+
const span = document.createElement("div");
|
|
351
|
+
span.textContent = text;
|
|
352
|
+
Object.assign(span.style, resolveStyle(hf.style as StyleRef | undefined, styles));
|
|
353
|
+
div.appendChild(span);
|
|
354
|
+
}
|
|
355
|
+
return div;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private setupScrollObserver() {
|
|
359
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
360
|
+
this.observer?.disconnect();
|
|
361
|
+
this.observer = new IntersectionObserver(
|
|
362
|
+
(entries) => {
|
|
363
|
+
const visible = entries
|
|
364
|
+
.filter((e) => e.isIntersecting)
|
|
365
|
+
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
|
|
366
|
+
if (visible[0]) {
|
|
367
|
+
const idx = Number((visible[0].target as HTMLElement).getAttribute("data-page-index"));
|
|
368
|
+
if (!isNaN(idx) && idx !== this.currentPage) {
|
|
369
|
+
this.currentPage = idx;
|
|
370
|
+
this.updateIndicators();
|
|
371
|
+
this.options.onPageChange?.(idx);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
{ root: this.pagesEl, threshold: [0.25, 0.5, 0.75] }
|
|
376
|
+
);
|
|
377
|
+
this.pagesEl.querySelectorAll("[data-page-index]").forEach((el) => this.observer!.observe(el));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private scrollToPage(idx: number) {
|
|
381
|
+
const el = this.pagesEl.querySelector(`[data-page-index="${idx}"]`) as HTMLElement | null;
|
|
382
|
+
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private applyZoom() {
|
|
386
|
+
this.pagesEl.style.setProperty("--jdfjs-zoom", String(this.zoom));
|
|
387
|
+
this.pagesEl.querySelectorAll<HTMLElement>(".jdfjs-page").forEach((el) => {
|
|
388
|
+
el.style.transform = `scale(${this.zoom})`;
|
|
389
|
+
el.style.transformOrigin = "top center";
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
setZoom(z: number) {
|
|
394
|
+
this.zoom = Math.max(0.25, Math.min(3, z));
|
|
395
|
+
this.applyZoom();
|
|
396
|
+
this.updateIndicators();
|
|
397
|
+
}
|
|
398
|
+
getZoom() { return this.zoom; }
|
|
399
|
+
|
|
400
|
+
goToPage(idx: number) {
|
|
401
|
+
const clamped = Math.max(0, Math.min(this.doc.pages.length - 1, idx));
|
|
402
|
+
this.currentPage = clamped;
|
|
403
|
+
this.scrollToPage(clamped);
|
|
404
|
+
this.updateIndicators();
|
|
405
|
+
this.options.onPageChange?.(clamped);
|
|
406
|
+
}
|
|
407
|
+
getCurrentPage() { return this.currentPage; }
|
|
408
|
+
|
|
409
|
+
setDocument(doc: JdfDocument) {
|
|
410
|
+
this.doc = doc;
|
|
411
|
+
this.currentPage = 0;
|
|
412
|
+
if (this.toolbarEl) {
|
|
413
|
+
const t = this.toolbarEl.querySelector(".jdfjs-title");
|
|
414
|
+
if (t) t.textContent = doc.meta?.title ?? "Document";
|
|
415
|
+
}
|
|
416
|
+
if (this.sidebarEl) {
|
|
417
|
+
this.sidebarEl.innerHTML = "";
|
|
418
|
+
const newSidebar = this.buildSidebar();
|
|
419
|
+
newSidebar.querySelectorAll(".jdfjs-sidebar-thumb").forEach((c) => this.sidebarEl!.appendChild(c));
|
|
420
|
+
}
|
|
421
|
+
this.renderAllPages();
|
|
422
|
+
this.setupScrollObserver();
|
|
423
|
+
this.options.onLoad?.(doc);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
destroy() {
|
|
427
|
+
this.observer?.disconnect();
|
|
428
|
+
this.resizeObs?.disconnect();
|
|
429
|
+
this.container.innerHTML = "";
|
|
430
|
+
this.container.classList.remove("jdfjs", "jdfjs-dark", "jdfjs-loading", "jdfjs-error");
|
|
431
|
+
this.container.style.removeProperty("width");
|
|
432
|
+
this.container.style.removeProperty("height");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
getInstance(): JDFViewerInstance {
|
|
436
|
+
return {
|
|
437
|
+
container: this.container,
|
|
438
|
+
document: this.doc,
|
|
439
|
+
setZoom: (z) => this.setZoom(z),
|
|
440
|
+
getZoom: () => this.getZoom(),
|
|
441
|
+
goToPage: (i) => this.goToPage(i),
|
|
442
|
+
getCurrentPage: () => this.getCurrentPage(),
|
|
443
|
+
setDocument: (d) => this.setDocument(d),
|
|
444
|
+
destroy: () => this.destroy(),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function resolveContainer(c: HTMLElement | string): HTMLElement {
|
|
450
|
+
if (typeof c === "string") {
|
|
451
|
+
const found = document.querySelector(c);
|
|
452
|
+
if (!found) throw new Error(`jdfjs: container "${c}" not found`);
|
|
453
|
+
return found as HTMLElement;
|
|
454
|
+
}
|
|
455
|
+
return c;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function escapeHtml(s: string): string {
|
|
459
|
+
return s.replace(/[&<>"']/g, (m) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[m]!));
|
|
460
|
+
}
|