@uurtech/jdf 0.1.13 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uurtech/jdf",
3
- "version": "0.1.13",
3
+ "version": "0.1.16",
4
4
  "description": "Render JDF (JSON Document Format) documents in the browser. Drop-in replacement for embedding PDFs — point at a .jdf file and it appears on the page, fully styled, searchable, with a clickable TOC.",
5
5
  "license": "MIT",
6
6
  "author": "Ugur Kazdal <ugur@uurtech.com>",
package/src/auto-init.ts CHANGED
@@ -13,9 +13,15 @@
13
13
  * window.JDFjsAutoInit = false // before loading the script
14
14
  */
15
15
 
16
- import { embed, type JDFViewerOptions } from "./viewer";
16
+ import { embed, type JDFViewerOptions, type JDFViewerInstance } from "./viewer";
17
17
 
18
18
  const PROCESSED = new WeakSet<Element>();
19
+ // Track per-element resources so we can dispose them when the element leaves
20
+ // the DOM (SPA route changes, frameworks that re-render). Without this, every
21
+ // route mount adds a fresh JDFViewer instance and a MutationObserver while
22
+ // the previous ones are kept alive by the observer references.
23
+ const VIEWER_INSTANCES = new WeakMap<Element, JDFViewerInstance>();
24
+ const ATTR_OBSERVERS = new WeakMap<Element, MutationObserver>();
19
25
 
