From 1f6b2a13fae5fde7aee27dfe97a46d5edcea074b Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Tue, 29 Sep 2026 02:08:48 +0800 Subject: [PATCH 1/5] test(schema): pin edge-label limits to characters, not UTF-16 units --- packages/schema/test/validate.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/schema/test/validate.test.ts b/packages/schema/test/validate.test.ts index 27664a6..9f45c3a 100644 --- a/packages/schema/test/validate.test.ts +++ b/packages/schema/test/validate.test.ts @@ -115,6 +115,15 @@ describe('validateDiagram', () => { expect(id!.allowedFixes).toEqual([expect.stringMatching(/^remove "id"/)]); }); + it('an edge label limit counts characters, not UTF-16 units (emoji)', () => { + const emoji = '🚀'.repeat(13); // 13 characters, 26 UTF-16 units + expect(diags(base({ edges: [{ id: 'ab', from: 'a', to: 'b', label: emoji }] }))).toEqual([]); + expect(diags(base({ edges: [{ id: 'ab', from: 'a', to: 'b', label: '🚀'.repeat(25) }] }))[0]).toMatchObject({ + subject: '/edges/0/label', + allowedFixes: ['shorten "label" to at most 24 characters'], + }); + }); + it('limits say how far to cut', () => { const stats = Array.from({ length: 4 }, (_, i) => ({ value: String(i), label: 'x' })); expect(diags(base({ nodes: [node('a', { card: { title: 'a', stats } })] }))[0]).toMatchObject({ From 95a9d09821c61f5f9c86eb4ff3518fdbbf730392 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Tue, 29 Sep 2026 02:08:48 +0800 Subject: [PATCH 2/5] fix(viewer): keep PNG exports within WebKit's canvas area limit --- packages/viewer/src/chrome/export.test.ts | 17 +++++++++++++---- packages/viewer/src/chrome/export.ts | 7 +++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/viewer/src/chrome/export.test.ts b/packages/viewer/src/chrome/export.test.ts index f2951c5..909a8fb 100644 --- a/packages/viewer/src/chrome/export.test.ts +++ b/packages/viewer/src/chrome/export.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { CANVAS_MAX, effectiveScale, exportFileName } from './export'; +import { CANVAS_AREA_MAX, CANVAS_MAX, effectiveScale, exportFileName } from './export'; describe('export', () => { it('keeps the requested scale while the canvas fits', () => { @@ -8,9 +8,18 @@ describe('export', () => { }); it('scales down so the longest side stays within the browser canvas limit', () => { - expect(effectiveScale(2344, 18636, 1)).toBeCloseTo(CANVAS_MAX / 18636, 6); - expect(effectiveScale(2344, 18636, 2)).toBeCloseTo(CANVAS_MAX / 18636, 6); - expect(effectiveScale(10000, 500, 2) * 10000).toBeLessThanOrEqual(CANVAS_MAX); + for (const [w, h, scale] of [[2344, 18636, 1], [2344, 18636, 2], [10000, 500, 2]] as const) { + const s = effectiveScale(w, h, scale); + expect(Math.max(w, h) * s).toBeLessThanOrEqual(CANVAS_MAX); + expect(w * s * h * s).toBeLessThanOrEqual(CANVAS_AREA_MAX + 1e-6); + expect(s).toBeLessThan(scale); + } + }); + + it("also stays within WebKit's canvas area limit (~16.7 Mpx)", () => { + const s = effectiveScale(6000, 6000, 2); + expect(6000 * s * (6000 * s)).toBeLessThanOrEqual(CANVAS_AREA_MAX); + expect(effectiveScale(1000, 800, 2)).toBe(2); }); it('names files after the title', () => { diff --git a/packages/viewer/src/chrome/export.ts b/packages/viewer/src/chrome/export.ts index 1d51e0c..fef197a 100644 --- a/packages/viewer/src/chrome/export.ts +++ b/packages/viewer/src/chrome/export.ts @@ -14,9 +14,12 @@ export const exportFileName = (title: string, ext: string) => .replace(/^-|-$/g, '') || 'diagram' }.${ext}`; -/** The requested scale, or less when the canvas would pass CANVAS_MAX on its longest side. */ +/** Canvas area Safari/WebKit renders; past it the canvas silently comes out blank. */ +export const CANVAS_AREA_MAX = 16_777_216; + +/** The requested scale, or less when the canvas would pass either browser limit. */ export const effectiveScale = (width: number, height: number, scale: number) => - Math.min(scale, CANVAS_MAX / Math.max(width, height)); + Math.min(scale, CANVAS_MAX / Math.max(width, height), Math.sqrt(CANVAS_AREA_MAX / (width * height))); // Only properties that change how a card, frame or edge looks. Copying every computed property // inline made a 6-card SVG 1.4 MB (a 500-node one ~90 MB). From 1d8e37e039da0df69e1f9a73620f9ad99aa0ae19 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Tue, 29 Sep 2026 02:08:48 +0800 Subject: [PATCH 3/5] fix(viewer): smooth held-key pans, minimap drag, zero-size mounts --- packages/viewer/e2e/explore.spec.ts | 53 +++++++++++++++++++ packages/viewer/src/canvas/DiagramCanvas.tsx | 14 ++--- packages/viewer/src/canvas/Minimap.tsx | 27 +++++++--- packages/viewer/src/canvas/useZoom.ts | 55 ++++++++++++++++---- packages/viewer/src/styles.css | 7 +++ 5 files changed, 134 insertions(+), 22 deletions(-) diff --git a/packages/viewer/e2e/explore.spec.ts b/packages/viewer/e2e/explore.spec.ts index 9abbb75..78354bc 100644 --- a/packages/viewer/e2e/explore.spec.ts +++ b/packages/viewer/e2e/explore.spec.ts @@ -303,3 +303,56 @@ test('a view fits its members clear of the toolbar and zoom bar', async ({ page } } }); + +test('holding an arrow key pans the full distance for every repeat', async ({ page }) => { + const start = await viewportOf(page); + await page.locator('.sm-stage').focus(); + await page.keyboard.down('ArrowRight'); + for (let i = 0; i < 4; i++) await page.keyboard.down('ArrowRight'); // auto-repeat + await page.keyboard.up('ArrowRight'); + await settle(page); + expect((await viewportOf(page)).x).toBeCloseTo(start.x - 5 * 80, 0); +}); + +test('dragging on the minimap pans the canvas with the pointer', async ({ page }) => { + await page.getByRole('button', { name: 'Toggle minimap' }).click(); + const map = (await page.locator('svg.sm-minimap').boundingBox())!; + const before = await viewportOf(page); + await page.mouse.move(map.x + map.width * 0.3, map.y + map.height / 2); + await page.mouse.down(); + await page.mouse.move(map.x + map.width * 0.7, map.y + map.height / 2, { steps: 6 }); + const mid = await viewportOf(page); + await page.mouse.up(); + expect(mid.x).toBeLessThan(before.x - 50); // moved during the drag, not only on release + expect(mid.k).toBeCloseTo(before.k, 5); +}); + +test('the canvas neither selects text nor scrolls the page on touch', async ({ page }) => { + const style = await page.locator('.sm-stage').evaluate((el) => { + const s = getComputedStyle(el); + return { select: s.userSelect, touch: s.touchAction }; + }); + expect(style).toEqual({ select: 'none', touch: 'none' }); +}); + +test('a canvas that mounts at zero size fits once it gets one, without errors', async ({ page }) => { + const errors: string[] = []; + page.on('console', (m) => m.type() === 'error' && errors.push(m.text())); + // A constructed sheet survives document parsing (a