UNPKG

js-draw

Version:

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

270 lines (269 loc) 10.8 kB
import TextComponent from '../../components/TextComponent.mjs'; import { Vec2, Color4 } from '@js-draw/math'; import AbstractRenderer from './AbstractRenderer.mjs'; import { visualEquivalent } from '../RenderablePathSpec.mjs'; /** * Renders onto a `CanvasRenderingContext2D`. * * **Example**: * [[include:doc-pages/inline-examples/canvas-renderer.md]] */ export default class CanvasRenderer extends AbstractRenderer { /** * Creates a new `CanvasRenderer` that renders to the given rendering context. * The `viewport` is used to determine the translation/rotation/scaling of the content * to draw. */ constructor(ctx, viewport) { super(viewport); this.ctx = ctx; this.ignoreObjectsAboveLevel = null; this.ignoringObject = false; this.currentObjectBBox = null; this.clipLevels = []; this.setDraftMode(false); } transformBy(transformBy) { // From MDN, transform(a,b,c,d,e,f) // takes input such that // ⎡ a c e ⎤ // ⎢ b d f ⎥ transforms content drawn to [ctx]. // ⎣ 0 0 1 ⎦ this.ctx.transform(transformBy.a1, transformBy.b1, // a, b transformBy.a2, transformBy.b2, // c, d transformBy.a3, transformBy.b3); } canRenderFromWithoutDataLoss(other) { return other instanceof CanvasRenderer; } renderFromOtherOfSameType(transformBy, other) { if (!(other instanceof CanvasRenderer)) { throw new Error(`${other} cannot be rendered onto ${this}`); } transformBy = this.getCanvasToScreenTransform().rightMul(transformBy); this.ctx.save(); this.transformBy(transformBy); this.ctx.drawImage(other.ctx.canvas, 0, 0); this.ctx.restore(); } // Set parameters for lower/higher quality rendering setDraftMode(draftMode) { if (draftMode) { this.minSquareCurveApproxDist = 9; this.minRenderSizeBothDimens = 1; this.minRenderSizeAnyDimen = 0.1; } else { this.minSquareCurveApproxDist = 0.5; this.minRenderSizeBothDimens = 0.1; this.minRenderSizeAnyDimen = 1e-6; } } displaySize() { return Vec2.of(this.ctx.canvas.clientWidth, this.ctx.canvas.clientHeight); } clear() { this.ctx.save(); this.ctx.resetTransform(); this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); this.ctx.restore(); } beginPath(startPoint) { startPoint = this.canvasToScreen(startPoint); this.ctx.beginPath(); this.ctx.moveTo(startPoint.x, startPoint.y); } endPath(style) { // Saving and restoring can be slow in some browsers // (e.g. 0.50ms). Avoid. //this.ctx.save(); // If not a transparent fill if (style.fill.a > 0) { this.ctx.fillStyle = style.fill.toHexString(); this.ctx.fill(); } if (style.stroke) { this.ctx.strokeStyle = style.stroke.color.toHexString(); this.ctx.lineWidth = this.getSizeOfCanvasPixelOnScreen() * style.stroke.width; this.ctx.lineCap = 'round'; this.ctx.lineJoin = 'round'; this.ctx.stroke(); this.ctx.lineWidth = 1; } this.ctx.closePath(); //this.ctx.restore(); } lineTo(point) { point = this.canvasToScreen(point); this.ctx.lineTo(point.x, point.y); } moveTo(point) { point = this.canvasToScreen(point); this.ctx.moveTo(point.x, point.y); } traceCubicBezierCurve(p1, p2, p3) { p1 = this.canvasToScreen(p1); p2 = this.canvasToScreen(p2); p3 = this.canvasToScreen(p3); // Approximate the curve if small enough. const delta1 = p2.minus(p1); const delta2 = p3.minus(p2); if (delta1.magnitudeSquared() < this.minSquareCurveApproxDist && delta2.magnitudeSquared() < this.minSquareCurveApproxDist) { this.ctx.lineTo(p3.x, p3.y); } else { this.ctx.bezierCurveTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y); } } traceQuadraticBezierCurve(controlPoint, endPoint) { controlPoint = this.canvasToScreen(controlPoint); endPoint = this.canvasToScreen(endPoint); // Approximate the curve with a line if small enough const delta = controlPoint.minus(endPoint); if (delta.magnitudeSquared() < this.minSquareCurveApproxDist) { this.ctx.lineTo(endPoint.x, endPoint.y); } else { this.ctx.quadraticCurveTo(controlPoint.x, controlPoint.y, endPoint.x, endPoint.y); } } drawPath(path) { if (this.ignoringObject) { return; } // If part of a huge object, it might be worth trimming the path const visibleRect = this.getVisibleRect(); if (this.currentObjectBBox?.containsRect(visibleRect)) { // Try to trim/remove parts of the path outside of the bounding box. path = visualEquivalent(path, visibleRect); } super.drawPath(path); } drawText(text, transform, style) { this.ctx.save(); transform = this.getCanvasToScreenTransform().rightMul(transform); this.transformBy(transform); TextComponent.applyTextStyles(this.ctx, style); if (style.renderingStyle.fill.a !== 0) { this.ctx.fillStyle = style.renderingStyle.fill.toHexString(); this.ctx.fillText(text, 0, 0); } if (style.renderingStyle.stroke) { this.ctx.strokeStyle = style.renderingStyle.stroke.color.toHexString(); this.ctx.lineWidth = style.renderingStyle.stroke.width; this.ctx.strokeText(text, 0, 0); } this.ctx.restore(); } drawImage(image) { // .drawImage can fail for zero-size images. if (image.image.width === 0 || image.image.height === 0) { return; } this.ctx.save(); const transform = this.getCanvasToScreenTransform().rightMul(image.transform); this.transformBy(transform); this.ctx.drawImage(image.image, 0, 0); this.ctx.restore(); } startObject(boundingBox, clip) { if (this.isTooSmallToRender(boundingBox)) { this.ignoreObjectsAboveLevel = this.getNestingLevel(); this.ignoringObject = true; } super.startObject(boundingBox); this.currentObjectBBox = boundingBox; if (!this.ignoringObject && clip) { // Don't clip if it would only remove content already trimmed by // the edge of the screen. const clippedIsOutsideScreen = boundingBox.containsRect(this.getVisibleRect()); if (!clippedIsOutsideScreen) { this.clipLevels.push(this.objectLevel); this.ctx.save(); this.ctx.beginPath(); for (const corner of boundingBox.corners) { const screenCorner = this.canvasToScreen(corner); this.ctx.lineTo(screenCorner.x, screenCorner.y); } this.ctx.clip(); } } } endObject() { // Cache this.objectLevel — it may be decremented by super.endObject. const objectLevel = this.objectLevel; this.currentObjectBBox = null; super.endObject(); if (!this.ignoringObject && this.clipLevels.length > 0) { if (this.clipLevels[this.clipLevels.length - 1] === objectLevel) { this.ctx.restore(); this.clipLevels.pop(); } } // If exiting an object with a too-small-to-draw bounding box, if (this.ignoreObjectsAboveLevel !== null && this.getNestingLevel() <= this.ignoreObjectsAboveLevel) { this.ignoreObjectsAboveLevel = null; this.ignoringObject = false; } } /** * Returns a reference to the underlying `CanvasRenderingContext2D`. * This can be used to render custom content not supported by {@link AbstractRenderer}. * However, such content won't support {@link SVGRenderer} or {@link TextOnlyRenderer} * by default. * * Use with caution. */ drawWithRawRenderingContext(callback) { this.ctx.save(); this.transformBy(this.getCanvasToScreenTransform()); callback(this.ctx); this.ctx.restore(); } // @internal drawPoints(...points) { const pointRadius = 10; for (let i = 0; i < points.length; i++) { const point = this.canvasToScreen(points[i]); this.ctx.beginPath(); this.ctx.arc(point.x, point.y, pointRadius, 0, Math.PI * 2); this.ctx.fillStyle = Color4.ofRGBA(0.5 + Math.sin(i) / 2, 1.0, 0.5 + Math.cos(i * 0.2) / 4, 0.5).toHexString(); this.ctx.lineWidth = 2; this.ctx.fill(); this.ctx.stroke(); this.ctx.closePath(); this.ctx.textAlign = 'center'; this.ctx.textBaseline = 'middle'; this.ctx.fillStyle = 'black'; this.ctx.fillText(`${i}`, point.x, point.y, pointRadius * 2); } } // @internal isTooSmallToRender(rect) { // Should we ignore all objects within this object's bbox? const diagonal = rect.size.times(this.getSizeOfCanvasPixelOnScreen()); const bothDimenMinSize = this.minRenderSizeBothDimens; const bothTooSmall = Math.abs(diagonal.x) < bothDimenMinSize && Math.abs(diagonal.y) < bothDimenMinSize; const anyDimenMinSize = this.minRenderSizeAnyDimen; const anyTooSmall = Math.abs(diagonal.x) < anyDimenMinSize || Math.abs(diagonal.y) < anyDimenMinSize; return bothTooSmall || anyTooSmall; } // @internal static fromViewport(exportViewport, options = {}) { const canvas = document.createElement('canvas'); const exportRectSize = exportViewport.getScreenRectSize(); let canvasSize = options.canvasSize ?? exportRectSize; if (options.maxCanvasDimen && canvasSize.maximumEntryMagnitude() > options.maxCanvasDimen) { canvasSize = canvasSize.times(options.maxCanvasDimen / canvasSize.maximumEntryMagnitude()); } canvas.width = canvasSize.x; canvas.height = canvasSize.y; const ctx = canvas.getContext('2d'); // Scale to ensure that the entire output is visible. const scaleFactor = Math.min(canvasSize.x / exportRectSize.x, canvasSize.y / exportRectSize.y); ctx.scale(scaleFactor, scaleFactor); return { renderer: new CanvasRenderer(ctx, exportViewport), element: canvas }; } }