@bendyline/docblocks-react 1.1.0 → 1.1.1

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 (53) hide show
  1. package/dist/AppMenu/AppMenu.d.ts +11 -1
  2. package/dist/AppMenu/AppMenu.d.ts.map +1 -1
  3. package/dist/AppMenu/AppMenu.js +2 -2
  4. package/dist/AppMenu/AppMenu.js.map +1 -1
  5. package/dist/DocBlocksShell/DocBlocksShell.d.ts +21 -1
  6. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
  7. package/dist/DocBlocksShell/DocBlocksShell.js +562 -67
  8. package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
  9. package/dist/Export/ExportDialog.d.ts.map +1 -1
  10. package/dist/Export/ExportDialog.js +104 -5
  11. package/dist/Export/ExportDialog.js.map +1 -1
  12. package/dist/Export/ExportToolbarControls.d.ts.map +1 -1
  13. package/dist/Export/ExportToolbarControls.js +44 -3
  14. package/dist/Export/ExportToolbarControls.js.map +1 -1
  15. package/dist/Export/export-options.d.ts +35 -0
  16. package/dist/Export/export-options.d.ts.map +1 -1
  17. package/dist/Export/export-options.js +6 -1
  18. package/dist/Export/export-options.js.map +1 -1
  19. package/dist/Export/run-export.d.ts.map +1 -1
  20. package/dist/Export/run-export.js +163 -91
  21. package/dist/Export/run-export.js.map +1 -1
  22. package/dist/FileExplorer/FileExplorer.d.ts.map +1 -1
  23. package/dist/FileExplorer/FileExplorer.js +17 -2
  24. package/dist/FileExplorer/FileExplorer.js.map +1 -1
  25. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +2 -1
  26. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +1 -1
  27. package/dist/WorkspacePicker/WorkspaceSettingsButton.js +2 -2
  28. package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +1 -1
  29. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts +20 -0
  30. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts.map +1 -0
  31. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js +36 -0
  32. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js.map +1 -0
  33. package/dist/hooks/useAutoSave.d.ts +8 -2
  34. package/dist/hooks/useAutoSave.d.ts.map +1 -1
  35. package/dist/hooks/useAutoSave.js +65 -30
  36. package/dist/hooks/useAutoSave.js.map +1 -1
  37. package/dist/preferences/versioning.d.ts +27 -0
  38. package/dist/preferences/versioning.d.ts.map +1 -0
  39. package/dist/preferences/versioning.js +62 -0
  40. package/dist/preferences/versioning.js.map +1 -0
  41. package/package.json +10 -8
  42. package/src/AppMenu/AppMenu.tsx +64 -1
  43. package/src/DocBlocksShell/DocBlocksShell.tsx +756 -80
  44. package/src/Export/ExportDialog.tsx +208 -19
  45. package/src/Export/ExportToolbarControls.tsx +45 -3
  46. package/src/Export/export-options.ts +43 -1
  47. package/src/Export/run-export.ts +190 -91
  48. package/src/FileExplorer/FileExplorer.tsx +31 -9
  49. package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +9 -0
  50. package/src/WorkspacePicker/WorkspaceSettingsDialog.tsx +121 -0
  51. package/src/hooks/useAutoSave.ts +87 -29
  52. package/src/preferences/versioning.ts +69 -0
  53. package/src/styles/docblocks.css +920 -53
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * DocBlocksShell — top-level layout component.
4
4
  *
@@ -9,15 +9,18 @@ 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 { DocumentVersionManager, } from '@bendyline/squisq/versions';
12
13
  import { IndexedDBFileSystemProvider, ElectronFileSystemProvider, FileSystemContentContainer, createFileMediaProvider, openNativeFolder, restoreNativeFolder, removeDirectoryHandle, } from '@bendyline/docblocks/filesystem';
13
- import { isElectronHost, getDocblocksHost } from '@bendyline/docblocks/host';
14
+ import { isElectronHost, getDocBlocksHost } from '@bendyline/docblocks/host';
14
15
  import { ensureDefaultWorkspace, getWorkspace, listWorkspaces, removeWorkspace, saveWorkspace, touchWorkspace, } from '@bendyline/docblocks/workspace';
15
16
  import { AppMenu } from '../AppMenu/AppMenu.js';
16
17
  import { FileExplorer } from '../FileExplorer/FileExplorer.js';
17
18
  import { WorkspacePicker } from '../WorkspacePicker/WorkspacePicker.js';
18
19
  import { WorkspaceSettingsButton } from '../WorkspacePicker/WorkspaceSettingsButton.js';
20
+ import { WorkspaceSettingsDialog, } from '../WorkspacePicker/WorkspaceSettingsDialog.js';
19
21
  import { useAutoSave } from '../hooks/useAutoSave.js';
20
22
  import { ExportToolbarControls } from '../Export/ExportToolbarControls.js';
