js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
440 lines (438 loc) • 18.7 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.renderedStylesheetId = void 0;
const math_1 = require("@js-draw/math");
const SVGLoader_1 = require("../../SVGLoader/SVGLoader");
const RenderingStyle_1 = require("../RenderingStyle");
const AbstractRenderer_1 = __importDefault(require("./AbstractRenderer"));
const RenderablePathSpec_1 = require("../RenderablePathSpec");
const listPrefixMatch_1 = __importDefault(require("../../util/listPrefixMatch"));
exports.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}
*/
class SVGRenderer extends AbstractRenderer_1.default {
/**
* 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(`#${exports.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', exports.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 math_1.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', (0, math_1.toRoundedString)(style.stroke.width * this.getSizeOfCanvasPixelOnScreen()));
}
this.elem.appendChild(pathElem);
this.objectElems?.push(pathElem);
return pathElem;
}
drawPath(pathSpec) {
const style = pathSpec.style;
const path = (0, RenderablePathSpec_1.pathFromRenderable)(pathSpec).transformedBy(this.getCanvasToScreenTransform());
// Try to extend the previous path, if possible
if (this.lastPathString.length === 0 ||
!this.lastPathStyle ||
!(0, RenderingStyle_1.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(math_1.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(math_1.Vec2.zero);
elem.setAttribute('x', `${(0, math_1.toRoundedString)(translation.x)}`);
elem.setAttribute('y', `${(0, math_1.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[SVGLoader_1.svgAttributesDataKey];
const styleAttrs = loaderData[SVGLoader_1.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[SVGLoader_1.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.
(0, listPrefixMatch_1.default)(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') === exports.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(math_1.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(math_1.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) => (0, math_1.toRoundedString)(part)).join(' '));
result.setAttribute('width', (0, math_1.toRoundedString)(screenRectSize.x));
result.setAttribute('height', (0, math_1.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 };
}
}
exports.default = SVGRenderer;