@bendyline/docblocks-react 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/AppMenu/AppMenu.d.ts +11 -1
  2. package/dist/AppMenu/AppMenu.d.ts.map +1 -1
  3. package/dist/AppMenu/AppMenu.js +2 -2
  4. package/dist/AppMenu/AppMenu.js.map +1 -1
  5. package/dist/DocBlocksShell/DocBlocksShell.d.ts +21 -1
  6. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
  7. package/dist/DocBlocksShell/DocBlocksShell.js +562 -67
  8. package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
  9. package/dist/Export/ExportDialog.d.ts.map +1 -1
  10. package/dist/Export/ExportDialog.js +104 -5
  11. package/dist/Export/ExportDialog.js.map +1 -1
  12. package/dist/Export/ExportToolbarControls.d.ts.map +1 -1
  13. package/dist/Export/ExportToolbarControls.js +44 -3
  14. package/dist/Export/ExportToolbarControls.js.map +1 -1
  15. package/dist/Export/export-options.d.ts +35 -0
  16. package/dist/Export/export-options.d.ts.map +1 -1
  17. package/dist/Export/export-options.js +6 -1
  18. package/dist/Export/export-options.js.map +1 -1
  19. package/dist/Export/run-export.d.ts.map +1 -1
  20. package/dist/Export/run-export.js +163 -91
  21. package/dist/Export/run-export.js.map +1 -1
  22. package/dist/FileExplorer/FileExplorer.d.ts.map +1 -1
  23. package/dist/FileExplorer/FileExplorer.js +17 -2
  24. package/dist/FileExplorer/FileExplorer.js.map +1 -1
  25. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +2 -1
  26. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +1 -1
  27. package/dist/WorkspacePicker/WorkspaceSettingsButton.js +2 -2
  28. package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +1 -1
  29. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts +20 -0
  30. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts.map +1 -0
  31. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js +36 -0
  32. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js.map +1 -0
  33. package/dist/hooks/useAutoSave.d.ts +8 -2
  34. package/dist/hooks/useAutoSave.d.ts.map +1 -1
  35. package/dist/hooks/useAutoSave.js +65 -30
  36. package/dist/hooks/useAutoSave.js.map +1 -1
  37. package/dist/preferences/versioning.d.ts +27 -0
  38. package/dist/preferences/versioning.d.ts.map +1 -0
  39. package/dist/preferences/versioning.js +62 -0
  40. package/dist/preferences/versioning.js.map +1 -0
  41. package/package.json +10 -8
  42. package/src/AppMenu/AppMenu.tsx +64 -1
  43. package/src/DocBlocksShell/DocBlocksShell.tsx +756 -80
  44. package/src/Export/ExportDialog.tsx +208 -19
  45. package/src/Export/ExportToolbarControls.tsx +45 -3
  46. package/src/Export/export-options.ts +43 -1
  47. package/src/Export/run-export.ts +190 -91
  48. package/src/FileExplorer/FileExplorer.tsx +31 -9
  49. package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +9 -0
  50. package/src/WorkspacePicker/WorkspaceSettingsDialog.tsx +121 -0
  51. package/src/hooks/useAutoSave.ts +87 -29
  52. package/src/preferences/versioning.ts +69 -0
  53. package/src/styles/docblocks.css +920 -53
@@ -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
- const html = wrapMarkdownAsHtml(markdown);
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
- /** Wrap raw markdown in a basic styled HTML page. */
98
- function wrapMarkdownAsHtml(markdown: string): string {
99
- // Convert markdown to simple HTML via the parser
100
- const doc = parseMarkdown(markdown);
101
- // Build a minimal representation from the parsed nodes
102
- const lines: string[] = [];
103
- for (const node of doc.children) {
104
- lines.push(nodeToHtml(node));
105
- }
106
- const body = lines.join('\n');
107
- return `<!DOCTYPE html>
108
- <html lang="en">
109
- <head>
110
- <meta charset="UTF-8">
111
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
112
- <title>Document</title>
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
- /** Convert a markdown AST node to HTML (basic). */
131
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
- function nodeToHtml(node: any): string {
133
- if (!node) return '';
134
- switch (node.type) {
135
- case 'heading': {
136
- const tag = `h${node.depth ?? 1}`;
137
- return `<${tag}>${childrenToHtml(node)}</${tag}>`;
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
- case 'paragraph':
140
- return `<p>${childrenToHtml(node)}</p>`;
141
- case 'text':
142
- return escapeHtml(node.value ?? '');
143
- case 'strong':
144
- return `<strong>${childrenToHtml(node)}</strong>`;
145
- case 'emphasis':
146
- return `<em>${childrenToHtml(node)}</em>`;
147
- case 'delete':
148
- return `<del>${childrenToHtml(node)}</del>`;
149
- case 'inlineCode':
150
- return `<code>${escapeHtml(node.value ?? '')}</code>`;
151
- case 'code':
152
- return `<pre><code>${escapeHtml(node.value ?? '')}</code></pre>`;
153
- case 'blockquote':
154
- return `<blockquote>${childrenToHtml(node)}</blockquote>`;
155
- case 'list': {
156
- const tag = node.ordered ? 'ol' : 'ul';
157
- return `<${tag}>${childrenToHtml(node)}</${tag}>`;
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
- case 'listItem':
160
- return `<li>${childrenToHtml(node)}</li>`;
161
- case 'link':
162
- return `<a href="${escapeAttr(node.url ?? '')}">${childrenToHtml(node)}</a>`;
163
- case 'image':
164
- return `<img src="${escapeAttr(node.url ?? '')}" alt="${escapeAttr(node.alt ?? '')}" />`;
165
- case 'thematicBreak':
166
- return '<hr />';
167
- default:
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
173
- function childrenToHtml(node: any): string {
174
- if (!node.children) return node.value ? escapeHtml(node.value) : '';
175
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
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
- function escapeHtml(s: string): string {
180
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
- function escapeAttr(s: string): string {
184
- return escapeHtml(s).replace(/"/g, '&quot;');
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 collectImageUrls(doc: MarkdownDocument): string[] {
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 = collectImageUrls(doc);
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
- // Skip external URLs
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
- <div className="db-tree" role="tree">
250
- {tree.loading ? (
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
- ) : tree.entries.length === 0 ? (
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
- renderEntries(tree.entries, 0)
256
- )}
257
- </div>
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
+ &times;
72
+ </button>
73
+ </div>
74
+ <div className="db-dialog-body">
75
+ <p className="db-settings-hint">
76
+ <strong>{workspace.name}</strong> &middot;{' '}
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>&lt;name&gt;_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
+ }