diff --git a/cspell.config.json b/cspell.config.json index df86a79..3d5a4e7 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -19,7 +19,11 @@ "autofit", "workbox", "pinia", - "trackpad" + "trackpad", + "lzstring", + "lz", + "qrcode", + "qr" ], "ignoreWords": [], "import": ["@cspell/dict-typescript", "@cspell/dict-node"] diff --git a/e2e/floorplan.spec.ts b/e2e/floorplan.spec.ts index 177768d..a8eb00b 100644 --- a/e2e/floorplan.spec.ts +++ b/e2e/floorplan.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; +import LZString from 'lz-string'; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -808,3 +809,156 @@ test.describe('Canvas keyboard shortcuts', () => { await expect(page.getByTestId('properties-panel')).not.toBeVisible(); }); }); + +// ─── Import / Export / QR ──────────────────────────────────────────────────── + +const validImportPlan = { + id: 'import-original-id', + version: 1, + name: 'Imported Plan', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + elements: [] as unknown[], +}; + +test.describe('Import / Export / QR', () => { + test.beforeEach(({ page }) => setup(page)); + + test('share button is visible per plan', async ({ page }) => { + await page.getByTestId('plans-button').click(); + await expect(page.getByTestId('floorplan-manager')).toBeVisible(); + + const plans = JSON.parse(await page.evaluate(() => localStorage.getItem('snapdraft_floorplans') ?? '[]')) as Array<{ id: string }>; + expect(plans.length).toBeGreaterThan(0); + await expect(page.getByTestId(`share-plan-${plans[0].id}`)).toBeVisible(); + }); + + test('import button is visible in footer', async ({ page }) => { + await page.getByTestId('plans-button').click(); + await expect(page.getByTestId('import-plan')).toBeVisible(); + }); + + test('importing valid JSON closes the manager and adds the plan', async ({ page }) => { + await page.getByTestId('plans-button').click(); + await expect(page.getByTestId('floorplan-manager')).toBeVisible(); + + await page.getByTestId('import-file-input').setInputFiles({ + name: 'test.snapdraft.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify(validImportPlan)), + }); + + await expect(page.getByTestId('floorplan-manager')).not.toBeVisible(); + + await page.getByTestId('plans-button').click(); + await expect( + page.getByTestId('floorplan-manager').getByText('Imported Plan'), + ).toBeVisible(); + }); + + test('importing malformed JSON shows error', async ({ page }) => { + await page.getByTestId('plans-button').click(); + + await page.getByTestId('import-file-input').setInputFiles({ + name: 'bad.json', + mimeType: 'application/json', + buffer: Buffer.from('not valid json {{{'), + }); + + await expect(page.getByTestId('import-error')).toBeVisible(); + await expect(page.getByTestId('import-error')).toContainText('not valid JSON'); + }); + + test('importing unknown version shows error', async ({ page }) => { + const futurePlan = { ...validImportPlan, version: 999 }; + await page.getByTestId('plans-button').click(); + + await page.getByTestId('import-file-input').setInputFiles({ + name: 'future.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify(futurePlan)), + }); + + await expect(page.getByTestId('import-error')).toBeVisible(); + }); + + test('imported plan gets a different id than original', async ({ page }) => { + await page.getByTestId('plans-button').click(); + + await page.getByTestId('import-file-input').setInputFiles({ + name: 'test.snapdraft.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify(validImportPlan)), + }); + + await expect(page.getByTestId('floorplan-manager')).not.toBeVisible(); + + const plans = await page.evaluate(() => + JSON.parse(localStorage.getItem('snapdraft_floorplans') ?? '[]'), + ) as Array<{ id: string; name: string }>; + const imported = plans.find((p) => p.name === 'Imported Plan'); + expect(imported).toBeDefined(); + expect(imported!.id).not.toBe('import-original-id'); + }); + + test('share modal opens with QR canvas, copy link, and download buttons', async ({ page }) => { + await page.getByTestId('plans-button').click(); + + const plans = JSON.parse(await page.evaluate(() => localStorage.getItem('snapdraft_floorplans') ?? '[]')) as Array<{ id: string }>; + await page.getByTestId(`share-plan-${plans[0].id}`).click(); + + await expect(page.getByTestId('share-modal')).toBeVisible(); + await expect(page.getByTestId('qr-canvas')).toBeVisible(); + await expect(page.getByTestId('copy-link-btn')).toBeVisible(); + await expect(page.getByTestId('download-plan-btn')).toBeVisible(); + }); + + test('copy link button writes URL with ?plan= to clipboard', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + + await page.getByTestId('plans-button').click(); + + const plans = JSON.parse(await page.evaluate(() => localStorage.getItem('snapdraft_floorplans') ?? '[]')) as Array<{ id: string }>; + await page.getByTestId(`share-plan-${plans[0].id}`).click(); + await expect(page.getByTestId('share-modal')).toBeVisible(); + + await page.getByTestId('copy-link-btn').click(); + await expect(page.getByTestId('copy-link-btn')).toContainText('Copied!'); + + const clipboard = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboard).toContain('?plan='); + }); + + test('navigating to a ?plan= URL auto-imports the plan', async ({ page }) => { + const planToShare = { + id: 'share-source-id', + version: 1, + name: 'Shared Via URL', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + elements: [], + }; + const compressed = LZString.compressToEncodedURIComponent(JSON.stringify(planToShare)); + + await page.goto(`/?plan=${compressed}`); + await dismissHelp(page); + + const plans = await page.evaluate(() => + JSON.parse(localStorage.getItem('snapdraft_floorplans') ?? '[]'), + ) as Array<{ name: string }>; + + expect(plans.some((p) => p.name === 'Shared Via URL')).toBe(true); + expect(page.url()).not.toContain('?plan='); + }); + + test('share modal closes on backdrop click', async ({ page }) => { + await page.getByTestId('plans-button').click(); + + const plans = JSON.parse(await page.evaluate(() => localStorage.getItem('snapdraft_floorplans') ?? '[]')) as Array<{ id: string }>; + await page.getByTestId(`share-plan-${plans[0].id}`).click(); + await expect(page.getByTestId('share-modal')).toBeVisible(); + + await page.getByTestId('share-modal').click({ position: { x: 10, y: 10 } }); + await expect(page.getByTestId('share-modal')).not.toBeVisible(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 30dfcb1..c4c0e62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "dependencies": { "konva": "^10.2.3", "lucide-react": "^0.577.0", + "lz-string": "^1.5.0", "nanoid": "^5.1.7", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-konva": "^19.2.3", @@ -23,6 +25,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.0", @@ -3414,6 +3417,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -3993,7 +4006,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4404,6 +4416,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001780", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", @@ -4554,11 +4575,85 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4571,7 +4666,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colorette": { @@ -5010,6 +5104,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -5090,6 +5193,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -5988,6 +6097,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", @@ -7640,6 +7758,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7984,6 +8111,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -8021,7 +8157,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8145,6 +8280,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -8264,6 +8408,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -8464,6 +8625,15 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -8474,6 +8644,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8701,6 +8877,12 @@ "randombytes": "^2.1.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10075,6 +10257,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -10447,6 +10635,12 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -10470,6 +10664,143 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 346f93f..98352cb 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,9 @@ "dependencies": { "konva": "^10.2.3", "lucide-react": "^0.577.0", + "lz-string": "^1.5.0", "nanoid": "^5.1.7", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-konva": "^19.2.3", @@ -42,6 +44,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^24.12.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.0", diff --git a/src/App.tsx b/src/App.tsx index 0f65fe4..f944aef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { HelpOverlay } from './components/HelpOverlay/HelpOverlay'; import { ScaleBar } from './components/Canvas/ScaleBar/ScaleBar'; import { MultiSelectBar } from './components/Canvas/MultiSelectBar/MultiSelectBar'; import { useFloorplanStore } from './store/useFloorplanStore/useFloorplanStore'; +import { decodePlanFromUrl } from './utils/storage/storage'; import styles from './App.module.css'; export default function App() { @@ -16,7 +17,20 @@ export default function App() { return true; }); - const { plans, createPlan, activeId } = useFloorplanStore(); + const { plans, createPlan, importPlan, activeId } = useFloorplanStore(); + + // Import plan from URL on first load + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const planParam = params.get('plan'); + if (planParam) { + const plan = decodePlanFromUrl(window.location.href); + if (plan) { + importPlan(plan); + window.history.replaceState(null, '', window.location.pathname); + } + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps // Create a default plan on first load useEffect(() => { diff --git a/src/components/FloorplanManager/FloorplanManager.module.css b/src/components/FloorplanManager/FloorplanManager.module.css index a269a1c..37e5621 100644 --- a/src/components/FloorplanManager/FloorplanManager.module.css +++ b/src/components/FloorplanManager/FloorplanManager.module.css @@ -159,7 +159,54 @@ outline-offset: 2px; } +.hidden { + display: none; +} + +.footer { + display: flex; + flex-direction: column; + gap: 8px; +} + +.importError { + font-size: 11px; + color: #b94a4a; + margin: 0; +} + +.importBtn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + background: none; + border: 1px solid #c8c4bc; + border-radius: 8px; + padding: 12px; + font-family: inherit; + font-size: 12px; + letter-spacing: 0.05em; + cursor: pointer; + min-height: 44px; + touch-action: manipulation; + color: #2c2c2c; +} + +.importBtn:hover { + background: #ece8e0; +} + +.importBtn:focus-visible { + outline: 2px solid #4a6fa5; + outline-offset: 2px; +} + .createBtn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; background: #2c2c2c; color: #f5f0e8; border: none; diff --git a/src/components/FloorplanManager/FloorplanManager.tsx b/src/components/FloorplanManager/FloorplanManager.tsx index 4467e8f..acd5f4c 100644 --- a/src/components/FloorplanManager/FloorplanManager.tsx +++ b/src/components/FloorplanManager/FloorplanManager.tsx @@ -1,7 +1,10 @@ import { useRef, useState } from 'react'; -import { X, Pencil, Plus } from 'lucide-react'; +import { X, Pencil, Plus, Upload, Share2, Trash2 } from 'lucide-react'; import { useFloorplanStore } from '../../store/useFloorplanStore/useFloorplanStore'; import { useFocusTrap } from '../../hooks/useFocusTrap/useFocusTrap'; +import { parseImportedPlan } from '../../utils/storage/storage'; +import type { FloorPlan } from '../../types'; +import { ShareModal } from './QrModal'; import styles from './FloorplanManager.module.css'; type Props = { @@ -9,11 +12,14 @@ type Props = { }; export function FloorplanManager({ onClose }: Props) { - const { plans, activeId, createPlan, deletePlan, renamePlan, setActivePlan } = + const { plans, activeId, createPlan, importPlan, deletePlan, renamePlan, setActivePlan } = useFloorplanStore(); const [editingId, setEditingId] = useState(null); const [editingName, setEditingName] = useState(''); + const [importError, setImportError] = useState(null); + const [qrPlan, setQrPlan] = useState(null); const panelRef = useRef(null); + const fileInputRef = useRef(null); const titleId = 'floorplan-manager-title'; useFocusTrap(panelRef, onClose); @@ -38,6 +44,30 @@ export function FloorplanManager({ onClose }: Props) { setEditingId(null); } + function handleImportFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const raw = JSON.parse(event.target?.result as string) as unknown; + const plan = parseImportedPlan(raw); + if (plan) { + importPlan(plan); + setImportError(null); + onClose(); + } else { + setImportError('File is not a valid SnapDraft plan'); + } + } catch { + setImportError('File is not valid JSON'); + } + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + reader.readAsText(file); + } + return (
+
))} - + + +
+ {importError && ( +

+ {importError} +

+ )} + + +
+ {qrPlan && setQrPlan(null)} />} ); } diff --git a/src/components/FloorplanManager/QrModal.module.css b/src/components/FloorplanManager/QrModal.module.css new file mode 100644 index 0000000..f953c0d --- /dev/null +++ b/src/components/FloorplanManager/QrModal.module.css @@ -0,0 +1,104 @@ +.backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 210; +} + +.panel { + background: #f5f0e8; + border: 1px solid #c8c4bc; + border-radius: 12px; + padding: 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + font-family: 'Courier New', monospace; + position: relative; + max-width: 90vw; +} + +.closeBtn { + position: absolute; + top: 12px; + right: 12px; + background: none; + border: none; + font-size: 20px; + cursor: pointer; + color: #4a4a4a; + min-height: 44px; + min-width: 44px; + border-radius: 6px; + line-height: 1; +} + +.closeBtn:hover { + background: #ece8e0; +} + +.closeBtn:focus-visible { + outline: 2px solid #4a6fa5; + outline-offset: 2px; +} + +.title { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #2c2c2c; + margin: 0; + text-align: center; + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.qrNote { + font-size: 12px; + color: #5a5a5a; + text-align: center; + margin: 0; + padding: 20px 0; + width: 240px; +} + +.actions { + display: flex; + gap: 8px; + width: 100%; +} + +.actionBtn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + background: #2c2c2c; + color: #f5f0e8; + border: none; + border-radius: 8px; + padding: 10px 12px; + font-family: inherit; + font-size: 12px; + letter-spacing: 0.05em; + cursor: pointer; + min-height: 44px; + touch-action: manipulation; +} + +.actionBtn:hover { + background: #3a3a3a; +} + +.actionBtn:focus-visible { + outline: 2px solid #4a6fa5; + outline-offset: 2px; +} diff --git a/src/components/FloorplanManager/QrModal.tsx b/src/components/FloorplanManager/QrModal.tsx new file mode 100644 index 0000000..90b5016 --- /dev/null +++ b/src/components/FloorplanManager/QrModal.tsx @@ -0,0 +1,62 @@ +import { useEffect, useRef, useState } from 'react'; +import QRCode from 'qrcode'; +import { encodePlanToUrl, exportFloorPlan } from '../../utils/storage/storage'; +import type { FloorPlan } from '../../types'; +import styles from './QrModal.module.css'; + +const QR_URL_LIMIT = 3000; + +type Props = { + plan: FloorPlan; + onClose: () => void; +}; + +export function ShareModal({ plan, onClose }: Props) { + const canvasRef = useRef(null); + const [copied, setCopied] = useState(false); + const url = encodePlanToUrl(plan); + const qrFits = url.length <= QR_URL_LIMIT; + + useEffect(() => { + if (qrFits && canvasRef.current) { + QRCode.toCanvas(canvasRef.current, url, { width: 240 }).catch(console.error); + } + }, [url, qrFits]); + + function handleCopy() { + navigator.clipboard.writeText(url).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + } + + return ( +
+
e.stopPropagation()}> + +

