@bendyline/docblocks-react 1.0.0 → 1.1.0

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.
Files changed (92) hide show
  1. package/dist/AppMenu/AppMenu.d.ts +6 -1
  2. package/dist/AppMenu/AppMenu.d.ts.map +1 -1
  3. package/dist/AppMenu/AppMenu.js +3 -2
  4. package/dist/AppMenu/AppMenu.js.map +1 -1
  5. package/dist/AppMenu/index.d.ts +1 -1
  6. package/dist/AppMenu/index.d.ts.map +1 -1
  7. package/dist/DocBlocksShell/DocBlocksShell.d.ts +1 -1
  8. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
  9. package/dist/DocBlocksShell/DocBlocksShell.js +410 -73
  10. package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
  11. package/dist/FileExplorer/FileTreeNode.d.ts.map +1 -1
  12. package/dist/FileExplorer/FileTreeNode.js +34 -8
  13. package/dist/FileExplorer/FileTreeNode.js.map +1 -1
  14. package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +1 -1
  15. package/dist/WorkspacePicker/WorkspacePicker.js +9 -3
  16. package/dist/WorkspacePicker/WorkspacePicker.js.map +1 -1
  17. package/dist/icons.d.ts +1 -0
  18. package/dist/icons.d.ts.map +1 -1
  19. package/dist/icons.js +3 -0
  20. package/dist/icons.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/package.json +8 -8
  24. package/src/AppMenu/AppMenu.tsx +78 -1
  25. package/src/AppMenu/index.ts +1 -1
  26. package/src/DocBlocksShell/DocBlocksShell.tsx +480 -87
  27. package/src/FileExplorer/FileTreeNode.tsx +68 -26
  28. package/src/WorkspacePicker/WorkspacePicker.tsx +18 -7
  29. package/src/fonts/fonts.css +362 -0
  30. package/src/fonts/licenses/LICENSE-cormorant-garamond.txt +93 -0
  31. package/src/fonts/licenses/LICENSE-crimson-text.txt +93 -0
  32. package/src/fonts/licenses/LICENSE-dm-sans.txt +93 -0
  33. package/src/fonts/licenses/LICENSE-dm-serif-display.txt +93 -0
  34. package/src/fonts/licenses/LICENSE-hanken-grotesk.txt +94 -0
  35. package/src/fonts/licenses/LICENSE-ibm-plex-sans.txt +93 -0
  36. package/src/fonts/licenses/LICENSE-inter.txt +93 -0
  37. package/src/fonts/licenses/LICENSE-jetbrains-mono.txt +93 -0
  38. package/src/fonts/licenses/LICENSE-lora.txt +93 -0
  39. package/src/fonts/licenses/LICENSE-merriweather.txt +93 -0
  40. package/src/fonts/licenses/LICENSE-oswald.txt +93 -0
  41. package/src/fonts/licenses/LICENSE-playfair-display.txt +93 -0
  42. package/src/fonts/licenses/LICENSE-pt-serif.txt +93 -0
  43. package/src/fonts/licenses/LICENSE-roboto.txt +93 -0
  44. package/src/fonts/licenses/LICENSE-source-serif-4.txt +93 -0
  45. package/src/fonts/woff2/cormorant-garamond-400.woff2 +0 -0
  46. package/src/fonts/woff2/cormorant-garamond-700.woff2 +0 -0
  47. package/src/fonts/woff2/crimson-text-400.woff2 +0 -0
  48. package/src/fonts/woff2/crimson-text-400i.woff2 +0 -0
  49. package/src/fonts/woff2/crimson-text-700.woff2 +0 -0
  50. package/src/fonts/woff2/dm-sans-400.woff2 +0 -0
  51. package/src/fonts/woff2/dm-sans-400i.woff2 +0 -0
  52. package/src/fonts/woff2/dm-sans-500.woff2 +0 -0
  53. package/src/fonts/woff2/dm-sans-700.woff2 +0 -0
  54. package/src/fonts/woff2/dm-serif-display-400.woff2 +0 -0
  55. package/src/fonts/woff2/hanken-grotesk-400.woff2 +0 -0
  56. package/src/fonts/woff2/hanken-grotesk-400i.woff2 +0 -0
  57. package/src/fonts/woff2/hanken-grotesk-500.woff2 +0 -0
  58. package/src/fonts/woff2/hanken-grotesk-600.woff2 +0 -0
  59. package/src/fonts/woff2/hanken-grotesk-700.woff2 +0 -0
  60. package/src/fonts/woff2/ibm-plex-sans-400.woff2 +0 -0
  61. package/src/fonts/woff2/ibm-plex-sans-400i.woff2 +0 -0
  62. package/src/fonts/woff2/ibm-plex-sans-500.woff2 +0 -0
  63. package/src/fonts/woff2/ibm-plex-sans-600.woff2 +0 -0
  64. package/src/fonts/woff2/ibm-plex-sans-700.woff2 +0 -0
  65. package/src/fonts/woff2/inter-400.woff2 +0 -0
  66. package/src/fonts/woff2/inter-400i.woff2 +0 -0
  67. package/src/fonts/woff2/inter-500.woff2 +0 -0
  68. package/src/fonts/woff2/inter-600.woff2 +0 -0
  69. package/src/fonts/woff2/inter-700.woff2 +0 -0
  70. package/src/fonts/woff2/jetbrains-mono-400.woff2 +0 -0
  71. package/src/fonts/woff2/jetbrains-mono-400i.woff2 +0 -0
  72. package/src/fonts/woff2/jetbrains-mono-700.woff2 +0 -0
  73. package/src/fonts/woff2/lora-400.woff2 +0 -0
  74. package/src/fonts/woff2/lora-400i.woff2 +0 -0
  75. package/src/fonts/woff2/lora-700.woff2 +0 -0
  76. package/src/fonts/woff2/merriweather-400.woff2 +0 -0
  77. package/src/fonts/woff2/merriweather-700.woff2 +0 -0
  78. package/src/fonts/woff2/oswald-400.woff2 +0 -0
  79. package/src/fonts/woff2/oswald-700.woff2 +0 -0
  80. package/src/fonts/woff2/playfair-display-400.woff2 +0 -0
  81. package/src/fonts/woff2/playfair-display-700.woff2 +0 -0
  82. package/src/fonts/woff2/pt-serif-400.woff2 +0 -0
  83. package/src/fonts/woff2/pt-serif-700.woff2 +0 -0
  84. package/src/fonts/woff2/roboto-400.woff2 +0 -0
  85. package/src/fonts/woff2/roboto-400i.woff2 +0 -0
  86. package/src/fonts/woff2/roboto-700.woff2 +0 -0
  87. package/src/fonts/woff2/source-serif-4-400.woff2 +0 -0
  88. package/src/fonts/woff2/source-serif-4-400i.woff2 +0 -0
  89. package/src/fonts/woff2/source-serif-4-700.woff2 +0 -0
  90. package/src/icons.tsx +10 -0
  91. package/src/index.ts +1 -1
  92. package/src/styles/docblocks.css +128 -13
