@uurtech/jdf 0.1.14 → 0.1.17

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/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
- const isJdfx = /\.jdfx(\?|#|$)/i.test(url);
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
- headers: isJdfx
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 (isJdfx) {
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
- if (this.options.darkMode === "dark") this.container.classList.add("jdfjs-dark");
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 === "undefined") return;
191
- this.resizeObs?.disconnect();
192
- this.resizeObs = new ResizeObserver(() => this.applyFit());
193
- this.resizeObs.observe(this.pagesEl);
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
- return {
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: this.doc,
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