103 lines
3.9 KiB
JavaScript
103 lines
3.9 KiB
JavaScript
"use strict";
|
|
|
|
const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
|
|
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
|
|
|
|
/**
|
|
* Convert the rendered CMap surface into a standalone SVG snapshot.
|
|
*
|
|
* HTML concept cards remain inside an XHTML foreignObject while connector SVG
|
|
* elements stay vector based. The renderer reads the current DOM only; it has
|
|
* no knowledge of the editor model or persistence layer.
|
|
*/
|
|
export class CmapSnapshotSvgRenderer {
|
|
constructor({ stylesheetFilter = (sheet) => Boolean(sheet.href?.includes("cmap.css")) } = {}) {
|
|
this.stylesheetFilter = stylesheetFilter;
|
|
}
|
|
|
|
render(surface, { width = null, height = null, includeStyles = true } = {}) {
|
|
if (!surface || typeof surface.cloneNode !== "function") {
|
|
throw new TypeError("A rendered CMap surface is required");
|
|
}
|
|
const dimensions = this.dimensions(surface, width, height);
|
|
const clone = surface.cloneNode(true);
|
|
this.removeInteractiveElements(clone);
|
|
clone.removeAttribute("id");
|
|
clone.style.zoom = "1";
|
|
clone.style.transform = "none";
|
|
clone.style.width = `${dimensions.width}px`;
|
|
clone.style.height = `${dimensions.height}px`;
|
|
|
|
const svg = document.createElementNS(SVG_NAMESPACE, "svg");
|
|
svg.setAttribute("xmlns", SVG_NAMESPACE);
|
|
svg.setAttribute("xmlns:xhtml", XHTML_NAMESPACE);
|
|
svg.setAttribute("version", "1.1");
|
|
svg.setAttribute("width", String(dimensions.width));
|
|
svg.setAttribute("height", String(dimensions.height));
|
|
svg.setAttribute("viewBox", `0 0 ${dimensions.width} ${dimensions.height}`);
|
|
svg.setAttribute("preserveAspectRatio", "xMinYMin meet");
|
|
|
|
if (includeStyles) {
|
|
const style = document.createElementNS(SVG_NAMESPACE, "style");
|
|
style.textContent = this.stylesheetText();
|
|
svg.append(style);
|
|
}
|
|
|
|
const foreignObject = document.createElementNS(SVG_NAMESPACE, "foreignObject");
|
|
foreignObject.setAttribute("x", "0");
|
|
foreignObject.setAttribute("y", "0");
|
|
foreignObject.setAttribute("width", String(dimensions.width));
|
|
foreignObject.setAttribute("height", String(dimensions.height));
|
|
clone.setAttribute("xmlns", XHTML_NAMESPACE);
|
|
foreignObject.append(clone);
|
|
svg.append(foreignObject);
|
|
|
|
return new XMLSerializer().serializeToString(svg);
|
|
}
|
|
|
|
dimensions(surface, requestedWidth, requestedHeight) {
|
|
const width = Number(requestedWidth) || Math.ceil(Math.max(
|
|
surface.scrollWidth || 0,
|
|
surface.getBoundingClientRect?.().width || 0,
|
|
...Array.from(surface.children || []).map((child) =>
|
|
(Number.parseFloat(child.style.left) || 0) + (child.offsetWidth || 0))
|
|
));
|
|
const height = Number(requestedHeight) || Math.ceil(Math.max(
|
|
surface.scrollHeight || 0,
|
|
surface.getBoundingClientRect?.().height || 0,
|
|
...Array.from(surface.children || []).map((child) =>
|
|
(Number.parseFloat(child.style.top) || 0) + (child.offsetHeight || 0))
|
|
));
|
|
return {
|
|
width: Math.max(1, width || 1),
|
|
height: Math.max(1, height || 1)
|
|
};
|
|
}
|
|
|
|
removeInteractiveElements(root) {
|
|
for (const element of root.querySelectorAll(
|
|
".rw-cmap-handle, .rw-cmap-resize-handle, .rw-cmap-relation-handle, " +
|
|
".rw-cmap-edit-handle, .rw-cmap-submap-toggle, .rw-cmap-submap-open")) {
|
|
element.remove();
|
|
}
|
|
for (const element of root.querySelectorAll("[aria-selected], [data-rw-cmap-bound]") ) {
|
|
element.removeAttribute("aria-selected");
|
|
element.removeAttribute("data-rw-cmap-bound");
|
|
}
|
|
}
|
|
|
|
stylesheetText() {
|
|
if (typeof document === "undefined") return "";
|
|
const rules = [];
|
|
for (const sheet of Array.from(document.styleSheets || [])) {
|
|
if (!this.stylesheetFilter(sheet)) continue;
|
|
try {
|
|
rules.push(...Array.from(sheet.cssRules || []).map((rule) => rule.cssText));
|
|
} catch (_error) {
|
|
// Cross-origin stylesheets cannot be inspected and are skipped.
|
|
}
|
|
}
|
|
return rules.join("\n");
|
|
}
|
|
}
|