@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
package/src/Export/run-export.ts
CHANGED
|
@@ -8,11 +8,7 @@ import type {
|
|
|
8
8
|
MarkdownInlineNode,
|
|
9
9
|
} from '@bendyline/squisq/markdown';
|
|
10
10
|
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
11
|
-
import {
|
|
12
|
-
import { applyTransform } from '@bendyline/squisq/transform';
|
|
13
|
-
import { markdownDocToDocx } from '@bendyline/squisq-formats/docx';
|
|
14
|
-
import { markdownDocToPdf } from '@bendyline/squisq-formats/pdf';
|
|
15
|
-
import { docToPptx } from '@bendyline/squisq-formats/pptx';
|
|
11
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
16
12
|
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
17
13
|
import type { ExportOptions, ExportFormat } from './export-options.js';
|
|
18
14
|
import { FORMAT_EXTENSIONS } from './export-options.js';
|
|
@@ -57,14 +53,14 @@ export async function runExport(
|
|
|
57
53
|
}
|
|
58
54
|
|
|
59
55
|
if (options.format === 'html') {
|
|
60
|
-
|
|
61
|
-
downloadBlob(new Blob([html], { type: MIME_TYPES.html }), filename);
|
|
56
|
+
await runHtmlExport(markdown, filename, options, mediaContainer, selectedFile);
|
|
62
57
|
return;
|
|
63
58
|
}
|
|
64
59
|
|
|
65
60
|
const doc = parseMarkdown(markdown);
|
|
66
61
|
|
|
67
62
|
if (options.format === 'docx') {
|
|
63
|
+
const { markdownDocToDocx } = await import('@bendyline/squisq-formats/docx');
|
|
68
64
|
const images = mediaContainer ? await resolveImages(doc, mediaContainer) : undefined;
|
|
69
65
|
const buf = await markdownDocToDocx(doc, { themeId, images });
|
|
70
66
|
downloadBlob(new Blob([buf], { type: MIME_TYPES.docx }), filename);
|
|
@@ -72,6 +68,7 @@ export async function runExport(
|
|
|
72
68
|
}
|
|
73
69
|
|
|
74
70
|
if (options.format === 'pdf') {
|
|
71
|
+
const { markdownDocToPdf } = await import('@bendyline/squisq-formats/pdf');
|
|
75
72
|
const buf = await markdownDocToPdf(doc, {
|
|
76
73
|
themeId,
|
|
77
74
|
pageSize: options.pageSize,
|
|
@@ -81,7 +78,12 @@ export async function runExport(
|
|
|
81
78
|
}
|
|
82
79
|
|
|
83
80
|
if (options.format === 'pptx') {
|
|
84
|
-
|
|
81
|
+
const [{ markdownToDoc }, { applyTransform }, { docToPptx }] = await Promise.all([
|
|
82
|
+
import('@bendyline/squisq/doc'),
|
|
83
|
+
import('@bendyline/squisq/transform'),
|
|
84
|
+
import('@bendyline/squisq-formats/pptx'),
|
|
85
|
+
]);
|
|
86
|
+
// Use the full transform pipeline: markdown -> Doc -> transform -> PPTX
|
|
85
87
|
const baseDoc = markdownToDoc(doc);
|
|
86
88
|
const transformed = applyTransform(baseDoc, options.transformStyle);
|
|
87
89
|
const enrichedDoc = transformed.doc;
|
|
@@ -94,94 +96,146 @@ export async function runExport(
|
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
/**
|
|
98
|
-
function
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
<style>
|
|
114
|
-
body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 2em auto; padding: 0 1em; line-height: 1.6; color: #1f2937; }
|
|
115
|
-
h1, h2, h3, h4, h5, h6 { margin-top: 1.5em; margin-bottom: 0.5em; }
|
|
116
|
-
pre { background: #f3f4f6; padding: 1em; border-radius: 4px; overflow-x: auto; }
|
|
117
|
-
code { background: #f3f4f6; padding: 0.15em 0.3em; border-radius: 3px; font-size: 0.9em; }
|
|
118
|
-
pre code { background: none; padding: 0; }
|
|
119
|
-
blockquote { border-left: 3px solid #d1d5db; margin-left: 0; padding-left: 1em; color: #6b7280; }
|
|
120
|
-
img { max-width: 100%; }
|
|
121
|
-
a { color: #3b82f6; }
|
|
122
|
-
</style>
|
|
123
|
-
</head>
|
|
124
|
-
<body>
|
|
125
|
-
${body}
|
|
126
|
-
</body>
|
|
127
|
-
</html>`;
|
|
128
|
-
}
|
|
99
|
+
/** Run the HTML export branch — handles all four htmlStyle × htmlBundle combinations. */
|
|
100
|
+
async function runHtmlExport(
|
|
101
|
+
markdown: string,
|
|
102
|
+
htmlFilename: string,
|
|
103
|
+
options: ExportOptions,
|
|
104
|
+
mediaContainer?: ContentContainer | null,
|
|
105
|
+
selectedFile?: string | null,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const baseName = htmlFilename.replace(/\.html$/, '');
|
|
108
|
+
const zipName = `${baseName}.zip`;
|
|
109
|
+
// When `entryAsIndex` is on, a single-doc HTML download lands on
|
|
110
|
+
// disk as `index.html` so it drops straight into a static host. The
|
|
111
|
+
// recursive ZIP path doesn't care — squisq's bundle helpers handle
|
|
112
|
+
// the inside-zip rename and the cross-doc link rewrite themselves.
|
|
113
|
+
const singleHtmlFilename = options.entryAsIndex ? 'index.html' : htmlFilename;
|
|
114
|
+
const themeId = options.themeId !== 'standard' ? options.themeId : undefined;
|
|
129
115
|
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
116
|
+
// Recursive HTML bundle: when the user opted in, ask squisq to walk
|
|
117
|
+
// this doc's relative .md links and emit a ZIP with every reachable
|
|
118
|
+
// page rendered + cross-doc hrefs rewritten. Two pipelines:
|
|
119
|
+
// • Plain → `markdownDocsToPlainHtmlBundle` (semantic HTML)
|
|
120
|
+
// • Rendered → `markdownDocsToHtmlBundle` (SquisqPlayer-rendered,
|
|
121
|
+
// shared `squisq-player.js`, Doc-tree link rewrites)
|
|
122
|
+
// Both consume the workspace container to resolve sibling files —
|
|
123
|
+
// `mediaContainer` is already scoped to the selected file's parent
|
|
124
|
+
// dir, which is exactly the bundle's scope root. Falls back to the
|
|
125
|
+
// single-doc path if either prerequisite is missing rather than
|
|
126
|
+
// silently skipping the recursion.
|
|
127
|
+
if (options.includeLinkedDocs && mediaContainer && selectedFile) {
|
|
128
|
+
const entryPath = basenameForBundle(selectedFile);
|
|
129
|
+
if (options.htmlStyle === 'rendered') {
|
|
130
|
+
const [{ markdownDocsToHtmlBundle }, { PLAYER_BUNDLE }] = await Promise.all([
|
|
131
|
+
import('@bendyline/squisq-formats/html'),
|
|
132
|
+
import('@bendyline/squisq-react/standalone-source'),
|
|
133
|
+
]);
|
|
134
|
+
const blob = await markdownDocsToHtmlBundle({
|
|
135
|
+
entryPath,
|
|
136
|
+
readDocument: (path) => readDocumentFromContainer(mediaContainer, path),
|
|
137
|
+
readBinary: (path) => mediaContainer.readFile(path),
|
|
138
|
+
playerScript: PLAYER_BUNDLE,
|
|
139
|
+
title: baseName,
|
|
140
|
+
themeId,
|
|
141
|
+
mode: 'static',
|
|
142
|
+
entryAsIndex: options.entryAsIndex,
|
|
143
|
+
});
|
|
144
|
+
downloadBlob(blob, zipName);
|
|
145
|
+
return;
|
|
138
146
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
147
|
+
const { markdownDocsToPlainHtmlBundle } = await import('@bendyline/squisq-formats/html');
|
|
148
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
149
|
+
entryPath,
|
|
150
|
+
readDocument: (path) => readDocumentFromContainer(mediaContainer, path),
|
|
151
|
+
readBinary: (path) => mediaContainer.readFile(path),
|
|
152
|
+
title: baseName,
|
|
153
|
+
themeId,
|
|
154
|
+
entryAsIndex: options.entryAsIndex,
|
|
155
|
+
});
|
|
156
|
+
downloadBlob(blob, zipName);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (options.htmlStyle === 'rendered') {
|
|
161
|
+
const [{ markdownToDoc }, { docToHtml, docToHtmlZip, collectImagePaths }, { PLAYER_BUNDLE }] =
|
|
162
|
+
await Promise.all([
|
|
163
|
+
import('@bendyline/squisq/doc'),
|
|
164
|
+
import('@bendyline/squisq-formats/html'),
|
|
165
|
+
import('@bendyline/squisq-react/standalone-source'),
|
|
166
|
+
]);
|
|
167
|
+
const mdDoc = parseMarkdown(markdown);
|
|
168
|
+
const baseDoc = markdownToDoc(mdDoc);
|
|
169
|
+
if (themeId) baseDoc.themeId = themeId;
|
|
170
|
+
|
|
171
|
+
const images = mediaContainer
|
|
172
|
+
? await resolveDocImages(baseDoc, mediaContainer, collectImagePaths)
|
|
173
|
+
: undefined;
|
|
174
|
+
|
|
175
|
+
if (options.htmlBundle === 'zip') {
|
|
176
|
+
const blob = await docToHtmlZip(baseDoc, {
|
|
177
|
+
playerScript: PLAYER_BUNDLE,
|
|
178
|
+
images,
|
|
179
|
+
mode: 'static',
|
|
180
|
+
title: baseName,
|
|
181
|
+
});
|
|
182
|
+
downloadBlob(blob, zipName);
|
|
183
|
+
return;
|
|
158
184
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return node.children ? childrenToHtml(node) : escapeHtml(node.value ?? '');
|
|
185
|
+
|
|
186
|
+
const html = docToHtml(baseDoc, {
|
|
187
|
+
playerScript: PLAYER_BUNDLE,
|
|
188
|
+
images,
|
|
189
|
+
mode: 'static',
|
|
190
|
+
title: baseName,
|
|
191
|
+
});
|
|
192
|
+
downloadBlob(new Blob([html], { type: MIME_TYPES.html }), singleHtmlFilename);
|
|
193
|
+
return;
|
|
169
194
|
}
|
|
170
|
-
}
|
|
171
195
|
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return node.children.map((c: any) => nodeToHtml(c)).join('');
|
|
177
|
-
}
|
|
196
|
+
// Plain semantic HTML
|
|
197
|
+
const mdDoc = parseMarkdown(markdown);
|
|
198
|
+
const referencedImages = collectMarkdownImageUrls(mdDoc);
|
|
199
|
+
const localImages = referencedImages.filter((u) => !isExternalUrl(u));
|
|
178
200
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
201
|
+
if (options.htmlBundle === 'zip' && mediaContainer && localImages.length > 0) {
|
|
202
|
+
const [{ markdownDocToPlainHtml }, { MemoryContentContainer }, { containerToZip }] =
|
|
203
|
+
await Promise.all([
|
|
204
|
+
import('@bendyline/squisq-formats/html'),
|
|
205
|
+
import('@bendyline/squisq/storage'),
|
|
206
|
+
import('@bendyline/squisq-formats/container'),
|
|
207
|
+
]);
|
|
208
|
+
// Mirror the markdown's relative paths inside the zip so <img src="..."> still resolves.
|
|
209
|
+
const html = markdownDocToPlainHtml(mdDoc, { title: baseName, themeId });
|
|
210
|
+
const container = new MemoryContentContainer();
|
|
211
|
+
await container.writeFile('index.html', new TextEncoder().encode(html));
|
|
212
|
+
for (const url of localImages) {
|
|
213
|
+
const data = await mediaContainer.readFile(url);
|
|
214
|
+
if (!data) continue;
|
|
215
|
+
const cleanPath = url.replace(/^\/+/, '');
|
|
216
|
+
await container.writeFile(cleanPath, new Uint8Array(data));
|
|
217
|
+
}
|
|
218
|
+
const blob = await containerToZip(container);
|
|
219
|
+
downloadBlob(blob, zipName);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
182
222
|
|
|
183
|
-
|
|
184
|
-
|
|
223
|
+
const { markdownDocToPlainHtml } = await import('@bendyline/squisq-formats/html');
|
|
224
|
+
// Single-file plain HTML — embed local images as base64 data URIs.
|
|
225
|
+
const inlineMap = new Map<string, string>();
|
|
226
|
+
if (mediaContainer) {
|
|
227
|
+
for (const url of localImages) {
|
|
228
|
+
const data = await mediaContainer.readFile(url);
|
|
229
|
+
if (!data) continue;
|
|
230
|
+
inlineMap.set(url, arrayBufferToDataUrl(data, url));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const html = markdownDocToPlainHtml(mdDoc, {
|
|
234
|
+
title: baseName,
|
|
235
|
+
images: inlineMap.size > 0 ? inlineMap : undefined,
|
|
236
|
+
themeId,
|
|
237
|
+
});
|
|
238
|
+
downloadBlob(new Blob([html], { type: MIME_TYPES.html }), singleHtmlFilename);
|
|
185
239
|
}
|
|
186
240
|
|
|
187
241
|
const IMAGE_MIME: Record<string, string> = {
|
|
@@ -191,15 +245,37 @@ const IMAGE_MIME: Record<string, string> = {
|
|
|
191
245
|
gif: 'image/gif',
|
|
192
246
|
webp: 'image/webp',
|
|
193
247
|
bmp: 'image/bmp',
|
|
248
|
+
svg: 'image/svg+xml',
|
|
194
249
|
};
|
|
195
250
|
|
|
251
|
+
function isExternalUrl(url: string): boolean {
|
|
252
|
+
return (
|
|
253
|
+
url.startsWith('http://') ||
|
|
254
|
+
url.startsWith('https://') ||
|
|
255
|
+
url.startsWith('data:') ||
|
|
256
|
+
url.startsWith('blob:')
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function arrayBufferToDataUrl(data: ArrayBuffer, url: string): string {
|
|
261
|
+
const ext = url.slice(url.lastIndexOf('.') + 1).toLowerCase();
|
|
262
|
+
const mime = IMAGE_MIME[ext] ?? 'application/octet-stream';
|
|
263
|
+
// btoa requires a binary string; chunk to avoid call-stack issues on large files.
|
|
264
|
+
const bytes = new Uint8Array(data);
|
|
265
|
+
let binary = '';
|
|
266
|
+
const CHUNK = 0x8000;
|
|
267
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
268
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
269
|
+
}
|
|
270
|
+
return `data:${mime};base64,${btoa(binary)}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
196
273
|
/** Walk the markdown AST and collect all image URLs. */
|
|
197
|
-
function
|
|
274
|
+
function collectMarkdownImageUrls(doc: MarkdownDocument): string[] {
|
|
198
275
|
const urls: string[] = [];
|
|
199
276
|
function walkBlocks(nodes: MarkdownBlockNode[]): void {
|
|
200
277
|
for (const node of nodes) {
|
|
201
278
|
if ('children' in node && Array.isArray(node.children)) {
|
|
202
|
-
// Check if children are inline or block nodes
|
|
203
279
|
for (const child of node.children as (MarkdownBlockNode | MarkdownInlineNode)[]) {
|
|
204
280
|
if (child.type === 'image') {
|
|
205
281
|
urls.push((child as { url: string }).url);
|
|
@@ -211,22 +287,20 @@ function collectImageUrls(doc: MarkdownDocument): string[] {
|
|
|
211
287
|
}
|
|
212
288
|
}
|
|
213
289
|
walkBlocks(doc.children);
|
|
214
|
-
return urls;
|
|
290
|
+
return Array.from(new Set(urls));
|
|
215
291
|
}
|
|
216
292
|
|
|
217
|
-
/** Resolve image URLs to binary data from a ContentContainer. */
|
|
293
|
+
/** Resolve image URLs (markdown → docx) to binary data from a ContentContainer. */
|
|
218
294
|
async function resolveImages(
|
|
219
295
|
doc: MarkdownDocument,
|
|
220
296
|
container: ContentContainer,
|
|
221
297
|
): Promise<Map<string, { data: ArrayBuffer | Uint8Array; contentType: string }>> {
|
|
222
|
-
const urls =
|
|
298
|
+
const urls = collectMarkdownImageUrls(doc);
|
|
223
299
|
const map = new Map<string, { data: ArrayBuffer | Uint8Array; contentType: string }>();
|
|
224
300
|
|
|
225
301
|
for (const url of urls) {
|
|
226
302
|
if (map.has(url)) continue;
|
|
227
|
-
|
|
228
|
-
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:'))
|
|
229
|
-
continue;
|
|
303
|
+
if (isExternalUrl(url)) continue;
|
|
230
304
|
const data = await container.readFile(url);
|
|
231
305
|
if (!data) continue;
|
|
232
306
|
const ext = url.slice(url.lastIndexOf('.') + 1).toLowerCase();
|
|
@@ -236,3 +310,40 @@ async function resolveImages(
|
|
|
236
310
|
|
|
237
311
|
return map;
|
|
238
312
|
}
|
|
313
|
+
|
|
314
|
+
/** Container-relative path for the bundle's entry document. `mediaContainer`
|
|
315
|
+
* is already scoped to the selected file's parent directory, so the bundle
|
|
316
|
+
* needs the basename ("docs/home.md" → "home.md"). */
|
|
317
|
+
function basenameForBundle(selectedFile: string): string {
|
|
318
|
+
const clean = selectedFile.replace(/^\/+/, '');
|
|
319
|
+
const idx = clean.lastIndexOf('/');
|
|
320
|
+
return idx === -1 ? clean : clean.slice(idx + 1);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Read a UTF-8 text document from a ContentContainer. Returns null when
|
|
324
|
+
* the file is missing — preserves the bundle's "abort with error" contract
|
|
325
|
+
* for unreachable .md links. */
|
|
326
|
+
async function readDocumentFromContainer(
|
|
327
|
+
container: ContentContainer,
|
|
328
|
+
path: string,
|
|
329
|
+
): Promise<string | null> {
|
|
330
|
+
const data = await container.readFile(path);
|
|
331
|
+
if (!data) return null;
|
|
332
|
+
return new TextDecoder('utf-8').decode(data);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Resolve image paths referenced by a squisq Doc to ArrayBuffers for HTML export. */
|
|
336
|
+
async function resolveDocImages(
|
|
337
|
+
doc: Doc,
|
|
338
|
+
container: ContentContainer,
|
|
339
|
+
collectImagePaths: (doc: Doc) => Iterable<string>,
|
|
340
|
+
): Promise<Map<string, ArrayBuffer>> {
|
|
341
|
+
const paths = collectImagePaths(doc);
|
|
342
|
+
const map = new Map<string, ArrayBuffer>();
|
|
343
|
+
for (const path of paths) {
|
|
344
|
+
const data = await container.readFile(path);
|
|
345
|
+
if (!data) continue;
|
|
346
|
+
map.set(path, data);
|
|
347
|
+
}
|
|
348
|
+
return map;
|
|
349
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ExportSummaryOption {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
let transformStyleSummariesPromise: Promise<ExportSummaryOption[]> | null = null;
|
|
8
|
+
|
|
9
|
+
export function loadTransformStyleSummaries(): Promise<ExportSummaryOption[]> {
|
|
10
|
+
transformStyleSummariesPromise ??= import('@bendyline/squisq/transform').then(
|
|
11
|
+
({ getTransformStyleSummaries }) => getTransformStyleSummaries(),
|
|
12
|
+
);
|
|
13
|
+
return transformStyleSummariesPromise;
|
|
14
|
+
}
|
|
@@ -13,6 +13,22 @@ import { NewFileIcon, NewFolderIcon, RefreshIcon } from '../icons.js';
|
|
|
13
13
|
|
|
14
14
|
const SUPPORTED_EXTENSIONS = new Set(['.txt', '.md', '.docx', '.pdf', '.dbk', '.zip']);
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Hide auto-generated `<basename>_files/` companion folders from the
|
|
18
|
+
* tree. They hold per-document images and version snapshots — useful
|
|
19
|
+
* to the editor, noisy in the user-facing file list. The folders still
|
|
20
|
+
* exist on disk; we just don't surface them in the explorer.
|
|
21
|
+
*/
|
|
22
|
+
function isHiddenEntry(entry: FileSystemEntry): boolean {
|
|
23
|
+
if (entry.kind !== 'directory') return false;
|
|
24
|
+
const name = entry.path.replace(/^\/+/, '').split('/').pop() ?? '';
|
|
25
|
+
return name.endsWith('_files');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function filterVisible(entries: FileSystemEntry[]): FileSystemEntry[] {
|
|
29
|
+
return entries.filter((e) => !isHiddenEntry(e));
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
export interface FileExplorerProps {
|
|
17
33
|
/** The filesystem to display. */
|
|
18
34
|
provider: FileSystemProvider | null;
|
|
@@ -154,7 +170,7 @@ export function FileExplorer({
|
|
|
154
170
|
|
|
155
171
|
const renderEntries = useCallback(
|
|
156
172
|
(entries: FileSystemEntry[], depth: number): React.ReactNode => {
|
|
157
|
-
return entries.map((entry) => (
|
|
173
|
+
return filterVisible(entries).map((entry) => (
|
|
158
174
|
<FileTreeNode
|
|
159
175
|
key={entry.path}
|
|
160
176
|
entry={entry}
|
|
@@ -245,16 +261,22 @@ export function FileExplorer({
|
|
|
245
261
|
</div>
|
|
246
262
|
)}
|
|
247
263
|
|
|
248
|
-
{/* Tree
|
|
249
|
-
|
|
250
|
-
|
|
264
|
+
{/* Tree — role="tree" only when there are real treeitem children; the
|
|
265
|
+
loading/empty states get a status role so axe doesn't flag the
|
|
266
|
+
tree as missing required children. */}
|
|
267
|
+
{tree.loading ? (
|
|
268
|
+
<div className="db-tree" role="status" aria-live="polite">
|
|
251
269
|
<div className="db-tree-loading">Loading...</div>
|
|
252
|
-
|
|
270
|
+
</div>
|
|
271
|
+
) : filterVisible(tree.entries).length === 0 ? (
|
|
272
|
+
<div className="db-tree">
|
|
253
273
|
<div className="db-tree-empty">No files yet</div>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
274
|
+
</div>
|
|
275
|
+
) : (
|
|
276
|
+
<div className="db-tree" role="tree">
|
|
277
|
+
{renderEntries(tree.entries, 0)}
|
|
278
|
+
</div>
|
|
279
|
+
)}
|
|
258
280
|
</div>
|
|
259
281
|
);
|
|
260
282
|
}
|
|
@@ -6,12 +6,14 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
|
|
6
6
|
import { WorkspaceIcon } from '../icons.js';
|
|
7
7
|
|
|
8
8
|
export interface WorkspaceSettingsButtonProps {
|
|
9
|
+
onSettings: () => void;
|
|
9
10
|
onRename: () => void;
|
|
10
11
|
onDownload: () => void;
|
|
11
12
|
onRemove: () => void;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export function WorkspaceSettingsButton({
|
|
16
|
+
onSettings,
|
|
15
17
|
onRename,
|
|
16
18
|
onDownload,
|
|
17
19
|
onRemove,
|
|
@@ -50,6 +52,13 @@ export function WorkspaceSettingsButton({
|
|
|
50
52
|
|
|
51
53
|
{isOpen && (
|
|
52
54
|
<div className="db-ws-settings-dropdown" role="menu">
|
|
55
|
+
<button
|
|
56
|
+
className="db-ws-settings-item"
|
|
57
|
+
role="menuitem"
|
|
58
|
+
onClick={() => handleAction(onSettings)}
|
|
59
|
+
>
|
|
60
|
+
Workspace settings…
|
|
61
|
+
</button>
|
|
53
62
|
<button
|
|
54
63
|
className="db-ws-settings-item"
|
|
55
64
|
role="menuitem"
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkspaceSettingsDialog — per-workspace settings modal.
|
|
3
|
+
*
|
|
4
|
+
* Lets the user override the global versioning preference for a single
|
|
5
|
+
* workspace (or fall back to inheriting it). The override is stored on
|
|
6
|
+
* the WorkspaceDescriptor and persisted via `saveWorkspace`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect } from 'react';
|
|
10
|
+
import type { WorkspaceDescriptor } from '@bendyline/docblocks/workspace';
|
|
11
|
+
import {
|
|
12
|
+
isLocalWorkspaceType,
|
|
13
|
+
resolveVersioningEnabled,
|
|
14
|
+
type VersioningPreference,
|
|
15
|
+
} from '../preferences/versioning.js';
|
|
16
|
+
|
|
17
|
+
export type WorkspaceVersioningOverride = NonNullable<WorkspaceDescriptor['versioningOverride']>;
|
|
18
|
+
|
|
19
|
+
export interface WorkspaceSettingsDialogProps {
|
|
20
|
+
workspace: WorkspaceDescriptor;
|
|
21
|
+
/** Current global versioning preference — used to label "Inherit". */
|
|
22
|
+
globalVersioningPreference: VersioningPreference;
|
|
23
|
+
/** Apply a new override and close the dialog. */
|
|
24
|
+
onChange: (override: WorkspaceVersioningOverride) => void;
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const VERSIONING_LABELS: Record<VersioningPreference, string> = {
|
|
29
|
+
on: 'on',
|
|
30
|
+
'browser-only': 'on for browser workspaces, off for local folders',
|
|
31
|
+
off: 'off',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function WorkspaceSettingsDialog({
|
|
35
|
+
workspace,
|
|
36
|
+
globalVersioningPreference,
|
|
37
|
+
onChange,
|
|
38
|
+
onClose,
|
|
39
|
+
}: WorkspaceSettingsDialogProps) {
|
|
40
|
+
// Treat absent/undefined as 'inherit' for the radio state.
|
|
41
|
+
const current: WorkspaceVersioningOverride = workspace.versioningOverride ?? 'inherit';
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const onKey = (e: KeyboardEvent) => {
|
|
45
|
+
if (e.key === 'Escape') onClose();
|
|
46
|
+
};
|
|
47
|
+
window.addEventListener('keydown', onKey);
|
|
48
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
49
|
+
}, [onClose]);
|
|
50
|
+
|
|
51
|
+
const handleBackdropClick = useCallback(
|
|
52
|
+
(e: React.MouseEvent) => {
|
|
53
|
+
if (e.target === e.currentTarget) onClose();
|
|
54
|
+
},
|
|
55
|
+
[onClose],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const isLocal = isLocalWorkspaceType(workspace.type);
|
|
59
|
+
const inheritedEnabled = resolveVersioningEnabled(
|
|
60
|
+
{ ...workspace, versioningOverride: 'inherit' },
|
|
61
|
+
globalVersioningPreference,
|
|
62
|
+
);
|
|
63
|
+
const inheritLabel = `Inherit default — currently ${VERSIONING_LABELS[globalVersioningPreference]} (${inheritedEnabled ? 'on' : 'off'} for this workspace)`;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="db-dialog-overlay" onClick={handleBackdropClick}>
|
|
67
|
+
<div className="db-dialog" role="dialog" aria-label="Workspace settings">
|
|
68
|
+
<div className="db-dialog-header">
|
|
69
|
+
<h2 className="db-dialog-title">Workspace settings</h2>
|
|
70
|
+
<button type="button" className="db-dialog-close" onClick={onClose} aria-label="Close">
|
|
71
|
+
×
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="db-dialog-body">
|
|
75
|
+
<p className="db-settings-hint">
|
|
76
|
+
<strong>{workspace.name}</strong> ·{' '}
|
|
77
|
+
{isLocal ? 'Local folder workspace' : 'Browser workspace'}
|
|
78
|
+
</p>
|
|
79
|
+
|
|
80
|
+
<fieldset className="db-settings-fieldset">
|
|
81
|
+
<legend className="db-settings-legend">Version history</legend>
|
|
82
|
+
<p className="db-settings-hint">
|
|
83
|
+
Controls whether DocBlocks keeps prior revisions in{' '}
|
|
84
|
+
<code><name>_files/.versions/</code> for documents in this workspace.
|
|
85
|
+
</p>
|
|
86
|
+
<label className="db-settings-radio">
|
|
87
|
+
<input
|
|
88
|
+
type="radio"
|
|
89
|
+
name="ws-versioning"
|
|
90
|
+
value="inherit"
|
|
91
|
+
checked={current === 'inherit'}
|
|
92
|
+
onChange={() => onChange('inherit')}
|
|
93
|
+
/>
|
|
94
|
+
{inheritLabel}
|
|
95
|
+
</label>
|
|
96
|
+
<label className="db-settings-radio">
|
|
97
|
+
<input
|
|
98
|
+
type="radio"
|
|
99
|
+
name="ws-versioning"
|
|
100
|
+
value="on"
|
|
101
|
+
checked={current === 'on'}
|
|
102
|
+
onChange={() => onChange('on')}
|
|
103
|
+
/>
|
|
104
|
+
On for this workspace
|
|
105
|
+
</label>
|
|
106
|
+
<label className="db-settings-radio">
|
|
107
|
+
<input
|
|
108
|
+
type="radio"
|
|
109
|
+
name="ws-versioning"
|
|
110
|
+
value="off"
|
|
111
|
+
checked={current === 'off'}
|
|
112
|
+
onChange={() => onChange('off')}
|
|
113
|
+
/>
|
|
114
|
+
Off for this workspace
|
|
115
|
+
</label>
|
|
116
|
+
</fieldset>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|