23
+ import { loadVersioningPreference, resolveVersioningEnabled, saveVersioningPreference, } from '../preferences/versioning.js';
21
24
  function useOsTheme() {
22
25
  const [dark, setDark] = useState(() => typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches);
23
26
  useEffect(() => {
@@ -90,6 +93,71 @@ function saveThemePreference(pref) {
90
93
  // ignore quota errors
91
94
  }
92
95
  }
96
+ const SIDEBAR_WIDTH_KEY = 'docblocks:sidebarWidth';
97
+ const SIDEBAR_WIDTH_DEFAULT = 260;
98
+ const SIDEBAR_WIDTH_MIN = 180;
99
+ const SIDEBAR_WIDTH_MAX = 600;
100
+ /** Drag below this many pixels and the sidebar collapses entirely —
101
+ * the editor takes the full width and a back-arrow appears in the
102
+ * toolbar so the user can pop the sidebar back open. Same UX as
103
+ * the existing mobile narrow-viewport flow. */
104
+ const SIDEBAR_COLLAPSE_THRESHOLD = 120;
105
+ function loadSidebarWidth() {
106
+ try {
107
+ const raw = localStorage.getItem(SIDEBAR_WIDTH_KEY);
108
+ if (raw === null)
109
+ return SIDEBAR_WIDTH_DEFAULT;
110
+ const n = Number(raw);
111
+ if (!Number.isFinite(n))
112
+ return SIDEBAR_WIDTH_DEFAULT;
113
+ return Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, n));
114
+ }
115
+ catch {
116
+ return SIDEBAR_WIDTH_DEFAULT;
117
+ }
118
+ }
119
+ function saveSidebarWidth(px) {
120
+ try {
121
+ localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(px)));
122
+ }
123
+ catch {
124
+ // ignore quota errors
125
+ }
126
+ }
127
+ const VIEW_PREFS_KEY = 'docblocks:viewPreferences';
128
+ const DEFAULT_VIEW_PREFS = {
129
+ outline: false,
130
+ inlinePreview: true,
131
+ showStatusBar: true,
132
+ };
133
+ function loadViewPreferences() {
134
+ try {
135
+ const raw = localStorage.getItem(VIEW_PREFS_KEY);
136
+ if (!raw)
137
+ return DEFAULT_VIEW_PREFS;
138
+ const parsed = JSON.parse(raw);
139
+ return {
140
+ outline: typeof parsed.outline === 'boolean' ? parsed.outline : DEFAULT_VIEW_PREFS.outline,
141
+ inlinePreview: typeof parsed.inlinePreview === 'boolean'
142
+ ? parsed.inlinePreview
143
+ : DEFAULT_VIEW_PREFS.inlinePreview,
144
+ showStatusBar: typeof parsed.showStatusBar === 'boolean'
145
+ ? parsed.showStatusBar
146
+ : DEFAULT_VIEW_PREFS.showStatusBar,
147
+ };
148
+ }
149
+ catch {
150
+ return DEFAULT_VIEW_PREFS;
151
+ }
152
+ }
153
+ function saveViewPreferences(prefs) {
154
+ try {
155
+ localStorage.setItem(VIEW_PREFS_KEY, JSON.stringify(prefs));
156
+ }
157
+ catch {
158
+ // ignore quota errors
159
+ }
160
+ }
93
161
  function dirnameOf(p) {
94
162
  const clean = p.replace(/^\/+/, '');
95
163
  const idx = clean.lastIndexOf('/');
@@ -100,6 +168,89 @@ function basenameOf(p) {
100
168
  const idx = clean.lastIndexOf('/');
101
169
  return idx === -1 ? clean : clean.slice(idx + 1);
102
170
  }
171
+ /** Strip the extension from a filename (`notes.md` -> `notes`). Matches
172
+ * squisq's `getDocBasename` convention so version snapshot filenames
173
+ * stay readable (`<basename>.<timestamp>.md`). */
174
+ function stripExtension(name) {
175
+ return name.replace(/\.[^.]+$/, '');
176
+ }
177
+ function normaliseProviderPath(p) {
178
+ return '/' + p.replace(/^\/+/, '');
179
+ }
180
+ function sameProviderPath(a, b) {
181
+ return normaliseProviderPath(a) === normaliseProviderPath(b);
182
+ }
183
+ /** Portable relative link from one workspace file to another. Walks up
184
+ * from the source's directory and back down to the target so the link
185
+ * survives folder reshuffles (`../sibling.md`, `subfolder/child.md`,
186
+ * `resume.md` for siblings at the workspace root). */
187
+ function relativeMarkdownLink(fromFile, toFile) {
188
+ const fromParts = fromFile.replace(/^\/+/, '').split('/').filter(Boolean);
189
+ const toParts = toFile.replace(/^\/+/, '').split('/').filter(Boolean);
190
+ const fromDir = fromParts.slice(0, -1);
191
+ const toDir = toParts.slice(0, -1);
192
+ const toBase = toParts[toParts.length - 1] ?? '';
193
+ let common = 0;
194
+ while (common < fromDir.length && common < toDir.length && fromDir[common] === toDir[common]) {
195
+ common++;
196
+ }
197
+ const ups = fromDir.length - common;
198
+ const downs = [...toDir.slice(common), toBase];
199
+ const parts = [...Array(ups).fill('..'), ...downs];
200
+ return parts.length === 0 ? toBase : parts.join('/');
201
+ }
202
+ /** Recursively collect all `.md` files reachable from `root`. Skips
203
+ * Word-style `*_files/` asset companions, dotfiles, and `node_modules`
204
+ * so the candidate list stays workspace-meaningful. */
205
+ async function collectMarkdownFiles(fs, root) {
206
+ const out = [];
207
+ const visited = new Set();
208
+ async function walk(dir) {
209
+ if (visited.has(dir))
210
+ return;
211
+ visited.add(dir);
212
+ let entries;
213
+ try {
214
+ entries = await fs.readDirectory(dir);
215
+ }
216
+ catch {
217
+ return;
218
+ }
219
+ for (const entry of entries) {
220
+ if (entry.kind === 'directory') {
221
+ const lower = entry.name.toLowerCase();
222
+ if (lower.startsWith('.'))
223
+ continue;
224
+ if (lower === 'node_modules')
225
+ continue;
226
+ if (lower.endsWith('_files'))
227
+ continue;
228
+ await walk(entry.path);
229
+ }
230
+ else if (entry.kind === 'file') {
231
+ if (entry.name.toLowerCase().endsWith('.md'))
232
+ out.push(entry);
233
+ }
234
+ }
235
+ }
236
+ await walk(root);
237
+ return out;
238
+ }
239
+ async function createElectronProviderFromWorkspace(ws) {
240
+ if (!ws.rootPath)
241
+ return null;
242
+ try {
243
+ await getDocBlocksHost().workspaces.register({
244
+ id: ws.id,
245
+ name: ws.name,
246
+ rootPath: ws.rootPath,
247
+ });
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ return new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
253
+ }
103
254
  function useIsMobile(breakpoint = 768) {
104
255
  const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.matchMedia(`(max-width: ${breakpoint}px)`).matches);
105
256
  useEffect(() => {
@@ -116,7 +267,7 @@ function FolderGlyph() {
116
267
  function FileGlyph() {
117
268
  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
269
  }
119
- export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
270
+ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersioning = true, versionBasename, versioningPrunePolicy, versioningAutoSaveIdleMs, onSaveVersion, versioningRef, }) {
120
271
  const osTheme = useOsTheme();
121
272
  const [themePreference, setThemePreference] = useState(loadThemePreference);
122
273
  // "System default" (auto) always follows the OS — the host's theme prop
@@ -126,10 +277,132 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
126
277
  setThemePreference(pref);
127
278
  saveThemePreference(pref);
128
279
  }, []);
280
+ const [viewPreferences, setViewPreferences] = useState(loadViewPreferences);
281
+ const handleViewPreferencesChange = useCallback((prefs) => {
282
+ setViewPreferences(prefs);
283
+ saveViewPreferences(prefs);
284
+ }, []);
129
285
  const isMobile = useIsMobile();
130
286
  const [mobileShowEditor, setMobileShowEditor] = useState(false);
287
+ // Sidebar width — persisted across sessions, dragged via the resizer
288
+ // between sidebar and editor area. We track the "live" width during a
289
+ // drag in a ref so each mousemove doesn't trigger a state update; only
290
+ // setState on commit so React doesn't churn through every pixel.
291
+ const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth);
292
+ // When the user drags the resizer below SIDEBAR_COLLAPSE_THRESHOLD,
293
+ // we switch the layout into single-pane "compact" mode — same UX as
294
+ // the mobile narrow-viewport flow, where only the sidebar OR the
295
+ // editor is visible at a time and a back-arrow in the toolbar pops
296
+ // between them. A "Restore split view" button on the editor toolbar
297
+ // exits compact mode; on real mobile that button is suppressed
298
+ // because there's not enough viewport for side-by-side. Not
299
+ // persisted across reloads. */
300
+ const [compactLayout, setCompactLayout] = useState(false);
301
+ const effectiveCompact = isMobile || compactLayout;
302
+ const sidebarRef = useRef(null);
303
+ const dragStateRef = useRef(null);
304
+ const handleResizerPointerDown = useCallback((e) => {
305
+ if (e.button !== 0)
306
+ return;
307
+ dragStateRef.current = { startX: e.clientX, startWidth: sidebarWidth };
308
+ e.preventDefault();
309
+ // Disable text selection + flip the body cursor for the duration
310
+ // of the drag so the col-resize cursor stays visible even when the
311
+ // pointer slips off the 7px hit area. The custom cursor lives in
312
+ // the CSS class so Windows' white-cursor preference doesn't make
313
+ // the dragging cursor invisible against the light chrome.
314
+ document.body.style.userSelect = 'none';
315
+ document.body.classList.add('db-resizing-sidebar');
316
+ let lastRaw = sidebarWidth;
317
+ const onMove = (ev) => {
318
+ const drag = dragStateRef.current;
319
+ if (!drag)
320
+ return;
321
+ lastRaw = drag.startWidth + (ev.clientX - drag.startX);
322
+ if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD && sidebarRef.current) {
323
+ // Below threshold — preview the collapse by snapping to the
324
+ // minimum width and fading the sidebar, so the user can see
325
+ // they've crossed into "release to collapse" territory.
326
+ sidebarRef.current.style.width = `${SIDEBAR_WIDTH_MIN}px`;
327
+ sidebarRef.current.style.opacity = '0.45';
328
+ return;
329
+ }
330
+ const clamped = Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, lastRaw));
331
+ if (sidebarRef.current) {
332
+ // Update the DOM directly during the drag for jank-free
333
+ // dragging; React state syncs on release.
334
+ sidebarRef.current.style.width = `${clamped}px`;
335
+ sidebarRef.current.style.opacity = '';
336
+ }
337
+ };
338
+ const onUp = () => {
339
+ document.removeEventListener('pointermove', onMove);
340
+ document.removeEventListener('pointerup', onUp);
341
+ document.body.style.userSelect = '';
342
+ document.body.classList.remove('db-resizing-sidebar');
343
+ if (sidebarRef.current) {
344
+ sidebarRef.current.style.opacity = '';
345
+ }
346
+ if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD) {
347
+ // Released below threshold — switch to compact (single-pane)
348
+ // layout focused on the editor. Keep the persisted
349
+ // sidebarWidth so exiting compact mode restores it.
350
+ setCompactLayout(true);
351
+ setMobileShowEditor(true);
352
+ }
353
+ else {
354
+ const finalWidth = sidebarRef.current?.getBoundingClientRect().width;
355
+ if (finalWidth) {
356
+ const clamped = Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, Math.round(finalWidth)));
357
+ setSidebarWidth(clamped);
358
+ saveSidebarWidth(clamped);
359
+ }
360
+ }
361
+ dragStateRef.current = null;
362
+ };
363
+ document.addEventListener('pointermove', onMove);
364
+ document.addEventListener('pointerup', onUp);
365
+ }, [sidebarWidth]);
131
366
  const [provider, setProvider] = useState(null);
