xref: /plugin/bpmnio/script/bpmnio_render.js (revision 738a44ff661500d783f23960888bd3ae2818f44c) !
1function extractXml(data) {
2    return new TextDecoder()
3        .decode(Uint8Array.from(atob(data), (c) => c.charCodeAt(0)))
4        .trim();
5}
6
7function getErrorMessage(error, fallbackMessage) {
8    if (error instanceof Error && error.message) {
9        return error.message;
10    }
11
12    if (typeof error === "string" && error.length > 0) {
13        return error;
14    }
15
16    return fallbackMessage;
17}
18
19function getPluginRoot(target) {
20    if (!(target instanceof Element)) {
21        return null;
22    }
23
24    return target.closest(".plugin-bpmnio") ?? target;
25}
26
27function getStatusElement(target) {
28    const root = getPluginRoot(target);
29    if (!root) {
30        return null;
31    }
32
33    let status = root.querySelector(".plugin_bpmnio_status");
34    if (!status) {
35        status = document.createElement("div");
36        status.className = "plugin_bpmnio_status";
37        root.append(status);
38    }
39
40    return status;
41}
42
43function clearStatusMessage(target) {
44    const status = getPluginRoot(target)?.querySelector(".plugin_bpmnio_status");
45    if (status) {
46        status.remove();
47    }
48}
49
50function showStatusMessage(target, message) {
51    const status = getStatusElement(target);
52    if (!status) {
53        return;
54    }
55
56    status.textContent = message;
57    status.style.color = "red";
58}
59
60function showContainerError(container, message) {
61    clearStatusMessage(container);
62    container.textContent = message;
63    container.style.color = "red";
64}
65
66function clearContainerError(container) {
67    container.style.color = "";
68    clearStatusMessage(container);
69}
70
71function getLayerBounds(canvas) {
72    const layer = canvas?.getActiveLayer?.();
73    if (!layer || typeof layer.getBBox !== "function") {
74        return null;
75    }
76
77    const bounds = layer.getBBox();
78    if (!bounds) {
79        return null;
80    }
81
82    const values = [bounds.x, bounds.y, bounds.width, bounds.height];
83    if (!values.every(Number.isFinite)) {
84        return null;
85    }
86
87    return bounds;
88}
89
90function extractPayload(data) {
91    if (!data) {
92        return "";
93    }
94
95    return new TextDecoder().decode(
96        Uint8Array.from(atob(data), (c) => c.charCodeAt(0))
97    );
98}
99
100function parseLinkMap(root, type) {
101    const dataId = "." + type + "_js_links";
102    const payload = root.find(dataId)[0];
103
104    if (!payload?.textContent?.trim()) {
105        return {};
106    }
107
108    try {
109        return JSON.parse(extractPayload(payload.textContent.trim()));
110    } catch {
111        return {};
112    }
113}
114
115function getElementRegistry(viewer, type) {
116    if (type === "dmn") {
117        const activeView = viewer.getActiveView();
118        if (!activeView || activeView.type !== "drd") {
119            return null;
120        }
121
122        return viewer.getActiveViewer()?.get("elementRegistry") ?? null;
123    }
124
125    return viewer.get("elementRegistry");
126}
127
128function openDiagramLink(event, href) {
129    if (event.button !== undefined && event.button !== 0) {
130        return;
131    }
132
133    if (event.metaKey || event.ctrlKey) {
134        window.open(href, "_blank", "noopener");
135        return;
136    }
137
138    window.location.assign(href);
139}
140
141function setGraphicsTooltip(graphics, href) {
142    let tooltip = graphics.querySelector(":scope > title");
143    if (!tooltip) {
144        tooltip = document.createElementNS("http://www.w3.org/2000/svg", "title");
145        graphics.insertBefore(tooltip, graphics.firstChild);
146    }
147
148    tooltip.textContent = href;
149}
150
151function wireGraphicsLink(graphics, href, linkClass = "wikilink1") {
152    if (!graphics) {
153        return;
154    }
155
156    if (graphics.dataset.bpmnioLinked !== "true") {
157        graphics.addEventListener("click", (event) => openDiagramLink(event, href));
158        graphics.addEventListener("keydown", (event) => {
159            if (event.key !== "Enter" && event.key !== " ") {
160                return;
161            }
162
163            event.preventDefault();
164            openDiagramLink(event, href);
165        });
166    }
167
168    graphics.setAttribute("tabindex", "0");
169    graphics.setAttribute("role", "link");
170    graphics.setAttribute("aria-label", href);
171    setGraphicsTooltip(graphics, href);
172    graphics.dataset.bpmnioLinked = "true";
173    graphics.classList.add("bpmnio-linked", linkClass);
174}
175
176function applyDiagramLinks(viewer, type, links) {
177    const elementRegistry = getElementRegistry(viewer, type);
178    if (!elementRegistry) {
179        return;
180    }
181
182    for (const [elementId, link] of Object.entries(links)) {
183        if (!link?.href) {
184            continue;
185        }
186
187        const element = elementRegistry.get(elementId);
188        if (!element) {
189            continue;
190        }
191
192        wireGraphicsLink(elementRegistry.getGraphics(element), link.href);
193
194        const labelElement = elementRegistry.get(`${elementId}_label`);
195        if (labelElement) {
196            wireGraphicsLink(elementRegistry.getGraphics(labelElement), link.href);
197        }
198    }
199}
200
201function restoreWikiLinks(xml, links) {
202    if (!links || Object.keys(links).length === 0) {
203        return xml;
204    }
205
206    const parser = new DOMParser();
207    const document = parser.parseFromString(xml, "application/xml");
208
209    if (document.querySelector("parsererror")) {
210        return xml;
211    }
212
213    const elements = document.getElementsByTagName("*");
214    for (const element of elements) {
215        const elementId = element.getAttribute("id");
216        if (!elementId || !Object.hasOwn(links, elementId) || !element.hasAttribute("name")) {
217            continue;
218        }
219
220        const currentName = element.getAttribute("name").trim();
221        const target = links[elementId]?.target;
222        if (!target) {
223            continue;
224        }
225
226        const linkMarkup = currentName === "" || currentName === target
227            ? `[[${target}]]`
228            : `[[${target}|${currentName}]]`;
229
230        element.setAttribute("name", linkMarkup);
231    }
232
233    return new XMLSerializer().serializeToString(document);
234}
235
236async function renderDiagram(xml, container, viewer, computeSizeFn, linkMap = {}, type) {
237    try {
238        clearContainerError(container);
239        await viewer.importXML(xml);
240
241        applyDiagramLinks(viewer, type, linkMap);
242
243        if (!computeSizeFn) return;
244
245        const zoom = getZoomFactor(container);
246        const layout = computeSizeFn(viewer, zoom);
247        if (!layout) return;
248
249        container.style.height = `${layout.scaledHeight}px`;
250        container.style.width = `${layout.scaledWidth}px`;
251
252        if (typeof layout.applyZoom === "function") {
253            layout.applyZoom();
254        }
255    } catch (err) {
256        showContainerError(
257            container,
258            getErrorMessage(err, "Unable to render diagram.")
259        );
260    }
261}
262
263function getZoomFactor(container) {
264    const zoom = Number.parseFloat(container.dataset.zoom ?? "1");
265
266    if (!Number.isFinite(zoom) || zoom <= 0) {
267        return 1;
268    }
269
270    return zoom;
271}
272
273function computeBpmnDiagramSize(viewer, zoom) {
274    const canvas = viewer.get("canvas");
275    const bboxViewport = getLayerBounds(canvas);
276    if (!bboxViewport) {
277        return undefined;
278    }
279
280    const width = bboxViewport.width + 4;
281    const height = bboxViewport.height + 4;
282
283    return {
284        width,
285        height,
286        scaledWidth: Math.max(width * zoom, 1),
287        scaledHeight: Math.max(height * zoom, 1),
288        applyZoom() {
289            canvas.resized();
290            canvas.viewbox({
291                x: bboxViewport.x - 2,
292                y: bboxViewport.y - 2,
293                width,
294                height,
295            });
296        },
297    };
298}
299
300function computeDmnDiagramSize(viewer, zoom) {
301    const activeView = viewer.getActiveView();
302    if (!activeView || activeView.type !== "drd") {
303        return undefined;
304    }
305
306    const activeEditor = viewer.getActiveViewer();
307    const canvas = activeEditor?.get("canvas");
308    const bboxViewport = getLayerBounds(canvas);
309    if (!bboxViewport) {
310        return undefined;
311    }
312
313    const width = bboxViewport.width + 4;
314    const height = bboxViewport.height + 4;
315    return {
316        width,
317        height,
318        scaledWidth: Math.max(width * zoom, 1),
319        scaledHeight: Math.max(height * zoom, 1),
320        applyZoom() {
321            canvas.resized();
322            canvas.viewbox({
323                x: bboxViewport.x - 2,
324                y: bboxViewport.y - 2,
325                width,
326                height,
327            });
328        },
329    };
330}
331
332function resolveBpmnLint() {
333    const lintModule =
334        window.BpmnLintModule ??
335        window.BpmnJS?.lintModule ??
336        window.BpmnJS?.Viewer?.lintModule ??
337        null;
338    const lintConfig =
339        window.BpmnLintConfig ??
340        window.BpmnJS?.lintConfig ??
341        window.BpmnJS?.Viewer?.lintConfig ??
342        null;
343
344    if (lintModule === null || (typeof lintModule !== "object" && typeof lintModule !== "function")) {
345        return null;
346    }
347
348    if (!lintConfig?.config || !lintConfig?.resolver) {
349        return null;
350    }
351
352    return { lintModule, lintConfig };
353}
354
355// Reads the per-diagram data-lint attribute and turns it into bpmn-js
356// constructor options. Recognised values:
357//   "off"          -> linter not loaded (no toggle button, no overlays)
358//   "on"           -> linter loaded, overlays active immediately
359//   "inactive"     -> linter loaded, toggle button present, overlays hidden
360//   absent / other -> linter not loaded (treated as "off")
361// The PHP renderer always emits data-lint for BPMN diagrams (resolved from the
362// per-diagram attribute or the global plugin config), so no client-side default
363// is needed. When the linter module cannot be resolved the diagram renders
364// exactly as before.
365function buildBpmnLintOptions(container) {
366    const mode = (container?.dataset?.lint ?? "").trim().toLowerCase();
367
368    if (mode !== "on" && mode !== "inactive") {
369        return { additionalModules: [], linting: undefined };
370    }
371
372    const resolved = resolveBpmnLint();
373    if (!resolved) {
374        return { additionalModules: [], linting: undefined };
375    }
376
377    return {
378        additionalModules: [resolved.lintModule],
379        linting: { bpmnlint: resolved.lintConfig, active: mode === "on" },
380    };
381}
382
383async function renderBpmnDiagram(xml, container) {
384    const BpmnViewer = window.BpmnJS?.Viewer;
385    if (typeof BpmnViewer !== "function") {
386        throw new Error("BPMN viewer library is unavailable.");
387    }
388
389    const { additionalModules, linting } = buildBpmnLintOptions(container);
390    const viewer = new BpmnViewer({ container, additionalModules, linting });
391    const root = jQuery(container).closest(".plugin-bpmnio");
392    const linkMap = parseLinkMap(root, "bpmn");
393
394    return renderDiagram(xml, container, viewer, computeBpmnDiagramSize, linkMap, "bpmn");
395}
396
397async function renderDmnDiagram(xml, container) {
398    const DmnViewer = window.DmnJSViewer;
399    if (typeof DmnViewer !== "function") {
400        throw new Error("DMN viewer library is unavailable.");
401    }
402
403    const viewer = new DmnViewer({ container });
404    const root = jQuery(container).closest(".plugin-bpmnio");
405    const linkMap = parseLinkMap(root, "dmn");
406
407    return renderDiagram(xml, container, viewer, computeDmnDiagramSize, linkMap, "dmn");
408}
409
410async function exportDataBase64(editor, linkMap = {}) {
411    try {
412        if (typeof editor?.saveXML !== "function") {
413            return null;
414        }
415
416        const options = { format: true };
417        const result = await editor.saveXML(options);
418        const { xml } = result;
419        if (typeof xml === "string" && xml.length > 0) {
420            const restoredXml = restoreWikiLinks(xml, linkMap);
421            const encoder = new TextEncoder();
422            const data = encoder.encode(restoredXml);
423            return btoa(String.fromCharCode(...data));
424        }
425    } catch {
426        return null;
427    }
428
429    return null;
430}
431
432function addFormSubmitListener(editor, container, type) {
433    const form = document.getElementById("dw__editform");
434    if (!form) {
435        showStatusMessage(container, "Editor form is unavailable.");
436        return;
437    }
438
439    if (form.dataset.pluginBpmnioListenerBound === "true") {
440        return;
441    }
442
443    form.dataset.pluginBpmnioListenerBound = "true";
444    form.addEventListener("submit", async (event) => {
445        if (form.dataset.pluginBpmnioSubmitting === "true") {
446            delete form.dataset.pluginBpmnioSubmitting;
447            return;
448        }
449
450        event.preventDefault();
451
452        const field = form.querySelector('input[name="plugin_bpmnio_data"]');
453        if (!field) {
454            showStatusMessage(container, "Diagram data field is unavailable.");
455            return;
456        }
457
458        clearStatusMessage(container);
459        const root = jQuery(container).closest(".plugin-bpmnio");
460        const linkMap = parseLinkMap(root, type);
461        const data = await exportDataBase64(editor, linkMap);
462        if (!data) {
463            showStatusMessage(container, "Unable to save diagram changes.");
464            return;
465        }
466
467        field.value = data;
468        form.dataset.pluginBpmnioSubmitting = "true";
469
470        if (typeof form.requestSubmit === "function") {
471            form.requestSubmit(event.submitter);
472            return;
473        }
474
475        delete form.dataset.pluginBpmnioSubmitting;
476        form.submit();
477    });
478}
479
480async function renderBpmnEditor(xml, container) {
481    const BpmnEditor = window.BpmnJS;
482    if (typeof BpmnEditor !== "function") {
483        throw new Error("BPMN editor library is unavailable.");
484    }
485
486    const { additionalModules, linting } = buildBpmnLintOptions(container);
487    const editor = new BpmnEditor({ container, additionalModules, linting });
488    addFormSubmitListener(editor, container, "bpmn");
489    return renderDiagram(xml, container, editor, null, {}, "bpmn");
490}
491
492async function renderDmnEditor(xml, container) {
493    const DmnEditor = window.DmnJS;
494    if (typeof DmnEditor !== "function") {
495        throw new Error("DMN editor library is unavailable.");
496    }
497
498    const editor = new DmnEditor({ container });
499    addFormSubmitListener(editor, container, "dmn");
500    return renderDiagram(xml, container, editor, null, {}, "dmn");
501}
502
503function startRender(fn, xml, container) {
504    Promise.resolve(fn(xml, container)).catch((error) => {
505        showContainerError(
506            container,
507            getErrorMessage(error, "Unable to initialize diagram.")
508        );
509    });
510}
511
512function safeRender(tag, type, fn) {
513    try {
514        const root = jQuery(tag);
515        const containerId = "." + type + "_js_container";
516        const container = root.find(containerId)[0];
517        if (!container) {
518            showStatusMessage(tag, "Diagram container is missing.");
519            return;
520        }
521
522        if (container.children?.length > 0) return;
523
524        const dataId = "." + type + "_js_data";
525        const data = root.find(dataId)[0];
526        if (!data) {
527            showContainerError(container, "Diagram data is missing.");
528            return;
529        }
530
531        const xml = extractXml(data.textContent);
532
533        if (xml.startsWith("Error:")) {
534            showContainerError(container, xml);
535            return;
536        }
537
538        startRender(fn, xml, container);
539    } catch (err) {
540        showStatusMessage(
541            tag,
542            getErrorMessage(err, "Unable to initialize diagram.")
543        );
544    }
545}
546
547jQuery(document).ready(function () {
548    jQuery("div[id^=__bpmn_js_]").each((_, tag) =>
549        safeRender(tag, "bpmn", renderBpmnDiagram)
550    );
551    jQuery("div[id^=__dmn_js_]").each((_, tag) =>
552        safeRender(tag, "dmn", renderDmnDiagram)
553    );
554    jQuery("div[id=plugin_bpmnio__bpmn_editor]").each((_, tag) =>
555        safeRender(tag, "bpmn", renderBpmnEditor)
556    );
557    jQuery("div[id=plugin_bpmnio__dmn_editor]").each((_, tag) =>
558        safeRender(tag, "dmn", renderDmnEditor)
559    );
560});
561