@bendyline/docblocks-react 0.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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/AppMenu/AppMenu.d.ts +11 -0
  4. package/dist/AppMenu/AppMenu.d.ts.map +1 -0
  5. package/dist/AppMenu/AppMenu.js +27 -0
  6. package/dist/AppMenu/AppMenu.js.map +1 -0
  7. package/dist/AppMenu/index.d.ts +3 -0
  8. package/dist/AppMenu/index.d.ts.map +1 -0
  9. package/dist/AppMenu/index.js +2 -0
  10. package/dist/AppMenu/index.js.map +1 -0
  11. package/dist/DocBlocksShell/DocBlocksShell.d.ts +16 -0
  12. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -0
  13. package/dist/DocBlocksShell/DocBlocksShell.js +487 -0
  14. package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -0
  15. package/dist/DocBlocksShell/index.d.ts +3 -0
  16. package/dist/DocBlocksShell/index.d.ts.map +1 -0
  17. package/dist/DocBlocksShell/index.js +2 -0
  18. package/dist/DocBlocksShell/index.js.map +1 -0
  19. package/dist/Export/ExportDialog.d.ts +16 -0
  20. package/dist/Export/ExportDialog.d.ts.map +1 -0
  21. package/dist/Export/ExportDialog.js +42 -0
  22. package/dist/Export/ExportDialog.js.map +1 -0
  23. package/dist/Export/ExportToolbarControls.d.ts +17 -0
  24. package/dist/Export/ExportToolbarControls.d.ts.map +1 -0
  25. package/dist/Export/ExportToolbarControls.js +109 -0
  26. package/dist/Export/ExportToolbarControls.js.map +1 -0
  27. package/dist/Export/export-options.d.ts +18 -0
  28. package/dist/Export/export-options.d.ts.map +1 -0
  29. package/dist/Export/export-options.js +44 -0
  30. package/dist/Export/export-options.js.map +1 -0
  31. package/dist/Export/index.d.ts +6 -0
  32. package/dist/Export/index.d.ts.map +1 -0
  33. package/dist/Export/index.js +3 -0
  34. package/dist/Export/index.js.map +1 -0
  35. package/dist/Export/run-export.d.ts +8 -0
  36. package/dist/Export/run-export.d.ts.map +1 -0
  37. package/dist/Export/run-export.js +208 -0
  38. package/dist/Export/run-export.js.map +1 -0
  39. package/dist/FileExplorer/FileExplorer.d.ts +21 -0
  40. package/dist/FileExplorer/FileExplorer.d.ts.map +1 -0
  41. package/dist/FileExplorer/FileExplorer.js +125 -0
  42. package/dist/FileExplorer/FileExplorer.js.map +1 -0
  43. package/dist/FileExplorer/FileTreeNode.d.ts +18 -0
  44. package/dist/FileExplorer/FileTreeNode.d.ts.map +1 -0
  45. package/dist/FileExplorer/FileTreeNode.js +86 -0
  46. package/dist/FileExplorer/FileTreeNode.js.map +1 -0
  47. package/dist/FileExplorer/index.d.ts +6 -0
  48. package/dist/FileExplorer/index.d.ts.map +1 -0
  49. package/dist/FileExplorer/index.js +4 -0
  50. package/dist/FileExplorer/index.js.map +1 -0
  51. package/dist/FileExplorer/useFileTree.d.ts +35 -0
  52. package/dist/FileExplorer/useFileTree.d.ts.map +1 -0
  53. package/dist/FileExplorer/useFileTree.js +124 -0
  54. package/dist/FileExplorer/useFileTree.js.map +1 -0
  55. package/dist/WorkspacePicker/WorkspacePicker.d.ts +17 -0
  56. package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +1 -0
  57. package/dist/WorkspacePicker/WorkspacePicker.js +57 -0
  58. package/dist/WorkspacePicker/WorkspacePicker.js.map +1 -0
  59. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +10 -0
  60. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +1 -0
  61. package/dist/WorkspacePicker/WorkspaceSettingsButton.js +27 -0
  62. package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +1 -0
  63. package/dist/WorkspacePicker/index.d.ts +3 -0
  64. package/dist/WorkspacePicker/index.d.ts.map +1 -0
  65. package/dist/WorkspacePicker/index.js +2 -0
  66. package/dist/WorkspacePicker/index.js.map +1 -0
  67. package/dist/hooks/index.d.ts +2 -0
  68. package/dist/hooks/index.d.ts.map +1 -0
  69. package/dist/hooks/index.js +2 -0
  70. package/dist/hooks/index.js.map +1 -0
  71. package/dist/hooks/useAutoSave.d.ts +9 -0
  72. package/dist/hooks/useAutoSave.d.ts.map +1 -0
  73. package/dist/hooks/useAutoSave.js +48 -0
  74. package/dist/hooks/useAutoSave.js.map +1 -0
  75. package/dist/icons.d.ts +6 -0
  76. package/dist/icons.d.ts.map +1 -0
  77. package/dist/icons.js +24 -0
  78. package/dist/icons.js.map +1 -0
  79. package/dist/index.d.ts +20 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +19 -0
  82. package/dist/index.js.map +1 -0
  83. package/package.json +65 -0
  84. package/src/AppMenu/AppMenu.tsx +124 -0
  85. package/src/AppMenu/index.ts +2 -0
  86. package/src/DocBlocksShell/DocBlocksShell.tsx +662 -0
  87. package/src/DocBlocksShell/index.ts +2 -0
  88. package/src/Export/ExportDialog.tsx +173 -0
  89. package/src/Export/ExportToolbarControls.tsx +183 -0
  90. package/src/Export/export-options.ts +57 -0
  91. package/src/Export/index.ts +5 -0
  92. package/src/Export/run-export.ts +238 -0
  93. package/src/FileExplorer/FileExplorer.tsx +260 -0
  94. package/src/FileExplorer/FileTreeNode.tsx +172 -0
  95. package/src/FileExplorer/index.ts +5 -0
  96. package/src/FileExplorer/useFileTree.ts +178 -0
  97. package/src/WorkspacePicker/WorkspacePicker.tsx +126 -0
  98. package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +79 -0
  99. package/src/WorkspacePicker/index.ts +2 -0
  100. package/src/hooks/index.ts +1 -0
  101. package/src/hooks/useAutoSave.ts +58 -0
  102. package/src/icons.tsx +60 -0
  103. package/src/index.ts +31 -0
  104. package/src/styles/docblocks.css +961 -0