132
367
  const [activeWorkspaceId, setActiveWorkspaceId] = useState(null);
368
+ const [activeWorkspaceDescriptor, setActiveWorkspaceDescriptor] = useState(null);
369
+ // Re-fetch the descriptor whenever the active id (or its versioning
370
+ // override) changes. `descriptorRefreshKey` is bumped after writes so
371
+ // the resolver picks up the updated override without remounting.
372
+ const [descriptorRefreshKey, setDescriptorRefreshKey] = useState(0);
373
+ useEffect(() => {
374
+ let cancelled = false;
375
+ if (!activeWorkspaceId) {
376
+ setActiveWorkspaceDescriptor(null);
377
+ return;
378
+ }
379
+ void getWorkspace(activeWorkspaceId).then((ws) => {
380
+ if (!cancelled)
381
+ setActiveWorkspaceDescriptor(ws);
382
+ });
383
+ return () => {
384
+ cancelled = true;
385
+ };
386
+ }, [activeWorkspaceId, descriptorRefreshKey]);
387
+ const [workspaceSettingsOpen, setWorkspaceSettingsOpen] = useState(false);
388
+ const [versioningPreference, setVersioningPreference] = useState(loadVersioningPreference);
389
+ const handleVersioningPreferenceChange = useCallback((pref) => {
390
+ setVersioningPreference(pref);
391
+ saveVersioningPreference(pref);
392
+ }, []);
393
+ const effectiveVersioning = allowVersioning && resolveVersioningEnabled(activeWorkspaceDescriptor, versioningPreference);
394
+ const handleOpenWorkspaceSettings = useCallback(() => {
395
+ if (!activeWorkspaceDescriptor)
396
+ return;
397
+ setWorkspaceSettingsOpen(true);
398
+ }, [activeWorkspaceDescriptor]);
399
+ const handleWorkspaceVersioningOverrideChange = useCallback(async (override) => {
400
+ if (!activeWorkspaceDescriptor)
401
+ return;
402
+ await saveWorkspace({ ...activeWorkspaceDescriptor, versioningOverride: override });
403
+ setDescriptorRefreshKey((k) => k + 1);
404
+ setWorkspaceSettingsOpen(false);
405
+ }, [activeWorkspaceDescriptor]);
133
406
  const [selectedFile, setSelectedFile] = useState(null);
