@bendyline/docblocks-react 0.1.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 (93) hide show
  1. package/LICENSE +21 -21
  2. package/dist/AppMenu/AppMenu.d.ts +6 -1
  3. package/dist/AppMenu/AppMenu.d.ts.map +1 -1
  4. package/dist/AppMenu/AppMenu.js +3 -2
  5. package/dist/AppMenu/AppMenu.js.map +1 -1
  6. package/dist/AppMenu/index.d.ts +1 -1
  7. package/dist/AppMenu/index.d.ts.map +1 -1
  8. package/dist/DocBlocksShell/DocBlocksShell.d.ts +1 -1
  9. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
  10. package/dist/DocBlocksShell/DocBlocksShell.js +410 -73
  11. package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
  12. package/dist/FileExplorer/FileTreeNode.d.ts.map +1 -1
  13. package/dist/FileExplorer/FileTreeNode.js +34 -8
  14. package/dist/FileExplorer/FileTreeNode.js.map +1 -1
  15. package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +1 -1
  16. package/dist/WorkspacePicker/WorkspacePicker.js +9 -3
  17. package/dist/WorkspacePicker/WorkspacePicker.js.map +1 -1
  18. package/dist/icons.d.ts +1 -0
  19. package/dist/icons.d.ts.map +1 -1
  20. package/dist/icons.js +3 -0
  21. package/dist/icons.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/package.json +8 -8
  25. package/src/AppMenu/AppMenu.tsx +78 -1
  26. package/src/AppMenu/index.ts +1 -1
  27. package/src/DocBlocksShell/DocBlocksShell.tsx +480 -87
  28. package/src/FileExplorer/FileTreeNode.tsx +68 -26
  29. package/src/WorkspacePicker/WorkspacePicker.tsx +18 -7
  30. package/src/fonts/fonts.css +362 -0
  31. package/src/fonts/licenses/LICENSE-cormorant-garamond.txt +93 -0
  32. package/src/fonts/licenses/LICENSE-crimson-text.txt +93 -0
  33. package/src/fonts/licenses/LICENSE-dm-sans.txt +93 -0
  34. package/src/fonts/licenses/LICENSE-dm-serif-display.txt +93 -0
  35. package/src/fonts/licenses/LICENSE-hanken-grotesk.txt +94 -0
  36. package/src/fonts/licenses/LICENSE-ibm-plex-sans.txt +93 -0
  37. package/src/fonts/licenses/LICENSE-inter.txt +93 -0
  38. package/src/fonts/licenses/LICENSE-jetbrains-mono.txt +93 -0
  39. package/src/fonts/licenses/LICENSE-lora.txt +93 -0
  40. package/src/fonts/licenses/LICENSE-merriweather.txt +93 -0
  41. package/src/fonts/licenses/LICENSE-oswald.txt +93 -0
  42. package/src/fonts/licenses/LICENSE-playfair-display.txt +93 -0
  43. package/src/fonts/licenses/LICENSE-pt-serif.txt +93 -0
  44. package/src/fonts/licenses/LICENSE-roboto.txt +93 -0
  45. package/src/fonts/licenses/LICENSE-source-serif-4.txt +93 -0
  46. package/src/fonts/woff2/cormorant-garamond-400.woff2 +0 -0
  47. package/src/fonts/woff2/cormorant-garamond-700.woff2 +0 -0
  48. package/src/fonts/woff2/crimson-text-400.woff2 +0 -0
  49. package/src/fonts/woff2/crimson-text-400i.woff2 +0 -0
  50. package/src/fonts/woff2/crimson-text-700.woff2 +0 -0
  51. package/src/fonts/woff2/dm-sans-400.woff2 +0 -0
  52. package/src/fonts/woff2/dm-sans-400i.woff2 +0 -0
  53. package/src/fonts/woff2/dm-sans-500.woff2 +0 -0
  54. package/src/fonts/woff2/dm-sans-700.woff2 +0 -0
  55. package/src/fonts/woff2/dm-serif-display-400.woff2 +0 -0
  56. package/src/fonts/woff2/hanken-grotesk-400.woff2 +0 -0
  57. package/src/fonts/woff2/hanken-grotesk-400i.woff2 +0 -0
  58. package/src/fonts/woff2/hanken-grotesk-500.woff2 +0 -0
  59. package/src/fonts/woff2/hanken-grotesk-600.woff2 +0 -0
  60. package/src/fonts/woff2/hanken-grotesk-700.woff2 +0 -0
  61. package/src/fonts/woff2/ibm-plex-sans-400.woff2 +0 -0
  62. package/src/fonts/woff2/ibm-plex-sans-400i.woff2 +0 -0
  63. package/src/fonts/woff2/ibm-plex-sans-500.woff2 +0 -0
  64. package/src/fonts/woff2/ibm-plex-sans-600.woff2 +0 -0
  65. package/src/fonts/woff2/ibm-plex-sans-700.woff2 +0 -0
  66. package/src/fonts/woff2/inter-400.woff2 +0 -0
  67. package/src/fonts/woff2/inter-400i.woff2 +0 -0
  68. package/src/fonts/woff2/inter-500.woff2 +0 -0
  69. package/src/fonts/woff2/inter-600.woff2 +0 -0
  70. package/src/fonts/woff2/inter-700.woff2 +0 -0
  71. package/src/fonts/woff2/jetbrains-mono-400.woff2 +0 -0
  72. package/src/fonts/woff2/jetbrains-mono-400i.woff2 +0 -0
  73. package/src/fonts/woff2/jetbrains-mono-700.woff2 +0 -0
  74. package/src/fonts/woff2/lora-400.woff2 +0 -0
  75. package/src/fonts/woff2/lora-400i.woff2 +0 -0
  76. package/src/fonts/woff2/lora-700.woff2 +0 -0
  77. package/src/fonts/woff2/merriweather-400.woff2 +0 -0
  78. package/src/fonts/woff2/merriweather-700.woff2 +0 -0
  79. package/src/fonts/woff2/oswald-400.woff2 +0 -0
  80. package/src/fonts/woff2/oswald-700.woff2 +0 -0
  81. package/src/fonts/woff2/playfair-display-400.woff2 +0 -0
  82. package/src/fonts/woff2/playfair-display-700.woff2 +0 -0
  83. package/src/fonts/woff2/pt-serif-400.woff2 +0 -0
  84. package/src/fonts/woff2/pt-serif-700.woff2 +0 -0
  85. package/src/fonts/woff2/roboto-400.woff2 +0 -0
  86. package/src/fonts/woff2/roboto-400i.woff2 +0 -0
  87. package/src/fonts/woff2/roboto-700.woff2 +0 -0
  88. package/src/fonts/woff2/source-serif-4-400.woff2 +0 -0
  89. package/src/fonts/woff2/source-serif-4-400i.woff2 +0 -0
  90. package/src/fonts/woff2/source-serif-4-700.woff2 +0 -0
  91. package/src/icons.tsx +10 -0
  92. package/src/index.ts +1 -1
  93. package/src/styles/docblocks.css +128 -13
