diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ee3376..afe41f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3.8.1 with: - node-version: 12 + node-version: 14 registry-url: https://registry.npmjs.org/ - run: npm install - run: npm run lint @@ -25,3 +25,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/') && github.event_name != 'pull_request' env: NODE_AUTH_TOKEN: ${{secrets.npm_token}} + - uses: actions/upload-artifact@v3 + with: + name: test-screenshots + path: shots/ diff --git a/README.md b/README.md index 41bb2d9..1a95f7e 100644 --- a/README.md +++ b/README.md @@ -72,12 +72,12 @@ The `confetti` parameter is a single optional `options` object, which has the fo - `origin.x` _Number (default: 0.5)_: The `x` position on the page, with `0` being the left edge and `1` being the right edge. - `origin.y` _Number (default: 0.5)_: The `y` position on the page, with `0` being the top edge and `1` being the bottom edge. - `colors` _Array<String>_: An array of color strings, in the HEX format... you know, like `#bada55`. -- `shapes` _Array<String|Shape>_: An array of shapes for the confetti. There are 3 built-in values of `square`, `circle`, and `star`. The default is to use both squares and circles in an even mix. To use a single shape, you can provide just one shape in the array, such as `['star']`. You can also change the mix by providing a value such as `['circle', 'circle', 'square']` to use two third circles and one third squares. You can also create your own shapes using the `confetti.shapeFromPath` helper method. +- `shapes` _Array<String|Shape>_: An array of shapes for the confetti. There are 3 built-in values of `square`, `circle`, and `star`. The default is to use both squares and circles in an even mix. To use a single shape, you can provide just one shape in the array, such as `['star']`. You can also change the mix by providing a value such as `['circle', 'circle', 'square']` to use two third circles and one third squares. You can also create your own shapes using the [`confetti.shapeFromPath`](#confettishapefrompath-path-matrix---shape) or [`confetti.shapeFromText`](#confettishapefromtext-text-scalar-color-fontfamily---shape) helper methods. - `scalar` _Number (default: 1)_: Scale factor for each confetti particle. Use decimals to make the confetti smaller. Go on, try teeny tiny confetti, they are adorable! - `zIndex` _Integer (default: 100)_: The confetti should be on top, after all. But if you have a crazy high page, you can set it even higher. - `disableForReducedMotion` _Boolean (default: false)_: Disables confetti entirely for users that [prefer reduced motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion). The `confetti()` promise will resolve immediately in this case. -### `confetti.shapeFromPath({ path, matrix? })` -> `Shape` +### `confetti.shapeFromPath({ path, matrix? })` → `Shape` This helper method lets you create a custom confetti shape using an [SVG Path string](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d). Any valid path should work, though there are a few caveats: - All paths will be filed. If you were hoping to have a stroke path, that is not implemented. @@ -98,6 +98,31 @@ confetti({ }); ``` +### `confetti.shapeFromText({ text, scalar?, color?, fontFamily? })` → `Shape` + +This is the highly anticipated feature to render emoji confetti! Use any standard unicode emoji. Or other text, but... maybe don't use other text. + +While any text should work, there are some caveats: +- For flailing confetti, something that is mostly square works best. That is, a single character, especially an emoji. +- Rather than rendering text every time a confetti is drawn, this helper actually rasterizes the text. Therefore, it does not scale well after it is created. If you plan to use the `scalar` value to scale your confetti, use the same `scalar` value here when creating the shape. This will make sure the confetti are not blurry. + +The options for this method are: +- `options` _`Object`_: + - `text` _`String`_: the text to be rendered as a confetti. If you can't make up your mind, I suggest "🐈". + - `scalar` _`Number, optional, default: 1`_: a scale value relative to the default size. It matches the `scalar` value in the confetti options. + - `color` _`String, optional, default: #000000`_: the color used to render the text. + - `fontFamily` _`String, optional, default: native emoji`_: the font family name to use when rendering the text. The default follows [best practices for rendring the native OS emoji of the device](https://nolanlawson.com/2022/04/08/the-struggle-of-using-native-emoji-on-the-web/), falling back to `sans-serif`. If using a web font, make sure this [font is loaded](https://developer.mozilla.org/en-US/docs/Web/API/FontFace/load) before rendering your confetti. + +```javascript +var scalar = 2; +var pineapple = confetti.shapeFromText({ text: '🍍', scalar }); + +confetti({ + shapes: [pineapple], + scalar +}); +``` + ### `confetti.create(canvas, [globalOptions])` → `function` This method creates an instance of the `confetti` function that uses a custom canvas. This is useful if you want to limit the area on your page in which confetti appear. By default, this method will not modify the canvas in any way (other than drawing to it). diff --git a/index.html b/index.html index ff16356..06ea0df 100644 --- a/index.html +++ b/index.html @@ -536,6 +536,30 @@

