@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.
- package/dist/AppMenu/AppMenu.d.ts +11 -1
- package/dist/AppMenu/AppMenu.d.ts.map +1 -1
- package/dist/AppMenu/AppMenu.js +2 -2
- package/dist/AppMenu/AppMenu.js.map +1 -1
- package/dist/DocBlocksShell/DocBlocksShell.d.ts +21 -1
- package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
- package/dist/DocBlocksShell/DocBlocksShell.js +562 -67
- package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
- package/dist/Export/ExportDialog.d.ts.map +1 -1
- package/dist/Export/ExportDialog.js +104 -5
- package/dist/Export/ExportDialog.js.map +1 -1
- package/dist/Export/ExportToolbarControls.d.ts.map +1 -1
- package/dist/Export/ExportToolbarControls.js +44 -3
- package/dist/Export/ExportToolbarControls.js.map +1 -1
- package/dist/Export/export-options.d.ts +35 -0
- package/dist/Export/export-options.d.ts.map +1 -1
- package/dist/Export/export-options.js +6 -1
- package/dist/Export/export-options.js.map +1 -1
- package/dist/Export/run-export.d.ts.map +1 -1
- package/dist/Export/run-export.js +163 -91
- package/dist/Export/run-export.js.map +1 -1
- package/dist/FileExplorer/FileExplorer.d.ts.map +1 -1
- package/dist/FileExplorer/FileExplorer.js +17 -2
- package/dist/FileExplorer/FileExplorer.js.map +1 -1
- package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +2 -1
- package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +1 -1
- package/dist/WorkspacePicker/WorkspaceSettingsButton.js +2 -2
- package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +1 -1
- package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts +20 -0
- package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts.map +1 -0
- package/dist/WorkspacePicker/WorkspaceSettingsDialog.js +36 -0
- package/dist/WorkspacePicker/WorkspaceSettingsDialog.js.map +1 -0
- package/dist/hooks/useAutoSave.d.ts +8 -2
- package/dist/hooks/useAutoSave.d.ts.map +1 -1
- package/dist/hooks/useAutoSave.js +65 -30
- package/dist/hooks/useAutoSave.js.map +1 -1
- package/dist/preferences/versioning.d.ts +27 -0
- package/dist/preferences/versioning.d.ts.map +1 -0
- package/dist/preferences/versioning.js +62 -0
- package/dist/preferences/versioning.js.map +1 -0
- package/package.json +10 -8
- package/src/AppMenu/AppMenu.tsx +64 -1
- package/src/DocBlocksShell/DocBlocksShell.tsx +756 -80
- package/src/Export/ExportDialog.tsx +208 -19
- package/src/Export/ExportToolbarControls.tsx +45 -3
- package/src/Export/export-options.ts +43 -1
- package/src/Export/run-export.ts +190 -91
- package/src/FileExplorer/FileExplorer.tsx +31 -9
- package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +9 -0
- package/src/WorkspacePicker/WorkspaceSettingsDialog.tsx +121 -0
- package/src/hooks/useAutoSave.ts +87 -29
- package/src/preferences/versioning.ts +69 -0
- package/src/styles/docblocks.css +920 -53
|
@@ -7,10 +7,21 @@
|
|
|
7
7
|
|
|
8
8
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
9
9
|
import { EditorShell } from '@bendyline/squisq-editor-react';
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
EditorTheme,
|
|
12
|
+
EditorView,
|
|
13
|
+
ViewPreferences,
|
|
14
|
+
DocumentLinkProvider,
|
|
15
|
+
DocumentLinkCandidate,
|
|
16
|
+
} from '@bendyline/squisq-editor-react';
|
|
11
17
|
import '@bendyline/squisq-editor-react/styles';
|
|
12
18
|
import { MediaContext } from '@bendyline/squisq-react';
|
|
13
19
|
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
20
|
+
import {
|
|
21
|
+
DocumentVersionManager,
|
|
22
|
+
type PrunePolicy,
|
|
23
|
+
type SaveVersionResult,
|
|
24
|
+
} from '@bendyline/squisq/versions';
|
|
14
25
|
import type { FileSystemProvider, FileSystemEntry } from '@bendyline/docblocks/filesystem';
|
|
15
26
|
import {
|
|
16
27
|
IndexedDBFileSystemProvider,
|
|
@@ -22,7 +33,7 @@ import {
|
|
|
22
33
|
removeDirectoryHandle,
|
|
23
34
|
} from '@bendyline/docblocks/filesystem';
|
|
24
35
|
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
25
|
-
import { isElectronHost,
|
|
36
|
+
import { isElectronHost, getDocBlocksHost } from '@bendyline/docblocks/host';
|
|
26
37
|
import type { WorkspaceDescriptor } from '@bendyline/docblocks/workspace';
|
|
27
38
|
import {
|
|
28
39
|
ensureDefaultWorkspace,
|
|
@@ -36,14 +47,43 @@ import { AppMenu, type ThemePreference } from '../AppMenu/AppMenu.js';
|
|
|
36
47
|
import { FileExplorer } from '../FileExplorer/FileExplorer.js';
|
|
37
48
|
import { WorkspacePicker } from '../WorkspacePicker/WorkspacePicker.js';
|
|
38
49
|
import { WorkspaceSettingsButton } from '../WorkspacePicker/WorkspaceSettingsButton.js';
|
|
50
|
+
import {
|
|
51
|
+
WorkspaceSettingsDialog,
|
|
52
|
+
type WorkspaceVersioningOverride,
|
|
53
|
+
} from '../WorkspacePicker/WorkspaceSettingsDialog.js';
|
|
39
54
|
import { useAutoSave } from '../hooks/useAutoSave.js';
|
|
40
55
|
import { ExportToolbarControls } from '../Export/ExportToolbarControls.js';
|
|
56
|
+
import {
|
|
57
|
+
loadVersioningPreference,
|
|
58
|
+
resolveVersioningEnabled,
|
|
59
|
+
saveVersioningPreference,
|
|
60
|
+
type VersioningPreference,
|
|
61
|
+
} from '../preferences/versioning.js';
|
|
41
62
|
|
|
42
63
|
export interface DocBlocksShellProps {
|
|
43
64
|
/** Optional theme override. Omit or pass 'auto' to follow OS preference. */
|
|
44
65
|
theme?: EditorTheme | 'auto';
|
|
45
66
|
/** Optional logo image URL for the app menu. */
|
|
46
67
|
logoUrl?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Enable document version history. Snapshots are written under
|
|
70
|
+
* `<basename>_files/.versions/` next to each markdown file (i.e., the
|
|
71
|
+
* per-document container). Defaults to `true`.
|
|
72
|
+
*/
|
|
73
|
+
allowVersioning?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Override the document basename used in version filenames. Defaults
|
|
76
|
+
* to the basename of the currently selected file.
|
|
77
|
+
*/
|
|
78
|
+
versionBasename?: string;
|
|
79
|
+
/** Prune policy applied after each successful save. Default: keep last 50. */
|
|
80
|
+
versioningPrunePolicy?: PrunePolicy;
|
|
81
|
+
/** Idle delay (ms) before the editor auto-saves a version. `0` disables. Default 5000. */
|
|
82
|
+
versioningAutoSaveIdleMs?: number;
|
|
83
|
+
/** Notified after each `saveVersion` attempt (saved=true and saved=false). */
|
|
84
|
+
onSaveVersion?: (result: SaveVersionResult) => void;
|
|
85
|
+
/** Optional escape hatch for hosts that want imperative access to the version manager. */
|
|
86
|
+
versioningRef?: React.Ref<DocumentVersionManager | null>;
|
|
47
87
|
}
|
|
48
88
|
|
|
49
89
|
function useOsTheme(): 'light' | 'dark' {
|
|
@@ -128,6 +168,73 @@ function saveThemePreference(pref: ThemePreference): void {
|
|
|
128
168
|
}
|
|
129
169
|
}
|
|
130
170
|
|
|
171
|
+
const SIDEBAR_WIDTH_KEY = 'docblocks:sidebarWidth';
|
|
172
|
+
const SIDEBAR_WIDTH_DEFAULT = 260;
|
|
173
|
+
const SIDEBAR_WIDTH_MIN = 180;
|
|
174
|
+
const SIDEBAR_WIDTH_MAX = 600;
|
|
175
|
+
/** Drag below this many pixels and the sidebar collapses entirely —
|
|
176
|
+
* the editor takes the full width and a back-arrow appears in the
|
|
177
|
+
* toolbar so the user can pop the sidebar back open. Same UX as
|
|
178
|
+
* the existing mobile narrow-viewport flow. */
|
|
179
|
+
const SIDEBAR_COLLAPSE_THRESHOLD = 120;
|
|
180
|
+
|
|
181
|
+
function loadSidebarWidth(): number {
|
|
182
|
+
try {
|
|
183
|
+
const raw = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
|
184
|
+
if (raw === null) return SIDEBAR_WIDTH_DEFAULT;
|
|
185
|
+
const n = Number(raw);
|
|
186
|
+
if (!Number.isFinite(n)) return SIDEBAR_WIDTH_DEFAULT;
|
|
187
|
+
return Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, n));
|
|
188
|
+
} catch {
|
|
189
|
+
return SIDEBAR_WIDTH_DEFAULT;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function saveSidebarWidth(px: number): void {
|
|
194
|
+
try {
|
|
195
|
+
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(px)));
|
|
196
|
+
} catch {
|
|
197
|
+
// ignore quota errors
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const VIEW_PREFS_KEY = 'docblocks:viewPreferences';
|
|
202
|
+
|
|
203
|
+
const DEFAULT_VIEW_PREFS: ViewPreferences = {
|
|
204
|
+
outline: false,
|
|
205
|
+
inlinePreview: true,
|
|
206
|
+
showStatusBar: true,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
function loadViewPreferences(): ViewPreferences {
|
|
210
|
+
try {
|
|
211
|
+
const raw = localStorage.getItem(VIEW_PREFS_KEY);
|
|
212
|
+
if (!raw) return DEFAULT_VIEW_PREFS;
|
|
213
|
+
const parsed = JSON.parse(raw) as Partial<ViewPreferences>;
|
|
214
|
+
return {
|
|
215
|
+
outline: typeof parsed.outline === 'boolean' ? parsed.outline : DEFAULT_VIEW_PREFS.outline,
|
|
216
|
+
inlinePreview:
|
|
217
|
+
typeof parsed.inlinePreview === 'boolean'
|
|
218
|
+
? parsed.inlinePreview
|
|
219
|
+
: DEFAULT_VIEW_PREFS.inlinePreview,
|
|
220
|
+
showStatusBar:
|
|
221
|
+
typeof parsed.showStatusBar === 'boolean'
|
|
222
|
+
? parsed.showStatusBar
|
|
223
|
+
: DEFAULT_VIEW_PREFS.showStatusBar,
|
|
224
|
+
};
|
|
225
|
+
} catch {
|
|
226
|
+
return DEFAULT_VIEW_PREFS;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function saveViewPreferences(prefs: ViewPreferences): void {
|
|
231
|
+
try {
|
|
232
|
+
localStorage.setItem(VIEW_PREFS_KEY, JSON.stringify(prefs));
|
|
233
|
+
} catch {
|
|
234
|
+
// ignore quota errors
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
131
238
|
function dirnameOf(p: string): string {
|
|
132
239
|
const clean = p.replace(/^\/+/, '');
|
|
133
240
|
const idx = clean.lastIndexOf('/');
|
|
@@ -140,6 +247,93 @@ function basenameOf(p: string): string {
|
|
|
140
247
|
return idx === -1 ? clean : clean.slice(idx + 1);
|
|
141
248
|
}
|
|
142
249
|
|
|
250
|
+
/** Strip the extension from a filename (`notes.md` -> `notes`). Matches
|
|
251
|
+
* squisq's `getDocBasename` convention so version snapshot filenames
|
|
252
|
+
* stay readable (`<basename>.<timestamp>.md`). */
|
|
253
|
+
function stripExtension(name: string): string {
|
|
254
|
+
return name.replace(/\.[^.]+$/, '');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function normaliseProviderPath(p: string): string {
|
|
258
|
+
return '/' + p.replace(/^\/+/, '');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function sameProviderPath(a: string, b: string): boolean {
|
|
262
|
+
return normaliseProviderPath(a) === normaliseProviderPath(b);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Portable relative link from one workspace file to another. Walks up
|
|
266
|
+
* from the source's directory and back down to the target so the link
|
|
267
|
+
* survives folder reshuffles (`../sibling.md`, `subfolder/child.md`,
|
|
268
|
+
* `resume.md` for siblings at the workspace root). */
|
|
269
|
+
function relativeMarkdownLink(fromFile: string, toFile: string): string {
|
|
270
|
+
const fromParts = fromFile.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
271
|
+
const toParts = toFile.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
272
|
+
const fromDir = fromParts.slice(0, -1);
|
|
273
|
+
const toDir = toParts.slice(0, -1);
|
|
274
|
+
const toBase = toParts[toParts.length - 1] ?? '';
|
|
275
|
+
let common = 0;
|
|
276
|
+
while (common < fromDir.length && common < toDir.length && fromDir[common] === toDir[common]) {
|
|
277
|
+
common++;
|
|
278
|
+
}
|
|
279
|
+
const ups = fromDir.length - common;
|
|
280
|
+
const downs = [...toDir.slice(common), toBase];
|
|
281
|
+
const parts = [...Array(ups).fill('..'), ...downs];
|
|
282
|
+
return parts.length === 0 ? toBase : parts.join('/');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Recursively collect all `.md` files reachable from `root`. Skips
|
|
286
|
+
* Word-style `*_files/` asset companions, dotfiles, and `node_modules`
|
|
287
|
+
* so the candidate list stays workspace-meaningful. */
|
|
288
|
+
async function collectMarkdownFiles(
|
|
289
|
+
fs: FileSystemProvider,
|
|
290
|
+
root: string,
|
|
291
|
+
): Promise<FileSystemEntry[]> {
|
|
292
|
+
const out: FileSystemEntry[] = [];
|
|
293
|
+
const visited = new Set<string>();
|
|
294
|
+
|
|
295
|
+
async function walk(dir: string): Promise<void> {
|
|
296
|
+
if (visited.has(dir)) return;
|
|
297
|
+
visited.add(dir);
|
|
298
|
+
let entries: FileSystemEntry[];
|
|
299
|
+
try {
|
|
300
|
+
entries = await fs.readDirectory(dir);
|
|
301
|
+
} catch {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
for (const entry of entries) {
|
|
305
|
+
if (entry.kind === 'directory') {
|
|
306
|
+
const lower = entry.name.toLowerCase();
|
|
307
|
+
if (lower.startsWith('.')) continue;
|
|
308
|
+
if (lower === 'node_modules') continue;
|
|
309
|
+
if (lower.endsWith('_files')) continue;
|
|
310
|
+
await walk(entry.path);
|
|
311
|
+
} else if (entry.kind === 'file') {
|
|
312
|
+
if (entry.name.toLowerCase().endsWith('.md')) out.push(entry);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
await walk(root);
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function createElectronProviderFromWorkspace(
|
|
322
|
+
ws: WorkspaceDescriptor,
|
|
323
|
+
): Promise<ElectronFileSystemProvider | null> {
|
|
324
|
+
if (!ws.rootPath) return null;
|
|
325
|
+
try {
|
|
326
|
+
await getDocBlocksHost().workspaces.register({
|
|
327
|
+
id: ws.id,
|
|
328
|
+
name: ws.name,
|
|
329
|
+
rootPath: ws.rootPath,
|
|
330
|
+
});
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
return new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
|
|
335
|
+
}
|
|
336
|
+
|
|
143
337
|
function useIsMobile(breakpoint = 768): boolean {
|
|
144
338
|
const [isMobile, setIsMobile] = useState(
|
|
145
339
|
() =>
|
|
@@ -185,7 +379,16 @@ function FileGlyph() {
|
|
|
185
379
|
);
|
|
186
380
|
}
|
|
187
381
|
|
|
188
|
-
export function DocBlocksShell({
|
|
382
|
+
export function DocBlocksShell({
|
|
383
|
+
theme: _themeProp = 'auto',
|
|
384
|
+
logoUrl,
|
|
385
|
+
allowVersioning = true,
|
|
386
|
+
versionBasename,
|
|
387
|
+
versioningPrunePolicy,
|
|
388
|
+
versioningAutoSaveIdleMs,
|
|
389
|
+
onSaveVersion,
|
|
390
|
+
versioningRef,
|
|
391
|
+
}: DocBlocksShellProps) {
|
|
189
392
|
const osTheme = useOsTheme();
|
|
190
393
|
const [themePreference, setThemePreference] = useState<ThemePreference>(loadThemePreference);
|
|
191
394
|
// "System default" (auto) always follows the OS — the host's theme prop
|
|
@@ -197,10 +400,142 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
197
400
|
setThemePreference(pref);
|
|
198
401
|
saveThemePreference(pref);
|
|
199
402
|
}, []);
|
|
403
|
+
|
|
404
|
+
const [viewPreferences, setViewPreferences] = useState<ViewPreferences>(loadViewPreferences);
|
|
405
|
+
const handleViewPreferencesChange = useCallback((prefs: ViewPreferences) => {
|
|
406
|
+
setViewPreferences(prefs);
|
|
407
|
+
saveViewPreferences(prefs);
|
|
408
|
+
}, []);
|
|
200
409
|
const isMobile = useIsMobile();
|
|
201
410
|
const [mobileShowEditor, setMobileShowEditor] = useState(false);
|
|
411
|
+
// Sidebar width — persisted across sessions, dragged via the resizer
|
|
412
|
+
// between sidebar and editor area. We track the "live" width during a
|
|
413
|
+
// drag in a ref so each mousemove doesn't trigger a state update; only
|
|
414
|
+
// setState on commit so React doesn't churn through every pixel.
|
|
415
|
+
const [sidebarWidth, setSidebarWidth] = useState<number>(loadSidebarWidth);
|
|
416
|
+
// When the user drags the resizer below SIDEBAR_COLLAPSE_THRESHOLD,
|
|
417
|
+
// we switch the layout into single-pane "compact" mode — same UX as
|
|
418
|
+
// the mobile narrow-viewport flow, where only the sidebar OR the
|
|
419
|
+
// editor is visible at a time and a back-arrow in the toolbar pops
|
|
420
|
+
// between them. A "Restore split view" button on the editor toolbar
|
|
421
|
+
// exits compact mode; on real mobile that button is suppressed
|
|
422
|
+
// because there's not enough viewport for side-by-side. Not
|
|
423
|
+
// persisted across reloads. */
|
|
424
|
+
const [compactLayout, setCompactLayout] = useState(false);
|
|
425
|
+
const effectiveCompact = isMobile || compactLayout;
|
|
426
|
+
const sidebarRef = useRef<HTMLDivElement>(null);
|
|
427
|
+
const dragStateRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
|
428
|
+
const handleResizerPointerDown = useCallback(
|
|
429
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
430
|
+
if (e.button !== 0) return;
|
|
431
|
+
dragStateRef.current = { startX: e.clientX, startWidth: sidebarWidth };
|
|
432
|
+
e.preventDefault();
|
|
433
|
+
// Disable text selection + flip the body cursor for the duration
|
|
434
|
+
// of the drag so the col-resize cursor stays visible even when the
|
|
435
|
+
// pointer slips off the 7px hit area. The custom cursor lives in
|
|
436
|
+
// the CSS class so Windows' white-cursor preference doesn't make
|
|
437
|
+
// the dragging cursor invisible against the light chrome.
|
|
438
|
+
document.body.style.userSelect = 'none';
|
|
439
|
+
document.body.classList.add('db-resizing-sidebar');
|
|
440
|
+
let lastRaw = sidebarWidth;
|
|
441
|
+
const onMove = (ev: PointerEvent) => {
|
|
442
|
+
const drag = dragStateRef.current;
|
|
443
|
+
if (!drag) return;
|
|
444
|
+
lastRaw = drag.startWidth + (ev.clientX - drag.startX);
|
|
445
|
+
if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD && sidebarRef.current) {
|
|
446
|
+
// Below threshold — preview the collapse by snapping to the
|
|
447
|
+
// minimum width and fading the sidebar, so the user can see
|
|
448
|
+
// they've crossed into "release to collapse" territory.
|
|
449
|
+
sidebarRef.current.style.width = `${SIDEBAR_WIDTH_MIN}px`;
|
|
450
|
+
sidebarRef.current.style.opacity = '0.45';
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
const clamped = Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, lastRaw));
|
|
454
|
+
if (sidebarRef.current) {
|
|
455
|
+
// Update the DOM directly during the drag for jank-free
|
|
456
|
+
// dragging; React state syncs on release.
|
|
457
|
+
sidebarRef.current.style.width = `${clamped}px`;
|
|
458
|
+
sidebarRef.current.style.opacity = '';
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
const onUp = () => {
|
|
462
|
+
document.removeEventListener('pointermove', onMove);
|
|
463
|
+
document.removeEventListener('pointerup', onUp);
|
|
464
|
+
document.body.style.userSelect = '';
|
|
465
|
+
document.body.classList.remove('db-resizing-sidebar');
|
|
466
|
+
if (sidebarRef.current) {
|
|
467
|
+
sidebarRef.current.style.opacity = '';
|
|
468
|
+
}
|
|
469
|
+
if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD) {
|
|
470
|
+
// Released below threshold — switch to compact (single-pane)
|
|
471
|
+
// layout focused on the editor. Keep the persisted
|
|
472
|
+
// sidebarWidth so exiting compact mode restores it.
|
|
473
|
+
setCompactLayout(true);
|
|
474
|
+
setMobileShowEditor(true);
|
|
475
|
+
} else {
|
|
476
|
+
const finalWidth = sidebarRef.current?.getBoundingClientRect().width;
|
|
477
|
+
if (finalWidth) {
|
|
478
|
+
const clamped = Math.min(
|
|
479
|
+
SIDEBAR_WIDTH_MAX,
|
|
480
|
+
Math.max(SIDEBAR_WIDTH_MIN, Math.round(finalWidth)),
|
|
481
|
+
);
|
|
482
|
+
setSidebarWidth(clamped);
|
|
483
|
+
saveSidebarWidth(clamped);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
dragStateRef.current = null;
|
|
487
|
+
};
|
|
488
|
+
document.addEventListener('pointermove', onMove);
|
|
489
|
+
document.addEventListener('pointerup', onUp);
|
|
490
|
+
},
|
|
491
|
+
[sidebarWidth],
|
|
492
|
+
);
|
|
202
493
|
const [provider, setProvider] = useState<FileSystemProvider | null>(null);
|
|
203
494
|
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(null);
|
|
495
|
+
const [activeWorkspaceDescriptor, setActiveWorkspaceDescriptor] =
|
|
496
|
+
useState<WorkspaceDescriptor | null>(null);
|
|
497
|
+
// Re-fetch the descriptor whenever the active id (or its versioning
|
|
498
|
+
// override) changes. `descriptorRefreshKey` is bumped after writes so
|
|
499
|
+
// the resolver picks up the updated override without remounting.
|
|
500
|
+
const [descriptorRefreshKey, setDescriptorRefreshKey] = useState(0);
|
|
501
|
+
useEffect(() => {
|
|
502
|
+
let cancelled = false;
|
|
503
|
+
if (!activeWorkspaceId) {
|
|
504
|
+
setActiveWorkspaceDescriptor(null);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
void getWorkspace(activeWorkspaceId).then((ws) => {
|
|
508
|
+
if (!cancelled) setActiveWorkspaceDescriptor(ws);
|
|
509
|
+
});
|
|
510
|
+
return () => {
|
|
511
|
+
cancelled = true;
|
|
512
|
+
};
|
|
513
|
+
}, [activeWorkspaceId, descriptorRefreshKey]);
|
|
514
|
+
|
|
515
|
+
const [workspaceSettingsOpen, setWorkspaceSettingsOpen] = useState(false);
|
|
516
|
+
const [versioningPreference, setVersioningPreference] =
|
|
517
|
+
useState<VersioningPreference>(loadVersioningPreference);
|
|
518
|
+
const handleVersioningPreferenceChange = useCallback((pref: VersioningPreference) => {
|
|
519
|
+
setVersioningPreference(pref);
|
|
520
|
+
saveVersioningPreference(pref);
|
|
521
|
+
}, []);
|
|
522
|
+
const effectiveVersioning =
|
|
523
|
+
allowVersioning && resolveVersioningEnabled(activeWorkspaceDescriptor, versioningPreference);
|
|
524
|
+
|
|
525
|
+
const handleOpenWorkspaceSettings = useCallback(() => {
|
|
526
|
+
if (!activeWorkspaceDescriptor) return;
|
|
527
|
+
setWorkspaceSettingsOpen(true);
|
|
528
|
+
}, [activeWorkspaceDescriptor]);
|
|
529
|
+
|
|
530
|
+
const handleWorkspaceVersioningOverrideChange = useCallback(
|
|
531
|
+
async (override: WorkspaceVersioningOverride) => {
|
|
532
|
+
if (!activeWorkspaceDescriptor) return;
|
|
533
|
+
await saveWorkspace({ ...activeWorkspaceDescriptor, versioningOverride: override });
|
|
534
|
+
setDescriptorRefreshKey((k) => k + 1);
|
|
535
|
+
setWorkspaceSettingsOpen(false);
|
|
536
|
+
},
|
|
537
|
+
[activeWorkspaceDescriptor],
|
|
538
|
+
);
|
|
204
539
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
205
540
|
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
|
|
206
541
|
const [folderEntries, setFolderEntries] = useState<FileSystemEntry[]>([]);
|
|
@@ -210,6 +545,11 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
210
545
|
const [initialView, setInitialView] = useState<EditorView>('wysiwyg');
|
|
211
546
|
/** Suppress popstate handling during programmatic navigation. */
|
|
212
547
|
const skipPopState = useRef(false);
|
|
548
|
+
const lastLocalSaveRef = useRef<{
|
|
549
|
+
filePath: string;
|
|
550
|
+
content: string;
|
|
551
|
+
savedAt: number;
|
|
552
|
+
} | null>(null);
|
|
213
553
|
|
|
214
554
|
/**
|
|
215
555
|
* Per-file media container: for `notes.md`, images live in
|
|
@@ -219,6 +559,23 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
219
559
|
*/
|
|
220
560
|
const mediaContainerRef = useRef<ContentContainer | null>(null);
|
|
221
561
|
const [mediaProvider, setMediaProvider] = useState<MediaProvider | null>(null);
|
|
562
|
+
/**
|
|
563
|
+
* Per-document container scoped to `<basename>_files/`. This is what
|
|
564
|
+
* the editor uses for version history (`.versions/` lives here) and
|
|
565
|
+
* for audio mapping (MP3 / timing.json discovery). Distinct from
|
|
566
|
+
* `mediaContainerRef` which is scoped to the parent directory so the
|
|
567
|
+
* media provider can write `notes_files/image.png` paths that stay
|
|
568
|
+
* portable in the markdown.
|
|
569
|
+
*/
|
|
570
|
+
const versionsContainerRef = useRef<ContentContainer | null>(null);
|
|
571
|
+
const [versionsContainer, setVersionsContainer] = useState<ContentContainer | null>(null);
|
|
572
|
+
|
|
573
|
+
/** Cache of .md files in the active workspace, used to power the
|
|
574
|
+
* squisq link-dialog's document picker. Lazily populated on first
|
|
575
|
+
* provider call; cleared whenever the backing filesystem changes so
|
|
576
|
+
* workspace switches don't surface stale neighbours. A pending Promise
|
|
577
|
+
* during in-flight walks lets concurrent calls share the same scan. */
|
|
578
|
+
const mdFileCacheRef = useRef<Promise<FileSystemEntry[]> | null>(null);
|
|
222
579
|
|
|
223
580
|
/** Push a new history entry with the given hash. */
|
|
224
581
|
const pushHash = useCallback((wsId: string, filePath?: string | null) => {
|
|
@@ -244,13 +601,8 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
244
601
|
|
|
245
602
|
let fsProvider: FileSystemProvider | null = null;
|
|
246
603
|
if (ws.type === 'electron-native') {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
id: ws.id,
|
|
250
|
-
name: ws.name,
|
|
251
|
-
rootPath: ws.rootPath,
|
|
252
|
-
});
|
|
253
|
-
fsProvider = new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
|
|
604
|
+
fsProvider = await createElectronProviderFromWorkspace(ws);
|
|
605
|
+
if (!fsProvider) return null;
|
|
254
606
|
} else if (ws.type === 'native') {
|
|
255
607
|
const restored = await restoreNativeFolder(ws.id);
|
|
256
608
|
if (!restored) return null;
|
|
@@ -300,11 +652,13 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
300
652
|
async (fs: FileSystemProvider) => {
|
|
301
653
|
const entries = await fs.readDirectory('/');
|
|
302
654
|
|
|
303
|
-
// If the only file is the welcome doc, auto-select it
|
|
655
|
+
// If the only file is the welcome doc, auto-select it.
|
|
656
|
+
// Match either casing so workspaces seeded before the rename
|
|
657
|
+
// (aboutDocblocks.md) keep working alongside new ones (aboutDocBlocks.md).
|
|
304
658
|
if (
|
|
305
659
|
entries.length === 1 &&
|
|
306
660
|
entries[0].kind === 'file' &&
|
|
307
|
-
entries[0].path.replace(/^\//, '') === '
|
|
661
|
+
entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md'
|
|
308
662
|
) {
|
|
309
663
|
const aboutPath = entries[0].path;
|
|
310
664
|
const content = await fs.readFile(aboutPath);
|
|
@@ -322,20 +676,20 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
322
676
|
|
|
323
677
|
if (entries.length > 0) return;
|
|
324
678
|
|
|
325
|
-
const welcomePath = '/
|
|
679
|
+
const welcomePath = '/aboutDocBlocks.md';
|
|
326
680
|
const welcomeContent = [
|
|
327
681
|
'# Welcome to DocBlocks',
|
|
328
682
|
'',
|
|
329
|
-
'DocBlocks is a browser-based markdown document editor that lets you create, organize, and manage your documents right in the browser.',
|
|
683
|
+
'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.',
|
|
684
|
+
'',
|
|
685
|
+
'Simple to write. Beautiful wherever it goes.',
|
|
330
686
|
'',
|
|
331
687
|
'## Features',
|
|
332
688
|
'',
|
|
333
|
-
'- **Rich Markdown Editing** — Write in a visual editor or switch to raw markdown anytime',
|
|
334
|
-
'- **Workspaces** — Organize your documents into separate workspaces',
|
|
689
|
+
'- **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.',
|
|
690
|
+
'- **Workspaces** — Organize your documents into separate workspaces in the browser or on your device.',
|
|
691
|
+
'- **Useful Everywhere** — Your content is usable across multiple formats — Microsoft Word .docx, PowerPoint, PDF, HTML, EPUB e-books, and Markdown.',
|
|
335
692
|
'- **Playback & Video** — Preview your documents as rich visual presentations and export them as MP4 video',
|
|
336
|
-
'- **Export Anywhere** — Export documents to PDF, Word, PowerPoint, HTML, or Markdown with theme options',
|
|
337
|
-
'- **Local Storage** — Your documents are stored in your browser using temporary browser storage (backup often!)',
|
|
338
|
-
'- **Device Folders** — Create workspaces based on folders on your computer',
|
|
339
693
|
'- **No BS** — Free, no ads, no accounts, no tracking - everything runs locally in your browser',
|
|
340
694
|
'',
|
|
341
695
|
'## Getting Started',
|
|
@@ -344,7 +698,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
344
698
|
'2. Start writing in markdown — the editor supports headings, lists, links, images, and more',
|
|
345
699
|
'3. Your work is saved automatically',
|
|
346
700
|
'',
|
|
347
|
-
'Built with [Squiggly Square](https://github.com/
|
|
701
|
+
'Built with [Squiggly Square](https://github.com/bendyline/squisq) by [Bendyline](https://bendyline.com).',
|
|
348
702
|
].join('\n');
|
|
349
703
|
|
|
350
704
|
await fs.writeFile(welcomePath, welcomeContent);
|
|
@@ -406,13 +760,8 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
406
760
|
);
|
|
407
761
|
for (const ws of sorted) {
|
|
408
762
|
if (ws.type === 'electron-native') {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
id: ws.id,
|
|
412
|
-
name: ws.name,
|
|
413
|
-
rootPath: ws.rootPath,
|
|
414
|
-
});
|
|
415
|
-
const p = new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
|
|
763
|
+
const p = await createElectronProviderFromWorkspace(ws);
|
|
764
|
+
if (!p) continue;
|
|
416
765
|
await touchWorkspace(ws.id);
|
|
417
766
|
fsProvider = p;
|
|
418
767
|
setProvider(p);
|
|
@@ -441,7 +790,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
441
790
|
if (electron) {
|
|
442
791
|
// Desktop: ask the host for the default folder workspace
|
|
443
792
|
// (creates ~/Documents/DocBlocks on first launch).
|
|
444
|
-
const info = await
|
|
793
|
+
const info = await getDocBlocksHost().workspaces.getDefault();
|
|
445
794
|
const descriptor: WorkspaceDescriptor = {
|
|
446
795
|
id: info.id,
|
|
447
796
|
name: info.name,
|
|
@@ -503,44 +852,161 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
503
852
|
return () => window.removeEventListener('click', handler, true);
|
|
504
853
|
}, [activeWorkspaceId, selectedFile]);
|
|
505
854
|
|
|
506
|
-
|
|
507
|
-
|
|
855
|
+
const handleAutoSaved = useCallback((filePath: string, savedContent: string) => {
|
|
856
|
+
lastLocalSaveRef.current = {
|
|
857
|
+
filePath: normaliseProviderPath(filePath),
|
|
858
|
+
content: savedContent,
|
|
859
|
+
savedAt: Date.now(),
|
|
860
|
+
};
|
|
861
|
+
}, []);
|
|
862
|
+
|
|
863
|
+
// Auto-save current file. The returned `flush` is called from the
|
|
864
|
+
// Ctrl/Cmd+S handler below so the user gets immediate confirmation.
|
|
865
|
+
const { flush: flushAutoSave } = useAutoSave(
|
|
866
|
+
provider,
|
|
867
|
+
selectedFile,
|
|
868
|
+
editorContent,
|
|
869
|
+
500,
|
|
870
|
+
handleAutoSaved,
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
// Comfort-blanket Ctrl/Cmd+S: flushes any pending autosave and pops a
|
|
874
|
+
// small "auto-save confirmed" toast. Files are already saved on every
|
|
875
|
+
// keystroke (debounced) — this is purely UX reassurance for users who
|
|
876
|
+
// muscle-memory hit Save.
|
|
877
|
+
const [saveToastVisible, setSaveToastVisible] = useState(false);
|
|
878
|
+
const saveToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
879
|
+
useEffect(() => {
|
|
880
|
+
const onKey = (e: KeyboardEvent) => {
|
|
881
|
+
const sKey = e.key === 's' || e.key === 'S';
|
|
882
|
+
const accel = e.ctrlKey || e.metaKey;
|
|
883
|
+
if (!sKey || !accel || e.altKey) return;
|
|
884
|
+
e.preventDefault();
|
|
885
|
+
e.stopPropagation();
|
|
886
|
+
void flushAutoSave().catch(() => undefined);
|
|
887
|
+
setSaveToastVisible(true);
|
|
888
|
+
if (saveToastTimerRef.current) clearTimeout(saveToastTimerRef.current);
|
|
889
|
+
saveToastTimerRef.current = setTimeout(() => setSaveToastVisible(false), 1800);
|
|
890
|
+
};
|
|
891
|
+
window.addEventListener('keydown', onKey, true);
|
|
892
|
+
return () => window.removeEventListener('keydown', onKey, true);
|
|
893
|
+
}, [flushAutoSave]);
|
|
894
|
+
useEffect(() => {
|
|
895
|
+
return () => {
|
|
896
|
+
if (saveToastTimerRef.current) clearTimeout(saveToastTimerRef.current);
|
|
897
|
+
};
|
|
898
|
+
}, []);
|
|
508
899
|
|
|
509
900
|
// Per-file media: for `notes.md` images live in `notes_files/` beside it.
|
|
510
901
|
// Rebuilds whenever the provider or selected file changes.
|
|
511
902
|
useEffect(() => {
|
|
512
903
|
if (!provider || !selectedFile) {
|
|
513
904
|
mediaContainerRef.current = null;
|
|
905
|
+
versionsContainerRef.current = null;
|
|
514
906
|
setMediaProvider(null);
|
|
907
|
+
setVersionsContainer(null);
|
|
515
908
|
return;
|
|
516
909
|
}
|
|
517
910
|
const parentDir = dirnameOf(selectedFile);
|
|
518
911
|
const base = basenameOf(selectedFile);
|
|
912
|
+
const baseNoExt = base.replace(/\.[^.]+$/, '');
|
|
519
913
|
const container = new FileSystemContentContainer(provider, parentDir);
|
|
914
|
+
const vPrefix = parentDir ? `${parentDir}/${baseNoExt}_files` : `${baseNoExt}_files`;
|
|
915
|
+
const vContainer = new FileSystemContentContainer(provider, vPrefix);
|
|
520
916
|
const mp = createFileMediaProvider(container, base);
|
|
521
917
|
mediaContainerRef.current = container;
|
|
918
|
+
versionsContainerRef.current = vContainer;
|
|
522
919
|
setMediaProvider(mp);
|
|
920
|
+
setVersionsContainer(vContainer);
|
|
523
921
|
return () => {
|
|
524
922
|
mp.dispose();
|
|
525
923
|
};
|
|
526
924
|
}, [provider, selectedFile]);
|
|
527
925
|
|
|
926
|
+
// Invalidate the document-link candidate cache when the backing
|
|
927
|
+
// workspace changes — otherwise the link dialog would surface
|
|
928
|
+
// neighbours from a previously-open workspace.
|
|
929
|
+
useEffect(() => {
|
|
930
|
+
mdFileCacheRef.current = null;
|
|
931
|
+
}, [provider]);
|
|
932
|
+
|
|
933
|
+
/** Powers the squisq link dialog's "Browse documents" picker. Returns
|
|
934
|
+
* workspace `.md` neighbours filtered by `query`, with paths expressed
|
|
935
|
+
* relative to the currently-open document so the link survives folder
|
|
936
|
+
* moves. The first call seeds an in-memory cache; subsequent calls
|
|
937
|
+
* filter against it. */
|
|
938
|
+
const documentLinkProvider = useCallback<DocumentLinkProvider>(
|
|
939
|
+
async (query: string): Promise<DocumentLinkCandidate[]> => {
|
|
940
|
+
if (!provider || !selectedFile) return [];
|
|
941
|
+
if (!mdFileCacheRef.current) {
|
|
942
|
+
mdFileCacheRef.current = collectMarkdownFiles(provider, '').catch(() => []);
|
|
943
|
+
}
|
|
944
|
+
const entries = await mdFileCacheRef.current;
|
|
945
|
+
const q = query.trim().toLowerCase();
|
|
946
|
+
const candidates: DocumentLinkCandidate[] = [];
|
|
947
|
+
for (const entry of entries) {
|
|
948
|
+
if (entry.kind !== 'file') continue;
|
|
949
|
+
if (sameProviderPath(entry.path, selectedFile)) continue;
|
|
950
|
+
const label = entry.name.replace(/\.md$/i, '');
|
|
951
|
+
const path = relativeMarkdownLink(selectedFile, entry.path);
|
|
952
|
+
if (q && !label.toLowerCase().includes(q) && !path.toLowerCase().includes(q)) continue;
|
|
953
|
+
const dir = dirnameOf(entry.path);
|
|
954
|
+
candidates.push(dir ? { path, label, description: dir } : { path, label });
|
|
955
|
+
}
|
|
956
|
+
// Stable alphabetical order keeps the picker predictable across
|
|
957
|
+
// re-opens; the dialog can re-sort or rank on its own if needed.
|
|
958
|
+
candidates.sort((a, b) => a.label.localeCompare(b.label));
|
|
959
|
+
return candidates;
|
|
960
|
+
},
|
|
961
|
+
[provider, selectedFile],
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
// Expose a DocumentVersionManager via versioningRef when requested.
|
|
965
|
+
useEffect(() => {
|
|
966
|
+
const ref = versioningRef;
|
|
967
|
+
if (!ref) return;
|
|
968
|
+
const assign = (mgr: DocumentVersionManager | null) => {
|
|
969
|
+
if (typeof ref === 'function') ref(mgr);
|
|
970
|
+
else (ref as React.MutableRefObject<DocumentVersionManager | null>).current = mgr;
|
|
971
|
+
};
|
|
972
|
+
if (!effectiveVersioning || !versionsContainer || !selectedFile) {
|
|
973
|
+
assign(null);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const base = stripExtension(basenameOf(selectedFile));
|
|
977
|
+
const mgr = new DocumentVersionManager(versionsContainer, {
|
|
978
|
+
basename: versionBasename ?? base,
|
|
979
|
+
});
|
|
980
|
+
assign(mgr);
|
|
981
|
+
return () => assign(null);
|
|
982
|
+
}, [versioningRef, effectiveVersioning, versionBasename, selectedFile, versionsContainer]);
|
|
983
|
+
|
|
528
984
|
// React to external file changes watched by the Electron host (chokidar).
|
|
529
985
|
useEffect(() => {
|
|
530
986
|
if (!isElectronHost()) return;
|
|
531
987
|
if (!provider || !(provider instanceof ElectronFileSystemProvider)) return;
|
|
532
|
-
const unwatch = provider.watch(() => {
|
|
988
|
+
const unwatch = provider.watch((changedPath) => {
|
|
533
989
|
setExplorerKey((k) => k + 1);
|
|
990
|
+
if (!selectedFile || !sameProviderPath(changedPath, selectedFile)) return;
|
|
991
|
+
|
|
534
992
|
// If the open file's contents changed on disk, reload it (best-effort).
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
993
|
+
(async () => {
|
|
994
|
+
const content = await provider.readFile(selectedFile);
|
|
995
|
+
if (content === null || content === editorContent) return;
|
|
996
|
+
|
|
997
|
+
const localSave = lastLocalSaveRef.current;
|
|
998
|
+
if (
|
|
999
|
+
localSave &&
|
|
1000
|
+
sameProviderPath(localSave.filePath, selectedFile) &&
|
|
1001
|
+
localSave.content === content &&
|
|
1002
|
+
Date.now() - localSave.savedAt < 5000
|
|
1003
|
+
) {
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
setEditorContent(content);
|
|
1008
|
+
setEditorKey((k) => k + 1);
|
|
1009
|
+
})();
|
|
544
1010
|
});
|
|
545
1011
|
return unwatch;
|
|
546
1012
|
}, [provider, selectedFile, editorContent]);
|
|
@@ -550,13 +1016,8 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
550
1016
|
await touchWorkspace(ws.id);
|
|
551
1017
|
let nextProvider: FileSystemProvider | null = null;
|
|
552
1018
|
if (ws.type === 'electron-native') {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
id: ws.id,
|
|
556
|
-
name: ws.name,
|
|
557
|
-
rootPath: ws.rootPath,
|
|
558
|
-
});
|
|
559
|
-
nextProvider = new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
|
|
1019
|
+
nextProvider = await createElectronProviderFromWorkspace(ws);
|
|
1020
|
+
if (!nextProvider) return;
|
|
560
1021
|
} else if (ws.type === 'native') {
|
|
561
1022
|
const restored = await restoreNativeFolder(ws.id);
|
|
562
1023
|
if (!restored) {
|
|
@@ -582,7 +1043,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
582
1043
|
const handleOpenFolder = useCallback(async () => {
|
|
583
1044
|
try {
|
|
584
1045
|
if (isElectronHost()) {
|
|
585
|
-
const info = await
|
|
1046
|
+
const info = await getDocBlocksHost().workspaces.pickFolder();
|
|
586
1047
|
if (!info) return; // user cancelled
|
|
587
1048
|
const descriptor: WorkspaceDescriptor = {
|
|
588
1049
|
id: info.id,
|
|
@@ -645,14 +1106,14 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
645
1106
|
if (!isElectronHost() || !activeWorkspaceId) return;
|
|
646
1107
|
const ws = await getWorkspace(activeWorkspaceId);
|
|
647
1108
|
if (ws?.type === 'electron-native' && ws.rootPath) {
|
|
648
|
-
await
|
|
1109
|
+
await getDocBlocksHost().shell.revealInFolder(ws.rootPath);
|
|
649
1110
|
}
|
|
650
1111
|
}, [activeWorkspaceId]);
|
|
651
1112
|
|
|
652
1113
|
// Subscribe to native menu commands (Electron host).
|
|
653
1114
|
useEffect(() => {
|
|
654
1115
|
if (!isElectronHost()) return;
|
|
655
|
-
const host =
|
|
1116
|
+
const host = getDocBlocksHost();
|
|
656
1117
|
return host.onMenuCommand((cmd) => {
|
|
657
1118
|
switch (cmd) {
|
|
658
1119
|
case 'file:new':
|
|
@@ -687,7 +1148,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
687
1148
|
// Subscribe to open-file / deep-link requests from the OS.
|
|
688
1149
|
useEffect(() => {
|
|
689
1150
|
if (!isElectronHost()) return;
|
|
690
|
-
const host =
|
|
1151
|
+
const host = getDocBlocksHost();
|
|
691
1152
|
return host.onOpenRequest(async (req) => {
|
|
692
1153
|
if (req.filePath) {
|
|
693
1154
|
const workspaces = (await listWorkspaces()).filter(
|
|
@@ -745,10 +1206,10 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
745
1206
|
setEditorKey((k) => k + 1);
|
|
746
1207
|
pushHash(activeWorkspaceId, path);
|
|
747
1208
|
saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
|
|
748
|
-
if (
|
|
1209
|
+
if (effectiveCompact) setMobileShowEditor(true);
|
|
749
1210
|
}
|
|
750
1211
|
},
|
|
751
|
-
[provider, activeWorkspaceId, pushHash,
|
|
1212
|
+
[provider, activeWorkspaceId, pushHash, effectiveCompact],
|
|
752
1213
|
);
|
|
753
1214
|
|
|
754
1215
|
const handleTreeChange = useCallback(async () => {
|
|
@@ -855,28 +1316,157 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
855
1316
|
setEditorKey((k) => k + 1);
|
|
856
1317
|
}, [activeWorkspaceId]);
|
|
857
1318
|
|
|
1319
|
+
/**
|
|
1320
|
+
* Walk a FileSystemProvider and copy every file into `container` under
|
|
1321
|
+
* `pathPrefix` (no leading slash; empty string for the root). Used by
|
|
1322
|
+
* both single- and all-workspace downloads.
|
|
1323
|
+
*/
|
|
1324
|
+
const copyProviderToContainer = useCallback(
|
|
1325
|
+
async (
|
|
1326
|
+
src: FileSystemProvider,
|
|
1327
|
+
container: { writeFile: (path: string, data: ArrayBuffer | Uint8Array) => Promise<void> },
|
|
1328
|
+
pathPrefix: string,
|
|
1329
|
+
): Promise<void> => {
|
|
1330
|
+
const encoder = new TextEncoder();
|
|
1331
|
+
const stack: string[] = ['/'];
|
|
1332
|
+
while (stack.length > 0) {
|
|
1333
|
+
const dir = stack.pop()!;
|
|
1334
|
+
const entries = await src.readDirectory(dir);
|
|
1335
|
+
for (const entry of entries) {
|
|
1336
|
+
if (entry.kind === 'directory') {
|
|
1337
|
+
stack.push(entry.path);
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1340
|
+
const rel = entry.path.replace(/^\/+/, '');
|
|
1341
|
+
const zipPath = pathPrefix ? `${pathPrefix}/${rel}` : rel;
|
|
1342
|
+
// Files may be stored as text (writeFile) or binary (writeBinary);
|
|
1343
|
+
// try binary first, fall back to text and encode as UTF-8.
|
|
1344
|
+
const binary = await src.readBinary(entry.path);
|
|
1345
|
+
if (binary) {
|
|
1346
|
+
await container.writeFile(zipPath, binary);
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
const text = await src.readFile(entry.path);
|
|
1350
|
+
if (text !== null) {
|
|
1351
|
+
await container.writeFile(zipPath, encoder.encode(text));
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
},
|
|
1356
|
+
[],
|
|
1357
|
+
);
|
|
1358
|
+
|
|
858
1359
|
const handleDownloadWorkspace = useCallback(async () => {
|
|
859
1360
|
if (!provider) return;
|
|
860
1361
|
try {
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1362
|
+
const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
|
|
1363
|
+
import('@bendyline/squisq/storage'),
|
|
1364
|
+
import('@bendyline/squisq-formats/container'),
|
|
1365
|
+
]);
|
|
1366
|
+
|
|
1367
|
+
const container = new MemoryContentContainer();
|
|
1368
|
+
await copyProviderToContainer(provider, container, '');
|
|
1369
|
+
|
|
1370
|
+
const blob = await containerToZip(container);
|
|
1371
|
+
const url = URL.createObjectURL(blob);
|
|
1372
|
+
const a = document.createElement('a');
|
|
1373
|
+
a.href = url;
|
|
1374
|
+
const safeName =
|
|
1375
|
+
(provider.label || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
|
|
1376
|
+
a.download = `${safeName}.zip`;
|
|
1377
|
+
a.click();
|
|
1378
|
+
URL.revokeObjectURL(url);
|
|
1379
|
+
} catch (err) {
|
|
1380
|
+
console.error('Failed to download workspace', err);
|
|
1381
|
+
alert('Failed to download workspace. See console for details.');
|
|
1382
|
+
}
|
|
1383
|
+
}, [provider, copyProviderToContainer]);
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Bundle every workspace the host can open without further prompting
|
|
1387
|
+
* into a single zip, with each workspace nested under its own folder.
|
|
1388
|
+
* Native (browser-picked) workspaces whose handle hasn't been re-granted
|
|
1389
|
+
* for this session are skipped — restoring them would require a user
|
|
1390
|
+
* gesture per workspace.
|
|
1391
|
+
*/
|
|
1392
|
+
const handleDownloadAllWorkspaces = useCallback(async () => {
|
|
1393
|
+
try {
|
|
1394
|
+
const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
|
|
1395
|
+
import('@bendyline/squisq/storage'),
|
|
1396
|
+
import('@bendyline/squisq-formats/container'),
|
|
1397
|
+
]);
|
|
1398
|
+
|
|
1399
|
+
const electron = isElectronHost();
|
|
1400
|
+
const all = await listWorkspaces();
|
|
1401
|
+
const candidates = all.filter((w) =>
|
|
1402
|
+
electron ? w.type === 'electron-native' : w.type !== 'electron-native',
|
|
1403
|
+
);
|
|
1404
|
+
if (candidates.length === 0) {
|
|
1405
|
+
alert('No workspaces to download.');
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const container = new MemoryContentContainer();
|
|
1410
|
+
const usedFolders = new Set<string>();
|
|
1411
|
+
const skipped: string[] = [];
|
|
1412
|
+
|
|
1413
|
+
for (const ws of candidates) {
|
|
1414
|
+
let p: FileSystemProvider | null = null;
|
|
1415
|
+
try {
|
|
1416
|
+
if (ws.type === 'electron-native') {
|
|
1417
|
+
p = await createElectronProviderFromWorkspace(ws);
|
|
1418
|
+
} else if (ws.type === 'native') {
|
|
1419
|
+
// Restore without prompting — only succeeds when the browser
|
|
1420
|
+
// still remembers the granted handle for this origin/session.
|
|
1421
|
+
p = await restoreNativeFolder(ws.id);
|
|
1422
|
+
} else {
|
|
1423
|
+
p = new IndexedDBFileSystemProvider(ws.id, ws.name);
|
|
1424
|
+
}
|
|
1425
|
+
} catch (err) {
|
|
1426
|
+
console.warn(`Skipping workspace ${ws.name}:`, err);
|
|
867
1427
|
}
|
|
1428
|
+
|
|
1429
|
+
if (!p) {
|
|
1430
|
+
skipped.push(ws.name);
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// Pick a unique, filesystem-safe folder name per workspace.
|
|
1435
|
+
const base = (ws.name || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
|
|
1436
|
+
let folder = base;
|
|
1437
|
+
let suffix = 2;
|
|
1438
|
+
while (usedFolders.has(folder)) {
|
|
1439
|
+
folder = `${base} (${suffix++})`;
|
|
1440
|
+
}
|
|
1441
|
+
usedFolders.add(folder);
|
|
1442
|
+
|
|
1443
|
+
await copyProviderToContainer(p, container, folder);
|
|
868
1444
|
}
|
|
869
|
-
|
|
1445
|
+
|
|
1446
|
+
if (usedFolders.size === 0) {
|
|
1447
|
+
alert('No workspaces could be opened for download.');
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
const blob = await containerToZip(container);
|
|
870
1452
|
const url = URL.createObjectURL(blob);
|
|
871
1453
|
const a = document.createElement('a');
|
|
872
1454
|
a.href = url;
|
|
873
|
-
|
|
1455
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
1456
|
+
a.download = `docblocks-workspaces-${stamp}.zip`;
|
|
874
1457
|
a.click();
|
|
875
1458
|
URL.revokeObjectURL(url);
|
|
876
|
-
|
|
877
|
-
|
|
1459
|
+
|
|
1460
|
+
if (skipped.length > 0) {
|
|
1461
|
+
alert(
|
|
1462
|
+
`Downloaded ${usedFolders.size} workspace(s). Skipped ${skipped.length} that require re-granting access: ${skipped.join(', ')}.`,
|
|
1463
|
+
);
|
|
1464
|
+
}
|
|
1465
|
+
} catch (err) {
|
|
1466
|
+
console.error('Failed to download all workspaces', err);
|
|
1467
|
+
alert('Failed to download all workspaces. See console for details.');
|
|
878
1468
|
}
|
|
879
|
-
}, [
|
|
1469
|
+
}, [copyProviderToContainer]);
|
|
880
1470
|
|
|
881
1471
|
const handleRemoveWorkspace = useCallback(async () => {
|
|
882
1472
|
if (!activeWorkspaceId) return;
|
|
@@ -888,7 +1478,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
888
1478
|
const ws = await getWorkspace(activeWorkspaceId);
|
|
889
1479
|
if (ws?.type === 'electron-native') {
|
|
890
1480
|
try {
|
|
891
|
-
await
|
|
1481
|
+
await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
|
|
892
1482
|
} catch {
|
|
893
1483
|
// ignore — host cleanup is best-effort
|
|
894
1484
|
}
|
|
@@ -906,7 +1496,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
906
1496
|
const next = remaining[0];
|
|
907
1497
|
await handleWorkspaceSelect(next);
|
|
908
1498
|
} else if (electron) {
|
|
909
|
-
const info = await
|
|
1499
|
+
const info = await getDocBlocksHost().workspaces.getDefault();
|
|
910
1500
|
const descriptor: WorkspaceDescriptor = {
|
|
911
1501
|
id: info.id,
|
|
912
1502
|
name: info.name,
|
|
@@ -937,17 +1527,42 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
937
1527
|
}, [activeWorkspaceId, handleWorkspaceSelect]);
|
|
938
1528
|
|
|
939
1529
|
return (
|
|
940
|
-
<div
|
|
1530
|
+
<div
|
|
1531
|
+
className={`db-shell${effectiveCompact ? ' db-shell--mobile' : ''}`}
|
|
1532
|
+
data-theme={resolvedTheme}
|
|
1533
|
+
>
|
|
1534
|
+
{saveToastVisible && (
|
|
1535
|
+
<div className="db-save-toast" role="status" aria-live="polite">
|
|
1536
|
+
Autosaved. You're all set.
|
|
1537
|
+
</div>
|
|
1538
|
+
)}
|
|
1539
|
+
{workspaceSettingsOpen && activeWorkspaceDescriptor && (
|
|
1540
|
+
<WorkspaceSettingsDialog
|
|
1541
|
+
workspace={activeWorkspaceDescriptor}
|
|
1542
|
+
globalVersioningPreference={versioningPreference}
|
|
1543
|
+
onChange={handleWorkspaceVersioningOverrideChange}
|
|
1544
|
+
onClose={() => setWorkspaceSettingsOpen(false)}
|
|
1545
|
+
/>
|
|
1546
|
+
)}
|
|
941
1547
|
{/* Main area */}
|
|
942
1548
|
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
|
943
|
-
{/* Left sidebar — hidden
|
|
944
|
-
|
|
945
|
-
|
|
1549
|
+
{/* Left sidebar — hidden in compact layout when the editor is
|
|
1550
|
+
showing (compact = real mobile narrow viewport OR the user
|
|
1551
|
+
dragged the resizer below SIDEBAR_COLLAPSE_THRESHOLD). */}
|
|
1552
|
+
{(!effectiveCompact || !mobileShowEditor) && (
|
|
1553
|
+
<div
|
|
1554
|
+
ref={sidebarRef}
|
|
1555
|
+
className="db-shell-sidebar"
|
|
1556
|
+
style={effectiveCompact ? undefined : { width: `${sidebarWidth}px` }}
|
|
1557
|
+
>
|
|
946
1558
|
<div className="db-shell-sidebar-header">
|
|
947
1559
|
<AppMenu
|
|
948
1560
|
logoUrl={logoUrl}
|
|
949
1561
|
themePreference={themePreference}
|
|
950
1562
|
onThemeChange={handleThemeChange}
|
|
1563
|
+
versioningPreference={versioningPreference}
|
|
1564
|
+
onVersioningPreferenceChange={handleVersioningPreferenceChange}
|
|
1565
|
+
onDownloadAllWorkspaces={handleDownloadAllWorkspaces}
|
|
951
1566
|
/>
|
|
952
1567
|
<WorkspacePicker
|
|
953
1568
|
activeWorkspaceId={activeWorkspaceId}
|
|
@@ -955,6 +1570,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
955
1570
|
onOpenFolder={handleOpenFolder}
|
|
956
1571
|
/>
|
|
957
1572
|
<WorkspaceSettingsButton
|
|
1573
|
+
onSettings={handleOpenWorkspaceSettings}
|
|
958
1574
|
onRename={handleRenameWorkspace}
|
|
959
1575
|
onDownload={handleDownloadWorkspace}
|
|
960
1576
|
onRemove={handleRemoveWorkspace}
|
|
@@ -979,8 +1595,20 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
979
1595
|
</div>
|
|
980
1596
|
)}
|
|
981
1597
|
|
|
982
|
-
{/*
|
|
983
|
-
|
|
1598
|
+
{/* Resize handle between sidebar and editor — hidden whenever
|
|
1599
|
+
the layout is compact (no sidebar to resize). */}
|
|
1600
|
+
{!effectiveCompact && (
|
|
1601
|
+
<div
|
|
1602
|
+
className="db-shell-sidebar-resizer"
|
|
1603
|
+
role="separator"
|
|
1604
|
+
aria-orientation="vertical"
|
|
1605
|
+
aria-label="Resize sidebar"
|
|
1606
|
+
onPointerDown={handleResizerPointerDown}
|
|
1607
|
+
/>
|
|
1608
|
+
)}
|
|
1609
|
+
|
|
1610
|
+
{/* Editor area — hidden in compact layout when the sidebar is showing. */}
|
|
1611
|
+
{(!effectiveCompact || mobileShowEditor) && (
|
|
984
1612
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
|
985
1613
|
{selectedFile && mediaProvider ? (
|
|
986
1614
|
<MediaContext.Provider value={mediaProvider}>
|
|
@@ -989,29 +1617,71 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
989
1617
|
initialMarkdown={editorContent}
|
|
990
1618
|
initialView={initialView}
|
|
991
1619
|
articleId={selectedFile}
|
|
1620
|
+
fileName={selectedFile}
|
|
992
1621
|
onChange={handleEditorChange}
|
|
993
1622
|
theme={resolvedTheme}
|
|
994
1623
|
height="100%"
|
|
1624
|
+
outlineWidth={280}
|
|
995
1625
|
mediaProvider={mediaProvider}
|
|
996
|
-
|
|
1626
|
+
documentLinkProvider={documentLinkProvider}
|
|
1627
|
+
container={versionsContainer ?? undefined}
|
|
1628
|
+
allowVersioning={effectiveVersioning}
|
|
1629
|
+
viewPreferences={viewPreferences}
|
|
1630
|
+
onViewPreferencesChange={handleViewPreferencesChange}
|
|
1631
|
+
versionBasename={versionBasename ?? stripExtension(basenameOf(selectedFile))}
|
|
1632
|
+
versioningPrunePolicy={versioningPrunePolicy}
|
|
1633
|
+
versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
|
|
1634
|
+
onSaveVersion={onSaveVersion}
|
|
997
1635
|
toolbarSlotLeft={
|
|
998
|
-
|
|
999
|
-
<button
|
|
1636
|
+
effectiveCompact ? (
|
|
1637
|
+
<button
|
|
1638
|
+
className="db-mobile-back"
|
|
1639
|
+
onClick={() => setMobileShowEditor(false)}
|
|
1640
|
+
aria-label="Show file list"
|
|
1641
|
+
>
|
|
1000
1642
|
<span className="db-mobile-back-arrow">←</span>
|
|
1001
1643
|
</button>
|
|
1002
1644
|
) : undefined
|
|
1003
1645
|
}
|
|
1004
1646
|
toolbarSlotRight={
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1647
|
+
<>
|
|
1648
|
+
{/* Restore split view — only relevant when compact
|
|
1649
|
+
layout was manually triggered on a wide viewport.
|
|
1650
|
+
On real mobile, side-by-side doesn't fit so the
|
|
1651
|
+
button is suppressed. */}
|
|
1652
|
+
{compactLayout && !isMobile && (
|
|
1653
|
+
<button
|
|
1654
|
+
className="db-restore-split"
|
|
1655
|
+
onClick={() => setCompactLayout(false)}
|
|
1656
|
+
aria-label="Restore split view"
|
|
1657
|
+
title="Restore split view"
|
|
1658
|
+
>
|
|
1659
|
+
<svg
|
|
1660
|
+
width="16"
|
|
1661
|
+
height="16"
|
|
1662
|
+
viewBox="0 0 16 16"
|
|
1663
|
+
fill="none"
|
|
1664
|
+
stroke="currentColor"
|
|
1665
|
+
strokeWidth="1.5"
|
|
1666
|
+
strokeLinecap="round"
|
|
1667
|
+
strokeLinejoin="round"
|
|
1668
|
+
>
|
|
1669
|
+
<rect x="1.5" y="2.5" width="13" height="11" rx="1" />
|
|
1670
|
+
<line x1="6" y1="2.5" x2="6" y2="13.5" />
|
|
1671
|
+
</svg>
|
|
1672
|
+
</button>
|
|
1673
|
+
)}
|
|
1674
|
+
<ExportToolbarControls
|
|
1675
|
+
selectedFile={selectedFile}
|
|
1676
|
+
mediaContainer={mediaContainerRef.current}
|
|
1677
|
+
/>
|
|
1678
|
+
</>
|
|
1009
1679
|
}
|
|
1010
1680
|
/>
|
|
1011
1681
|
</MediaContext.Provider>
|
|
1012
1682
|
) : selectedFolder ? (
|
|
1013
1683
|
<div className="db-folder-view">
|
|
1014
|
-
{
|
|
1684
|
+
{effectiveCompact && (
|
|
1015
1685
|
<button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
|
|
1016
1686
|
<span className="db-mobile-back-arrow">←</span>
|
|
1017
1687
|
Back to files
|
|
@@ -1044,6 +1714,12 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
1044
1714
|
</div>
|
|
1045
1715
|
) : (
|
|
1046
1716
|
<div className="db-shell-empty">
|
|
1717
|
+
{effectiveCompact && (
|
|
1718
|
+
<button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
|
|
1719
|
+
<span className="db-mobile-back-arrow">←</span>
|
|
1720
|
+
Back to files
|
|
1721
|
+
</button>
|
|
1722
|
+
)}
|
|
1047
1723
|
<p>Select a file to start editing, or create a new one.</p>
|
|
1048
1724
|
</div>
|
|
1049
1725
|
)}
|