@bendyline/docblocks-react 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -14
- package/dist/AppMenu/AppMenu.d.ts +11 -1
- package/dist/AppMenu/AppMenu.d.ts.map +1 -1
- package/dist/AppMenu/AppMenu.js +2 -2
- package/dist/AppMenu/AppMenu.js.map +1 -1
- package/dist/DocBlocksShell/DocBlocksShell.d.ts +23 -3
- package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
- package/dist/DocBlocksShell/DocBlocksShell.js +621 -94
- package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
- package/dist/Export/ExportDialog.d.ts.map +1 -1
- package/dist/Export/ExportDialog.js +123 -7
- package/dist/Export/ExportDialog.js.map +1 -1
- package/dist/Export/ExportToolbarControls.d.ts.map +1 -1
- package/dist/Export/ExportToolbarControls.js +108 -17
- package/dist/Export/ExportToolbarControls.js.map +1 -1
- package/dist/Export/export-options.d.ts +35 -0
- package/dist/Export/export-options.d.ts.map +1 -1
- package/dist/Export/export-options.js +6 -1
- package/dist/Export/export-options.js.map +1 -1
- package/dist/Export/run-export.d.ts.map +1 -1
- package/dist/Export/run-export.js +185 -97
- package/dist/Export/run-export.js.map +1 -1
- package/dist/Export/transform-summaries.d.ts +7 -0
- package/dist/Export/transform-summaries.d.ts.map +1 -0
- package/dist/Export/transform-summaries.js +6 -0
- package/dist/Export/transform-summaries.js.map +1 -0
- package/dist/FileExplorer/FileExplorer.d.ts.map +1 -1
- package/dist/FileExplorer/FileExplorer.js +17 -2
- package/dist/FileExplorer/FileExplorer.js.map +1 -1
- package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +2 -1
- package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +1 -1
- package/dist/WorkspacePicker/WorkspaceSettingsButton.js +2 -2
- package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +1 -1
- package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts +20 -0
- package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts.map +1 -0
- package/dist/WorkspacePicker/WorkspaceSettingsDialog.js +36 -0
- package/dist/WorkspacePicker/WorkspaceSettingsDialog.js.map +1 -0
- package/dist/hooks/useAutoSave.d.ts +8 -2
- package/dist/hooks/useAutoSave.d.ts.map +1 -1
- package/dist/hooks/useAutoSave.js +65 -30
- package/dist/hooks/useAutoSave.js.map +1 -1
- package/dist/monaco-slim.d.ts +19 -0
- package/dist/monaco-slim.d.ts.map +1 -0
- package/dist/monaco-slim.js +19 -0
- package/dist/monaco-slim.js.map +1 -0
- package/dist/preferences/versioning.d.ts +27 -0
- package/dist/preferences/versioning.d.ts.map +1 -0
- package/dist/preferences/versioning.js +62 -0
- package/dist/preferences/versioning.js.map +1 -0
- package/package.json +14 -10
- package/src/AppMenu/AppMenu.tsx +64 -1
- package/src/DocBlocksShell/DocBlocksShell.tsx +839 -116
- package/src/Export/ExportDialog.tsx +236 -26
- package/src/Export/ExportToolbarControls.tsx +151 -21
- package/src/Export/export-options.ts +43 -1
- package/src/Export/run-export.ts +208 -97
- package/src/Export/transform-summaries.ts +14 -0
- package/src/FileExplorer/FileExplorer.tsx +31 -9
- package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +9 -0
- package/src/WorkspacePicker/WorkspaceSettingsDialog.tsx +121 -0
- package/src/hooks/useAutoSave.ts +87 -29
- package/src/monaco-slim.ts +20 -0
- package/src/preferences/versioning.ts +69 -0
- package/src/styles/docblocks.css +995 -53
|
@@ -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(() => {
|
|
@@ -70,6 +73,26 @@ function loadLastState() {
|
|
|
70
73
|
return null;
|
|
71
74
|
}
|
|
72
75
|
}
|
|
76
|
+
/** One-time first-run callout shown over the welcome doc's Play view.
|
|
77
|
+
* Once the user starts writing, switches views themselves, or dismisses
|
|
78
|
+
* it, it never comes back — on any workspace. */
|
|
79
|
+
const WELCOME_GATEWAY_KEY = 'docblocks:welcomeGatewayDismissed';
|
|
80
|
+
function isWelcomeGatewayDismissed() {
|
|
81
|
+
try {
|
|
82
|
+
return localStorage.getItem(WELCOME_GATEWAY_KEY) === '1';
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function markWelcomeGatewayDismissed() {
|
|
89
|
+
try {
|
|
90
|
+
localStorage.setItem(WELCOME_GATEWAY_KEY, '1');
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// ignore quota errors
|
|
94
|
+
}
|
|
95
|
+
}
|
|
73
96
|
const THEME_PREF_KEY = 'docblocks:themePreference';
|
|
74
97
|
function loadThemePreference() {
|
|
75
98
|
try {
|
|
@@ -90,6 +113,71 @@ function saveThemePreference(pref) {
|
|
|
90
113
|
// ignore quota errors
|
|
91
114
|
}
|
|
92
115
|
}
|
|
116
|
+
const SIDEBAR_WIDTH_KEY = 'docblocks:sidebarWidth';
|
|
117
|
+
const SIDEBAR_WIDTH_DEFAULT = 260;
|
|
118
|
+
const SIDEBAR_WIDTH_MIN = 180;
|
|
119
|
+
const SIDEBAR_WIDTH_MAX = 600;
|
|
120
|
+
/** Drag below this many pixels and the sidebar collapses entirely —
|
|
121
|
+
* the editor takes the full width and a back-arrow appears in the
|
|
122
|
+
* toolbar so the user can pop the sidebar back open. Same UX as
|
|
123
|
+
* the existing mobile narrow-viewport flow. */
|
|
124
|
+
const SIDEBAR_COLLAPSE_THRESHOLD = 120;
|
|
125
|
+
function loadSidebarWidth() {
|
|
126
|
+
try {
|
|
127
|
+
const raw = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
|
128
|
+
if (raw === null)
|
|
129
|
+
return SIDEBAR_WIDTH_DEFAULT;
|
|
130
|
+
const n = Number(raw);
|
|
131
|
+
if (!Number.isFinite(n))
|
|
132
|
+
return SIDEBAR_WIDTH_DEFAULT;
|
|
133
|
+
return Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, n));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return SIDEBAR_WIDTH_DEFAULT;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function saveSidebarWidth(px) {
|
|
140
|
+
try {
|
|
141
|
+
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(px)));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// ignore quota errors
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const VIEW_PREFS_KEY = 'docblocks:viewPreferences';
|
|
148
|
+
const DEFAULT_VIEW_PREFS = {
|
|
149
|
+
outline: false,
|
|
150
|
+
inlinePreview: true,
|
|
151
|
+
showStatusBar: true,
|
|
152
|
+
};
|
|
153
|
+
function loadViewPreferences() {
|
|
154
|
+
try {
|
|
155
|
+
const raw = localStorage.getItem(VIEW_PREFS_KEY);
|
|
156
|
+
if (!raw)
|
|
157
|
+
return DEFAULT_VIEW_PREFS;
|
|
158
|
+
const parsed = JSON.parse(raw);
|
|
159
|
+
return {
|
|
160
|
+
outline: typeof parsed.outline === 'boolean' ? parsed.outline : DEFAULT_VIEW_PREFS.outline,
|
|
161
|
+
inlinePreview: typeof parsed.inlinePreview === 'boolean'
|
|
162
|
+
? parsed.inlinePreview
|
|
163
|
+
: DEFAULT_VIEW_PREFS.inlinePreview,
|
|
164
|
+
showStatusBar: typeof parsed.showStatusBar === 'boolean'
|
|
165
|
+
? parsed.showStatusBar
|
|
166
|
+
: DEFAULT_VIEW_PREFS.showStatusBar,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return DEFAULT_VIEW_PREFS;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function saveViewPreferences(prefs) {
|
|
174
|
+
try {
|
|
175
|
+
localStorage.setItem(VIEW_PREFS_KEY, JSON.stringify(prefs));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// ignore quota errors
|
|
179
|
+
}
|
|
180
|
+
}
|
|
93
181
|
function dirnameOf(p) {
|
|
94
182
|
const clean = p.replace(/^\/+/, '');
|
|
95
183
|
const idx = clean.lastIndexOf('/');
|
|
@@ -100,6 +188,89 @@ function basenameOf(p) {
|
|
|
100
188
|
const idx = clean.lastIndexOf('/');
|
|
101
189
|
return idx === -1 ? clean : clean.slice(idx + 1);
|
|
102
190
|
}
|
|
191
|
+
/** Strip the extension from a filename (`notes.md` -> `notes`). Matches
|
|
192
|
+
* squisq's `getDocBasename` convention so version snapshot filenames
|
|
193
|
+
* stay readable (`<basename>.<timestamp>.md`). */
|
|
194
|
+
function stripExtension(name) {
|
|
195
|
+
return name.replace(/\.[^.]+$/, '');
|
|
196
|
+
}
|
|
197
|
+
function normaliseProviderPath(p) {
|
|
198
|
+
return '/' + p.replace(/^\/+/, '');
|
|
199
|
+
}
|
|
200
|
+
function sameProviderPath(a, b) {
|
|
201
|
+
return normaliseProviderPath(a) === normaliseProviderPath(b);
|
|
202
|
+
}
|
|
203
|
+
/** Portable relative link from one workspace file to another. Walks up
|
|
204
|
+
* from the source's directory and back down to the target so the link
|
|
205
|
+
* survives folder reshuffles (`../sibling.md`, `subfolder/child.md`,
|
|
206
|
+
* `resume.md` for siblings at the workspace root). */
|
|
207
|
+
function relativeMarkdownLink(fromFile, toFile) {
|
|
208
|
+
const fromParts = fromFile.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
209
|
+
const toParts = toFile.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
210
|
+
const fromDir = fromParts.slice(0, -1);
|
|
211
|
+
const toDir = toParts.slice(0, -1);
|
|
212
|
+
const toBase = toParts[toParts.length - 1] ?? '';
|
|
213
|
+
let common = 0;
|
|
214
|
+
while (common < fromDir.length && common < toDir.length && fromDir[common] === toDir[common]) {
|
|
215
|
+
common++;
|
|
216
|
+
}
|
|
217
|
+
const ups = fromDir.length - common;
|
|
218
|
+
const downs = [...toDir.slice(common), toBase];
|
|
219
|
+
const parts = [...Array(ups).fill('..'), ...downs];
|
|
220
|
+
return parts.length === 0 ? toBase : parts.join('/');
|
|
221
|
+
}
|
|
222
|
+
/** Recursively collect all `.md` files reachable from `root`. Skips
|
|
223
|
+
* Word-style `*_files/` asset companions, dotfiles, and `node_modules`
|
|
224
|
+
* so the candidate list stays workspace-meaningful. */
|
|
225
|
+
async function collectMarkdownFiles(fs, root) {
|
|
226
|
+
const out = [];
|
|
227
|
+
const visited = new Set();
|
|
228
|
+
async function walk(dir) {
|
|
229
|
+
if (visited.has(dir))
|
|
230
|
+
return;
|
|
231
|
+
visited.add(dir);
|
|
232
|
+
let entries;
|
|
233
|
+
try {
|
|
234
|
+
entries = await fs.readDirectory(dir);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
if (entry.kind === 'directory') {
|
|
241
|
+
const lower = entry.name.toLowerCase();
|
|
242
|
+
if (lower.startsWith('.'))
|
|
243
|
+
continue;
|
|
244
|
+
if (lower === 'node_modules')
|
|
245
|
+
continue;
|
|
246
|
+
if (lower.endsWith('_files'))
|
|
247
|
+
continue;
|
|
248
|
+
await walk(entry.path);
|
|
249
|
+
}
|
|
250
|
+
else if (entry.kind === 'file') {
|
|
251
|
+
if (entry.name.toLowerCase().endsWith('.md'))
|
|
252
|
+
out.push(entry);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
await walk(root);
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
async function createElectronProviderFromWorkspace(ws) {
|
|
260
|
+
if (!ws.rootPath)
|
|
261
|
+
return null;
|
|
262
|
+
try {
|
|
263
|
+
await getDocBlocksHost().workspaces.register({
|
|
264
|
+
id: ws.id,
|
|
265
|
+
name: ws.name,
|
|
266
|
+
rootPath: ws.rootPath,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
return new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
|
|
273
|
+
}
|
|
103
274
|
function useIsMobile(breakpoint = 768) {
|
|
104
275
|
const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.matchMedia(`(max-width: ${breakpoint}px)`).matches);
|
|
105
276
|
useEffect(() => {
|
|
@@ -116,7 +287,7 @@ function FolderGlyph() {
|
|
|
116
287
|
function FileGlyph() {
|
|
117
288
|
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
289
|
}
|
|
119
|
-
export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
290
|
+
export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersioning = true, versionBasename, versioningPrunePolicy, versioningAutoSaveIdleMs, onSaveVersion, versioningRef, }) {
|
|
120
291
|
const osTheme = useOsTheme();
|
|
121
292
|
const [themePreference, setThemePreference] = useState(loadThemePreference);
|
|
122
293
|
// "System default" (auto) always follows the OS — the host's theme prop
|
|
@@ -126,10 +297,132 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
126
297
|
setThemePreference(pref);
|
|
127
298
|
saveThemePreference(pref);
|
|
128
299
|
}, []);
|
|
300
|
+
const [viewPreferences, setViewPreferences] = useState(loadViewPreferences);
|
|
301
|
+
const handleViewPreferencesChange = useCallback((prefs) => {
|
|
302
|
+
setViewPreferences(prefs);
|
|
303
|
+
saveViewPreferences(prefs);
|
|
304
|
+
}, []);
|
|
129
305
|
const isMobile = useIsMobile();
|
|
130
306
|
const [mobileShowEditor, setMobileShowEditor] = useState(false);
|
|
307
|
+
// Sidebar width — persisted across sessions, dragged via the resizer
|
|
308
|
+
// between sidebar and editor area. We track the "live" width during a
|
|
309
|
+
// drag in a ref so each mousemove doesn't trigger a state update; only
|
|
310
|
+
// setState on commit so React doesn't churn through every pixel.
|
|
311
|
+
const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth);
|
|
312
|
+
// When the user drags the resizer below SIDEBAR_COLLAPSE_THRESHOLD,
|
|
313
|
+
// we switch the layout into single-pane "compact" mode — same UX as
|
|
314
|
+
// the mobile narrow-viewport flow, where only the sidebar OR the
|
|
315
|
+
// editor is visible at a time and a back-arrow in the toolbar pops
|
|
316
|
+
// between them. A "Restore split view" button on the editor toolbar
|
|
317
|
+
// exits compact mode; on real mobile that button is suppressed
|
|
318
|
+
// because there's not enough viewport for side-by-side. Not
|
|
319
|
+
// persisted across reloads. */
|
|
320
|
+
const [compactLayout, setCompactLayout] = useState(false);
|
|
321
|
+
const effectiveCompact = isMobile || compactLayout;
|
|
322
|
+
const sidebarRef = useRef(null);
|
|
323
|
+
const dragStateRef = useRef(null);
|
|
324
|
+
const handleResizerPointerDown = useCallback((e) => {
|
|
325
|
+
if (e.button !== 0)
|
|
326
|
+
return;
|
|
327
|
+
dragStateRef.current = { startX: e.clientX, startWidth: sidebarWidth };
|
|
328
|
+
e.preventDefault();
|
|
329
|
+
// Disable text selection + flip the body cursor for the duration
|
|
330
|
+
// of the drag so the col-resize cursor stays visible even when the
|
|
331
|
+
// pointer slips off the 7px hit area. The custom cursor lives in
|
|
332
|
+
// the CSS class so Windows' white-cursor preference doesn't make
|
|
333
|
+
// the dragging cursor invisible against the light chrome.
|
|
334
|
+
document.body.style.userSelect = 'none';
|
|
335
|
+
document.body.classList.add('db-resizing-sidebar');
|
|
336
|
+
let lastRaw = sidebarWidth;
|
|
337
|
+
const onMove = (ev) => {
|
|
338
|
+
const drag = dragStateRef.current;
|
|
339
|
+
if (!drag)
|
|
340
|
+
return;
|
|
341
|
+
lastRaw = drag.startWidth + (ev.clientX - drag.startX);
|
|
342
|
+
if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD && sidebarRef.current) {
|
|
343
|
+
// Below threshold — preview the collapse by snapping to the
|
|
344
|
+
// minimum width and fading the sidebar, so the user can see
|
|
345
|
+
// they've crossed into "release to collapse" territory.
|
|
346
|
+
sidebarRef.current.style.width = `${SIDEBAR_WIDTH_MIN}px`;
|
|
347
|
+
sidebarRef.current.style.opacity = '0.45';
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const clamped = Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, lastRaw));
|
|
351
|
+
if (sidebarRef.current) {
|
|
352
|
+
// Update the DOM directly during the drag for jank-free
|
|
353
|
+
// dragging; React state syncs on release.
|
|
354
|
+
sidebarRef.current.style.width = `${clamped}px`;
|
|
355
|
+
sidebarRef.current.style.opacity = '';
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
const onUp = () => {
|
|
359
|
+
document.removeEventListener('pointermove', onMove);
|
|
360
|
+
document.removeEventListener('pointerup', onUp);
|
|
361
|
+
document.body.style.userSelect = '';
|
|
362
|
+
document.body.classList.remove('db-resizing-sidebar');
|
|
363
|
+
if (sidebarRef.current) {
|
|
364
|
+
sidebarRef.current.style.opacity = '';
|
|
365
|
+
}
|
|
366
|
+
if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD) {
|
|
367
|
+
// Released below threshold — switch to compact (single-pane)
|
|
368
|
+
// layout focused on the editor. Keep the persisted
|
|
369
|
+
// sidebarWidth so exiting compact mode restores it.
|
|
370
|
+
setCompactLayout(true);
|
|
371
|
+
setMobileShowEditor(true);
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
const finalWidth = sidebarRef.current?.getBoundingClientRect().width;
|
|
375
|
+
if (finalWidth) {
|
|
376
|
+
const clamped = Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, Math.round(finalWidth)));
|
|
377
|
+
setSidebarWidth(clamped);
|
|
378
|
+
saveSidebarWidth(clamped);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
dragStateRef.current = null;
|
|
382
|
+
};
|
|
383
|
+
document.addEventListener('pointermove', onMove);
|
|
384
|
+
document.addEventListener('pointerup', onUp);
|
|
385
|
+
}, [sidebarWidth]);
|
|
131
386
|
const [provider, setProvider] = useState(null);
|
|
132
387
|
const [activeWorkspaceId, setActiveWorkspaceId] = useState(null);
|
|
388
|
+
const [activeWorkspaceDescriptor, setActiveWorkspaceDescriptor] = useState(null);
|
|
389
|
+
// Re-fetch the descriptor whenever the active id (or its versioning
|
|
390
|
+
// override) changes. `descriptorRefreshKey` is bumped after writes so
|
|
391
|
+
// the resolver picks up the updated override without remounting.
|
|
392
|
+
const [descriptorRefreshKey, setDescriptorRefreshKey] = useState(0);
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
let cancelled = false;
|
|
395
|
+
if (!activeWorkspaceId) {
|
|
396
|
+
setActiveWorkspaceDescriptor(null);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
void getWorkspace(activeWorkspaceId).then((ws) => {
|
|
400
|
+
if (!cancelled)
|
|
401
|
+
setActiveWorkspaceDescriptor(ws);
|
|
402
|
+
});
|
|
403
|
+
return () => {
|
|
404
|
+
cancelled = true;
|
|
405
|
+
};
|
|
406
|
+
}, [activeWorkspaceId, descriptorRefreshKey]);
|
|
407
|
+
const [workspaceSettingsOpen, setWorkspaceSettingsOpen] = useState(false);
|
|
408
|
+
const [versioningPreference, setVersioningPreference] = useState(loadVersioningPreference);
|
|
409
|
+
const handleVersioningPreferenceChange = useCallback((pref) => {
|
|
410
|
+
setVersioningPreference(pref);
|
|
411
|
+
saveVersioningPreference(pref);
|
|
412
|
+
}, []);
|
|
413
|
+
const effectiveVersioning = allowVersioning && resolveVersioningEnabled(activeWorkspaceDescriptor, versioningPreference);
|
|
414
|
+
const handleOpenWorkspaceSettings = useCallback(() => {
|
|
415
|
+
if (!activeWorkspaceDescriptor)
|
|
416
|
+
return;
|
|
417
|
+
setWorkspaceSettingsOpen(true);
|
|
418
|
+
}, [activeWorkspaceDescriptor]);
|
|
419
|
+
const handleWorkspaceVersioningOverrideChange = useCallback(async (override) => {
|
|
420
|
+
if (!activeWorkspaceDescriptor)
|
|
421
|
+
return;
|
|
422
|
+
await saveWorkspace({ ...activeWorkspaceDescriptor, versioningOverride: override });
|
|
423
|
+
setDescriptorRefreshKey((k) => k + 1);
|
|
424
|
+
setWorkspaceSettingsOpen(false);
|
|
425
|
+
}, [activeWorkspaceDescriptor]);
|
|
133
426
|
const [selectedFile, setSelectedFile] = useState(null);
|
|
134
427
|
const [selectedFolder, setSelectedFolder] = useState(null);
|
|
135
428
|
const [folderEntries, setFolderEntries] = useState([]);
|
|
@@ -137,8 +430,11 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
137
430
|
const [editorKey, setEditorKey] = useState(0);
|
|
138
431
|
const [explorerKey, setExplorerKey] = useState(0);
|
|
139
432
|
const [initialView, setInitialView] = useState('wysiwyg');
|
|
433
|
+
// First-run gateway over the welcome doc's Play view — see WELCOME_GATEWAY_KEY.
|
|
434
|
+
const [showWelcomeGateway, setShowWelcomeGateway] = useState(false);
|
|
140
435
|
/** Suppress popstate handling during programmatic navigation. */
|
|
141
436
|
const skipPopState = useRef(false);
|
|
437
|
+
const lastLocalSaveRef = useRef(null);
|
|
142
438
|
/**
|
|
143
439
|
* Per-file media container: for `notes.md`, images live in
|
|
144
440
|
* `notes_files/` next to the markdown. Created lazily on first write,
|
|
@@ -147,6 +443,22 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
147
443
|
*/
|
|
148
444
|
const mediaContainerRef = useRef(null);
|
|
149
445
|
const [mediaProvider, setMediaProvider] = useState(null);
|
|
446
|
+
/**
|
|
447
|
+
* Per-document container scoped to `<basename>_files/`. This is what
|
|
448
|
+
* the editor uses for version history (`.versions/` lives here) and
|
|
449
|
+
* for audio mapping (MP3 / timing.json discovery). Distinct from
|
|
450
|
+
* `mediaContainerRef` which is scoped to the parent directory so the
|
|
451
|
+
* media provider can write `notes_files/image.png` paths that stay
|
|
452
|
+
* portable in the markdown.
|
|
453
|
+
*/
|
|
454
|
+
const versionsContainerRef = useRef(null);
|
|
455
|
+
const [versionsContainer, setVersionsContainer] = useState(null);
|
|
456
|
+
/** Cache of .md files in the active workspace, used to power the
|
|
457
|
+
* squisq link-dialog's document picker. Lazily populated on first
|
|
458
|
+
* provider call; cleared whenever the backing filesystem changes so
|
|
459
|
+
* workspace switches don't surface stale neighbours. A pending Promise
|
|
460
|
+
* during in-flight walks lets concurrent calls share the same scan. */
|
|
461
|
+
const mdFileCacheRef = useRef(null);
|
|
150
462
|
/** Push a new history entry with the given hash. */
|
|
151
463
|
const pushHash = useCallback((wsId, filePath) => {
|
|
152
464
|
const hash = buildHash(wsId, filePath);
|
|
@@ -164,14 +476,9 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
164
476
|
return null;
|
|
165
477
|
let fsProvider = null;
|
|
166
478
|
if (ws.type === 'electron-native') {
|
|
167
|
-
|
|
479
|
+
fsProvider = await createElectronProviderFromWorkspace(ws);
|
|
480
|
+
if (!fsProvider)
|
|
168
481
|
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
482
|
}
|
|
176
483
|
else if (ws.type === 'native') {
|
|
177
484
|
const restored = await restoreNativeFolder(ws.id);
|
|
@@ -219,10 +526,12 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
219
526
|
}, [pushHash]);
|
|
220
527
|
const seedWelcomeFile = useCallback(async (fs) => {
|
|
221
528
|
const entries = await fs.readDirectory('/');
|
|
222
|
-
// If the only file is the welcome doc, auto-select it
|
|
529
|
+
// If the only file is the welcome doc, auto-select it.
|
|
530
|
+
// Match either casing so workspaces seeded before the rename
|
|
531
|
+
// (aboutDocblocks.md) keep working alongside new ones (aboutDocBlocks.md).
|
|
223
532
|
if (entries.length === 1 &&
|
|
224
533
|
entries[0].kind === 'file' &&
|
|
225
|
-
entries[0].path.replace(/^\//, '') === '
|
|
534
|
+
entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md') {
|
|
226
535
|
const aboutPath = entries[0].path;
|
|
227
536
|
const content = await fs.readFile(aboutPath);
|
|
228
537
|
if (content !== null) {
|
|
@@ -233,25 +542,27 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
233
542
|
setExplorerKey((k) => k + 1);
|
|
234
543
|
pushHash(fs.id, aboutPath);
|
|
235
544
|
saveLastState({ workspaceId: fs.id, filePath: aboutPath, view: 'preview' });
|
|
545
|
+
if (!isWelcomeGatewayDismissed())
|
|
546
|
+
setShowWelcomeGateway(true);
|
|
236
547
|
}
|
|
237
548
|
return;
|
|
238
549
|
}
|
|
239
550
|
if (entries.length > 0)
|
|
240
551
|
return;
|
|
241
|
-
const welcomePath = '/
|
|
552
|
+
const welcomePath = '/aboutDocBlocks.md';
|
|
242
553
|
const welcomeContent = [
|
|
243
554
|
'# Welcome to DocBlocks',
|
|
244
555
|
'',
|
|
245
|
-
'DocBlocks is a browser-based markdown document editor that lets you create, organize, and manage your documents right in the browser.',
|
|
556
|
+
'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.',
|
|
557
|
+
'',
|
|
558
|
+
'Simple to write. Beautiful wherever it goes.',
|
|
246
559
|
'',
|
|
247
560
|
'## Features',
|
|
248
561
|
'',
|
|
249
|
-
'- **Rich Markdown Editing** — Write in a visual editor or switch to raw markdown anytime',
|
|
250
|
-
'- **Workspaces** — Organize your documents into separate workspaces',
|
|
562
|
+
'- **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.',
|
|
563
|
+
'- **Workspaces** — Organize your documents into separate workspaces in the browser or on your device.',
|
|
564
|
+
'- **Useful Everywhere** — Your content is usable across multiple formats — Microsoft Word .docx, PowerPoint, PDF, HTML, EPUB e-books, and Markdown.',
|
|
251
565
|
'- **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
566
|
'- **No BS** — Free, no ads, no accounts, no tracking - everything runs locally in your browser',
|
|
256
567
|
'',
|
|
257
568
|
'## Getting Started',
|
|
@@ -260,7 +571,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
260
571
|
'2. Start writing in markdown — the editor supports headings, lists, links, images, and more',
|
|
261
572
|
'3. Your work is saved automatically',
|
|
262
573
|
'',
|
|
263
|
-
'Built with [Squiggly Square](https://github.com/
|
|
574
|
+
'Built with [Squiggly Square](https://github.com/bendyline/squisq) by [Bendyline](https://bendyline.com).',
|
|
264
575
|
].join('\n');
|
|
265
576
|
await fs.writeFile(welcomePath, welcomeContent);
|
|
266
577
|
setSelectedFile(welcomePath);
|
|
@@ -270,7 +581,27 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
270
581
|
setExplorerKey((k) => k + 1);
|
|
271
582
|
pushHash(fs.id, welcomePath);
|
|
272
583
|
saveLastState({ workspaceId: fs.id, filePath: welcomePath, view: 'preview' });
|
|
584
|
+
if (!isWelcomeGatewayDismissed())
|
|
585
|
+
setShowWelcomeGateway(true);
|
|
273
586
|
}, [pushHash]);
|
|
587
|
+
/** Hide the welcome gateway and never show it again. Safe to call from
|
|
588
|
+
* paths where it may not be showing — only persists when it was. */
|
|
589
|
+
const closeWelcomeGateway = useCallback(() => {
|
|
590
|
+
setShowWelcomeGateway((showing) => {
|
|
591
|
+
if (showing)
|
|
592
|
+
markWelcomeGatewayDismissed();
|
|
593
|
+
return false;
|
|
594
|
+
});
|
|
595
|
+
}, []);
|
|
596
|
+
/** Gateway CTA — flip the welcome doc from Play into the editor. */
|
|
597
|
+
const handleStartWriting = useCallback(() => {
|
|
598
|
+
closeWelcomeGateway();
|
|
599
|
+
setInitialView('wysiwyg');
|
|
600
|
+
setEditorKey((k) => k + 1);
|
|
601
|
+
if (activeWorkspaceId && selectedFile) {
|
|
602
|
+
saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view: 'wysiwyg' });
|
|
603
|
+
}
|
|
604
|
+
}, [closeWelcomeGateway, activeWorkspaceId, selectedFile]);
|
|
274
605
|
// Initialise workspace on mount — restore from hash or last-used
|
|
275
606
|
useEffect(() => {
|
|
276
607
|
(async () => {
|
|
@@ -305,14 +636,9 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
305
636
|
const sorted = [...candidates].sort((a, b) => (b.lastOpened ?? '').localeCompare(a.lastOpened ?? ''));
|
|
306
637
|
for (const ws of sorted) {
|
|
307
638
|
if (ws.type === 'electron-native') {
|
|
308
|
-
|
|
639
|
+
const p = await createElectronProviderFromWorkspace(ws);
|
|
640
|
+
if (!p)
|
|
309
641
|
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
642
|
await touchWorkspace(ws.id);
|
|
317
643
|
fsProvider = p;
|
|
318
644
|
setProvider(p);
|
|
@@ -342,7 +668,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
342
668
|
if (electron) {
|
|
343
669
|
// Desktop: ask the host for the default folder workspace
|
|
344
670
|
// (creates ~/Documents/DocBlocks on first launch).
|
|
345
|
-
const info = await
|
|
671
|
+
const info = await getDocBlocksHost().workspaces.getDefault();
|
|
346
672
|
const descriptor = {
|
|
347
673
|
id: info.id,
|
|
348
674
|
name: info.name,
|
|
@@ -392,6 +718,10 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
392
718
|
const target = e.target.closest?.('[data-view]');
|
|
393
719
|
if (target) {
|
|
394
720
|
const view = target.getAttribute('data-view');
|
|
721
|
+
if (view) {
|
|
722
|
+
// The user found the view tabs on their own — the gateway's job is done.
|
|
723
|
+
closeWelcomeGateway();
|
|
724
|
+
}
|
|
395
725
|
if (view && activeWorkspaceId && selectedFile) {
|
|
396
726
|
saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view });
|
|
397
727
|
}
|
|
@@ -399,45 +729,155 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
399
729
|
};
|
|
400
730
|
window.addEventListener('click', handler, true);
|
|
401
731
|
return () => window.removeEventListener('click', handler, true);
|
|
402
|
-
}, [activeWorkspaceId, selectedFile]);
|
|
403
|
-
|
|
404
|
-
|
|
732
|
+
}, [activeWorkspaceId, selectedFile, closeWelcomeGateway]);
|
|
733
|
+
const handleAutoSaved = useCallback((filePath, savedContent) => {
|
|
734
|
+
lastLocalSaveRef.current = {
|
|
735
|
+
filePath: normaliseProviderPath(filePath),
|
|
736
|
+
content: savedContent,
|
|
737
|
+
savedAt: Date.now(),
|
|
738
|
+
};
|
|
739
|
+
}, []);
|
|
740
|
+
// Auto-save current file. The returned `flush` is called from the
|
|
741
|
+
// Ctrl/Cmd+S handler below so the user gets immediate confirmation.
|
|
742
|
+
const { flush: flushAutoSave } = useAutoSave(provider, selectedFile, editorContent, 500, handleAutoSaved);
|
|
743
|
+
// Comfort-blanket Ctrl/Cmd+S: flushes any pending autosave and pops a
|
|
744
|
+
// small "auto-save confirmed" toast. Files are already saved on every
|
|
745
|
+
// keystroke (debounced) — this is purely UX reassurance for users who
|
|
746
|
+
// muscle-memory hit Save.
|
|
747
|
+
const [saveToastVisible, setSaveToastVisible] = useState(false);
|
|
748
|
+
const saveToastTimerRef = useRef(null);
|
|
749
|
+
useEffect(() => {
|
|
750
|
+
const onKey = (e) => {
|
|
751
|
+
const sKey = e.key === 's' || e.key === 'S';
|
|
752
|
+
const accel = e.ctrlKey || e.metaKey;
|
|
753
|
+
if (!sKey || !accel || e.altKey)
|
|
754
|
+
return;
|
|
755
|
+
e.preventDefault();
|
|
756
|
+
e.stopPropagation();
|
|
757
|
+
void flushAutoSave().catch(() => undefined);
|
|
758
|
+
setSaveToastVisible(true);
|
|
759
|
+
if (saveToastTimerRef.current)
|
|
760
|
+
clearTimeout(saveToastTimerRef.current);
|
|
761
|
+
saveToastTimerRef.current = setTimeout(() => setSaveToastVisible(false), 1800);
|
|
762
|
+
};
|
|
763
|
+
window.addEventListener('keydown', onKey, true);
|
|
764
|
+
return () => window.removeEventListener('keydown', onKey, true);
|
|
765
|
+
}, [flushAutoSave]);
|
|
766
|
+
useEffect(() => {
|
|
767
|
+
return () => {
|
|
768
|
+
if (saveToastTimerRef.current)
|
|
769
|
+
clearTimeout(saveToastTimerRef.current);
|
|
770
|
+
};
|
|
771
|
+
}, []);
|
|
405
772
|
// Per-file media: for `notes.md` images live in `notes_files/` beside it.
|
|
406
773
|
// Rebuilds whenever the provider or selected file changes.
|
|
407
774
|
useEffect(() => {
|
|
408
775
|
if (!provider || !selectedFile) {
|
|
409
776
|
mediaContainerRef.current = null;
|
|
777
|
+
versionsContainerRef.current = null;
|
|
410
778
|
setMediaProvider(null);
|
|
779
|
+
setVersionsContainer(null);
|
|
411
780
|
return;
|
|
412
781
|
}
|
|
413
782
|
const parentDir = dirnameOf(selectedFile);
|
|
414
783
|
const base = basenameOf(selectedFile);
|
|
784
|
+
const baseNoExt = base.replace(/\.[^.]+$/, '');
|
|
415
785
|
const container = new FileSystemContentContainer(provider, parentDir);
|
|
786
|
+
const vPrefix = parentDir ? `${parentDir}/${baseNoExt}_files` : `${baseNoExt}_files`;
|
|
787
|
+
const vContainer = new FileSystemContentContainer(provider, vPrefix);
|
|
416
788
|
const mp = createFileMediaProvider(container, base);
|
|
417
789
|
mediaContainerRef.current = container;
|
|
790
|
+
versionsContainerRef.current = vContainer;
|
|
418
791
|
setMediaProvider(mp);
|
|
792
|
+
setVersionsContainer(vContainer);
|
|
419
793
|
return () => {
|
|
420
794
|
mp.dispose();
|
|
421
795
|
};
|
|
422
796
|
}, [provider, selectedFile]);
|
|
797
|
+
// Invalidate the document-link candidate cache when the backing
|
|
798
|
+
// workspace changes — otherwise the link dialog would surface
|
|
799
|
+
// neighbours from a previously-open workspace.
|
|
800
|
+
useEffect(() => {
|
|
801
|
+
mdFileCacheRef.current = null;
|
|
802
|
+
}, [provider]);
|
|
803
|
+
/** Powers the squisq link dialog's "Browse documents" picker. Returns
|
|
804
|
+
* workspace `.md` neighbours filtered by `query`, with paths expressed
|
|
805
|
+
* relative to the currently-open document so the link survives folder
|
|
806
|
+
* moves. The first call seeds an in-memory cache; subsequent calls
|
|
807
|
+
* filter against it. */
|
|
808
|
+
const documentLinkProvider = useCallback(async (query) => {
|
|
809
|
+
if (!provider || !selectedFile)
|
|
810
|
+
return [];
|
|
811
|
+
if (!mdFileCacheRef.current) {
|
|
812
|
+
mdFileCacheRef.current = collectMarkdownFiles(provider, '').catch(() => []);
|
|
813
|
+
}
|
|
814
|
+
const entries = await mdFileCacheRef.current;
|
|
815
|
+
const q = query.trim().toLowerCase();
|
|
816
|
+
const candidates = [];
|
|
817
|
+
for (const entry of entries) {
|
|
818
|
+
if (entry.kind !== 'file')
|
|
819
|
+
continue;
|
|
820
|
+
if (sameProviderPath(entry.path, selectedFile))
|
|
821
|
+
continue;
|
|
822
|
+
const label = entry.name.replace(/\.md$/i, '');
|
|
823
|
+
const path = relativeMarkdownLink(selectedFile, entry.path);
|
|
824
|
+
if (q && !label.toLowerCase().includes(q) && !path.toLowerCase().includes(q))
|
|
825
|
+
continue;
|
|
826
|
+
const dir = dirnameOf(entry.path);
|
|
827
|
+
candidates.push(dir ? { path, label, description: dir } : { path, label });
|
|
828
|
+
}
|
|
829
|
+
// Stable alphabetical order keeps the picker predictable across
|
|
830
|
+
// re-opens; the dialog can re-sort or rank on its own if needed.
|
|
831
|
+
candidates.sort((a, b) => a.label.localeCompare(b.label));
|
|
832
|
+
return candidates;
|
|
833
|
+
}, [provider, selectedFile]);
|
|
834
|
+
// Expose a DocumentVersionManager via versioningRef when requested.
|
|
835
|
+
useEffect(() => {
|
|
836
|
+
const ref = versioningRef;
|
|
837
|
+
if (!ref)
|
|
838
|
+
return;
|
|
839
|
+
const assign = (mgr) => {
|
|
840
|
+
if (typeof ref === 'function')
|
|
841
|
+
ref(mgr);
|
|
842
|
+
else
|
|
843
|
+
ref.current = mgr;
|
|
844
|
+
};
|
|
845
|
+
if (!effectiveVersioning || !versionsContainer || !selectedFile) {
|
|
846
|
+
assign(null);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const base = stripExtension(basenameOf(selectedFile));
|
|
850
|
+
const mgr = new DocumentVersionManager(versionsContainer, {
|
|
851
|
+
basename: versionBasename ?? base,
|
|
852
|
+
});
|
|
853
|
+
assign(mgr);
|
|
854
|
+
return () => assign(null);
|
|
855
|
+
}, [versioningRef, effectiveVersioning, versionBasename, selectedFile, versionsContainer]);
|
|
423
856
|
// React to external file changes watched by the Electron host (chokidar).
|
|
424
857
|
useEffect(() => {
|
|
425
858
|
if (!isElectronHost())
|
|
426
859
|
return;
|
|
427
860
|
if (!provider || !(provider instanceof ElectronFileSystemProvider))
|
|
428
861
|
return;
|
|
429
|
-
const unwatch = provider.watch(() => {
|
|
862
|
+
const unwatch = provider.watch((changedPath) => {
|
|
430
863
|
setExplorerKey((k) => k + 1);
|
|
864
|
+
if (!selectedFile || !sameProviderPath(changedPath, selectedFile))
|
|
865
|
+
return;
|
|
431
866
|
// If the open file's contents changed on disk, reload it (best-effort).
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
867
|
+
(async () => {
|
|
868
|
+
const content = await provider.readFile(selectedFile);
|
|
869
|
+
if (content === null || content === editorContent)
|
|
870
|
+
return;
|
|
871
|
+
const localSave = lastLocalSaveRef.current;
|
|
872
|
+
if (localSave &&
|
|
873
|
+
sameProviderPath(localSave.filePath, selectedFile) &&
|
|
874
|
+
localSave.content === content &&
|
|
875
|
+
Date.now() - localSave.savedAt < 5000) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
setEditorContent(content);
|
|
879
|
+
setEditorKey((k) => k + 1);
|
|
880
|
+
})();
|
|
441
881
|
});
|
|
442
882
|
return unwatch;
|
|
443
883
|
}, [provider, selectedFile, editorContent]);
|
|
@@ -445,14 +885,9 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
445
885
|
await touchWorkspace(ws.id);
|
|
446
886
|
let nextProvider = null;
|
|
447
887
|
if (ws.type === 'electron-native') {
|
|
448
|
-
|
|
888
|
+
nextProvider = await createElectronProviderFromWorkspace(ws);
|
|
889
|
+
if (!nextProvider)
|
|
449
890
|
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
891
|
}
|
|
457
892
|
else if (ws.type === 'native') {
|
|
458
893
|
const restored = await restoreNativeFolder(ws.id);
|
|
@@ -477,7 +912,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
477
912
|
const handleOpenFolder = useCallback(async () => {
|
|
478
913
|
try {
|
|
479
914
|
if (isElectronHost()) {
|
|
480
|
-
const info = await
|
|
915
|
+
const info = await getDocBlocksHost().workspaces.pickFolder();
|
|
481
916
|
if (!info)
|
|
482
917
|
return; // user cancelled
|
|
483
918
|
const descriptor = {
|
|
@@ -533,23 +968,24 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
533
968
|
setInitialView('wysiwyg');
|
|
534
969
|
setEditorKey((k) => k + 1);
|
|
535
970
|
setExplorerKey((k) => k + 1);
|
|
971
|
+
closeWelcomeGateway();
|
|
536
972
|
if (activeWorkspaceId) {
|
|
537
973
|
pushHash(activeWorkspaceId, '/' + filename);
|
|
538
974
|
}
|
|
539
|
-
}, [provider, activeWorkspaceId, pushHash]);
|
|
975
|
+
}, [provider, activeWorkspaceId, pushHash, closeWelcomeGateway]);
|
|
540
976
|
const handleRevealWorkspace = useCallback(async () => {
|
|
541
977
|
if (!isElectronHost() || !activeWorkspaceId)
|
|
542
978
|
return;
|
|
543
979
|
const ws = await getWorkspace(activeWorkspaceId);
|
|
544
980
|
if (ws?.type === 'electron-native' && ws.rootPath) {
|
|
545
|
-
await
|
|
981
|
+
await getDocBlocksHost().shell.revealInFolder(ws.rootPath);
|
|
546
982
|
}
|
|
547
983
|
}, [activeWorkspaceId]);
|
|
548
984
|
// Subscribe to native menu commands (Electron host).
|
|
549
985
|
useEffect(() => {
|
|
550
986
|
if (!isElectronHost())
|
|
551
987
|
return;
|
|
552
|
-
const host =
|
|
988
|
+
const host = getDocBlocksHost();
|
|
553
989
|
return host.onMenuCommand((cmd) => {
|
|
554
990
|
switch (cmd) {
|
|
555
991
|
case 'file:new':
|
|
@@ -584,33 +1020,9 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
584
1020
|
useEffect(() => {
|
|
585
1021
|
if (!isElectronHost())
|
|
586
1022
|
return;
|
|
587
|
-
const host =
|
|
1023
|
+
const host = getDocBlocksHost();
|
|
588
1024
|
return host.onOpenRequest(async (req) => {
|
|
589
|
-
|
|
590
|
-
const workspaces = (await listWorkspaces()).filter((w) => w.type === 'electron-native' && w.rootPath);
|
|
591
|
-
const match = workspaces.find((w) => req.filePath.startsWith((w.rootPath ?? '') + '/') || req.filePath === w.rootPath);
|
|
592
|
-
if (match && match.rootPath) {
|
|
593
|
-
const rel = '/' + req.filePath.slice(match.rootPath.length).replace(/^\/+/, '');
|
|
594
|
-
await openFromIds(match.id, rel, true);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
else if (req.url) {
|
|
598
|
-
try {
|
|
599
|
-
const u = new URL(req.url);
|
|
600
|
-
const path = u.searchParams.get('path');
|
|
601
|
-
if (path) {
|
|
602
|
-
const workspaces = (await listWorkspaces()).filter((w) => w.type === 'electron-native' && w.rootPath);
|
|
603
|
-
const match = workspaces.find((w) => path.startsWith((w.rootPath ?? '') + '/') || path === w.rootPath);
|
|
604
|
-
if (match && match.rootPath) {
|
|
605
|
-
const rel = '/' + path.slice(match.rootPath.length).replace(/^\/+/, '');
|
|
606
|
-
await openFromIds(match.id, rel, true);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
catch {
|
|
611
|
-
// bad URL, ignore
|
|
612
|
-
}
|
|
613
|
-
}
|
|
1025
|
+
await openFromIds(req.workspaceId, req.path, true);
|
|
614
1026
|
});
|
|
615
1027
|
}, [openFromIds]);
|
|
616
1028
|
const handleSelect = useCallback(async (path, kind) => {
|
|
@@ -633,12 +1045,13 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
633
1045
|
setEditorContent(content ?? '');
|
|
634
1046
|
setInitialView('wysiwyg');
|
|
635
1047
|
setEditorKey((k) => k + 1);
|
|
1048
|
+
closeWelcomeGateway();
|
|
636
1049
|
pushHash(activeWorkspaceId, path);
|
|
637
1050
|
saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
|
|
638
|
-
if (
|
|
1051
|
+
if (effectiveCompact)
|
|
639
1052
|
setMobileShowEditor(true);
|
|
640
1053
|
}
|
|
641
|
-
}, [provider, activeWorkspaceId, pushHash,
|
|
1054
|
+
}, [provider, activeWorkspaceId, pushHash, effectiveCompact, closeWelcomeGateway]);
|
|
642
1055
|
const handleTreeChange = useCallback(async () => {
|
|
643
1056
|
if (!provider)
|
|
644
1057
|
return;
|
|
@@ -736,30 +1149,138 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
736
1149
|
// Bump key to trigger re-render — workspace name is read from the descriptor, not the provider
|
|
737
1150
|
setEditorKey((k) => k + 1);
|
|
738
1151
|
}, [activeWorkspaceId]);
|
|
1152
|
+
/**
|
|
1153
|
+
* Walk a FileSystemProvider and copy every file into `container` under
|
|
1154
|
+
* `pathPrefix` (no leading slash; empty string for the root). Used by
|
|
1155
|
+
* both single- and all-workspace downloads.
|
|
1156
|
+
*/
|
|
1157
|
+
const copyProviderToContainer = useCallback(async (src, container, pathPrefix) => {
|
|
1158
|
+
const encoder = new TextEncoder();
|
|
1159
|
+
const stack = ['/'];
|
|
1160
|
+
while (stack.length > 0) {
|
|
1161
|
+
const dir = stack.pop();
|
|
1162
|
+
const entries = await src.readDirectory(dir);
|
|
1163
|
+
for (const entry of entries) {
|
|
1164
|
+
if (entry.kind === 'directory') {
|
|
1165
|
+
stack.push(entry.path);
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
const rel = entry.path.replace(/^\/+/, '');
|
|
1169
|
+
const zipPath = pathPrefix ? `${pathPrefix}/${rel}` : rel;
|
|
1170
|
+
// Files may be stored as text (writeFile) or binary (writeBinary);
|
|
1171
|
+
// try binary first, fall back to text and encode as UTF-8.
|
|
1172
|
+
const binary = await src.readBinary(entry.path);
|
|
1173
|
+
if (binary) {
|
|
1174
|
+
await container.writeFile(zipPath, binary);
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
const text = await src.readFile(entry.path);
|
|
1178
|
+
if (text !== null) {
|
|
1179
|
+
await container.writeFile(zipPath, encoder.encode(text));
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}, []);
|
|
739
1184
|
const handleDownloadWorkspace = useCallback(async () => {
|
|
740
1185
|
if (!provider)
|
|
741
1186
|
return;
|
|
742
1187
|
try {
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
1188
|
+
const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
|
|
1189
|
+
import('@bendyline/squisq/storage'),
|
|
1190
|
+
import('@bendyline/squisq-formats/container'),
|
|
1191
|
+
]);
|
|
1192
|
+
const container = new MemoryContentContainer();
|
|
1193
|
+
await copyProviderToContainer(provider, container, '');
|
|
1194
|
+
const blob = await containerToZip(container);
|
|
1195
|
+
const url = URL.createObjectURL(blob);
|
|
1196
|
+
const a = document.createElement('a');
|
|
1197
|
+
a.href = url;
|
|
1198
|
+
const safeName = (provider.label || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
|
|
1199
|
+
a.download = `${safeName}.zip`;
|
|
1200
|
+
a.click();
|
|
1201
|
+
URL.revokeObjectURL(url);
|
|
1202
|
+
}
|
|
1203
|
+
catch (err) {
|
|
1204
|
+
console.error('Failed to download workspace', err);
|
|
1205
|
+
alert('Failed to download workspace. See console for details.');
|
|
1206
|
+
}
|
|
1207
|
+
}, [provider, copyProviderToContainer]);
|
|
1208
|
+
/**
|
|
1209
|
+
* Bundle every workspace the host can open without further prompting
|
|
1210
|
+
* into a single zip, with each workspace nested under its own folder.
|
|
1211
|
+
* Native (browser-picked) workspaces whose handle hasn't been re-granted
|
|
1212
|
+
* for this session are skipped — restoring them would require a user
|
|
1213
|
+
* gesture per workspace.
|
|
1214
|
+
*/
|
|
1215
|
+
const handleDownloadAllWorkspaces = useCallback(async () => {
|
|
1216
|
+
try {
|
|
1217
|
+
const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
|
|
1218
|
+
import('@bendyline/squisq/storage'),
|
|
1219
|
+
import('@bendyline/squisq-formats/container'),
|
|
1220
|
+
]);
|
|
1221
|
+
const electron = isElectronHost();
|
|
1222
|
+
const all = await listWorkspaces();
|
|
1223
|
+
const candidates = all.filter((w) => electron ? w.type === 'electron-native' : w.type !== 'electron-native');
|
|
1224
|
+
if (candidates.length === 0) {
|
|
1225
|
+
alert('No workspaces to download.');
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
const container = new MemoryContentContainer();
|
|
1229
|
+
const usedFolders = new Set();
|
|
1230
|
+
const skipped = [];
|
|
1231
|
+
for (const ws of candidates) {
|
|
1232
|
+
let p = null;
|
|
1233
|
+
try {
|
|
1234
|
+
if (ws.type === 'electron-native') {
|
|
1235
|
+
p = await createElectronProviderFromWorkspace(ws);
|
|
1236
|
+
}
|
|
1237
|
+
else if (ws.type === 'native') {
|
|
1238
|
+
// Restore without prompting — only succeeds when the browser
|
|
1239
|
+
// still remembers the granted handle for this origin/session.
|
|
1240
|
+
p = await restoreNativeFolder(ws.id);
|
|
1241
|
+
}
|
|
1242
|
+
else {
|
|
1243
|
+
p = new IndexedDBFileSystemProvider(ws.id, ws.name);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
catch (err) {
|
|
1247
|
+
console.warn(`Skipping workspace ${ws.name}:`, err);
|
|
1248
|
+
}
|
|
1249
|
+
if (!p) {
|
|
1250
|
+
skipped.push(ws.name);
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
// Pick a unique, filesystem-safe folder name per workspace.
|
|
1254
|
+
const base = (ws.name || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
|
|
1255
|
+
let folder = base;
|
|
1256
|
+
let suffix = 2;
|
|
1257
|
+
while (usedFolders.has(folder)) {
|
|
1258
|
+
folder = `${base} (${suffix++})`;
|
|
749
1259
|
}
|
|
1260
|
+
usedFolders.add(folder);
|
|
1261
|
+
await copyProviderToContainer(p, container, folder);
|
|
750
1262
|
}
|
|
751
|
-
|
|
1263
|
+
if (usedFolders.size === 0) {
|
|
1264
|
+
alert('No workspaces could be opened for download.');
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
const blob = await containerToZip(container);
|
|
752
1268
|
const url = URL.createObjectURL(blob);
|
|
753
1269
|
const a = document.createElement('a');
|
|
754
1270
|
a.href = url;
|
|
755
|
-
|
|
1271
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
1272
|
+
a.download = `docblocks-workspaces-${stamp}.zip`;
|
|
756
1273
|
a.click();
|
|
757
1274
|
URL.revokeObjectURL(url);
|
|
1275
|
+
if (skipped.length > 0) {
|
|
1276
|
+
alert(`Downloaded ${usedFolders.size} workspace(s). Skipped ${skipped.length} that require re-granting access: ${skipped.join(', ')}.`);
|
|
1277
|
+
}
|
|
758
1278
|
}
|
|
759
|
-
catch {
|
|
760
|
-
|
|
1279
|
+
catch (err) {
|
|
1280
|
+
console.error('Failed to download all workspaces', err);
|
|
1281
|
+
alert('Failed to download all workspaces. See console for details.');
|
|
761
1282
|
}
|
|
762
|
-
}, [
|
|
1283
|
+
}, [copyProviderToContainer]);
|
|
763
1284
|
const handleRemoveWorkspace = useCallback(async () => {
|
|
764
1285
|
if (!activeWorkspaceId)
|
|
765
1286
|
return;
|
|
@@ -771,7 +1292,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
771
1292
|
const ws = await getWorkspace(activeWorkspaceId);
|
|
772
1293
|
if (ws?.type === 'electron-native') {
|
|
773
1294
|
try {
|
|
774
|
-
await
|
|
1295
|
+
await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
|
|
775
1296
|
}
|
|
776
1297
|
catch {
|
|
777
1298
|
// ignore — host cleanup is best-effort
|
|
@@ -789,7 +1310,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
789
1310
|
await handleWorkspaceSelect(next);
|
|
790
1311
|
}
|
|
791
1312
|
else if (electron) {
|
|
792
|
-
const info = await
|
|
1313
|
+
const info = await getDocBlocksHost().workspaces.getDefault();
|
|
793
1314
|
const descriptor = {
|
|
794
1315
|
id: info.id,
|
|
795
1316
|
name: info.name,
|
|
@@ -819,6 +1340,12 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }) {
|
|
|
819
1340
|
setEditorKey((k) => k + 1);
|
|
820
1341
|
}
|
|
821
1342
|
}, [activeWorkspaceId, handleWorkspaceSelect]);
|
|
822
|
-
return (
|
|
1343
|
+
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: {
|
|
1344
|
+
flex: 1,
|
|
1345
|
+
overflow: 'hidden',
|
|
1346
|
+
display: 'flex',
|
|
1347
|
+
flexDirection: 'column',
|
|
1348
|
+
position: 'relative',
|
|
1349
|
+
}, children: selectedFile && mediaProvider ? (_jsxs(MediaContext.Provider, { value: mediaProvider, children: [_jsx(EditorShell, { initialMarkdown: editorContent, initialView: initialView, articleId: selectedFile, fileName: selectedFile, onChange: handleEditorChange, colorScheme: 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}`), showWelcomeGateway && (_jsxs("div", { className: "db-welcome-gateway", role: "note", "aria-label": "Welcome tip", children: [_jsxs("span", { className: "db-welcome-gateway-text", children: ["You\u2019re watching this welcome doc in ", _jsx("strong", { children: "Play" }), " view \u2014 it\u2019s a regular markdown file, and so is everything you\u2019ll write."] }), _jsx("button", { className: "db-welcome-gateway-cta", onClick: handleStartWriting, children: "Start writing" }), _jsx("button", { className: "db-welcome-gateway-dismiss", onClick: closeWelcomeGateway, "aria-label": "Dismiss welcome tip", title: "Dismiss", children: "\u00D7" })] }))] })) : 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
1350
|
}
|
|
824
1351
|
//# sourceMappingURL=DocBlocksShell.js.map
|