@@ -0,0 +1,662 @@
1
+ /**
2
+ * DocBlocksShell — top-level layout component.
3
+ *
4
+ * Composes the left sidebar (WorkspacePicker + FileExplorer) with
5
+ * the center editor area (squisq EditorShell).
6
+ */
7
+
8
+ import { useState, useCallback, useEffect, useRef } from 'react';
9
+ import { EditorShell } from '@bendyline/squisq-editor-react';
10
+ import type { EditorTheme, EditorView } from '@bendyline/squisq-editor-react';
11
+ import '@bendyline/squisq-editor-react/styles';
12
+ import { MediaContext } from '@bendyline/squisq-react';
13
+ import type { MediaProvider } from '@bendyline/squisq/schemas';
14
+ import { createMediaProviderFromContainer } from '@bendyline/squisq/storage';
15
+ import type { FileSystemProvider, FileSystemEntry } from '@bendyline/docblocks/filesystem';
16
+ import {
17
+ IndexedDBFileSystemProvider,
18
+ IndexedDBContentContainer,
19
+ openNativeFolder,
20
+ restoreNativeFolder,
21
+ removeDirectoryHandle,
22
+ } from '@bendyline/docblocks/filesystem';
23
+ import type { WorkspaceDescriptor } from '@bendyline/docblocks/workspace';
24
+ import {
25
+ ensureDefaultWorkspace,
26
+ getWorkspace,
27
+ listWorkspaces,
28
+ removeWorkspace,
29
+ saveWorkspace,
30
+ touchWorkspace,
31
+ } from '@bendyline/docblocks/workspace';
32
+ import { AppMenu } from '../AppMenu/AppMenu.js';
33
+ import { FileExplorer } from '../FileExplorer/FileExplorer.js';
34
+ import { WorkspacePicker } from '../WorkspacePicker/WorkspacePicker.js';
35
+ import { WorkspaceSettingsButton } from '../WorkspacePicker/WorkspaceSettingsButton.js';
36
+ import { useAutoSave } from '../hooks/useAutoSave.js';
37
+ import { ExportToolbarControls } from '../Export/ExportToolbarControls.js';
38
+
39
+ export interface DocBlocksShellProps {
40
+ /** Optional theme override. Omit or pass 'auto' to follow OS preference. */
41
+ theme?: EditorTheme | 'auto';
42
+ /** Optional logo image URL for the app menu. */
43
+ logoUrl?: string;
44
+ }
45
+
46
+ function useOsTheme(): 'light' | 'dark' {
47
+ const [dark, setDark] = useState(
48
+ () =>
49
+ typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches,
50
+ );
51
+ useEffect(() => {
52
+ const mq = window.matchMedia('(prefers-color-scheme: dark)');
53
+ const handler = (e: MediaQueryListEvent) => setDark(e.matches);
54
+ mq.addEventListener('change', handler);
55
+ return () => mq.removeEventListener('change', handler);
56
+ }, []);
57
+ return dark ? 'dark' : 'light';
58
+ }
59
+
60
+ /** Encode workspace + optional file path into a URL hash. */
61
+ function buildHash(workspaceId: string, filePath?: string | null): string {
62
+ const ws = encodeURIComponent(workspaceId);
63
+ if (!filePath) return `#${ws}`;
64
+ const fp = filePath.replace(/^\//, '');
65
+ return `#${ws}/${encodeURIComponent(fp)}`;
66
+ }
67
+
68
+ /** Decode the current URL hash into workspace id + file path. */
69
+ function parseHash(): { workspaceId: string; filePath: string | null } | null {
70
+ const raw = window.location.hash.slice(1);
71
+ if (!raw) return null;
72
+ const slashIdx = raw.indexOf('/');
73
+ if (slashIdx === -1) {
74
+ return { workspaceId: decodeURIComponent(raw), filePath: null };
75
+ }
76
+ return {
77
+ workspaceId: decodeURIComponent(raw.slice(0, slashIdx)),
78
+ filePath: '/' + decodeURIComponent(raw.slice(slashIdx + 1)),
79
+ };
80
+ }
81
+
82
+ function useIsMobile(breakpoint = 768): boolean {
83
+ const [isMobile, setIsMobile] = useState(
84
+ () =>
85
+ typeof window !== 'undefined' && window.matchMedia(`(max-width: ${breakpoint}px)`).matches,
86
+ );
87
+ useEffect(() => {
88
+ const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
89
+ const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
90
+ mq.addEventListener('change', handler);
91
+ return () => mq.removeEventListener('change', handler);
92
+ }, [breakpoint]);
93
+ return isMobile;
94
+ }
95
+
96
+ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps) {
97
+ const osTheme = useOsTheme();
98
+ const resolvedTheme: 'light' | 'dark' = theme === 'auto' ? osTheme : theme;
99
+ const isMobile = useIsMobile();
100
+ const [mobileShowEditor, setMobileShowEditor] = useState(false);
101
+ const [provider, setProvider] = useState<FileSystemProvider | null>(null);
102
+ const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(null);
103
+ const [selectedFile, setSelectedFile] = useState<string | null>(null);
104
+ const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
105
+ const [folderEntries, setFolderEntries] = useState<FileSystemEntry[]>([]);
106
+ const [editorContent, setEditorContent] = useState('');
107
+ const [editorKey, setEditorKey] = useState(0);
108
+ const [explorerKey, setExplorerKey] = useState(0);
109
+ const [initialView, setInitialView] = useState<EditorView>('wysiwyg');
110
+ /** Suppress popstate handling during programmatic navigation. */
111
+ const skipPopState = useRef(false);
112
+
113
+ /** Persistent media container backed by IndexedDB (survives page refresh). */
114
+ const mediaContainerRef = useRef<IndexedDBContentContainer | null>(null);
115
+ const [mediaProvider, setMediaProvider] = useState<MediaProvider | null>(null);
116
+
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
+ /** Push a new history entry with the given hash. */
125
+ const pushHash = useCallback((wsId: string, filePath?: string | null) => {
126
+ const hash = buildHash(wsId, filePath);
127
+ if (window.location.hash !== hash) {
128
+ skipPopState.current = true;
129
+ window.history.pushState(null, '', hash);
130
+ }
131
+ }, []);
132
+
133
+ /** Open a workspace (and optionally a file) from identifiers. */
134
+ const openFromIds = useCallback(
135
+ async (wsId: string, filePath: string | null, push: boolean) => {
136
+ const workspaces = await listWorkspaces();
137
+ const ws = workspaces.find((w) => w.id === wsId);
138
+ if (!ws) return false;
139
+
140
+ let fsProvider: FileSystemProvider | null = null;
141
+ if (ws.type === 'native') {
142
+ const restored = await restoreNativeFolder(ws.id);
143
+ if (!restored) return false;
144
+ fsProvider = restored;
145
+ } else {
146
+ fsProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
147
+ }
148
+ await touchWorkspace(ws.id);
149
+ setProvider(fsProvider);
150
+ setActiveWorkspaceId(ws.id);
151
+
152
+ setupMediaContainer(ws.id);
153
+
154
+ if (filePath) {
155
+ const content = await fsProvider.readFile(filePath);
156
+ if (content !== null) {
157
+ setSelectedFile(filePath);
158
+ setSelectedFolder(null);
159
+ setFolderEntries([]);
160
+ setEditorContent(content);
161
+ setInitialView('wysiwyg');
162
+ setEditorKey((k) => k + 1);
163
+ } else {
164
+ setSelectedFile(null);
165
+ setSelectedFolder(null);
166
+ setFolderEntries([]);
167
+ setEditorContent('');
168
+ setEditorKey((k) => k + 1);
169
+ }
170
+ } else {
171
+ setSelectedFile(null);
172
+ setSelectedFolder(null);
173
+ setFolderEntries([]);
174
+ setEditorContent('');
175
+ setEditorKey((k) => k + 1);
176
+ }
177
+
178
+ if (push) {
179
+ pushHash(ws.id, filePath);
180
+ }
181
+ return true;
182
+ },
183
+ [pushHash, setupMediaContainer],
184
+ );
185
+
186
+ const seedWelcomeFile = useCallback(
187
+ async (fs: FileSystemProvider) => {
188
+ const entries = await fs.readDirectory('/');
189
+
190
+ // If the only file is the welcome doc, auto-select it
191
+ if (
192
+ entries.length === 1 &&
193
+ entries[0].kind === 'file' &&
194
+ entries[0].path === '/aboutDocblocks.md'
195
+ ) {
196
+ const content = await fs.readFile('/aboutDocblocks.md');
197
+ if (content !== null) {
198
+ setSelectedFile('/aboutDocblocks.md');
199
+ setEditorContent(content);
200
+ setInitialView('preview');
201
+ setEditorKey((k) => k + 1);
202
+ setExplorerKey((k) => k + 1);
203
+ pushHash(fs.id, '/aboutDocblocks.md');
204
+ }
205
+ return;
206
+ }
207
+
208
+ if (entries.length > 0) return;
209
+
210
+ const welcomePath = '/aboutDocblocks.md';
211
+ const welcomeContent = [
212
+ '# Welcome to DocBlocks',
213
+ '',
214
+ 'DocBlocks is a browser-based markdown document editor that lets you create, organize, and manage your documents right in the browser.',
215
+ '',
216
+ '## Features',
217
+ '',
218
+ '- **Rich Markdown Editing** — Write in a visual editor or switch to raw markdown anytime',
219
+ '- **Workspaces** — Organize your documents into separate workspaces',
220
+ '- **Playback & Video** — Preview your documents as rich visual presentations and export them as MP4 video',
221
+ '- **Export Anywhere** — Export documents to PDF, Word, PowerPoint, HTML, or Markdown with theme options',
222
+ '- **Local Storage** — Your documents are stored in your browser using temporary browser storage (backup often!)',
223
+ '- **Device Folders** — Create workspaces based on folders on your computer',
224
+ '- **No BS** — Free, no ads, no accounts, no tracking - everything runs locally in your browser',
225
+ '',
226
+ '## Getting Started',
227
+ '',
228
+ '1. Create a new file using the **New File** button in the sidebar',
229
+ '2. Start writing in markdown — the editor supports headings, lists, links, images, and more',
230
+ '3. Your work is saved automatically',
231
+ '',
232
+ 'Built with [Squiggly Square](https://github.com/nicoth-in/squisq) by [Bendyline](https://bendyline.com).',
233
+ ].join('\n');
234
+
235
+ await fs.writeFile(welcomePath, welcomeContent);
236
+ setSelectedFile(welcomePath);
237
+ setEditorContent(welcomeContent);
238
+ setInitialView('preview');
239
+ setEditorKey((k) => k + 1);
240
+ setExplorerKey((k) => k + 1);
241
+ pushHash(fs.id, welcomePath);
242
+ },
243
+ [pushHash],
244
+ );
245
+
246
+ // Initialise workspace on mount — restore from hash or last-used
247
+ useEffect(() => {
248
+ (async () => {
249
+ // Try restoring from URL hash first
250
+ const hashState = parseHash();
251
+ if (hashState) {
252
+ const ok = await openFromIds(hashState.workspaceId, hashState.filePath, false);
253
+ if (ok) return;
254
+ }
255
+
256
+ let fsProvider: FileSystemProvider | null = null;
257
+
258
+ const workspaces = await listWorkspaces();
259
+ // Pick the most recently opened workspace
260
+ const sorted = [...workspaces].sort((a, b) =>
261
+ (b.lastOpened ?? '').localeCompare(a.lastOpened ?? ''),
262
+ );
263
+ for (const ws of sorted) {
264
+ if (ws.type === 'native') {
265
+ const restored = await restoreNativeFolder(ws.id);
266
+ if (restored) {
267
+ await touchWorkspace(ws.id);
268
+ fsProvider = restored;
269
+ setProvider(restored);
270
+ setActiveWorkspaceId(ws.id);
271
+
272
+ setupMediaContainer(ws.id);
273
+ break;
274
+ }
275
+ } else {
276
+ const p = new IndexedDBFileSystemProvider(ws.id, ws.name);
277
+ await touchWorkspace(ws.id);
278
+ fsProvider = p;
279
+ setProvider(p);
280
+ setActiveWorkspaceId(ws.id);
281
+
282
+ setupMediaContainer(ws.id);
283
+ break;
284
+ }
285
+ }
286
+
287
+ 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);
296
+ }
297
+
298
+ // Set initial hash
299
+ pushHash(fsProvider.id, null);
300
+
301
+ // Seed welcome file if workspace is empty
302
+ await seedWelcomeFile(fsProvider);
303
+ })();
304
+ }, [openFromIds, pushHash, seedWelcomeFile, setupMediaContainer]);
305
+
306
+ // Handle browser back/forward
307
+ useEffect(() => {
308
+ const onPopState = () => {
309
+ if (skipPopState.current) {
310
+ skipPopState.current = false;
311
+ return;
312
+ }
313
+ const hashState = parseHash();
314
+ if (hashState) {
315
+ openFromIds(hashState.workspaceId, hashState.filePath, false);
316
+ }
317
+ };
318
+ window.addEventListener('popstate', onPopState);
319
+ return () => window.removeEventListener('popstate', onPopState);
320
+ }, [openFromIds]);
321
+
322
+ // Auto-save current file
323
+ useAutoSave(provider, selectedFile, editorContent);
324
+
325
+ const handleWorkspaceSelect = useCallback(
326
+ async (ws: WorkspaceDescriptor) => {
327
+ await touchWorkspace(ws.id);
328
+ if (ws.type === 'native') {
329
+ const restored = await restoreNativeFolder(ws.id);
330
+ if (restored) {
331
+ setProvider(restored);
332
+ } else {
333
+ // Permission denied or handle lost — fall through without changing provider
334
+ return;
335
+ }
336
+ } else {
337
+ setProvider(new IndexedDBFileSystemProvider(ws.id, ws.name));
338
+ }
339
+ setActiveWorkspaceId(ws.id);
340
+ setSelectedFile(null);
341
+ setSelectedFolder(null);
342
+ setFolderEntries([]);
343
+ setEditorContent('');
344
+ setEditorKey((k) => k + 1);
345
+ pushHash(ws.id, null);
346
+
347
+ setupMediaContainer(ws.id);
348
+ },
349
+ [pushHash, setupMediaContainer],
350
+ );
351
+
352
+ const handleOpenFolder = useCallback(async () => {
353
+ try {
354
+ const nativeProvider = await openNativeFolder();
355
+ const descriptor: WorkspaceDescriptor = {
356
+ id: nativeProvider.id,
357
+ name: nativeProvider.label,
358
+ type: 'native',
359
+ lastOpened: new Date().toISOString(),
360
+ };
361
+ await saveWorkspace(descriptor);
362
+ setProvider(nativeProvider);
363
+ setActiveWorkspaceId(descriptor.id);
364
+ setSelectedFile(null);
365
+ setSelectedFolder(null);
366
+ setFolderEntries([]);
367
+ setEditorContent('');
368
+ setEditorKey((k) => k + 1);
369
+ pushHash(descriptor.id, null);
370
+
371
+ setupMediaContainer(descriptor.id);
372
+ } catch {
373
+ // User cancelled or API not supported
374
+ }
375
+ }, [pushHash, setupMediaContainer]);
376
+
377
+ const handleSelect = useCallback(
378
+ async (path: string, kind: 'file' | 'directory') => {
379
+ if (!provider || !activeWorkspaceId) return;
380
+
381
+ if (kind === 'directory') {
382
+ setSelectedFile(null);
383
+ setSelectedFolder(path);
384
+ const entries = await provider.readDirectory(path);
385
+ setFolderEntries(entries);
386
+ setEditorContent('');
387
+ setEditorKey((k) => k + 1);
388
+ pushHash(activeWorkspaceId, null);
389
+ } else {
390
+ const content = await provider.readFile(path);
391
+ setSelectedFile(path);
392
+ setSelectedFolder(null);
393
+ setFolderEntries([]);
394
+ setEditorContent(content ?? '');
395
+ setInitialView('wysiwyg');
396
+ setEditorKey((k) => k + 1);
397
+ pushHash(activeWorkspaceId, path);
398
+ if (isMobile) setMobileShowEditor(true);
399
+ }
400
+ },
401
+ [provider, activeWorkspaceId, pushHash, isMobile],
402
+ );
403
+
404
+ const handleTreeChange = useCallback(async () => {
405
+ if (!provider) return;
406
+ // If the open file was deleted, clear the editor
407
+ if (selectedFile) {
408
+ const exists = await provider.exists(selectedFile);
409
+ if (!exists) {
410
+ setSelectedFile(null);
411
+ setEditorContent('');
412
+ setEditorKey((k) => k + 1);
413
+ if (activeWorkspaceId) pushHash(activeWorkspaceId, null);
414
+ }
415
+ }
416
+ if (selectedFolder) {
417
+ const entries = await provider.readDirectory(selectedFolder);
418
+ setFolderEntries(entries);
419
+ }
420
+ }, [provider, selectedFile, selectedFolder, activeWorkspaceId, pushHash]);
421
+
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;
430
+ const entries = await source.listFiles();
431
+ for (const entry of entries) {
432
+ if (entry.path.endsWith('.md')) continue;
433
+ const data = await source.readFile(entry.path);
434
+ if (data) {
435
+ await mc.writeFile(entry.path, new Uint8Array(data), entry.mimeType);
436
+ }
437
+ }
438
+ },
439
+ [],
440
+ );
441
+
442
+ const handleImportFiles = useCallback(
443
+ async (files: File[]) => {
444
+ if (!provider) return;
445
+ for (const file of files) {
446
+ const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
447
+ const baseName = file.name.replace(/\.[^.]+$/, '');
448
+ const destPath = `${baseName}.md`;
449
+
450
+ try {
451
+ let markdown: string;
452
+ if (ext === '.md' || ext === '.txt') {
453
+ markdown = await file.text();
454
+ } else if (ext === '.docx') {
455
+ const { docxToContainer } = await import('@bendyline/squisq-formats/docx');
456
+ const container = await docxToContainer(await file.arrayBuffer());
457
+ markdown = (await container.readDocument()) ?? '';
458
+ await persistContainerMedia(container);
459
+ } else if (ext === '.pdf') {
460
+ const { pdfToContainer } = await import('@bendyline/squisq-formats/pdf');
461
+ const container = await pdfToContainer(await file.arrayBuffer());
462
+ markdown = (await container.readDocument()) ?? '';
463
+ await persistContainerMedia(container);
464
+ } else if (ext === '.dbk' || ext === '.zip') {
465
+ const { zipToContainer } = await import('@bendyline/squisq-formats/container');
466
+ const container = await zipToContainer(await file.arrayBuffer());
467
+ markdown = (await container.readDocument()) ?? '';
468
+ await persistContainerMedia(container);
469
+ } else {
470
+ continue;
471
+ }
472
+
473
+ await provider.writeFile(destPath, markdown);
474
+ } catch (err) {
475
+ console.error(`Failed to import ${file.name}:`, err);
476
+ }
477
+ }
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
484
+ setExplorerKey((k) => k + 1);
485
+ },
486
+ [provider, persistContainerMedia],
487
+ );
488
+
489
+ const handleEditorChange = useCallback((source: string) => {
490
+ setEditorContent(source);
491
+ }, []);
492
+
493
+ const handleRenameWorkspace = useCallback(async () => {
494
+ if (!activeWorkspaceId) return;
495
+ const ws = await getWorkspace(activeWorkspaceId);
496
+ if (!ws) return;
497
+ const newName = prompt('Rename workspace:', ws.name);
498
+ if (!newName || newName === ws.name) return;
499
+ await saveWorkspace({ ...ws, name: newName });
500
+ // Bump key to trigger re-render — workspace name is read from the descriptor, not the provider
501
+ setEditorKey((k) => k + 1);
502
+ }, [activeWorkspaceId, provider]);
503
+
504
+ const handleDownloadWorkspace = useCallback(async () => {
505
+ if (!provider) return;
506
+ try {
507
+ const entries = await provider.readDirectory('/');
508
+ const lines: string[] = [];
509
+ for (const entry of entries) {
510
+ if (entry.kind === 'file') {
511
+ const content = await provider.readFile(entry.path);
512
+ lines.push(`--- ${entry.path} ---\n${content ?? ''}\n`);
513
+ }
514
+ }
515
+ const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
516
+ const url = URL.createObjectURL(blob);
517
+ const a = document.createElement('a');
518
+ a.href = url;
519
+ a.download = 'workspace.txt';
520
+ a.click();
521
+ URL.revokeObjectURL(url);
522
+ } catch {
523
+ // ignore
524
+ }
525
+ }, [provider]);
526
+
527
+ const handleRemoveWorkspace = useCallback(async () => {
528
+ if (!activeWorkspaceId) return;
529
+ if (!confirm('Remove this workspace? This cannot be undone.')) return;
530
+ await removeDirectoryHandle(activeWorkspaceId);
531
+ await removeWorkspace(activeWorkspaceId);
532
+ // Switch to most recent remaining workspace or create default
533
+ const remaining = await listWorkspaces();
534
+ if (remaining.length > 0) {
535
+ const next = remaining[0];
536
+ await handleWorkspaceSelect(next);
537
+ } else {
538
+ const defaultWs = await ensureDefaultWorkspace();
539
+ const fsProvider = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
540
+ setProvider(fsProvider);
541
+ setActiveWorkspaceId(defaultWs.id);
542
+ setSelectedFile(null);
543
+ setSelectedFolder(null);
544
+ setFolderEntries([]);
545
+ setEditorContent('');
546
+ setEditorKey((k) => k + 1);
547
+
548
+ setupMediaContainer(defaultWs.id);
549
+ }
550
+ }, [activeWorkspaceId, handleWorkspaceSelect, setupMediaContainer]);
551
+
552
+ return (
553
+ <div className={`db-shell${isMobile ? ' db-shell--mobile' : ''}`} data-theme={resolvedTheme}>
554
+ {/* Main area */}
555
+ <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
556
+ {/* Left sidebar — hidden on mobile when editor is shown */}
557
+ {(!isMobile || !mobileShowEditor) && (
558
+ <div className="db-shell-sidebar">
559
+ <div className="db-shell-sidebar-header">
560
+ <AppMenu logoUrl={logoUrl} />
561
+ <WorkspacePicker
562
+ activeWorkspaceId={activeWorkspaceId}
563
+ onSelect={handleWorkspaceSelect}
564
+ onOpenFolder={handleOpenFolder}
565
+ />
566
+ <WorkspaceSettingsButton
567
+ onRename={handleRenameWorkspace}
568
+ onDownload={handleDownloadWorkspace}
569
+ onRemove={handleRemoveWorkspace}
570
+ />
571
+ </div>
572
+ <FileExplorer
573
+ key={explorerKey}
574
+ provider={provider}
575
+ onSelect={handleSelect}
576
+ onTreeChange={handleTreeChange}
577
+ onImportFiles={handleImportFiles}
578
+ />
579
+ <div className="db-shell-sidebar-footer">
580
+ <a
581
+ href="https://github.com/bendyline/docblocks/blob/main/LICENSE"
582
+ target="_blank"
583
+ rel="noopener noreferrer"
584
+ >
585
+ Terms of Use
586
+ </a>
587
+ </div>
588
+ </div>
589
+ )}
590
+
591
+ {/* Editor area — hidden on mobile when sidebar is shown */}
592
+ {(!isMobile || mobileShowEditor) && (
593
+ <div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
594
+ {selectedFile && mediaProvider ? (
595
+ <MediaContext.Provider value={mediaProvider}>
596
+ <EditorShell
597
+ key={`${selectedFile}-${editorKey}`}
598
+ initialMarkdown={editorContent}
599
+ initialView={initialView}
600
+ articleId={selectedFile}
601
+ onChange={handleEditorChange}
602
+ theme={resolvedTheme}
603
+ height="100%"
604
+ mediaProvider={mediaProvider}
605
+ container={mediaContainerRef.current ?? undefined}
606
+ toolbarSlotLeft={
607
+ isMobile ? (
608
+ <button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
609
+ <span className="db-mobile-back-arrow">&larr;</span>
610
+ </button>
611
+ ) : undefined
612
+ }
613
+ toolbarSlotRight={
614
+ <ExportToolbarControls
615
+ selectedFile={selectedFile}
616
+ mediaContainer={mediaContainerRef.current}
617
+ />
618
+ }
619
+ />
620
+ </MediaContext.Provider>
621
+ ) : selectedFolder ? (
622
+ <div className="db-folder-view">
623
+ {isMobile && (
624
+ <button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
625
+ <span className="db-mobile-back-arrow">&larr;</span>
626
+ Back to files
627
+ </button>
628
+ )}
629
+ <div className="db-folder-view-header">
630
+ <span className="db-folder-view-icon">📁</span>
631
+ <span className="db-folder-view-path">{selectedFolder}</span>
632
+ </div>
633
+ {folderEntries.length === 0 ? (
634
+ <p className="db-folder-view-empty">This folder is empty.</p>
635
+ ) : (
636
+ <ul className="db-folder-view-list">
637
+ {folderEntries.map((entry) => (
638
+ <li
639
+ key={entry.path}
640
+ className="db-folder-view-item"
641
+ onClick={() => handleSelect(entry.path, entry.kind)}
642
+ >
643
+ <span className="db-folder-view-item-icon">
644
+ {entry.kind === 'directory' ? '📁' : '📄'}
645
+ </span>
646
+ {entry.name}
647
+ </li>
648
+ ))}
649
+ </ul>
650
+ )}
651
+ </div>
652
+ ) : (
653
+ <div className="db-shell-empty">
654
+ <p>Select a file to start editing, or create a new one.</p>
655
+ </div>
656
+ )}
657
+ </div>
658
+ )}
659
+ </div>
660
+ </div>
661
+ );
662
+ }
@@ -0,0 +1,2 @@
1
+ export { DocBlocksShell } from './DocBlocksShell.js';
2
+ export type { DocBlocksShellProps } from './DocBlocksShell.js';