@@ -11,15 +11,18 @@ import type { EditorTheme, EditorView } from '@bendyline/squisq-editor-react';
11
11
  import '@bendyline/squisq-editor-react/styles';
12
12
  import { MediaContext } from '@bendyline/squisq-react';
13
13
  import type { MediaProvider } from '@bendyline/squisq/schemas';
14
- import { createMediaProviderFromContainer } from '@bendyline/squisq/storage';
15
14
  import type { FileSystemProvider, FileSystemEntry } from '@bendyline/docblocks/filesystem';
16
15
  import {
17
16
  IndexedDBFileSystemProvider,
18
- IndexedDBContentContainer,
17
+ ElectronFileSystemProvider,
18
+ FileSystemContentContainer,
19
+ createFileMediaProvider,
19
20
  openNativeFolder,
20
21
  restoreNativeFolder,
21
22
  removeDirectoryHandle,
22
23
  } from '@bendyline/docblocks/filesystem';
24
+ import type { ContentContainer } from '@bendyline/squisq/storage';
25
+ import { isElectronHost, getDocblocksHost } from '@bendyline/docblocks/host';
23
26
  import type { WorkspaceDescriptor } from '@bendyline/docblocks/workspace';
24
27
  import {
25
28
  ensureDefaultWorkspace,
@@ -29,7 +32,7 @@ import {
29
32
  saveWorkspace,
30
33
  touchWorkspace,
31
34
  } from '@bendyline/docblocks/workspace';
32
- import { AppMenu } from '../AppMenu/AppMenu.js';
35
+ import { AppMenu, type ThemePreference } from '../AppMenu/AppMenu.js';
33
36
  import { FileExplorer } from '../FileExplorer/FileExplorer.js';
34
37
  import { WorkspacePicker } from '../WorkspacePicker/WorkspacePicker.js';
35
38
  import { WorkspaceSettingsButton } from '../WorkspacePicker/WorkspaceSettingsButton.js';
@@ -79,6 +82,64 @@ function parseHash(): { workspaceId: string; filePath: string | null } | null {
79
82
  };
80
83
  }
81
84
 