@@ -9,8 +9,8 @@ import { useState, useCallback, useEffect, useRef } from 'react';
9
9
  import { EditorShell } from '@bendyline/squisq-editor-react';
10
10
  import '@bendyline/squisq-editor-react/styles';
11
11
  import { MediaContext } from '@bendyline/squisq-react';
12
- import { createMediaProviderFromContainer } from '@bendyline/squisq/storage';
13
- import { IndexedDBFileSystemProvider, IndexedDBContentContainer, openNativeFolder, restoreNativeFolder, removeDirectoryHandle, } from '@bendyline/docblocks/filesystem';
12
+ import { IndexedDBFileSystemProvider, ElectronFileSystemProvider, FileSystemContentContainer, createFileMediaProvider, openNativeFolder, restoreNativeFolder, removeDirectoryHandle, } from '@bendyline/docblocks/filesystem';
13
+ import { isElectronHost, getDocblocksHost } from '@bendyline/docblocks/host';
14
14
  import { ensureDefaultWorkspace, getWorkspace, listWorkspaces, removeWorkspace, saveWorkspace, touchWorkspace, } from '@bendyline/docblocks/workspace';
15
15
  import { AppMenu } from '../AppMenu/AppMenu.js';
16
16
  import { FileExplorer } from '../FileExplorer/FileExplorer.js';
@@ -50,6 +50,56 @@ function parseHash() {
50
50
  filePath: '/' + decodeURIComponent(raw.slice(slashIdx + 1)),
51
51
  };
52
52
  }
53
+ const LAST_STATE_KEY = 'docblocks:lastState';
54
+ function saveLastState(state) {
55
+ try {
56
+ localStorage.setItem(LAST_STATE_KEY, JSON.stringify(state));
57
+ }
58
+ catch {
59
+ // ignore quota errors
60
+ }
61
+ }
62
+ function loadLastState() {
63
+ try {
64
+ const raw = localStorage.getItem(LAST_STATE_KEY);
65
+ if (!raw)
66
+ return null;
67
+ return JSON.parse(raw);
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ }
73
+ const THEME_PREF_KEY = 'docblocks:themePreference';
74
+ function loadThemePreference() {
75
+ try {
76
+ const raw = localStorage.getItem(THEME_PREF_KEY);
77
+ if (raw === 'light' || raw === 'dark' || raw === 'auto')
78
+ return raw;
79
+ }
80
+ catch {
81
+ // ignore
82
+ }
83
+ return 'auto';
84
+ }
85
+ function saveThemePreference(pref) {
86
+ try {
87
+ localStorage.setItem(THEME_PREF_KEY, pref);
88
+ }
89
+ catch {
90
+ // ignore quota errors
91
+ }
92
+ }
93
+ function dirnameOf(p) {
94
+ const clean = p.replace(/^\/+/, '');
95
+ const idx = clean.lastIndexOf('/');
96
+ return idx === -1 ? '' : clean.slice(0, idx);
97
+ }
98
+ function basenameOf(p) {
99
+ const clean = p.replace(/^\/+/, '');
100
+ const idx = clean.lastIndexOf('/');
101
+ return idx === -1 ? clean : clean.slice(idx + 1);
102
+ }
53
103
  function useIsMobile(breakpoint = 768) {
54
104
  const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.matchMedia(`(max-width: ${breakpoint}px)`).matches);
55
105
  useEffect(() => {
@@ -60,9 +110,22 @@ function useIsMobile(breakpoint = 768) {
60
110
  }, [breakpoint]);
61
111
  return isMobile;
62
112
  }
63
- export function DocBlocksShell({ theme = 'auto', logoUrl }) {
113
+ function FolderGlyph() {
114
+ return (_jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("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" }) }));
115
+ }
116
+ function FileGlyph() {
117
+ return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("path", { d: "M6 3h8l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" }), _jsx("path", { d: "M14 3v5h5" })] }));
118
+ }
119
+ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
64
120
  const osTheme = useOsTheme();