{plan.name}

+ {qrFits ? ( + + ) : ( +

+ Plan is too large for a QR code. +

+ )} +
+ + +
+
+
+ ); +} diff --git a/src/store/useFloorplanStore/useFloorplanStore.test.ts b/src/store/useFloorplanStore/useFloorplanStore.test.ts index 18ccf9e..4d6463b 100644 --- a/src/store/useFloorplanStore/useFloorplanStore.test.ts +++ b/src/store/useFloorplanStore/useFloorplanStore.test.ts @@ -218,6 +218,118 @@ describe('activePlan', () => { }); }); +describe('importPlan', () => { + it('adds the plan to the list and makes it active', () => { + const incoming: import('../../types').FloorPlan = { + id: 'orig-id', + version: 1, + name: 'Imported', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + elements: [ + { + id: 'w1', + type: 'wall', + points: [ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + ], + }, + ], + }; + const countBefore = useFloorplanStore.getState().plans.length; + const newId = useFloorplanStore.getState().importPlan(incoming); + + const plans = useFloorplanStore.getState().plans; + expect(plans.length).toBe(countBefore + 1); + expect(useFloorplanStore.getState().activeId).toBe(newId); + }); + + it('assigns a new id (does not reuse original id)', () => { + const incoming: import('../../types').FloorPlan = { + id: 'should-not-be-used', + version: 1, + name: 'Imported', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + elements: [], + }; + const newId = useFloorplanStore.getState().importPlan(incoming); + expect(newId).not.toBe('should-not-be-used'); + const plan = useFloorplanStore.getState().plans.find((p) => p.id === newId); + expect(plan).toBeDefined(); + }); + + it('preserves name and elements from incoming plan', () => { + const wall: import('../../types').Element = { + id: 'w1', + type: 'wall', + points: [ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + ], + }; + const incoming: import('../../types').FloorPlan = { + id: 'orig', + version: 1, + name: 'My Shared Plan', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + elements: [wall], + }; + const newId = useFloorplanStore.getState().importPlan(incoming); + const plan = useFloorplanStore.getState().plans.find((p) => p.id === newId)!; + expect(plan.name).toBe('My Shared Plan'); + expect(plan.elements).toHaveLength(1); + expect(plan.elements[0].id).toBe('w1'); + }); + + it('sets version to FLOORPLAN_VERSION', () => { + const incoming: import('../../types').FloorPlan = { + id: 'orig', + version: 0, + name: 'Old Plan', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + elements: [], + }; + const newId = useFloorplanStore.getState().importPlan(incoming); + const plan = useFloorplanStore.getState().plans.find((p) => p.id === newId)!; + expect(plan.version).toBe(FLOORPLAN_VERSION); + }); + + it('resets undo/redo history', () => { + useFloorplanStore.getState().addElement(wall()); + expect(useFloorplanStore.getState().past.length).toBeGreaterThan(0); + + const incoming: import('../../types').FloorPlan = { + id: 'orig', + version: 1, + name: 'Imported', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + elements: [], + }; + useFloorplanStore.getState().importPlan(incoming); + expect(useFloorplanStore.getState().past).toHaveLength(0); + expect(useFloorplanStore.getState().future).toHaveLength(0); + }); + + it('persists the new plan to localStorage', () => { + const incoming: import('../../types').FloorPlan = { + id: 'orig', + version: 1, + name: 'Persisted Import', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + elements: [], + }; + const newId = useFloorplanStore.getState().importPlan(incoming); + const stored = JSON.parse(localStorage.getItem('snapdraft_floorplans')!); + expect(stored.find((p: { id: string }) => p.id === newId)?.name).toBe('Persisted Import'); + }); +}); + describe('persistence to localStorage', () => { it('saves and reloads plans', () => { const id = useFloorplanStore.getState().activeId!; diff --git a/src/store/useFloorplanStore/useFloorplanStore.ts b/src/store/useFloorplanStore/useFloorplanStore.ts index bc25e97..2c617c5 100644 --- a/src/store/useFloorplanStore/useFloorplanStore.ts +++ b/src/store/useFloorplanStore/useFloorplanStore.ts @@ -24,6 +24,7 @@ type FloorplanStore = { // Plan management createPlan: (name?: string) => string; + importPlan: (plan: FloorPlan) => string; deletePlan: (id: string) => void; renamePlan: (id: string, name: string) => void; setActivePlan: (id: string) => void; @@ -148,6 +149,24 @@ export const useFloorplanStore = create((set, get) => ({ return id; }, + importPlan: (plan) => { + const id = nanoid(); + const now = new Date().toISOString(); + const newPlan: FloorPlan = { + ...plan, + id, + createdAt: now, + updatedAt: now, + version: FLOORPLAN_VERSION, + }; + set((state) => { + const plans = [...state.plans, newPlan]; + persist(plans, id); + return { plans, activeId: id, past: [], future: [] }; + }); + return id; + }, + deletePlan: (id) => { set((state) => { const plans = state.plans.filter((p) => p.id !== id); diff --git a/src/utils/storage/storage.test.ts b/src/utils/storage/storage.test.ts index 4133dbf..17cb82f 100644 --- a/src/utils/storage/storage.test.ts +++ b/src/utils/storage/storage.test.ts @@ -1,10 +1,14 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { FLOORPLAN_VERSION, loadFloorPlans, saveFloorPlans, loadActiveId, saveActiveId, + exportFloorPlan, + parseImportedPlan, + encodePlanToUrl, + decodePlanFromUrl, } from './storage'; import type { FloorPlan } from '../../types'; @@ -78,3 +82,221 @@ describe('loadActiveId / saveActiveId', () => { expect(loadActiveId()).toBe('abc-123'); }); }); + +describe('exportFloorPlan', () => { + it('triggers a download with the correct filename and blob URL', () => { + const createObjectURL = vi.fn().mockReturnValue('blob:mock-url'); + const revokeObjectURL = vi.fn(); + global.URL.createObjectURL = createObjectURL; + global.URL.revokeObjectURL = revokeObjectURL; + + let capturedAnchor: HTMLAnchorElement | null = null; + const appendChildSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((node) => { + if (node instanceof HTMLAnchorElement) capturedAnchor = node; + return node; + }); + const removeChildSpy = vi + .spyOn(document.body, 'removeChild') + .mockImplementation((node) => node); + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => undefined); + + exportFloorPlan(mockPlan); + + expect(createObjectURL).toHaveBeenCalled(); + expect(capturedAnchor).not.toBeNull(); + expect(capturedAnchor!.download).toBe('Test Plan.snapdraft.json'); + expect(capturedAnchor!.href).toContain('blob:'); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + + appendChildSpy.mockRestore(); + removeChildSpy.mockRestore(); + clickSpy.mockRestore(); + }); + + it('sanitizes filename — strips special chars', () => { + const plan = { ...mockPlan, name: '!@#My Plan!@#' }; + global.URL.createObjectURL = vi.fn().mockReturnValue('blob:mock'); + global.URL.revokeObjectURL = vi.fn(); + + let capturedAnchor: HTMLAnchorElement | null = null; + vi.spyOn(document.body, 'appendChild').mockImplementation((node) => { + if (node instanceof HTMLAnchorElement) capturedAnchor = node; + return node; + }); + vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined); + + exportFloorPlan(plan); + + expect(capturedAnchor!.download).toBe('My Plan.snapdraft.json'); + + vi.restoreAllMocks(); + }); + + it('falls back to "floorplan" when name is all special chars', () => { + const plan = { ...mockPlan, name: '!!!###' }; + global.URL.createObjectURL = vi.fn().mockReturnValue('blob:mock'); + global.URL.revokeObjectURL = vi.fn(); + + let capturedAnchor: HTMLAnchorElement | null = null; + vi.spyOn(document.body, 'appendChild').mockImplementation((node) => { + if (node instanceof HTMLAnchorElement) capturedAnchor = node; + return node; + }); + vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined); + + exportFloorPlan(plan); + + expect(capturedAnchor!.download).toBe('floorplan.snapdraft.json'); + + vi.restoreAllMocks(); + }); +}); + +describe('parseImportedPlan', () => { + it('returns a valid plan unchanged (version 1)', () => { + const result = parseImportedPlan(mockPlan); + expect(result).toEqual(mockPlan); + }); + + it('upgrades legacy plan (no version field) to current version', () => { + const legacy = { ...mockPlan }; + delete (legacy as Partial).version; + const result = parseImportedPlan(legacy); + expect(result).not.toBeNull(); + expect(result!.version).toBe(FLOORPLAN_VERSION); + }); + + it('returns null for future (unsupported) version', () => { + const futurePlan = { ...mockPlan, version: FLOORPLAN_VERSION + 1 }; + expect(parseImportedPlan(futurePlan)).toBeNull(); + }); + + it('returns null for null input', () => { + expect(parseImportedPlan(null)).toBeNull(); + }); + + it('returns null for non-object input', () => { + expect(parseImportedPlan('string')).toBeNull(); + expect(parseImportedPlan(42)).toBeNull(); + expect(parseImportedPlan([])).toBeNull(); + }); + + it('returns null when required fields are missing', () => { + expect(parseImportedPlan({ id: 'x', name: 'x', createdAt: 'x', updatedAt: 'x' })).toBeNull(); + expect( + parseImportedPlan({ id: 'x', name: 'x', createdAt: 'x', updatedAt: 'x', elements: 'bad' }), + ).toBeNull(); + }); + + it('returns null for invalid wall points', () => { + const plan = { + ...mockPlan, + elements: [{ id: 'w1', type: 'wall', points: [{ x: 'not-a-number', y: 0 }] }], + }; + expect(parseImportedPlan(plan)).toBeNull(); + }); + + it('returns null for missing wall points array', () => { + const plan = { ...mockPlan, elements: [{ id: 'w1', type: 'wall', points: 'bad' }] }; + expect(parseImportedPlan(plan)).toBeNull(); + }); + + it('returns valid plan with a well-formed wall element', () => { + const plan = { + ...mockPlan, + elements: [ + { + id: 'w1', + type: 'wall', + points: [ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + ], + }, + ], + }; + const result = parseImportedPlan(plan); + expect(result).not.toBeNull(); + expect(result!.elements).toHaveLength(1); + }); + + it('returns null for invalid box fields', () => { + const plan = { + ...mockPlan, + elements: [{ id: 'b1', type: 'box', x: NaN, y: 0, width: 4, height: 3, rotation: 0 }], + }; + expect(parseImportedPlan(plan)).toBeNull(); + }); + + it('returns null when box label is not a string', () => { + const plan = { + ...mockPlan, + elements: [ + { id: 'b1', type: 'box', x: 0, y: 0, width: 4, height: 3, rotation: 0, label: 42 }, + ], + }; + expect(parseImportedPlan(plan)).toBeNull(); + }); + + it('accepts box with valid optional label', () => { + const plan = { + ...mockPlan, + elements: [ + { id: 'b1', type: 'box', x: 0, y: 0, width: 4, height: 3, rotation: 0, label: 'Room' }, + ], + }; + expect(parseImportedPlan(plan)).not.toBeNull(); + }); + + it('returns null for unknown element type', () => { + const plan = { ...mockPlan, elements: [{ id: 'x1', type: 'circle' }] }; + expect(parseImportedPlan(plan)).toBeNull(); + }); +}); + +describe('encodePlanToUrl / decodePlanFromUrl', () => { + it('roundtrip preserves plan data', () => { + const url = encodePlanToUrl(mockPlan); + expect(url).toContain('?plan='); + const decoded = decodePlanFromUrl(url); + expect(decoded).toEqual(mockPlan); + }); + + it('encodes a plan with elements correctly', () => { + const planWithElements: FloorPlan = { + ...mockPlan, + elements: [ + { + id: 'w1', + type: 'wall', + points: [ + { x: 0, y: 0 }, + { x: 5, y: 0 }, + ], + }, + { id: 'b1', type: 'box', x: 1, y: 1, width: 3, height: 2, rotation: 0 }, + ], + }; + const url = encodePlanToUrl(planWithElements); + const decoded = decodePlanFromUrl(url); + expect(decoded).toEqual(planWithElements); + }); + + it('returns null for invalid compressed string', () => { + const result = decodePlanFromUrl('http://localhost/?plan=INVALID!!!GARBAGE'); + expect(result).toBeNull(); + }); + + it('returns null when plan param is missing', () => { + expect(decodePlanFromUrl('http://localhost/')).toBeNull(); + }); + + it('returns null for malformed URL', () => { + expect(decodePlanFromUrl('not-a-url')).toBeNull(); + }); +}); diff --git a/src/utils/storage/storage.ts b/src/utils/storage/storage.ts index b7d88c1..6f5fbd0 100644 --- a/src/utils/storage/storage.ts +++ b/src/utils/storage/storage.ts @@ -1,3 +1,4 @@ +import LZString from 'lz-string'; import type { FloorPlan } from '../../types'; const STORAGE_KEY = 'snapdraft_floorplans'; @@ -64,3 +65,74 @@ export function loadActiveId(): string | null { export function saveActiveId(id: string): void { localStorage.setItem(ACTIVE_KEY, id); } + +function sanitizeFilename(name: string): string { + return name.replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'floorplan'; +} + +export function exportFloorPlan(plan: FloorPlan): void { + const json = JSON.stringify(plan, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${sanitizeFilename(plan.name)}.snapdraft.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +export function parseImportedPlan(raw: unknown): FloorPlan | null { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null; + const obj = raw as Record; + if (typeof obj.id !== 'string') return null; + if (typeof obj.name !== 'string') return null; + if (typeof obj.createdAt !== 'string') return null; + if (typeof obj.updatedAt !== 'string') return null; + if (!Array.isArray(obj.elements)) return null; + + for (const el of obj.elements) { + if (typeof el !== 'object' || el === null) return null; + const elem = el as Record; + if (typeof elem.id !== 'string') return null; + if (elem.type === 'wall') { + if (!Array.isArray(elem.points)) return null; + for (const pt of elem.points) { + if (typeof pt !== 'object' || pt === null) return null; + const p = pt as Record; + if (typeof p.x !== 'number' || typeof p.y !== 'number') return null; + } + } else if (elem.type === 'box') { + if (typeof elem.x !== 'number' || !isFinite(elem.x as number)) return null; + if (typeof elem.y !== 'number' || !isFinite(elem.y as number)) return null; + if (typeof elem.width !== 'number' || !isFinite(elem.width as number)) return null; + if (typeof elem.height !== 'number' || !isFinite(elem.height as number)) return null; + if (typeof elem.rotation !== 'number' || !isFinite(elem.rotation as number)) return null; + if (elem.label !== undefined && typeof elem.label !== 'string') return null; + } else { + return null; + } + } + + return normalizeFloorPlan(raw as LegacyFloorPlan); +} + +export function encodePlanToUrl(plan: FloorPlan): string { + const compressed = LZString.compressToEncodedURIComponent(JSON.stringify(plan)); + return window.location.origin + window.location.pathname + '?plan=' + compressed; +} + +export function decodePlanFromUrl(href: string): FloorPlan | null { + try { + const url = new URL(href); + const param = url.searchParams.get('plan'); + if (!param) return null; + const decompressed = LZString.decompressFromEncodedURIComponent(param); + if (!decompressed) return null; + const parsed = JSON.parse(decompressed) as unknown; + return parseImportedPlan(parsed); + } catch { + return null; + } +}