85
+ const LAST_STATE_KEY = 'docblocks:lastState';
86
+
87
+ interface LastState {
88
+ workspaceId: string;
89
+ filePath: string;
90
+ view: EditorView;
91
+ }
92
+
93
+ function saveLastState(state: LastState): void {
94
+ try {
95
+ localStorage.setItem(LAST_STATE_KEY, JSON.stringify(state));
96
+ } catch {
97
+ // ignore quota errors
98
+ }
99
+ }
100
+
101
+ function loadLastState(): LastState | null {
102
+ try {
103
+ const raw = localStorage.getItem(LAST_STATE_KEY);
104
+ if (!raw) return null;
105
+ return JSON.parse(raw);
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ const THEME_PREF_KEY = 'docblocks:themePreference';
112
+
113
+ function loadThemePreference(): ThemePreference {
114
+ try {
115
+ const raw = localStorage.getItem(THEME_PREF_KEY);
116
+ if (raw === 'light' || raw === 'dark' || raw === 'auto') return raw;
117
+ } catch {
118
+ // ignore
119
+ }
120
+ return 'auto';
121
+ }
122
+
123
+ function saveThemePreference(pref: ThemePreference): void {
124
+ try {
125
+ localStorage.setItem(THEME_PREF_KEY, pref);
126
+ } catch {
127
+ // ignore quota errors
128
+ }
129
+ }
130
+
131
+ function dirnameOf(p: string): string {
132
+ const clean = p.replace(/^\/+/, '');
133
+ const idx = clean.lastIndexOf('/');
134
+ return idx === -1 ? '' : clean.slice(0, idx);
135
+ }
136
+
137
+ function basenameOf(p: string): string {
138
+ const clean = p.replace(/^\/+/, '');
139
+ const idx = clean.lastIndexOf('/');
140
+ return idx === -1 ? clean : clean.slice(idx + 1);
141
+ }
142
+
82
143
  function useIsMobile(breakpoint = 768): boolean {
83
144
  const [isMobile, setIsMobile] = useState(
84
145
  () =>
@@ -93,9 +154,49 @@ function useIsMobile(breakpoint = 768): boolean {
93
154
  return isMobile;
94
155
  }
95
156
 
96
- export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps) {
157
+ function FolderGlyph() {
158
+ return (
159
+ <svg
160
+ viewBox="0 0 24 24"
161
+ fill="none"
162
+ stroke="currentColor"
163
+ strokeWidth="1.5"
164
+ strokeLinejoin="round"
165
+ aria-hidden="true"
166
+ >
167
+ <path d="M3 7a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7z" />
168
+ </svg>
169
+ );
170
+ }
171
+
172
+ function FileGlyph() {
173
+ return (
174
+ <svg
175
+ viewBox="0 0 24 24"
176
+ fill="none"
177
+ stroke="currentColor"
178
+ strokeWidth="1.5"
179
+ strokeLinejoin="round"
180
+ aria-hidden="true"
181
+ >
182
+ <path d="M6 3h8l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" />
183
+ <path d="M14 3v5h5" />
184
+ </svg>
185
+ );
186
+ }
187
+
188
+ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlocksShellProps) {
97
189
  const osTheme = useOsTheme();
98
- const resolvedTheme: 'light' | 'dark' = theme === 'auto' ? osTheme : theme;
190
+ const [themePreference, setThemePreference] = useState<ThemePreference>(loadThemePreference);
191
+ // "System default" (auto) always follows the OS — the host's theme prop
192
+ // is kept only for API back-compat and does not override OS detection.
193
+ const resolvedTheme: 'light' | 'dark' =
194
+ themePreference === 'light' || themePreference === 'dark' ? themePreference : osTheme;
195
+
196
+ const handleThemeChange = useCallback((pref: ThemePreference) => {
197
+ setThemePreference(pref);
198
+ saveThemePreference(pref);
199
+ }, []);
99
200
  const isMobile = useIsMobile();
100
201
  const [mobileShowEditor, setMobileShowEditor] = useState(false);
101
202
  const [provider, setProvider] = useState<FileSystemProvider | null>(null);
@@ -110,17 +211,15 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
110
211
  /** Suppress popstate handling during programmatic navigation. */
111
212
  const skipPopState = useRef(false);
112
213
 
113
- /** Persistent media container backed by IndexedDB (survives page refresh). */
114
- const mediaContainerRef = useRef<IndexedDBContentContainer | null>(null);
214
+ /**
215
+ * Per-file media container: for `notes.md`, images live in
216
+ * `notes_files/` next to the markdown. Created lazily on first write,
217
+ * picked up automatically if it already exists. Set up by the effect
218
+ * below whenever the selected file (or backing provider) changes.
219
+ */
220
+ const mediaContainerRef = useRef<ContentContainer | null>(null);
115
221
  const [mediaProvider, setMediaProvider] = useState<MediaProvider | null>(null);
116
222
 
117
- /** Set up persistent media container for a workspace. */
118
- const setupMediaContainer = useCallback((workspaceId: string) => {
119
- const mc = new IndexedDBContentContainer(workspaceId);
120
- mediaContainerRef.current = mc;
121
- setMediaProvider(createMediaProviderFromContainer(mc));
122
- }, []);
123
-
124
223
  /** Push a new history entry with the given hash. */
125
224
  const pushHash = useCallback((wsId: string, filePath?: string | null) => {
126
225
  const hash = buildHash(wsId, filePath);
@@ -130,17 +229,31 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
130
229
  }
131
230
  }, []);
132
231
 
133
- /** Open a workspace (and optionally a file) from identifiers. */
232
+ /** Open a workspace (and optionally a file) from identifiers.
233
+ * Returns the FileSystemProvider on success, or null on failure. */
134
234
  const openFromIds = useCallback(
135
- async (wsId: string, filePath: string | null, push: boolean) => {
235
+ async (
236
+ wsId: string,
237
+ filePath: string | null,
238
+ push: boolean,
239
+ view?: EditorView,
240
+ ): Promise<FileSystemProvider | null> => {
136
241
  const workspaces = await listWorkspaces();
137
242
  const ws = workspaces.find((w) => w.id === wsId);
138
- if (!ws) return false;
243
+ if (!ws) return null;
139
244
 
140
245
  let fsProvider: FileSystemProvider | null = null;
141
- if (ws.type === 'native') {
246
+ if (ws.type === 'electron-native') {
247
+ if (!ws.rootPath) return null;
248
+ await getDocblocksHost().workspaces.register({
249
+ id: ws.id,
250
+ name: ws.name,
251
+ rootPath: ws.rootPath,
252
+ });
253
+ fsProvider = new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
254
+ } else if (ws.type === 'native') {
142
255
  const restored = await restoreNativeFolder(ws.id);
143
- if (!restored) return false;
256
+ if (!restored) return null;
144
257
  fsProvider = restored;
145
258
  } else {
146
259
  fsProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
@@ -149,8 +262,6 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
149
262
  setProvider(fsProvider);
150
263
  setActiveWorkspaceId(ws.id);
151
264
 
152
- setupMediaContainer(ws.id);
153
-
154
265
  if (filePath) {
155
266
  const content = await fsProvider.readFile(filePath);
156
267
  if (content !== null) {
@@ -158,8 +269,10 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
158
269
  setSelectedFolder(null);
159
270
  setFolderEntries([]);
160
271
  setEditorContent(content);
161
- setInitialView('wysiwyg');
272
+ const effectiveView = view ?? 'wysiwyg';
273
+ setInitialView(effectiveView);
162
274
  setEditorKey((k) => k + 1);
275
+ saveLastState({ workspaceId: wsId, filePath, view: effectiveView });
163
276
  } else {
164
277
  setSelectedFile(null);
165
278
  setSelectedFolder(null);
@@ -178,9 +291,9 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
178
291
  if (push) {
179
292
  pushHash(ws.id, filePath);
180
293
  }
181
- return true;
294
+ return fsProvider;
182
295
  },
183
- [pushHash, setupMediaContainer],
296
+ [pushHash],
184
297
  );
185
298
 
186
299
  const seedWelcomeFile = useCallback(
@@ -191,16 +304,18 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
191
304
  if (
192
305
  entries.length === 1 &&
193
306
  entries[0].kind === 'file' &&
194
- entries[0].path === '/aboutDocblocks.md'
307
+ entries[0].path.replace(/^\//, '') === 'aboutDocblocks.md'
195
308
  ) {
196
- const content = await fs.readFile('/aboutDocblocks.md');
309
+ const aboutPath = entries[0].path;
310
+ const content = await fs.readFile(aboutPath);
197
311
  if (content !== null) {
198
- setSelectedFile('/aboutDocblocks.md');
312
+ setSelectedFile(aboutPath);
199
313
  setEditorContent(content);
200
314
  setInitialView('preview');
201
315
  setEditorKey((k) => k + 1);
202
316
  setExplorerKey((k) => k + 1);
203
- pushHash(fs.id, '/aboutDocblocks.md');
317
+ pushHash(fs.id, aboutPath);
318
+ saveLastState({ workspaceId: fs.id, filePath: aboutPath, view: 'preview' });
204
319
  }
205
320
  return;
206
321
  }
@@ -239,6 +354,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
239
354
  setEditorKey((k) => k + 1);
240
355
  setExplorerKey((k) => k + 1);
241
356
  pushHash(fs.id, welcomePath);
357
+ saveLastState({ workspaceId: fs.id, filePath: welcomePath, view: 'preview' });
242
358
  },
243
359
  [pushHash],
244
360
  );
@@ -249,27 +365,66 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
249
365
  // Try restoring from URL hash first
250
366
  const hashState = parseHash();
251
367
  if (hashState) {
252
- const ok = await openFromIds(hashState.workspaceId, hashState.filePath, false);
253
- if (ok) return;
368
+ const restoredProvider = await openFromIds(
369
+ hashState.workspaceId,
370
+ hashState.filePath,
371
+ false,
372
+ );
373
+ if (restoredProvider) {
374
+ // If the hash had no file, check whether we should auto-select the welcome doc
375
+ if (!hashState.filePath) {
376
+ await seedWelcomeFile(restoredProvider);
377
+ }
378
+ return;
379
+ }
380
+ }
381
+
382
+ // Try restoring last viewed document from localStorage
383
+ const lastState = loadLastState();
384
+ if (lastState) {
385
+ const restoredProvider = await openFromIds(
386
+ lastState.workspaceId,
387
+ lastState.filePath,
388
+ true,
389
+ lastState.view,
390
+ );
391
+ if (restoredProvider) return;
254
392
  }
255
393
 
256
394
  let fsProvider: FileSystemProvider | null = null;
257
395
 
396
+ const electron = isElectronHost();
258
397
  const workspaces = await listWorkspaces();
398
+ // On desktop, hide web-only workspaces (indexeddb/native) — only
399
+ // folder-based workspaces are valid.
400
+ const candidates = electron
401
+ ? workspaces.filter((w) => w.type === 'electron-native')
402
+ : workspaces.filter((w) => w.type !== 'electron-native');
259
403
  // Pick the most recently opened workspace
260
- const sorted = [...workspaces].sort((a, b) =>
404
+ const sorted = [...candidates].sort((a, b) =>
261
405
  (b.lastOpened ?? '').localeCompare(a.lastOpened ?? ''),
262
406
  );
263
407
  for (const ws of sorted) {
264
- if (ws.type === 'native') {
408
+ if (ws.type === 'electron-native') {
409
+ if (!ws.rootPath) continue;
410
+ await getDocblocksHost().workspaces.register({
411
+ id: ws.id,
412
+ name: ws.name,
413
+ rootPath: ws.rootPath,
414
+ });
415
+ const p = new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
416
+ await touchWorkspace(ws.id);
417
+ fsProvider = p;
418
+ setProvider(p);
419
+ setActiveWorkspaceId(ws.id);
420
+ break;
421
+ } else if (ws.type === 'native') {
265
422
  const restored = await restoreNativeFolder(ws.id);
266
423
  if (restored) {
267
424
  await touchWorkspace(ws.id);
268
425
  fsProvider = restored;
269
426
  setProvider(restored);
270
427
  setActiveWorkspaceId(ws.id);
271
-
272
- setupMediaContainer(ws.id);
273
428
  break;
274
429
  }
275
430
  } else {
@@ -278,21 +433,35 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
278
433
  fsProvider = p;
279
434
  setProvider(p);
280
435
  setActiveWorkspaceId(ws.id);
281
-
282
- setupMediaContainer(ws.id);
283
436
  break;
284
437
  }
285
438
  }
286
439
 
287
440
  if (!fsProvider) {
288
- // No workspaces yet — create default
289
- const defaultWs = await ensureDefaultWorkspace();
290
- const p = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
291
- fsProvider = p;
292
- setProvider(p);
293
- setActiveWorkspaceId(defaultWs.id);
294
-
295
- setupMediaContainer(defaultWs.id);
441
+ if (electron) {
442
+ // Desktop: ask the host for the default folder workspace
443
+ // (creates ~/Documents/DocBlocks on first launch).
444
+ const info = await getDocblocksHost().workspaces.getDefault();
445
+ const descriptor: WorkspaceDescriptor = {
446
+ id: info.id,
447
+ name: info.name,
448
+ type: 'electron-native',
449
+ rootPath: info.rootPath,
450
+ lastOpened: new Date().toISOString(),
451
+ };
452
+ await saveWorkspace(descriptor);
453
+ const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
454
+ fsProvider = p;
455
+ setProvider(p);
456
+ setActiveWorkspaceId(info.id);
457
+ } else {
458
+ // Web: create the IndexedDB default workspace
459
+ const defaultWs = await ensureDefaultWorkspace();
460
+ const p = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
461
+ fsProvider = p;
462
+ setProvider(p);
463
+ setActiveWorkspaceId(defaultWs.id);
464
+ }
296
465
  }
297
466
 
298
467
  // Set initial hash
@@ -301,7 +470,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
301
470
  // Seed welcome file if workspace is empty
302
471
  await seedWelcomeFile(fsProvider);
303
472
  })();
304
- }, [openFromIds, pushHash, seedWelcomeFile, setupMediaContainer]);
473
+ }, [openFromIds, pushHash, seedWelcomeFile]);
305
474
 
306
475
  // Handle browser back/forward
307
476
  useEffect(() => {
@@ -319,23 +488,86 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
319
488
  return () => window.removeEventListener('popstate', onPopState);
320
489
  }, [openFromIds]);
321
490
 
491
+ // Track view mode changes (Editor/Raw/Play tabs) and persist to localStorage
492
+ useEffect(() => {
493
+ const handler = (e: MouseEvent) => {
494
+ const target = (e.target as HTMLElement).closest?.('[data-view]');
495
+ if (target) {
496
+ const view = target.getAttribute('data-view') as EditorView;
497
+ if (view && activeWorkspaceId && selectedFile) {
498
+ saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view });
499
+ }
500
+ }
501
+ };
502
+ window.addEventListener('click', handler, true);
503
+ return () => window.removeEventListener('click', handler, true);
504
+ }, [activeWorkspaceId, selectedFile]);
505
+
322
506
  // Auto-save current file
323
507
  useAutoSave(provider, selectedFile, editorContent);
324
508
 
509
+ // Per-file media: for `notes.md` images live in `notes_files/` beside it.
510
+ // Rebuilds whenever the provider or selected file changes.
511
+ useEffect(() => {
512
+ if (!provider || !selectedFile) {
513
+ mediaContainerRef.current = null;
514
+ setMediaProvider(null);
515
+ return;
516
+ }
517
+ const parentDir = dirnameOf(selectedFile);
518
+ const base = basenameOf(selectedFile);
519
+ const container = new FileSystemContentContainer(provider, parentDir);
520
+ const mp = createFileMediaProvider(container, base);
521
+ mediaContainerRef.current = container;
522
+ setMediaProvider(mp);
523
+ return () => {
524
+ mp.dispose();
525
+ };
526
+ }, [provider, selectedFile]);
527
+
528
+ // React to external file changes watched by the Electron host (chokidar).
529
+ useEffect(() => {
530
+ if (!isElectronHost()) return;
531
+ if (!provider || !(provider instanceof ElectronFileSystemProvider)) return;
532
+ const unwatch = provider.watch(() => {
533
+ setExplorerKey((k) => k + 1);
534
+ // If the open file's contents changed on disk, reload it (best-effort).
535
+ if (selectedFile) {
536
+ (async () => {
537
+ const content = await provider.readFile(selectedFile);
538
+ if (content !== null && content !== editorContent) {
539
+ setEditorContent(content);
540
+ setEditorKey((k) => k + 1);
541
+ }
542
+ })();
543
+ }
544
+ });
545
+ return unwatch;
546
+ }, [provider, selectedFile, editorContent]);
547
+
325
548
  const handleWorkspaceSelect = useCallback(
326
549
  async (ws: WorkspaceDescriptor) => {
327
550
  await touchWorkspace(ws.id);
328
- if (ws.type === 'native') {
551
+ let nextProvider: FileSystemProvider | null = null;
552
+ if (ws.type === 'electron-native') {
553
+ if (!ws.rootPath) return;
554
+ await getDocblocksHost().workspaces.register({
555
+ id: ws.id,
556
+ name: ws.name,
557
+ rootPath: ws.rootPath,
558
+ });
559
+ nextProvider = new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
560
+ } else if (ws.type === 'native') {
329
561
  const restored = await restoreNativeFolder(ws.id);
330
- if (restored) {
331
- setProvider(restored);
332
- } else {
562
+ if (!restored) {
333
563
  // Permission denied or handle lost — fall through without changing provider
334
564
  return;
335
565
  }
566
+ nextProvider = restored;
336
567
  } else {
337
- setProvider(new IndexedDBFileSystemProvider(ws.id, ws.name));
568
+ nextProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
338
569
  }
570
+ setProvider(nextProvider);
339
571
  setActiveWorkspaceId(ws.id);
340
572
  setSelectedFile(null);
341
573
  setSelectedFolder(null);
@@ -343,14 +575,35 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
343
575
  setEditorContent('');
344
576
  setEditorKey((k) => k + 1);
345
577
  pushHash(ws.id, null);
346
-
347
- setupMediaContainer(ws.id);
348
578
  },
349
- [pushHash, setupMediaContainer],
579
+ [pushHash],
350
580
  );
351
581
 
352
582
  const handleOpenFolder = useCallback(async () => {
353
583
  try {
584
+ if (isElectronHost()) {
585
+ const info = await getDocblocksHost().workspaces.pickFolder();
586
+ if (!info) return; // user cancelled
587
+ const descriptor: WorkspaceDescriptor = {
588
+ id: info.id,
589
+ name: info.name,
590
+ type: 'electron-native',
591
+ rootPath: info.rootPath,
592
+ lastOpened: new Date().toISOString(),
593
+ };
594
+ await saveWorkspace(descriptor);
595
+ const provider = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
596
+ setProvider(provider);
597
+ setActiveWorkspaceId(descriptor.id);
598
+ setSelectedFile(null);
599
+ setSelectedFolder(null);
600
+ setFolderEntries([]);
601
+ setEditorContent('');
602
+ setEditorKey((k) => k + 1);
603
+ pushHash(descriptor.id, null);
604
+ return;
605
+ }
606
+
354
607
  const nativeProvider = await openNativeFolder();
355
608
  const descriptor: WorkspaceDescriptor = {
356
609
  id: nativeProvider.id,
@@ -367,12 +620,108 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
367
620
  setEditorContent('');
368
621
  setEditorKey((k) => k + 1);
369
622
  pushHash(descriptor.id, null);
370
-
371
- setupMediaContainer(descriptor.id);
372
623
  } catch {
373
624
  // User cancelled or API not supported
374
625
  }
375
- }, [pushHash, setupMediaContainer]);
626
+ }, [pushHash]);
627
+
628
+ const handleNewFile = useCallback(async () => {
629
+ if (!provider) return;
630
+ const name = prompt('New document name:', 'Untitled.md');
631
+ if (!name) return;
632
+ const filename = /\.[a-zA-Z0-9]+$/.test(name) ? name : `${name}.md`;
633
+ await provider.writeFile(filename, '# ' + filename.replace(/\.[^.]+$/, '') + '\n');
634
+ setSelectedFile('/' + filename);
635
+ setEditorContent('# ' + filename.replace(/\.[^.]+$/, '') + '\n');
636
+ setInitialView('wysiwyg');
637
+ setEditorKey((k) => k + 1);
638
+ setExplorerKey((k) => k + 1);
639
+ if (activeWorkspaceId) {
640
+ pushHash(activeWorkspaceId, '/' + filename);
641
+ }
642
+ }, [provider, activeWorkspaceId, pushHash]);
643
+
644
+ const handleRevealWorkspace = useCallback(async () => {
645
+ if (!isElectronHost() || !activeWorkspaceId) return;
646
+ const ws = await getWorkspace(activeWorkspaceId);
647
+ if (ws?.type === 'electron-native' && ws.rootPath) {
648
+ await getDocblocksHost().shell.revealInFolder(ws.rootPath);
649
+ }
650
+ }, [activeWorkspaceId]);
651
+
652
+ // Subscribe to native menu commands (Electron host).
653
+ useEffect(() => {
654
+ if (!isElectronHost()) return;
655
+ const host = getDocblocksHost();
656
+ return host.onMenuCommand((cmd) => {
657
+ switch (cmd) {
658
+ case 'file:new':
659
+ handleNewFile();
660
+ break;
661
+ case 'file:openFolder':
662
+ handleOpenFolder();
663
+ break;
664
+ case 'file:revealWorkspace':
665
+ handleRevealWorkspace();
666
+ break;
667
+ case 'help:viewOnGitHub':
668
+ host.shell.openExternal('https://github.com/bendyline/docblocks');
669
+ break;
670
+ case 'help:checkForUpdates':
671
+ host.updater.checkForUpdates().catch(() => {
672
+ // errors surface via updater.onStatus
673
+ });
674
+ break;
675
+ case 'help:about':
676
+ (async () => {
677
+ const version = await host.updater.getVersion();
678
+ alert(`DocBlocks ${version}`);
679
+ })();
680
+ break;
681
+ default:
682
+ break;
683
+ }
684
+ });
685
+ }, [handleNewFile, handleOpenFolder, handleRevealWorkspace]);
686
+
687
+ // Subscribe to open-file / deep-link requests from the OS.
688
+ useEffect(() => {
689
+ if (!isElectronHost()) return;
690
+ const host = getDocblocksHost();
691
+ return host.onOpenRequest(async (req) => {
692
+ if (req.filePath) {
693
+ const workspaces = (await listWorkspaces()).filter(
694
+ (w) => w.type === 'electron-native' && w.rootPath,
695
+ );
696
+ const match = workspaces.find(
697
+ (w) => req.filePath!.startsWith((w.rootPath ?? '') + '/') || req.filePath === w.rootPath,
698
+ );
699
+ if (match && match.rootPath) {
700
+ const rel = '/' + req.filePath.slice(match.rootPath.length).replace(/^\/+/, '');
701
+ await openFromIds(match.id, rel, true);
702
+ }
703
+ } else if (req.url) {
704
+ try {
705
+ const u = new URL(req.url);
706
+ const path = u.searchParams.get('path');
707
+ if (path) {
708
+ const workspaces = (await listWorkspaces()).filter(
709
+ (w) => w.type === 'electron-native' && w.rootPath,
710
+ );
711
+ const match = workspaces.find(
712
+ (w) => path.startsWith((w.rootPath ?? '') + '/') || path === w.rootPath,
713
+ );
714
+ if (match && match.rootPath) {
715
+ const rel = '/' + path.slice(match.rootPath.length).replace(/^\/+/, '');
716
+ await openFromIds(match.id, rel, true);
717
+ }
718
+ }
719
+ } catch {
720
+ // bad URL, ignore
721
+ }
722
+ }
723
+ });
724
+ }, [openFromIds]);
376
725
 
377
726
  const handleSelect = useCallback(
378
727
  async (path: string, kind: 'file' | 'directory') => {
@@ -395,6 +744,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
395
744
  setInitialView('wysiwyg');
396
745
  setEditorKey((k) => k + 1);
397
746
  pushHash(activeWorkspaceId, path);
747
+ saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
398
748
  if (isMobile) setMobileShowEditor(true);
399
749
  }
400
750
  },
@@ -419,21 +769,30 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
419
769
  }
420
770
  }, [provider, selectedFile, selectedFolder, activeWorkspaceId, pushHash]);