65
- const resolvedTheme = theme === 'auto' ? osTheme : theme;
121
+ const [themePreference, setThemePreference] = useState(loadThemePreference);
122
+ // "System default" (auto) always follows the OS — the host's theme prop
123
+ // is kept only for API back-compat and does not override OS detection.
124
+ const resolvedTheme = themePreference === 'light' || themePreference === 'dark' ? themePreference : osTheme;
125
+ const handleThemeChange = useCallback((pref) => {
126
+ setThemePreference(pref);
127
+ saveThemePreference(pref);
128
+ }, []);
66
129
  const isMobile = useIsMobile();
67
130
  const [mobileShowEditor, setMobileShowEditor] = useState(false);
68
131
  const [provider, setProvider] = useState(null);
@@ -76,15 +139,14 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
76
139
  const [initialView, setInitialView] = useState('wysiwyg');
77
140
  /** Suppress popstate handling during programmatic navigation. */
78
141
  const skipPopState = useRef(false);
79
- /** Persistent media container backed by IndexedDB (survives page refresh). */
142
+ /**
143
+ * Per-file media container: for `notes.md`, images live in
144
+ * `notes_files/` next to the markdown. Created lazily on first write,
145
+ * picked up automatically if it already exists. Set up by the effect
146
+ * below whenever the selected file (or backing provider) changes.
147
+ */
80
148
  const mediaContainerRef = useRef(null);
81
149
  const [mediaProvider, setMediaProvider] = useState(null);
82
- /** Set up persistent media container for a workspace. */
83
- const setupMediaContainer = useCallback((workspaceId) => {
84
- const mc = new IndexedDBContentContainer(workspaceId);
85
- mediaContainerRef.current = mc;
86
- setMediaProvider(createMediaProviderFromContainer(mc));
87
- }, []);
88
150
  /** Push a new history entry with the given hash. */
89
151
  const pushHash = useCallback((wsId, filePath) => {
90
152
  const hash = buildHash(wsId, filePath);
@@ -93,17 +155,28 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
93
155
  window.history.pushState(null, '', hash);
94
156
  }
95
157
  }, []);
