diff --git a/app/components/rive-animation.hbs b/app/components/rive-animation.hbs new file mode 100644 index 000000000..0eadf3e48 --- /dev/null +++ b/app/components/rive-animation.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/components/rive-animation.ts b/app/components/rive-animation.ts new file mode 100644 index 000000000..35835d0d1 --- /dev/null +++ b/app/components/rive-animation.ts @@ -0,0 +1,71 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { Fit, Layout, Rive } from '@rive-app/canvas'; +import * as Sentry from '@sentry/ember'; + +interface Signature { + Element: HTMLCanvasElement; + Args: { + src: string; + }; +} + +export default class RiveAnimationComponent extends Component { + resizeObserver: ResizeObserver | null = null; + @tracked riveInstance: Rive | null = null; + + @action + handleDidInsert(element: HTMLCanvasElement) { + // Set up ResizeObserver to handle canvas resizing + this.resizeObserver = new ResizeObserver(() => { + if (this.riveInstance) { + this.riveInstance.resizeDrawingSurfaceToCanvas(); + } + }); + + // Observe the canvas element itself + this.resizeObserver.observe(element); + + try { + this.riveInstance = new Rive({ + src: this.args.src, + canvas: element, + stateMachines: 'Default', + autoplay: true, + automaticallyHandleEvents: true, + layout: new Layout({ + fit: Fit.Contain, + }), + onLoad: async () => { + if (this.riveInstance) { + // Initial resize + this.riveInstance.resizeDrawingSurfaceToCanvas(); + } + }, + }); + } catch (error: unknown) { + console.error('Error setting up Rive:', error); + Sentry.captureException(error); + } + } + + @action + handleWillDestroy() { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + + if (this.riveInstance) { + this.riveInstance.stop(); + this.riveInstance = null; + } + } +} + +declare module '@glint/environment-ember-loose/registry' { + export default interface Registry { + RiveAnimation: typeof RiveAnimationComponent; + } +} diff --git a/app/components/track-page/header/index.hbs b/app/components/track-page/header/index.hbs index cb30c5d9e..3eee0a2f9 100644 --- a/app/components/track-page/header/index.hbs +++ b/app/components/track-page/header/index.hbs @@ -6,7 +6,11 @@ {{@language.name}}.
- + {{#if (eq @language.name "Gleam")}} + + {{else}} + + {{/if}}
@@ -54,6 +58,10 @@ {{/if}} \ No newline at end of file diff --git a/app/router.ts b/app/router.ts index 88872f955..3e937bf8d 100644 --- a/app/router.ts +++ b/app/router.ts @@ -108,5 +108,6 @@ Router.map(function () { this.route('dark-mode-toggle'); this.route('file-contents-card'); this.route('file-diff-card'); + this.route('rive-animation'); }); }); diff --git a/app/routes/demo/rive-animation.ts b/app/routes/demo/rive-animation.ts new file mode 100644 index 000000000..eb869a752 --- /dev/null +++ b/app/routes/demo/rive-animation.ts @@ -0,0 +1,8 @@ +import Route from '@ember/routing/route'; +import RouteInfoMetadata, { RouteColorScheme } from 'codecrafters-frontend/utils/route-info-metadata'; + +export default class DemoRiveAnimationRoute extends Route { + buildRouteInfoMetadata() { + return new RouteInfoMetadata({ colorScheme: RouteColorScheme.Both }); + } +} diff --git a/app/templates/demo.hbs b/app/templates/demo.hbs index 1537da15e..7bbc2ef7a 100644 --- a/app/templates/demo.hbs +++ b/app/templates/demo.hbs @@ -49,6 +49,16 @@ class={{if (eq this.router.currentRouteName "demo.dark-mode-toggle") "font-medium text-teal-500" "text-gray-600 dark:text-gray-200"}} >DarkModeToggle + + RiveAnimation + {{outlet}} diff --git a/app/templates/demo/rive-animation.hbs b/app/templates/demo/rive-animation.hbs new file mode 100644 index 000000000..8d921a708 --- /dev/null +++ b/app/templates/demo/rive-animation.hbs @@ -0,0 +1,40 @@ +{{page-title "Rive Animation Demo"}} + +
+
+

Default Size

+
+ +
+
+ +
+

Custom Size (200px)

+
+ +
+
+ +
+

Small Size (100px)

+
+ +
+
+ + {{! Test pig_cuddly.riv }} +
+

Test pig_cuddly.riv

+
+ +
+
+ + {{! Test capy_walking.riv }} +
+

Test capy_walking.riv

+
+ +
+
+
\ No newline at end of file diff --git a/package-lock.json b/package-lock.json index cbf9d0f7b..1ebdeca1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@rails/actioncable": "^8.0.200", + "@rive-app/canvas": "^2.27.0", "@stripe/stripe-js": "^5.5.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/typography": "^0.5.16", @@ -8394,6 +8395,12 @@ "integrity": "sha512-EDqWyxck22BHmv1e+mD8Kl6GmtNkhEPdRfGFT7kvsv1yoXd9iYrqHDVAaR8bKmU/syC5eEZ2I5aWWxtB73ukMw==", "license": "MIT" }, + "node_modules/@rive-app/canvas": { + "version": "2.27.0", + "resolved": "https://registry.npmjs.org/@rive-app/canvas/-/canvas-2.27.0.tgz", + "integrity": "sha512-wXGYCjXO+UpqesRPVSy7YmTnKSelhJVEu6kCWNWzwFD5IfwaM7rF9oG6WsubZ/D2Aacl9snaJUWjos7Em8mUSg==", + "license": "MIT" + }, "node_modules/@ro0gr/ceibo": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ro0gr/ceibo/-/ceibo-2.2.0.tgz", diff --git a/package.json b/package.json index 346717c72..23590ab08 100644 --- a/package.json +++ b/package.json @@ -202,6 +202,7 @@ }, "dependencies": { "@rails/actioncable": "^8.0.200", + "@rive-app/canvas": "^2.27.0", "@stripe/stripe-js": "^5.5.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/typography": "^0.5.16", diff --git a/public/assets/animations/capy_walking.riv b/public/assets/animations/capy_walking.riv new file mode 100644 index 000000000..d447da1ca Binary files /dev/null and b/public/assets/animations/capy_walking.riv differ diff --git a/public/assets/animations/gleam_logo_animation.riv b/public/assets/animations/gleam_logo_animation.riv new file mode 100644 index 000000000..0583b7458 Binary files /dev/null and b/public/assets/animations/gleam_logo_animation.riv differ diff --git a/public/assets/animations/pig_cuddly.riv b/public/assets/animations/pig_cuddly.riv new file mode 100644 index 000000000..9fd36967a Binary files /dev/null and b/public/assets/animations/pig_cuddly.riv differ diff --git a/tests/integration/components/rive-animation-test.js b/tests/integration/components/rive-animation-test.js new file mode 100644 index 000000000..64509a738 --- /dev/null +++ b/tests/integration/components/rive-animation-test.js @@ -0,0 +1,111 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'codecrafters-frontend/tests/helpers'; +import { render, settled, waitUntil } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | rive-animation', function (hooks) { + setupRenderingTest(hooks); + + test('it renders and initializes correctly', async function (assert) { + await render(hbs` +
+ +
+ `); + + // Check that canvas is rendered with correct classes + const canvas = this.element.querySelector('canvas'); + assert.ok(canvas, 'Canvas element exists'); + assert.strictEqual(canvas?.className, 'w-full h-full block max-w-full', 'Canvas has correct classes'); + + // Wait for the animation to load and render + await settled(); + + // Wait until the canvas has non-transparent pixels + await waitUntil(() => { + const context = canvas.getContext('2d'); + if (!context) return false; + + const imageData = context.getImageData(0, 0, canvas.width, canvas.height).data; + + for (let i = 0; i < imageData.length; i += 4) { + if (imageData[i + 3] !== 0) { + return true; + } + } + + return false; + }); + + // Check that canvas has been initialized + assert.ok(canvas.width > 0, 'Canvas has width'); + assert.ok(canvas.height > 0, 'Canvas has height'); + const context = canvas.getContext('2d'); + assert.ok(context, 'Canvas has 2D context'); + + // Check for non-transparent pixels + const imageData = context.getImageData(0, 0, canvas.width, canvas.height).data; + let hasNonTransparentPixels = false; + + for (let i = 0; i < imageData.length; i += 4) { + if (imageData[i + 3] !== 0) { + // Check alpha channel + hasNonTransparentPixels = true; + break; + } + } + + assert.ok(hasNonTransparentPixels, 'Canvas has non-transparent pixels'); + }); + + test('it works with different animation files', async function (assert) { + await render(hbs` +
+ +
+ `); + + const canvas = this.element.querySelector('canvas'); + assert.ok(canvas, 'Canvas element exists'); + assert.strictEqual(canvas?.className, 'w-full h-full block max-w-full', 'Canvas has correct classes'); + + // Wait for the animation to load and render + await settled(); + + // Wait until the canvas has non-transparent pixels + await waitUntil(() => { + const context = canvas.getContext('2d'); + if (!context) return false; + + const imageData = context.getImageData(0, 0, canvas.width, canvas.height).data; + + for (let i = 0; i < imageData.length; i += 4) { + if (imageData[i + 3] !== 0) { + return true; + } + } + + return false; + }); + + // Check that canvas has been initialized + assert.ok(canvas.width > 0, 'Canvas has width'); + assert.ok(canvas.height > 0, 'Canvas has height'); + const context = canvas.getContext('2d'); + assert.ok(context, 'Canvas has 2D context'); + + // Check for non-transparent pixels + const imageData = context.getImageData(0, 0, canvas.width, canvas.height).data; + let hasNonTransparentPixels = false; + + for (let i = 0; i < imageData.length; i += 4) { + if (imageData[i + 3] !== 0) { + // Check alpha channel + hasNonTransparentPixels = true; + break; + } + } + + assert.ok(hasNonTransparentPixels, 'Canvas has non-transparent pixels'); + }); +});