From edcdca84294c9746b1817493789d8ad0cae13f45 Mon Sep 17 00:00:00 2001 From: Derk Date: Tue, 8 Sep 2026 23:57:53 +0000 Subject: [PATCH] fix: guard null viewport position in content bounds Toolbar positioning reads viewportContainer.position via optional chaining that stopped at the container. When position is still null before the viewport is ready, accessing .x/.y threw. Chain through position so updates no-op safely until the viewport is initialized. --- src/components/canvas/shotstack-canvas.ts | 4 +- tests/content-bounds.test.ts | 55 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/content-bounds.test.ts diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index f975f5b1..34055d9b 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -403,8 +403,8 @@ export class Canvas { public getContentBounds(): { left: number; right: number; top: number; bottom: number } { const scaledWidth = this.edit.size.width * this.currentZoom; const scaledHeight = this.edit.size.height * this.currentZoom; - const posX = this.viewportContainer?.position.x ?? 0; - const posY = this.viewportContainer?.position.y ?? 0; + const posX = this.viewportContainer?.position?.x ?? 0; + const posY = this.viewportContainer?.position?.y ?? 0; return { left: posX, diff --git a/tests/content-bounds.test.ts b/tests/content-bounds.test.ts new file mode 100644 index 00000000..b173e222 --- /dev/null +++ b/tests/content-bounds.test.ts @@ -0,0 +1,55 @@ +/** + * @jest-environment jsdom + */ + +import { Canvas } from "@canvas/shotstack-canvas"; + +// pixi.js ships untransformed ESM that jest can't parse; getContentBounds never touches a renderer. +jest.mock("pixi.js", () => ({ + Application: jest.fn(), + Container: jest.fn(), + Graphics: jest.fn(), + Rectangle: jest.fn() +})); +jest.mock("pixi.js/app", () => ({})); +jest.mock("pixi.js/events", () => ({})); +jest.mock("pixi.js/graphics", () => ({})); +jest.mock("pixi.js/text", () => ({})); +jest.mock("pixi.js/text-html", () => ({})); +jest.mock("pixi.js/sprite-tiling", () => ({})); +jest.mock("pixi.js/filters", () => ({})); +jest.mock("pixi.js/mesh", () => ({})); + +/** + * Regression: optional chaining must continue through `.position`. + * `viewportContainer?.position.x` still throws when `position` is null/undefined. + */ +describe("Canvas.getContentBounds", () => { + function boundsFor(viewportContainer: unknown) { + const canvas = Object.create(Canvas.prototype) as Canvas; + Object.assign(canvas, { + edit: { size: { width: 100, height: 50 } }, + currentZoom: 2, + viewportContainer + }); + return canvas.getContentBounds(); + } + + it("returns zeroed origin when viewportContainer is missing", () => { + expect(boundsFor(undefined)).toEqual({ left: 0, right: 200, top: 0, bottom: 100 }); + }); + + it("does not throw when viewportContainer.position is null", () => { + expect(() => boundsFor({ position: null })).not.toThrow(); + expect(boundsFor({ position: null })).toEqual({ left: 0, right: 200, top: 0, bottom: 100 }); + }); + + it("uses viewport position when available", () => { + expect(boundsFor({ position: { x: 10, y: 20 } })).toEqual({ + left: 10, + right: 210, + top: 20, + bottom: 120 + }); + }); +});