421
771
 
422
- /** Copy non-markdown files from an imported container into persistent media storage. */
423
- const persistContainerMedia = useCallback(
424
- async (source: {
425
- listFiles(): Promise<Array<{ path: string; mimeType: string }>>;
426
- readFile(path: string): Promise<ArrayBuffer | null>;
427
- }) => {
428
- const mc = mediaContainerRef.current;
429
- if (!mc) return;
772
+ /**
773
+ * Copy non-markdown files from an imported container into the target
774
+ * markdown file's `_files/` sibling folder, preserving the source's
775
+ * relative structure.
776
+ */
777
+ const persistImportedMedia = useCallback(
778
+ async (
779
+ source: {
780
+ listFiles(): Promise<Array<{ path: string; mimeType: string }>>;
781
+ readFile(path: string): Promise<ArrayBuffer | null>;
782
+ },
783
+ target: FileSystemProvider,
784
+ importedMarkdownPath: string,
785
+ ) => {
786
+ const parentDir = dirnameOf(importedMarkdownPath);
787
+ const folder = basenameOf(importedMarkdownPath).replace(/\.[^.]+$/, '') + '_files';
788
+ const mediaRoot = parentDir ? `${parentDir}/${folder}` : folder;
430
789
  const entries = await source.listFiles();
431
790
  for (const entry of entries) {
432
791
  if (entry.path.endsWith('.md')) continue;
433
792
  const data = await source.readFile(entry.path);
434
- if (data) {
435
- await mc.writeFile(entry.path, new Uint8Array(data), entry.mimeType);
436
- }
793
+ if (!data) continue;
794
+ const cleanPath = entry.path.replace(/^\/+/, '');
795
+ await target.writeBinary(`${mediaRoot}/${cleanPath}`, new Uint8Array(data));
437
796
  }
438
797
  },
439
798
  [],
@@ -455,17 +814,17 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
455
814
  const { docxToContainer } = await import('@bendyline/squisq-formats/docx');
456
815
  const container = await docxToContainer(await file.arrayBuffer());
457
816
  markdown = (await container.readDocument()) ?? '';
458
- await persistContainerMedia(container);
817
+ await persistImportedMedia(container, provider, destPath);
459
818
  } else if (ext === '.pdf') {
460
819
  const { pdfToContainer } = await import('@bendyline/squisq-formats/pdf');
461
820
  const container = await pdfToContainer(await file.arrayBuffer());
462
821
  markdown = (await container.readDocument()) ?? '';
463
- await persistContainerMedia(container);
822
+ await persistImportedMedia(container, provider, destPath);
464
823
  } else if (ext === '.dbk' || ext === '.zip') {
465
824
  const { zipToContainer } = await import('@bendyline/squisq-formats/container');
466
825
  const container = await zipToContainer(await file.arrayBuffer());
467
826
  markdown = (await container.readDocument()) ?? '';
468
- await persistContainerMedia(container);
827
+ await persistImportedMedia(container, provider, destPath);
469
828
  } else {
470
829
  continue;
471
830
  }
@@ -475,15 +834,10 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
475
834
  console.error(`Failed to import ${file.name}:`, err);
476
835
  }
477
836
  }
