diff --git a/context.js b/context.js index 7215ac2..29e41d0 100644 --- a/context.js +++ b/context.js @@ -675,6 +675,13 @@ export default (function () { this.__currentDefaultPath.rect(x, y, width, height); }; + Context.prototype.roundRect = function (x, y, width, height, radii) { + if (!this.__currentDefaultPath) { + this.beginPath(); + } + this.__currentDefaultPath.roundRect(x, y, width, height, radii); + }; + Context.prototype.bezierCurveTo = function (cp1x, cp1y, cp2x, cp2y, x, y) { if (!this.__currentDefaultPath) { this.beginPath(); diff --git a/package.json b/package.json index a71d22b..a1a559b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aha-app/svgcanvas", - "version": "2.5.0-a13", + "version": "2.5.0-a14", "description": "svgcanvas", "main": "dist/svgcanvas.js", "scripts": { diff --git a/path2d.js b/path2d.js index f09bb19..487049c 100644 --- a/path2d.js +++ b/path2d.js @@ -1,4 +1,5 @@ import { format } from "./utils"; +import roundRectPolyfill from "./roundRect"; export default (function () { "use strict"; @@ -90,6 +91,11 @@ export default (function () { this.lineTo(x, y); }; + /** + * Adds a rounded rectangle to the path. + */ + Path2D.prototype.roundRect = roundRectPolyfill; + /** * Add a bezier command */ @@ -287,9 +293,9 @@ export default (function () { ea = 2 * Math.PI - ea; } if (sa > ea && sa - ea < Math.PI) - anticlockwise = true; + anticlockwise = true; if (sa < ea && ea - sa > Math.PI) - anticlockwise = true; + anticlockwise = true; this.lineTo(t_p1p0[0], t_p1p0[1]) this.arc(p[0], p[1], radius, sa, ea, anticlockwise) @@ -328,9 +334,9 @@ export default (function () { (2 * Math.PI); } var endX = - x + - Math.cos(-rotation) * radiusX * Math.cos(endAngle) + - Math.sin(-rotation) * radiusY * Math.sin(endAngle), + x + + Math.cos(-rotation) * radiusX * Math.cos(endAngle) + + Math.sin(-rotation) * radiusY * Math.sin(endAngle), endY = y - Math.sin(-rotation) * radiusX * Math.cos(endAngle) + diff --git a/roundRect.js b/roundRect.js new file mode 100644 index 0000000..b04cd1e --- /dev/null +++ b/roundRect.js @@ -0,0 +1,232 @@ +/* + * Implements the .roundRect() method of the CanvasPath mixin + * as introduced by https://github.com/whatwg/html/pull/6765 + * + * From https://github.com/Kaiido/roundRect + */ +export default function roundRect(x, y, w, h, radii) { + if (!([x, y, w, h].every((input) => Number.isFinite(input)))) { + return; + } + + radii = parseRadiiArgument(radii); + + let upperLeft, upperRight, lowerRight, lowerLeft; + + if (radii.length === 4) { + + upperLeft = toCornerPoint(radii[0]); + upperRight = toCornerPoint(radii[1]); + lowerRight = toCornerPoint(radii[2]); + lowerLeft = toCornerPoint(radii[3]); + + } else if (radii.length === 3) { + + upperLeft = toCornerPoint(radii[0]); + upperRight = toCornerPoint(radii[1]); + lowerLeft = toCornerPoint(radii[1]); + lowerRight = toCornerPoint(radii[2]); + + } else if (radii.length === 2) { + + upperLeft = toCornerPoint(radii[0]); + lowerRight = toCornerPoint(radii[0]); + upperRight = toCornerPoint(radii[1]); + lowerLeft = toCornerPoint(radii[1]); + + } else if (radii.length === 1) { + + upperLeft = toCornerPoint(radii[0]); + upperRight = toCornerPoint(radii[0]); + lowerRight = toCornerPoint(radii[0]); + lowerLeft = toCornerPoint(radii[0]); + + } else { + + throw new RangeError(`${getErrorMessageHeader(this)} ${radii.length} is not a valid size for radii sequence.`); + + } + + const corners = [upperLeft, upperRight, lowerRight, lowerLeft]; + const negativeCorner = corners.find(({ x, y }) => x < 0 || y < 0); + const negativeValue = negativeCorner?.x < 0 ? negativeCorner.x : negativeCorner?.y + + if (corners.some(({ x, y }) => !Number.isFinite(x) || !Number.isFinite(y))) { + + return; + + } + + if (negativeCorner) { + + throw new RangeError(`${getErrorMessageHeader(this)} Radius value ${negativeCorner} is negative.`); + + } + + fixOverlappingCorners(corners); + + if (w < 0 && h < 0) { + + this.moveTo(x - upperLeft.x, y); + this.ellipse(x + w + upperRight.x, y - upperRight.y, upperRight.x, upperRight.y, 0, -Math.PI * 1.5, -Math.PI); + this.ellipse(x + w + lowerRight.x, y + h + lowerRight.y, lowerRight.x, lowerRight.y, 0, -Math.PI, -Math.PI / 2); + this.ellipse(x - lowerLeft.x, y + h + lowerLeft.y, lowerLeft.x, lowerLeft.y, 0, -Math.PI / 2, 0); + this.ellipse(x - upperLeft.x, y - upperLeft.y, upperLeft.x, upperLeft.y, 0, 0, -Math.PI / 2); + + } else if (w < 0) { + + this.moveTo(x - upperLeft.x, y); + this.ellipse(x + w + upperRight.x, y + upperRight.y, upperRight.x, upperRight.y, 0, -Math.PI / 2, -Math.PI, 1); + this.ellipse(x + w + lowerRight.x, y + h - lowerRight.y, lowerRight.x, lowerRight.y, 0, -Math.PI, -Math.PI * 1.5, 1); + this.ellipse(x - lowerLeft.x, y + h - lowerLeft.y, lowerLeft.x, lowerLeft.y, 0, Math.PI / 2, 0, 1); + this.ellipse(x - upperLeft.x, y + upperLeft.y, upperLeft.x, upperLeft.y, 0, 0, -Math.PI / 2, 1); + + } else if (h < 0) { + + this.moveTo(x + upperLeft.x, y); + this.ellipse(x + w - upperRight.x, y - upperRight.y, upperRight.x, upperRight.y, 0, Math.PI / 2, 0, 1); + this.ellipse(x + w - lowerRight.x, y + h + lowerRight.y, lowerRight.x, lowerRight.y, 0, 0, -Math.PI / 2, 1); + this.ellipse(x + lowerLeft.x, y + h + lowerLeft.y, lowerLeft.x, lowerLeft.y, 0, -Math.PI / 2, -Math.PI, 1); + this.ellipse(x + upperLeft.x, y - upperLeft.y, upperLeft.x, upperLeft.y, 0, -Math.PI, -Math.PI * 1.5, 1); + + } else { + + this.moveTo(x + upperLeft.x, y); + this.ellipse(x + w - upperRight.x, y + upperRight.y, upperRight.x, upperRight.y, 0, -Math.PI / 2, 0); + this.ellipse(x + w - lowerRight.x, y + h - lowerRight.y, lowerRight.x, lowerRight.y, 0, 0, Math.PI / 2); + this.ellipse(x + lowerLeft.x, y + h - lowerLeft.y, lowerLeft.x, lowerLeft.y, 0, Math.PI / 2, Math.PI); + this.ellipse(x + upperLeft.x, y + upperLeft.y, upperLeft.x, upperLeft.y, 0, Math.PI, Math.PI * 1.5); + + } + + this.closePath(); + this.moveTo(x, y); + + function toDOMPointInit(value) { + + const { x, y, z, w } = value; + return { x, y, z, w }; + + } + + function parseRadiiArgument(value) { + + // https://webidl.spec.whatwg.org/#es-union + // with 'optional (unrestricted double or DOMPointInit + // or sequence<(unrestricted double or DOMPointInit)>) radii = 0' + const type = typeof value; + + if (type === "undefined" || value === null) { + + return [0]; + + } + if (type === "function") { + + return [NaN]; + + } + if (type === "object") { + + if (typeof value[Symbol.iterator] === "function") { + + return [...value].map((elem) => { + // https://webidl.spec.whatwg.org/#es-union + // with '(unrestricted double or DOMPointInit)' + const elemType = typeof elem; + if (elemType === "undefined" || elem === null) { + return 0; + } + if (elemType === "function") { + return NaN; + } + if (elemType === "object") { + return toDOMPointInit(elem); + } + return toUnrestrictedNumber(elem); + }); + + } + + return [toDOMPointInit(value)]; + + } + + return [toUnrestrictedNumber(value)]; + + } + + function toUnrestrictedNumber(value) { + + return +value; + + } + + function toCornerPoint(value) { + + const asNumber = toUnrestrictedNumber(value); + if (Number.isFinite(asNumber)) { + + return { + x: asNumber, + y: asNumber + }; + + } + if (Object(value) === value) { + + return { + x: toUnrestrictedNumber(value.x ?? 0), + y: toUnrestrictedNumber(value.y ?? 0) + }; + + } + + return { + x: NaN, + y: NaN + }; + + } + + function fixOverlappingCorners(corners) { + + const [upperLeft, upperRight, lowerRight, lowerLeft] = corners; + const factors = [ + Math.abs(w) / (upperLeft.x + upperRight.x), + Math.abs(h) / (upperRight.y + lowerRight.y), + Math.abs(w) / (lowerRight.x + lowerLeft.x), + Math.abs(h) / (upperLeft.y + lowerLeft.y) + ]; + const minFactor = Math.min(...factors); + if (minFactor <= 1) { + + for (const radii of corners) { + + radii.x *= minFactor; + radii.y *= minFactor; + + } + + } + + } + +} + +function getErrorMessageHeader(instance) { + + return `Failed to execute 'roundRect' on '${getConstructorName(instance)}':`; + +} + +function getConstructorName(instance) { + + return Object(instance) === instance && + instance instanceof Path2D ? "Path2D" : + instance instanceof globalThis?.CanvasRenderingContext2D ? "CanvasRenderingContext2D" : + instance instanceof globalThis?.OffscreenCanvasRenderingContext2D ? "OffscreenCanvasRenderingContext2D" : + instance?.constructor.name || + instance; + +} diff --git a/test/index.js b/test/index.js index 8e63def..795b738 100644 --- a/test/index.js +++ b/test/index.js @@ -23,6 +23,7 @@ import transform from "./tests/transform"; import pattern from "./tests/pattern"; import path2D from "./tests/path2D"; import clip from "./tests/clip"; +import roundRect from "./tests/roundRect"; const tests = [ tiger, @@ -49,6 +50,7 @@ const tests = [ transform, pattern, path2D, + roundRect, ]; for (let fn of tests) { diff --git a/test/rendering.test.js b/test/rendering.test.js index e1e83b3..877e07e 100644 --- a/test/rendering.test.js +++ b/test/rendering.test.js @@ -22,6 +22,7 @@ import tiger from './tests/tiger' import transform from './tests/transform' import pattern from "./tests/pattern"; import path2D from './tests/path2D'; +import roundRect from './tests/roundRect' const tests = { tiger, @@ -45,7 +46,8 @@ const tests = { text, transform, pattern, - path2D + path2D, + roundRect, }; const config = { diff --git a/test/tests/roundRect.js b/test/tests/roundRect.js new file mode 100644 index 0000000..a2165c0 --- /dev/null +++ b/test/tests/roundRect.js @@ -0,0 +1,29 @@ +export default function roundRect(ctx) { + ctx.beginPath(); + ctx.roundRect(150, 20, 100, 50, 10); + ctx.stroke(); + + ctx.beginPath(); + ctx.roundRect(150, 150, 100, 50, 50); + ctx.stroke(); + + ctx.beginPath(); + ctx.roundRect(150, 300, 50, 50, 50); + ctx.stroke(); + + ctx.beginPath(); + ctx.roundRect(300, 20, 100, 50, 10); + ctx.fill(); + + ctx.beginPath(); + ctx.roundRect(300, 150, 100, 50, [10, 20, 30, 50]); + ctx.stroke(); + + ctx.beginPath(); + ctx.roundRect(300, 300, 50, 50, [10, 30]); + ctx.stroke(); + + ctx.beginPath(); + ctx.roundRect(300, 400, 50, 50, [30]); + ctx.stroke(); +}; \ No newline at end of file