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