20
26
  function readOptions(el: Element): JDFViewerOptions {
21
27
  const opts: JDFViewerOptions = {};
@@ -35,7 +41,11 @@ function readOptions(el: Element): JDFViewerOptions {
35
41
  const page = attr("page");
36
42
  if (page != null) {
37
43
  const n = Number(page);
38
- if (!isNaN(n)) opts.initialPage = n;
44
+ // The `page` attribute is 1-based for users (matches the toolbar
45
+ // indicator); JDFViewer's internal initialPage is 0-based. Convert
46
+ // here so <jdf page="1"> opens on page 1, not page 2. Clamp to >= 0
47
+ // so a typo'd `page="0"` doesn't crash navigation later.
48
+ if (!isNaN(n)) opts.initialPage = Math.max(0, Math.floor(n) - 1);
39
49
  }
40
50
  const w = attr("width");
41
51
  if (w != null) {
@@ -62,10 +72,16 @@ function processElement(el: Element) {
62
72
  const container = el as HTMLElement;
63
73
  applySizeAttrs(container);
64
74
  const opts = readOptions(el);
65
- embed(container, src, opts).catch((err) => {
66
- console.error("[jdf.js] failed to embed", src, err);
67
- container.dispatchEvent(new CustomEvent("jdf-error", { detail: err, bubbles: true }));
68
- });
75
+ embed(container, src, opts)
76
+ .then((inst) => {
77
+ VIEWER_INSTANCES.set(el, inst);
78
+ maybeAttachSaveButton(container, inst);
79
+ })
80
+ .catch((err) => {
81
+ if ((err as Error)?.name === "AbortError") return; // src changed mid-fetch
82
+ console.error("[jdf.js] failed to embed", src, err);
83
+ container.dispatchEvent(new CustomEvent("jdf-error", { detail: err, bubbles: true }));
84
+ });
69
85
  // Watch for attribute changes — re-render when src changes,
70
86
  // resize when width/height changes. Replaces the custom element
71
87
  // attributeChangedCallback (which we can't use because <jdf> isn't
@@ -73,6 +89,49 @@ function processElement(el: Element) {
73
89
  observeAttributes(container);
74
90
  }
75
91
 
92
+ /**
93
+ * Wire up the optional save button. Two attributes opt in:
94
+ * <jdf src="form.jdf" save-button> — adds a "Save" button
95
+ * <jdf src="form.jdf" save-button="Download form"> — custom label
96
+ * <jdf src="form.jdf" save-filename="filled.jdf"> — explicit filename
97
+ *
98
+ * The button is positioned in the embed's bottom-right corner via the
99
+ * `jdfjs-save-button` class (themable from the host page). Clicking it
100
+ * downloads the current document — including any form values the user
101
+ * has typed — to the user's filesystem.
102
+ */
103
+ function maybeAttachSaveButton(container: HTMLElement, inst: JDFViewerInstance) {
104
+ if (!container.hasAttribute("save-button")) return;
105
+ const labelAttr = container.getAttribute("save-button");
106
+ const label = labelAttr && labelAttr.length > 0 && labelAttr !== "true" ? labelAttr : "Save";
107
+ const filename = container.getAttribute("save-filename") || undefined;
108
+ const btn = window.document.createElement("button");
109
+ btn.type = "button";
110
+ btn.className = "jdfjs-save-button";
111
+ btn.textContent = label;
112
+ btn.addEventListener("click", () => inst.downloadJdf(filename));
113
+ // Make sure the host has positioning context for the absolutely-placed button.
114
+ if (getComputedStyle(container).position === "static") {
115
+ container.style.position = "relative";
116
+ }
117
+ container.appendChild(btn);
118
+ }
119
+
120
+ function disposeElement(el: Element) {
121
+ const inst = VIEWER_INSTANCES.get(el);
122
+ if (inst) {
123
+ try { inst.destroy(); } catch { /* swallow */ }
124
+ VIEWER_INSTANCES.delete(el);
125
+ }
126
+ const aobs = ATTR_OBSERVERS.get(el);
127
+ if (aobs) {
128
+ aobs.disconnect();
129
+ ATTR_OBSERVERS.delete(el);
130
+ }
131
+ PROCESSED.delete(el);
132
+ ATTR_OBSERVED.delete(el);
133
+ }
134
+
76
135
  function applySizeAttrs(el: HTMLElement) {
77
136
  const w = el.getAttribute("width");
78
137
  if (w != null) {
@@ -96,8 +155,9 @@ function observeAttributes(el: Element) {
96
155
  if (m.type !== "attributes" || !m.attributeName) continue;
97
156
  const name = m.attributeName;
98
157
  if (name === "src") {
99
- // Re-render with the new src
100
- PROCESSED.delete(el);
158
+ // Re-render with the new src — dispose the previous viewer first so
159
+ // its observers/listeners are torn down before the next one mounts.
160
+ disposeElement(el);
101
161
  processElement(el);
102
162
  } else if (name === "width" || name === "height") {
103
163
  applySizeAttrs(el as HTMLElement);
@@ -105,6 +165,7 @@ function observeAttributes(el: Element) {
105
165
  }
106
166
  });
107
167
  obs.observe(el, { attributes: true, attributeFilter: ["src", "width", "height"] });
168
+ ATTR_OBSERVERS.set(el, obs);
108
169
  }
109
170
 
110
171
  function scan(root: ParentNode = document) {
@@ -118,7 +179,19 @@ function watchForNewTargets() {
118
179
  m.addedNodes.forEach((n) => {
119
180
  if (n instanceof Element) {
120
181
  if (n.tagName.toLowerCase() === "jdf") processElement(n);
121
- else scan(n);
182
+ else if (typeof n.querySelectorAll === "function") {
183
+ // Limit to elements that look like roots, not viewer-internal
184
+ // children — those are added by us and have no <jdf> tag inside.
185
+ n.querySelectorAll("jdf").forEach(processElement);
186
+ }
187
+ }
188
+ });
189
+ m.removedNodes.forEach((n) => {
190
+ if (n instanceof Element) {
191
+ if (n.tagName.toLowerCase() === "jdf") disposeElement(n);
192
+ else if (typeof n.querySelectorAll === "function") {
193
+ n.querySelectorAll("jdf").forEach(disposeElement);
194
+ }
122
195
  }
123
196
  });
124
197
  }
package/src/jdfjs.css CHANGED
@@ -223,3 +223,138 @@ jdf {
223
223
  width: 100%;
224
224
  min-height: 600px;
225
225
  }
226
+
227
+ /* === Form elements ====================================================== */
228
+ .jdfjs-form-field {
229
+ display: flex;
230
+ flex-direction: column;
231
+ gap: 4px;
232
+ font-family: "Inter", -apple-system, sans-serif;
233
+ font-size: 12px;
234
+ color: var(--jdfjs-text);
235
+ width: 100%;
236
+ height: 100%;
237
+ }
238
+
239
+ .jdfjs-form-label {
240
+ font-size: 11px;
241
+ font-weight: 600;
242
+ color: var(--jdfjs-text-soft);
243
+ letter-spacing: 0.02em;
244
+ }
245
+
246
+ .jdfjs-form-control {
247
+ display: block;
248
+ width: 100%;
249
+ padding: 6px 10px;
250
+ font: inherit;
251
+ color: var(--jdfjs-text);
252
+ background: var(--jdfjs-bg);
253
+ border: 1px solid var(--jdfjs-border);
254
+ border-radius: 6px;
255
+ transition: border-color 0.12s ease, box-shadow 0.12s ease;
256
+ box-sizing: border-box;
257
+ }
258
+ .jdfjs-form-control:focus {
259
+ outline: none;
260
+ border-color: var(--jdfjs-brand);
261
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.16);
262
+ }
263
+ .jdfjs-form-control[readonly],
264
+ .jdfjs-form-control:disabled {
265
+ background: var(--jdfjs-bg-soft, #f3f4f6);
266
+ color: var(--jdfjs-text-soft);
267
+ cursor: not-allowed;
268
+ }
269
+
270
+ textarea.jdfjs-form-control {
271
+ resize: vertical;
272
+ min-height: 60px;
273
+ line-height: 1.4;
274
+ }
275
+
276
+ .jdfjs-form-checkbox {
277
+ flex-direction: row;
278
+ align-items: center;
279
+ gap: 8px;
280
+ cursor: pointer;
281
+ }
282
+ .jdfjs-form-checkbox input[type="checkbox"] {
283
+ width: 16px; height: 16px;
284
+ margin: 0;
285
+ cursor: pointer;
286
+ accent-color: var(--jdfjs-brand);
287
+ }
288
+ .jdfjs-form-checkbox-label {
289
+ font-size: 13px;
290
+ color: var(--jdfjs-text);
291
+ user-select: none;
292
+ }
293
+
294
+ select.jdfjs-form-control {
295
+ appearance: none;
296
+ -webkit-appearance: none;
297
+ background-image: linear-gradient(45deg, transparent 50%, var(--jdfjs-text-soft) 50%),
298
+ linear-gradient(135deg, var(--jdfjs-text-soft) 50%, transparent 50%);
299
+ background-position: calc(100% - 16px) 50%, calc(100% - 11px) 50%;
300
+ background-size: 5px 5px, 5px 5px;
301
+ background-repeat: no-repeat;
302
+ padding-right: 28px;
303
+ }
304
+
305
+ .jdfjs-form-signature-canvas {
306
+ border: 1px dashed var(--jdfjs-border);
307
+ border-radius: 6px;
308
+ background: var(--jdfjs-bg);
309
+ cursor: crosshair;
310
+ touch-action: none;
311
+ }
312
+ .jdfjs-form-signature-clear {
313
+ align-self: flex-start;
314
+ margin-top: 4px;
315
+ padding: 2px 10px;
316
+ font-size: 11px;
317
+ color: var(--jdfjs-text-soft);
318
+ background: transparent;
319
+ border: 1px solid var(--jdfjs-border);
320
+ border-radius: 4px;
321
+ cursor: pointer;
322
+ }
323
+ .jdfjs-form-signature-clear:hover {
324
+ color: var(--jdfjs-text);
325
+ border-color: var(--jdfjs-text-soft);
326
+ }
327
+
328
+ /* === Save button (auto-init's `save-button` attribute) ================= */
329
+ .jdfjs-save-button {
330
+ position: absolute;
331
+ bottom: 16px;
332
+ right: 16px;
333
+ z-index: 5;
334
+ padding: 8px 16px;
335
+ font-family: "Inter", -apple-system, sans-serif;
336
+ font-size: 13px;
337
+ font-weight: 600;
338
+ color: #fff;
339
+ background: var(--jdfjs-brand);
340
+ border: 0;
341
+ border-radius: 8px;
342
+ box-shadow: 0 4px 14px rgba(37, 99, 235, 0.25);
343
+ cursor: pointer;
344
+ transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.12s ease;
345
+ }
346
+ .jdfjs-save-button:hover {
347
+ transform: translateY(-1px);
348
+ box-shadow: 0 6px 18px rgba(37, 99, 235, 0.35);
349
+ }
350
+ .jdfjs-save-button:active {
351
+ transform: translateY(0);
352
+ }
353
+
354
+ .jdfjs-dark .jdfjs-save-button {
355
+ background: #3b82f6;
356
+ }
357
+ .jdfjs-dark .jdfjs-form-control[readonly],
358
+ .jdfjs-dark .jdfjs-form-control:disabled {
359
+ background: #1f2937;
360
+ }
package/src/jdfx.ts CHANGED
@@ -2,10 +2,26 @@ import JSZip from "jszip";
2
2
  import {
3
3
  JDFX_DOCUMENT_PATH,
4
4
  JDFX_MANIFEST_PATH,
5
+ JDFX_ASSET_DIR,
5
6
  type JdfDocument,
6
7
  type JdfxManifest,
7
8
  } from "@jdf/core";
8
9
 
10
+ /**
11
+ * Reject manifest asset paths that try to escape the bundle's asset
12
+ * directory. JSZip operates in-memory so this isn't a filesystem traversal
13
+ * — but a crafted manifest can still bind an image resource to
14
+ * `document.json` itself, or to any other zip entry, which produces
15
+ * confusing render output (the document's own JSON loaded as an image).
16
+ * Restrict to entries under `assets/` and reject anything with `..`.
17
+ */
18
+ function isSafeAssetPath(p: string): boolean {
19
+ if (!p || typeof p !== "string") return false;
20
+ if (p.startsWith("/") || p.includes("\\")) return false;
21
+ if (p.includes("..")) return false;
22
+ return p.startsWith(`${JDFX_ASSET_DIR}/`);
23
+ }
24
+
9
25
  /**
10
26
  * Open a `.jdfx` zip bundle and return the embedded JDF document with all
11
27
  * `image` element `resource` references rewritten to blob URLs that work as
@@ -33,6 +49,10 @@ export async function unpackJdfxToDocument(bytes: ArrayBuffer | Uint8Array): Pro
33
49
  const idToBlobUrl = new Map<string, string>();
34
50
  if (manifest?.assets) {
35
51
  for (const entry of manifest.assets) {
52
+ if (!isSafeAssetPath(entry.path)) {
53
+ console.warn(`[jdfjs] dropping unsafe manifest asset path: ${entry.path}`);
54
+ continue;
55
+ }
36
56
  const file = zip.file(entry.path);
37
57
  if (!file) continue;
38
58
  const data = await file.async("uint8array");
@@ -2,6 +2,7 @@ import type {
2
2
  Element, Style, Resources, JdfDocument,
3
3
  TextElement, RichTextElement, ImageElement, TableElement, ListElement,
4
4
  ShapeElement, CollapsibleElement, TocElement, RichTextRun, ListItem, TableCellValue, ImageResource,
5
+ FormInputElement, FormTextareaElement, FormCheckboxElement, FormSelectElement, FormSignatureElement,
5
6
  } from "@jdf/core";
6
7
  import { unitToPx } from "@jdf/core";
7
8
  import { resolveStyle, styleToCss, applyStyle } from "../utils/style";
@@ -13,6 +14,14 @@ export interface RenderContext {
13
14
  document: JdfDocument;
14
15
  path: (string | number)[];
15
16
  onNavigatePage?: (pageIndex: number) => void;
17
+ /**
18
+ * Called when a form field's value changes. The viewer wires this to a
19
+ * mutation that updates the in-memory JdfDocument so `exportJdf()` later
20
+ * returns the user-filled state. `path` is the element path inside the
21
+ * doc (e.g. `["pages", 0, "elements", 3]`); `field` is `"value"` /
22
+ * `"checked"` / `"values"`.
23
+ */
24
+ onFormChange?: (path: (string | number)[], field: string, value: unknown) => void;
16
25
  }
17
26
 
18
27
  const NS_SVG = "http://www.w3.org/2000/svg";
@@ -30,6 +39,11 @@ export function renderElement(el: Element, ctx: RenderContext): HTMLElement | nu
30
39
  case "shape": inner = renderShape(el); break;
31
40
  case "collapsible": inner = renderCollapsible(el, ctx); break;
32
41
  case "toc": inner = renderToc(el, ctx); break;
42
+ case "input": inner = renderFormInput(el, ctx); break;
43
+ case "textarea": inner = renderFormTextarea(el, ctx); break;
44
+ case "checkbox": inner = renderFormCheckbox(el, ctx); break;
45
+ case "select": inner = renderFormSelect(el, ctx); break;
46
+ case "signature": inner = renderFormSignature(el, ctx); break;
33
47
  }
34
48
  if (!inner) return null;
35
49
  wrap.appendChild(inner);
@@ -511,3 +525,183 @@ function renderToc(el: TocElement, ctx: RenderContext): HTMLElement {
511
525
  }
512
526
  return wrap;
513
527
  }
528
+
529
+ // ── form elements ──────────────────────────────────────────────────────────
530
+
531
+ function makeLabel(text: string | undefined): HTMLLabelElement | null {
532
+ if (!text) return null;
533
+ const lab = document.createElement("label");
534
+ lab.className = "jdfjs-form-label";
535
+ lab.textContent = text;
536
+ return lab;
537
+ }
538
+
539
+ function commitFormChange(ctx: RenderContext, field: string, value: unknown) {
540
+ if (!ctx.onFormChange) return;
541
+ ctx.onFormChange(ctx.path, field, value);
542
+ }
543
+
544
+ function renderFormInput(el: FormInputElement, ctx: RenderContext): HTMLElement {
545
+ const wrap = document.createElement("div");
546
+ wrap.className = "jdfjs-form-field jdfjs-form-input";
547
+ applyStyle(wrap, resolveStyle(el.style, ctx.styles));
548
+ const lab = makeLabel(el.label);
549
+ if (lab) wrap.appendChild(lab);
550
+ const input = document.createElement("input");
551
+ input.className = "jdfjs-form-control";
552
+ input.type = el.inputType || "text";
553
+ input.name = el.name;
554
+ if (el.value != null) input.value = el.value;
555
+ if (el.placeholder) input.placeholder = el.placeholder;
556
+ if (el.readonly) input.readOnly = true;
557
+ if (el.required) input.required = true;
558
+ if (el.pattern) input.pattern = el.pattern;
559
+ input.addEventListener("input", () => commitFormChange(ctx, "value", input.value));
560
+ input.addEventListener("change", () => commitFormChange(ctx, "value", input.value));
561
+ wrap.appendChild(input);
562
+ return wrap;
563
+ }
564
+
565
+ function renderFormTextarea(el: FormTextareaElement, ctx: RenderContext): HTMLElement {
566
+ const wrap = document.createElement("div");
567
+ wrap.className = "jdfjs-form-field jdfjs-form-textarea";
568
+ applyStyle(wrap, resolveStyle(el.style, ctx.styles));
569
+ const lab = makeLabel(el.label);
570
+ if (lab) wrap.appendChild(lab);
571
+ const ta = document.createElement("textarea");
572
+ ta.className = "jdfjs-form-control";
573
+ ta.name = el.name;
574
+ if (el.value != null) ta.value = el.value;
575
+ if (el.placeholder) ta.placeholder = el.placeholder;
576
+ if (el.readonly) ta.readOnly = true;
577
+ if (el.required) ta.required = true;
578
+ if (el.rows) ta.rows = el.rows;
579
+ ta.addEventListener("input", () => commitFormChange(ctx, "value", ta.value));
580
+ ta.addEventListener("change", () => commitFormChange(ctx, "value", ta.value));
581
+ wrap.appendChild(ta);
582
+ return wrap;
583
+ }
584
+
585
+ function renderFormCheckbox(el: FormCheckboxElement, ctx: RenderContext): HTMLElement {
586
+ const wrap = document.createElement("label");
587
+ wrap.className = "jdfjs-form-field jdfjs-form-checkbox";
588
+ applyStyle(wrap, resolveStyle(el.style, ctx.styles));
589
+ const cb = document.createElement("input");
590
+ cb.type = "checkbox";
591
+ cb.name = el.name;
592
+ cb.checked = el.checked === true;
593
+ if (el.readonly) cb.disabled = true;
594
+ if (el.required) cb.required = true;
595
+ cb.addEventListener("change", () => commitFormChange(ctx, "checked", cb.checked));
596
+ wrap.appendChild(cb);
597
+ if (el.label) {
598
+ const span = document.createElement("span");
599
+ span.className = "jdfjs-form-checkbox-label";
600
+ span.textContent = el.label;
601
+ wrap.appendChild(span);
602
+ }
603
+ return wrap;
604
+ }
605
+
606
+ function renderFormSelect(el: FormSelectElement, ctx: RenderContext): HTMLElement {
607
+ const wrap = document.createElement("div");
608
+ wrap.className = "jdfjs-form-field jdfjs-form-select";
609
+ applyStyle(wrap, resolveStyle(el.style, ctx.styles));
610
+ const lab = makeLabel(el.label);
611
+ if (lab) wrap.appendChild(lab);
612
+ const sel = document.createElement("select");
613
+ sel.className = "jdfjs-form-control";
614
+ sel.name = el.name;
615
+ if (el.multiple) sel.multiple = true;
616
+ if (el.readonly) sel.disabled = true;
617
+ if (el.required) sel.required = true;
618
+ const selectedSet = new Set<string>(
619
+ el.multiple ? (el.values || []) : (el.value != null ? [el.value] : []),
620
+ );
621
+ for (const opt of el.options) {
622
+ const o = document.createElement("option");
623
+ o.value = opt.value;
624
+ o.textContent = opt.label ?? opt.value;
625
+ if (selectedSet.has(opt.value)) o.selected = true;
626
+ sel.appendChild(o);
627
+ }
628
+ sel.addEventListener("change", () => {
629
+ if (el.multiple) {
630
+ const vals = Array.from(sel.selectedOptions).map((o) => o.value);
631
+ commitFormChange(ctx, "values", vals);
632
+ } else {
633
+ commitFormChange(ctx, "value", sel.value);
634
+ }
635
+ });
636
+ wrap.appendChild(sel);
637
+ return wrap;
638
+ }
639
+
640
+ function renderFormSignature(el: FormSignatureElement, ctx: RenderContext): HTMLElement {
641
+ const wrap = document.createElement("div");
642
+ wrap.className = "jdfjs-form-field jdfjs-form-signature";
643
+ applyStyle(wrap, resolveStyle(el.style, ctx.styles));
644
+ const lab = makeLabel(el.label);
645
+ if (lab) wrap.appendChild(lab);
646
+ const cw = unitToPx(el.width ?? 80);
647
+ const ch = unitToPx(el.height ?? 30);
648
+ const canvas = document.createElement("canvas");
649
+ canvas.className = "jdfjs-form-signature-canvas";
650
+ canvas.width = Math.max(50, cw);
651
+ canvas.height = Math.max(20, ch);
652
+ if (el.value) {
653
+ const img = new Image();
654
+ img.onload = () => {
655
+ const c = canvas.getContext("2d");
656
+ c?.drawImage(img, 0, 0, canvas.width, canvas.height);
657
+ };
658
+ img.src = el.value;
659
+ }
660
+ let drawing = false;
661
+ let last: { x: number; y: number } | null = null;
662
+ function pointerStart(e: PointerEvent) {
663
+ if (el.readonly) return;
664
+ drawing = true;
665
+ last = { x: e.offsetX, y: e.offsetY };
666
+ }
667
+ function pointerMove(e: PointerEvent) {
668
+ if (!drawing || !last) return;
669
+ const c = canvas.getContext("2d");
670
+ if (!c) return;
671
+ c.strokeStyle = "#0f172a";
672
+ c.lineWidth = 1.6;
673
+ c.lineCap = "round";
674
+ c.beginPath();
675
+ c.moveTo(last.x, last.y);
676
+ c.lineTo(e.offsetX, e.offsetY);
677
+ c.stroke();
678
+ last = { x: e.offsetX, y: e.offsetY };
679
+ }
680
+ function pointerEnd() {
681
+ if (!drawing) return;
682
+ drawing = false;
683
+ last = null;
684
+ try {
685
+ commitFormChange(ctx, "value", canvas.toDataURL("image/png"));
686
+ } catch { /* tainted canvas — shouldn't happen, no cross-origin sources */ }
687
+ }
688
+ canvas.addEventListener("pointerdown", pointerStart);
689
+ canvas.addEventListener("pointermove", pointerMove);
690
+ canvas.addEventListener("pointerup", pointerEnd);
691
+ canvas.addEventListener("pointerleave", pointerEnd);
692
+ wrap.appendChild(canvas);
693
+ if (!el.readonly) {
694
+ const clear = document.createElement("button");
695
+ clear.type = "button";
696
+ clear.className = "jdfjs-form-signature-clear";
697
+ clear.textContent = "Clear";
698
+ clear.addEventListener("click", (e) => {
699
+ e.preventDefault();
700
+ const c = canvas.getContext("2d");
701
+ c?.clearRect(0, 0, canvas.width, canvas.height);
702
+ commitFormChange(ctx, "value", "");
703
+ });
704
+ wrap.appendChild(clear);
705
+ }
706
+ return wrap;
707
+ }
package/src/utils/link.ts CHANGED
@@ -26,8 +26,11 @@ export function attachLinkBehaviour(
26
26
  el.href = resolved.href;
27
27
  if (resolved.internal) {
28
28
  el.addEventListener("click", (e) => {
29
+ // Always prevent default for internal anchors. If we have a real target
30
+ // page, navigate; otherwise just no-op so the host page's URL bar /
31
+ // history isn't polluted with hashes the embed couldn't resolve.
32
+ e.preventDefault();
29
33
  if (resolved.pageIndex != null && onNavigatePage) {
30
- e.preventDefault();
31
34
  onNavigatePage(resolved.pageIndex);
32
35
  }
33
36
  });