@bendyline/docblocks-react 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AppMenu/AppMenu.d.ts +16 -1
- package/dist/AppMenu/AppMenu.d.ts.map +1 -1
- package/dist/AppMenu/AppMenu.js +3 -2
- package/dist/AppMenu/AppMenu.js.map +1 -1
- package/dist/AppMenu/index.d.ts +1 -1
- package/dist/AppMenu/index.d.ts.map +1 -1
- package/dist/DocBlocksShell/DocBlocksShell.d.ts +21 -1
- package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
- package/dist/DocBlocksShell/DocBlocksShell.js +930 -98
- package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
- package/dist/Export/ExportDialog.d.ts.map +1 -1
- package/dist/Export/ExportDialog.js +104 -5
- package/dist/Export/ExportDialog.js.map +1 -1
- package/dist/Export/ExportToolbarControls.d.ts.map +1 -1
- package/dist/Export/ExportToolbarControls.js +44 -3
- package/dist/Export/ExportToolbarControls.js.map +1 -1
- package/dist/Export/export-options.d.ts +35 -0
- package/dist/Export/export-options.d.ts.map +1 -1
- package/dist/Export/export-options.js +6 -1
- package/dist/Export/export-options.js.map +1 -1
- package/dist/Export/run-export.d.ts.map +1 -1
- package/dist/Export/run-export.js +163 -91
- package/dist/Export/run-export.js.map +1 -1
- package/dist/FileExplorer/FileExplorer.d.ts.map +1 -1
- package/dist/FileExplorer/FileExplorer.js +17 -2
- package/dist/FileExplorer/FileExplorer.js.map +1 -1
- package/dist/FileExplorer/FileTreeNode.d.ts.map +1 -1
- package/dist/FileExplorer/FileTreeNode.js +34 -8
- package/dist/FileExplorer/FileTreeNode.js.map +1 -1
- package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +1 -1
- package/dist/WorkspacePicker/WorkspacePicker.js +9 -3
- package/dist/WorkspacePicker/WorkspacePicker.js.map +1 -1
- package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +2 -1
- package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +1 -1
- package/dist/WorkspacePicker/WorkspaceSettingsButton.js +2 -2
- package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +1 -1
- package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts +20 -0
- package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts.map +1 -0
- package/dist/WorkspacePicker/WorkspaceSettingsDialog.js +36 -0
- package/dist/WorkspacePicker/WorkspaceSettingsDialog.js.map +1 -0
- package/dist/hooks/useAutoSave.d.ts +8 -2
- package/dist/hooks/useAutoSave.d.ts.map +1 -1
- package/dist/hooks/useAutoSave.js +65 -30
- package/dist/hooks/useAutoSave.js.map +1 -1
- package/dist/icons.d.ts +1 -0
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +3 -0
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/preferences/versioning.d.ts +27 -0
- package/dist/preferences/versioning.d.ts.map +1 -0
- package/dist/preferences/versioning.js +62 -0
- package/dist/preferences/versioning.js.map +1 -0
- package/package.json +10 -8
- package/src/AppMenu/AppMenu.tsx +142 -2
- package/src/AppMenu/index.ts +1 -1
- package/src/DocBlocksShell/DocBlocksShell.tsx +1194 -125
- package/src/Export/ExportDialog.tsx +208 -19
- package/src/Export/ExportToolbarControls.tsx +45 -3
- package/src/Export/export-options.ts +43 -1
- package/src/Export/run-export.ts +190 -91
- package/src/FileExplorer/FileExplorer.tsx +31 -9
- package/src/FileExplorer/FileTreeNode.tsx +68 -26
- package/src/WorkspacePicker/WorkspacePicker.tsx +18 -7
- package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +9 -0
- package/src/WorkspacePicker/WorkspaceSettingsDialog.tsx +121 -0
- package/src/fonts/fonts.css +362 -0
- package/src/fonts/licenses/LICENSE-cormorant-garamond.txt +93 -0
- package/src/fonts/licenses/LICENSE-crimson-text.txt +93 -0
- package/src/fonts/licenses/LICENSE-dm-sans.txt +93 -0
- package/src/fonts/licenses/LICENSE-dm-serif-display.txt +93 -0
- package/src/fonts/licenses/LICENSE-hanken-grotesk.txt +94 -0
- package/src/fonts/licenses/LICENSE-ibm-plex-sans.txt +93 -0
- package/src/fonts/licenses/LICENSE-inter.txt +93 -0
- package/src/fonts/licenses/LICENSE-jetbrains-mono.txt +93 -0
- package/src/fonts/licenses/LICENSE-lora.txt +93 -0
- package/src/fonts/licenses/LICENSE-merriweather.txt +93 -0
- package/src/fonts/licenses/LICENSE-oswald.txt +93 -0
- package/src/fonts/licenses/LICENSE-playfair-display.txt +93 -0
- package/src/fonts/licenses/LICENSE-pt-serif.txt +93 -0
- package/src/fonts/licenses/LICENSE-roboto.txt +93 -0
- package/src/fonts/licenses/LICENSE-source-serif-4.txt +93 -0
- package/src/fonts/woff2/cormorant-garamond-400.woff2 +0 -0
- package/src/fonts/woff2/cormorant-garamond-700.woff2 +0 -0
- package/src/fonts/woff2/crimson-text-400.woff2 +0 -0
- package/src/fonts/woff2/crimson-text-400i.woff2 +0 -0
- package/src/fonts/woff2/crimson-text-700.woff2 +0 -0
- package/src/fonts/woff2/dm-sans-400.woff2 +0 -0
- package/src/fonts/woff2/dm-sans-400i.woff2 +0 -0
- package/src/fonts/woff2/dm-sans-500.woff2 +0 -0
- package/src/fonts/woff2/dm-sans-700.woff2 +0 -0
- package/src/fonts/woff2/dm-serif-display-400.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-400.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-400i.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-500.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-600.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-700.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-400.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-400i.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-500.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-600.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-700.woff2 +0 -0
- package/src/fonts/woff2/inter-400.woff2 +0 -0
- package/src/fonts/woff2/inter-400i.woff2 +0 -0
- package/src/fonts/woff2/inter-500.woff2 +0 -0
- package/src/fonts/woff2/inter-600.woff2 +0 -0
- package/src/fonts/woff2/inter-700.woff2 +0 -0
- package/src/fonts/woff2/jetbrains-mono-400.woff2 +0 -0
- package/src/fonts/woff2/jetbrains-mono-400i.woff2 +0 -0
- package/src/fonts/woff2/jetbrains-mono-700.woff2 +0 -0
- package/src/fonts/woff2/lora-400.woff2 +0 -0
- package/src/fonts/woff2/lora-400i.woff2 +0 -0
- package/src/fonts/woff2/lora-700.woff2 +0 -0
- package/src/fonts/woff2/merriweather-400.woff2 +0 -0
- package/src/fonts/woff2/merriweather-700.woff2 +0 -0
- package/src/fonts/woff2/oswald-400.woff2 +0 -0
- package/src/fonts/woff2/oswald-700.woff2 +0 -0
- package/src/fonts/woff2/playfair-display-400.woff2 +0 -0
- package/src/fonts/woff2/playfair-display-700.woff2 +0 -0
- package/src/fonts/woff2/pt-serif-400.woff2 +0 -0
- package/src/fonts/woff2/pt-serif-700.woff2 +0 -0
- package/src/fonts/woff2/roboto-400.woff2 +0 -0
- package/src/fonts/woff2/roboto-400i.woff2 +0 -0
- package/src/fonts/woff2/roboto-700.woff2 +0 -0
- package/src/fonts/woff2/source-serif-4-400.woff2 +0 -0
- package/src/fonts/woff2/source-serif-4-400i.woff2 +0 -0
- package/src/fonts/woff2/source-serif-4-700.woff2 +0 -0
- package/src/hooks/useAutoSave.ts +87 -29
- package/src/icons.tsx +10 -0
- package/src/index.ts +1 -1
- package/src/preferences/versioning.ts +69 -0
- package/src/styles/docblocks.css +1037 -55
package/src/Export/run-export.ts
CHANGED
|
@@ -9,10 +9,22 @@ import type {
|
|
|
9
9
|
} from '@bendyline/squisq/markdown';
|
|
10
10
|
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
11
11
|
import { markdownToDoc } from '@bendyline/squisq/doc';
|
|
12
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
12
13
|
import { applyTransform } from '@bendyline/squisq/transform';
|
|
13
14
|
import { markdownDocToDocx } from '@bendyline/squisq-formats/docx';
|
|
14
15
|
import { markdownDocToPdf } from '@bendyline/squisq-formats/pdf';
|
|
15
16
|
import { docToPptx } from '@bendyline/squisq-formats/pptx';
|
|
17
|
+
import {
|
|
18
|
+
docToHtml,
|
|
19
|
+
docToHtmlZip,
|
|
20
|
+
collectImagePaths,
|
|
21
|
+
markdownDocsToPlainHtmlBundle,
|
|
22
|
+
markdownDocsToHtmlBundle,
|
|
23
|
+
markdownDocToPlainHtml,
|
|
24
|
+
} from '@bendyline/squisq-formats/html';
|
|
25
|
+
import { containerToZip } from '@bendyline/squisq-formats/container';
|
|
26
|
+
import { MemoryContentContainer } from '@bendyline/squisq/storage';
|
|
27
|
+
import { PLAYER_BUNDLE } from '@bendyline/squisq-react/standalone-source';
|
|
16
28
|
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
17
29
|
import type { ExportOptions, ExportFormat } from './export-options.js';
|
|
18
30
|
import { FORMAT_EXTENSIONS } from './export-options.js';
|
|
@@ -57,8 +69,7 @@ export async function runExport(
|
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
if (options.format === 'html') {
|
|
60
|
-
|
|
61
|
-
downloadBlob(new Blob([html], { type: MIME_TYPES.html }), filename);
|
|
72
|
+
await runHtmlExport(markdown, filename, options, mediaContainer, selectedFile);
|
|
62
73
|
return;
|
|
63
74
|
}
|
|
64
75
|
|
|
@@ -94,94 +105,126 @@ export async function runExport(
|
|
|
94
105
|
}
|
|
95
106
|
}
|
|
96
107
|
|
|
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
|
-
}
|
|
108
|
+
/** Run the HTML export branch — handles all four htmlStyle × htmlBundle combinations. */
|
|
109
|
+
async function runHtmlExport(
|
|
110
|
+
markdown: string,
|
|
111
|
+
htmlFilename: string,
|
|
112
|
+
options: ExportOptions,
|
|
113
|
+
mediaContainer?: ContentContainer | null,
|
|
114
|
+
selectedFile?: string | null,
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
const baseName = htmlFilename.replace(/\.html$/, '');
|
|
117
|
+
const zipName = `${baseName}.zip`;
|
|
118
|
+
// When `entryAsIndex` is on, a single-doc HTML download lands on
|
|
119
|
+
// disk as `index.html` so it drops straight into a static host. The
|
|
120
|
+
// recursive ZIP path doesn't care — squisq's bundle helpers handle
|
|
121
|
+
// the inside-zip rename and the cross-doc link rewrite themselves.
|
|
122
|
+
const singleHtmlFilename = options.entryAsIndex ? 'index.html' : htmlFilename;
|
|
123
|
+
const themeId = options.themeId !== 'standard' ? options.themeId : undefined;
|
|
129
124
|
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
125
|
+
// Recursive HTML bundle: when the user opted in, ask squisq to walk
|
|
126
|
+
// this doc's relative .md links and emit a ZIP with every reachable
|
|
127
|
+
// page rendered + cross-doc hrefs rewritten. Two pipelines:
|
|
128
|
+
// • Plain → `markdownDocsToPlainHtmlBundle` (semantic HTML)
|
|
129
|
+
// • Rendered → `markdownDocsToHtmlBundle` (SquisqPlayer-rendered,
|
|
130
|
+
// shared `squisq-player.js`, Doc-tree link rewrites)
|
|
131
|
+
// Both consume the workspace container to resolve sibling files —
|
|
132
|
+
// `mediaContainer` is already scoped to the selected file's parent
|
|
133
|
+
// dir, which is exactly the bundle's scope root. Falls back to the
|
|
134
|
+
// single-doc path if either prerequisite is missing rather than
|
|
135
|
+
// silently skipping the recursion.
|
|
136
|
+
if (options.includeLinkedDocs && mediaContainer && selectedFile) {
|
|
137
|
+
const entryPath = basenameForBundle(selectedFile);
|
|
138
|
+
if (options.htmlStyle === 'rendered') {
|
|
139
|
+
const blob = await markdownDocsToHtmlBundle({
|
|
140
|
+
entryPath,
|
|
141
|
+
readDocument: (path) => readDocumentFromContainer(mediaContainer, path),
|
|
142
|
+
readBinary: (path) => mediaContainer.readFile(path),
|
|
143
|
+
playerScript: PLAYER_BUNDLE,
|
|
144
|
+
title: baseName,
|
|
145
|
+
themeId,
|
|
146
|
+
mode: 'static',
|
|
147
|
+
entryAsIndex: options.entryAsIndex,
|
|
148
|
+
});
|
|
149
|
+
downloadBlob(blob, zipName);
|
|
150
|
+
return;
|
|
138
151
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
152
|
+
const blob = await markdownDocsToPlainHtmlBundle({
|
|
153
|
+
entryPath,
|
|
154
|
+
readDocument: (path) => readDocumentFromContainer(mediaContainer, path),
|
|
155
|
+
readBinary: (path) => mediaContainer.readFile(path),
|
|
156
|
+
title: baseName,
|
|
157
|
+
themeId,
|
|
158
|
+
entryAsIndex: options.entryAsIndex,
|
|
159
|
+
});
|
|
160
|
+
downloadBlob(blob, zipName);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (options.htmlStyle === 'rendered') {
|
|
165
|
+
const mdDoc = parseMarkdown(markdown);
|
|
166
|
+
const baseDoc = markdownToDoc(mdDoc);
|
|
167
|
+
if (themeId) baseDoc.themeId = themeId;
|
|
168
|
+
|
|
169
|
+
const images = mediaContainer ? await resolveDocImages(baseDoc, mediaContainer) : undefined;
|
|
170
|
+
|
|
171
|
+
if (options.htmlBundle === 'zip') {
|
|
172
|
+
const blob = await docToHtmlZip(baseDoc, {
|
|
173
|
+
playerScript: PLAYER_BUNDLE,
|
|
174
|
+
images,
|
|
175
|
+
mode: 'static',
|
|
176
|
+
title: baseName,
|
|
177
|
+
});
|
|
178
|
+
downloadBlob(blob, zipName);
|
|
179
|
+
return;
|
|
158
180
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return node.children ? childrenToHtml(node) : escapeHtml(node.value ?? '');
|
|
181
|
+
|
|
182
|
+
const html = docToHtml(baseDoc, {
|
|
183
|
+
playerScript: PLAYER_BUNDLE,
|
|
184
|
+
images,
|
|
185
|
+
mode: 'static',
|
|
186
|
+
title: baseName,
|
|
187
|
+
});
|
|
188
|
+
downloadBlob(new Blob([html], { type: MIME_TYPES.html }), singleHtmlFilename);
|
|
189
|
+
return;
|
|
169
190
|
}
|
|
170
|
-
}
|
|
171
191
|
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return node.children.map((c: any) => nodeToHtml(c)).join('');
|
|
177
|
-
}
|
|
192
|
+
// Plain semantic HTML
|
|
193
|
+
const mdDoc = parseMarkdown(markdown);
|
|
194
|
+
const referencedImages = collectMarkdownImageUrls(mdDoc);
|
|
195
|
+
const localImages = referencedImages.filter((u) => !isExternalUrl(u));
|
|
178
196
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
197
|
+
if (options.htmlBundle === 'zip' && mediaContainer && localImages.length > 0) {
|
|
198
|
+
// Mirror the markdown's relative paths inside the zip so <img src="..."> still resolves.
|
|
199
|
+
const html = markdownDocToPlainHtml(mdDoc, { title: baseName, themeId });
|
|
200
|
+
const container = new MemoryContentContainer();
|
|
201
|
+
await container.writeFile('index.html', new TextEncoder().encode(html));
|
|
202
|
+
for (const url of localImages) {
|
|
203
|
+
const data = await mediaContainer.readFile(url);
|
|
204
|
+
if (!data) continue;
|
|
205
|
+
const cleanPath = url.replace(/^\/+/, '');
|
|
206
|
+
await container.writeFile(cleanPath, new Uint8Array(data));
|
|
207
|
+
}
|
|
208
|
+
const blob = await containerToZip(container);
|
|
209
|
+
downloadBlob(blob, zipName);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
182
212
|
|
|
183
|
-
|
|
184
|
-
|
|
213
|
+
// Single-file plain HTML — embed local images as base64 data URIs.
|
|
214
|
+
const inlineMap = new Map<string, string>();
|
|
215
|
+
if (mediaContainer) {
|
|
216
|
+
for (const url of localImages) {
|
|
217
|
+
const data = await mediaContainer.readFile(url);
|
|
218
|
+
if (!data) continue;
|
|
219
|
+
inlineMap.set(url, arrayBufferToDataUrl(data, url));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const html = markdownDocToPlainHtml(mdDoc, {
|
|
223
|
+
title: baseName,
|
|
224
|
+
images: inlineMap.size > 0 ? inlineMap : undefined,
|
|
225
|
+
themeId,
|
|
226
|
+
});
|
|
227
|
+
downloadBlob(new Blob([html], { type: MIME_TYPES.html }), singleHtmlFilename);
|
|
185
228
|
}
|
|
186
229
|
|
|
187
230
|
const IMAGE_MIME: Record<string, string> = {
|
|
@@ -191,15 +234,37 @@ const IMAGE_MIME: Record<string, string> = {
|
|
|
191
234
|
gif: 'image/gif',
|
|
192
235
|
webp: 'image/webp',
|
|
193
236
|
bmp: 'image/bmp',
|
|
237
|
+
svg: 'image/svg+xml',
|
|
194
238
|
};
|
|
195
239
|
|
|
240
|
+
function isExternalUrl(url: string): boolean {
|
|
241
|
+
return (
|
|
242
|
+
url.startsWith('http://') ||
|
|
243
|
+
url.startsWith('https://') ||
|
|
244
|
+
url.startsWith('data:') ||
|
|
245
|
+
url.startsWith('blob:')
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function arrayBufferToDataUrl(data: ArrayBuffer, url: string): string {
|
|
250
|
+
const ext = url.slice(url.lastIndexOf('.') + 1).toLowerCase();
|
|
251
|
+
const mime = IMAGE_MIME[ext] ?? 'application/octet-stream';
|
|
252
|
+
// btoa requires a binary string; chunk to avoid call-stack issues on large files.
|
|
253
|
+
const bytes = new Uint8Array(data);
|
|
254
|
+
let binary = '';
|
|
255
|
+
const CHUNK = 0x8000;
|
|
256
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
257
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
258
|
+
}
|
|
259
|
+
return `data:${mime};base64,${btoa(binary)}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
196
262
|
/** Walk the markdown AST and collect all image URLs. */
|
|
197
|
-
function
|
|
263
|
+
function collectMarkdownImageUrls(doc: MarkdownDocument): string[] {
|
|
198
264
|
const urls: string[] = [];
|
|
199
265
|
function walkBlocks(nodes: MarkdownBlockNode[]): void {
|
|
200
266
|
for (const node of nodes) {
|
|
201
267
|
if ('children' in node && Array.isArray(node.children)) {
|
|
202
|
-
// Check if children are inline or block nodes
|
|
203
268
|
for (const child of node.children as (MarkdownBlockNode | MarkdownInlineNode)[]) {
|
|
204
269
|
if (child.type === 'image') {
|
|
205
270
|
urls.push((child as { url: string }).url);
|
|
@@ -211,22 +276,20 @@ function collectImageUrls(doc: MarkdownDocument): string[] {
|
|
|
211
276
|
}
|
|
212
277
|
}
|
|
213
278
|
walkBlocks(doc.children);
|
|
214
|
-
return urls;
|
|
279
|
+
return Array.from(new Set(urls));
|
|
215
280
|
}
|
|
216
281
|
|
|
217
|
-
/** Resolve image URLs to binary data from a ContentContainer. */
|
|
282
|
+
/** Resolve image URLs (markdown → docx) to binary data from a ContentContainer. */
|
|
218
283
|
async function resolveImages(
|
|
219
284
|
doc: MarkdownDocument,
|
|
220
285
|
container: ContentContainer,
|
|
221
286
|
): Promise<Map<string, { data: ArrayBuffer | Uint8Array; contentType: string }>> {
|
|
222
|
-
const urls =
|
|
287
|
+
const urls = collectMarkdownImageUrls(doc);
|
|
223
288
|
const map = new Map<string, { data: ArrayBuffer | Uint8Array; contentType: string }>();
|
|
224
289
|
|
|
225
290
|
for (const url of urls) {
|
|
226
291
|
if (map.has(url)) continue;
|
|
227
|
-
|
|
228
|
-
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:'))
|
|
229
|
-
continue;
|
|
292
|
+
if (isExternalUrl(url)) continue;
|
|
230
293
|
const data = await container.readFile(url);
|
|
231
294
|
if (!data) continue;
|
|
232
295
|
const ext = url.slice(url.lastIndexOf('.') + 1).toLowerCase();
|
|
@@ -236,3 +299,39 @@ async function resolveImages(
|
|
|
236
299
|
|
|
237
300
|
return map;
|
|
238
301
|
}
|
|
302
|
+
|
|
303
|
+
/** Container-relative path for the bundle's entry document. `mediaContainer`
|
|
304
|
+
* is already scoped to the selected file's parent directory, so the bundle
|
|
305
|
+
* needs the basename ("docs/home.md" → "home.md"). */
|
|
306
|
+
function basenameForBundle(selectedFile: string): string {
|
|
307
|
+
const clean = selectedFile.replace(/^\/+/, '');
|
|
308
|
+
const idx = clean.lastIndexOf('/');
|
|
309
|
+
return idx === -1 ? clean : clean.slice(idx + 1);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Read a UTF-8 text document from a ContentContainer. Returns null when
|
|
313
|
+
* the file is missing — preserves the bundle's "abort with error" contract
|
|
314
|
+
* for unreachable .md links. */
|
|
315
|
+
async function readDocumentFromContainer(
|
|
316
|
+
container: ContentContainer,
|
|
317
|
+
path: string,
|
|
318
|
+
): Promise<string | null> {
|
|
319
|
+
const data = await container.readFile(path);
|
|
320
|
+
if (!data) return null;
|
|
321
|
+
return new TextDecoder('utf-8').decode(data);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Resolve image paths referenced by a squisq Doc to ArrayBuffers for HTML export. */
|
|
325
|
+
async function resolveDocImages(
|
|
326
|
+
doc: Doc,
|
|
327
|
+
container: ContentContainer,
|
|
328
|
+
): Promise<Map<string, ArrayBuffer>> {
|
|
329
|
+
const paths = collectImagePaths(doc);
|
|
330
|
+
const map = new Map<string, ArrayBuffer>();
|
|
331
|
+
for (const path of paths) {
|
|
332
|
+
const data = await container.readFile(path);
|
|
333
|
+
if (!data) continue;
|
|
334
|
+
map.set(path, data);
|
|
335
|
+
}
|
|
336
|
+
return map;
|
|
337
|
+
}
|
|
@@ -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
|
}
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* FileTreeNode — recursive tree node for the file explorer.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { useCallback, useState, useRef, useEffect } from 'react';
|
|
5
|
+
import { useCallback, useState, useRef, useEffect, useLayoutEffect } from 'react';
|
|
6
|
+
import { createPortal } from 'react-dom';
|
|
6
7
|
import type { FileSystemEntry } from '@bendyline/docblocks/filesystem';
|
|
8
|
+
import { MoreIcon } from '../icons.js';
|
|
7
9
|
|
|
8
10
|
export interface FileTreeNodeProps {
|
|
9
11
|
entry: FileSystemEntry;
|
|
@@ -50,11 +52,15 @@ export function FileTreeNode({
|
|
|
50
52
|
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
|
51
53
|
e.preventDefault();
|
|
52
54
|
e.stopPropagation();
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
setContextPos({ x: e.clientX, y: e.clientY });
|
|
56
|
+
setShowContext(true);
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const handleMoreClick = useCallback((e: React.MouseEvent) => {
|
|
60
|
+
e.preventDefault();
|
|
61
|
+
e.stopPropagation();
|
|
62
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
63
|
+
setContextPos({ x: rect.right, y: rect.bottom });
|
|
58
64
|
setShowContext(true);
|
|
59
65
|
}, []);
|
|
60
66
|
|
|
@@ -106,6 +112,26 @@ export function FileTreeNode({
|
|
|
106
112
|
}
|
|
107
113
|
}, [renaming]);
|
|
108
114
|
|
|
115
|
+
// Clamp menu inside viewport after it renders
|
|
116
|
+
useLayoutEffect(() => {
|
|
117
|
+
if (!showContext || !contextRef.current) return;
|
|
118
|
+
const menu = contextRef.current;
|
|
119
|
+
const rect = menu.getBoundingClientRect();
|
|
120
|
+
const margin = 4;
|
|
121
|
+
let left = contextPos.x;
|
|
122
|
+
let top = contextPos.y;
|
|
123
|
+
if (rect.right > window.innerWidth - margin) {
|
|
124
|
+
left = Math.max(margin, window.innerWidth - rect.width - margin);
|
|
125
|
+
}
|
|
126
|
+
if (rect.bottom > window.innerHeight - margin) {
|
|
127
|
+
top = Math.max(margin, window.innerHeight - rect.height - margin);
|
|
128
|
+
}
|
|
129
|
+
if (left !== contextPos.x || top !== contextPos.y) {
|
|
130
|
+
menu.style.left = `${left}px`;
|
|
131
|
+
menu.style.top = `${top}px`;
|
|
132
|
+
}
|
|
133
|
+
}, [showContext, contextPos]);
|
|
134
|
+
|
|
109
135
|
return (
|
|
110
136
|
<div className="db-tree-node" ref={nodeRef}>
|
|
111
137
|
<div
|
|
@@ -136,30 +162,46 @@ export function FileTreeNode({
|
|
|
136
162
|
onClick={(e) => e.stopPropagation()}
|
|
137
163
|
/>
|
|
138
164
|
) : (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
165
|
+
<>
|
|
166
|
+
<span className="db-tree-label">
|
|
167
|
+
{entry.name.endsWith('.md') ? entry.name.slice(0, -3) : entry.name}
|
|
168
|
+
</span>
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
className={`db-tree-more${showContext ? ' db-tree-more--active' : ''}`}
|
|
172
|
+
onClick={handleMoreClick}
|
|
173
|
+
onContextMenu={handleMoreClick}
|
|
174
|
+
aria-label="More actions"
|
|
175
|
+
aria-haspopup="menu"
|
|
176
|
+
aria-expanded={showContext}
|
|
177
|
+
tabIndex={-1}
|
|
178
|
+
>
|
|
179
|
+
<MoreIcon width={14} height={14} />
|
|
180
|
+
</button>
|
|
181
|
+
</>
|
|
142
182
|
)}
|
|
143
183
|
</div>
|
|
144
184
|
|
|
145
|
-
{/* Context menu */}
|
|
146
|
-
{showContext &&
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<button className="db-tree-context-item" onClick={handleRenameStart}>
|
|
153
|
-
Rename
|
|
154
|
-
</button>
|
|
155
|
-
<button
|
|
156
|
-
className="db-tree-context-item db-tree-context-item--danger"
|
|
157
|
-
onClick={handleDeleteClick}
|
|
185
|
+
{/* Context menu — portaled so ancestor overflow:hidden doesn't clip it */}
|
|
186
|
+
{showContext &&
|
|
187
|
+
createPortal(
|
|
188
|
+
<div
|
|
189
|
+
ref={contextRef}
|
|
190
|
+
className="db-tree-context"
|
|
191
|
+
style={{ left: contextPos.x, top: contextPos.y }}
|
|
158
192
|
>
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
193
|
+
<button className="db-tree-context-item" onClick={handleRenameStart}>
|
|
194
|
+
Rename
|
|
195
|
+
</button>
|
|
196
|
+
<button
|
|
197
|
+
className="db-tree-context-item db-tree-context-item--danger"
|
|
198
|
+
onClick={handleDeleteClick}
|
|
199
|
+
>
|
|
200
|
+
Delete
|
|
201
|
+
</button>
|
|
202
|
+
</div>,
|
|
203
|
+
document.body,
|
|
204
|
+
)}
|
|
163
205
|
|
|
164
206
|
{/* Render children if directory is expanded */}
|
|
165
207
|
{isDir && expanded && renderChildren && (
|
|
@@ -7,6 +7,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
7
7
|
import type { WorkspaceDescriptor } from '@bendyline/docblocks/workspace';
|
|
8
8
|
import { listWorkspaces, saveWorkspace, touchWorkspace } from '@bendyline/docblocks/workspace';
|
|
9
9
|
import { isNativeFileSystemSupported } from '@bendyline/docblocks/filesystem';
|
|
10
|
+
import { isElectronHost } from '@bendyline/docblocks/host';
|
|
10
11
|
|
|
11
12
|
export interface WorkspacePickerProps {
|
|
12
13
|
/** Currently active workspace id. */
|
|
@@ -41,10 +42,16 @@ export function WorkspacePicker({
|
|
|
41
42
|
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
|
42
43
|
}, [isOpen]);
|
|
43
44
|
|
|
45
|
+
const electron = isElectronHost();
|
|
46
|
+
|
|
44
47
|
const refresh = useCallback(async () => {
|
|
45
48
|
const list = await listWorkspaces();
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
// On desktop, hide web-only workspaces (indexeddb/native).
|
|
50
|
+
const filtered = electron
|
|
51
|
+
? list.filter((w) => w.type === 'electron-native')
|
|
52
|
+
: list.filter((w) => w.type !== 'electron-native');
|
|
53
|
+
setWorkspaces(filtered);
|
|
54
|
+
}, [electron]);
|
|
48
55
|
|
|
49
56
|
useEffect(() => {
|
|
50
57
|
refresh();
|
|
@@ -98,17 +105,21 @@ export function WorkspacePicker({
|
|
|
98
105
|
onClick={() => handleSelect(ws)}
|
|
99
106
|
>
|
|
100
107
|
{ws.name}
|
|
101
|
-
<span className="db-workspace-type">
|
|
108
|
+
<span className="db-workspace-type">
|
|
109
|
+
{ws.type === 'native' || ws.type === 'electron-native' ? '(folder)' : ''}
|
|
110
|
+
</span>
|
|
102
111
|
</button>
|
|
103
112
|
))}
|
|
104
113
|
|
|
105
114
|
<div className="db-workspace-dropdown-divider" />
|
|
106
115
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
{!electron && (
|
|
117
|
+
<button className="db-workspace-dropdown-item" onClick={handleCreateNew}>
|
|
118
|
+
+ New Workspace
|
|
119
|
+
</button>
|
|
120
|
+
)}
|
|
110
121
|
|
|
111
|
-
{isNativeFileSystemSupported() && (
|
|
122
|
+
{(electron || isNativeFileSystemSupported()) && (
|
|
112
123
|
<button
|
|
113
124
|
className="db-workspace-dropdown-item"
|
|
114
125
|
onClick={() => {
|
|
@@ -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"
|