UNPKG

@reactodia/workspace

Version:

Reactodia Workspace -- library for visual interaction with graphs in a form of a diagram.

733 lines (644 loc) 26.1 kB
import type { ColorSchemeApi } from '../coreUtils/colorScheme'; import { Rect, Size, Vector } from './geometry'; export interface ToSVGOptions { colorSchemeApi: ColorSchemeApi; styleRoot: HTMLElement | SVGElement; layers: ReadonlyArray<SVGSVGElement | HTMLElement>; contentBox: Rect; /** @default false */ preserveDimensions?: boolean; /** @default false */ convertImagesToDataUris?: boolean; /** @default [] */ removeByCssSelectors?: ReadonlyArray<string>; watermarkSvg?: string; /** @default false */ addXmlHeader?: boolean; } interface Bounds { width: number; height: number; } const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; const XML_ENCODING_HEADER = '<?xml version="1.0" encoding="UTF-8"?>'; /** * @category Core */ export function toSVG(options: ToSVGOptions): Promise<string> { return exportSVG(options) .then(svg => { const svgText = new XMLSerializer().serializeToString(svg); return options.addXmlHeader ? (XML_ENCODING_HEADER + svgText) : svgText; }); } async function exportSVG(options: ToSVGOptions): Promise<SVGElement> { const { colorSchemeApi, contentBox: viewBox, watermarkSvg, preserveDimensions, convertImagesToDataUris, removeByCssSelectors = [], } = options; let clonedPaperSvg!: ReturnType<typeof composeExportedSvg>; let cssPropertyValues!: ReturnType<typeof captureCustomCssPropertyValues>; colorSchemeApi.actInColorScheme('light', () => { clonedPaperSvg = composeExportedSvg(options.layers, viewBox); const knownProperties = new Set<string>(); collectKnownPropertiesFromCssRules(knownProperties); collectKnownPropertiesFromInlineStyles(clonedPaperSvg.composedSvg, knownProperties); cssPropertyValues = captureCustomCssPropertyValues(options.styleRoot, knownProperties); }); const {composedSvg, imageBounds} = clonedPaperSvg; for (const selector of removeByCssSelectors) { for (const node of composedSvg.querySelectorAll(selector)) { node.remove(); } } // Workaround to include only library-related stylesheets const appliedCssRules = collectAppliedCssFromDocument(composedSvg); const imageUrls = new Map<string, string | null>(); if (convertImagesToDataUris) { collectImageUrlsFromElements(composedSvg, imageUrls); collectImageUrlsFromCssRules(appliedCssRules, imageUrls); collectImageUrlsFromInlineStyles(composedSvg, imageUrls); } if (preserveDimensions) { composedSvg.setAttribute('width', String(viewBox.width)); composedSvg.setAttribute('height', String(viewBox.height)); } else { composedSvg.setAttribute('width', '100%'); composedSvg.setAttribute('height', '100%'); } composedSvg.setAttribute('viewBox', `${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`); if (watermarkSvg) { addWatermark(composedSvg, viewBox, watermarkSvg); } await fetchImages(imageUrls); for (const img of composedSvg.querySelectorAll('img')) { const exportKey = img.getAttribute('export-key'); img.removeAttribute('export-key'); if (exportKey) { const {width, height} = imageBounds[exportKey]; img.setAttribute('width', width.toString()); img.setAttribute('height', height.toString()); const exportedUrl = imageUrls.get(img.src); if (exportedUrl) { img.src = exportedUrl; } } } embedImageUrlsToInlineStyles(composedSvg, imageUrls); const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); defs.innerHTML = ( `<style>${serializeCssPropertyValues(cssPropertyValues)}</style>\n` + `<style>${exportCssRules(appliedCssRules, imageUrls)}</style>` ); composedSvg.insertBefore(defs, composedSvg.firstChild); return composedSvg; } function addWatermark(svg: SVGElement, viewBox: Rect, watermarkSvg: string) { const WATERMARK_CLASS = 'reactodia-exported-watermark'; const WATERMARK_MAX_WIDTH = 120; const WATERMARK_PADDING = 20; const image = document.createElementNS(SVG_NAMESPACE, 'image'); image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', watermarkSvg); image.setAttribute('class', WATERMARK_CLASS); const width = Math.min(viewBox.width * 0.2, WATERMARK_MAX_WIDTH); const x = viewBox.x + viewBox.width - width - WATERMARK_PADDING; const y = viewBox.y + WATERMARK_PADDING; image.setAttribute('x', x.toString()); image.setAttribute('y', y.toString()); image.setAttribute('width', width.toString()); image.setAttribute('opacity', '0.3'); svg.insertBefore(image, svg.firstChild); } function collectAppliedCssFromDocument(targetSubtree: Element): CSSStyleRule[] { const appliedRules: CSSStyleRule[] = []; enumerateStylesheets(rule => { const selectorWithoutPseudo = rule.selectorText.replace(/::[a-zA-Z-]+$/, ''); if (targetSubtree.querySelector(selectorWithoutPseudo)) { appliedRules.push(rule); } }); return appliedRules; } function collectImageUrlsFromElements( target: Element, imageUrls: Map<string, string | null> ) { for (const img of target.querySelectorAll('img')) { if (img.src && !imageUrls.has(img.src)) { imageUrls.set(img.src, null); } } } function collectImageUrlsFromCssRules( rules: readonly CSSStyleRule[], imageUrls: Map<string, string | null> ): void { for (const rule of rules) { const maskUrl = parseCssImageUrl(rule.style.getPropertyValue('mask-image')); if (maskUrl && !imageUrls.has(maskUrl)) { imageUrls.set(maskUrl, null); } const backgroundUrl = parseCssImageUrl(rule.style.getPropertyValue('background-image')); if (backgroundUrl && !imageUrls.has(backgroundUrl)) { imageUrls.set(backgroundUrl, null); } } } function collectImageUrlsFromInlineStyles( target: Element, imageUrls: Map<string, string | null> ): void { enumerateDescendants(target, element => { if (element instanceof HTMLElement) { const maskUrl = parseCssImageUrl(element.style.maskImage); if (maskUrl && !imageUrls.has(maskUrl)) { imageUrls.set(maskUrl, null); } const backgroundUrl = parseCssImageUrl(element.style.backgroundImage); if (backgroundUrl && !imageUrls.has(backgroundUrl)) { imageUrls.set(backgroundUrl, null); } } }); } function embedImageUrlsToInlineStyles( target: Element, imageUrls: Map<string, string | null> ): void { enumerateDescendants(target, element => { if (element instanceof HTMLElement) { const maskUrl = parseCssImageUrl(element.style.maskImage); const exportedMaskUrl = maskUrl ? imageUrls.get(maskUrl) : undefined; if (exportedMaskUrl) { element.style.maskImage = serializeCssImageUrl(exportedMaskUrl); } const backgroundUrl = parseCssImageUrl(element.style.backgroundImage); const exportedBackgroundUrl = backgroundUrl ? imageUrls.get(backgroundUrl) : undefined; if (exportedBackgroundUrl) { element.style.backgroundImage = serializeCssImageUrl(exportedBackgroundUrl); } } }); } async function fetchImages(imageUrls: Map<string, string | null>): Promise<void> { await Promise.all(Array.from(imageUrls.keys(), async sourceUrl => { if (sourceUrl.startsWith('data:')) { return; } try { const dataUri = await exportImageAsDataUri(sourceUrl); if (dataUri && dataUri !== 'data:image/svg+xml,') { imageUrls.set(sourceUrl, dataUri); } } catch (err) { console.warn('Reactodia: Failed to export image: ' + sourceUrl, err); } })); } function exportCssRules( rules: readonly CSSStyleRule[], imageUrls: ReadonlyMap<string, string | null> ): string { const cssTexts = rules.map(rule => rule.cssText); const styleElement = document.createElement('style'); document.body.appendChild(styleElement); const sheet = styleElement.sheet; try { for (let i = 0; i < rules.length; i++) { const rule = rules[i]; const maskUrl = parseCssImageUrl(rule.style.getPropertyValue('mask-image')); const exportedMaskUrl = maskUrl ? imageUrls.get(maskUrl) : undefined; const backgroundUrl = parseCssImageUrl(rule.style.getPropertyValue('background-image')); const exportedBackgroundUrl = backgroundUrl ? imageUrls.get(backgroundUrl) : undefined; if (sheet && (exportedMaskUrl || exportedBackgroundUrl)) { sheet.insertRule(rule.cssText); const ruleCopy = sheet.cssRules[0]; if (ruleCopy instanceof CSSStyleRule) { if (exportedMaskUrl) { ruleCopy.style.setProperty('mask-image', serializeCssImageUrl(exportedMaskUrl)); } if (exportedBackgroundUrl) { ruleCopy.style.setProperty('background-image', serializeCssImageUrl(exportedBackgroundUrl)); } cssTexts[i] = ruleCopy.cssText; } sheet.deleteRule(0); } } } finally { document.body.removeChild(styleElement); } return cssTexts.join('\n'); } function parseCssImageUrl(imageValue: string | undefined): string | undefined { if (imageValue) { const trimmedValue = imageValue.trim(); let match = /^url\(\s*'(.*)'\s*\)$/i.exec(trimmedValue); if (match) { return match[1]; } match = /^url\(\s*"(.*)"\s*\)$/i.exec(trimmedValue); if (match) { return match[1]; } } return undefined; } function serializeCssImageUrl(dataUri: string): string { return `url("${dataUri}")`; } function composeExportedSvg(layers: ToSVGOptions['layers'], viewBox: Rect): { composedSvg: SVGSVGElement; imageBounds: { [path: string]: Bounds }; } { const composedSvg = document.createElementNS(SVG_NAMESPACE, 'svg'); const composedViewport = document.createElementNS(SVG_NAMESPACE, 'g'); composedViewport.setAttribute('class', 'reactodia-exported-canvas'); composedSvg.appendChild(composedViewport); const imageBounds = Object.create(null) as { [path: string]: Bounds }; for (const layer of layers) { if (layer instanceof SVGSVGElement) { const layerClone = layer.cloneNode(true) as SVGSVGElement; const layerViewport = findSvgLayerViewport(layerClone)!; layerViewport.removeAttribute('transform'); composedViewport.appendChild(layerViewport); } else if (layer instanceof HTMLElement) { const layerClone = layer.cloneNode(true) as HTMLElement; layerClone.classList.add('reactodia-exported-layer'); layerClone.setAttribute('style', `transform: translate(${-viewBox.x}px,${-viewBox.y}px)`); const layerForeignObject = document.createElementNS(SVG_NAMESPACE, 'foreignObject'); layerForeignObject.appendChild(layerClone); layerForeignObject.setAttribute('transform', `translate(${viewBox.x},${viewBox.y})`); layerForeignObject.setAttribute('width', String(viewBox.width)); layerForeignObject.setAttribute('height', String(viewBox.height)); const layerRoot = document.createElementNS(SVG_NAMESPACE, 'g'); layerRoot.appendChild(layerForeignObject); composedViewport.appendChild(layerRoot); const clonedNodes = layerClone.querySelectorAll('img'); let nextImageIndex = 0; for (const img of layer.querySelectorAll('img')) { const index = nextImageIndex; nextImageIndex++; const exportKey = `export-key-${index}`; clonedNodes[index].setAttribute('export-key', exportKey); imageBounds[exportKey] = { width: img.clientWidth, height: img.clientHeight, }; } } } return {composedSvg, imageBounds}; } function findSvgLayerViewport(layer: SVGSVGElement): SVGGElement | undefined { let child = layer.firstChild; while (child) { if (child instanceof SVGGElement) { return child; } child = child.nextSibling; } return undefined; } async function exportImageAsDataUri(url: string): Promise<string> { if (!url || url.startsWith('data:')) { return url; } // match extensions like "http://example.com/images/foo.JPG&w=200" const extensionMatch = url.match(/\.([a-zA-Z0-9]+)[^.a-zA-Z0-9]?[^.]*$/); const extension = extensionMatch ? extensionMatch[1].toLowerCase() : undefined; if (extension === 'svg') { try { const response = await fetch(url); if (response.ok) { const svgText = await response.text(); if (svgText.length > 0) { return 'data:image/svg+xml,' + encodeURIComponent(svgText.replace(/\r\n/g, '\n')); } } } catch (err) { /* Failed to fetch image as SVG */ } } const image = await loadCrossOriginImage(url); const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; const context = canvas.getContext('2d')!; context.drawImage(image, 0, 0); try { const mimeType = 'image/' + (extension === 'jpg' ? 'jpeg' : 'png'); const dataUri = canvas.toDataURL(mimeType); return dataUri; } catch (e) { throw new Error(`Failed to convert image to data URI: ${url}`, {cause: e}); } } function loadCrossOriginImage(src: string): Promise<HTMLImageElement> { const image = new Image(); image.crossOrigin = 'anonymous'; const promise = new Promise<HTMLImageElement>((resolve, reject) => { image.onload = () => resolve(image); image.onerror = ev => reject(ev); }); image.src = src; return promise; } function collectKnownPropertiesFromCssRules(foundProperties: Set<string>): void { enumerateStylesheets(rule => { for (let i = 0; i < rule.style.length; i++) { const property = rule.style[i]; if (property.startsWith('--') && !foundProperties.has(property)) { foundProperties.add(property); } } }); } function collectKnownPropertiesFromInlineStyles(target: Element, foundProperties: Set<string>): void { const visit = (element: Element) => { if (element instanceof HTMLElement) { for (let i = 0; i < element.style.length; i++) { const property = element.style[i]; if (property.startsWith('--') && !foundProperties.has(property)) { foundProperties.add(property); } } } }; // Visit all element descendants enumerateDescendants(target, visit); // Visit parent element chain let parent = target.parentElement; while (parent) { visit(parent); parent = parent.parentElement; } } function captureCustomCssPropertyValues( element: Element, knownProperties: ReadonlySet<string> ): Map<string, string> { const propertyValues = new Map<string, string>(); const styles = getComputedStyle(element); for (const property of styles) { if (property.startsWith('--')) { const value = styles.getPropertyValue(property); propertyValues.set(property, value); } } // Workaround for Chromium not iterating over custom CSS properties in getComputedStyle(): // https://issues.chromium.org/issues/41451306 for (const property of knownProperties) { if (!propertyValues.has(property)) { const value = styles.getPropertyValue(property); if (value !== undefined) { propertyValues.set(property, value); } } } return propertyValues; } function enumerateStylesheets(visit: (rule: CSSStyleRule) => void): void { const visitedRules = new WeakSet<CSSRule>(); const visitRule = (rule: CSSRule): void => { if (visitedRules.has(rule)) { return; } visitedRules.add(rule); if (rule instanceof CSSStyleRule) { visit(rule); } else if (rule instanceof CSSLayerBlockRule) { for (const subRule of rule.cssRules) { visitRule(subRule); } } }; for (let i = 0; i < document.styleSheets.length; i++) { let rules: CSSRuleList; try { const cssSheet = document.styleSheets[i]; rules = cssSheet.cssRules || cssSheet.rules; if (!rules) { continue; } } catch (e) { continue; } for (const rule of rules) { visitRule(rule); } } } function enumerateDescendants(target: Element, visit: (element: Element) => void): void { const visited = new WeakSet<Element>(); const visitElement = (element: Element): void => { if (visited.has(element)) { return; } visited.add(element); visit(element); for (const child of element.children) { visitElement(child); } }; visitElement(target); } function serializeCssPropertyValues(propertyValues: ReadonlyMap<string, string>) { const parts: string[] = [':root {\n']; for (const [property, value] of propertyValues) { parts.push(' ', property, ': ', value, ';\n'); } parts.push('}\n'); return parts.join(''); } /** * Options for exporting the canvas as raster image Base64-encoded into data URL. * * @see {@link toDataURL} */ export interface ToDataURLOptions { /** * [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) * for the exported raster image. * * Example: `image/png`, `image/jpeg`, ... * * @default "image/png" */ mimeType?: string; /** * Target width of the exported image. * * If only `width` is specified, the height is set based on the diagram aspect ratio, * otherwise the diagram is fit into desired bounds with margins on sides. * * If neither `width` or `height` is set, the image size is computed automatically * based on `maxFallbackSize` with 2x maximum resolution for the content. */ width?: number; /** * Target height of the exported image. * * If only `height` is specified, the height is set based on the diagram aspect ratio, * otherwise the diagram is fit into desired bounds with margins on sides. * * If neither `width` or `height` is set, the image size is computed automatically * based on `maxFallbackSize` with 2x maximum resolution for the content. */ height?: number; /** * Background color for the exported image. * * If not specified, the background is transparent by default. */ backgroundColor?: string; /** * Exported image quality value from 0.0 to 1.0 * (applicable only for lossy image types). * * @default 1.0 */ quality?: number; /** * Maximum exported image size when neither `width` nor `height` is specified. * * @default {width: 4096, height: 4096} */ maxFallbackSize?: Size; } const DEFAULT_MAX_FALLBACK_SIZE: Size = {width: 4096, height: 4096}; /** * @category Core */ export async function toDataURL(options: ToSVGOptions & ToDataURLOptions): Promise<string> { const { mimeType = 'image/png', maxFallbackSize = DEFAULT_MAX_FALLBACK_SIZE, } = options; const svgOptions = { ...options, convertImagesToDataUris: true, mockImages: false, preserveDimensions: true, }; const svg = await exportSVG(svgOptions); const svgBox: Bounds = { width: Number(svg.getAttribute('width')), height: Number(svg.getAttribute('height')), }; const containerSize = (typeof options.width === 'number' || typeof options.height === 'number') ? {width: options.width, height: options.height} : fallbackContainerSize(svgBox, maxFallbackSize); const {innerSize, outerSize, offset} = computeAutofit(svgBox, containerSize); svg.setAttribute('width', innerSize.width.toString()); svg.setAttribute('height', innerSize.height.toString()); const svgString = new XMLSerializer().serializeToString(svg); const {canvas, context} = createCanvas( outerSize.width, outerSize.height, options.backgroundColor, ); const image = await loadImage('data:image/svg+xml,' + encodeURIComponent(svgString)); context.drawImage(image, offset.x, offset.y, innerSize.width, innerSize.height); return canvas.toDataURL(mimeType, options.quality); function createCanvas(canvasWidth: number, canvasHeight: number, backgroundColor?: string) { const cnv = document.createElement('canvas'); cnv.width = canvasWidth; cnv.height = canvasHeight; const cnt = cnv.getContext('2d')!; if (backgroundColor) { cnt.fillStyle = backgroundColor; cnt.fillRect(0, 0, canvasWidth, canvasHeight); } return {canvas: cnv, context: cnt}; } } function loadImage(source: string): Promise<HTMLImageElement> { return new Promise((resolve, reject) => { const image = new Image(); image.onload = function () { resolve(image); }; image.onerror = function (ev) { reject(ev); }; image.src = source; }); } function computeAutofit(itemSize: Bounds, containerSize: Partial<Bounds>) { const fit = fitRectKeepingAspectRatio( itemSize.width, itemSize.height, containerSize.width, containerSize.height, ); const innerSize: Bounds = { width: Math.floor(fit.width), height: Math.floor(fit.height), }; const outerSize: Bounds = { width: typeof containerSize.width === 'number' ? containerSize.width : innerSize.width, height: typeof containerSize.height === 'number' ? containerSize.height : innerSize.height, }; const offset: Vector = { x: Math.round((outerSize.width - innerSize.width) / 2), y: Math.round((outerSize.height - innerSize.height) / 2), }; return {innerSize, outerSize, offset}; } function fallbackContainerSize(itemSize: Bounds, maxCanvasSize: Size): Bounds { const maxResolutionScale = Math.min( maxCanvasSize.width / itemSize.width, maxCanvasSize.height / itemSize.height, ); const resolutionScale = Math.min(2.0, maxResolutionScale); const width = Math.floor(itemSize.width * resolutionScale); const height = Math.floor(itemSize.height * resolutionScale); return {width, height}; } export function fitRectKeepingAspectRatio( sourceWidth: number, sourceHeight: number, targetWidth: number | undefined, targetHeight: number | undefined, ): { width: number; height: number } { if (!(typeof targetWidth === 'number' || typeof targetHeight === 'number')) { return {width: sourceWidth, height: sourceHeight}; } const sourceAspectRatio = sourceWidth / sourceHeight; targetWidth = typeof targetWidth === 'number' ? targetWidth : targetHeight! * sourceAspectRatio; targetHeight = typeof targetHeight === 'number' ? targetHeight : targetWidth / sourceAspectRatio; if (targetHeight * sourceAspectRatio <= targetWidth) { return {width: targetHeight * sourceAspectRatio, height: targetHeight}; } else { return {width: targetWidth, height: targetWidth / sourceAspectRatio}; } } /** * Creates and returns a blob from a data URL (either base64 encoded or not). * * @param {string} dataURL The data URL to convert. * @return {Blob} A blob representing the array buffer data. */ export function dataURLToBlob(dataURL: string): Blob { const BASE64_MARKER = ';base64,'; if (dataURL.indexOf(BASE64_MARKER) === -1) { const parts = dataURL.split(','); const contentType = parts[0].split(':')[1]; const raw = decodeURIComponent(parts[1]); return new Blob([raw], {type: contentType}); } else { const parts = dataURL.split(BASE64_MARKER); const contentType = parts[0].split(':')[1]; const raw = window.atob(parts[1]); const rawLength = raw.length; const uInt8Array = new Uint8Array(rawLength); for (let i = 0; i < rawLength; ++i) { uInt8Array[i] = raw.charCodeAt(i); } return new Blob([uInt8Array], {type: contentType}); } }