Custom Shapes

+
+
+
+
+

Emoji

+ +
+
+

+ Don't know any custom shapes? That's okay, just use emoji instead! That's what they + are for anyway. I mean... why do they even exist if we weren't supposed to make + confetti out of them? Think about it. +

+
+
+
+
+
+
@@ -856,6 +880,44 @@

Custom Canvas shapes: [ heart ], colors: ['#f93963', '#a10864', '#ee0b93'] }); + }, + emoji: function () { + var scalar = 2; + var unicorn = confetti.shapeFromText({ text: '🦄', scalar }); + + var defaults = { + spread: 360, + ticks: 60, + gravity: 0, + decay: 0.96, + startVelocity: 20, + shapes: [unicorn], + scalar + }; + + function shoot() { + confetti({ + ...defaults, + particleCount: 30 + }); + + confetti({ + ...defaults, + particleCount: 5, + flat: true + }); + + confetti({ + ...defaults, + particleCount: 15, + scalar: scalar / 2, + shapes: ['circle'] + }); + } + + setTimeout(shoot, 0); + setTimeout(shoot, 100); + setTimeout(shoot, 200); } }; diff --git a/package.json b/package.json index 849af47..e6593a7 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "eslint": "^4.16.0", "eslint-plugin-ava": "9.0.0", "jimp": "^0.2.28", - "puppeteer": "^1.0.0", + "puppeteer": "^19.11.1", "rootrequire": "^1.0.0", "send": "^0.16.1", "terser": "^3.14.1" diff --git a/src/confetti.js b/src/confetti.js index b78f5bb..8228400 100644 --- a/src/confetti.js +++ b/src/confetti.js @@ -356,6 +356,37 @@ Math.abs(y2 - y1) * 0.1, Math.PI / 10 * fetti.wobble )); + } else if (fetti.shape.type === 'bitmap') { + var rotation = Math.PI / 10 * fetti.wobble; + var scaleX = Math.abs(x2 - x1) * 0.1; + var scaleY = Math.abs(y2 - y1) * 0.1; + var width = fetti.shape.bitmap.width * fetti.scalar; + var height = fetti.shape.bitmap.height * fetti.scalar; + + var matrix = new DOMMatrix([ + Math.cos(rotation) * scaleX, + Math.sin(rotation) * scaleX, + -Math.sin(rotation) * scaleY, + Math.cos(rotation) * scaleY, + fetti.x, + fetti.y + ]); + + // apply the transform matrix from the confetti shape + matrix.multiplySelf(new DOMMatrix(fetti.shape.matrix)); + + var pattern = context.createPattern(fetti.shape.bitmap, 'no-repeat'); + pattern.setTransform(matrix); + + context.globalAlpha = (1 - progress); + context.fillStyle = pattern; + context.fillRect( + fetti.x - (width / 2), + fetti.y - (height / 2), + width, + height + ); + context.globalAlpha = 1; } else if (fetti.shape === 'circle') { context.ellipse ? context.ellipse(fetti.x, fetti.y, Math.abs(x2 - x1) * fetti.ovalScalar, Math.abs(y2 - y1) * fetti.ovalScalar, Math.PI / 10 * fetti.wobble, 0, 2 * Math.PI) : @@ -657,7 +688,7 @@ return t2; } - function createPathFetti(pathData) { + function shapeFromPath(pathData) { if (!canUsePaths) { throw new Error('path confetti are not supported in this browser'); } @@ -717,6 +748,52 @@ }; } + function shapeFromText(textData) { + var text, + scalar = 1, + color = '#000000', + // see https://nolanlawson.com/2022/04/08/the-struggle-of-using-native-emoji-on-the-web/ + fontFamily = '"Twemoji Mozilla", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", "EmojiOne Color", "Android Emoji", "system emoji", sans-serif'; + + if (typeof textData === 'string') { + text = textData; + } else { + text = textData.text; + scalar = 'scalar' in textData ? textData.scalar : scalar; + fontFamily = 'fontFamily' in textData ? textData.fontFamily : fontFamily; + color = 'color' in textData ? textData.color : color; + } + + // all other confetti are 10 pixels, + // so this pixel size is the de-facto 100% scale confetti + var fontSize = 10 * scalar; + var font = '' + fontSize + 'px ' + fontFamily; + + var canvas = new OffscreenCanvas(fontSize, fontSize); + var ctx = canvas.getContext('2d'); + + ctx.font = font; + var size = ctx.measureText(text); + var width = Math.floor(size.width); + var height = Math.floor(size.fontBoundingBoxAscent + size.fontBoundingBoxDescent); + + canvas = new OffscreenCanvas(width, height); + ctx = canvas.getContext('2d'); + ctx.font = font; + ctx.fillStyle = color; + + ctx.fillText(text, 0, fontSize); + + var scale = 1 / scalar; + + return { + type: 'bitmap', + // TODO these probably need to be transfered for workers + bitmap: canvas.transferToImageBitmap(), + matrix: [scale, 0, 0, scale, -width * scale / 2, -height * scale / 2] + }; + } + module.exports = function() { return getDefaultFire().apply(this, arguments); }; @@ -724,7 +801,8 @@ getDefaultFire().reset(); }; module.exports.create = confettiCannon; - module.exports.shapeFromPath = createPathFetti; + module.exports.shapeFromPath = shapeFromPath; + module.exports.shapeFromText = shapeFromText; }((function () { if (typeof window !== 'undefined') { return window; diff --git a/test/index.test.js b/test/index.test.js index d32163c..564b860 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -77,6 +77,8 @@ const testPage = async () => { // eslint-disable-next-line no-console page.on('pageerror', err => console.error(err)); + // eslint-disable-next-line no-console + page.on('console', msg => console.log(msg.text())); return page; }; @@ -103,26 +105,53 @@ const createBuffer = (data, format) => { } }; +function serializeConfettiOptions(opts) { + let serializedOpts = opts ? JSON.stringify(opts) : ''; + + if (opts && opts.shapes && Array.isArray(opts.shapes)) { + const { shapes, ...rest } = opts; + + const serializedShapes = shapes.map(shape => { + if (typeof shape === 'function') { + return `(${shape.toString()})()`; + } + + return JSON.stringify(shape); + }); + + serializedOpts = `{ + ...${JSON.stringify(rest)}, + shapes: [${serializedShapes.join(', ')}] + }`; + } + + return serializedOpts; +} + function confetti(opts, wait = false, funcName = 'confetti') { + const serializedOpts = serializeConfettiOptions(opts); + return ` ${wait ? '' : `${funcName}.Promise = null;`} -${funcName}(${opts ? JSON.stringify(opts) : ''}); +${funcName}(${serializedOpts}); `; } +const base64ToBuffer = base64png => createBuffer(base64png.replace(/data:image\/png;base64,/, ''), 'base64'); + async function confettiImage(page, opts = {}, funcName = 'confetti') { + const serializedOpts = serializeConfettiOptions(opts); const base64png = await page.evaluate(` - ${funcName}(${JSON.stringify(opts)}); - new Promise(function (resolve, reject) { - setTimeout(function () { - var canvas = document.querySelector('canvas'); - return resolve(canvas.toDataURL('image/png')); - }, 200); - }); -`); + ${funcName}(${serializedOpts}); + new Promise(function (resolve, reject) { + setTimeout(function () { + var canvas = document.querySelector('canvas'); + return resolve(canvas.toDataURL('image/png')); + }, 200); + }); + `); - const imageData = base64png.replace(/data:image\/png;base64,/, ''); - return createBuffer(imageData, 'base64'); + return base64ToBuffer(base64png); } function hex(n) { @@ -646,6 +675,137 @@ test('[path] shoots confetti of a custom shape', async t => { t.is(t.context.image.hash(), '9I0p03d03c0'); }); +/* + * Shape from text + */ + +const loadFont = async page => { + // Noto Color Emoji + const url = 'https://fonts.gstatic.com/s/notocoloremoji/v25/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.9.woff2'; + const name = 'Web Font'; + + await page.evaluate(` + Promise.resolve().then(async () => { + const fontFile = new FontFace( + "${name}", + "url(${url})", + ); + + document.fonts.add(fontFile); + + await fontFile.load(); + }); + `, ); + + return name; +}; + +const shapeFromTextImage = async (page, args) => { + const { base64png, ...shape } = await page.evaluate(` + Promise.resolve().then(async () => { + const { bitmap, ...shape } = confetti.shapeFromText(${JSON.stringify(args)}); + + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height); + + return { + ...shape, + base64png: canvas.toDataURL('image/png') + }; + }); + `); + + return { + ...shape, + buffer: base64ToBuffer(base64png) + }; +}; + +test('[text] shapeFromText renders an emoji', async t => { + const page = t.context.page = await fixturePage(); + + const fontFace = await loadFont(page); + + const { buffer, ...shape } = await shapeFromTextImage(page, { text: '😀', fontFamily: `"${fontFace}"`, scalar: 10 }); + + t.context.buffer = buffer; + t.context.image = await readImage(buffer); + + t.deepEqual({ + hash: t.context.image.hash(), + ...shape + }, { + type: 'bitmap', + matrix: [ 0.1, 0, 0, 0.1, -6.25, -5.8500000000000005 ], + hash: '8647FpWTCBH' + }); +}); + +test('[text] shapeFromText works with just a string parameter', async t => { + const page = t.context.page = await fixturePage(); + + const shape = await page.evaluate(` + confetti.shapeFromText("🍍"); + `); + + t.deepEqual(Object.keys(shape).sort(), ['type', 'bitmap', 'matrix'].sort()); + // the actual contents will differ from OS to OS, so just validate + // the shape has some expected properties + t.is(shape.type, 'bitmap'); + t.is(Array.isArray(shape.matrix), true); + t.is(shape.matrix.length, 6); +}); + +test('[text] shapeFromText renders black text by default', async t => { + const page = t.context.page = await fixturePage(); + + const { buffer } = await shapeFromTextImage(page, { text: 'pie', scalar: 3 }); + + t.context.buffer = buffer; + t.context.image = await reduceImg(buffer); + + t.deepEqual(await uniqueColors(t.context.image), ['#000000', '#ffffff']); +}); + +test('[text] shapeFromText can optionally render text in a requested color', async t => { + const page = t.context.page = await fixturePage(); + + const { buffer } = await shapeFromTextImage(page, { text: 'pie', color: '#00ff00', scalar: 3 }); + + t.context.buffer = buffer; + t.context.image = await reduceImg(buffer); + + t.deepEqual(await uniqueColors(t.context.image), ['#00ff00', '#ffffff']); +}); + +// this test renders a black canvas in a headless browser +// but works fine when it is not headless +// eslint-disable-next-line ava/no-skip-test +(headless ? test.skip : test)('[text] shoots confetti of an emoji shape', async t => { + const page = t.context.page = await fixturePage(); + + const fontFace = await loadFont(page); + await page.evaluate(`window.__fontFamily = '"${fontFace}"'`); + + // these parameters should create an image + // that is the same every time + t.context.buffer = await confettiImage(page, { + startVelocity: 0, + gravity: 0, + scalar: 10, + flat: 1, + ticks: 1000, + // eslint-disable-next-line no-undef + shapes: [() => confetti.shapeFromText({ text: '😀', fontFamily: __fontFamily, scalar: 10 })] + }); + t.context.image = await readImage(t.context.buffer); + + t.is(t.context.image.hash(), '9CppCqpCmtC'); +}); + /* * Custom canvas */ @@ -1071,3 +1231,9 @@ test('[esm] exposed confetti method has a `shapeFromPath` property', async t => t.is(await page.evaluate(`typeof confettiAlias.shapeFromPath`), 'function'); }); + +test('[esm] exposed confetti method has a `shapeFromText` property', async t => { + const page = t.context.page = await fixturePage('fixtures/page.module.html'); + + t.is(await page.evaluate(`typeof confettiAlias.shapeFromText`), 'function'); +});