96
- /** Open a workspace (and optionally a file) from identifiers. */
97
- const openFromIds = useCallback(async (wsId, filePath, push) => {
158
+ /** Open a workspace (and optionally a file) from identifiers.
159
+ * Returns the FileSystemProvider on success, or null on failure. */
160
+ const openFromIds = useCallback(async (wsId, filePath, push, view) => {
98
161
  const workspaces = await listWorkspaces();
99
162
  const ws = workspaces.find((w) => w.id === wsId);
100
163
  if (!ws)
101
- return false;
164
+ return null;
102
165
  let fsProvider = null;
103
- if (ws.type === 'native') {
166
+ if (ws.type === 'electron-native') {
167
+ if (!ws.rootPath)
168
+ return null;
169
+ await getDocblocksHost().workspaces.register({
170
+ id: ws.id,
171
+ name: ws.name,
172
+ rootPath: ws.rootPath,
173
+ });
174
+ fsProvider = new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
175
+ }
176
+ else if (ws.type === 'native') {
104
177
  const restored = await restoreNativeFolder(ws.id);
105
178
  if (!restored)
106
- return false;
179
+ return null;
107
180
  fsProvider = restored;
108
181
  }
109
182
  else {
@@ -112,7 +185,6 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
112
185
  await touchWorkspace(ws.id);
113
186
  setProvider(fsProvider);
114
187
  setActiveWorkspaceId(ws.id);
115
- setupMediaContainer(ws.id);
116
188
  if (filePath) {
117
189
  const content = await fsProvider.readFile(filePath);
118
190
  if (content !== null) {
@@ -120,8 +192,10 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
120
192
  setSelectedFolder(null);
121
193
  setFolderEntries([]);
122
194
  setEditorContent(content);
123
- setInitialView('wysiwyg');
195
+ const effectiveView = view ?? 'wysiwyg';
196
+ setInitialView(effectiveView);
124
197
  setEditorKey((k) => k + 1);
198
+ saveLastState({ workspaceId: wsId, filePath, view: effectiveView });
125
199
  }
126
200
  else {
127
201
  setSelectedFile(null);
@@ -141,22 +215,24 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
141
215
  if (push) {
142
216
  pushHash(ws.id, filePath);
143
217
  }
144
- return true;
145
- }, [pushHash, setupMediaContainer]);
218
+ return fsProvider;
219
+ }, [pushHash]);
146
220
  const seedWelcomeFile = useCallback(async (fs) => {
147
221
  const entries = await fs.readDirectory('/');
148
222
  // If the only file is the welcome doc, auto-select it
149
223
  if (entries.length === 1 &&
150
224
  entries[0].kind === 'file' &&
151
- entries[0].path === '/aboutDocblocks.md') {
152
- const content = await fs.readFile('/aboutDocblocks.md');
225
+ entries[0].path.replace(/^\//, '') === 'aboutDocblocks.md') {
226
+ const aboutPath = entries[0].path;
227
+ const content = await fs.readFile(aboutPath);
153
228
  if (content !== null) {
154
- setSelectedFile('/aboutDocblocks.md');
229
+ setSelectedFile(aboutPath);
155
230
  setEditorContent(content);
156
231
  setInitialView('preview');
157
232
  setEditorKey((k) => k + 1);
158
233
  setExplorerKey((k) => k + 1);
159
- pushHash(fs.id, '/aboutDocblocks.md');
234
+ pushHash(fs.id, aboutPath);
235
+ saveLastState({ workspaceId: fs.id, filePath: aboutPath, view: 'preview' });
160
236
  }
161
237
  return;
162
238
  }
@@ -193,6 +269,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
193
269
  setEditorKey((k) => k + 1);
194
270
  setExplorerKey((k) => k + 1);
195
271
  pushHash(fs.id, welcomePath);
272
+ saveLastState({ workspaceId: fs.id, filePath: welcomePath, view: 'preview' });
196
273
  }, [pushHash]);
197
274
  // Initialise workspace on mount — restore from hash or last-used
198
275
  useEffect(() => {
@@ -200,23 +277,55 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
200
277
  // Try restoring from URL hash first
201
278
  const hashState = parseHash();
202
279
  if (hashState) {
203
- const ok = await openFromIds(hashState.workspaceId, hashState.filePath, false);
204
- if (ok)
280
+ const restoredProvider = await openFromIds(hashState.workspaceId, hashState.filePath, false);
281
+ if (restoredProvider) {
282
+ // If the hash had no file, check whether we should auto-select the welcome doc
283
+ if (!hashState.filePath) {
284
+ await seedWelcomeFile(restoredProvider);
285
+ }
286
+ return;
287
+ }
288
+ }
289
+ // Try restoring last viewed document from localStorage
290
+ const lastState = loadLastState();
291
+ if (lastState) {
292
+ const restoredProvider = await openFromIds(lastState.workspaceId, lastState.filePath, true, lastState.view);
293
+ if (restoredProvider)
205
294
  return;
206
295
  }
207
296
  let fsProvider = null;
297
+ const electron = isElectronHost();
208
298
  const workspaces = await listWorkspaces();
299
+ // On desktop, hide web-only workspaces (indexeddb/native) — only
300
+ // folder-based workspaces are valid.
301
+ const candidates = electron
302
+ ? workspaces.filter((w) => w.type === 'electron-native')
303
+ : workspaces.filter((w) => w.type !== 'electron-native');
209
304
  // Pick the most recently opened workspace
210
- const sorted = [...workspaces].sort((a, b) => (b.lastOpened ?? '').localeCompare(a.lastOpened ?? ''));
305
+ const sorted = [...candidates].sort((a, b) => (b.lastOpened ?? '').localeCompare(a.lastOpened ?? ''));
211
306
  for (const ws of sorted) {
212
- if (ws.type === 'native') {
307
+ if (ws.type === 'electron-native') {
308
+ if (!ws.rootPath)
309
+ continue;
310
+ await getDocblocksHost().workspaces.register({
311
+ id: ws.id,
312
+ name: ws.name,
313
+ rootPath: ws.rootPath,
314
+ });
315
+ const p = new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
316
+ await touchWorkspace(ws.id);
317
+ fsProvider = p;
318
+ setProvider(p);
319
+ setActiveWorkspaceId(ws.id);
320
+ break;
321
+ }
322
+ else if (ws.type === 'native') {
213
323
  const restored = await restoreNativeFolder(ws.id);
214
324
  if (restored) {
215
325
  await touchWorkspace(ws.id);
216
326
  fsProvider = restored;
217
327
  setProvider(restored);
218
328
  setActiveWorkspaceId(ws.id);
219
- setupMediaContainer(ws.id);
220
329
  break;
221
330
  }
222
331
  }
@@ -226,25 +335,42 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
226
335
  fsProvider = p;
227
336
  setProvider(p);
228
337
  setActiveWorkspaceId(ws.id);
229
- setupMediaContainer(ws.id);
230
338
  break;
231
339
  }
232
340
  }
233
341
  if (!fsProvider) {
234
- // No workspaces yet — create default
235
- const defaultWs = await ensureDefaultWorkspace();
236
- const p = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
237
- fsProvider = p;
238
- setProvider(p);
239
- setActiveWorkspaceId(defaultWs.id);
240
- setupMediaContainer(defaultWs.id);
342
+ if (electron) {
343
+ // Desktop: ask the host for the default folder workspace
344
+ // (creates ~/Documents/DocBlocks on first launch).
345
+ const info = await getDocblocksHost().workspaces.getDefault();
346
+ const descriptor = {
347
+ id: info.id,
348
+ name: info.name,
349
+ type: 'electron-native',
350
+ rootPath: info.rootPath,
351
+ lastOpened: new Date().toISOString(),
352
+ };
353
+ await saveWorkspace(descriptor);
354
+ const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
355
+ fsProvider = p;
356
+ setProvider(p);
357
+ setActiveWorkspaceId(info.id);
358
+ }
359
+ else {
360
+ // Web: create the IndexedDB default workspace
361
+ const defaultWs = await ensureDefaultWorkspace();
362
+ const p = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
363
+ fsProvider = p;
364
+ setProvider(p);
365
+ setActiveWorkspaceId(defaultWs.id);
366
+ }
241
367
  }
242
368
  // Set initial hash
243
369
  pushHash(fsProvider.id, null);
244
370
  // Seed welcome file if workspace is empty
245
371
  await seedWelcomeFile(fsProvider);
246
372
  })();
247
- }, [openFromIds, pushHash, seedWelcomeFile, setupMediaContainer]);
373
+ }, [openFromIds, pushHash, seedWelcomeFile]);
248
374
  // Handle browser back/forward
249
375
  useEffect(() => {
250
376
  const onPopState = () => {
@@ -260,23 +386,86 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
260
386
  window.addEventListener('popstate', onPopState);
261
387
  return () => window.removeEventListener('popstate', onPopState);
262
388
  }, [openFromIds]);
389
+ // Track view mode changes (Editor/Raw/Play tabs) and persist to localStorage
390
+ useEffect(() => {
391
+ const handler = (e) => {
392
+ const target = e.target.closest?.('[data-view]');
393
+ if (target) {
394
+ const view = target.getAttribute('data-view');
395
+ if (view && activeWorkspaceId && selectedFile) {
396
+ saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view });
397
+ }
398
+ }
399
+ };
400
+ window.addEventListener('click', handler, true);
401
+ return () => window.removeEventListener('click', handler, true);
402
+ }, [activeWorkspaceId, selectedFile]);
263
403
  // Auto-save current file
