diff --git a/FEATURE-URL-DIAGRAM-ID.md b/FEATURE-URL-DIAGRAM-ID.md new file mode 100644 index 00000000..f74249cf --- /dev/null +++ b/FEATURE-URL-DIAGRAM-ID.md @@ -0,0 +1,75 @@ +# Diagram ID in URL Feature + Multi-Tab Support + +## Overview +This feature includes two major improvements: + +### 1. Diagram ID in URL +Displays the current diagram ID in the URL, making it easier for users to: +- Share direct links to specific diagrams +- Bookmark specific diagrams +- See which diagram they are currently viewing +- Navigate back to specific diagrams using browser history + +### 2. Multi-Tab Support +Removes the previous limitation that prevented opening the app in multiple browser tabs: +- Users can now work with multiple diagrams simultaneously across different tabs +- Graceful fallback from Firestore persistence when multiple tabs are detected +- No more blocking error messages when opening additional tabs + +## Implementation Details + +### URL Feature Changes +1. **`updateUrlWithDiagramId(diagramId)` method**: Updates the browser URL with the diagram ID using `window.history.replaceState()` +2. **Modified `setCurrentItem(item)` method**: Automatically updates URL when a diagram is loaded/switched +3. **Modified `createNewItem()` method**: Ensures new items get unique IDs before setting them as current +4. **Modified `forkItem(sourceItem)` method**: Generates new IDs for forked diagrams +5. **Enhanced lastCode handling**: Ensures restored items from localStorage have IDs + +### Multi-Tab Support Changes +1. **Modified `getDb()` function in `src/db.js`**: Graceful fallback when Firestore persistence fails +2. **Removed blocking alert**: No more error message preventing multi-tab usage +3. **Persistence behavior**: First tab gets persistence, additional tabs work without persistence +4. **Error tracking**: Changed from `multiTabError` to `multiTabFallback` for analytics + +### URL Format +- **With diagram**: `https://app.zenuml.com/?id=` +- **Without diagram**: `https://app.zenuml.com/` (ID parameter removed) + +### Behavior +- **New diagrams**: URL automatically updates with new random ID +- **Existing diagrams**: URL updates when diagram is opened/loaded +- **Forked diagrams**: URL updates with new forked diagram ID +- **Shared diagrams**: URL includes the shared diagram ID +- **Desktop app**: URL updates are skipped (no browser context) + +## Testing + +### Manual Testing +1. Open http://localhost:3000 +2. Create a new diagram → URL should show `?id=` +3. Open an existing diagram → URL should update to show that diagram's ID +4. Fork a diagram → URL should show new forked diagram's ID +5. Refresh page with `?id=` → Should load that specific diagram + +### Automated Testing +Run the test script in browser console: +```javascript +// Load the test script and run +testUrlFunctionality(); +``` + +## Benefits +- **Shareable links**: Users can copy URL to share specific diagrams +- **Bookmarking**: Users can bookmark specific diagrams +- **Navigation**: Browser back/forward works with diagram contexts +- **User experience**: Clear indication of which diagram is currently active + +## Technical Notes +- Uses `replaceState` instead of `pushState` to avoid cluttering browser history +- Skips URL updates in desktop app environment +- Maintains backward compatibility with existing URL parameter handling +- Generates unique IDs for all new diagrams to ensure URL uniqueness + +## Files Modified +- `src/components/app.jsx`: Core implementation of URL updating logic +- `src/db.js`: Multi-tab support and graceful Firestore persistence fallback diff --git a/package.json b/package.json index a5e30437..30af45a1 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-tooltip": "^1.0.7", - "@zenuml/core": "^3.35.1", + "@zenuml/core": "^3.40.1", "clsx": "^2.0.0", "code-blast-codemirror": "chinchang/code-blast-codemirror#web-maker", "codemirror": "^5.65.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06f7b2ed..5eeec26d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^1.0.7 version: 1.2.8(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@zenuml/core': - specifier: ^3.35.1 - version: 3.36.0(@babel/core@7.28.3)(@babel/template@7.27.2) + specifier: ^3.40.1 + version: 3.40.1(@babel/core@7.28.3)(@babel/template@7.27.2) clsx: specifier: ^2.0.0 version: 2.1.1 @@ -58,7 +58,7 @@ importers: version: 7.24.0 firebase-tools: specifier: ^13.6.0 - version: 13.35.1(@types/node@24.3.0)(encoding@0.1.13) + version: 13.35.1(@types/node@24.3.1)(encoding@0.1.13) http-server: specifier: ^0.12.3 version: 0.12.3 @@ -104,10 +104,10 @@ importers: version: 1.55.0 '@preact/preset-vite': specifier: ^2.10.1 - version: 2.10.2(@babel/core@7.28.3)(preact@10.18.1)(vite@6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1)) + version: 2.10.2(@babel/core@7.28.3)(preact@10.18.1)(vite@6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1)) '@vitejs/plugin-legacy': specifier: ^6.1.1 - version: 6.1.1(terser@5.43.1)(vite@6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1)) + version: 6.1.1(terser@5.43.1)(vite@6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1)) autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) @@ -182,7 +182,7 @@ importers: version: 5.43.1 vite: specifier: ^6.3.5 - version: 6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1) + version: 6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1) packages: @@ -1933,6 +1933,9 @@ packages: '@types/node@24.3.0': resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==} + '@types/node@24.3.1': + resolution: {integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==} + '@types/request@2.48.13': resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} @@ -2047,8 +2050,8 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - '@zenuml/core@3.36.0': - resolution: {integrity: sha512-haEUgNrEvAG3Ub8dutKoQk3inReaoYexYoXDWkF0rZebSolgyrMvglrjl5kZzjib43sRkpyq+/kc/BbmWCWr3A==} + '@zenuml/core@3.40.1': + resolution: {integrity: sha512-YhhQcxHcXzW8rcW5bzJfU67y4zDIojK3MEL9aSF7G/8zPlfAFdzZhkl0WWHhxdmE/yK4zZ6f7dqjgImw/jJrYA==} engines: {node: '>=20'} abab@2.0.6: @@ -7133,6 +7136,11 @@ packages: engines: {node: '>=10'} hasBin: true + terser@5.44.0: + resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} + engines: {node: '>=10'} + hasBin: true + test-exclude@5.2.3: resolution: {integrity: sha512-M+oxtseCFO3EDtAaGH7iiej3CBkzXqFMbzqYAACdzKui4eZA+pq3tZEwChvOdNfa7xxy8BfbmgJSIr43cC/+2g==} engines: {node: '>=6'} @@ -9035,12 +9043,12 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} - '@inquirer/external-editor@1.0.1(@types/node@24.3.0)': + '@inquirer/external-editor@1.0.1(@types/node@24.3.1)': dependencies: chardet: 2.1.0 iconv-lite: 0.6.3 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 24.3.1 '@isaacs/cliui@8.0.2': dependencies: @@ -9274,18 +9282,18 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@preact/preset-vite@2.10.2(@babel/core@7.28.3)(preact@10.18.1)(vite@6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1))': + '@preact/preset-vite@2.10.2(@babel/core@7.28.3)(preact@10.18.1)(vite@6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.3 '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.3) '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.3) - '@prefresh/vite': 2.4.10(preact@10.18.1)(vite@6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1)) + '@prefresh/vite': 2.4.10(preact@10.18.1)(vite@6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1)) '@rollup/pluginutils': 4.2.1 babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.28.3) debug: 4.4.1 picocolors: 1.1.1 - vite: 6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1) - vite-prerender-plugin: 0.5.11(vite@6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1)) + vite: 6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1) + vite-prerender-plugin: 0.5.11(vite@6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1)) transitivePeerDependencies: - preact - supports-color @@ -9298,7 +9306,7 @@ snapshots: '@prefresh/utils@1.2.1': {} - '@prefresh/vite@2.4.10(preact@10.18.1)(vite@6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1))': + '@prefresh/vite@2.4.10(preact@10.18.1)(vite@6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.3 '@prefresh/babel-plugin': 0.5.2 @@ -9306,7 +9314,7 @@ snapshots: '@prefresh/utils': 1.2.1 '@rollup/pluginutils': 4.2.1 preact: 10.18.1 - vite: 6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1) + vite: 6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -9789,6 +9797,10 @@ snapshots: dependencies: undici-types: 7.10.0 + '@types/node@24.3.1': + dependencies: + undici-types: 7.10.0 + '@types/request@2.48.13': dependencies: '@types/caseless': 0.12.5 @@ -9813,7 +9825,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-legacy@6.1.1(terser@5.43.1)(vite@6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1))': + '@vitejs/plugin-legacy@6.1.1(terser@5.43.1)(vite@6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.3 '@babel/preset-env': 7.28.3(@babel/core@7.28.3) @@ -9824,7 +9836,7 @@ snapshots: regenerator-runtime: 0.14.1 systemjs: 6.15.1 terser: 5.43.1 - vite: 6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1) + vite: 6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -9964,7 +9976,7 @@ snapshots: '@xtuc/long@4.2.2': {} - '@zenuml/core@3.36.0(@babel/core@7.28.3)(@babel/template@7.27.2)': + '@zenuml/core@3.40.1(@babel/core@7.28.3)(@babel/template@7.27.2)': dependencies: '@floating-ui/react': 0.27.16(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@headlessui/react': 2.2.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -9978,6 +9990,7 @@ snapshots: html-to-image: 1.11.13 immer: 10.1.1 jotai: 2.13.1(@babel/core@7.28.3)(@babel/template@7.27.2)(react@19.1.1) + lodash: 4.17.21 marked: 4.3.0 pako: 2.1.0 pino: 8.21.0 @@ -12000,7 +12013,7 @@ snapshots: object.pick: 1.3.0 parse-filepath: 1.0.2 - firebase-tools@13.35.1(@types/node@24.3.0)(encoding@0.1.13): + firebase-tools@13.35.1(@types/node@24.3.1)(encoding@0.1.13): dependencies: '@electric-sql/pglite': 0.2.17 '@google-cloud/cloud-sql-connector': 1.8.3 @@ -12032,8 +12045,8 @@ snapshots: gaxios: 6.7.1(encoding@0.1.13) glob: 10.4.5 google-auth-library: 9.15.1(encoding@0.1.13) - inquirer: 8.2.7(@types/node@24.3.0) - inquirer-autocomplete-prompt: 2.0.1(inquirer@8.2.7(@types/node@24.3.0)) + inquirer: 8.2.7(@types/node@24.3.1) + inquirer-autocomplete-prompt: 2.0.1(inquirer@8.2.7(@types/node@24.3.1)) js-yaml: 3.14.1 jsonwebtoken: 9.0.2 leven: 3.1.0 @@ -12862,18 +12875,18 @@ snapshots: ini@2.0.0: {} - inquirer-autocomplete-prompt@2.0.1(inquirer@8.2.7(@types/node@24.3.0)): + inquirer-autocomplete-prompt@2.0.1(inquirer@8.2.7(@types/node@24.3.1)): dependencies: ansi-escapes: 4.3.2 figures: 3.2.0 - inquirer: 8.2.7(@types/node@24.3.0) + inquirer: 8.2.7(@types/node@24.3.1) picocolors: 1.1.1 run-async: 2.4.1 rxjs: 7.8.2 - inquirer@8.2.7(@types/node@24.3.0): + inquirer@8.2.7(@types/node@24.3.1): dependencies: - '@inquirer/external-editor': 1.0.1(@types/node@24.3.0) + '@inquirer/external-editor': 1.0.1(@types/node@24.3.1) ansi-escapes: 4.3.2 chalk: 4.1.2 cli-cursor: 3.1.0 @@ -13556,7 +13569,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.3.0 + '@types/node': 24.3.1 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -16186,7 +16199,7 @@ snapshots: jest-worker: 27.5.1 schema-utils: 4.3.2 serialize-javascript: 6.0.2 - terser: 5.43.1 + terser: 5.44.0 webpack: 5.101.3 terser@5.43.1: @@ -16196,6 +16209,13 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + terser@5.44.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + test-exclude@5.2.3: dependencies: glob: 7.2.3 @@ -16641,7 +16661,7 @@ snapshots: remove-trailing-separator: 1.1.0 replace-ext: 1.0.1 - vite-prerender-plugin@0.5.11(vite@6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1)): + vite-prerender-plugin@0.5.11(vite@6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1)): dependencies: kolorist: 1.8.0 magic-string: 0.30.18 @@ -16649,9 +16669,9 @@ snapshots: simple-code-frame: 1.3.0 source-map: 0.7.6 stack-trace: 1.0.0-pre2 - vite: 6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1) + vite: 6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1) - vite@6.3.5(@types/node@24.3.0)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1): + vite@6.3.5(@types/node@24.3.1)(jiti@1.21.7)(terser@5.43.1)(yaml@2.8.1): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -16660,7 +16680,7 @@ snapshots: rollup: 4.48.0 tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 24.3.0 + '@types/node': 24.3.1 fsevents: 2.3.3 jiti: 1.21.7 terser: 5.43.1 diff --git a/src/components/app.jsx b/src/components/app.jsx index c290ee58..51353a4f 100644 --- a/src/components/app.jsx +++ b/src/components/app.jsx @@ -297,6 +297,10 @@ export default class App extends Component { }); } else { log('Load last unsaved item', lastCode); + // Ensure lastCode has an ID for URL display + if (!lastCode.id) { + lastCode.id = generateRandomId(); + } this.setCurrentItem(lastCode).then(() => this.refreshEditor()); } } else { @@ -371,7 +375,7 @@ export default class App extends Component { } } const fork = JSON.parse(JSON.stringify(sourceItem)); - delete fork.id; + fork.id = generateRandomId(); // Generate new ID for forked item fork.title = '(Forked) ' + sourceItem.title; fork.updatedOn = Date.now(); this.setCurrentItem(fork).then(() => this.refreshEditor()); @@ -381,7 +385,9 @@ export default class App extends Component { createNewItem() { var d = new Date(); + const newItemId = generateRandomId(); // Generate ID for new item this.setCurrentItem({ + id: newItemId, // Ensure new item has an ID title: 'Untitled ' + d.getDate() + @@ -448,6 +454,22 @@ BookLibService.Borrow(id) { mixpanel.track({ event: 'itemRemoved', category: 'fn' }); } + // Update URL to include diagram ID + updateUrlWithDiagramId(diagramId) { + if (window.zenumlDesktop) return; // Skip URL update for desktop app + + const url = new URL(window.location); + + if (diagramId) { + url.searchParams.set('id', diagramId); + } else { + url.searchParams.delete('id'); + } + + // Use replaceState to avoid adding to browser history + window.history.replaceState(null, '', url.toString()); + } + async setCurrentItem(item) { const d = deferred(); // TODO: remove later @@ -463,6 +485,10 @@ BookLibService.Borrow(id) { // Reset unsaved count, in UI also. await this.setState({ unsavedEditCount: 0 }); currentBrowserTab.setTitle(item.title); + + // Update URL with current diagram ID + this.updateUrlWithDiagramId(item.id); + return d.promise; } diff --git a/src/db.js b/src/db.js index 32da1147..e71a04f9 100644 --- a/src/db.js +++ b/src/db.js @@ -65,23 +65,27 @@ import { log } from './utils'; // timestampsInSnapshots: true // }; // db.settings(settings); - log('firebase db ready', db); + log('firebase db ready with persistence enabled', db); resolve(db); }) .catch(function (err) { - reject(err.code); + // Handle persistence failures gracefully by falling back to non-persistent mode + log('Firestore persistence failed, falling back to non-persistent mode:', err.code); + if (err.code === 'failed-precondition') { - // Multiple tabs open, persistence can only be enabled - // in one tab at a a time. - alert( - "Opening ZenUML web app in multiple tabs isn't supported at present and it seems like you already have it opened in another tab. Please use in one tab.", - ); - trackEvent('fn', 'multiTabError'); + // Multiple tabs open, persistence can only be enabled in one tab at a time. + // Instead of blocking, continue without persistence to allow multi-tab usage + log('Multiple tabs detected, running without persistence for this tab'); + trackEvent('fn', 'multiTabFallback'); // Changed from 'multiTabError' } else if (err.code === 'unimplemented') { - // The current browser does not support all of the - // features required to enable persistence - // ... + // The current browser does not support all of the features required to enable persistence + log('Persistence not supported in this browser, running without persistence'); } + + // Initialize Firestore without persistence - allows multi-tab usage + db = firebase.firestore(); + log('firebase db ready without persistence', db); + resolve(db); }); }); return dbPromise;