478
- // Refresh media provider once after all imports (not per-file)
479
- const mc = mediaContainerRef.current;
480
- if (mc) {
481
- setMediaProvider(createMediaProviderFromContainer(mc));
482
- }
483
- // Refresh file tree
837
+ // Refresh file tree so the new markdown files + _files folders show up.
484
838
  setExplorerKey((k) => k + 1);
485
839
  },
486
- [provider, persistContainerMedia],
840
+ [provider, persistImportedMedia],
487
841
  );
488
842
 
489
843
  const handleEditorChange = useCallback((source: string) => {
@@ -499,7 +853,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
499
853
  await saveWorkspace({ ...ws, name: newName });
500
854
  // Bump key to trigger re-render — workspace name is read from the descriptor, not the provider
501
855
  setEditorKey((k) => k + 1);
502
- }, [activeWorkspaceId, provider]);
856
+ }, [activeWorkspaceId]);
503
857
 
504
858
  const handleDownloadWorkspace = useCallback(async () => {
505
859
  if (!provider) return;
@@ -526,14 +880,49 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
526
880
 
527
881
  const handleRemoveWorkspace = useCallback(async () => {
528
882
  if (!activeWorkspaceId) return;
529
- if (!confirm('Remove this workspace? This cannot be undone.')) return;
530
- await removeDirectoryHandle(activeWorkspaceId);
883
+ const confirmMsg = isElectronHost()
884
+ ? 'Remove this workspace from DocBlocks? The files on disk will not be deleted.'
885
+ : 'Remove this workspace? This cannot be undone.';
886
+ if (!confirm(confirmMsg)) return;
887
+
888
+ const ws = await getWorkspace(activeWorkspaceId);
889
+ if (ws?.type === 'electron-native') {
890
+ try {
891
+ await getDocblocksHost().workspaces.unregister(activeWorkspaceId);
892
+ } catch {
893
+ // ignore — host cleanup is best-effort
894
+ }
895
+ } else {
896
+ await removeDirectoryHandle(activeWorkspaceId);
897
+ }
531
898
  await removeWorkspace(activeWorkspaceId);
899
+
900
+ const electron = isElectronHost();
532
901
  // Switch to most recent remaining workspace or create default
533
- const remaining = await listWorkspaces();
902
+ const remaining = (await listWorkspaces()).filter((w) =>
903
+ electron ? w.type === 'electron-native' : w.type !== 'electron-native',
904
+ );
534
905
  if (remaining.length > 0) {
535
906
  const next = remaining[0];
536
907
  await handleWorkspaceSelect(next);
908
+ } else if (electron) {
909
+ const info = await getDocblocksHost().workspaces.getDefault();
910
+ const descriptor: WorkspaceDescriptor = {
911
+ id: info.id,
912
+ name: info.name,
913
+ type: 'electron-native',
914
+ rootPath: info.rootPath,
915
+ lastOpened: new Date().toISOString(),
916
+ };
917
+ await saveWorkspace(descriptor);
918
+ const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
919
+ setProvider(p);
920
+ setActiveWorkspaceId(info.id);
921
+ setSelectedFile(null);
922
+ setSelectedFolder(null);
923
+ setFolderEntries([]);
924
+ setEditorContent('');
925
+ setEditorKey((k) => k + 1);
537
926
  } else {
538
927
  const defaultWs = await ensureDefaultWorkspace();
539
928
  const fsProvider = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
@@ -544,10 +933,8 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
544
933
  setFolderEntries([]);
545
934
  setEditorContent('');
546
935
  setEditorKey((k) => k + 1);
547
-
548
- setupMediaContainer(defaultWs.id);
549
936
  }
550
- }, [activeWorkspaceId, handleWorkspaceSelect, setupMediaContainer]);
937
+ }, [activeWorkspaceId, handleWorkspaceSelect]);
551
938
 
