UNPKG

js-draw

Version:

Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.

433 lines (431 loc) 18.3 kB
import { Mat33, Vec2, toRoundedString } from '@js-draw/math'; import { svgAttributesDataKey, svgLoaderAttributeContainerID, svgStyleAttributesDataKey, } from '../../SVGLoader/SVGLoader.mjs'; import { stylesEqual } from '../RenderingStyle.mjs'; import AbstractRenderer from './AbstractRenderer.mjs'; import { pathFromRenderable } from '../RenderablePathSpec.mjs'; import listPrefixMatch from '../../util/listPrefixMatch.mjs'; export const renderedStylesheetId = 'js-draw-style-sheet'; const svgNameSpace = 'http://www.w3.org/2000/svg'; const defaultTextStyle = { fontWeight: '400', fontStyle: 'normal', }; /** * Renders onto an `SVGElement`. * * @see {@link Editor.toSVG} */ export default class SVGRenderer extends AbstractRenderer { /** * Creates a renderer that renders onto `elem`. If `sanitize`, don't render potentially untrusted data. * * `viewport` is used to determine the translation/rotation/scaling/output size of the rendered * data. */ constructor(elem, viewport, sanitize = false) { super(viewport); this.elem = elem; this.sanitize = sanitize; this.lastPathStyle = null; this.lastPathString = []; this.lastContainerIDList = []; // Elements that make up the current object (as created by startObject) // if any. this.objectElems = null; this.overwrittenAttrs = {}; this.textContainer = null; this.textContainerTransform = null; this.textParentStyle = defaultTextStyle; this.clear(); this.addStyleSheet(); } addStyleSheet() { if (!this.elem.querySelector(`#${renderedStylesheetId}`)) { // Default to rounded strokes. const styleSheet = document.createElementNS('http://www.w3.org/2000/svg', 'style'); styleSheet.appendChild(document.createTextNode(` path { stroke-linecap: round; stroke-linejoin: round; } text { white-space: pre; } `.replace(/\s+/g, ''))); styleSheet.setAttribute('id', renderedStylesheetId); this.elem.appendChild(styleSheet); } } // Sets an attribute on the root SVG element. setRootSVGAttribute(name, value) { if (this.sanitize) { return; } // Make the original value of the attribute restorable on clear if (!(name in this.overwrittenAttrs)) { this.overwrittenAttrs[name] = this.elem.getAttribute(name); } if (value !== null) { this.elem.setAttribute(name, value); } else { this.elem.removeAttribute(name); } } displaySize() { return Vec2.of(this.elem.clientWidth, this.elem.clientHeight); } clear() { this.lastPathString = []; this.lastContainerIDList = []; if (!this.sanitize) { // Restore all all attributes for (const attrName in this.overwrittenAttrs) { const value = this.overwrittenAttrs[attrName]; if (value) { this.elem.setAttribute(attrName, value); } else { this.elem.removeAttribute(attrName); } } this.overwrittenAttrs = {}; } } // Push `this.fullPath` to the SVG. Returns the path added to the SVG, if any. // @internal addPathToSVG() { if (!this.lastPathStyle || this.lastPathString.length === 0) { return null; } const pathElem = document.createElementNS(svgNameSpace, 'path'); pathElem.setAttribute('d', this.lastPathString.join(' ')); const style = this.lastPathStyle; if (style.fill.a > 0) { pathElem.setAttribute('fill', style.fill.toHexString()); } else { pathElem.setAttribute('fill', 'none'); } if (style.stroke) { pathElem.setAttribute('stroke', style.stroke.color.toHexString()); pathElem.setAttribute('stroke-width', toRoundedString(style.stroke.width * this.getSizeOfCanvasPixelOnScreen())); } this.elem.appendChild(pathElem); this.objectElems?.push(pathElem); return pathElem; } drawPath(pathSpec) { const style = pathSpec.style; const path = pathFromRenderable(pathSpec).transformedBy(this.getCanvasToScreenTransform()); // Try to extend the previous path, if possible if (this.lastPathString.length === 0 || !this.lastPathStyle || !stylesEqual(this.lastPathStyle, style)) { this.addPathToSVG(); this.lastPathStyle = style; this.lastPathString = []; } this.lastPathString.push(path.toString()); } // Apply [elemTransform] to [elem]. Uses both a `matrix` and `.x`, `.y` properties if `setXY` is true. // Otherwise, just uses a `matrix`. transformFrom(elemTransform, elem, inCanvasSpace = false) { const transform = !inCanvasSpace ? this.getCanvasToScreenTransform().rightMul(elemTransform) : elemTransform; if (!transform.eq(Mat33.identity)) { const matrixString = transform.toCSSMatrix(); elem.style.transform = matrixString; // Most browsers round the components of CSS transforms. // Include a higher precision copy of the element's transform. elem.setAttribute('data-highp-transform', matrixString); } else { elem.style.transform = ''; } } drawText(text, transform, style) { const applyTextStyles = (elem, style) => { if (style.fontFamily !== this.textParentStyle?.fontFamily) { elem.style.fontFamily = style.fontFamily; } if (style.fontVariant !== this.textParentStyle?.fontVariant) { elem.style.fontVariant = style.fontVariant ?? ''; } if (style.fontWeight !== this.textParentStyle?.fontWeight) { elem.style.fontWeight = style.fontWeight ?? ''; } if (style.fontStyle !== this.textParentStyle?.fontStyle) { elem.style.fontStyle = style.fontStyle ?? ''; } if (style.size !== this.textParentStyle?.size) { elem.style.fontSize = style.size + 'px'; } const fillString = style.renderingStyle.fill.toHexString(); // TODO: Uncomment at some future major version release --- currently causes incompatibility due // to an SVG parsing bug in older versions. //const parentFillString = this.textParentStyle?.renderingStyle?.fill?.toHexString(); //if (fillString !== parentFillString) { elem.style.fill = fillString; //} if (style.renderingStyle.stroke) { const strokeStyle = style.renderingStyle.stroke; elem.style.stroke = strokeStyle.color.toHexString(); elem.style.strokeWidth = strokeStyle.width + 'px'; } }; transform = this.getCanvasToScreenTransform().rightMul(transform); if (!this.textContainer) { const container = document.createElementNS(svgNameSpace, 'text'); container.appendChild(document.createTextNode(text)); this.transformFrom(transform, container, true); applyTextStyles(container, style); this.elem.appendChild(container); this.objectElems?.push(container); if (this.objectLevel > 0) { this.textContainer = container; this.textContainerTransform = transform; this.textParentStyle = { ...defaultTextStyle, ...style }; } } else { const elem = document.createElementNS(svgNameSpace, 'tspan'); elem.appendChild(document.createTextNode(text)); this.textContainer.appendChild(elem); // Make .x/.y relative to the parent. transform = this.textContainerTransform.inverse().rightMul(transform); // .style.transform does nothing to tspan elements. As such, we need to set x/y: const translation = transform.transformVec2(Vec2.zero); elem.setAttribute('x', `${toRoundedString(translation.x)}`); elem.setAttribute('y', `${toRoundedString(translation.y)}`); applyTextStyles(elem, style); } } drawImage(image) { let label = image.label ?? image.image.getAttribute('aria-label') ?? ''; if (label === '') { label = image.image.getAttribute('alt') ?? ''; } const svgImgElem = document.createElementNS(svgNameSpace, 'image'); svgImgElem.setAttribute('href', image.base64Url); svgImgElem.setAttribute('width', image.image.getAttribute('width') ?? ''); svgImgElem.setAttribute('height', image.image.getAttribute('height') ?? ''); svgImgElem.setAttribute('aria-label', label); this.transformFrom(image.transform, svgImgElem); this.elem.appendChild(svgImgElem); this.objectElems?.push(svgImgElem); } startObject(boundingBox) { super.startObject(boundingBox); // Only accumulate a path within an object this.lastPathString = []; this.lastPathStyle = null; this.textContainer = null; this.textParentStyle = defaultTextStyle; this.objectElems = []; } endObject(loaderData, elemClassNames) { super.endObject(loaderData); // Don't extend paths across objects this.addPathToSVG(); // If empty/not an object, stop. if (!this.objectElems) { return; } if (loaderData && !this.sanitize) { // Restore any attributes unsupported by the app. for (const elem of this.objectElems) { const attrs = loaderData[svgAttributesDataKey]; const styleAttrs = loaderData[svgStyleAttributesDataKey]; if (attrs) { for (const [attr, value] of attrs) { elem.setAttribute(attr, value); } } if (styleAttrs) { for (const attr of styleAttrs) { elem.style.setProperty(attr.key, attr.value, attr.priority); } } } // Update the parent const containerIDData = loaderData[svgLoaderAttributeContainerID]; let containerIDList = []; if (containerIDData && containerIDData[0]) { // If a string list, if (containerIDData[0].length) { containerIDList = containerIDData[0]; } } if (containerIDList.length > 0 && // containerIDList must share a prefix with the last ID list // otherwise, the z order of elements may have been changed from // the original image. // In the case that the z order has been changed, keep the current // element as a child of the root to preserve z order. listPrefixMatch(this.lastContainerIDList, containerIDList) && // The component can add at most one more parent than the previous item. this.lastContainerIDList.length >= containerIDList.length - 1) { // Select the last const containerID = containerIDList[containerIDList.length - 1]; const containerCandidates = this.elem.querySelectorAll(`g#${containerID}`); if (containerCandidates.length >= 1) { const container = containerCandidates[0]; // If this is the first time we're entering the group, the // group should be empty. // Otherwise, this may be a case that would break z-ordering. if (container.children.length === 0 || this.lastContainerIDList.length >= containerIDList.length) { // Move all objectElems to the found container for (const elem of this.objectElems) { elem.remove(); container.appendChild(elem); } } else { containerIDList = []; } } } else { containerIDList = []; } this.lastContainerIDList = containerIDList; } // Add class names to the object, if given. if (elemClassNames && this.objectElems) { if (this.objectElems.length === 1) { this.objectElems[0].classList.add(...elemClassNames); } else { const wrapper = document.createElementNS(svgNameSpace, 'g'); wrapper.classList.add(...elemClassNames); for (const elem of this.objectElems) { elem.remove(); wrapper.appendChild(elem); } this.elem.appendChild(wrapper); } } } // Not implemented -- use drawPath instead. unimplementedMessage() { throw new Error('Not implemenented!'); } beginPath(_startPoint) { this.unimplementedMessage(); } endPath(_style) { this.unimplementedMessage(); } lineTo(_point) { this.unimplementedMessage(); } moveTo(_point) { this.unimplementedMessage(); } traceCubicBezierCurve(_controlPoint1, _controlPoint2, _endPoint) { this.unimplementedMessage(); } traceQuadraticBezierCurve(_controlPoint, _endPoint) { this.unimplementedMessage(); } drawPoints(...points) { points.map((point) => { const elem = document.createElementNS(svgNameSpace, 'circle'); elem.setAttribute('cx', `${point.x}`); elem.setAttribute('cy', `${point.y}`); elem.setAttribute('r', '15'); this.elem.appendChild(elem); }); } /** * Adds a **copy** of the given element directly to the container * SVG element, **without applying transforms**. * * If `sanitize` is enabled, this does nothing. */ drawSVGElem(elem) { if (this.sanitize) { return; } // Don't add multiple copies of the default stylesheet. if (elem.tagName.toLowerCase() === 'style' && elem.getAttribute('id') === renderedStylesheetId) { return; } const elemToDraw = elem.cloneNode(true); this.elem.appendChild(elemToDraw); this.objectElems?.push(elemToDraw); } /** * Allows rendering directly to the underlying SVG element. Rendered * content is added to a `<g>` element that's passed as `parent` to `callback`. * * **Note**: Unlike {@link drawSVGElem}, this method can be used even if `sanitize` is `true`. * In this case, it's the responsibility of `callback` to ensure that everything added * to `parent` is safe to render. */ drawWithSVGParent(callback) { const parent = document.createElementNS(svgNameSpace, 'g'); this.transformFrom(Mat33.identity, parent, true); callback(parent, { sanitize: this.sanitize }); this.elem.appendChild(parent); this.objectElems?.push(parent); } isTooSmallToRender(_rect) { return false; } /** * Creates a new SVG element and `SVGRenerer` with `width`, `height`, `viewBox`, * and other metadata attributes set for the given `Viewport`. * * If `options` is a `boolean`, it is interpreted as whether to sanitize (not add unknown * SVG entities to) the output. */ static fromViewport(viewport, options = true) { let sanitize; let useViewBoxForPositioning; if (typeof options === 'boolean') { sanitize = options; useViewBoxForPositioning = false; } else { sanitize = options.sanitize ?? true; useViewBoxForPositioning = options.useViewBoxForPositioning ?? false; } const svgNameSpace = 'http://www.w3.org/2000/svg'; const result = document.createElementNS(svgNameSpace, 'svg'); const screenRectSize = viewport.getScreenRectSize(); const visibleRect = viewport.visibleRect; let viewBoxComponents; if (useViewBoxForPositioning) { const exportRect = viewport.visibleRect; viewBoxComponents = [exportRect.x, exportRect.y, exportRect.w, exportRect.h]; // Replace the viewport with a copy that has a modified transform. // (Avoids modifying the original viewport). viewport = viewport.getTemporaryClone(); // TODO: This currently discards any rotation information. // Render with (0,0) at (0,0) -- the translation is handled by the viewBox. viewport.resetTransform(Mat33.identity); } else { viewBoxComponents = [0, 0, screenRectSize.x, screenRectSize.y]; } // rect.x -> size of rect in x direction, rect.y -> size of rect in y direction. result.setAttribute('viewBox', viewBoxComponents.map((part) => toRoundedString(part)).join(' ')); result.setAttribute('width', toRoundedString(screenRectSize.x)); result.setAttribute('height', toRoundedString(screenRectSize.y)); // Ensure the image can be identified as an SVG if downloaded. // See https://jwatt.org/svg/authoring/ result.setAttribute('version', '1.1'); result.setAttribute('baseProfile', 'full'); result.setAttribute('xmlns', svgNameSpace); const renderer = new SVGRenderer(result, viewport, sanitize); if (!visibleRect.eq(viewport.visibleRect)) { renderer.overrideVisibleRect(visibleRect); } return { element: result, renderer }; } }