From 1636e8c2fbd28453aa1c771f11feddcc26b5cca3 Mon Sep 17 00:00:00 2001 From: Rohit Rajendran Date: Fri, 20 Mar 2026 17:25:59 -0400 Subject: [PATCH] feat: offline export/import and QR/link sharing for floor plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three ways to move plans between devices without a backend: - Export plan as a .snapdraft.json file (download) - Import plan from a .snapdraft.json file - Share via a compressed URL (?plan=…) with QR code or copy-link A single Share button per plan opens a unified modal with QR code (when the plan fits within QR data limits), Copy link, and Download file. Navigating to a shared URL auto-imports the plan on load and strips the param from the address bar. Includes unit tests for all new storage helpers and the importPlan store action, plus E2E coverage for the full import/export/share workflow. --- cspell.config.json | 6 +- e2e/floorplan.spec.ts | 154 ++++++++ package-lock.json | 339 +++++++++++++++++- package.json | 3 + src/App.tsx | 16 +- .../FloorplanManager.module.css | 47 +++ .../FloorplanManager/FloorplanManager.tsx | 78 +++- .../FloorplanManager/QrModal.module.css | 104 ++++++ src/components/FloorplanManager/QrModal.tsx | 62 ++++ .../useFloorplanStore.test.ts | 112 ++++++ .../useFloorplanStore/useFloorplanStore.ts | 19 + src/utils/storage/storage.test.ts | 224 +++++++++++- src/utils/storage/storage.ts | 72 ++++ 13 files changed, 1223 insertions(+), 13 deletions(-) create mode 100644 src/components/FloorplanManager/QrModal.module.css create mode 100644 src/components/FloorplanManager/QrModal.tsx 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; + } +}