xref: /plugin/bpmnio/script/bpmnio_render.js (revision 36b712d809a9afeda77eb7dba8abf621818208c9)
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
332async function renderBpmnDiagram(xml, container) {
333    const BpmnViewer = window.BpmnJS?.Viewer;
334    if (typeof BpmnViewer !== "function") {
335        throw new Error("BPMN viewer library is unavailable.");
336    }
337
338    const viewer = new BpmnViewer({ container });
339    const root = jQuery(container).closest(".plugin-bpmnio");
340    const linkMap = parseLinkMap(root, "bpmn");
341
342    return renderDiagram(xml, container, viewer, computeBpmnDiagramSize, linkMap, "bpmn");
343}
344
345async function renderDmnDiagram(xml, container) {
346    const DmnViewer = window.DmnJSViewer;
347    if (typeof DmnViewer !== "function") {
348        throw new Error("DMN viewer library is unavailable.");
349    }
350
351    const viewer = new DmnViewer({ container });
352    const root = jQuery(container).closest(".plugin-bpmnio");
353    const linkMap = parseLinkMap(root, "dmn");
354
355    return renderDiagram(xml, container, viewer, computeDmnDiagramSize, linkMap, "dmn");
356}
357
358async function exportDataBase64(editor, linkMap = {}) {
359    try {
360        if (typeof editor?.saveXML !== "function") {
361            return null;
362        }
363
364        const options = { format: true };
365        const result = await editor.saveXML(options);
366        const { xml } = result;
367        if (typeof xml === "string" && xml.length > 0) {
368            const restoredXml = restoreWikiLinks(xml, linkMap);
369            const encoder = new TextEncoder();
370            const data = encoder.encode(restoredXml);
371            return btoa(String.fromCharCode(...data));
372        }
373    } catch {
374        return null;
375    }
376
377    return null;
378}
379
380function addFormSubmitListener(editor, container, type) {
381    const form = document.getElementById("dw__editform");
382    if (!form) {
383        showStatusMessage(container, "Editor form is unavailable.");
384        return;
385    }
386
387    if (form.dataset.pluginBpmnioListenerBound === "true") {
388        return;
389    }
390
391    form.dataset.pluginBpmnioListenerBound = "true";
392    form.addEventListener("submit", async (event) => {
393        if (form.dataset.pluginBpmnioSubmitting === "true") {
394            delete form.dataset.pluginBpmnioSubmitting;
395            return;
396        }
397
398        event.preventDefault();
399
400        const field = form.querySelector('input[name="plugin_bpmnio_data"]');
401        if (!field) {
402            showStatusMessage(container, "Diagram data field is unavailable.");
403            return;
404        }
405
406        clearStatusMessage(container);
407        const root = jQuery(container).closest(".plugin-bpmnio");
408        const linkMap = parseLinkMap(root, type);
409        const data = await exportDataBase64(editor, linkMap);
410        if (!data) {
411            showStatusMessage(container, "Unable to save diagram changes.");
412            return;
413        }
414
415        field.value = data;
416        form.dataset.pluginBpmnioSubmitting = "true";
417
418        if (typeof form.requestSubmit === "function") {
419            form.requestSubmit(event.submitter);
420            return;
421        }
422
423        delete form.dataset.pluginBpmnioSubmitting;
424        form.submit();
425    });
426}
427
428async function renderBpmnEditor(xml, container) {
429    const BpmnEditor = window.BpmnJS;
430    if (typeof BpmnEditor !== "function") {
431        throw new Error("BPMN editor library is unavailable.");
432    }
433
434    const editor = new BpmnEditor({ container });
435    addFormSubmitListener(editor, container, "bpmn");
436    return renderDiagram(xml, container, editor, null, {}, "bpmn");
437}
438
439async function renderDmnEditor(xml, container) {
440    const DmnEditor = window.DmnJS;
441    if (typeof DmnEditor !== "function") {
442        throw new Error("DMN editor library is unavailable.");
443    }
444
445    const editor = new DmnEditor({ container });
446    addFormSubmitListener(editor, container, "dmn");
447    return renderDiagram(xml, container, editor, null, {}, "dmn");
448}
449
450function startRender(fn, xml, container) {
451    Promise.resolve(fn(xml, container)).catch((error) => {
452        showContainerError(
453            container,
454            getErrorMessage(error, "Unable to initialize diagram.")
455        );
456    });
457}
458
459function safeRender(tag, type, fn) {
460    try {
461        const root = jQuery(tag);
462        const containerId = "." + type + "_js_container";
463        const container = root.find(containerId)[0];
464        if (!container) {
465            showStatusMessage(tag, "Diagram container is missing.");
466            return;
467        }
468
469        if (container.children?.length > 0) return;
470
471        const dataId = "." + type + "_js_data";
472        const data = root.find(dataId)[0];
473        if (!data) {
474            showContainerError(container, "Diagram data is missing.");
475            return;
476        }
477
478        const xml = extractXml(data.textContent);
479
480        if (xml.startsWith("Error:")) {
481            showContainerError(container, xml);
482            return;
483        }
484
485        startRender(fn, xml, container);
486    } catch (err) {
487        showStatusMessage(
488            tag,
489            getErrorMessage(err, "Unable to initialize diagram.")
490        );
491    }
492}
493
494jQuery(document).ready(function () {
495    jQuery("div[id^=__bpmn_js_]").each((_, tag) =>
496        safeRender(tag, "bpmn", renderBpmnDiagram)
497    );
498    jQuery("div[id^=__dmn_js_]").each((_, tag) =>
499        safeRender(tag, "dmn", renderDmnDiagram)
500    );
501    jQuery("div[id=plugin_bpmnio__bpmn_editor]").each((_, tag) =>
502        safeRender(tag, "bpmn", renderBpmnEditor)
503    );
504    jQuery("div[id=plugin_bpmnio__dmn_editor]").each((_, tag) =>
505        safeRender(tag, "dmn", renderDmnEditor)
506    );
507});
508