@bendyline/docblocks-react 1.1.0 → 1.1.2
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/README.md +23 -14
- 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 +23 -3
- package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
- package/dist/DocBlocksShell/DocBlocksShell.js +621 -94
- package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
- package/dist/Export/ExportDialog.d.ts.map +1 -1
- package/dist/Export/ExportDialog.js +123 -7
- package/dist/Export/ExportDialog.js.map +1 -1
- package/dist/Export/ExportToolbarControls.d.ts.map +1 -1
- package/dist/Export/ExportToolbarControls.js +108 -17
- 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 +185 -97
- package/dist/Export/run-export.js.map +1 -1
- package/dist/Export/transform-summaries.d.ts +7 -0
- package/dist/Export/transform-summaries.d.ts.map +1 -0
- package/dist/Export/transform-summaries.js +6 -0
- package/dist/Export/transform-summaries.js.map +1 -0
- 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/monaco-slim.d.ts +19 -0
- package/dist/monaco-slim.d.ts.map +1 -0
- package/dist/monaco-slim.js +19 -0
- package/dist/monaco-slim.js.map +1 -0
- 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 +14 -10
- package/src/AppMenu/AppMenu.tsx +64 -1
- package/src/DocBlocksShell/DocBlocksShell.tsx +839 -116
- package/src/Export/ExportDialog.tsx +236 -26
- package/src/Export/ExportToolbarControls.tsx +151 -21
- package/src/Export/export-options.ts +43 -1
- package/src/Export/run-export.ts +208 -97
- package/src/Export/transform-summaries.ts +14 -0
- 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/monaco-slim.ts +20 -0
- package/src/preferences/versioning.ts +69 -0
- package/src/styles/docblocks.css +995 -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
|
+
EditorColorScheme,
|
|
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
|
-
theme?:
|
|
65
|
+
theme?: EditorColorScheme | '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' {
|
|
@@ -108,6 +148,27 @@ function loadLastState(): LastState | null {
|
|
|
108
148
|
}
|
|
109
149
|
}
|
|
110
150
|
|
|
151
|
+
/** One-time first-run callout shown over the welcome doc's Play view.
|
|
152
|
+
* Once the user starts writing, switches views themselves, or dismisses
|
|
153
|
+
* it, it never comes back — on any workspace. */
|
|
154
|
+
const WELCOME_GATEWAY_KEY = 'docblocks:welcomeGatewayDismissed';
|
|
155
|
+
|
|
156
|
+
function isWelcomeGatewayDismissed(): boolean {
|
|
157
|
+
try {
|
|
158
|
+
return localStorage.getItem(WELCOME_GATEWAY_KEY) === '1';
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function markWelcomeGatewayDismissed(): void {
|
|
165
|
+
try {
|
|
166
|
+
localStorage.setItem(WELCOME_GATEWAY_KEY, '1');
|
|
167
|
+
} catch {
|
|
168
|
+
// ignore quota errors
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
111
172
|
const THEME_PREF_KEY = 'docblocks:themePreference';
|
|
112
173
|
|
|
113
174
|
function loadThemePreference(): ThemePreference {
|
|
@@ -128,6 +189,73 @@ function saveThemePreference(pref: ThemePreference): void {
|
|
|
128
189
|
}
|
|
129
190
|
}
|
|
130
191
|
|
|
192
|
+
const SIDEBAR_WIDTH_KEY = 'docblocks:sidebarWidth';
|
|
193
|
+
const SIDEBAR_WIDTH_DEFAULT = 260;
|
|
194
|
+
const SIDEBAR_WIDTH_MIN = 180;
|
|
195
|
+
const SIDEBAR_WIDTH_MAX = 600;
|
|
196
|
+
/** Drag below this many pixels and the sidebar collapses entirely —
|
|
197
|
+
* the editor takes the full width and a back-arrow appears in the
|
|
198
|
+
* toolbar so the user can pop the sidebar back open. Same UX as
|
|
199
|
+
* the existing mobile narrow-viewport flow. */
|
|
200
|
+
const SIDEBAR_COLLAPSE_THRESHOLD = 120;
|
|
201
|
+
|
|
202
|
+
function loadSidebarWidth(): number {
|
|
203
|
+
try {
|
|
204
|
+
const raw = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
|
205
|
+
if (raw === null) return SIDEBAR_WIDTH_DEFAULT;
|
|
206
|
+
const n = Number(raw);
|
|
207
|
+
if (!Number.isFinite(n)) return SIDEBAR_WIDTH_DEFAULT;
|
|
208
|
+
return Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, n));
|
|
209
|
+
} catch {
|
|
210
|
+
return SIDEBAR_WIDTH_DEFAULT;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function saveSidebarWidth(px: number): void {
|
|
215
|
+
try {
|
|
216
|
+
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(px)));
|
|
217
|
+
} catch {
|
|
218
|
+
// ignore quota errors
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const VIEW_PREFS_KEY = 'docblocks:viewPreferences';
|
|
223
|
+
|
|
224
|
+
const DEFAULT_VIEW_PREFS: ViewPreferences = {
|
|
225
|
+
outline: false,
|
|
226
|
+
inlinePreview: true,
|
|
227
|
+
showStatusBar: true,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
function loadViewPreferences(): ViewPreferences {
|
|
231
|
+
try {
|
|
232
|
+
const raw = localStorage.getItem(VIEW_PREFS_KEY);
|
|
233
|
+
if (!raw) return DEFAULT_VIEW_PREFS;
|
|
234
|
+
const parsed = JSON.parse(raw) as Partial<ViewPreferences>;
|
|
235
|
+
return {
|
|
236
|
+
outline: typeof parsed.outline === 'boolean' ? parsed.outline : DEFAULT_VIEW_PREFS.outline,
|
|
237
|
+
inlinePreview:
|
|
238
|
+
typeof parsed.inlinePreview === 'boolean'
|
|
239
|
+
? parsed.inlinePreview
|
|
240
|
+
: DEFAULT_VIEW_PREFS.inlinePreview,
|
|
241
|
+
showStatusBar:
|
|
242
|
+
typeof parsed.showStatusBar === 'boolean'
|
|
243
|
+
? parsed.showStatusBar
|
|
244
|
+
: DEFAULT_VIEW_PREFS.showStatusBar,
|
|
245
|
+
};
|
|
246
|
+
} catch {
|
|
247
|
+
return DEFAULT_VIEW_PREFS;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function saveViewPreferences(prefs: ViewPreferences): void {
|
|
252
|
+
try {
|
|
253
|
+
localStorage.setItem(VIEW_PREFS_KEY, JSON.stringify(prefs));
|
|
254
|
+
} catch {
|
|
255
|
+
// ignore quota errors
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
131
259
|
function dirnameOf(p: string): string {
|
|
132
260
|
const clean = p.replace(/^\/+/, '');
|
|
133
261
|
const idx = clean.lastIndexOf('/');
|
|
@@ -140,6 +268,93 @@ function basenameOf(p: string): string {
|
|
|
140
268
|
return idx === -1 ? clean : clean.slice(idx + 1);
|
|
141
269
|
}
|
|
142
270
|
|
|
271
|
+
/** Strip the extension from a filename (`notes.md` -> `notes`). Matches
|
|
272
|
+
* squisq's `getDocBasename` convention so version snapshot filenames
|
|
273
|
+
* stay readable (`<basename>.<timestamp>.md`). */
|
|
274
|
+
function stripExtension(name: string): string {
|
|
275
|
+
return name.replace(/\.[^.]+$/, '');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function normaliseProviderPath(p: string): string {
|
|
279
|
+
return '/' + p.replace(/^\/+/, '');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function sameProviderPath(a: string, b: string): boolean {
|
|
283
|
+
return normaliseProviderPath(a) === normaliseProviderPath(b);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Portable relative link from one workspace file to another. Walks up
|
|
287
|
+
* from the source's directory and back down to the target so the link
|
|
288
|
+
* survives folder reshuffles (`../sibling.md`, `subfolder/child.md`,
|
|
289
|
+
* `resume.md` for siblings at the workspace root). */
|
|
290
|
+
function relativeMarkdownLink(fromFile: string, toFile: string): string {
|
|
291
|
+
const fromParts = fromFile.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
292
|
+
const toParts = toFile.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
293
|
+
const fromDir = fromParts.slice(0, -1);
|
|
294
|
+
const toDir = toParts.slice(0, -1);
|
|
295
|
+
const toBase = toParts[toParts.length - 1] ?? '';
|
|
296
|
+
let common = 0;
|
|
297
|
+
while (common < fromDir.length && common < toDir.length && fromDir[common] === toDir[common]) {
|
|
298
|
+
common++;
|
|
299
|
+
}
|
|
300
|
+
const ups = fromDir.length - common;
|
|
301
|
+
const downs = [...toDir.slice(common), toBase];
|
|
302
|
+
const parts = [...Array(ups).fill('..'), ...downs];
|
|
303
|
+
return parts.length === 0 ? toBase : parts.join('/');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Recursively collect all `.md` files reachable from `root`. Skips
|
|
307
|
+
* Word-style `*_files/` asset companions, dotfiles, and `node_modules`
|
|
308
|
+
* so the candidate list stays workspace-meaningful. */
|
|
309
|
+
async function collectMarkdownFiles(
|
|
310
|
+
fs: FileSystemProvider,
|
|
311
|
+
root: string,
|
|
312
|
+
): Promise<FileSystemEntry[]> {
|
|
313
|
+
const out: FileSystemEntry[] = [];
|
|
314
|
+
const visited = new Set<string>();
|
|
315
|
+
|
|
316
|
+
async function walk(dir: string): Promise<void> {
|
|
317
|
+
if (visited.has(dir)) return;
|
|
318
|
+
visited.add(dir);
|
|
319
|
+
let entries: FileSystemEntry[];
|
|
320
|
+
try {
|
|
321
|
+
entries = await fs.readDirectory(dir);
|
|
322
|
+
} catch {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
for (const entry of entries) {
|
|
326
|
+
if (entry.kind === 'directory') {
|
|
327
|
+
const lower = entry.name.toLowerCase();
|
|
328
|
+
if (lower.startsWith('.')) continue;
|
|
329
|
+
if (lower === 'node_modules') continue;
|
|
330
|
+
if (lower.endsWith('_files')) continue;
|
|
331
|
+
await walk(entry.path);
|
|
332
|
+
} else if (entry.kind === 'file') {
|
|
333
|
+
if (entry.name.toLowerCase().endsWith('.md')) out.push(entry);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
await walk(root);
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function createElectronProviderFromWorkspace(
|
|
343
|
+
ws: WorkspaceDescriptor,
|
|
344
|
+
): Promise<ElectronFileSystemProvider | null> {
|
|
345
|
+
if (!ws.rootPath) return null;
|
|
346
|
+
try {
|
|
347
|
+
await getDocBlocksHost().workspaces.register({
|
|
348
|
+
id: ws.id,
|
|
349
|
+
name: ws.name,
|
|
350
|
+
rootPath: ws.rootPath,
|
|
351
|
+
});
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
return new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
|
|
356
|
+
}
|
|
357
|
+
|
|
143
358
|
function useIsMobile(breakpoint = 768): boolean {
|
|
144
359
|
const [isMobile, setIsMobile] = useState(
|
|
145
360
|
() =>
|
|
@@ -185,7 +400,16 @@ function FileGlyph() {
|
|
|
185
400
|
);
|
|
186
401
|
}
|
|
187
402
|
|
|
188
|
-
export function DocBlocksShell({
|
|
403
|
+
export function DocBlocksShell({
|
|
404
|
+
theme: _themeProp = 'auto',
|
|
405
|
+
logoUrl,
|
|
406
|
+
allowVersioning = true,
|
|
407
|
+
versionBasename,
|
|
408
|
+
versioningPrunePolicy,
|
|
409
|
+
versioningAutoSaveIdleMs,
|
|
410
|
+
onSaveVersion,
|
|
411
|
+
versioningRef,
|
|
412
|
+
}: DocBlocksShellProps) {
|
|
189
413
|
const osTheme = useOsTheme();
|
|
190
414
|
const [themePreference, setThemePreference] = useState<ThemePreference>(loadThemePreference);
|
|
191
415
|
// "System default" (auto) always follows the OS — the host's theme prop
|
|
@@ -197,10 +421,142 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
197
421
|
setThemePreference(pref);
|
|
198
422
|
saveThemePreference(pref);
|
|
199
423
|
}, []);
|
|
424
|
+
|
|
425
|
+
const [viewPreferences, setViewPreferences] = useState<ViewPreferences>(loadViewPreferences);
|
|
426
|
+
const handleViewPreferencesChange = useCallback((prefs: ViewPreferences) => {
|
|
427
|
+
setViewPreferences(prefs);
|
|
428
|
+
saveViewPreferences(prefs);
|
|
429
|
+
}, []);
|
|
200
430
|
const isMobile = useIsMobile();
|
|
201
431
|
const [mobileShowEditor, setMobileShowEditor] = useState(false);
|
|
432
|
+
// Sidebar width — persisted across sessions, dragged via the resizer
|
|
433
|
+
// between sidebar and editor area. We track the "live" width during a
|
|
434
|
+
// drag in a ref so each mousemove doesn't trigger a state update; only
|
|
435
|
+
// setState on commit so React doesn't churn through every pixel.
|
|
436
|
+
const [sidebarWidth, setSidebarWidth] = useState<number>(loadSidebarWidth);
|
|
437
|
+
// When the user drags the resizer below SIDEBAR_COLLAPSE_THRESHOLD,
|
|
438
|
+
// we switch the layout into single-pane "compact" mode — same UX as
|
|
439
|
+
// the mobile narrow-viewport flow, where only the sidebar OR the
|
|
440
|
+
// editor is visible at a time and a back-arrow in the toolbar pops
|
|
441
|
+
// between them. A "Restore split view" button on the editor toolbar
|
|
442
|
+
// exits compact mode; on real mobile that button is suppressed
|
|
443
|
+
// because there's not enough viewport for side-by-side. Not
|
|
444
|
+
// persisted across reloads. */
|
|
445
|
+
const [compactLayout, setCompactLayout] = useState(false);
|
|
446
|
+
const effectiveCompact = isMobile || compactLayout;
|
|
447
|
+
const sidebarRef = useRef<HTMLDivElement>(null);
|
|
448
|
+
const dragStateRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
|
449
|
+
const handleResizerPointerDown = useCallback(
|
|
450
|
+
(e: React.PointerEvent<HTMLDivElement>) => {
|
|
451
|
+
if (e.button !== 0) return;
|
|
452
|
+
dragStateRef.current = { startX: e.clientX, startWidth: sidebarWidth };
|
|
453
|
+
e.preventDefault();
|
|
454
|
+
// Disable text selection + flip the body cursor for the duration
|
|
455
|
+
// of the drag so the col-resize cursor stays visible even when the
|
|
456
|
+
// pointer slips off the 7px hit area. The custom cursor lives in
|
|
457
|
+
// the CSS class so Windows' white-cursor preference doesn't make
|
|
458
|
+
// the dragging cursor invisible against the light chrome.
|
|
459
|
+
document.body.style.userSelect = 'none';
|
|
460
|
+
document.body.classList.add('db-resizing-sidebar');
|
|
461
|
+
let lastRaw = sidebarWidth;
|
|
462
|
+
const onMove = (ev: PointerEvent) => {
|
|
463
|
+
const drag = dragStateRef.current;
|
|
464
|
+
if (!drag) return;
|
|
465
|
+
lastRaw = drag.startWidth + (ev.clientX - drag.startX);
|
|
466
|
+
if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD && sidebarRef.current) {
|
|
467
|
+
// Below threshold — preview the collapse by snapping to the
|
|
468
|
+
// minimum width and fading the sidebar, so the user can see
|
|
469
|
+
// they've crossed into "release to collapse" territory.
|
|
470
|
+
sidebarRef.current.style.width = `${SIDEBAR_WIDTH_MIN}px`;
|
|
471
|
+
sidebarRef.current.style.opacity = '0.45';
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const clamped = Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, lastRaw));
|
|
475
|
+
if (sidebarRef.current) {
|
|
476
|
+
// Update the DOM directly during the drag for jank-free
|
|
477
|
+
// dragging; React state syncs on release.
|
|
478
|
+
sidebarRef.current.style.width = `${clamped}px`;
|
|
479
|
+
sidebarRef.current.style.opacity = '';
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
const onUp = () => {
|
|
483
|
+
document.removeEventListener('pointermove', onMove);
|
|
484
|
+
document.removeEventListener('pointerup', onUp);
|
|
485
|
+
document.body.style.userSelect = '';
|
|
486
|
+
document.body.classList.remove('db-resizing-sidebar');
|
|
487
|
+
if (sidebarRef.current) {
|
|
488
|
+
sidebarRef.current.style.opacity = '';
|
|
489
|
+
}
|
|
490
|
+
if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD) {
|
|
491
|
+
// Released below threshold — switch to compact (single-pane)
|
|
492
|
+
// layout focused on the editor. Keep the persisted
|
|
493
|
+
// sidebarWidth so exiting compact mode restores it.
|
|
494
|
+
setCompactLayout(true);
|
|
495
|
+
setMobileShowEditor(true);
|
|
496
|
+
} else {
|
|
497
|
+
const finalWidth = sidebarRef.current?.getBoundingClientRect().width;
|
|
498
|
+
if (finalWidth) {
|
|
499
|
+
const clamped = Math.min(
|
|
500
|
+
SIDEBAR_WIDTH_MAX,
|
|
501
|
+
Math.max(SIDEBAR_WIDTH_MIN, Math.round(finalWidth)),
|
|
502
|
+
);
|
|
503
|
+
setSidebarWidth(clamped);
|
|
504
|
+
saveSidebarWidth(clamped);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
dragStateRef.current = null;
|
|
508
|
+
};
|
|
509
|
+
document.addEventListener('pointermove', onMove);
|
|
510
|
+
document.addEventListener('pointerup', onUp);
|
|
511
|
+
},
|
|
512
|
+
[sidebarWidth],
|
|
513
|
+
);
|
|
202
514
|
const [provider, setProvider] = useState<FileSystemProvider | null>(null);
|
|
203
515
|
const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(null);
|
|
516
|
+
const [activeWorkspaceDescriptor, setActiveWorkspaceDescriptor] =
|
|
517
|
+
useState<WorkspaceDescriptor | null>(null);
|
|
518
|
+
// Re-fetch the descriptor whenever the active id (or its versioning
|
|
519
|
+
// override) changes. `descriptorRefreshKey` is bumped after writes so
|
|
520
|
+
// the resolver picks up the updated override without remounting.
|
|
521
|
+
const [descriptorRefreshKey, setDescriptorRefreshKey] = useState(0);
|
|
522
|
+
useEffect(() => {
|
|
523
|
+
let cancelled = false;
|
|
524
|
+
if (!activeWorkspaceId) {
|
|
525
|
+
setActiveWorkspaceDescriptor(null);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
void getWorkspace(activeWorkspaceId).then((ws) => {
|
|
529
|
+
if (!cancelled) setActiveWorkspaceDescriptor(ws);
|
|
530
|
+
});
|
|
531
|
+
return () => {
|
|
532
|
+
cancelled = true;
|
|
533
|
+
};
|
|
534
|
+
}, [activeWorkspaceId, descriptorRefreshKey]);
|
|
535
|
+
|
|
536
|
+
const [workspaceSettingsOpen, setWorkspaceSettingsOpen] = useState(false);
|
|
537
|
+
const [versioningPreference, setVersioningPreference] =
|
|
538
|
+
useState<VersioningPreference>(loadVersioningPreference);
|
|
539
|
+
const handleVersioningPreferenceChange = useCallback((pref: VersioningPreference) => {
|
|
540
|
+
setVersioningPreference(pref);
|
|
541
|
+
saveVersioningPreference(pref);
|
|
542
|
+
}, []);
|
|
543
|
+
const effectiveVersioning =
|
|
544
|
+
allowVersioning && resolveVersioningEnabled(activeWorkspaceDescriptor, versioningPreference);
|
|
545
|
+
|
|
546
|
+
const handleOpenWorkspaceSettings = useCallback(() => {
|
|
547
|
+
if (!activeWorkspaceDescriptor) return;
|
|
548
|
+
setWorkspaceSettingsOpen(true);
|
|
549
|
+
}, [activeWorkspaceDescriptor]);
|
|
550
|
+
|
|
551
|
+
const handleWorkspaceVersioningOverrideChange = useCallback(
|
|
552
|
+
async (override: WorkspaceVersioningOverride) => {
|
|
553
|
+
if (!activeWorkspaceDescriptor) return;
|
|
554
|
+
await saveWorkspace({ ...activeWorkspaceDescriptor, versioningOverride: override });
|
|
555
|
+
setDescriptorRefreshKey((k) => k + 1);
|
|
556
|
+
setWorkspaceSettingsOpen(false);
|
|
557
|
+
},
|
|
558
|
+
[activeWorkspaceDescriptor],
|
|
559
|
+
);
|
|
204
560
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
205
561
|
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
|
|
206
562
|
const [folderEntries, setFolderEntries] = useState<FileSystemEntry[]>([]);
|
|
@@ -208,8 +564,15 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
208
564
|
const [editorKey, setEditorKey] = useState(0);
|
|
209
565
|
const [explorerKey, setExplorerKey] = useState(0);
|
|
210
566
|
const [initialView, setInitialView] = useState<EditorView>('wysiwyg');
|
|
567
|
+
// First-run gateway over the welcome doc's Play view — see WELCOME_GATEWAY_KEY.
|
|
568
|
+
const [showWelcomeGateway, setShowWelcomeGateway] = useState(false);
|
|
211
569
|
/** Suppress popstate handling during programmatic navigation. */
|
|
212
570
|
const skipPopState = useRef(false);
|
|
571
|
+
const lastLocalSaveRef = useRef<{
|
|
572
|
+
filePath: string;
|
|
573
|
+
content: string;
|
|
574
|
+
savedAt: number;
|
|
575
|
+
} | null>(null);
|
|
213
576
|
|
|
214
577
|
/**
|
|
215
578
|
* Per-file media container: for `notes.md`, images live in
|
|
@@ -219,6 +582,23 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
219
582
|
*/
|
|
220
583
|
const mediaContainerRef = useRef<ContentContainer | null>(null);
|
|
221
584
|
const [mediaProvider, setMediaProvider] = useState<MediaProvider | null>(null);
|
|
585
|
+
/**
|
|
586
|
+
* Per-document container scoped to `<basename>_files/`. This is what
|
|
587
|
+
* the editor uses for version history (`.versions/` lives here) and
|
|
588
|
+
* for audio mapping (MP3 / timing.json discovery). Distinct from
|
|
589
|
+
* `mediaContainerRef` which is scoped to the parent directory so the
|
|
590
|
+
* media provider can write `notes_files/image.png` paths that stay
|
|
591
|
+
* portable in the markdown.
|
|
592
|
+
*/
|
|
593
|
+
const versionsContainerRef = useRef<ContentContainer | null>(null);
|
|
594
|
+
const [versionsContainer, setVersionsContainer] = useState<ContentContainer | null>(null);
|
|
595
|
+
|
|
596
|
+
/** Cache of .md files in the active workspace, used to power the
|
|
597
|
+
* squisq link-dialog's document picker. Lazily populated on first
|
|
598
|
+
* provider call; cleared whenever the backing filesystem changes so
|
|
599
|
+
* workspace switches don't surface stale neighbours. A pending Promise
|
|
600
|
+
* during in-flight walks lets concurrent calls share the same scan. */
|
|
601
|
+
const mdFileCacheRef = useRef<Promise<FileSystemEntry[]> | null>(null);
|
|
222
602
|
|
|
223
603
|
/** Push a new history entry with the given hash. */
|
|
224
604
|
const pushHash = useCallback((wsId: string, filePath?: string | null) => {
|
|
@@ -244,13 +624,8 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
244
624
|
|
|
245
625
|
let fsProvider: FileSystemProvider | null = null;
|
|
246
626
|
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);
|
|
627
|
+
fsProvider = await createElectronProviderFromWorkspace(ws);
|
|
628
|
+
if (!fsProvider) return null;
|
|
254
629
|
} else if (ws.type === 'native') {
|
|
255
630
|
const restored = await restoreNativeFolder(ws.id);
|
|
256
631
|
if (!restored) return null;
|
|
@@ -300,11 +675,13 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
300
675
|
async (fs: FileSystemProvider) => {
|
|
301
676
|
const entries = await fs.readDirectory('/');
|
|
302
677
|
|
|
303
|
-
// If the only file is the welcome doc, auto-select it
|
|
678
|
+
// If the only file is the welcome doc, auto-select it.
|
|
679
|
+
// Match either casing so workspaces seeded before the rename
|
|
680
|
+
// (aboutDocblocks.md) keep working alongside new ones (aboutDocBlocks.md).
|
|
304
681
|
if (
|
|
305
682
|
entries.length === 1 &&
|
|
306
683
|
entries[0].kind === 'file' &&
|
|
307
|
-
entries[0].path.replace(/^\//, '') === '
|
|
684
|
+
entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md'
|
|
308
685
|
) {
|
|
309
686
|
const aboutPath = entries[0].path;
|
|
310
687
|
const content = await fs.readFile(aboutPath);
|
|
@@ -316,26 +693,27 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
316
693
|
setExplorerKey((k) => k + 1);
|
|
317
694
|
pushHash(fs.id, aboutPath);
|
|
318
695
|
saveLastState({ workspaceId: fs.id, filePath: aboutPath, view: 'preview' });
|
|
696
|
+
if (!isWelcomeGatewayDismissed()) setShowWelcomeGateway(true);
|
|
319
697
|
}
|
|
320
698
|
return;
|
|
321
699
|
}
|
|
322
700
|
|
|
323
701
|
if (entries.length > 0) return;
|
|
324
702
|
|
|
325
|
-
const welcomePath = '/
|
|
703
|
+
const welcomePath = '/aboutDocBlocks.md';
|
|
326
704
|
const welcomeContent = [
|
|
327
705
|
'# Welcome to DocBlocks',
|
|
328
706
|
'',
|
|
329
|
-
'DocBlocks is a browser-based markdown document editor that lets you create, organize, and manage your documents right in the browser.',
|
|
707
|
+
'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.',
|
|
708
|
+
'',
|
|
709
|
+
'Simple to write. Beautiful wherever it goes.',
|
|
330
710
|
'',
|
|
331
711
|
'## Features',
|
|
332
712
|
'',
|
|
333
|
-
'- **Rich Markdown Editing** — Write in a visual editor or switch to raw markdown anytime',
|
|
334
|
-
'- **Workspaces** — Organize your documents into separate workspaces',
|
|
713
|
+
'- **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.',
|
|
714
|
+
'- **Workspaces** — Organize your documents into separate workspaces in the browser or on your device.',
|
|
715
|
+
'- **Useful Everywhere** — Your content is usable across multiple formats — Microsoft Word .docx, PowerPoint, PDF, HTML, EPUB e-books, and Markdown.',
|
|
335
716
|
'- **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
717
|
'- **No BS** — Free, no ads, no accounts, no tracking - everything runs locally in your browser',
|
|
340
718
|
'',
|
|
341
719
|
'## Getting Started',
|
|
@@ -344,7 +722,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
344
722
|
'2. Start writing in markdown — the editor supports headings, lists, links, images, and more',
|
|
345
723
|
'3. Your work is saved automatically',
|
|
346
724
|
'',
|
|
347
|
-
'Built with [Squiggly Square](https://github.com/
|
|
725
|
+
'Built with [Squiggly Square](https://github.com/bendyline/squisq) by [Bendyline](https://bendyline.com).',
|
|
348
726
|
].join('\n');
|
|
349
727
|
|
|
350
728
|
await fs.writeFile(welcomePath, welcomeContent);
|
|
@@ -355,10 +733,30 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
355
733
|
setExplorerKey((k) => k + 1);
|
|
356
734
|
pushHash(fs.id, welcomePath);
|
|
357
735
|
saveLastState({ workspaceId: fs.id, filePath: welcomePath, view: 'preview' });
|
|
736
|
+
if (!isWelcomeGatewayDismissed()) setShowWelcomeGateway(true);
|
|
358
737
|
},
|
|
359
738
|
[pushHash],
|
|
360
739
|
);
|
|
361
740
|
|
|
741
|
+
/** Hide the welcome gateway and never show it again. Safe to call from
|
|
742
|
+
* paths where it may not be showing — only persists when it was. */
|
|
743
|
+
const closeWelcomeGateway = useCallback(() => {
|
|
744
|
+
setShowWelcomeGateway((showing) => {
|
|
745
|
+
if (showing) markWelcomeGatewayDismissed();
|
|
746
|
+
return false;
|
|
747
|
+
});
|
|
748
|
+
}, []);
|
|
749
|
+
|
|
750
|
+
/** Gateway CTA — flip the welcome doc from Play into the editor. */
|
|
751
|
+
const handleStartWriting = useCallback(() => {
|
|
752
|
+
closeWelcomeGateway();
|
|
753
|
+
setInitialView('wysiwyg');
|
|
754
|
+
setEditorKey((k) => k + 1);
|
|
755
|
+
if (activeWorkspaceId && selectedFile) {
|
|
756
|
+
saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view: 'wysiwyg' });
|
|
757
|
+
}
|
|
758
|
+
}, [closeWelcomeGateway, activeWorkspaceId, selectedFile]);
|
|
759
|
+
|
|
362
760
|
// Initialise workspace on mount — restore from hash or last-used
|
|
363
761
|
useEffect(() => {
|
|
364
762
|
(async () => {
|
|
@@ -406,13 +804,8 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
406
804
|
);
|
|
407
805
|
for (const ws of sorted) {
|
|
408
806
|
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);
|
|
807
|
+
const p = await createElectronProviderFromWorkspace(ws);
|
|
808
|
+
if (!p) continue;
|
|
416
809
|
await touchWorkspace(ws.id);
|
|
417
810
|
fsProvider = p;
|
|
418
811
|
setProvider(p);
|
|
@@ -441,7 +834,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
441
834
|
if (electron) {
|
|
442
835
|
// Desktop: ask the host for the default folder workspace
|
|
443
836
|
// (creates ~/Documents/DocBlocks on first launch).
|
|
444
|
-
const info = await
|
|
837
|
+
const info = await getDocBlocksHost().workspaces.getDefault();
|
|
445
838
|
const descriptor: WorkspaceDescriptor = {
|
|
446
839
|
id: info.id,
|
|
447
840
|
name: info.name,
|
|
@@ -494,6 +887,10 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
494
887
|
const target = (e.target as HTMLElement).closest?.('[data-view]');
|
|
495
888
|
if (target) {
|
|
496
889
|
const view = target.getAttribute('data-view') as EditorView;
|
|
890
|
+
if (view) {
|
|
891
|
+
// The user found the view tabs on their own — the gateway's job is done.
|
|
892
|
+
closeWelcomeGateway();
|
|
893
|
+
}
|
|
497
894
|
if (view && activeWorkspaceId && selectedFile) {
|
|
498
895
|
saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view });
|
|
499
896
|
}
|
|
@@ -501,46 +898,163 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
501
898
|
};
|
|
502
899
|
window.addEventListener('click', handler, true);
|
|
503
900
|
return () => window.removeEventListener('click', handler, true);
|
|
504
|
-
}, [activeWorkspaceId, selectedFile]);
|
|
901
|
+
}, [activeWorkspaceId, selectedFile, closeWelcomeGateway]);
|
|
902
|
+
|
|
903
|
+
const handleAutoSaved = useCallback((filePath: string, savedContent: string) => {
|
|
904
|
+
lastLocalSaveRef.current = {
|
|
905
|
+
filePath: normaliseProviderPath(filePath),
|
|
906
|
+
content: savedContent,
|
|
907
|
+
savedAt: Date.now(),
|
|
908
|
+
};
|
|
909
|
+
}, []);
|
|
505
910
|
|
|
506
|
-
// Auto-save current file
|
|
507
|
-
|
|
911
|
+
// Auto-save current file. The returned `flush` is called from the
|
|
912
|
+
// Ctrl/Cmd+S handler below so the user gets immediate confirmation.
|
|
913
|
+
const { flush: flushAutoSave } = useAutoSave(
|
|
914
|
+
provider,
|
|
915
|
+
selectedFile,
|
|
916
|
+
editorContent,
|
|
917
|
+
500,
|
|
918
|
+
handleAutoSaved,
|
|
919
|
+
);
|
|
920
|
+
|
|
921
|
+
// Comfort-blanket Ctrl/Cmd+S: flushes any pending autosave and pops a
|
|
922
|
+
// small "auto-save confirmed" toast. Files are already saved on every
|
|
923
|
+
// keystroke (debounced) — this is purely UX reassurance for users who
|
|
924
|
+
// muscle-memory hit Save.
|
|
925
|
+
const [saveToastVisible, setSaveToastVisible] = useState(false);
|
|
926
|
+
const saveToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
927
|
+
useEffect(() => {
|
|
928
|
+
const onKey = (e: KeyboardEvent) => {
|
|
929
|
+
const sKey = e.key === 's' || e.key === 'S';
|
|
930
|
+
const accel = e.ctrlKey || e.metaKey;
|
|
931
|
+
if (!sKey || !accel || e.altKey) return;
|
|
932
|
+
e.preventDefault();
|
|
933
|
+
e.stopPropagation();
|
|
934
|
+
void flushAutoSave().catch(() => undefined);
|
|
935
|
+
setSaveToastVisible(true);
|
|
936
|
+
if (saveToastTimerRef.current) clearTimeout(saveToastTimerRef.current);
|
|
937
|
+
saveToastTimerRef.current = setTimeout(() => setSaveToastVisible(false), 1800);
|
|
938
|
+
};
|
|
939
|
+
window.addEventListener('keydown', onKey, true);
|
|
940
|
+
return () => window.removeEventListener('keydown', onKey, true);
|
|
941
|
+
}, [flushAutoSave]);
|
|
942
|
+
useEffect(() => {
|
|
943
|
+
return () => {
|
|
944
|
+
if (saveToastTimerRef.current) clearTimeout(saveToastTimerRef.current);
|
|
945
|
+
};
|
|
946
|
+
}, []);
|
|
508
947
|
|
|
509
948
|
// Per-file media: for `notes.md` images live in `notes_files/` beside it.
|
|
510
949
|
// Rebuilds whenever the provider or selected file changes.
|
|
511
950
|
useEffect(() => {
|
|
512
951
|
if (!provider || !selectedFile) {
|
|
513
952
|
mediaContainerRef.current = null;
|
|
953
|
+
versionsContainerRef.current = null;
|
|
514
954
|
setMediaProvider(null);
|
|
955
|
+
setVersionsContainer(null);
|
|
515
956
|
return;
|
|
516
957
|
}
|
|
517
958
|
const parentDir = dirnameOf(selectedFile);
|
|
518
959
|
const base = basenameOf(selectedFile);
|
|
960
|
+
const baseNoExt = base.replace(/\.[^.]+$/, '');
|
|
519
961
|
const container = new FileSystemContentContainer(provider, parentDir);
|
|
962
|
+
const vPrefix = parentDir ? `${parentDir}/${baseNoExt}_files` : `${baseNoExt}_files`;
|
|
963
|
+
const vContainer = new FileSystemContentContainer(provider, vPrefix);
|
|
520
964
|
const mp = createFileMediaProvider(container, base);
|
|
521
965
|
mediaContainerRef.current = container;
|
|
966
|
+
versionsContainerRef.current = vContainer;
|
|
522
967
|
setMediaProvider(mp);
|
|
968
|
+
setVersionsContainer(vContainer);
|
|
523
969
|
return () => {
|
|
524
970
|
mp.dispose();
|
|
525
971
|
};
|
|
526
972
|
}, [provider, selectedFile]);
|
|
527
973
|
|
|
974
|
+
// Invalidate the document-link candidate cache when the backing
|
|
975
|
+
// workspace changes — otherwise the link dialog would surface
|
|
976
|
+
// neighbours from a previously-open workspace.
|
|
977
|
+
useEffect(() => {
|
|
978
|
+
mdFileCacheRef.current = null;
|
|
979
|
+
}, [provider]);
|
|
980
|
+
|
|
981
|
+
/** Powers the squisq link dialog's "Browse documents" picker. Returns
|
|
982
|
+
* workspace `.md` neighbours filtered by `query`, with paths expressed
|
|
983
|
+
* relative to the currently-open document so the link survives folder
|
|
984
|
+
* moves. The first call seeds an in-memory cache; subsequent calls
|
|
985
|
+
* filter against it. */
|
|
986
|
+
const documentLinkProvider = useCallback<DocumentLinkProvider>(
|
|
987
|
+
async (query: string): Promise<DocumentLinkCandidate[]> => {
|
|
988
|
+
if (!provider || !selectedFile) return [];
|
|
989
|
+
if (!mdFileCacheRef.current) {
|
|
990
|
+
mdFileCacheRef.current = collectMarkdownFiles(provider, '').catch(() => []);
|
|
991
|
+
}
|
|
992
|
+
const entries = await mdFileCacheRef.current;
|
|
993
|
+
const q = query.trim().toLowerCase();
|
|
994
|
+
const candidates: DocumentLinkCandidate[] = [];
|
|
995
|
+
for (const entry of entries) {
|
|
996
|
+
if (entry.kind !== 'file') continue;
|
|
997
|
+
if (sameProviderPath(entry.path, selectedFile)) continue;
|
|
998
|
+
const label = entry.name.replace(/\.md$/i, '');
|
|
999
|
+
const path = relativeMarkdownLink(selectedFile, entry.path);
|
|
1000
|
+
if (q && !label.toLowerCase().includes(q) && !path.toLowerCase().includes(q)) continue;
|
|
1001
|
+
const dir = dirnameOf(entry.path);
|
|
1002
|
+
candidates.push(dir ? { path, label, description: dir } : { path, label });
|
|
1003
|
+
}
|
|
1004
|
+
// Stable alphabetical order keeps the picker predictable across
|
|
1005
|
+
// re-opens; the dialog can re-sort or rank on its own if needed.
|
|
1006
|
+
candidates.sort((a, b) => a.label.localeCompare(b.label));
|
|
1007
|
+
return candidates;
|
|
1008
|
+
},
|
|
1009
|
+
[provider, selectedFile],
|
|
1010
|
+
);
|
|
1011
|
+
|
|
1012
|
+
// Expose a DocumentVersionManager via versioningRef when requested.
|
|
1013
|
+
useEffect(() => {
|
|
1014
|
+
const ref = versioningRef;
|
|
1015
|
+
if (!ref) return;
|
|
1016
|
+
const assign = (mgr: DocumentVersionManager | null) => {
|
|
1017
|
+
if (typeof ref === 'function') ref(mgr);
|
|
1018
|
+
else (ref as React.MutableRefObject<DocumentVersionManager | null>).current = mgr;
|
|
1019
|
+
};
|
|
1020
|
+
if (!effectiveVersioning || !versionsContainer || !selectedFile) {
|
|
1021
|
+
assign(null);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const base = stripExtension(basenameOf(selectedFile));
|
|
1025
|
+
const mgr = new DocumentVersionManager(versionsContainer, {
|
|
1026
|
+
basename: versionBasename ?? base,
|
|
1027
|
+
});
|
|
1028
|
+
assign(mgr);
|
|
1029
|
+
return () => assign(null);
|
|
1030
|
+
}, [versioningRef, effectiveVersioning, versionBasename, selectedFile, versionsContainer]);
|
|
1031
|
+
|
|
528
1032
|
// React to external file changes watched by the Electron host (chokidar).
|
|
529
1033
|
useEffect(() => {
|
|
530
1034
|
if (!isElectronHost()) return;
|
|
531
1035
|
if (!provider || !(provider instanceof ElectronFileSystemProvider)) return;
|
|
532
|
-
const unwatch = provider.watch(() => {
|
|
1036
|
+
const unwatch = provider.watch((changedPath) => {
|
|
533
1037
|
setExplorerKey((k) => k + 1);
|
|
1038
|
+
if (!selectedFile || !sameProviderPath(changedPath, selectedFile)) return;
|
|
1039
|
+
|
|
534
1040
|
// If the open file's contents changed on disk, reload it (best-effort).
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
1041
|
+
(async () => {
|
|
1042
|
+
const content = await provider.readFile(selectedFile);
|
|
1043
|
+
if (content === null || content === editorContent) return;
|
|
1044
|
+
|
|
1045
|
+
const localSave = lastLocalSaveRef.current;
|
|
1046
|
+
if (
|
|
1047
|
+
localSave &&
|
|
1048
|
+
sameProviderPath(localSave.filePath, selectedFile) &&
|
|
1049
|
+
localSave.content === content &&
|
|
1050
|
+
Date.now() - localSave.savedAt < 5000
|
|
1051
|
+
) {
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
setEditorContent(content);
|
|
1056
|
+
setEditorKey((k) => k + 1);
|
|
1057
|
+
})();
|
|
544
1058
|
});
|
|
545
1059
|
return unwatch;
|
|
546
1060
|
}, [provider, selectedFile, editorContent]);
|
|
@@ -550,13 +1064,8 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
550
1064
|
await touchWorkspace(ws.id);
|
|
551
1065
|
let nextProvider: FileSystemProvider | null = null;
|
|
552
1066
|
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);
|
|
1067
|
+
nextProvider = await createElectronProviderFromWorkspace(ws);
|
|
1068
|
+
if (!nextProvider) return;
|
|
560
1069
|
} else if (ws.type === 'native') {
|
|
561
1070
|
const restored = await restoreNativeFolder(ws.id);
|
|
562
1071
|
if (!restored) {
|
|
@@ -582,7 +1091,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
582
1091
|
const handleOpenFolder = useCallback(async () => {
|
|
583
1092
|
try {
|
|
584
1093
|
if (isElectronHost()) {
|
|
585
|
-
const info = await
|
|
1094
|
+
const info = await getDocBlocksHost().workspaces.pickFolder();
|
|
586
1095
|
if (!info) return; // user cancelled
|
|
587
1096
|
const descriptor: WorkspaceDescriptor = {
|
|
588
1097
|
id: info.id,
|
|
@@ -636,23 +1145,24 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
636
1145
|
setInitialView('wysiwyg');
|
|
637
1146
|
setEditorKey((k) => k + 1);
|
|
638
1147
|
setExplorerKey((k) => k + 1);
|
|
1148
|
+
closeWelcomeGateway();
|
|
639
1149
|
if (activeWorkspaceId) {
|
|
640
1150
|
pushHash(activeWorkspaceId, '/' + filename);
|
|
641
1151
|
}
|
|
642
|
-
}, [provider, activeWorkspaceId, pushHash]);
|
|
1152
|
+
}, [provider, activeWorkspaceId, pushHash, closeWelcomeGateway]);
|
|
643
1153
|
|
|
644
1154
|
const handleRevealWorkspace = useCallback(async () => {
|
|
645
1155
|
if (!isElectronHost() || !activeWorkspaceId) return;
|
|
646
1156
|
const ws = await getWorkspace(activeWorkspaceId);
|
|
647
1157
|
if (ws?.type === 'electron-native' && ws.rootPath) {
|
|
648
|
-
await
|
|
1158
|
+
await getDocBlocksHost().shell.revealInFolder(ws.rootPath);
|
|
649
1159
|
}
|
|
650
1160
|
}, [activeWorkspaceId]);
|
|
651
1161
|
|
|
652
1162
|
// Subscribe to native menu commands (Electron host).
|
|
653
1163
|
useEffect(() => {
|
|
654
1164
|
if (!isElectronHost()) return;
|
|
655
|
-
const host =
|
|
1165
|
+
const host = getDocBlocksHost();
|
|
656
1166
|
return host.onMenuCommand((cmd) => {
|
|
657
1167
|
switch (cmd) {
|
|
658
1168
|
case 'file:new':
|
|
@@ -687,39 +1197,9 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
687
1197
|
// Subscribe to open-file / deep-link requests from the OS.
|
|
688
1198
|
useEffect(() => {
|
|
689
1199
|
if (!isElectronHost()) return;
|
|
690
|
-
const host =
|
|
1200
|
+
const host = getDocBlocksHost();
|
|
691
1201
|
return host.onOpenRequest(async (req) => {
|
|
692
|
-
|
|
693
|
-
const workspaces = (await listWorkspaces()).filter(
|
|
694
|
-
(w) => w.type === 'electron-native' && w.rootPath,
|
|
695
|
-
);
|
|
696
|
-
const match = workspaces.find(
|
|
697
|
-
(w) => req.filePath!.startsWith((w.rootPath ?? '') + '/') || req.filePath === w.rootPath,
|
|
698
|
-
);
|
|
699
|
-
if (match && match.rootPath) {
|
|
700
|
-
const rel = '/' + req.filePath.slice(match.rootPath.length).replace(/^\/+/, '');
|
|
701
|
-
await openFromIds(match.id, rel, true);
|
|
702
|
-
}
|
|
703
|
-
} else if (req.url) {
|
|
704
|
-
try {
|
|
705
|
-
const u = new URL(req.url);
|
|
706
|
-
const path = u.searchParams.get('path');
|
|
707
|
-
if (path) {
|
|
708
|
-
const workspaces = (await listWorkspaces()).filter(
|
|
709
|
-
(w) => w.type === 'electron-native' && w.rootPath,
|
|
710
|
-
);
|
|
711
|
-
const match = workspaces.find(
|
|
712
|
-
(w) => path.startsWith((w.rootPath ?? '') + '/') || path === w.rootPath,
|
|
713
|
-
);
|
|
714
|
-
if (match && match.rootPath) {
|
|
715
|
-
const rel = '/' + path.slice(match.rootPath.length).replace(/^\/+/, '');
|
|
716
|
-
await openFromIds(match.id, rel, true);
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
} catch {
|
|
720
|
-
// bad URL, ignore
|
|
721
|
-
}
|
|
722
|
-
}
|
|
1202
|
+
await openFromIds(req.workspaceId, req.path, true);
|
|
723
1203
|
});
|
|
724
1204
|
}, [openFromIds]);
|
|
725
1205
|
|
|
@@ -743,12 +1223,13 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
743
1223
|
setEditorContent(content ?? '');
|
|
744
1224
|
setInitialView('wysiwyg');
|
|
745
1225
|
setEditorKey((k) => k + 1);
|
|
1226
|
+
closeWelcomeGateway();
|
|
746
1227
|
pushHash(activeWorkspaceId, path);
|
|
747
1228
|
saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
|
|
748
|
-
if (
|
|
1229
|
+
if (effectiveCompact) setMobileShowEditor(true);
|
|
749
1230
|
}
|
|
750
1231
|
},
|
|
751
|
-
[provider, activeWorkspaceId, pushHash,
|
|
1232
|
+
[provider, activeWorkspaceId, pushHash, effectiveCompact, closeWelcomeGateway],
|
|
752
1233
|
);
|
|
753
1234
|
|
|
754
1235
|
const handleTreeChange = useCallback(async () => {
|
|
@@ -855,28 +1336,157 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
855
1336
|
setEditorKey((k) => k + 1);
|
|
856
1337
|
}, [activeWorkspaceId]);
|
|
857
1338
|
|
|
1339
|
+
/**
|
|
1340
|
+
* Walk a FileSystemProvider and copy every file into `container` under
|
|
1341
|
+
* `pathPrefix` (no leading slash; empty string for the root). Used by
|
|
1342
|
+
* both single- and all-workspace downloads.
|
|
1343
|
+
*/
|
|
1344
|
+
const copyProviderToContainer = useCallback(
|
|
1345
|
+
async (
|
|
1346
|
+
src: FileSystemProvider,
|
|
1347
|
+
container: { writeFile: (path: string, data: ArrayBuffer | Uint8Array) => Promise<void> },
|
|
1348
|
+
pathPrefix: string,
|
|
1349
|
+
): Promise<void> => {
|
|
1350
|
+
const encoder = new TextEncoder();
|
|
1351
|
+
const stack: string[] = ['/'];
|
|
1352
|
+
while (stack.length > 0) {
|
|
1353
|
+
const dir = stack.pop()!;
|
|
1354
|
+
const entries = await src.readDirectory(dir);
|
|
1355
|
+
for (const entry of entries) {
|
|
1356
|
+
if (entry.kind === 'directory') {
|
|
1357
|
+
stack.push(entry.path);
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
const rel = entry.path.replace(/^\/+/, '');
|
|
1361
|
+
const zipPath = pathPrefix ? `${pathPrefix}/${rel}` : rel;
|
|
1362
|
+
// Files may be stored as text (writeFile) or binary (writeBinary);
|
|
1363
|
+
// try binary first, fall back to text and encode as UTF-8.
|
|
1364
|
+
const binary = await src.readBinary(entry.path);
|
|
1365
|
+
if (binary) {
|
|
1366
|
+
await container.writeFile(zipPath, binary);
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
const text = await src.readFile(entry.path);
|
|
1370
|
+
if (text !== null) {
|
|
1371
|
+
await container.writeFile(zipPath, encoder.encode(text));
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
},
|
|
1376
|
+
[],
|
|
1377
|
+
);
|
|
1378
|
+
|
|
858
1379
|
const handleDownloadWorkspace = useCallback(async () => {
|
|
859
1380
|
if (!provider) return;
|
|
860
1381
|
try {
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1382
|
+
const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
|
|
1383
|
+
import('@bendyline/squisq/storage'),
|
|
1384
|
+
import('@bendyline/squisq-formats/container'),
|
|
1385
|
+
]);
|
|
1386
|
+
|
|
1387
|
+
const container = new MemoryContentContainer();
|
|
1388
|
+
await copyProviderToContainer(provider, container, '');
|
|
1389
|
+
|
|
1390
|
+
const blob = await containerToZip(container);
|
|
1391
|
+
const url = URL.createObjectURL(blob);
|
|
1392
|
+
const a = document.createElement('a');
|
|
1393
|
+
a.href = url;
|
|
1394
|
+
const safeName =
|
|
1395
|
+
(provider.label || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
|
|
1396
|
+
a.download = `${safeName}.zip`;
|
|
1397
|
+
a.click();
|
|
1398
|
+
URL.revokeObjectURL(url);
|
|
1399
|
+
} catch (err) {
|
|
1400
|
+
console.error('Failed to download workspace', err);
|
|
1401
|
+
alert('Failed to download workspace. See console for details.');
|
|
1402
|
+
}
|
|
1403
|
+
}, [provider, copyProviderToContainer]);
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* Bundle every workspace the host can open without further prompting
|
|
1407
|
+
* into a single zip, with each workspace nested under its own folder.
|
|
1408
|
+
* Native (browser-picked) workspaces whose handle hasn't been re-granted
|
|
1409
|
+
* for this session are skipped — restoring them would require a user
|
|
1410
|
+
* gesture per workspace.
|
|
1411
|
+
*/
|
|
1412
|
+
const handleDownloadAllWorkspaces = useCallback(async () => {
|
|
1413
|
+
try {
|
|
1414
|
+
const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
|
|
1415
|
+
import('@bendyline/squisq/storage'),
|
|
1416
|
+
import('@bendyline/squisq-formats/container'),
|
|
1417
|
+
]);
|
|
1418
|
+
|
|
1419
|
+
const electron = isElectronHost();
|
|
1420
|
+
const all = await listWorkspaces();
|
|
1421
|
+
const candidates = all.filter((w) =>
|
|
1422
|
+
electron ? w.type === 'electron-native' : w.type !== 'electron-native',
|
|
1423
|
+
);
|
|
1424
|
+
if (candidates.length === 0) {
|
|
1425
|
+
alert('No workspaces to download.');
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const container = new MemoryContentContainer();
|
|
1430
|
+
const usedFolders = new Set<string>();
|
|
1431
|
+
const skipped: string[] = [];
|
|
1432
|
+
|
|
1433
|
+
for (const ws of candidates) {
|
|
1434
|
+
let p: FileSystemProvider | null = null;
|
|
1435
|
+
try {
|
|
1436
|
+
if (ws.type === 'electron-native') {
|
|
1437
|
+
p = await createElectronProviderFromWorkspace(ws);
|
|
1438
|
+
} else if (ws.type === 'native') {
|
|
1439
|
+
// Restore without prompting — only succeeds when the browser
|
|
1440
|
+
// still remembers the granted handle for this origin/session.
|
|
1441
|
+
p = await restoreNativeFolder(ws.id);
|
|
1442
|
+
} else {
|
|
1443
|
+
p = new IndexedDBFileSystemProvider(ws.id, ws.name);
|
|
1444
|
+
}
|
|
1445
|
+
} catch (err) {
|
|
1446
|
+
console.warn(`Skipping workspace ${ws.name}:`, err);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
if (!p) {
|
|
1450
|
+
skipped.push(ws.name);
|
|
1451
|
+
continue;
|
|
867
1452
|
}
|
|
1453
|
+
|
|
1454
|
+
// Pick a unique, filesystem-safe folder name per workspace.
|
|
1455
|
+
const base = (ws.name || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
|
|
1456
|
+
let folder = base;
|
|
1457
|
+
let suffix = 2;
|
|
1458
|
+
while (usedFolders.has(folder)) {
|
|
1459
|
+
folder = `${base} (${suffix++})`;
|
|
1460
|
+
}
|
|
1461
|
+
usedFolders.add(folder);
|
|
1462
|
+
|
|
1463
|
+
await copyProviderToContainer(p, container, folder);
|
|
868
1464
|
}
|
|
869
|
-
|
|
1465
|
+
|
|
1466
|
+
if (usedFolders.size === 0) {
|
|
1467
|
+
alert('No workspaces could be opened for download.');
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
const blob = await containerToZip(container);
|
|
870
1472
|
const url = URL.createObjectURL(blob);
|
|
871
1473
|
const a = document.createElement('a');
|
|
872
1474
|
a.href = url;
|
|
873
|
-
|
|
1475
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
1476
|
+
a.download = `docblocks-workspaces-${stamp}.zip`;
|
|
874
1477
|
a.click();
|
|
875
1478
|
URL.revokeObjectURL(url);
|
|
876
|
-
|
|
877
|
-
|
|
1479
|
+
|
|
1480
|
+
if (skipped.length > 0) {
|
|
1481
|
+
alert(
|
|
1482
|
+
`Downloaded ${usedFolders.size} workspace(s). Skipped ${skipped.length} that require re-granting access: ${skipped.join(', ')}.`,
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
} catch (err) {
|
|
1486
|
+
console.error('Failed to download all workspaces', err);
|
|
1487
|
+
alert('Failed to download all workspaces. See console for details.');
|
|
878
1488
|
}
|
|
879
|
-
}, [
|
|
1489
|
+
}, [copyProviderToContainer]);
|
|
880
1490
|
|
|
881
1491
|
const handleRemoveWorkspace = useCallback(async () => {
|
|
882
1492
|
if (!activeWorkspaceId) return;
|
|
@@ -888,7 +1498,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
888
1498
|
const ws = await getWorkspace(activeWorkspaceId);
|
|
889
1499
|
if (ws?.type === 'electron-native') {
|
|
890
1500
|
try {
|
|
891
|
-
await
|
|
1501
|
+
await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
|
|
892
1502
|
} catch {
|
|
893
1503
|
// ignore — host cleanup is best-effort
|
|
894
1504
|
}
|
|
@@ -906,7 +1516,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
906
1516
|
const next = remaining[0];
|
|
907
1517
|
await handleWorkspaceSelect(next);
|
|
908
1518
|
} else if (electron) {
|
|
909
|
-
const info = await
|
|
1519
|
+
const info = await getDocBlocksHost().workspaces.getDefault();
|
|
910
1520
|
const descriptor: WorkspaceDescriptor = {
|
|
911
1521
|
id: info.id,
|
|
912
1522
|
name: info.name,
|
|
@@ -937,17 +1547,42 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
937
1547
|
}, [activeWorkspaceId, handleWorkspaceSelect]);
|
|
938
1548
|
|
|
939
1549
|
return (
|
|
940
|
-
<div
|
|
1550
|
+
<div
|
|
1551
|
+
className={`db-shell${effectiveCompact ? ' db-shell--mobile' : ''}`}
|
|
1552
|
+
data-theme={resolvedTheme}
|
|
1553
|
+
>
|
|
1554
|
+
{saveToastVisible && (
|
|
1555
|
+
<div className="db-save-toast" role="status" aria-live="polite">
|
|
1556
|
+
Autosaved. You're all set.
|
|
1557
|
+
</div>
|
|
1558
|
+
)}
|
|
1559
|
+
{workspaceSettingsOpen && activeWorkspaceDescriptor && (
|
|
1560
|
+
<WorkspaceSettingsDialog
|
|
1561
|
+
workspace={activeWorkspaceDescriptor}
|
|
1562
|
+
globalVersioningPreference={versioningPreference}
|
|
1563
|
+
onChange={handleWorkspaceVersioningOverrideChange}
|
|
1564
|
+
onClose={() => setWorkspaceSettingsOpen(false)}
|
|
1565
|
+
/>
|
|
1566
|
+
)}
|
|
941
1567
|
{/* Main area */}
|
|
942
1568
|
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
|
943
|
-
{/* Left sidebar — hidden
|
|
944
|
-
|
|
945
|
-
|
|
1569
|
+
{/* Left sidebar — hidden in compact layout when the editor is
|
|
1570
|
+
showing (compact = real mobile narrow viewport OR the user
|
|
1571
|
+
dragged the resizer below SIDEBAR_COLLAPSE_THRESHOLD). */}
|
|
1572
|
+
{(!effectiveCompact || !mobileShowEditor) && (
|
|
1573
|
+
<div
|
|
1574
|
+
ref={sidebarRef}
|
|
1575
|
+
className="db-shell-sidebar"
|
|
1576
|
+
style={effectiveCompact ? undefined : { width: `${sidebarWidth}px` }}
|
|
1577
|
+
>
|
|
946
1578
|
<div className="db-shell-sidebar-header">
|
|
947
1579
|
<AppMenu
|
|
948
1580
|
logoUrl={logoUrl}
|
|
949
1581
|
themePreference={themePreference}
|
|
950
1582
|
onThemeChange={handleThemeChange}
|
|
1583
|
+
versioningPreference={versioningPreference}
|
|
1584
|
+
onVersioningPreferenceChange={handleVersioningPreferenceChange}
|
|
1585
|
+
onDownloadAllWorkspaces={handleDownloadAllWorkspaces}
|
|
951
1586
|
/>
|
|
952
1587
|
<WorkspacePicker
|
|
953
1588
|
activeWorkspaceId={activeWorkspaceId}
|
|
@@ -955,6 +1590,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
955
1590
|
onOpenFolder={handleOpenFolder}
|
|
956
1591
|
/>
|
|
957
1592
|
<WorkspaceSettingsButton
|
|
1593
|
+
onSettings={handleOpenWorkspaceSettings}
|
|
958
1594
|
onRename={handleRenameWorkspace}
|
|
959
1595
|
onDownload={handleDownloadWorkspace}
|
|
960
1596
|
onRemove={handleRemoveWorkspace}
|
|
@@ -979,9 +1615,29 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
979
1615
|
</div>
|
|
980
1616
|
)}
|
|
981
1617
|
|
|
982
|
-
{/*
|
|
983
|
-
|
|
984
|
-
|
|
1618
|
+
{/* Resize handle between sidebar and editor — hidden whenever
|
|
1619
|
+
the layout is compact (no sidebar to resize). */}
|
|
1620
|
+
{!effectiveCompact && (
|
|
1621
|
+
<div
|
|
1622
|
+
className="db-shell-sidebar-resizer"
|
|
1623
|
+
role="separator"
|
|
1624
|
+
aria-orientation="vertical"
|
|
1625
|
+
aria-label="Resize sidebar"
|
|
1626
|
+
onPointerDown={handleResizerPointerDown}
|
|
1627
|
+
/>
|
|
1628
|
+
)}
|
|
1629
|
+
|
|
1630
|
+
{/* Editor area — hidden in compact layout when the sidebar is showing. */}
|
|
1631
|
+
{(!effectiveCompact || mobileShowEditor) && (
|
|
1632
|
+
<div
|
|
1633
|
+
style={{
|
|
1634
|
+
flex: 1,
|
|
1635
|
+
overflow: 'hidden',
|
|
1636
|
+
display: 'flex',
|
|
1637
|
+
flexDirection: 'column',
|
|
1638
|
+
position: 'relative',
|
|
1639
|
+
}}
|
|
1640
|
+
>
|
|
985
1641
|
{selectedFile && mediaProvider ? (
|
|
986
1642
|
<MediaContext.Provider value={mediaProvider}>
|
|
987
1643
|
<EditorShell
|
|
@@ -989,29 +1645,90 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
989
1645
|
initialMarkdown={editorContent}
|
|
990
1646
|
initialView={initialView}
|
|
991
1647
|
articleId={selectedFile}
|
|
1648
|
+
fileName={selectedFile}
|
|
992
1649
|
onChange={handleEditorChange}
|
|
993
|
-
|
|
1650
|
+
colorScheme={resolvedTheme}
|
|
994
1651
|
height="100%"
|
|
1652
|
+
outlineWidth={280}
|
|
995
1653
|
mediaProvider={mediaProvider}
|
|
996
|
-
|
|
1654
|
+
documentLinkProvider={documentLinkProvider}
|
|
1655
|
+
container={versionsContainer ?? undefined}
|
|
1656
|
+
allowVersioning={effectiveVersioning}
|
|
1657
|
+
viewPreferences={viewPreferences}
|
|
1658
|
+
onViewPreferencesChange={handleViewPreferencesChange}
|
|
1659
|
+
versionBasename={versionBasename ?? stripExtension(basenameOf(selectedFile))}
|
|
1660
|
+
versioningPrunePolicy={versioningPrunePolicy}
|
|
1661
|
+
versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
|
|
1662
|
+
onSaveVersion={onSaveVersion}
|
|
997
1663
|
toolbarSlotLeft={
|
|
998
|
-
|
|
999
|
-
<button
|
|
1664
|
+
effectiveCompact ? (
|
|
1665
|
+
<button
|
|
1666
|
+
className="db-mobile-back"
|
|
1667
|
+
onClick={() => setMobileShowEditor(false)}
|
|
1668
|
+
aria-label="Show file list"
|
|
1669
|
+
>
|
|
1000
1670
|
<span className="db-mobile-back-arrow">←</span>
|
|
1001
1671
|
</button>
|
|
1002
1672
|
) : undefined
|
|
1003
1673
|
}
|
|
1004
1674
|
toolbarSlotRight={
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1675
|
+
<>
|
|
1676
|
+
{/* Restore split view — only relevant when compact
|
|
1677
|
+
layout was manually triggered on a wide viewport.
|
|
1678
|
+
On real mobile, side-by-side doesn't fit so the
|
|
1679
|
+
button is suppressed. */}
|
|
1680
|
+
{compactLayout && !isMobile && (
|
|
1681
|
+
<button
|
|
1682
|
+
className="db-restore-split"
|
|
1683
|
+
onClick={() => setCompactLayout(false)}
|
|
1684
|
+
aria-label="Restore split view"
|
|
1685
|
+
title="Restore split view"
|
|
1686
|
+
>
|
|
1687
|
+
<svg
|
|
1688
|
+
width="16"
|
|
1689
|
+
height="16"
|
|
1690
|
+
viewBox="0 0 16 16"
|
|
1691
|
+
fill="none"
|
|
1692
|
+
stroke="currentColor"
|
|
1693
|
+
strokeWidth="1.5"
|
|
1694
|
+
strokeLinecap="round"
|
|
1695
|
+
strokeLinejoin="round"
|
|
1696
|
+
>
|
|
1697
|
+
<rect x="1.5" y="2.5" width="13" height="11" rx="1" />
|
|
1698
|
+
<line x1="6" y1="2.5" x2="6" y2="13.5" />
|
|
1699
|
+
</svg>
|
|
1700
|
+
</button>
|
|
1701
|
+
)}
|
|
1702
|
+
<ExportToolbarControls
|
|
1703
|
+
selectedFile={selectedFile}
|
|
1704
|
+
mediaContainer={mediaContainerRef.current}
|
|
1705
|
+
/>
|
|
1706
|
+
</>
|
|
1009
1707
|
}
|
|
1010
1708
|
/>
|
|
1709
|
+
{showWelcomeGateway && (
|
|
1710
|
+
<div className="db-welcome-gateway" role="note" aria-label="Welcome tip">
|
|
1711
|
+
<span className="db-welcome-gateway-text">
|
|
1712
|
+
You’re watching this welcome doc in <strong>Play</strong> view —
|
|
1713
|
+
it’s a regular markdown file, and so is everything you’ll write.
|
|
1714
|
+
</span>
|
|
1715
|
+
<button className="db-welcome-gateway-cta" onClick={handleStartWriting}>
|
|
1716
|
+
Start writing
|
|
1717
|
+
</button>
|
|
1718
|
+
<button
|
|
1719
|
+
className="db-welcome-gateway-dismiss"
|
|
1720
|
+
onClick={closeWelcomeGateway}
|
|
1721
|
+
aria-label="Dismiss welcome tip"
|
|
1722
|
+
title="Dismiss"
|
|
1723
|
+
>
|
|
1724
|
+
×
|
|
1725
|
+
</button>
|
|
1726
|
+
</div>
|
|
1727
|
+
)}
|
|
1011
1728
|
</MediaContext.Provider>
|
|
1012
1729
|
) : selectedFolder ? (
|
|
1013
1730
|
<div className="db-folder-view">
|
|
1014
|
-
{
|
|
1731
|
+
{effectiveCompact && (
|
|
1015
1732
|
<button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
|
|
1016
1733
|
<span className="db-mobile-back-arrow">←</span>
|
|
1017
1734
|
Back to files
|
|
@@ -1044,6 +1761,12 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
|
|
|
1044
1761
|
</div>
|
|
1045
1762
|
) : (
|
|
1046
1763
|
<div className="db-shell-empty">
|
|
1764
|
+
{effectiveCompact && (
|
|
1765
|
+
<button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
|
|
1766
|
+
<span className="db-mobile-back-arrow">←</span>
|
|
1767
|
+
Back to files
|
|
1768
|
+
</button>
|
|
1769
|
+
)}
|
|
1047
1770
|
<p>Select a file to start editing, or create a new one.</p>
|
|
1048
1771
|
</div>
|
|
1049
1772
|
)}
|