552
939
  return (
553
940
  <div className={`db-shell${isMobile ? ' db-shell--mobile' : ''}`} data-theme={resolvedTheme}>
@@ -557,7 +944,11 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
557
944
  {(!isMobile || !mobileShowEditor) && (
558
945
  <div className="db-shell-sidebar">
559
946
  <div className="db-shell-sidebar-header">
560
- <AppMenu logoUrl={logoUrl} />
947
+ <AppMenu
948
+ logoUrl={logoUrl}
949
+ themePreference={themePreference}
950
+ onThemeChange={handleThemeChange}
951
+ />
561
952
  <WorkspacePicker
562
953
  activeWorkspaceId={activeWorkspaceId}
563
954
  onSelect={handleWorkspaceSelect}
@@ -627,7 +1018,9 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
627
1018
  </button>
628
1019
  )}
629
1020
  <div className="db-folder-view-header">
630
- <span className="db-folder-view-icon">📁</span>
1021
+ <span className="db-folder-view-icon">
1022
+ <FolderGlyph />
1023
+ </span>
631
1024
  <span className="db-folder-view-path">{selectedFolder}</span>
632
1025
  </div>
633
1026
  {folderEntries.length === 0 ? (
@@ -641,7 +1034,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
641
1034
  onClick={() => handleSelect(entry.path, entry.kind)}
642
1035
  >
643
1036
  <span className="db-folder-view-item-icon">
644
- {entry.kind === 'directory' ? '📁' : '📄'}
1037
+ {entry.kind === 'directory' ? <FolderGlyph /> : <FileGlyph />}
645
1038
  </span>
646
1039
  {entry.name}
647
1040
  </li>