@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uurtech/jdf",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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)
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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");
|
package/src/renderers/element.ts
CHANGED
|
@@ -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
|
});
|