134
407
  const [selectedFolder, setSelectedFolder] = useState(null);
135
408
  const [folderEntries, setFolderEntries] = useState([]);
@@ -139,6 +412,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
139
412
  const [initialView, setInitialView] = useState('wysiwyg');
140
413
  /** Suppress popstate handling during programmatic navigation. */
141
414
  const skipPopState = useRef(false);
415
+ const lastLocalSaveRef = useRef(null);
142
416
  /**
143
417
  * Per-file media container: for `notes.md`, images live in
144
418
  * `notes_files/` next to the markdown. Created lazily on first write,
@@ -147,6 +421,22 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
147
421
  */
148
422
  const mediaContainerRef = useRef(null);
149
423
  const [mediaProvider, setMediaProvider] = useState(null);
424
+ /**
425
+ * Per-document container scoped to `<basename>_files/`. This is what
426
+ * the editor uses for version history (`.versions/` lives here) and
427
+ * for audio mapping (MP3 / timing.json discovery). Distinct from
428
+ * `mediaContainerRef` which is scoped to the parent directory so the
429
+ * media provider can write `notes_files/image.png` paths that stay
430
+ * portable in the markdown.
431
+ */
432
+ const versionsContainerRef = useRef(null);
433
+ const [versionsContainer, setVersionsContainer] = useState(null);
434
+ /** Cache of .md files in the active workspace, used to power the
435
+ * squisq link-dialog's document picker. Lazily populated on first
436
+ * provider call; cleared whenever the backing filesystem changes so
437
+ * workspace switches don't surface stale neighbours. A pending Promise
438
+ * during in-flight walks lets concurrent calls share the same scan. */
439
+ const mdFileCacheRef = useRef(null);
150
440
  /** Push a new history entry with the given hash. */
