rita-workspace 0.5.21 → 0.5.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,11 +5,13 @@ Multi-drawing workspace feature for Rita (Excalidraw fork based on B310-digital/
5
5
  ## Features
6
6
 
7
7
  - **Multiple drawings** - Create and manage multiple drawings in one workspace
8
- - **Menu integration** - Seamlessly integrates with Excalidraw's hamburger menu
8
+ - **Folders** - Organize drawings in folders
9
9
  - **Auto-save** - All drawings saved locally in IndexedDB
10
- - **Auto-sync** - Automatic sync between workspace and Excalidraw canvas
11
- - **Rename & delete** - Full drawing management via dialog
10
+ - **Multi-tab conflict detection** - Prevents data loss when same drawing is open in multiple tabs
11
+ - **Workspace toggle** - Preview feature that can be enabled/disabled per browser tab
12
+ - **Export/Import** - Export workspace as JSON, import `.excalidraw` files
12
13
  - **i18n support** - Swedish and English with automatic Excalidraw language sync
14
+ - **Optimized loading** - DB pre-warming and parallel initialization
13
15
 
14
16
  ## Installation
15
17
 
@@ -21,127 +23,103 @@ yarn add rita-workspace
21
23
 
22
24
  ## Integration Guide
23
25
 
24
- Three files need to be modified in the B310/Excalidraw fork:
25
-
26
- ### 1. `excalidraw-app/App.tsx` - Add Provider and Bridge
27
-
28
- **Add imports:**
26
+ ### 1. `App.tsx` - Add Provider
29
27
 
30
28
  ```tsx
31
- import { WorkspaceProvider, WorkspaceBridge } from "rita-workspace";
29
+ import { WorkspaceProvider, useWorkspace, DrawingsDialog } from "rita-workspace";
30
+
31
+ const ExcalidrawApp = () => (
32
+ <WorkspaceProvider lang="sv">
33
+ <ExcalidrawWrapper />
34
+ </WorkspaceProvider>
35
+ );
32
36
  ```
33
37
 
34
- **Wrap ExcalidrawApp with WorkspaceProvider:**
38
+ ### 2. Use workspace in your component
35
39
 
36
40
  ```tsx
37
- const ExcalidrawApp = () => {
38
- return (
39
- <TopErrorBoundary>
40
- <Provider store={appJotaiStore}>
41
- <WorkspaceProvider lang="sv"> {/* <-- Add this */}
42
- <ExcalidrawWrapper />
43
- </WorkspaceProvider> {/* <-- And this */}
44
- </Provider>
45
- </TopErrorBoundary>
46
- );
41
+ const ExcalidrawWrapper = () => {
42
+ const {
43
+ activeDrawing,
44
+ saveCurrentDrawing,
45
+ saveDrawingById,
46
+ isDrawingConflict,
47
+ } = useWorkspace();
48
+
49
+ // Load drawing into canvas when activeDrawing changes
50
+ useEffect(() => {
51
+ if (!excalidrawAPI || !activeDrawing) return;
52
+ excalidrawAPI.updateScene({
53
+ elements: activeDrawing.elements || [],
54
+ appState: activeDrawing.appState || {},
55
+ });
56
+ }, [activeDrawing?.id]);
57
+
58
+ // Auto-save on canvas changes (debounced)
59
+ const onChange = (elements, appState, files) => {
60
+ if (activeDrawing && !isDrawingConflict) {
61
+ saveCurrentDrawing(elements, { viewBackgroundColor: appState.viewBackgroundColor }, files);
62
+ }
63
+ };
47
64
  };
48
65
  ```
49
66
 
50
- **Add WorkspaceBridge inside ExcalidrawWrapper** (this syncs the canvas automatically):
67
+ ### 3. Add DrawingsDialog for management UI
51
68
 
52
69
  ```tsx
53
- const ExcalidrawWrapper = () => {
54
- const [excalidrawAPI, excalidrawRefCallback] =
55
- useCallbackRefState<ExcalidrawImperativeAPI>();
56
-
57
- // ... existing code ...
58
-
59
- return (
60
- <div style={{ height: "100%" }}>
61
- {/* === ADD THIS - Auto-syncs workspace with Excalidraw === */}
62
- <WorkspaceBridge excalidrawAPI={excalidrawAPI} />
63
-
64
- <Excalidraw
65
- excalidrawAPI={excalidrawRefCallback}
66
- // ... rest of props ...
67
- />
68
- </div>
69
- );
70
- };
70
+ const [showDialog, setShowDialog] = useState(false);
71
+
72
+ <DrawingsDialog
73
+ open={showDialog}
74
+ onClose={() => setShowDialog(false)}
75
+ onDrawingSelect={() => setShowDialog(false)}
76
+ renderThumbnail={(drawing) => <DrawingThumbnail drawing={drawing} />}
77
+ />
71
78
  ```
72
79
 
73
- ### 2. `excalidraw-app/components/AppMainMenu.tsx` - Add Menu Items
80
+ ## Multi-Tab Conflict Detection
74
81
 
75
- **Add imports:**
82
+ When the same drawing is open in multiple browser tabs, the workspace automatically detects this and makes the later tab **read-only** to prevent data loss.
76
83
 
77
- ```tsx
78
- import React, { useState } from "react";
84
+ ### How it works
79
85
 
80
- import { WorkspaceMenuItems, DrawingsDialog } from "rita-workspace";
81
- import { LoadIcon } from "../components/icons"; // Excalidraw's folder icon
82
- ```
86
+ 1. Each tab registers itself with a unique `TAB_ID` in `localStorage`
87
+ 2. When a drawing is opened, the tab records which drawing it has and when it opened it
88
+ 3. If another tab already has the same drawing open (opened earlier), `isDrawingConflict` becomes `true`
89
+ 4. The conflicted tab is read-only — `saveCurrentDrawing` and `saveDrawingById` silently skip saves
90
+ 5. When the first tab closes or switches to another drawing, the conflict resolves automatically
83
91
 
84
- **Add state and menu items:**
92
+ ### External conflict check
85
93
 
86
94
  ```tsx
87
- export const AppMainMenu: React.FC<{...}> = React.memo((props) => {
88
- const [showDrawingsDialog, setShowDrawingsDialog] = useState(false);
89
-
90
- return (
91
- <>
92
- <MainMenu>
93
- <MainMenu.DefaultItems.LoadScene />
94
- <MainMenu.DefaultItems.SaveToActiveFile />
95
-
96
- {/* === RITA WORKSPACE === */}
97
- <MainMenu.Sub>
98
- <MainMenu.Sub.Trigger>{LoadIcon} Arbetsyta</MainMenu.Sub.Trigger>
99
- <MainMenu.Sub.Content>
100
- <WorkspaceMenuItems
101
- onManageDrawings={() => setShowDrawingsDialog(true)}
102
- />
103
- </MainMenu.Sub.Content>
104
- </MainMenu.Sub>
105
-
106
- <MainMenu.DefaultItems.Export />
107
- {/* ... rest of menu items ... */}
108
- </MainMenu>
109
-
110
- <DrawingsDialog
111
- open={showDrawingsDialog}
112
- onClose={() => setShowDrawingsDialog(false)}
113
- />
114
- </>
115
- );
116
- });
95
+ import { isDrawingOpenedEarlierInOtherTab } from "rita-workspace";
96
+
97
+ // Returns true if another tab opened this drawing before the current tab
98
+ if (isDrawingOpenedEarlierInOtherTab(drawingId)) {
99
+ // Don't save — another tab owns this drawing
100
+ }
117
101
  ```
118
102
 
119
- ## How It Works
103
+ ### Communication between tabs
120
104
 
121
- 1. **WorkspaceProvider** - Manages workspace state (drawings list, active drawing)
122
- 2. **WorkspaceBridge** - Automatically syncs between workspace and Excalidraw:
123
- - Loads drawing into canvas when you switch drawings
124
- - Auto-saves canvas changes back to workspace
125
- - Saves current drawing before switching to another
126
- 3. **WorkspaceMenuItems** - Provides the menu UI for switching drawings
127
- 4. **DrawingsDialog** - Full management UI (rename, delete, create)
105
+ - **BroadcastChannel** (`rita-workspace-tabs`) instant notification when tabs open/close/switch drawings
106
+ - **localStorage** (`rita-workspace-tabs`) persistent tab registry, backup for BroadcastChannel
107
+ - **Stale tab cleanup** on mount, pings other tabs via BroadcastChannel and removes entries that don't respond
128
108
 
129
- ## Language Support (i18n)
109
+ ## Workspace Toggle (Preview Feature)
130
110
 
131
- Pass Excalidraw's `langCode` to `WorkspaceProvider`:
111
+ The workspace can be enabled/disabled per browser tab using `sessionStorage`:
132
112
 
133
113
  ```tsx
134
- const [langCode] = useAppLangCode();
135
-
136
- <WorkspaceProvider lang={langCode}>
137
- {/* All components automatically use the same language */}
138
- </WorkspaceProvider>
114
+ // Each tab reads its own toggle state
115
+ const [workspaceEnabled] = useState(() =>
116
+ sessionStorage.getItem("rita-workspace-enabled") === "true"
117
+ );
139
118
  ```
140
119
 
141
- | Code | Language |
142
- |------|----------|
143
- | `sv`, `sv-SE` | 🇸🇪 Swedish |
144
- | `en`, `en-US` | 🇬🇧 English (default) |
120
+ - Default: **off** (each new tab starts without workspace)
121
+ - State stored in `sessionStorage` (not shared between tabs)
122
+ - When disabled: auto-save to workspace skipped, drawing-switch disabled, footer hidden
145
123
 
146
124
  ## API Reference
147
125
 
@@ -149,71 +127,96 @@ const [langCode] = useAppLangCode();
149
127
 
150
128
  | Component | Description |
151
129
  |-----------|-------------|
152
- | `WorkspaceProvider` | React context provider. Props: `lang` |
153
- | `WorkspaceBridge` | Auto-syncs workspace ↔ Excalidraw. Props: `excalidrawAPI`, `autoSaveInterval` |
154
- | `WorkspaceMenuItems` | Menu items for MainMenu. Props: `onManageDrawings`, `lang` |
155
- | `DrawingsDialog` | Management dialog. Props: `open`, `onClose`, `lang` |
130
+ | `WorkspaceProvider` | React context provider. Props: `lang`, `children` |
131
+ | `DrawingsDialog` | Management dialog. Props: `open`, `onClose`, `onDrawingSelect`, `renderThumbnail` |
156
132
 
157
133
  ### Hooks
158
134
 
159
- | Hook | Description |
160
- |------|-------------|
161
- | `useWorkspace()` | Access workspace state and actions |
162
- | `useWorkspaceLang()` | Get current language and translations |
135
+ | Hook | Returns |
136
+ |------|---------|
137
+ | `useWorkspace()` | Full workspace state and actions |
138
+ | `useWorkspaceLang()` | `{ lang, t }` — current language and translations |
139
+
140
+ ### Exported functions
141
+
142
+ | Function | Description |
143
+ |----------|-------------|
144
+ | `isDrawingOpenedEarlierInOtherTab(id)` | Check if another tab has this drawing open |
145
+ | `warmDB()` | Pre-warm IndexedDB connection (called automatically at import) |
163
146
 
164
147
  ### useWorkspace() returns
165
148
 
166
149
  ```tsx
167
150
  const {
168
151
  // State
169
- drawings, // Drawing[] - all drawings
170
- activeDrawing, // Drawing | null - currently active
152
+ workspace, // Workspace | null
153
+ drawings, // Drawing[]
154
+ folders, // Folder[]
155
+ activeDrawing, // Drawing | null
171
156
  isLoading, // boolean
172
157
  error, // string | null
173
- lang, // string - current language code
174
- t, // Translations object
175
-
176
- // Actions
177
- createNewDrawing, // (name?: string) => Promise<Drawing>
178
- switchDrawing, // (id: string) => Promise<void>
179
- renameDrawing, // (id: string, name: string) => Promise<void>
180
- removeDrawing, // (id: string) => Promise<void>
158
+ isDrawingConflict, // boolean — true if read-only (another tab has this drawing)
159
+ lang, // string
160
+ t, // Translations
161
+
162
+ // Drawing actions
163
+ createNewDrawing, // (name?, folderId?) => Promise<Drawing | null>
164
+ switchDrawing, // (id) => Promise<void>
165
+ renameDrawing, // (id, name) => Promise<void>
166
+ removeDrawing, // (id) => Promise<void>
167
+ duplicateCurrentDrawing, // () => Promise<Drawing | null>
168
+
169
+ // Folder actions
170
+ createFolder, // (name) => Promise<Folder | null>
171
+ renameFolder, // (id, name) => Promise<void>
172
+ deleteFolder, // (id) => Promise<void>
173
+ moveDrawingToFolder, // (drawingId, folderId) => Promise<void>
174
+
175
+ // Save (blocked if drawing is in conflict)
181
176
  saveCurrentDrawing, // (elements, appState, files?) => Promise<void>
177
+ saveDrawingById, // (id, elements, appState, files?) => Promise<void>
178
+
179
+ // Utilities
180
+ refreshDrawings, // () => Promise<void>
181
+ exportWorkspace, // () => Promise<void>
182
+ importWorkspace, // () => Promise<void>
183
+ exportDrawingAsExcalidraw, // (id) => Promise<void>
184
+ importExcalidrawFile, // () => Promise<void>
182
185
  } = useWorkspace();
183
186
  ```
184
187
 
185
- ### WorkspaceBridge Props
186
-
187
- ```tsx
188
- <WorkspaceBridge
189
- excalidrawAPI={excalidrawAPI} // Required - from Excalidraw
190
- autoSaveInterval={2000} // Optional - ms between saves (default: 2000)
191
- onDrawingLoad={(id) => {}} // Optional - called when drawing loads
192
- onDrawingSave={(id) => {}} // Optional - called when drawing saves
193
- />
194
- ```
195
-
196
188
  ## Data Storage
197
189
 
198
- Drawings are stored in IndexedDB:
190
+ Drawings are stored in **IndexedDB** (`rita-workspace` database, version 2):
199
191
 
200
192
  ```typescript
201
193
  interface Drawing {
202
- id: string;
194
+ id: string; // nanoid
203
195
  name: string;
204
- elements: ExcalidrawElement[];
196
+ folderId: string | null;
197
+ elements: unknown[]; // Excalidraw elements
205
198
  appState: Record<string, unknown>;
206
- files: Record<string, unknown>;
199
+ files: Record<string, unknown>; // Image files
207
200
  createdAt: number;
208
201
  updatedAt: number;
209
202
  }
210
203
  ```
211
204
 
212
- ## Links
205
+ ## Language Support
206
+
207
+ | Code | Language |
208
+ |------|----------|
209
+ | `sv`, `sv-SE` | Swedish |
210
+ | `en`, `en-US` | English (default) |
213
211
 
214
- - **npm:** https://www.npmjs.com/package/rita-workspace
215
- - **B310 Excalidraw Fork:** https://github.com/b310-digital/excalidraw
216
- - **Original Excalidraw:** https://github.com/excalidraw/excalidraw
212
+ ## Development
213
+
214
+ ```bash
215
+ yarn build # Build with tsup (cjs + esm + dts)
216
+ yarn dev # Watch mode
217
+ yarn test # Run tests with vitest
218
+ yarn typecheck # TypeScript check
219
+ ```
217
220
 
218
221
  ## License
219
222
 
package/dist/index.d.mts CHANGED
@@ -138,7 +138,7 @@ interface WorkspaceContextValue {
138
138
  isDrawingConflict: boolean;
139
139
  lang: string;
140
140
  t: Translations;
141
- createNewDrawing: (name?: string, folderId?: string | null) => Promise<Drawing | null>;
141
+ createNewDrawing: (name?: string, folderId?: string | null, activate?: boolean) => Promise<Drawing | null>;
142
142
  switchDrawing: (id: string) => Promise<void>;
143
143
  renameDrawing: (id: string, name: string) => Promise<void>;
144
144
  removeDrawing: (id: string) => Promise<void>;
@@ -153,6 +153,7 @@ interface WorkspaceContextValue {
153
153
  exportWorkspace: () => Promise<void>;
154
154
  importWorkspace: () => Promise<void>;
155
155
  exportDrawingAsExcalidraw: (id: string) => Promise<void>;
156
+ exportAllDrawingsAsExcalidraw: () => Promise<void>;
156
157
  importExcalidrawFile: () => Promise<void>;
157
158
  }
158
159
  declare function useWorkspace(): WorkspaceContextValue;
package/dist/index.d.ts CHANGED
@@ -138,7 +138,7 @@ interface WorkspaceContextValue {
138
138
  isDrawingConflict: boolean;
139
139
  lang: string;
140
140
  t: Translations;
141
- createNewDrawing: (name?: string, folderId?: string | null) => Promise<Drawing | null>;
141
+ createNewDrawing: (name?: string, folderId?: string | null, activate?: boolean) => Promise<Drawing | null>;
142
142
  switchDrawing: (id: string) => Promise<void>;
143
143
  renameDrawing: (id: string, name: string) => Promise<void>;
144
144
  removeDrawing: (id: string) => Promise<void>;
@@ -153,6 +153,7 @@ interface WorkspaceContextValue {
153
153
  exportWorkspace: () => Promise<void>;
154
154
  importWorkspace: () => Promise<void>;
155
155
  exportDrawingAsExcalidraw: (id: string) => Promise<void>;
156
+ exportAllDrawingsAsExcalidraw: () => Promise<void>;
156
157
  importExcalidrawFile: () => Promise<void>;
157
158
  }
158
159
  declare function useWorkspace(): WorkspaceContextValue;
package/dist/index.js CHANGED
@@ -431,9 +431,30 @@ function useWorkspaceLang() {
431
431
  }
432
432
  return { lang: context.lang, t: context.t };
433
433
  }
434
- var TAB_ID = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
434
+ var TAB_ID_KEY = "rita-workspace-tab-id";
435
+ var TAB_ID = (() => {
436
+ try {
437
+ const existing = sessionStorage.getItem(TAB_ID_KEY);
438
+ if (existing) return existing;
439
+ } catch {
440
+ }
441
+ const fresh = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
442
+ try {
443
+ sessionStorage.setItem(TAB_ID_KEY, fresh);
444
+ } catch {
445
+ }
446
+ return fresh;
447
+ })();
435
448
  var TABS_KEY = "rita-workspace-tabs";
436
449
  var TAB_CHANNEL = "rita-workspace-tabs";
450
+ function broadcastWorkspaceChange() {
451
+ try {
452
+ const channel = new BroadcastChannel(TAB_CHANNEL);
453
+ channel.postMessage({ type: "workspace-changed", tabId: TAB_ID });
454
+ channel.close();
455
+ } catch {
456
+ }
457
+ }
437
458
  var tabsMapCache = null;
438
459
  var tabsMapRaw = null;
439
460
  function getTabsMap() {
@@ -456,16 +477,34 @@ function getTabsMap() {
456
477
  return {};
457
478
  }
458
479
  }
480
+ var TAB_ENTRY_KEY = "rita-workspace-tab-entry";
459
481
  function setTabDrawing(drawingId) {
460
482
  const tabs = getTabsMap();
461
483
  if (drawingId) {
462
484
  const existing = tabs[TAB_ID];
485
+ let openedAt;
463
486
  if (existing && existing.drawingId === drawingId) {
487
+ openedAt = existing.openedAt;
464
488
  } else {
465
- tabs[TAB_ID] = { drawingId, openedAt: Date.now() };
489
+ let sessionEntry = null;
490
+ try {
491
+ const raw = sessionStorage.getItem(TAB_ENTRY_KEY);
492
+ if (raw) sessionEntry = JSON.parse(raw);
493
+ } catch {
494
+ }
495
+ openedAt = sessionEntry && sessionEntry.drawingId === drawingId ? sessionEntry.openedAt : Date.now();
496
+ }
497
+ tabs[TAB_ID] = { drawingId, openedAt };
498
+ try {
499
+ sessionStorage.setItem(TAB_ENTRY_KEY, JSON.stringify(tabs[TAB_ID]));
500
+ } catch {
466
501
  }
467
502
  } else {
468
503
  delete tabs[TAB_ID];
504
+ try {
505
+ sessionStorage.removeItem(TAB_ENTRY_KEY);
506
+ } catch {
507
+ }
469
508
  }
470
509
  const json = JSON.stringify(tabs);
471
510
  localStorage.setItem(TABS_KEY, json);
@@ -562,6 +601,8 @@ function WorkspaceProvider({ children, lang = "en" }) {
562
601
  channel.onmessage = (event) => {
563
602
  if (event.data?.type === "ping") {
564
603
  channel?.postMessage({ type: "pong", tabId: TAB_ID });
604
+ } else if (event.data?.type === "workspace-changed" && event.data?.tabId !== TAB_ID) {
605
+ refreshDrawingsRef.current();
565
606
  }
566
607
  };
567
608
  } catch {
@@ -699,11 +740,16 @@ function WorkspaceProvider({ children, lang = "en" }) {
699
740
  const refreshDrawings = (0, import_react.useCallback)(async () => {
700
741
  if (!workspace) return;
701
742
  try {
702
- const [allDrawings, allFolders] = await Promise.all([
743
+ const [freshWorkspace, allDrawings, allFolders] = await Promise.all([
744
+ getWorkspace(workspace.id),
703
745
  getAllDrawings(),
704
746
  getAllFolders()
705
747
  ]);
706
- const wsDrawings = allDrawings.filter((d) => workspace.drawingIds.includes(d.id));
748
+ const drawingIds = freshWorkspace?.drawingIds || workspace.drawingIds;
749
+ const wsDrawings = allDrawings.filter((d) => drawingIds.includes(d.id));
750
+ if (freshWorkspace) {
751
+ setWorkspace((prev) => prev ? { ...freshWorkspace, activeDrawingId: prev.activeDrawingId } : freshWorkspace);
752
+ }
707
753
  setDrawings(wsDrawings);
708
754
  setFolders(allFolders);
709
755
  } catch (err) {
@@ -715,7 +761,9 @@ function WorkspaceProvider({ children, lang = "en" }) {
715
761
  foldersRef.current = folders;
716
762
  const activeDrawingIdRef = (0, import_react.useRef)(activeDrawing?.id ?? null);
717
763
  activeDrawingIdRef.current = activeDrawing?.id ?? null;
718
- const createNewDrawing = (0, import_react.useCallback)(async (name, folderId) => {
764
+ const refreshDrawingsRef = (0, import_react.useRef)(refreshDrawings);
765
+ refreshDrawingsRef.current = refreshDrawings;
766
+ const createNewDrawing = (0, import_react.useCallback)(async (name, folderId, activate = true) => {
719
767
  if (!workspace) return null;
720
768
  const now = Date.now();
721
769
  const tempId = `temp-${now}`;
@@ -732,19 +780,24 @@ function WorkspaceProvider({ children, lang = "en" }) {
732
780
  updatedAt: now
733
781
  };
734
782
  setDrawings((prev) => [...prev, tempDrawing]);
735
- setActiveDrawing2(tempDrawing);
736
- sessionStorage.setItem("rita-workspace-tab-drawing", tempId);
783
+ if (activate) {
784
+ setActiveDrawing2(tempDrawing);
785
+ sessionStorage.setItem("rita-workspace-tab-drawing", tempId);
786
+ }
737
787
  try {
738
788
  const drawing = await createDrawing(name || defaultName, [], {}, folderId);
739
789
  await addDrawingToWorkspace(workspace.id, drawing.id);
740
790
  setDrawings((prev) => prev.map((d) => d.id === tempId ? drawing : d));
741
- setActiveDrawing2(drawing);
791
+ if (activate) {
792
+ setActiveDrawing2(drawing);
793
+ sessionStorage.setItem("rita-workspace-tab-drawing", drawing.id);
794
+ }
742
795
  setWorkspace((prev) => prev ? {
743
796
  ...prev,
744
797
  drawingIds: [...prev.drawingIds, drawing.id],
745
- activeDrawingId: drawing.id
798
+ ...activate ? { activeDrawingId: drawing.id } : {}
746
799
  } : null);
747
- sessionStorage.setItem("rita-workspace-tab-drawing", drawing.id);
800
+ broadcastWorkspaceChange();
748
801
  return drawing;
749
802
  } catch (err) {
750
803
  setDrawings((prev) => prev.filter((d) => d.id !== tempId));
@@ -772,6 +825,7 @@ function WorkspaceProvider({ children, lang = "en" }) {
772
825
  }
773
826
  try {
774
827
  await updateDrawing(id, { name });
828
+ broadcastWorkspaceChange();
775
829
  } catch (err) {
776
830
  refreshDrawings();
777
831
  setError(err instanceof Error ? err.message : "Failed to rename drawing");
@@ -793,6 +847,7 @@ function WorkspaceProvider({ children, lang = "en" }) {
793
847
  if (updatedWorkspace) {
794
848
  setWorkspace(updatedWorkspace);
795
849
  }
850
+ broadcastWorkspaceChange();
796
851
  } catch (err) {
797
852
  if (removedDrawing) {
798
853
  setDrawings((prev) => [...prev, removedDrawing]);
@@ -823,6 +878,7 @@ function WorkspaceProvider({ children, lang = "en" }) {
823
878
  ...prev,
824
879
  drawingIds: [...prev.drawingIds, duplicate.id]
825
880
  } : null);
881
+ broadcastWorkspaceChange();
826
882
  return duplicate;
827
883
  }
828
884
  setDrawings((prev) => prev.filter((d) => d.id !== tempId));
@@ -954,6 +1010,36 @@ function WorkspaceProvider({ children, lang = "en" }) {
954
1010
  setError(err instanceof Error ? err.message : "Failed to export drawing");
955
1011
  }
956
1012
  }, [drawings]);
1013
+ const exportAllDrawingsAsExcalidraw = (0, import_react.useCallback)(async () => {
1014
+ try {
1015
+ const all = await getAllDrawings();
1016
+ const wsDrawings = workspace ? all.filter((d) => workspace.drawingIds.includes(d.id)) : all;
1017
+ for (let i = 0; i < wsDrawings.length; i++) {
1018
+ const drawing = wsDrawings[i];
1019
+ const excalidrawData = {
1020
+ type: "excalidraw",
1021
+ version: 2,
1022
+ source: "rita-workspace",
1023
+ elements: drawing.elements || [],
1024
+ appState: { viewBackgroundColor: "#ffffff", ...drawing.appState || {} },
1025
+ files: drawing.files || {}
1026
+ };
1027
+ const blob = new Blob([JSON.stringify(excalidrawData, null, 2)], { type: "application/json" });
1028
+ const url = URL.createObjectURL(blob);
1029
+ const a = document.createElement("a");
1030
+ a.href = url;
1031
+ const safeName = (drawing.name || "ritning").replace(/[\\/:*?"<>|]/g, "_");
1032
+ a.download = `${String(i + 1).padStart(2, "0")}_${safeName}.excalidraw`;
1033
+ document.body.appendChild(a);
1034
+ a.click();
1035
+ document.body.removeChild(a);
1036
+ URL.revokeObjectURL(url);
1037
+ await new Promise((r) => setTimeout(r, 150));
1038
+ }
1039
+ } catch (err) {
1040
+ setError(err instanceof Error ? err.message : "Failed to export all drawings");
1041
+ }
1042
+ }, [workspace]);
957
1043
  const importExcalidrawFile = (0, import_react.useCallback)(async () => {
958
1044
  if (!workspace) return;
959
1045
  try {
@@ -1068,6 +1154,7 @@ function WorkspaceProvider({ children, lang = "en" }) {
1068
1154
  exportWorkspace,
1069
1155
  importWorkspace,
1070
1156
  exportDrawingAsExcalidraw,
1157
+ exportAllDrawingsAsExcalidraw,
1071
1158
  importExcalidrawFile
1072
1159
  };
1073
1160
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(WorkspaceContext.Provider, { value, children });
@@ -1432,6 +1519,7 @@ var DrawingsDialog = ({
1432
1519
  exportWorkspace,
1433
1520
  importWorkspace,
1434
1521
  exportDrawingAsExcalidraw,
1522
+ exportAllDrawingsAsExcalidraw,
1435
1523
  importExcalidrawFile,
1436
1524
  refreshDrawings,
1437
1525
  t: contextT,
@@ -1515,9 +1603,8 @@ var DrawingsDialog = ({
1515
1603
  setSwitchingId(null);
1516
1604
  }, [switchDrawing, onDrawingSelect]);
1517
1605
  const handleCreate = (0, import_react5.useCallback)(async (folderId) => {
1518
- const newDrawing = await createNewDrawing(void 0, folderId);
1519
- if (newDrawing) onDrawingSelect?.(newDrawing);
1520
- }, [createNewDrawing, onDrawingSelect]);
1606
+ await createNewDrawing(void 0, folderId, false);
1607
+ }, [createNewDrawing]);
1521
1608
  const handleStartEdit = (0, import_react5.useCallback)((drawing) => {
1522
1609
  setEditingId(drawing.id);
1523
1610
  setEditName(drawing.name);
@@ -2244,6 +2331,15 @@ var DrawingsDialog = ({
2244
2331
  }
2245
2332
  }
2246
2333
  ),
2334
+ /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
2335
+ ActionButton,
2336
+ {
2337
+ icon: "\u{1F4E6}",
2338
+ label: "Exportera alla som .excalidraw",
2339
+ description: "Laddar ner varje ritning som separat fil",
2340
+ onClick: exportAllDrawingsAsExcalidraw
2341
+ }
2342
+ ),
2247
2343
  /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
2248
2344
  ActionButton,
2249
2345
  {
package/dist/index.mjs CHANGED
@@ -360,9 +360,30 @@ function useWorkspaceLang() {
360
360
  }
361
361
  return { lang: context.lang, t: context.t };
362
362
  }
363
- var TAB_ID = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
363
+ var TAB_ID_KEY = "rita-workspace-tab-id";
364
+ var TAB_ID = (() => {
365
+ try {
366
+ const existing = sessionStorage.getItem(TAB_ID_KEY);
367
+ if (existing) return existing;
368
+ } catch {
369
+ }
370
+ const fresh = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
371
+ try {
372
+ sessionStorage.setItem(TAB_ID_KEY, fresh);
373
+ } catch {
374
+ }
375
+ return fresh;
376
+ })();
364
377
  var TABS_KEY = "rita-workspace-tabs";
365
378
  var TAB_CHANNEL = "rita-workspace-tabs";
379
+ function broadcastWorkspaceChange() {
380
+ try {
381
+ const channel = new BroadcastChannel(TAB_CHANNEL);
382
+ channel.postMessage({ type: "workspace-changed", tabId: TAB_ID });
383
+ channel.close();
384
+ } catch {
385
+ }
386
+ }
366
387
  var tabsMapCache = null;
367
388
  var tabsMapRaw = null;
368
389
  function getTabsMap() {
@@ -385,16 +406,34 @@ function getTabsMap() {
385
406
  return {};
386
407
  }
387
408
  }
409
+ var TAB_ENTRY_KEY = "rita-workspace-tab-entry";
388
410
  function setTabDrawing(drawingId) {
389
411
  const tabs = getTabsMap();
390
412
  if (drawingId) {
391
413
  const existing = tabs[TAB_ID];
414
+ let openedAt;
392
415
  if (existing && existing.drawingId === drawingId) {
416
+ openedAt = existing.openedAt;
393
417
  } else {
394
- tabs[TAB_ID] = { drawingId, openedAt: Date.now() };
418
+ let sessionEntry = null;
419
+ try {
420
+ const raw = sessionStorage.getItem(TAB_ENTRY_KEY);
421
+ if (raw) sessionEntry = JSON.parse(raw);
422
+ } catch {
423
+ }
424
+ openedAt = sessionEntry && sessionEntry.drawingId === drawingId ? sessionEntry.openedAt : Date.now();
425
+ }
426
+ tabs[TAB_ID] = { drawingId, openedAt };
427
+ try {
428
+ sessionStorage.setItem(TAB_ENTRY_KEY, JSON.stringify(tabs[TAB_ID]));
429
+ } catch {
395
430
  }
396
431
  } else {
397
432
  delete tabs[TAB_ID];
433
+ try {
434
+ sessionStorage.removeItem(TAB_ENTRY_KEY);
435
+ } catch {
436
+ }
398
437
  }
399
438
  const json = JSON.stringify(tabs);
400
439
  localStorage.setItem(TABS_KEY, json);
@@ -491,6 +530,8 @@ function WorkspaceProvider({ children, lang = "en" }) {
491
530
  channel.onmessage = (event) => {
492
531
  if (event.data?.type === "ping") {
493
532
  channel?.postMessage({ type: "pong", tabId: TAB_ID });
533
+ } else if (event.data?.type === "workspace-changed" && event.data?.tabId !== TAB_ID) {
534
+ refreshDrawingsRef.current();
494
535
  }
495
536
  };
496
537
  } catch {
@@ -628,11 +669,16 @@ function WorkspaceProvider({ children, lang = "en" }) {
628
669
  const refreshDrawings = useCallback(async () => {
629
670
  if (!workspace) return;
630
671
  try {
631
- const [allDrawings, allFolders] = await Promise.all([
672
+ const [freshWorkspace, allDrawings, allFolders] = await Promise.all([
673
+ getWorkspace(workspace.id),
632
674
  getAllDrawings(),
633
675
  getAllFolders()
634
676
  ]);
635
- const wsDrawings = allDrawings.filter((d) => workspace.drawingIds.includes(d.id));
677
+ const drawingIds = freshWorkspace?.drawingIds || workspace.drawingIds;
678
+ const wsDrawings = allDrawings.filter((d) => drawingIds.includes(d.id));
679
+ if (freshWorkspace) {
680
+ setWorkspace((prev) => prev ? { ...freshWorkspace, activeDrawingId: prev.activeDrawingId } : freshWorkspace);
681
+ }
636
682
  setDrawings(wsDrawings);
637
683
  setFolders(allFolders);
638
684
  } catch (err) {
@@ -644,7 +690,9 @@ function WorkspaceProvider({ children, lang = "en" }) {
644
690
  foldersRef.current = folders;
645
691
  const activeDrawingIdRef = useRef(activeDrawing?.id ?? null);
646
692
  activeDrawingIdRef.current = activeDrawing?.id ?? null;
647
- const createNewDrawing = useCallback(async (name, folderId) => {
693
+ const refreshDrawingsRef = useRef(refreshDrawings);
694
+ refreshDrawingsRef.current = refreshDrawings;
695
+ const createNewDrawing = useCallback(async (name, folderId, activate = true) => {
648
696
  if (!workspace) return null;
649
697
  const now = Date.now();
650
698
  const tempId = `temp-${now}`;
@@ -661,19 +709,24 @@ function WorkspaceProvider({ children, lang = "en" }) {
661
709
  updatedAt: now
662
710
  };
663
711
  setDrawings((prev) => [...prev, tempDrawing]);
664
- setActiveDrawing2(tempDrawing);
665
- sessionStorage.setItem("rita-workspace-tab-drawing", tempId);
712
+ if (activate) {
713
+ setActiveDrawing2(tempDrawing);
714
+ sessionStorage.setItem("rita-workspace-tab-drawing", tempId);
715
+ }
666
716
  try {
667
717
  const drawing = await createDrawing(name || defaultName, [], {}, folderId);
668
718
  await addDrawingToWorkspace(workspace.id, drawing.id);
669
719
  setDrawings((prev) => prev.map((d) => d.id === tempId ? drawing : d));
670
- setActiveDrawing2(drawing);
720
+ if (activate) {
721
+ setActiveDrawing2(drawing);
722
+ sessionStorage.setItem("rita-workspace-tab-drawing", drawing.id);
723
+ }
671
724
  setWorkspace((prev) => prev ? {
672
725
  ...prev,
673
726
  drawingIds: [...prev.drawingIds, drawing.id],
674
- activeDrawingId: drawing.id
727
+ ...activate ? { activeDrawingId: drawing.id } : {}
675
728
  } : null);
676
- sessionStorage.setItem("rita-workspace-tab-drawing", drawing.id);
729
+ broadcastWorkspaceChange();
677
730
  return drawing;
678
731
  } catch (err) {
679
732
  setDrawings((prev) => prev.filter((d) => d.id !== tempId));
@@ -701,6 +754,7 @@ function WorkspaceProvider({ children, lang = "en" }) {
701
754
  }
702
755
  try {
703
756
  await updateDrawing(id, { name });
757
+ broadcastWorkspaceChange();
704
758
  } catch (err) {
705
759
  refreshDrawings();
706
760
  setError(err instanceof Error ? err.message : "Failed to rename drawing");
@@ -722,6 +776,7 @@ function WorkspaceProvider({ children, lang = "en" }) {
722
776
  if (updatedWorkspace) {
723
777
  setWorkspace(updatedWorkspace);
724
778
  }
779
+ broadcastWorkspaceChange();
725
780
  } catch (err) {
726
781
  if (removedDrawing) {
727
782
  setDrawings((prev) => [...prev, removedDrawing]);
@@ -752,6 +807,7 @@ function WorkspaceProvider({ children, lang = "en" }) {
752
807
  ...prev,
753
808
  drawingIds: [...prev.drawingIds, duplicate.id]
754
809
  } : null);
810
+ broadcastWorkspaceChange();
755
811
  return duplicate;
756
812
  }
757
813
  setDrawings((prev) => prev.filter((d) => d.id !== tempId));
@@ -883,6 +939,36 @@ function WorkspaceProvider({ children, lang = "en" }) {
883
939
  setError(err instanceof Error ? err.message : "Failed to export drawing");
884
940
  }
885
941
  }, [drawings]);
942
+ const exportAllDrawingsAsExcalidraw = useCallback(async () => {
943
+ try {
944
+ const all = await getAllDrawings();
945
+ const wsDrawings = workspace ? all.filter((d) => workspace.drawingIds.includes(d.id)) : all;
946
+ for (let i = 0; i < wsDrawings.length; i++) {
947
+ const drawing = wsDrawings[i];
948
+ const excalidrawData = {
949
+ type: "excalidraw",
950
+ version: 2,
951
+ source: "rita-workspace",
952
+ elements: drawing.elements || [],
953
+ appState: { viewBackgroundColor: "#ffffff", ...drawing.appState || {} },
954
+ files: drawing.files || {}
955
+ };
956
+ const blob = new Blob([JSON.stringify(excalidrawData, null, 2)], { type: "application/json" });
957
+ const url = URL.createObjectURL(blob);
958
+ const a = document.createElement("a");
959
+ a.href = url;
960
+ const safeName = (drawing.name || "ritning").replace(/[\\/:*?"<>|]/g, "_");
961
+ a.download = `${String(i + 1).padStart(2, "0")}_${safeName}.excalidraw`;
962
+ document.body.appendChild(a);
963
+ a.click();
964
+ document.body.removeChild(a);
965
+ URL.revokeObjectURL(url);
966
+ await new Promise((r) => setTimeout(r, 150));
967
+ }
968
+ } catch (err) {
969
+ setError(err instanceof Error ? err.message : "Failed to export all drawings");
970
+ }
971
+ }, [workspace]);
886
972
  const importExcalidrawFile = useCallback(async () => {
887
973
  if (!workspace) return;
888
974
  try {
@@ -997,6 +1083,7 @@ function WorkspaceProvider({ children, lang = "en" }) {
997
1083
  exportWorkspace,
998
1084
  importWorkspace,
999
1085
  exportDrawingAsExcalidraw,
1086
+ exportAllDrawingsAsExcalidraw,
1000
1087
  importExcalidrawFile
1001
1088
  };
1002
1089
  return /* @__PURE__ */ jsx(WorkspaceContext.Provider, { value, children });
@@ -1361,6 +1448,7 @@ var DrawingsDialog = ({
1361
1448
  exportWorkspace,
1362
1449
  importWorkspace,
1363
1450
  exportDrawingAsExcalidraw,
1451
+ exportAllDrawingsAsExcalidraw,
1364
1452
  importExcalidrawFile,
1365
1453
  refreshDrawings,
1366
1454
  t: contextT,
@@ -1444,9 +1532,8 @@ var DrawingsDialog = ({
1444
1532
  setSwitchingId(null);
1445
1533
  }, [switchDrawing, onDrawingSelect]);
1446
1534
  const handleCreate = useCallback2(async (folderId) => {
1447
- const newDrawing = await createNewDrawing(void 0, folderId);
1448
- if (newDrawing) onDrawingSelect?.(newDrawing);
1449
- }, [createNewDrawing, onDrawingSelect]);
1535
+ await createNewDrawing(void 0, folderId, false);
1536
+ }, [createNewDrawing]);
1450
1537
  const handleStartEdit = useCallback2((drawing) => {
1451
1538
  setEditingId(drawing.id);
1452
1539
  setEditName(drawing.name);
@@ -2173,6 +2260,15 @@ var DrawingsDialog = ({
2173
2260
  }
2174
2261
  }
2175
2262
  ),
2263
+ /* @__PURE__ */ jsx6(
2264
+ ActionButton,
2265
+ {
2266
+ icon: "\u{1F4E6}",
2267
+ label: "Exportera alla som .excalidraw",
2268
+ description: "Laddar ner varje ritning som separat fil",
2269
+ onClick: exportAllDrawingsAsExcalidraw
2270
+ }
2271
+ ),
2176
2272
  /* @__PURE__ */ jsx6(
2177
2273
  ActionButton,
2178
2274
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rita-workspace",
3
- "version": "0.5.21",
3
+ "version": "0.5.23",
4
4
  "description": "Multi-drawing workspace feature for Rita (Excalidraw fork)",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",