264
404
  useAutoSave(provider, selectedFile, editorContent);
405
+ // Per-file media: for `notes.md` images live in `notes_files/` beside it.
406
+ // Rebuilds whenever the provider or selected file changes.
407
+ useEffect(() => {
408
+ if (!provider || !selectedFile) {
409
+ mediaContainerRef.current = null;
410
+ setMediaProvider(null);
411
+ return;
412
+ }
413
+ const parentDir = dirnameOf(selectedFile);
414
+ const base = basenameOf(selectedFile);
415
+ const container = new FileSystemContentContainer(provider, parentDir);
416
+ const mp = createFileMediaProvider(container, base);
417
+ mediaContainerRef.current = container;
418
+ setMediaProvider(mp);
419
+ return () => {
420
+ mp.dispose();
421
+ };
422
+ }, [provider, selectedFile]);
423
+ // React to external file changes watched by the Electron host (chokidar).
424
+ useEffect(() => {
425
+ if (!isElectronHost())
426
+ return;
427
+ if (!provider || !(provider instanceof ElectronFileSystemProvider))
428
+ return;
429
+ const unwatch = provider.watch(() => {
430
+ setExplorerKey((k) => k + 1);
431
+ // If the open file's contents changed on disk, reload it (best-effort).
432
+ if (selectedFile) {
433
+ (async () => {
434
+ const content = await provider.readFile(selectedFile);
435
+ if (content !== null && content !== editorContent) {
436
+ setEditorContent(content);
437
+ setEditorKey((k) => k + 1);
438
+ }
439
+ })();
440
+ }
441
+ });
442
+ return unwatch;
443
+ }, [provider, selectedFile, editorContent]);
265
444
  const handleWorkspaceSelect = useCallback(async (ws) => {
266
445
  await touchWorkspace(ws.id);
267
- if (ws.type === 'native') {
446
+ let nextProvider = null;
447
+ if (ws.type === 'electron-native') {
448
+ if (!ws.rootPath)
449
+ return;
450
+ await getDocblocksHost().workspaces.register({
451
+ id: ws.id,
452
+ name: ws.name,
453
+ rootPath: ws.rootPath,
454
+ });
455
+ nextProvider = new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
456
+ }
457
+ else if (ws.type === 'native') {
268
458
  const restored = await restoreNativeFolder(ws.id);
269
- if (restored) {
270
- setProvider(restored);
271
- }
272
- else {
459
+ if (!restored) {
273
460
  // Permission denied or handle lost — fall through without changing provider
274
461
  return;
275
462
  }
463
+ nextProvider = restored;
276
464
  }
277
465
  else {
278
- setProvider(new IndexedDBFileSystemProvider(ws.id, ws.name));
466
+ nextProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
279
467
  }
468
+ setProvider(nextProvider);
280
469
  setActiveWorkspaceId(ws.id);
281
470
  setSelectedFile(null);
282
471
  setSelectedFolder(null);
@@ -284,10 +473,32 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
284
473
  setEditorContent('');
285
474
  setEditorKey((k) => k + 1);
286
475
  pushHash(ws.id, null);
287
- setupMediaContainer(ws.id);
288
- }, [pushHash, setupMediaContainer]);
476
+ }, [pushHash]);
289
477
  const handleOpenFolder = useCallback(async () => {
290
478
  try {
479
+ if (isElectronHost()) {
480
+ const info = await getDocblocksHost().workspaces.pickFolder();
481
+ if (!info)
482
+ return; // user cancelled
483
+ const descriptor = {
484
+ id: info.id,
485
+ name: info.name,
486
+ type: 'electron-native',
487
+ rootPath: info.rootPath,
488
+ lastOpened: new Date().toISOString(),
489
+ };
490
+ await saveWorkspace(descriptor);
491
+ const provider = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
492
+ setProvider(provider);
493
+ setActiveWorkspaceId(descriptor.id);
494
+ setSelectedFile(null);
495
+ setSelectedFolder(null);
496
+ setFolderEntries([]);
497
+ setEditorContent('');
498
+ setEditorKey((k) => k + 1);
499
+ pushHash(descriptor.id, null);
500
+ return;
501
+ }
291
502
  const nativeProvider = await openNativeFolder();
292
503
  const descriptor = {
293
504
  id: nativeProvider.id,
@@ -304,12 +515,104 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
304
515
  setEditorContent('');
305
516
  setEditorKey((k) => k + 1);
306
517
  pushHash(descriptor.id, null);
307
- setupMediaContainer(descriptor.id);
308
518
  }
309
519
  catch {
310
520
  // User cancelled or API not supported
311
521
  }
312
- }, [pushHash, setupMediaContainer]);
522
+ }, [pushHash]);
523
+ const handleNewFile = useCallback(async () => {
524
+ if (!provider)
525
+ return;
526
+ const name = prompt('New document name:', 'Untitled.md');
527
+ if (!name)
528
+ return;
529
+ const filename = /\.[a-zA-Z0-9]+$/.test(name) ? name : `${name}.md`;
530
+ await provider.writeFile(filename, '# ' + filename.replace(/\.[^.]+$/, '') + '\n');
531
+ setSelectedFile('/' + filename);
532
+ setEditorContent('# ' + filename.replace(/\.[^.]+$/, '') + '\n');
533
+ setInitialView('wysiwyg');
534
+ setEditorKey((k) => k + 1);
535
+ setExplorerKey((k) => k + 1);
536
+ if (activeWorkspaceId) {
537
+ pushHash(activeWorkspaceId, '/' + filename);
538
+ }
539
+ }, [provider, activeWorkspaceId, pushHash]);
540
+ const handleRevealWorkspace = useCallback(async () => {
541
+ if (!isElectronHost() || !activeWorkspaceId)
542
+ return;
543
+ const ws = await getWorkspace(activeWorkspaceId);
544
+ if (ws?.type === 'electron-native' && ws.rootPath) {
545
+ await getDocblocksHost().shell.revealInFolder(ws.rootPath);
546
+ }
547
+ }, [activeWorkspaceId]);
548
+ // Subscribe to native menu commands (Electron host).
549
+ useEffect(() => {
550
+ if (!isElectronHost())
551
+ return;
552
+ const host = getDocblocksHost();
553
+ return host.onMenuCommand((cmd) => {
554
+ switch (cmd) {
555
+ case 'file:new':
556
+ handleNewFile();
557
+ break;
558
+ case 'file:openFolder':
559
+ handleOpenFolder();
560
+ break;
561
+ case 'file:revealWorkspace':
562
+ handleRevealWorkspace();
563
+ break;
564
+ case 'help:viewOnGitHub':
565
+ host.shell.openExternal('https://github.com/bendyline/docblocks');
566
+ break;
567
+ case 'help:checkForUpdates':
568
+ host.updater.checkForUpdates().catch(() => {
569
+ // errors surface via updater.onStatus
570
+ });
571
+ break;
572
+ case 'help:about':
573
+ (async () => {
574
+ const version = await host.updater.getVersion();
575
+ alert(`DocBlocks ${version}`);
576
+ })();
577
+ break;
578
+ default:
579
+ break;
580
+ }
581
+ });
582
+ }, [handleNewFile, handleOpenFolder, handleRevealWorkspace]);
583
+ // Subscribe to open-file / deep-link requests from the OS.
584
+ useEffect(() => {
585
+ if (!isElectronHost())
586
+ return;
587
+ const host = getDocblocksHost();
588
+ return host.onOpenRequest(async (req) => {
589
+ if (req.filePath) {
590
+ const workspaces = (await listWorkspaces()).filter((w) => w.type === 'electron-native' && w.rootPath);
591
+ const match = workspaces.find((w) => req.filePath.startsWith((w.rootPath ?? '') + '/') || req.filePath === w.rootPath);
592
+ if (match && match.rootPath) {
593
+ const rel = '/' + req.filePath.slice(match.rootPath.length).replace(/^\/+/, '');
594
+ await openFromIds(match.id, rel, true);
595
+ }
596
+ }
597
+ else if (req.url) {
598
+ try {
599
+ const u = new URL(req.url);
600
+ const path = u.searchParams.get('path');
601
+ if (path) {
602
+ const workspaces = (await listWorkspaces()).filter((w) => w.type === 'electron-native' && w.rootPath);
603
+ const match = workspaces.find((w) => path.startsWith((w.rootPath ?? '') + '/') || path === w.rootPath);
604
+ if (match && match.rootPath) {
605
+ const rel = '/' + path.slice(match.rootPath.length).replace(/^\/+/, '');
606
+ await openFromIds(match.id, rel, true);
607
+ }
608
+ }
609
+ }
610
+ catch {
611
+ // bad URL, ignore
612
+ }
613
+ }
614
+ });
615
+ }, [openFromIds]);
313
616
  const handleSelect = useCallback(async (path, kind) => {
314
617
  if (!provider || !activeWorkspaceId)
315
618
  return;
@@ -331,6 +634,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
331
634
  setInitialView('wysiwyg');
332
635
  setEditorKey((k) => k + 1);
333
636
  pushHash(activeWorkspaceId, path);
637
+ saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
334
638
  if (isMobile)
335
639
  setMobileShowEditor(true);
336
640
  }
@@ -354,19 +658,24 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
354
658
  setFolderEntries(entries);
355
659
  }
