@uurtech/jdf 0.1.14 → 0.1.16
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/dist/jdfjs.cjs +3 -3
- package/dist/jdfjs.cjs.map +1 -1
- package/dist/jdfjs.css +135 -0
- package/dist/jdfjs.d.cts +69 -5
- package/dist/jdfjs.d.ts +69 -5
- package/dist/jdfjs.js +3 -3
- package/dist/jdfjs.js.map +1 -1
- package/package.json +1 -1
- package/src/auto-init.ts +82 -9
- package/src/jdfjs.css +135 -0
- package/src/jdfx.ts +20 -0
- package/src/renderers/element.ts +194 -0
- package/src/utils/link.ts +4 -1
- package/src/viewer.ts +222 -20
package/src/viewer.ts
CHANGED
|
@@ -31,12 +31,18 @@ export interface JDFViewerOptions {
|
|
|
31
31
|
onLoad?: (doc: JdfDocument) => void;
|
|
32
32
|
/** Called on any rendering error */
|
|
33
33
|
onError?: (err: Error) => void;
|
|
34
|
+
/**
|
|
35
|
+
* Called when a form field's value changes. Useful for live previews,
|
|
36
|
+
* dirty-state tracking, or auto-saving filled forms to a backend instead
|
|
37
|
+
* of triggering a manual download.
|
|
38
|
+
*/
|
|
39
|
+
onFormChange?: (doc: JdfDocument, change: { path: (string | number)[]; field: string; value: unknown }) => void;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
export interface JDFViewerInstance {
|
|
37
43
|
/** The container element */
|
|
38
44
|
container: HTMLElement;
|
|
39
|
-
/** The current document */
|
|
45
|
+
/** The current document — reflects user form input as the user types. */
|
|
40
46
|
document: JdfDocument;
|
|
41
47
|
/** Get / set zoom (1 = 100%) */
|
|
42
48
|
setZoom: (zoom: number) => void;
|
|
@@ -48,40 +54,85 @@ export interface JDFViewerInstance {
|
|
|
48
54
|
setDocument: (doc: JdfDocument) => void;
|
|
49
55
|
/** Tear down — removes DOM and event listeners */
|
|
50
56
|
destroy: () => void;
|
|
57
|
+
/**
|
|
58
|
+
* Return the current document as a Blob — ready to attach to a form-data
|
|
59
|
+
* upload, save through `URL.createObjectURL`, or hand to a Worker. The
|
|
60
|
+
* blob reflects user form input (whatever the user has typed / ticked /
|
|
61
|
+
* selected). Pass `{ pretty: false }` for a compact JSON.
|
|
62
|
+
*/
|
|
63
|
+
exportJdf: (options?: { pretty?: boolean }) => Blob;
|
|
64
|
+
/** Return the current document as a JSON string (form-filled state). */
|
|
65
|
+
toJSON: (options?: { pretty?: boolean }) => string;
|
|
66
|
+
/**
|
|
67
|
+
* Trigger a browser download of the current document. The host page's
|
|
68
|
+
* "Save" button can call this directly: `viewer.downloadJdf("form.jdf")`.
|
|
69
|
+
* Default filename is `<title>.jdf` from `meta.title`.
|
|
70
|
+
*/
|
|
71
|
+
downloadJdf: (filename?: string) => void;
|
|
72
|
+
/** Read the value of a single form field by `name`. */
|
|
73
|
+
getFormValue: (name: string) => unknown;
|
|
74
|
+
/** Read every form field's value as a flat `{ [name]: value }` map. */
|
|
75
|
+
getFormValues: () => Record<string, unknown>;
|
|
51
76
|
}
|
|
52
77
|
|
|
53
78
|
/**
|
|
54
79
|
* Embed a JDF document into a container by URL.
|
|
55
80
|
* The simplest "PDF.js-like" usage.
|
|
56
81
|
*/
|
|
82
|
+
// Per-container AbortController so a rapid `src` change cancels the previous
|
|
83
|
+
// fetch and the slower (older) response can't overwrite the newer document.
|
|
84
|
+
const FETCH_ABORTS = new WeakMap<HTMLElement, AbortController>();
|
|
85
|
+
|
|
57
86
|
export async function embed(
|
|
58
87
|
container: HTMLElement | string,
|
|
59
88
|
url: string,
|
|
60
89
|
options: JDFViewerOptions = {}
|
|
61
90
|
): Promise<JDFViewerInstance> {
|
|
62
91
|
const el = resolveContainer(container);
|
|
92
|
+
|
|
93
|
+
// Abort any in-flight fetch for this element.
|
|
94
|
+
const previous = FETCH_ABORTS.get(el);
|
|
95
|
+
if (previous) previous.abort();
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
FETCH_ABORTS.set(el, controller);
|
|
98
|
+
|
|
63
99
|
el.classList.add("jdfjs-loading");
|
|
64
100
|
try {
|
|
65
|
-
|
|
101
|
+
// Detect .jdfx by extension first; if the URL has no extension hint
|
|
102
|
+
// (signed URLs, ?download=...), fall back to Content-Type sniffing
|
|
103
|
+
// after the response arrives.
|
|
104
|
+
const extLooksJdfx = /\.jdfx(\?|#|$)/i.test(url);
|
|
66
105
|
const res = await fetch(url, {
|
|
67
|
-
|
|
106
|
+
signal: controller.signal,
|
|
107
|
+
headers: extLooksJdfx
|
|
68
108
|
? { Accept: "application/jdf+zip,application/zip" }
|
|
69
|
-
: { Accept: "application/json,application/jdf+json" },
|
|
109
|
+
: { Accept: "application/json,application/jdf+json,application/jdf+zip,application/zip" },
|
|
70
110
|
});
|
|
71
111
|
if (!res.ok) throw new Error(`Failed to fetch ${url} (${res.status})`);
|
|
72
112
|
|
|
113
|
+
const ctype = (res.headers.get("content-type") || "").toLowerCase();
|
|
114
|
+
const ctypeLooksJdfx = ctype.includes("zip") || ctype.includes("jdf+zip");
|
|
115
|
+
|
|
73
116
|
let doc: JdfDocument;
|
|
74
|
-
if (
|
|
117
|
+
if (extLooksJdfx || ctypeLooksJdfx) {
|
|
75
118
|
const { unpackJdfxToDocument } = await import("./jdfx");
|
|
76
119
|
doc = await unpackJdfxToDocument(await res.arrayBuffer());
|
|
77
120
|
} else {
|
|
78
121
|
doc = (await res.json()) as JdfDocument;
|
|
79
122
|
}
|
|
80
123
|
if (!doc?.$jdf) throw new Error("Not a valid JDF document (missing $jdf field)");
|
|
124
|
+
|
|
125
|
+
// Race guard: if another embed() started for this container while we were
|
|
126
|
+
// parsing, a different controller is now stored — bail without rendering.
|
|
127
|
+
if (FETCH_ABORTS.get(el) !== controller) {
|
|
128
|
+
throw new DOMException("Superseded by a newer src change", "AbortError");
|
|
129
|
+
}
|
|
130
|
+
|
|
81
131
|
el.classList.remove("jdfjs-loading");
|
|
82
132
|
return render(el, doc, options);
|
|
83
133
|
} catch (err) {
|
|
84
134
|
el.classList.remove("jdfjs-loading");
|
|
135
|
+
if ((err as Error).name === "AbortError") throw err; // silent abort
|
|
85
136
|
el.classList.add("jdfjs-error");
|
|
86
137
|
el.innerHTML = `<div class="jdfjs-error-msg">${escapeHtml((err as Error).message)}</div>`;
|
|
87
138
|
options.onError?.(err as Error);
|
|
@@ -116,6 +167,14 @@ export class JDFViewer {
|
|
|
116
167
|
private sidebarEl: HTMLDivElement | null = null;
|
|
117
168
|
private root!: HTMLDivElement;
|
|
118
169
|
private observer: IntersectionObserver | null = null;
|
|
170
|
+
// System dark-mode subscription — needs explicit cleanup so a SPA route
|
|
171
|
+
// change that destroys the viewer doesn't leave a listener attached to
|
|
172
|
+
// the matchMedia query.
|
|
173
|
+
private darkModeMql: MediaQueryList | null = null;
|
|
174
|
+
private darkModeListener: ((e: MediaQueryListEvent) => void) | null = null;
|
|
175
|
+
// Window resize fallback for fit-width / fit-page when the host element's
|
|
176
|
+
// own size doesn't change but the viewport's does (flex re-layout, etc).
|
|
177
|
+
private windowResizeListener: (() => void) | null = null;
|
|
119
178
|
|
|
120
179
|
constructor(container: HTMLElement, doc: JdfDocument, options: JDFViewerOptions = {}) {
|
|
121
180
|
this.container = container;
|
|
@@ -150,12 +209,7 @@ export class JDFViewer {
|
|
|
150
209
|
private mount() {
|
|
151
210
|
this.container.innerHTML = "";
|
|
152
211
|
this.container.classList.add("jdfjs");
|
|
153
|
-
|
|
154
|
-
else if (this.options.darkMode === "auto") {
|
|
155
|
-
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
|
|
156
|
-
this.container.classList.add("jdfjs-dark");
|
|
157
|
-
}
|
|
158
|
-
}
|
|
212
|
+
this.applyDarkMode();
|
|
159
213
|
|
|
160
214
|
this.root = document.createElement("div");
|
|
161
215
|
this.root.className = "jdfjs-root";
|
|
@@ -187,10 +241,56 @@ export class JDFViewer {
|
|
|
187
241
|
}
|
|
188
242
|
|
|
189
243
|
private setupResizeObserver() {
|
|
190
|
-
if (typeof ResizeObserver
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
244
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
245
|
+
this.resizeObs?.disconnect();
|
|
246
|
+
this.resizeObs = new ResizeObserver(() => this.applyFit());
|
|
247
|
+
this.resizeObs.observe(this.pagesEl);
|
|
248
|
+
// Also observe the host container — flex re-layouts can change the
|
|
249
|
+
// host width without changing pagesEl's computed size synchronously.
|
|
250
|
+
this.resizeObs.observe(this.container);
|
|
251
|
+
}
|
|
252
|
+
// Window resize as a fallback for environments where the ResizeObserver
|
|
253
|
+
// doesn't fire (older Safari + nested flex). Subscribe once and remove
|
|
254
|
+
// on destroy.
|
|
255
|
+
if (this.windowResizeListener == null && typeof window !== "undefined") {
|
|
256
|
+
this.windowResizeListener = () => this.applyFit();
|
|
257
|
+
window.addEventListener("resize", this.windowResizeListener);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Apply the current `darkMode` option to the container. For `auto`, also
|
|
263
|
+
* subscribe to system colour-scheme changes so the embed flips when the
|
|
264
|
+
* user toggles their OS theme. Replaces a one-shot read at mount that
|
|
265
|
+
* froze the embed on its boot-time value.
|
|
266
|
+
*/
|
|
267
|
+
private applyDarkMode() {
|
|
268
|
+
// Tear down any previous subscription first — used during setDocument
|
|
269
|
+
// and option changes.
|
|
270
|
+
if (this.darkModeMql && this.darkModeListener) {
|
|
271
|
+
this.darkModeMql.removeEventListener("change", this.darkModeListener);
|
|
272
|
+
this.darkModeMql = null;
|
|
273
|
+
this.darkModeListener = null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const setDark = (on: boolean) => {
|
|
277
|
+
this.container.classList.toggle("jdfjs-dark", on);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const mode = this.options.darkMode;
|
|
281
|
+
if (mode === "dark") { setDark(true); return; }
|
|
282
|
+
if (mode === "light") { setDark(false); return; }
|
|
283
|
+
// mode === "auto"
|
|
284
|
+
if (typeof window === "undefined" || !window.matchMedia) {
|
|
285
|
+
setDark(false);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const mql = window.matchMedia("(prefers-color-scheme: dark)");
|
|
289
|
+
setDark(mql.matches);
|
|
290
|
+
const listener = (e: MediaQueryListEvent) => setDark(e.matches);
|
|
291
|
+
mql.addEventListener("change", listener);
|
|
292
|
+
this.darkModeMql = mql;
|
|
293
|
+
this.darkModeListener = listener;
|
|
194
294
|
}
|
|
195
295
|
|
|
196
296
|
/** Auto-zoom for fit modes. */
|
|
@@ -331,6 +431,7 @@ export class JDFViewer {
|
|
|
331
431
|
document: this.doc,
|
|
332
432
|
path: ["pages", pageIndex, "elements", elIdx],
|
|
333
433
|
onNavigatePage: (idx) => this.goToPage(idx),
|
|
434
|
+
onFormChange: (path, field, value) => this.handleFormChange(path, field, value),
|
|
334
435
|
});
|
|
335
436
|
if (node) content.appendChild(node);
|
|
336
437
|
});
|
|
@@ -440,9 +541,99 @@ export class JDFViewer {
|
|
|
440
541
|
this.options.onLoad?.(doc);
|
|
441
542
|
}
|
|
442
543
|
|
|
544
|
+
/**
|
|
545
|
+
* Apply a form-field value mutation to the in-memory document. Called by
|
|
546
|
+
* every form renderer on every keystroke / toggle / selection — the
|
|
547
|
+
* document carries the user's current state so `exportJdf()` returns a
|
|
548
|
+
* filled JDF that's identical in shape to the source, just with values.
|
|
549
|
+
*
|
|
550
|
+
* No re-render: the DOM input already shows what the user typed, and a
|
|
551
|
+
* full re-render mid-typing would lose focus. The mutation only matters
|
|
552
|
+
* at export time.
|
|
553
|
+
*/
|
|
554
|
+
private handleFormChange(path: (string | number)[], field: string, value: unknown) {
|
|
555
|
+
const target = path.reduce<any>((acc, key) => (acc == null ? acc : acc[key]), this.doc as any);
|
|
556
|
+
if (target == null) return;
|
|
557
|
+
target[field] = value;
|
|
558
|
+
this.options.onFormChange?.(this.doc, { path, field, value });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Walk every page's elements and yield each form element with its
|
|
563
|
+
* resolved name. Used by getFormValues / downloadJdf consumers.
|
|
564
|
+
*/
|
|
565
|
+
private *iterFormFields(): Generator<{ name: string; field: any }> {
|
|
566
|
+
for (const page of this.doc.pages || []) {
|
|
567
|
+
for (const el of (page.elements || []) as any[]) {
|
|
568
|
+
if (!el || typeof el !== "object") continue;
|
|
569
|
+
if (el.type === "input" || el.type === "textarea" || el.type === "checkbox" || el.type === "select" || el.type === "signature") {
|
|
570
|
+
if (typeof el.name === "string" && el.name.length > 0) yield { name: el.name, field: el };
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
toJSON(options: { pretty?: boolean } = {}): string {
|
|
577
|
+
return options.pretty === false
|
|
578
|
+
? JSON.stringify(this.doc)
|
|
579
|
+
: JSON.stringify(this.doc, null, 2);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
exportJdf(options: { pretty?: boolean } = {}): Blob {
|
|
583
|
+
return new Blob([this.toJSON(options)], { type: "application/jdf+json" });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
downloadJdf(filename?: string): void {
|
|
587
|
+
const name = filename
|
|
588
|
+
|| (this.doc.meta?.title ? `${this.doc.meta.title.replace(/[^\w\s.-]+/g, "_")}.jdf` : "document.jdf");
|
|
589
|
+
const blob = this.exportJdf();
|
|
590
|
+
const url = URL.createObjectURL(blob);
|
|
591
|
+
const a = window.document.createElement("a");
|
|
592
|
+
a.href = url;
|
|
593
|
+
a.download = name;
|
|
594
|
+
window.document.body.appendChild(a);
|
|
595
|
+
a.click();
|
|
596
|
+
window.document.body.removeChild(a);
|
|
597
|
+
// Revoke after the click handler returns the file to the browser.
|
|
598
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
getFormValue(name: string): unknown {
|
|
602
|
+
for (const f of this.iterFormFields()) {
|
|
603
|
+
if (f.name !== name) continue;
|
|
604
|
+
if (f.field.type === "checkbox") return f.field.checked === true;
|
|
605
|
+
if (f.field.type === "select" && f.field.multiple) return f.field.values || [];
|
|
606
|
+
return f.field.value ?? "";
|
|
607
|
+
}
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
getFormValues(): Record<string, unknown> {
|
|
612
|
+
const out: Record<string, unknown> = {};
|
|
613
|
+
for (const f of this.iterFormFields()) {
|
|
614
|
+
if (f.field.type === "checkbox") out[f.name] = f.field.checked === true;
|
|
615
|
+
else if (f.field.type === "select" && f.field.multiple) out[f.name] = f.field.values || [];
|
|
616
|
+
else out[f.name] = f.field.value ?? "";
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
|
|
443
621
|
destroy() {
|
|
444
622
|
this.observer?.disconnect();
|
|
445
623
|
this.resizeObs?.disconnect();
|
|
624
|
+
if (this.darkModeMql && this.darkModeListener) {
|
|
625
|
+
this.darkModeMql.removeEventListener("change", this.darkModeListener);
|
|
626
|
+
this.darkModeMql = null;
|
|
627
|
+
this.darkModeListener = null;
|
|
628
|
+
}
|
|
629
|
+
if (this.windowResizeListener && typeof window !== "undefined") {
|
|
630
|
+
window.removeEventListener("resize", this.windowResizeListener);
|
|
631
|
+
this.windowResizeListener = null;
|
|
632
|
+
}
|
|
633
|
+
// Drop any in-flight fetch tied to this container so a late response
|
|
634
|
+
// doesn't try to render into a destroyed DOM.
|
|
635
|
+
const ctrl = FETCH_ABORTS.get(this.container);
|
|
636
|
+
if (ctrl) { ctrl.abort(); FETCH_ABORTS.delete(this.container); }
|
|
446
637
|
this.container.innerHTML = "";
|
|
447
638
|
this.container.classList.remove("jdfjs", "jdfjs-dark", "jdfjs-loading", "jdfjs-error");
|
|
448
639
|
this.container.style.removeProperty("width");
|
|
@@ -450,16 +641,27 @@ export class JDFViewer {
|
|
|
450
641
|
}
|
|
451
642
|
|
|
452
643
|
getInstance(): JDFViewerInstance {
|
|
453
|
-
|
|
644
|
+
const self = this;
|
|
645
|
+
// `document` is a getter so callers always see the up-to-date document
|
|
646
|
+
// (including form values typed after the instance was returned), not a
|
|
647
|
+
// snapshot from when getInstance() ran. The previous version used
|
|
648
|
+
// `document: this.doc` which froze the reference at instance time.
|
|
649
|
+
const inst = {
|
|
454
650
|
container: this.container,
|
|
455
|
-
document
|
|
456
|
-
setZoom: (z) => this.setZoom(z),
|
|
651
|
+
get document() { return self.doc; },
|
|
652
|
+
setZoom: (z: number) => this.setZoom(z),
|
|
457
653
|
getZoom: () => this.getZoom(),
|
|
458
|
-
goToPage: (i) => this.goToPage(i),
|
|
654
|
+
goToPage: (i: number) => this.goToPage(i),
|
|
459
655
|
getCurrentPage: () => this.getCurrentPage(),
|
|
460
|
-
setDocument: (d) => this.setDocument(d),
|
|
656
|
+
setDocument: (d: JdfDocument) => this.setDocument(d),
|
|
461
657
|
destroy: () => this.destroy(),
|
|
658
|
+
exportJdf: (opts?: { pretty?: boolean }) => this.exportJdf(opts),
|
|
659
|
+
toJSON: (opts?: { pretty?: boolean }) => this.toJSON(opts),
|
|
660
|
+
downloadJdf: (n?: string) => this.downloadJdf(n),
|
|
661
|
+
getFormValue: (n: string) => this.getFormValue(n),
|
|
662
|
+
getFormValues: () => this.getFormValues(),
|
|
462
663
|
};
|
|
664
|
+
return inst as JDFViewerInstance;
|
|
463
665
|
}
|
|
464
666
|
}
|
|
465
667
|
|