151
441
  const pushHash = useCallback((wsId, filePath) => {
152
442
  const hash = buildHash(wsId, filePath);
@@ -164,14 +454,9 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
164
454
  return null;
165
455
  let fsProvider = null;
166
456
  if (ws.type === 'electron-native') {
167
- if (!ws.rootPath)
457
+ fsProvider = await createElectronProviderFromWorkspace(ws);
458
+ if (!fsProvider)
168
459
  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
460
  }
176
461
  else if (ws.type === 'native') {
177
462
  const restored = await restoreNativeFolder(ws.id);
@@ -219,10 +504,12 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
219
504
  }, [pushHash]);
220
505
  const seedWelcomeFile = useCallback(async (fs) => {
221
506
  const entries = await fs.readDirectory('/');
222
- // If the only file is the welcome doc, auto-select it
507
+ // If the only file is the welcome doc, auto-select it.
508
+ // Match either casing so workspaces seeded before the rename
509
+ // (aboutDocblocks.md) keep working alongside new ones (aboutDocBlocks.md).
223
510
  if (entries.length === 1 &&
224
511
  entries[0].kind === 'file' &&
225
- entries[0].path.replace(/^\//, '') === 'aboutDocblocks.md') {
512
+ entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md') {
226
513
  const aboutPath = entries[0].path;
227
514
  const content = await fs.readFile(aboutPath);
228
515
  if (content !== null) {
@@ -238,20 +525,20 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
238
525
  }
239
526
  if (entries.length > 0)
240
527
  return;
241
- const welcomePath = '/aboutDocblocks.md';
528
+ const welcomePath = '/aboutDocBlocks.md';
242
529
  const welcomeContent = [
243
530
  '# Welcome to DocBlocks',
244
531
  '',
245
- 'DocBlocks is a browser-based markdown document editor that lets you create, organize, and manage your documents right in the browser.',
532
+ 'DocBlocks is a free browser-based markdown document editor that lets you create, organize, and manage your documents right in the browser. What you write here can become a Word or PDF doc, a slide deck, an e-book, or a video.',
533
+ '',
534
+ 'Simple to write. Beautiful wherever it goes.',
246
535
  '',
247
536
  '## Features',
248
537
  '',
249
- '- **Rich Markdown Editing** — Write in a visual editor or switch to raw markdown anytime',
250
- '- **Workspaces** — Organize your documents into separate workspaces',
538
+ '- **Rich Markdown Editing** — Write in a visual editor or switch to raw markdown anytime. Use section annotations to change the visualization for blocks of content.',
539
+ '- **Workspaces** — Organize your documents into separate workspaces in the browser or on your device.',
540
+ '- **Useful Everywhere** — Your content is usable across multiple formats — Microsoft Word .docx, PowerPoint, PDF, HTML, EPUB e-books, and Markdown.',
251
541
  '- **Playback & Video** — Preview your documents as rich visual presentations and export them as MP4 video',
252
- '- **Export Anywhere** — Export documents to PDF, Word, PowerPoint, HTML, or Markdown with theme options',
253
- '- **Local Storage** — Your documents are stored in your browser using temporary browser storage (backup often!)',
254
- '- **Device Folders** — Create workspaces based on folders on your computer',
255
542
  '- **No BS** — Free, no ads, no accounts, no tracking - everything runs locally in your browser',
256
543
  '',
257
544
  '## Getting Started',
@@ -260,7 +547,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
260
547
  '2. Start writing in markdown — the editor supports headings, lists, links, images, and more',
261
548
  '3. Your work is saved automatically',
262
549
  '',
263
- 'Built with [Squiggly Square](https://github.com/nicoth-in/squisq) by [Bendyline](https://bendyline.com).',
550
+ 'Built with [Squiggly Square](https://github.com/bendyline/squisq) by [Bendyline](https://bendyline.com).',
264
551
  ].join('\n');
265
552
  await fs.writeFile(welcomePath, welcomeContent);
266
553
  setSelectedFile(welcomePath);
@@ -305,14 +592,9 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
305
592
  const sorted = [...candidates].sort((a, b) => (b.lastOpened ?? '').localeCompare(a.lastOpened ?? ''));
306
593
  for (const ws of sorted) {
307
594
  if (ws.type === 'electron-native') {
308
- if (!ws.rootPath)
595
+ const p = await createElectronProviderFromWorkspace(ws);
596
+ if (!p)
309
597
  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
598
  await touchWorkspace(ws.id);
317
599
  fsProvider = p;
318
600
  setProvider(p);
@@ -342,7 +624,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
342
624
  if (electron) {
343
625
  // Desktop: ask the host for the default folder workspace
344
626
  // (creates ~/Documents/DocBlocks on first launch).
345
- const info = await getDocblocksHost().workspaces.getDefault();
627
+ const info = await getDocBlocksHost().workspaces.getDefault();
346
628
  const descriptor = {
347
629
  id: info.id,
348
630
  name: info.name,
@@ -400,44 +682,154 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
400
682
  window.addEventListener('click', handler, true);
401
683
  return () => window.removeEventListener('click', handler, true);
402
684
  }, [activeWorkspaceId, selectedFile]);
403
- // Auto-save current file
404
- useAutoSave(provider, selectedFile, editorContent);
685
+ const handleAutoSaved = useCallback((filePath, savedContent) => {
686
+ lastLocalSaveRef.current = {
687
+ filePath: normaliseProviderPath(filePath),
688
+ content: savedContent,
689
+ savedAt: Date.now(),
690
+ };
691
+ }, []);
692
+ // Auto-save current file. The returned `flush` is called from the
693
+ // Ctrl/Cmd+S handler below so the user gets immediate confirmation.
694
+ const { flush: flushAutoSave } = useAutoSave(provider, selectedFile, editorContent, 500, handleAutoSaved);
695
+ // Comfort-blanket Ctrl/Cmd+S: flushes any pending autosave and pops a
696
+ // small "auto-save confirmed" toast. Files are already saved on every
697
+ // keystroke (debounced) — this is purely UX reassurance for users who
698
+ // muscle-memory hit Save.
699
+ const [saveToastVisible, setSaveToastVisible] = useState(false);
700
+ const saveToastTimerRef = useRef(null);
701
+ useEffect(() => {
702
+ const onKey = (e) => {
703
+ const sKey = e.key === 's' || e.key === 'S';
704
+ const accel = e.ctrlKey || e.metaKey;
705
+ if (!sKey || !accel || e.altKey)
706
+ return;
707
+ e.preventDefault();
708
+ e.stopPropagation();
709
+ void flushAutoSave().catch(() => undefined);
710
+ setSaveToastVisible(true);
711
+ if (saveToastTimerRef.current)
712
+ clearTimeout(saveToastTimerRef.current);
713
+ saveToastTimerRef.current = setTimeout(() => setSaveToastVisible(false), 1800);
714
+ };
715
+ window.addEventListener('keydown', onKey, true);
716
+ return () => window.removeEventListener('keydown', onKey, true);
717
+ }, [flushAutoSave]);
718
+ useEffect(() => {
719
+ return () => {
720
+ if (saveToastTimerRef.current)
721
+ clearTimeout(saveToastTimerRef.current);
722
+ };
723
+ }, []);
405
724
  // Per-file media: for `notes.md` images live in `notes_files/` beside it.
406
725
  // Rebuilds whenever the provider or selected file changes.
407
726
  useEffect(() => {
408
727
  if (!provider || !selectedFile) {
409
728
  mediaContainerRef.current = null;
729
+ versionsContainerRef.current = null;
410
730
  setMediaProvider(null);
731
+ setVersionsContainer(null);
411
732
  return;
412
733
  }
413
734
  const parentDir = dirnameOf(selectedFile);
414
735
  const base = basenameOf(selectedFile);
736
+ const baseNoExt = base.replace(/\.[^.]+$/, '');
415
737
  const container = new FileSystemContentContainer(provider, parentDir);
738
+ const vPrefix = parentDir ? `${parentDir}/${baseNoExt}_files` : `${baseNoExt}_files`;
739
+ const vContainer = new FileSystemContentContainer(provider, vPrefix);
416
740
  const mp = createFileMediaProvider(container, base);
417
741
  mediaContainerRef.current = container;
742
+ versionsContainerRef.current = vContainer;
418
743
  setMediaProvider(mp);
744
+ setVersionsContainer(vContainer);
419
745
  return () => {
420
746
  mp.dispose();
421
747
  };
422
748
  }, [provider, selectedFile]);
749
+ // Invalidate the document-link candidate cache when the backing
750
+ // workspace changes — otherwise the link dialog would surface
751
+ // neighbours from a previously-open workspace.
752
+ useEffect(() => {
753
+ mdFileCacheRef.current = null;
754
+ }, [provider]);
755
+ /** Powers the squisq link dialog's "Browse documents" picker. Returns
756
+ * workspace `.md` neighbours filtered by `query`, with paths expressed
757
+ * relative to the currently-open document so the link survives folder
758
+ * moves. The first call seeds an in-memory cache; subsequent calls
759
+ * filter against it. */
760
+ const documentLinkProvider = useCallback(async (query) => {
761
+ if (!provider || !selectedFile)
762
+ return [];
763
+ if (!mdFileCacheRef.current) {
764
+ mdFileCacheRef.current = collectMarkdownFiles(provider, '').catch(() => []);
765
+ }
766
+ const entries = await mdFileCacheRef.current;
767
+ const q = query.trim().toLowerCase();
768
+ const candidates = [];
769
+ for (const entry of entries) {
770
+ if (entry.kind !== 'file')
771
+ continue;
772
+ if (sameProviderPath(entry.path, selectedFile))
773
+ continue;
774
+ const label = entry.name.replace(/\.md$/i, '');
775
+ const path = relativeMarkdownLink(selectedFile, entry.path);
776
+ if (q && !label.toLowerCase().includes(q) && !path.toLowerCase().includes(q))
777
+ continue;
778
+ const dir = dirnameOf(entry.path);
779
+ candidates.push(dir ? { path, label, description: dir } : { path, label });
780
+ }
781
+ // Stable alphabetical order keeps the picker predictable across
782
+ // re-opens; the dialog can re-sort or rank on its own if needed.
783
+ candidates.sort((a, b) => a.label.localeCompare(b.label));
784
+ return candidates;
785
+ }, [provider, selectedFile]);
786
+ // Expose a DocumentVersionManager via versioningRef when requested.
787
+ useEffect(() => {
788
+ const ref = versioningRef;
789
+ if (!ref)
790
+ return;
791
+ const assign = (mgr) => {
792
+ if (typeof ref === 'function')
793
+ ref(mgr);
794
+ else
795
+ ref.current = mgr;
796
+ };
797
+ if (!effectiveVersioning || !versionsContainer || !selectedFile) {
798
+ assign(null);
799
+ return;
800
+ }
801
+ const base = stripExtension(basenameOf(selectedFile));
802
+ const mgr = new DocumentVersionManager(versionsContainer, {
803
+ basename: versionBasename ?? base,
804
+ });
805
+ assign(mgr);
806
+ return () => assign(null);
807
+ }, [versioningRef, effectiveVersioning, versionBasename, selectedFile, versionsContainer]);
423
808
  // React to external file changes watched by the Electron host (chokidar).
424
809
  useEffect(() => {
425
810
  if (!isElectronHost())
426
811
  return;
427
812
  if (!provider || !(provider instanceof ElectronFileSystemProvider))
428
813
  return;
429
- const unwatch = provider.watch(() => {
814
+ const unwatch = provider.watch((changedPath) => {
430
815
  setExplorerKey((k) => k + 1);
816
+ if (!selectedFile || !sameProviderPath(changedPath, selectedFile))
817
+ return;
431
818
  // 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
- }
819
+ (async () => {
820
+ const content = await provider.readFile(selectedFile);
821
+ if (content === null || content === editorContent)
822
+ return;
823
+ const localSave = lastLocalSaveRef.current;
824
+ if (localSave &&
825
+ sameProviderPath(localSave.filePath, selectedFile) &&
826
+ localSave.content === content &&
827
+ Date.now() - localSave.savedAt < 5000) {
828
+ return;
829
+ }
830
+ setEditorContent(content);
831
+ setEditorKey((k) => k + 1);
832
+ })();
441
833
  });
442
834
  return unwatch;
443
835
  }, [provider, selectedFile, editorContent]);
@@ -445,14 +837,9 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
445
837
  await touchWorkspace(ws.id);
446
838
  let nextProvider = null;
447
839
  if (ws.type === 'electron-native') {
448
- if (!ws.rootPath)
840
+ nextProvider = await createElectronProviderFromWorkspace(ws);
841
+ if (!nextProvider)
449
842
  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
843
  }
457
844
  else if (ws.type === 'native') {
458
845
  const restored = await restoreNativeFolder(ws.id);
@@ -477,7 +864,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
477
864
  const handleOpenFolder = useCallback(async () => {
478
865
  try {
479
866
  if (isElectronHost()) {
480
- const info = await getDocblocksHost().workspaces.pickFolder();
867
+ const info = await getDocBlocksHost().workspaces.pickFolder();
481
868
  if (!info)
482
869
  return; // user cancelled
483
870
  const descriptor = {
@@ -542,14 +929,14 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
542
929
  return;
543
930
  const ws = await getWorkspace(activeWorkspaceId);
544
931
  if (ws?.type === 'electron-native' && ws.rootPath) {
545
- await getDocblocksHost().shell.revealInFolder(ws.rootPath);
932
+ await getDocBlocksHost().shell.revealInFolder(ws.rootPath);
546
933
  }
547
934
  }, [activeWorkspaceId]);
548
935
  // Subscribe to native menu commands (Electron host).
549
936
  useEffect(() => {
550
937
  if (!isElectronHost())
551
938
  return;
552
- const host = getDocblocksHost();
939
+ const host = getDocBlocksHost();
553
940
  return host.onMenuCommand((cmd) => {
554
941
  switch (cmd) {
555
942
  case 'file:new':
@@ -584,7 +971,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
584
971
  useEffect(() => {
585
972
  if (!isElectronHost())
586
973
  return;
587
- const host = getDocblocksHost();
974
+ const host = getDocBlocksHost();
588
975
  return host.onOpenRequest(async (req) => {
589
976
  if (req.filePath) {
590
977
  const workspaces = (await listWorkspaces()).filter((w) => w.type === 'electron-native' && w.rootPath);
@@ -635,10 +1022,10 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
635
1022
  setEditorKey((k) => k + 1);
636
1023
  pushHash(activeWorkspaceId, path);
637
1024
  saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
638
- if (isMobile)
1025
+ if (effectiveCompact)
639
1026
  setMobileShowEditor(true);
640
1027
  }
641
- }, [provider, activeWorkspaceId, pushHash, isMobile]);
1028
+ }, [provider, activeWorkspaceId, pushHash, effectiveCompact]);
642
1029
  const handleTreeChange = useCallback(async () => {
643
1030
  if (!provider)
644
1031
  return;
@@ -736,30 +1123,138 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
736
1123
  // Bump key to trigger re-render — workspace name is read from the descriptor, not the provider
737
1124
  setEditorKey((k) => k + 1);
738
1125
  }, [activeWorkspaceId]);
1126
+ /**
1127
+ * Walk a FileSystemProvider and copy every file into `container` under
1128
+ * `pathPrefix` (no leading slash; empty string for the root). Used by
1129
+ * both single- and all-workspace downloads.
1130
+ */
1131
+ const copyProviderToContainer = useCallback(async (src, container, pathPrefix) => {
1132
+ const encoder = new TextEncoder();
1133
+ const stack = ['/'];
1134
+ while (stack.length > 0) {
1135
+ const dir = stack.pop();
1136
+ const entries = await src.readDirectory(dir);
1137
+ for (const entry of entries) {
1138
+ if (entry.kind === 'directory') {
1139
+ stack.push(entry.path);
1140
+ continue;
1141
+ }
1142
+ const rel = entry.path.replace(/^\/+/, '');
1143
+ const zipPath = pathPrefix ? `${pathPrefix}/${rel}` : rel;
1144
+ // Files may be stored as text (writeFile) or binary (writeBinary);
1145
+ // try binary first, fall back to text and encode as UTF-8.
1146
+ const binary = await src.readBinary(entry.path);
1147
+ if (binary) {
1148
+ await container.writeFile(zipPath, binary);
1149
+ continue;
1150
+ }
1151
+ const text = await src.readFile(entry.path);
1152
+ if (text !== null) {
1153
+ await container.writeFile(zipPath, encoder.encode(text));
1154
+ }
1155
+ }
1156
+ }
1157
+ }, []);
739
1158
  const handleDownloadWorkspace = useCallback(async () => {
740
1159
  if (!provider)
741
1160
  return;
742
1161
  try {
743
- const entries = await provider.readDirectory('/');
744
- const lines = [];
745
- for (const entry of entries) {
746
- if (entry.kind === 'file') {
747
- const content = await provider.readFile(entry.path);
748
- lines.push(`--- ${entry.path} ---\n${content ?? ''}\n`);
1162
+ const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1163
+ import('@bendyline/squisq/storage'),
1164
+ import('@bendyline/squisq-formats/container'),
1165
+ ]);
1166
+ const container = new MemoryContentContainer();
1167
+ await copyProviderToContainer(provider, container, '');
1168
+ const blob = await containerToZip(container);
1169
+ const url = URL.createObjectURL(blob);
1170
+ const a = document.createElement('a');
1171
+ a.href = url;
1172
+ const safeName = (provider.label || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
1173
+ a.download = `${safeName}.zip`;
1174
+ a.click();
1175
+ URL.revokeObjectURL(url);
1176
+ }
1177
+ catch (err) {
1178
+ console.error('Failed to download workspace', err);
1179
+ alert('Failed to download workspace. See console for details.');
1180
+ }
1181
+ }, [provider, copyProviderToContainer]);
1182
+ /**
1183
+ * Bundle every workspace the host can open without further prompting
1184
+ * into a single zip, with each workspace nested under its own folder.
1185
+ * Native (browser-picked) workspaces whose handle hasn't been re-granted
1186
+ * for this session are skipped — restoring them would require a user
1187
+ * gesture per workspace.
1188
+ */
1189
+ const handleDownloadAllWorkspaces = useCallback(async () => {
1190
+ try {
1191
+ const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1192
+ import('@bendyline/squisq/storage'),
1193
+ import('@bendyline/squisq-formats/container'),
1194
+ ]);
1195
+ const electron = isElectronHost();
1196
+ const all = await listWorkspaces();
1197
+ const candidates = all.filter((w) => electron ? w.type === 'electron-native' : w.type !== 'electron-native');
1198
+ if (candidates.length === 0) {
1199
+ alert('No workspaces to download.');
1200
+ return;
1201
+ }
1202
+ const container = new MemoryContentContainer();
1203
+ const usedFolders = new Set();
1204
+ const skipped = [];
1205
+ for (const ws of candidates) {
1206
+ let p = null;
1207
+ try {
1208
+ if (ws.type === 'electron-native') {
1209
+ p = await createElectronProviderFromWorkspace(ws);
1210
+ }
1211
+ else if (ws.type === 'native') {
1212
+ // Restore without prompting — only succeeds when the browser
1213
+ // still remembers the granted handle for this origin/session.
1214
+ p = await restoreNativeFolder(ws.id);
1215
+ }
1216
+ else {
1217
+ p = new IndexedDBFileSystemProvider(ws.id, ws.name);
1218
+ }
749
1219
  }
1220
+ catch (err) {
1221
+ console.warn(`Skipping workspace ${ws.name}:`, err);
1222
+ }
1223
+ if (!p) {
1224
+ skipped.push(ws.name);
1225
+ continue;
1226
+ }
1227
+ // Pick a unique, filesystem-safe folder name per workspace.
1228
+ const base = (ws.name || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
1229
+ let folder = base;
1230
+ let suffix = 2;
1231
+ while (usedFolders.has(folder)) {
1232
+ folder = `${base} (${suffix++})`;
1233
+ }
1234
+ usedFolders.add(folder);
1235
+ await copyProviderToContainer(p, container, folder);
750
1236
  }
751
- const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
1237
+ if (usedFolders.size === 0) {
1238
+ alert('No workspaces could be opened for download.');
1239
+ return;
1240
+ }
1241
+ const blob = await containerToZip(container);
752
1242
  const url = URL.createObjectURL(blob);
753
1243
  const a = document.createElement('a');
754
1244
  a.href = url;
755
- a.download = 'workspace.txt';
1245
+ const stamp = new Date().toISOString().slice(0, 10);
1246
+ a.download = `docblocks-workspaces-${stamp}.zip`;
756
1247
  a.click();
757
1248
  URL.revokeObjectURL(url);
1249
+ if (skipped.length > 0) {
1250
+ alert(`Downloaded ${usedFolders.size} workspace(s). Skipped ${skipped.length} that require re-granting access: ${skipped.join(', ')}.`);
1251
+ }
758
1252
  }
759
- catch {
760
- // ignore
1253
+ catch (err) {
1254
+ console.error('Failed to download all workspaces', err);
1255
+ alert('Failed to download all workspaces. See console for details.');
761
1256
  }
762
- }, [provider]);
1257
+ }, [copyProviderToContainer]);
763
1258
  const handleRemoveWorkspace = useCallback(async () => {
764
1259
  if (!activeWorkspaceId)
765
1260
  return;
@@ -771,7 +1266,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
771
1266
  const ws = await getWorkspace(activeWorkspaceId);
772
1267
  if (ws?.type === 'electron-native') {
773
1268
  try {
774
- await getDocblocksHost().workspaces.unregister(activeWorkspaceId);
1269
+ await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
775
1270
  }
776
1271
  catch {
777
1272
  // ignore — host cleanup is best-effort
@@ -789,7 +1284,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
789
1284
  await handleWorkspaceSelect(next);
790
1285
  }
791
1286
  else if (electron) {
792
- const info = await getDocblocksHost().workspaces.getDefault();
1287
+ const info = await getDocBlocksHost().workspaces.getDefault();
793
1288
  const descriptor = {
794
1289
  id: info.id,
795
1290
  name: info.name,
@@ -819,6 +1314,6 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
819
1314
  setEditorKey((k) => k + 1);
820
1315
  }
821
1316
  }, [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." }) })) }))] }) }));
1317
+ return (_jsxs("div", { className: `db-shell${effectiveCompact ? ' db-shell--mobile' : ''}`, "data-theme": resolvedTheme, children: [saveToastVisible && (_jsx("div", { className: "db-save-toast", role: "status", "aria-live": "polite", children: "Autosaved. You're all set." })), workspaceSettingsOpen && activeWorkspaceDescriptor && (_jsx(WorkspaceSettingsDialog, { workspace: activeWorkspaceDescriptor, globalVersioningPreference: versioningPreference, onChange: handleWorkspaceVersioningOverrideChange, onClose: () => setWorkspaceSettingsOpen(false) })), _jsxs("div", { style: { display: 'flex', flex: 1, overflow: 'hidden' }, children: [(!effectiveCompact || !mobileShowEditor) && (_jsxs("div", { ref: sidebarRef, className: "db-shell-sidebar", style: effectiveCompact ? undefined : { width: `${sidebarWidth}px` }, children: [_jsxs("div", { className: "db-shell-sidebar-header", children: [_jsx(AppMenu, { logoUrl: logoUrl, themePreference: themePreference, onThemeChange: handleThemeChange, versioningPreference: versioningPreference, onVersioningPreferenceChange: handleVersioningPreferenceChange, onDownloadAllWorkspaces: handleDownloadAllWorkspaces }), _jsx(WorkspacePicker, { activeWorkspaceId: activeWorkspaceId, onSelect: handleWorkspaceSelect, onOpenFolder: handleOpenFolder }), _jsx(WorkspaceSettingsButton, { onSettings: handleOpenWorkspaceSettings, 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" }) })] })), !effectiveCompact && (_jsx("div", { className: "db-shell-sidebar-resizer", role: "separator", "aria-orientation": "vertical", "aria-label": "Resize sidebar", onPointerDown: handleResizerPointerDown })), (!effectiveCompact || 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, fileName: selectedFile, onChange: handleEditorChange, theme: resolvedTheme, height: "100%", outlineWidth: 280, mediaProvider: mediaProvider, documentLinkProvider: documentLinkProvider, container: versionsContainer ?? undefined, allowVersioning: effectiveVersioning, viewPreferences: viewPreferences, onViewPreferencesChange: handleViewPreferencesChange, versionBasename: versionBasename ?? stripExtension(basenameOf(selectedFile)), versioningPrunePolicy: versioningPrunePolicy, versioningAutoSaveIdleMs: versioningAutoSaveIdleMs, onSaveVersion: onSaveVersion, toolbarSlotLeft: effectiveCompact ? (_jsx("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), "aria-label": "Show file list", children: _jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }) })) : undefined, toolbarSlotRight: _jsxs(_Fragment, { children: [compactLayout && !isMobile && (_jsx("button", { className: "db-restore-split", onClick: () => setCompactLayout(false), "aria-label": "Restore split view", title: "Restore split view", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("rect", { x: "1.5", y: "2.5", width: "13", height: "11", rx: "1" }), _jsx("line", { x1: "6", y1: "2.5", x2: "6", y2: "13.5" })] }) })), _jsx(ExportToolbarControls, { selectedFile: selectedFile, mediaContainer: mediaContainerRef.current })] }) }, `${selectedFile}-${editorKey}`) })) : selectedFolder ? (_jsxs("div", { className: "db-folder-view", children: [effectiveCompact && (_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))) }))] })) : (_jsxs("div", { className: "db-shell-empty", children: [effectiveCompact && (_jsxs("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), children: [_jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }), "Back to files"] })), _jsx("p", { children: "Select a file to start editing, or create a new one." })] })) }))] })] }));
823
1318
  }
824
1319
  //# sourceMappingURL=DocBlocksShell.js.map