356
660
  }, [provider, selectedFile, selectedFolder, activeWorkspaceId, pushHash]);
357
- /** Copy non-markdown files from an imported container into persistent media storage. */
358
- const persistContainerMedia = useCallback(async (source) => {
359
- const mc = mediaContainerRef.current;
360
- if (!mc)
361
- return;
661
+ /**
662
+ * Copy non-markdown files from an imported container into the target
663
+ * markdown file's `_files/` sibling folder, preserving the source's
664
+ * relative structure.
665
+ */
666
+ const persistImportedMedia = useCallback(async (source, target, importedMarkdownPath) => {
667
+ const parentDir = dirnameOf(importedMarkdownPath);
668
+ const folder = basenameOf(importedMarkdownPath).replace(/\.[^.]+$/, '') + '_files';
669
+ const mediaRoot = parentDir ? `${parentDir}/${folder}` : folder;
362
670
  const entries = await source.listFiles();
363
671
  for (const entry of entries) {
364
672
  if (entry.path.endsWith('.md'))
365
673
  continue;
366
674
  const data = await source.readFile(entry.path);
367
- if (data) {
368
- await mc.writeFile(entry.path, new Uint8Array(data), entry.mimeType);
369
- }
675
+ if (!data)
676
+ continue;
677
+ const cleanPath = entry.path.replace(/^\/+/, '');
678
+ await target.writeBinary(`${mediaRoot}/${cleanPath}`, new Uint8Array(data));
370
679
  }
371
680
  }, []);
372
681
  const handleImportFiles = useCallback(async (files) => {
@@ -385,19 +694,19 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
385
694
  const { docxToContainer } = await import('@bendyline/squisq-formats/docx');
386
695
  const container = await docxToContainer(await file.arrayBuffer());
387
696
  markdown = (await container.readDocument()) ?? '';
388
- await persistContainerMedia(container);
697
+ await persistImportedMedia(container, provider, destPath);
389
698
  }
390
699
  else if (ext === '.pdf') {
391
700
  const { pdfToContainer } = await import('@bendyline/squisq-formats/pdf');
392
701
  const container = await pdfToContainer(await file.arrayBuffer());
393
702
  markdown = (await container.readDocument()) ?? '';
394
- await persistContainerMedia(container);
703
+ await persistImportedMedia(container, provider, destPath);
395
704
  }
396
705
  else if (ext === '.dbk' || ext === '.zip') {
397
706
  const { zipToContainer } = await import('@bendyline/squisq-formats/container');
398
707
  const container = await zipToContainer(await file.arrayBuffer());
399
708
  markdown = (await container.readDocument()) ?? '';
400
- await persistContainerMedia(container);
709
+ await persistImportedMedia(container, provider, destPath);
401
710
  }
402
711
  else {
403
712
  continue;
@@ -408,14 +717,9 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
408
717
  console.error(`Failed to import ${file.name}:`, err);
409
718
  }
410
719
  }
411
- // Refresh media provider once after all imports (not per-file)
412
- const mc = mediaContainerRef.current;
413
- if (mc) {
414
- setMediaProvider(createMediaProviderFromContainer(mc));
415
- }
416
- // Refresh file tree
720
+ // Refresh file tree so the new markdown files + _files folders show up.
417
721
  setExplorerKey((k) => k + 1);
418
- }, [provider, persistContainerMedia]);
722
+ }, [provider, persistImportedMedia]);
419
723
  const handleEditorChange = useCallback((source) => {
420
724
  setEditorContent(source);
421
725
  }, []);
