@bendyline/docblocks-react 1.0.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 +16 -1
- package/dist/AppMenu/AppMenu.d.ts.map +1 -1
- package/dist/AppMenu/AppMenu.js +3 -2
- package/dist/AppMenu/AppMenu.js.map +1 -1
- package/dist/AppMenu/index.d.ts +1 -1
- package/dist/AppMenu/index.d.ts.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 +930 -98
- 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/FileExplorer/FileTreeNode.d.ts.map +1 -1
- package/dist/FileExplorer/FileTreeNode.js +34 -8
- package/dist/FileExplorer/FileTreeNode.js.map +1 -1
- package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +1 -1
- package/dist/WorkspacePicker/WorkspacePicker.js +9 -3
- package/dist/WorkspacePicker/WorkspacePicker.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/icons.d.ts +1 -0
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +3 -0
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.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 +142 -2
- package/src/AppMenu/index.ts +1 -1
- package/src/DocBlocksShell/DocBlocksShell.tsx +1194 -125
- 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/FileExplorer/FileTreeNode.tsx +68 -26
- package/src/WorkspacePicker/WorkspacePicker.tsx +18 -7
- package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +9 -0
- package/src/WorkspacePicker/WorkspaceSettingsDialog.tsx +121 -0
- package/src/fonts/fonts.css +362 -0
- package/src/fonts/licenses/LICENSE-cormorant-garamond.txt +93 -0
- package/src/fonts/licenses/LICENSE-crimson-text.txt +93 -0
- package/src/fonts/licenses/LICENSE-dm-sans.txt +93 -0
- package/src/fonts/licenses/LICENSE-dm-serif-display.txt +93 -0
- package/src/fonts/licenses/LICENSE-hanken-grotesk.txt +94 -0
- package/src/fonts/licenses/LICENSE-ibm-plex-sans.txt +93 -0
- package/src/fonts/licenses/LICENSE-inter.txt +93 -0
- package/src/fonts/licenses/LICENSE-jetbrains-mono.txt +93 -0
- package/src/fonts/licenses/LICENSE-lora.txt +93 -0
- package/src/fonts/licenses/LICENSE-merriweather.txt +93 -0
- package/src/fonts/licenses/LICENSE-oswald.txt +93 -0
- package/src/fonts/licenses/LICENSE-playfair-display.txt +93 -0
- package/src/fonts/licenses/LICENSE-pt-serif.txt +93 -0
- package/src/fonts/licenses/LICENSE-roboto.txt +93 -0
- package/src/fonts/licenses/LICENSE-source-serif-4.txt +93 -0
- package/src/fonts/woff2/cormorant-garamond-400.woff2 +0 -0
- package/src/fonts/woff2/cormorant-garamond-700.woff2 +0 -0
- package/src/fonts/woff2/crimson-text-400.woff2 +0 -0
- package/src/fonts/woff2/crimson-text-400i.woff2 +0 -0
- package/src/fonts/woff2/crimson-text-700.woff2 +0 -0
- package/src/fonts/woff2/dm-sans-400.woff2 +0 -0
- package/src/fonts/woff2/dm-sans-400i.woff2 +0 -0
- package/src/fonts/woff2/dm-sans-500.woff2 +0 -0
- package/src/fonts/woff2/dm-sans-700.woff2 +0 -0
- package/src/fonts/woff2/dm-serif-display-400.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-400.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-400i.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-500.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-600.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-700.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-400.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-400i.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-500.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-600.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-700.woff2 +0 -0
- package/src/fonts/woff2/inter-400.woff2 +0 -0
- package/src/fonts/woff2/inter-400i.woff2 +0 -0
- package/src/fonts/woff2/inter-500.woff2 +0 -0
- package/src/fonts/woff2/inter-600.woff2 +0 -0
- package/src/fonts/woff2/inter-700.woff2 +0 -0
- package/src/fonts/woff2/jetbrains-mono-400.woff2 +0 -0
- package/src/fonts/woff2/jetbrains-mono-400i.woff2 +0 -0
- package/src/fonts/woff2/jetbrains-mono-700.woff2 +0 -0
- package/src/fonts/woff2/lora-400.woff2 +0 -0
- package/src/fonts/woff2/lora-400i.woff2 +0 -0
- package/src/fonts/woff2/lora-700.woff2 +0 -0
- package/src/fonts/woff2/merriweather-400.woff2 +0 -0
- package/src/fonts/woff2/merriweather-700.woff2 +0 -0
- package/src/fonts/woff2/oswald-400.woff2 +0 -0
- package/src/fonts/woff2/oswald-700.woff2 +0 -0
- package/src/fonts/woff2/playfair-display-400.woff2 +0 -0
- package/src/fonts/woff2/playfair-display-700.woff2 +0 -0
- package/src/fonts/woff2/pt-serif-400.woff2 +0 -0
- package/src/fonts/woff2/pt-serif-700.woff2 +0 -0
- package/src/fonts/woff2/roboto-400.woff2 +0 -0
- package/src/fonts/woff2/roboto-400i.woff2 +0 -0
- package/src/fonts/woff2/roboto-700.woff2 +0 -0
- package/src/fonts/woff2/source-serif-4-400.woff2 +0 -0
- package/src/fonts/woff2/source-serif-4-400i.woff2 +0 -0
- package/src/fonts/woff2/source-serif-4-700.woff2 +0 -0
- package/src/hooks/useAutoSave.ts +87 -29
- package/src/icons.tsx +10 -0
- package/src/index.ts +1 -1
- package/src/preferences/versioning.ts +69 -0
- package/src/styles/docblocks.css +1037 -55
|
@@ -7,19 +7,33 @@
|
|
|
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';
|
|
14
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
DocumentVersionManager,
|
|
22
|
+
type PrunePolicy,
|
|
23
|
+
type SaveVersionResult,
|
|
24
|
+
} from '@bendyline/squisq/versions';
|
|
15
25
|
import type { FileSystemProvider, FileSystemEntry } from '@bendyline/docblocks/filesystem';
|
|
16
26
|
import {
|
|
17
27
|
IndexedDBFileSystemProvider,
|
|
18
|
-
|
|
28
|
+
ElectronFileSystemProvider,
|
|
29
|
+
FileSystemContentContainer,
|
|
30
|
+
createFileMediaProvider,
|
|
19
31
|
openNativeFolder,
|
|
20
32
|
restoreNativeFolder,
|
|
21
33
|
removeDirectoryHandle,
|
|
22
34
|
} from '@bendyline/docblocks/filesystem';
|
|
35
|
+
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
36
|
+
import { isElectronHost, getDocBlocksHost } from '@bendyline/docblocks/host';
|
|
23
37
|
import type { WorkspaceDescriptor } from '@bendyline/docblocks/workspace';
|
|
24
38
|
import {
|
|
25
39
|
ensureDefaultWorkspace,
|
|
@@ -29,18 +43,47 @@ import {
|
|
|
29
43
|
saveWorkspace,
|
|
30
44
|
touchWorkspace,
|
|
31
45
|
} from '@bendyline/docblocks/workspace';
|
|
32
|
-
import { AppMenu } from '../AppMenu/AppMenu.js';
|
|
46
|
+
import { AppMenu, type ThemePreference } from '../AppMenu/AppMenu.js';
|
|
33
47
|
import { FileExplorer } from '../FileExplorer/FileExplorer.js';
|
|
34
48
|
import { WorkspacePicker } from '../WorkspacePicker/WorkspacePicker.js';
|
|
35
49
|
import { WorkspaceSettingsButton } from '../WorkspacePicker/WorkspaceSettingsButton.js';
|
|
50
|
+
import {
|
|
51
|
+
WorkspaceSettingsDialog,
|
|
52
|
+
type WorkspaceVersioningOverride,
|
|
53
|
+
} from '../WorkspacePicker/WorkspaceSettingsDialog.js';
|
|
36
54
|
import { useAutoSave } from '../hooks/useAutoSave.js';
|
|
37
55
|
import { ExportToolbarControls } from '../Export/ExportToolbarControls.js';
|
|
56
|
+
import {
|
|
57
|
+
loadVersioningPreference,
|
|
58
|
+
resolveVersioningEnabled,
|
|
59
|
+
saveVersioningPreference,
|
|
60
|
+
type VersioningPreference,
|
|
61
|
+
} from '../preferences/versioning.js';
|
|
38
62
|
|
|
39
63
|
export interface DocBlocksShellProps {
|
|
40
64
|
/** Optional theme override. Omit or pass 'auto' to follow OS preference. */
|
|
41
65
|
theme?: EditorTheme | 'auto';
|
|
42
66
|
/** Optional logo image URL for the app menu. */
|
|
43
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>;
|
|
44
87
|
}
|
|
45
88
|
|
|
46
89
|
function useOsTheme(): 'light' | 'dark' {
|
|
@@ -79,6 +122,218 @@ function parseHash(): { workspaceId: string; filePath: string | null } | null {
|
|
|
79
122
|
};
|
|
80
123
|
}
|
|
81
124
|
|
|
125
|
+
const LAST_STATE_KEY = 'docblocks:lastState';
|
|
126
|
+
|
|
127
|
+
interface LastState {
|
|
128
|
+
workspaceId: string;
|
|
129
|
+
filePath: string;
|
|
130
|
+
view: EditorView;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function saveLastState(state: LastState): void {
|
|
134
|
+
try {
|
|
135
|
+
localStorage.setItem(LAST_STATE_KEY, JSON.stringify(state));
|
|
136
|
+
} catch {
|
|
137
|
+
// ignore quota errors
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function loadLastState(): LastState | null {
|
|
142
|
+
try {
|
|
143
|
+
const raw = localStorage.getItem(LAST_STATE_KEY);
|
|
144
|
+
if (!raw) return null;
|
|
145
|
+
return JSON.parse(raw);
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const THEME_PREF_KEY = 'docblocks:themePreference';
|
|
152
|
+
|
|
153
|
+
function loadThemePreference(): ThemePreference {
|
|
154
|
+
try {
|
|
155
|
+
const raw = localStorage.getItem(THEME_PREF_KEY);
|
|
156
|
+
if (raw === 'light' || raw === 'dark' || raw === 'auto') return raw;
|
|
157
|
+
} catch {
|
|
158
|
+
// ignore
|
|
159
|
+
}
|
|
160
|
+
return 'auto';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function saveThemePreference(pref: ThemePreference): void {
|
|
164
|
+
try {
|
|
165
|
+
localStorage.setItem(THEME_PREF_KEY, pref);
|
|
166
|
+
} catch {
|
|
167
|
+
// ignore quota errors
|
|
168
|
+
}
|
|
169
|
+
}
|
|
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
|
+
|
|
238
|
+
function dirnameOf(p: string): string {
|
|
239
|
+
const clean = p.replace(/^\/+/, '');
|
|
240
|
+
const idx = clean.lastIndexOf('/');
|
|
241
|
+
return idx === -1 ? '' : clean.slice(0, idx);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function basenameOf(p: string): string {
|
|
245
|
+
const clean = p.replace(/^\/+/, '');
|
|
246
|
+
const idx = clean.lastIndexOf('/');
|
|
247
|
+
return idx === -1 ? clean : clean.slice(idx + 1);
|
|
248
|
+
}
|
|
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
|
+
|
|
82
337
|
function useIsMobile(breakpoint = 768): boolean {
|
|
83
338
|
const [isMobile, setIsMobile] = useState(
|
|
84
339
|
() =>
|
|
@@ -93,13 +348,194 @@ function useIsMobile(breakpoint = 768): boolean {
|
|
|
93
348
|
return isMobile;
|
|
94
349
|
}
|
|
95
350
|
|
|
96
|
-
|
|
351
|
+
function FolderGlyph() {
|
|
352
|
+
return (
|
|
353
|
+
<svg
|
|
354
|
+
viewBox="0 0 24 24"
|
|
355
|
+
fill="none"
|
|
356
|
+
stroke="currentColor"
|
|
357
|
+
strokeWidth="1.5"
|
|
358
|
+
strokeLinejoin="round"
|
|
359
|
+
aria-hidden="true"
|
|
360
|
+
>
|
|
361
|
+
<path d="M3 7a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7z" />
|
|
362
|
+
</svg>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function FileGlyph() {
|
|
367
|
+
return (
|
|
368
|
+
<svg
|
|
369
|
+
viewBox="0 0 24 24"
|
|
370
|
+
fill="none"
|
|
371
|
+
stroke="currentColor"
|
|
372
|
+
strokeWidth="1.5"
|
|
373
|
+
strokeLinejoin="round"
|
|
374
|
+
aria-hidden="true"
|
|
375
|
+
>
|
|
376
|
+
<path d="M6 3h8l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" />
|
|
377
|
+
<path d="M14 3v5h5" />
|
|
378
|
+
</svg>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
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) {
|
|
97
392
|
const osTheme = useOsTheme();
|
|
98
|
-
const
|
|
393
|
+
const [themePreference, setThemePreference] = useState<ThemePreference>(loadThemePreference);
|
|
394
|
+
// "System default" (auto) always follows the OS — the host's theme prop
|
|
395
|
+
// is kept only for API back-compat and does not override OS detection.
|
|
396
|
+
const resolvedTheme: 'light' | 'dark' =
|
|
397
|
+
themePreference === 'light' || themePreference === 'dark' ? themePreference : osTheme;
|
|
398
|
+
|
|
399
|
+
const handleThemeChange = useCallback((pref: ThemePreference) => {
|
|
400
|
+
setThemePreference(pref);
|
|
401
|
+
saveThemePreference(pref);
|
|
402
|
+
}, []);
|
|
403
|
+
|
|
404
|
+
const [viewPreferences, setViewPreferences] = useState<ViewPreferences>(loadViewPreferences);
|
|
405
|
+
const handleViewPreferencesChange = useCallback((prefs: ViewPreferences) => {
|
|
406
|
+
setViewPreferences(prefs);
|
|
407
|
+
saveViewPreferences(prefs);
|
|
408
|
+
}, []);
|
|
99
409
|
const isMobile = useIsMobile();
|
|
100
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
|
+
);
|
|
101
493
|
const [provider, setProvider] = useState<FileSystemProvider | null>(null);
|
|
102
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
|
+
);
|
|
103
539
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
104
540
|
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
|
|
105
541
|
const [folderEntries, setFolderEntries] = useState<FileSystemEntry[]>([]);
|
|
@@ -109,17 +545,37 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
109
545
|
const [initialView, setInitialView] = useState<EditorView>('wysiwyg');
|
|
110
546
|
/** Suppress popstate handling during programmatic navigation. */
|
|
111
547
|
const skipPopState = useRef(false);
|
|
548
|
+
const lastLocalSaveRef = useRef<{
|
|
549
|
+
filePath: string;
|
|
550
|
+
content: string;
|
|
551
|
+
savedAt: number;
|
|
552
|
+
} | null>(null);
|
|
112
553
|
|
|
113
|
-
/**
|
|
114
|
-
|
|
554
|
+
/**
|
|
555
|
+
* Per-file media container: for `notes.md`, images live in
|
|
556
|
+
* `notes_files/` next to the markdown. Created lazily on first write,
|
|
557
|
+
* picked up automatically if it already exists. Set up by the effect
|
|
558
|
+
* below whenever the selected file (or backing provider) changes.
|
|
559
|
+
*/
|
|
560
|
+
const mediaContainerRef = useRef<ContentContainer | null>(null);
|
|
115
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);
|
|
116
572
|
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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);
|
|
123
579
|
|
|
124
580
|
/** Push a new history entry with the given hash. */
|
|
125
581
|
const pushHash = useCallback((wsId: string, filePath?: string | null) => {
|
|
@@ -130,17 +586,26 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
130
586
|
}
|
|
131
587
|
}, []);
|
|
132
588
|
|
|
133
|
-
/** Open a workspace (and optionally a file) from identifiers.
|
|
589
|
+
/** Open a workspace (and optionally a file) from identifiers.
|
|
590
|
+
* Returns the FileSystemProvider on success, or null on failure. */
|
|
134
591
|
const openFromIds = useCallback(
|
|
135
|
-
async (
|
|
592
|
+
async (
|
|
593
|
+
wsId: string,
|
|
594
|
+
filePath: string | null,
|
|
595
|
+
push: boolean,
|
|
596
|
+
view?: EditorView,
|
|
597
|
+
): Promise<FileSystemProvider | null> => {
|
|
136
598
|
const workspaces = await listWorkspaces();
|
|
137
599
|
const ws = workspaces.find((w) => w.id === wsId);
|
|
138
|
-
if (!ws) return
|
|
600
|
+
if (!ws) return null;
|
|
139
601
|
|
|
140
602
|
let fsProvider: FileSystemProvider | null = null;
|
|
141
|
-
if (ws.type === 'native') {
|
|
603
|
+
if (ws.type === 'electron-native') {
|
|
604
|
+
fsProvider = await createElectronProviderFromWorkspace(ws);
|
|
605
|
+
if (!fsProvider) return null;
|
|
606
|
+
} else if (ws.type === 'native') {
|
|
142
607
|
const restored = await restoreNativeFolder(ws.id);
|
|
143
|
-
if (!restored) return
|
|
608
|
+
if (!restored) return null;
|
|
144
609
|
fsProvider = restored;
|
|
145
610
|
} else {
|
|
146
611
|
fsProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
|
|
@@ -149,8 +614,6 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
149
614
|
setProvider(fsProvider);
|
|
150
615
|
setActiveWorkspaceId(ws.id);
|
|
151
616
|
|
|
152
|
-
setupMediaContainer(ws.id);
|
|
153
|
-
|
|
154
617
|
if (filePath) {
|
|
155
618
|
const content = await fsProvider.readFile(filePath);
|
|
156
619
|
if (content !== null) {
|
|
@@ -158,8 +621,10 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
158
621
|
setSelectedFolder(null);
|
|
159
622
|
setFolderEntries([]);
|
|
160
623
|
setEditorContent(content);
|
|
161
|
-
|
|
624
|
+
const effectiveView = view ?? 'wysiwyg';
|
|
625
|
+
setInitialView(effectiveView);
|
|
162
626
|
setEditorKey((k) => k + 1);
|
|
627
|
+
saveLastState({ workspaceId: wsId, filePath, view: effectiveView });
|
|
163
628
|
} else {
|
|
164
629
|
setSelectedFile(null);
|
|
165
630
|
setSelectedFolder(null);
|
|
@@ -178,49 +643,53 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
178
643
|
if (push) {
|
|
179
644
|
pushHash(ws.id, filePath);
|
|
180
645
|
}
|
|
181
|
-
return
|
|
646
|
+
return fsProvider;
|
|
182
647
|
},
|
|
183
|
-
[pushHash
|
|
648
|
+
[pushHash],
|
|
184
649
|
);
|
|
185
650
|
|
|
186
651
|
const seedWelcomeFile = useCallback(
|
|
187
652
|
async (fs: FileSystemProvider) => {
|
|
188
653
|
const entries = await fs.readDirectory('/');
|
|
189
654
|
|
|
190
|
-
// 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).
|
|
191
658
|
if (
|
|
192
659
|
entries.length === 1 &&
|
|
193
660
|
entries[0].kind === 'file' &&
|
|
194
|
-
entries[0].path === '
|
|
661
|
+
entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md'
|
|
195
662
|
) {
|
|
196
|
-
const
|
|
663
|
+
const aboutPath = entries[0].path;
|
|
664
|
+
const content = await fs.readFile(aboutPath);
|
|
197
665
|
if (content !== null) {
|
|
198
|
-
setSelectedFile(
|
|
666
|
+
setSelectedFile(aboutPath);
|
|
199
667
|
setEditorContent(content);
|
|
200
668
|
setInitialView('preview');
|
|
201
669
|
setEditorKey((k) => k + 1);
|
|
202
670
|
setExplorerKey((k) => k + 1);
|
|
203
|
-
pushHash(fs.id,
|
|
671
|
+
pushHash(fs.id, aboutPath);
|
|
672
|
+
saveLastState({ workspaceId: fs.id, filePath: aboutPath, view: 'preview' });
|
|
204
673
|
}
|
|
205
674
|
return;
|
|
206
675
|
}
|
|
207
676
|
|
|
208
677
|
if (entries.length > 0) return;
|
|
209
678
|
|
|
210
|
-
const welcomePath = '/
|
|
679
|
+
const welcomePath = '/aboutDocBlocks.md';
|
|
211
680
|
const welcomeContent = [
|
|
212
681
|
'# Welcome to DocBlocks',
|
|
213
682
|
'',
|
|
214
|
-
'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.',
|
|
215
686
|
'',
|
|
216
687
|
'## Features',
|
|
217
688
|
'',
|
|
218
|
-
'- **Rich Markdown Editing** — Write in a visual editor or switch to raw markdown anytime',
|
|
219
|
-
'- **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.',
|
|
220
692
|
'- **Playback & Video** — Preview your documents as rich visual presentations and export them as MP4 video',
|
|
221
|
-
'- **Export Anywhere** — Export documents to PDF, Word, PowerPoint, HTML, or Markdown with theme options',
|
|
222
|
-
'- **Local Storage** — Your documents are stored in your browser using temporary browser storage (backup often!)',
|
|
223
|
-
'- **Device Folders** — Create workspaces based on folders on your computer',
|
|
224
693
|
'- **No BS** — Free, no ads, no accounts, no tracking - everything runs locally in your browser',
|
|
225
694
|
'',
|
|
226
695
|
'## Getting Started',
|
|
@@ -229,7 +698,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
229
698
|
'2. Start writing in markdown — the editor supports headings, lists, links, images, and more',
|
|
230
699
|
'3. Your work is saved automatically',
|
|
231
700
|
'',
|
|
232
|
-
'Built with [Squiggly Square](https://github.com/
|
|
701
|
+
'Built with [Squiggly Square](https://github.com/bendyline/squisq) by [Bendyline](https://bendyline.com).',
|
|
233
702
|
].join('\n');
|
|
234
703
|
|
|
235
704
|
await fs.writeFile(welcomePath, welcomeContent);
|
|
@@ -239,6 +708,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
239
708
|
setEditorKey((k) => k + 1);
|
|
240
709
|
setExplorerKey((k) => k + 1);
|
|
241
710
|
pushHash(fs.id, welcomePath);
|
|
711
|
+
saveLastState({ workspaceId: fs.id, filePath: welcomePath, view: 'preview' });
|
|
242
712
|
},
|
|
243
713
|
[pushHash],
|
|
244
714
|
);
|
|
@@ -249,27 +719,61 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
249
719
|
// Try restoring from URL hash first
|
|
250
720
|
const hashState = parseHash();
|
|
251
721
|
if (hashState) {
|
|
252
|
-
const
|
|
253
|
-
|
|
722
|
+
const restoredProvider = await openFromIds(
|
|
723
|
+
hashState.workspaceId,
|
|
724
|
+
hashState.filePath,
|
|
725
|
+
false,
|
|
726
|
+
);
|
|
727
|
+
if (restoredProvider) {
|
|
728
|
+
// If the hash had no file, check whether we should auto-select the welcome doc
|
|
729
|
+
if (!hashState.filePath) {
|
|
730
|
+
await seedWelcomeFile(restoredProvider);
|
|
731
|
+
}
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Try restoring last viewed document from localStorage
|
|
737
|
+
const lastState = loadLastState();
|
|
738
|
+
if (lastState) {
|
|
739
|
+
const restoredProvider = await openFromIds(
|
|
740
|
+
lastState.workspaceId,
|
|
741
|
+
lastState.filePath,
|
|
742
|
+
true,
|
|
743
|
+
lastState.view,
|
|
744
|
+
);
|
|
745
|
+
if (restoredProvider) return;
|
|
254
746
|
}
|
|
255
747
|
|
|
256
748
|
let fsProvider: FileSystemProvider | null = null;
|
|
257
749
|
|
|
750
|
+
const electron = isElectronHost();
|
|
258
751
|
const workspaces = await listWorkspaces();
|
|
752
|
+
// On desktop, hide web-only workspaces (indexeddb/native) — only
|
|
753
|
+
// folder-based workspaces are valid.
|
|
754
|
+
const candidates = electron
|
|
755
|
+
? workspaces.filter((w) => w.type === 'electron-native')
|
|
756
|
+
: workspaces.filter((w) => w.type !== 'electron-native');
|
|
259
757
|
// Pick the most recently opened workspace
|
|
260
|
-
const sorted = [...
|
|
758
|
+
const sorted = [...candidates].sort((a, b) =>
|
|
261
759
|
(b.lastOpened ?? '').localeCompare(a.lastOpened ?? ''),
|
|
262
760
|
);
|
|
263
761
|
for (const ws of sorted) {
|
|
264
|
-
if (ws.type === 'native') {
|
|
762
|
+
if (ws.type === 'electron-native') {
|
|
763
|
+
const p = await createElectronProviderFromWorkspace(ws);
|
|
764
|
+
if (!p) continue;
|
|
765
|
+
await touchWorkspace(ws.id);
|
|
766
|
+
fsProvider = p;
|
|
767
|
+
setProvider(p);
|
|
768
|
+
setActiveWorkspaceId(ws.id);
|
|
769
|
+
break;
|
|
770
|
+
} else if (ws.type === 'native') {
|
|
265
771
|
const restored = await restoreNativeFolder(ws.id);
|
|
266
772
|
if (restored) {
|
|
267
773
|
await touchWorkspace(ws.id);
|
|
268
774
|
fsProvider = restored;
|
|
269
775
|
setProvider(restored);
|
|
270
776
|
setActiveWorkspaceId(ws.id);
|
|
271
|
-
|
|
272
|
-
setupMediaContainer(ws.id);
|
|
273
777
|
break;
|
|
274
778
|
}
|
|
275
779
|
} else {
|
|
@@ -278,21 +782,35 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
278
782
|
fsProvider = p;
|
|
279
783
|
setProvider(p);
|
|
280
784
|
setActiveWorkspaceId(ws.id);
|
|
281
|
-
|
|
282
|
-
setupMediaContainer(ws.id);
|
|
283
785
|
break;
|
|
284
786
|
}
|
|
285
787
|
}
|
|
286
788
|
|
|
287
789
|
if (!fsProvider) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
790
|
+
if (electron) {
|
|
791
|
+
// Desktop: ask the host for the default folder workspace
|
|
792
|
+
// (creates ~/Documents/DocBlocks on first launch).
|
|
793
|
+
const info = await getDocBlocksHost().workspaces.getDefault();
|
|
794
|
+
const descriptor: WorkspaceDescriptor = {
|
|
795
|
+
id: info.id,
|
|
796
|
+
name: info.name,
|
|
797
|
+
type: 'electron-native',
|
|
798
|
+
rootPath: info.rootPath,
|
|
799
|
+
lastOpened: new Date().toISOString(),
|
|
800
|
+
};
|
|
801
|
+
await saveWorkspace(descriptor);
|
|
802
|
+
const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
|
|
803
|
+
fsProvider = p;
|
|
804
|
+
setProvider(p);
|
|
805
|
+
setActiveWorkspaceId(info.id);
|
|
806
|
+
} else {
|
|
807
|
+
// Web: create the IndexedDB default workspace
|
|
808
|
+
const defaultWs = await ensureDefaultWorkspace();
|
|
809
|
+
const p = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
|
|
810
|
+
fsProvider = p;
|
|
811
|
+
setProvider(p);
|
|
812
|
+
setActiveWorkspaceId(defaultWs.id);
|
|
813
|
+
}
|
|
296
814
|
}
|
|
297
815
|
|
|
298
816
|
// Set initial hash
|
|
@@ -301,7 +819,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
301
819
|
// Seed welcome file if workspace is empty
|
|
302
820
|
await seedWelcomeFile(fsProvider);
|
|
303
821
|
})();
|
|
304
|
-
}, [openFromIds, pushHash, seedWelcomeFile
|
|
822
|
+
}, [openFromIds, pushHash, seedWelcomeFile]);
|
|
305
823
|
|
|
306
824
|
// Handle browser back/forward
|
|
307
825
|
useEffect(() => {
|
|
@@ -319,23 +837,198 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
319
837
|
return () => window.removeEventListener('popstate', onPopState);
|
|
320
838
|
}, [openFromIds]);
|
|
321
839
|
|
|
322
|
-
//
|
|
323
|
-
|
|
840
|
+
// Track view mode changes (Editor/Raw/Play tabs) and persist to localStorage
|
|
841
|
+
useEffect(() => {
|
|
842
|
+
const handler = (e: MouseEvent) => {
|
|
843
|
+
const target = (e.target as HTMLElement).closest?.('[data-view]');
|
|
844
|
+
if (target) {
|
|
845
|
+
const view = target.getAttribute('data-view') as EditorView;
|
|
846
|
+
if (view && activeWorkspaceId && selectedFile) {
|
|
847
|
+
saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view });
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
window.addEventListener('click', handler, true);
|
|
852
|
+
return () => window.removeEventListener('click', handler, true);
|
|
853
|
+
}, [activeWorkspaceId, selectedFile]);
|
|
854
|
+
|
|
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
|
+
}, []);
|
|
899
|
+
|
|
900
|
+
// Per-file media: for `notes.md` images live in `notes_files/` beside it.
|
|
901
|
+
// Rebuilds whenever the provider or selected file changes.
|
|
902
|
+
useEffect(() => {
|
|
903
|
+
if (!provider || !selectedFile) {
|
|
904
|
+
mediaContainerRef.current = null;
|
|
905
|
+
versionsContainerRef.current = null;
|
|
906
|
+
setMediaProvider(null);
|
|
907
|
+
setVersionsContainer(null);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const parentDir = dirnameOf(selectedFile);
|
|
911
|
+
const base = basenameOf(selectedFile);
|
|
912
|
+
const baseNoExt = base.replace(/\.[^.]+$/, '');
|
|
913
|
+
const container = new FileSystemContentContainer(provider, parentDir);
|
|
914
|
+
const vPrefix = parentDir ? `${parentDir}/${baseNoExt}_files` : `${baseNoExt}_files`;
|
|
915
|
+
const vContainer = new FileSystemContentContainer(provider, vPrefix);
|
|
916
|
+
const mp = createFileMediaProvider(container, base);
|
|
917
|
+
mediaContainerRef.current = container;
|
|
918
|
+
versionsContainerRef.current = vContainer;
|
|
919
|
+
setMediaProvider(mp);
|
|
920
|
+
setVersionsContainer(vContainer);
|
|
921
|
+
return () => {
|
|
922
|
+
mp.dispose();
|
|
923
|
+
};
|
|
924
|
+
}, [provider, selectedFile]);
|
|
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
|
+
|
|
984
|
+
// React to external file changes watched by the Electron host (chokidar).
|
|
985
|
+
useEffect(() => {
|
|
986
|
+
if (!isElectronHost()) return;
|
|
987
|
+
if (!provider || !(provider instanceof ElectronFileSystemProvider)) return;
|
|
988
|
+
const unwatch = provider.watch((changedPath) => {
|
|
989
|
+
setExplorerKey((k) => k + 1);
|
|
990
|
+
if (!selectedFile || !sameProviderPath(changedPath, selectedFile)) return;
|
|
991
|
+
|
|
992
|
+
// If the open file's contents changed on disk, reload it (best-effort).
|
|
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
|
+
})();
|
|
1010
|
+
});
|
|
1011
|
+
return unwatch;
|
|
1012
|
+
}, [provider, selectedFile, editorContent]);
|
|
324
1013
|
|
|
325
1014
|
const handleWorkspaceSelect = useCallback(
|
|
326
1015
|
async (ws: WorkspaceDescriptor) => {
|
|
327
1016
|
await touchWorkspace(ws.id);
|
|
328
|
-
|
|
1017
|
+
let nextProvider: FileSystemProvider | null = null;
|
|
1018
|
+
if (ws.type === 'electron-native') {
|
|
1019
|
+
nextProvider = await createElectronProviderFromWorkspace(ws);
|
|
1020
|
+
if (!nextProvider) return;
|
|
1021
|
+
} else if (ws.type === 'native') {
|
|
329
1022
|
const restored = await restoreNativeFolder(ws.id);
|
|
330
|
-
if (restored) {
|
|
331
|
-
setProvider(restored);
|
|
332
|
-
} else {
|
|
1023
|
+
if (!restored) {
|
|
333
1024
|
// Permission denied or handle lost — fall through without changing provider
|
|
334
1025
|
return;
|
|
335
1026
|
}
|
|
1027
|
+
nextProvider = restored;
|
|
336
1028
|
} else {
|
|
337
|
-
|
|
1029
|
+
nextProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
|
|
338
1030
|
}
|
|
1031
|
+
setProvider(nextProvider);
|
|
339
1032
|
setActiveWorkspaceId(ws.id);
|
|
340
1033
|
setSelectedFile(null);
|
|
341
1034
|
setSelectedFolder(null);
|
|
@@ -343,14 +1036,35 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
343
1036
|
setEditorContent('');
|
|
344
1037
|
setEditorKey((k) => k + 1);
|
|
345
1038
|
pushHash(ws.id, null);
|
|
346
|
-
|
|
347
|
-
setupMediaContainer(ws.id);
|
|
348
1039
|
},
|
|
349
|
-
[pushHash
|
|
1040
|
+
[pushHash],
|
|
350
1041
|
);
|
|
351
1042
|
|
|
352
1043
|
const handleOpenFolder = useCallback(async () => {
|
|
353
1044
|
try {
|
|
1045
|
+
if (isElectronHost()) {
|
|
1046
|
+
const info = await getDocBlocksHost().workspaces.pickFolder();
|
|
1047
|
+
if (!info) return; // user cancelled
|
|
1048
|
+
const descriptor: WorkspaceDescriptor = {
|
|
1049
|
+
id: info.id,
|
|
1050
|
+
name: info.name,
|
|
1051
|
+
type: 'electron-native',
|
|
1052
|
+
rootPath: info.rootPath,
|
|
1053
|
+
lastOpened: new Date().toISOString(),
|
|
1054
|
+
};
|
|
1055
|
+
await saveWorkspace(descriptor);
|
|
1056
|
+
const provider = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
|
|
1057
|
+
setProvider(provider);
|
|
1058
|
+
setActiveWorkspaceId(descriptor.id);
|
|
1059
|
+
setSelectedFile(null);
|
|
1060
|
+
setSelectedFolder(null);
|
|
1061
|
+
setFolderEntries([]);
|
|
1062
|
+
setEditorContent('');
|
|
1063
|
+
setEditorKey((k) => k + 1);
|
|
1064
|
+
pushHash(descriptor.id, null);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
354
1068
|
const nativeProvider = await openNativeFolder();
|
|
355
1069
|
const descriptor: WorkspaceDescriptor = {
|
|
356
1070
|
id: nativeProvider.id,
|
|
@@ -367,12 +1081,108 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
367
1081
|
setEditorContent('');
|
|
368
1082
|
setEditorKey((k) => k + 1);
|
|
369
1083
|
pushHash(descriptor.id, null);
|
|
370
|
-
|
|
371
|
-
setupMediaContainer(descriptor.id);
|
|
372
1084
|
} catch {
|
|
373
1085
|
// User cancelled or API not supported
|
|
374
1086
|
}
|
|
375
|
-
}, [pushHash
|
|
1087
|
+
}, [pushHash]);
|
|
1088
|
+
|
|
1089
|
+
const handleNewFile = useCallback(async () => {
|
|
1090
|
+
if (!provider) return;
|
|
1091
|
+
const name = prompt('New document name:', 'Untitled.md');
|
|
1092
|
+
if (!name) return;
|
|
1093
|
+
const filename = /\.[a-zA-Z0-9]+$/.test(name) ? name : `${name}.md`;
|
|
1094
|
+
await provider.writeFile(filename, '# ' + filename.replace(/\.[^.]+$/, '') + '\n');
|
|
1095
|
+
setSelectedFile('/' + filename);
|
|
1096
|
+
setEditorContent('# ' + filename.replace(/\.[^.]+$/, '') + '\n');
|
|
1097
|
+
setInitialView('wysiwyg');
|
|
1098
|
+
setEditorKey((k) => k + 1);
|
|
1099
|
+
setExplorerKey((k) => k + 1);
|
|
1100
|
+
if (activeWorkspaceId) {
|
|
1101
|
+
pushHash(activeWorkspaceId, '/' + filename);
|
|
1102
|
+
}
|
|
1103
|
+
}, [provider, activeWorkspaceId, pushHash]);
|
|
1104
|
+
|
|
1105
|
+
const handleRevealWorkspace = useCallback(async () => {
|
|
1106
|
+
if (!isElectronHost() || !activeWorkspaceId) return;
|
|
1107
|
+
const ws = await getWorkspace(activeWorkspaceId);
|
|
1108
|
+
if (ws?.type === 'electron-native' && ws.rootPath) {
|
|
1109
|
+
await getDocBlocksHost().shell.revealInFolder(ws.rootPath);
|
|
1110
|
+
}
|
|
1111
|
+
}, [activeWorkspaceId]);
|
|
1112
|
+
|
|
1113
|
+
// Subscribe to native menu commands (Electron host).
|
|
1114
|
+
useEffect(() => {
|
|
1115
|
+
if (!isElectronHost()) return;
|
|
1116
|
+
const host = getDocBlocksHost();
|
|
1117
|
+
return host.onMenuCommand((cmd) => {
|
|
1118
|
+
switch (cmd) {
|
|
1119
|
+
case 'file:new':
|
|
1120
|
+
handleNewFile();
|
|
1121
|
+
break;
|
|
1122
|
+
case 'file:openFolder':
|
|
1123
|
+
handleOpenFolder();
|
|
1124
|
+
break;
|
|
1125
|
+
case 'file:revealWorkspace':
|
|
1126
|
+
handleRevealWorkspace();
|
|
1127
|
+
break;
|
|
1128
|
+
case 'help:viewOnGitHub':
|
|
1129
|
+
host.shell.openExternal('https://github.com/bendyline/docblocks');
|
|
1130
|
+
break;
|
|
1131
|
+
case 'help:checkForUpdates':
|
|
1132
|
+
host.updater.checkForUpdates().catch(() => {
|
|
1133
|
+
// errors surface via updater.onStatus
|
|
1134
|
+
});
|
|
1135
|
+
break;
|
|
1136
|
+
case 'help:about':
|
|
1137
|
+
(async () => {
|
|
1138
|
+
const version = await host.updater.getVersion();
|
|
1139
|
+
alert(`DocBlocks ${version}`);
|
|
1140
|
+
})();
|
|
1141
|
+
break;
|
|
1142
|
+
default:
|
|
1143
|
+
break;
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
}, [handleNewFile, handleOpenFolder, handleRevealWorkspace]);
|
|
1147
|
+
|
|
1148
|
+
// Subscribe to open-file / deep-link requests from the OS.
|
|
1149
|
+
useEffect(() => {
|
|
1150
|
+
if (!isElectronHost()) return;
|
|
1151
|
+
const host = getDocBlocksHost();
|
|
1152
|
+
return host.onOpenRequest(async (req) => {
|
|
1153
|
+
if (req.filePath) {
|
|
1154
|
+
const workspaces = (await listWorkspaces()).filter(
|
|
1155
|
+
(w) => w.type === 'electron-native' && w.rootPath,
|
|
1156
|
+
);
|
|
1157
|
+
const match = workspaces.find(
|
|
1158
|
+
(w) => req.filePath!.startsWith((w.rootPath ?? '') + '/') || req.filePath === w.rootPath,
|
|
1159
|
+
);
|
|
1160
|
+
if (match && match.rootPath) {
|
|
1161
|
+
const rel = '/' + req.filePath.slice(match.rootPath.length).replace(/^\/+/, '');
|
|
1162
|
+
await openFromIds(match.id, rel, true);
|
|
1163
|
+
}
|
|
1164
|
+
} else if (req.url) {
|
|
1165
|
+
try {
|
|
1166
|
+
const u = new URL(req.url);
|
|
1167
|
+
const path = u.searchParams.get('path');
|
|
1168
|
+
if (path) {
|
|
1169
|
+
const workspaces = (await listWorkspaces()).filter(
|
|
1170
|
+
(w) => w.type === 'electron-native' && w.rootPath,
|
|
1171
|
+
);
|
|
1172
|
+
const match = workspaces.find(
|
|
1173
|
+
(w) => path.startsWith((w.rootPath ?? '') + '/') || path === w.rootPath,
|
|
1174
|
+
);
|
|
1175
|
+
if (match && match.rootPath) {
|
|
1176
|
+
const rel = '/' + path.slice(match.rootPath.length).replace(/^\/+/, '');
|
|
1177
|
+
await openFromIds(match.id, rel, true);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
} catch {
|
|
1181
|
+
// bad URL, ignore
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
}, [openFromIds]);
|
|
376
1186
|
|
|
377
1187
|
const handleSelect = useCallback(
|
|
378
1188
|
async (path: string, kind: 'file' | 'directory') => {
|
|
@@ -395,10 +1205,11 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
395
1205
|
setInitialView('wysiwyg');
|
|
396
1206
|
setEditorKey((k) => k + 1);
|
|
397
1207
|
pushHash(activeWorkspaceId, path);
|
|
398
|
-
|
|
1208
|
+
saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
|
|
1209
|
+
if (effectiveCompact) setMobileShowEditor(true);
|
|
399
1210
|
}
|
|
400
1211
|
},
|
|
401
|
-
[provider, activeWorkspaceId, pushHash,
|
|
1212
|
+
[provider, activeWorkspaceId, pushHash, effectiveCompact],
|
|
402
1213
|
);
|
|
403
1214
|
|
|
404
1215
|
const handleTreeChange = useCallback(async () => {
|
|
@@ -419,21 +1230,30 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
419
1230
|
}
|
|
420
1231
|
}, [provider, selectedFile, selectedFolder, activeWorkspaceId, pushHash]);
|
|
421
1232
|
|
|
422
|
-
/**
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
1233
|
+
/**
|
|
1234
|
+
* Copy non-markdown files from an imported container into the target
|
|
1235
|
+
* markdown file's `_files/` sibling folder, preserving the source's
|
|
1236
|
+
* relative structure.
|
|
1237
|
+
*/
|
|
1238
|
+
const persistImportedMedia = useCallback(
|
|
1239
|
+
async (
|
|
1240
|
+
source: {
|
|
1241
|
+
listFiles(): Promise<Array<{ path: string; mimeType: string }>>;
|
|
1242
|
+
readFile(path: string): Promise<ArrayBuffer | null>;
|
|
1243
|
+
},
|
|
1244
|
+
target: FileSystemProvider,
|
|
1245
|
+
importedMarkdownPath: string,
|
|
1246
|
+
) => {
|
|
1247
|
+
const parentDir = dirnameOf(importedMarkdownPath);
|
|
1248
|
+
const folder = basenameOf(importedMarkdownPath).replace(/\.[^.]+$/, '') + '_files';
|
|
1249
|
+
const mediaRoot = parentDir ? `${parentDir}/${folder}` : folder;
|
|
430
1250
|
const entries = await source.listFiles();
|
|
431
1251
|
for (const entry of entries) {
|
|
432
1252
|
if (entry.path.endsWith('.md')) continue;
|
|
433
1253
|
const data = await source.readFile(entry.path);
|
|
434
|
-
if (data)
|
|
435
|
-
|
|
436
|
-
}
|
|
1254
|
+
if (!data) continue;
|
|
1255
|
+
const cleanPath = entry.path.replace(/^\/+/, '');
|
|
1256
|
+
await target.writeBinary(`${mediaRoot}/${cleanPath}`, new Uint8Array(data));
|
|
437
1257
|
}
|
|
438
1258
|
},
|
|
439
1259
|
[],
|
|
@@ -455,17 +1275,17 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
455
1275
|
const { docxToContainer } = await import('@bendyline/squisq-formats/docx');
|
|
456
1276
|
const container = await docxToContainer(await file.arrayBuffer());
|
|
457
1277
|
markdown = (await container.readDocument()) ?? '';
|
|
458
|
-
await
|
|
1278
|
+
await persistImportedMedia(container, provider, destPath);
|
|
459
1279
|
} else if (ext === '.pdf') {
|
|
460
1280
|
const { pdfToContainer } = await import('@bendyline/squisq-formats/pdf');
|
|
461
1281
|
const container = await pdfToContainer(await file.arrayBuffer());
|
|
462
1282
|
markdown = (await container.readDocument()) ?? '';
|
|
463
|
-
await
|
|
1283
|
+
await persistImportedMedia(container, provider, destPath);
|
|
464
1284
|
} else if (ext === '.dbk' || ext === '.zip') {
|
|
465
1285
|
const { zipToContainer } = await import('@bendyline/squisq-formats/container');
|
|
466
1286
|
const container = await zipToContainer(await file.arrayBuffer());
|
|
467
1287
|
markdown = (await container.readDocument()) ?? '';
|
|
468
|
-
await
|
|
1288
|
+
await persistImportedMedia(container, provider, destPath);
|
|
469
1289
|
} else {
|
|
470
1290
|
continue;
|
|
471
1291
|
}
|
|
@@ -475,15 +1295,10 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
475
1295
|
console.error(`Failed to import ${file.name}:`, err);
|
|
476
1296
|
}
|
|
477
1297
|
}
|
|
478
|
-
// Refresh
|
|
479
|
-
const mc = mediaContainerRef.current;
|
|
480
|
-
if (mc) {
|
|
481
|
-
setMediaProvider(createMediaProviderFromContainer(mc));
|
|
482
|
-
}
|
|
483
|
-
// Refresh file tree
|
|
1298
|
+
// Refresh file tree so the new markdown files + _files folders show up.
|
|
484
1299
|
setExplorerKey((k) => k + 1);
|
|
485
1300
|
},
|
|
486
|
-
[provider,
|
|
1301
|
+
[provider, persistImportedMedia],
|
|
487
1302
|
);
|
|
488
1303
|
|
|
489
1304
|
const handleEditorChange = useCallback((source: string) => {
|
|
@@ -499,41 +1314,205 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
499
1314
|
await saveWorkspace({ ...ws, name: newName });
|
|
500
1315
|
// Bump key to trigger re-render — workspace name is read from the descriptor, not the provider
|
|
501
1316
|
setEditorKey((k) => k + 1);
|
|
502
|
-
}, [activeWorkspaceId
|
|
1317
|
+
}, [activeWorkspaceId]);
|
|
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
|
+
);
|
|
503
1358
|
|
|
504
1359
|
const handleDownloadWorkspace = useCallback(async () => {
|
|
505
1360
|
if (!provider) return;
|
|
506
1361
|
try {
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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);
|
|
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++})`;
|
|
513
1440
|
}
|
|
1441
|
+
usedFolders.add(folder);
|
|
1442
|
+
|
|
1443
|
+
await copyProviderToContainer(p, container, folder);
|
|
514
1444
|
}
|
|
515
|
-
|
|
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);
|
|
516
1452
|
const url = URL.createObjectURL(blob);
|
|
517
1453
|
const a = document.createElement('a');
|
|
518
1454
|
a.href = url;
|
|
519
|
-
|
|
1455
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
1456
|
+
a.download = `docblocks-workspaces-${stamp}.zip`;
|
|
520
1457
|
a.click();
|
|
521
1458
|
URL.revokeObjectURL(url);
|
|
522
|
-
|
|
523
|
-
|
|
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.');
|
|
524
1468
|
}
|
|
525
|
-
}, [
|
|
1469
|
+
}, [copyProviderToContainer]);
|
|
526
1470
|
|
|
527
1471
|
const handleRemoveWorkspace = useCallback(async () => {
|
|
528
1472
|
if (!activeWorkspaceId) return;
|
|
529
|
-
|
|
530
|
-
|
|
1473
|
+
const confirmMsg = isElectronHost()
|
|
1474
|
+
? 'Remove this workspace from DocBlocks? The files on disk will not be deleted.'
|
|
1475
|
+
: 'Remove this workspace? This cannot be undone.';
|
|
1476
|
+
if (!confirm(confirmMsg)) return;
|
|
1477
|
+
|
|
1478
|
+
const ws = await getWorkspace(activeWorkspaceId);
|
|
1479
|
+
if (ws?.type === 'electron-native') {
|
|
1480
|
+
try {
|
|
1481
|
+
await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
|
|
1482
|
+
} catch {
|
|
1483
|
+
// ignore — host cleanup is best-effort
|
|
1484
|
+
}
|
|
1485
|
+
} else {
|
|
1486
|
+
await removeDirectoryHandle(activeWorkspaceId);
|
|
1487
|
+
}
|
|
531
1488
|
await removeWorkspace(activeWorkspaceId);
|
|
1489
|
+
|
|
1490
|
+
const electron = isElectronHost();
|
|
532
1491
|
// Switch to most recent remaining workspace or create default
|
|
533
|
-
const remaining = await listWorkspaces()
|
|
1492
|
+
const remaining = (await listWorkspaces()).filter((w) =>
|
|
1493
|
+
electron ? w.type === 'electron-native' : w.type !== 'electron-native',
|
|
1494
|
+
);
|
|
534
1495
|
if (remaining.length > 0) {
|
|
535
1496
|
const next = remaining[0];
|
|
536
1497
|
await handleWorkspaceSelect(next);
|
|
1498
|
+
} else if (electron) {
|
|
1499
|
+
const info = await getDocBlocksHost().workspaces.getDefault();
|
|
1500
|
+
const descriptor: WorkspaceDescriptor = {
|
|
1501
|
+
id: info.id,
|
|
1502
|
+
name: info.name,
|
|
1503
|
+
type: 'electron-native',
|
|
1504
|
+
rootPath: info.rootPath,
|
|
1505
|
+
lastOpened: new Date().toISOString(),
|
|
1506
|
+
};
|
|
1507
|
+
await saveWorkspace(descriptor);
|
|
1508
|
+
const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
|
|
1509
|
+
setProvider(p);
|
|
1510
|
+
setActiveWorkspaceId(info.id);
|
|
1511
|
+
setSelectedFile(null);
|
|
1512
|
+
setSelectedFolder(null);
|
|
1513
|
+
setFolderEntries([]);
|
|
1514
|
+
setEditorContent('');
|
|
1515
|
+
setEditorKey((k) => k + 1);
|
|
537
1516
|
} else {
|
|
538
1517
|
const defaultWs = await ensureDefaultWorkspace();
|
|
539
1518
|
const fsProvider = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
|
|
@@ -544,26 +1523,54 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
544
1523
|
setFolderEntries([]);
|
|
545
1524
|
setEditorContent('');
|
|
546
1525
|
setEditorKey((k) => k + 1);
|
|
547
|
-
|
|
548
|
-
setupMediaContainer(defaultWs.id);
|
|
549
1526
|
}
|
|
550
|
-
}, [activeWorkspaceId, handleWorkspaceSelect
|
|
1527
|
+
}, [activeWorkspaceId, handleWorkspaceSelect]);
|
|
551
1528
|
|
|
552
1529
|
return (
|
|
553
|
-
<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
|
+
)}
|
|
554
1547
|
{/* Main area */}
|
|
555
1548
|
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
|
556
|
-
{/* Left sidebar — hidden
|
|
557
|
-
|
|
558
|
-
|
|
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
|
+
>
|
|
559
1558
|
<div className="db-shell-sidebar-header">
|
|
560
|
-
<AppMenu
|
|
1559
|
+
<AppMenu
|
|
1560
|
+
logoUrl={logoUrl}
|
|
1561
|
+
themePreference={themePreference}
|
|
1562
|
+
onThemeChange={handleThemeChange}
|
|
1563
|
+
versioningPreference={versioningPreference}
|
|
1564
|
+
onVersioningPreferenceChange={handleVersioningPreferenceChange}
|
|
1565
|
+
onDownloadAllWorkspaces={handleDownloadAllWorkspaces}
|
|
1566
|
+
/>
|
|
561
1567
|
<WorkspacePicker
|
|
562
1568
|
activeWorkspaceId={activeWorkspaceId}
|
|
563
1569
|
onSelect={handleWorkspaceSelect}
|
|
564
1570
|
onOpenFolder={handleOpenFolder}
|
|
565
1571
|
/>
|
|
566
1572
|
<WorkspaceSettingsButton
|
|
1573
|
+
onSettings={handleOpenWorkspaceSettings}
|
|
567
1574
|
onRename={handleRenameWorkspace}
|
|
568
1575
|
onDownload={handleDownloadWorkspace}
|
|
569
1576
|
onRemove={handleRemoveWorkspace}
|
|
@@ -588,8 +1595,20 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
588
1595
|
</div>
|
|
589
1596
|
)}
|
|
590
1597
|
|
|
591
|
-
{/*
|
|
592
|
-
|
|
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) && (
|
|
593
1612
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
|
594
1613
|
{selectedFile && mediaProvider ? (
|
|
595
1614
|
<MediaContext.Provider value={mediaProvider}>
|
|
@@ -598,36 +1617,80 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
598
1617
|
initialMarkdown={editorContent}
|
|
599
1618
|
initialView={initialView}
|
|
600
1619
|
articleId={selectedFile}
|
|
1620
|
+
fileName={selectedFile}
|
|
601
1621
|
onChange={handleEditorChange}
|
|
602
1622
|
theme={resolvedTheme}
|
|
603
1623
|
height="100%"
|
|
1624
|
+
outlineWidth={280}
|
|
604
1625
|
mediaProvider={mediaProvider}
|
|
605
|
-
|
|
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}
|
|
606
1635
|
toolbarSlotLeft={
|
|
607
|
-
|
|
608
|
-
<button
|
|
1636
|
+
effectiveCompact ? (
|
|
1637
|
+
<button
|
|
1638
|
+
className="db-mobile-back"
|
|
1639
|
+
onClick={() => setMobileShowEditor(false)}
|
|
1640
|
+
aria-label="Show file list"
|
|
1641
|
+
>
|
|
609
1642
|
<span className="db-mobile-back-arrow">←</span>
|
|
610
1643
|
</button>
|
|
611
1644
|
) : undefined
|
|
612
1645
|
}
|
|
613
1646
|
toolbarSlotRight={
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
+
</>
|
|
618
1679
|
}
|
|
619
1680
|
/>
|
|
620
1681
|
</MediaContext.Provider>
|
|
621
1682
|
) : selectedFolder ? (
|
|
622
1683
|
<div className="db-folder-view">
|
|
623
|
-
{
|
|
1684
|
+
{effectiveCompact && (
|
|
624
1685
|
<button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
|
|
625
1686
|
<span className="db-mobile-back-arrow">←</span>
|
|
626
1687
|
Back to files
|
|
627
1688
|
</button>
|
|
628
1689
|
)}
|
|
629
1690
|
<div className="db-folder-view-header">
|
|
630
|
-
<span className="db-folder-view-icon"
|
|
1691
|
+
<span className="db-folder-view-icon">
|
|
1692
|
+
<FolderGlyph />
|
|
1693
|
+
</span>
|
|
631
1694
|
<span className="db-folder-view-path">{selectedFolder}</span>
|
|
632
1695
|
</div>
|
|
633
1696
|
{folderEntries.length === 0 ? (
|
|
@@ -641,7 +1704,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
641
1704
|
onClick={() => handleSelect(entry.path, entry.kind)}
|
|
642
1705
|
>
|
|
643
1706
|
<span className="db-folder-view-item-icon">
|
|
644
|
-
{entry.kind === 'directory' ?
|
|
1707
|
+
{entry.kind === 'directory' ? <FolderGlyph /> : <FileGlyph />}
|
|
645
1708
|
</span>
|
|
646
1709
|
{entry.name}
|
|
647
1710
|
</li>
|
|
@@ -651,6 +1714,12 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
|
|
|
651
1714
|
</div>
|
|
652
1715
|
) : (
|
|
653
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
|
+
)}
|
|
654
1723
|
<p>Select a file to start editing, or create a new one.</p>
|
|
655
1724
|
</div>
|
|
656
1725
|
)}
|