@@ -431,7 +735,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
431
735
  await saveWorkspace({ ...ws, name: newName });
432
736
  // Bump key to trigger re-render — workspace name is read from the descriptor, not the provider
433
737
  setEditorKey((k) => k + 1);
434
- }, [activeWorkspaceId, provider]);
738
+ }, [activeWorkspaceId]);
435
739
  const handleDownloadWorkspace = useCallback(async () => {
436
740
  if (!provider)
437
741
  return;
@@ -459,16 +763,50 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
459
763
  const handleRemoveWorkspace = useCallback(async () => {
460
764
  if (!activeWorkspaceId)
461
765
  return;
462
- if (!confirm('Remove this workspace? This cannot be undone.'))
766
+ const confirmMsg = isElectronHost()
767
+ ? 'Remove this workspace from DocBlocks? The files on disk will not be deleted.'
768
+ : 'Remove this workspace? This cannot be undone.';
769
+ if (!confirm(confirmMsg))
463
770
  return;
464
- await removeDirectoryHandle(activeWorkspaceId);
771
+ const ws = await getWorkspace(activeWorkspaceId);
772
+ if (ws?.type === 'electron-native') {
773
+ try {
774
+ await getDocblocksHost().workspaces.unregister(activeWorkspaceId);
775
+ }
776
+ catch {
777
+ // ignore — host cleanup is best-effort
778
+ }
779
+ }
780
+ else {
781
+ await removeDirectoryHandle(activeWorkspaceId);
782
+ }
465
783
  await removeWorkspace(activeWorkspaceId);
784
+ const electron = isElectronHost();
466
785
  // Switch to most recent remaining workspace or create default
467
- const remaining = await listWorkspaces();
786
+ const remaining = (await listWorkspaces()).filter((w) => electron ? w.type === 'electron-native' : w.type !== 'electron-native');
468
787
  if (remaining.length > 0) {
469
788
  const next = remaining[0];
470
789
  await handleWorkspaceSelect(next);
471
790
  }
791
+ else if (electron) {
792
+ const info = await getDocblocksHost().workspaces.getDefault();
793
+ const descriptor = {
794
+ id: info.id,
795
+ name: info.name,
796
+ type: 'electron-native',
797
+ rootPath: info.rootPath,
798
+ lastOpened: new Date().toISOString(),
799
+ };
800
+ await saveWorkspace(descriptor);
801
+ const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
802
+ setProvider(p);
803
+ setActiveWorkspaceId(info.id);
804
+ setSelectedFile(null);
805
+ setSelectedFolder(null);
806
+ setFolderEntries([]);
807
+ setEditorContent('');
808
+ setEditorKey((k) => k + 1);
809
+ }
472
810
  else {
473
811
  const defaultWs = await ensureDefaultWorkspace();
474
812
  const fsProvider = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
@@ -479,9 +817,8 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
479
817
  setFolderEntries([]);
480
818
  setEditorContent('');
481
819
  setEditorKey((k) => k + 1);
482
- setupMediaContainer(defaultWs.id);
483
820
  }
484
- }, [activeWorkspaceId, handleWorkspaceSelect, setupMediaContainer]);
485
- return (_jsx("div", { className: `db-shell${isMobile ? ' db-shell--mobile' : ''}`, "data-theme": resolvedTheme, children: _jsxs("div", { style: { display: 'flex', flex: 1, overflow: 'hidden' }, children: [(!isMobile || !mobileShowEditor) && (_jsxs("div", { className: "db-shell-sidebar", children: [_jsxs("div", { className: "db-shell-sidebar-header", children: [_jsx(AppMenu, { logoUrl: logoUrl }), _jsx(WorkspacePicker, { activeWorkspaceId: activeWorkspaceId, onSelect: handleWorkspaceSelect, onOpenFolder: handleOpenFolder }), _jsx(WorkspaceSettingsButton, { onRename: handleRenameWorkspace, onDownload: handleDownloadWorkspace, onRemove: handleRemoveWorkspace })] }), _jsx(FileExplorer, { provider: provider, onSelect: handleSelect, onTreeChange: handleTreeChange, onImportFiles: handleImportFiles }, explorerKey), _jsx("div", { className: "db-shell-sidebar-footer", children: _jsx("a", { href: "https://github.com/bendyline/docblocks/blob/main/LICENSE", target: "_blank", rel: "noopener noreferrer", children: "Terms of Use" }) })] })), (!isMobile || mobileShowEditor) && (_jsx("div", { style: { flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }, children: selectedFile && mediaProvider ? (_jsx(MediaContext.Provider, { value: mediaProvider, children: _jsx(EditorShell, { initialMarkdown: editorContent, initialView: initialView, articleId: selectedFile, onChange: handleEditorChange, theme: resolvedTheme, height: "100%", mediaProvider: mediaProvider, container: mediaContainerRef.current ?? undefined, toolbarSlotLeft: isMobile ? (_jsx("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), children: _jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }) })) : undefined, toolbarSlotRight: _jsx(ExportToolbarControls, { selectedFile: selectedFile, mediaContainer: mediaContainerRef.current }) }, `${selectedFile}-${editorKey}`) })) : selectedFolder ? (_jsxs("div", { className: "db-folder-view", children: [isMobile && (_jsxs("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), children: [_jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }), "Back to files"] })), _jsxs("div", { className: "db-folder-view-header", children: [_jsx("span", { className: "db-folder-view-icon", children: "\uD83D\uDCC1" }), _jsx("span", { className: "db-folder-view-path", children: selectedFolder })] }), folderEntries.length === 0 ? (_jsx("p", { className: "db-folder-view-empty", children: "This folder is empty." })) : (_jsx("ul", { className: "db-folder-view-list", children: folderEntries.map((entry) => (_jsxs("li", { className: "db-folder-view-item", onClick: () => handleSelect(entry.path, entry.kind), children: [_jsx("span", { className: "db-folder-view-item-icon", children: entry.kind === 'directory' ? '📁' : '📄' }), entry.name] }, entry.path))) }))] })) : (_jsx("div", { className: "db-shell-empty", children: _jsx("p", { children: "Select a file to start editing, or create a new one." }) })) }))] }) }));
821
+ }, [activeWorkspaceId, handleWorkspaceSelect]);
822
+ return (_jsx("div", { className: `db-shell${isMobile ? ' db-shell--mobile' : ''}`, "data-theme": resolvedTheme, children: _jsxs("div", { style: { display: 'flex', flex: 1, overflow: 'hidden' }, children: [(!isMobile || !mobileShowEditor) && (_jsxs("div", { className: "db-shell-sidebar", children: [_jsxs("div", { className: "db-shell-sidebar-header", children: [_jsx(AppMenu, { logoUrl: logoUrl, themePreference: themePreference, onThemeChange: handleThemeChange }), _jsx(WorkspacePicker, { activeWorkspaceId: activeWorkspaceId, onSelect: handleWorkspaceSelect, onOpenFolder: handleOpenFolder }), _jsx(WorkspaceSettingsButton, { onRename: handleRenameWorkspace, onDownload: handleDownloadWorkspace, onRemove: handleRemoveWorkspace })] }), _jsx(FileExplorer, { provider: provider, onSelect: handleSelect, onTreeChange: handleTreeChange, onImportFiles: handleImportFiles }, explorerKey), _jsx("div", { className: "db-shell-sidebar-footer", children: _jsx("a", { href: "https://github.com/bendyline/docblocks/blob/main/LICENSE", target: "_blank", rel: "noopener noreferrer", children: "Terms of Use" }) })] })), (!isMobile || mobileShowEditor) && (_jsx("div", { style: { flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }, children: selectedFile && mediaProvider ? (_jsx(MediaContext.Provider, { value: mediaProvider, children: _jsx(EditorShell, { initialMarkdown: editorContent, initialView: initialView, articleId: selectedFile, onChange: handleEditorChange, theme: resolvedTheme, height: "100%", mediaProvider: mediaProvider, container: mediaContainerRef.current ?? undefined, toolbarSlotLeft: isMobile ? (_jsx("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), children: _jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }) })) : undefined, toolbarSlotRight: _jsx(ExportToolbarControls, { selectedFile: selectedFile, mediaContainer: mediaContainerRef.current }) }, `${selectedFile}-${editorKey}`) })) : selectedFolder ? (_jsxs("div", { className: "db-folder-view", children: [isMobile && (_jsxs("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), children: [_jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }), "Back to files"] })), _jsxs("div", { className: "db-folder-view-header", children: [_jsx("span", { className: "db-folder-view-icon", children: _jsx(FolderGlyph, {}) }), _jsx("span", { className: "db-folder-view-path", children: selectedFolder })] }), folderEntries.length === 0 ? (_jsx("p", { className: "db-folder-view-empty", children: "This folder is empty." })) : (_jsx("ul", { className: "db-folder-view-list", children: folderEntries.map((entry) => (_jsxs("li", { className: "db-folder-view-item", onClick: () => handleSelect(entry.path, entry.kind), children: [_jsx("span", { className: "db-folder-view-item-icon", children: entry.kind === 'directory' ? _jsx(FolderGlyph, {}) : _jsx(FileGlyph, {}) }), entry.name] }, entry.path))) }))] })) : (_jsx("div", { className: "db-shell-empty", children: _jsx("p", { children: "Select a file to start editing, or create a new one." }) })) }))] }) }));
486
823
  }
487
824
  //# sourceMappingURL=DocBlocksShell.js.map