@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.
Files changed (64) hide show
  1. package/README.md +23 -14
  2. package/dist/AppMenu/AppMenu.d.ts +11 -1
  3. package/dist/AppMenu/AppMenu.d.ts.map +1 -1
  4. package/dist/AppMenu/AppMenu.js +2 -2
  5. package/dist/AppMenu/AppMenu.js.map +1 -1
  6. package/dist/DocBlocksShell/DocBlocksShell.d.ts +23 -3
  7. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
  8. package/dist/DocBlocksShell/DocBlocksShell.js +621 -94
  9. package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
  10. package/dist/Export/ExportDialog.d.ts.map +1 -1
  11. package/dist/Export/ExportDialog.js +123 -7
  12. package/dist/Export/ExportDialog.js.map +1 -1
  13. package/dist/Export/ExportToolbarControls.d.ts.map +1 -1
  14. package/dist/Export/ExportToolbarControls.js +108 -17
  15. package/dist/Export/ExportToolbarControls.js.map +1 -1
  16. package/dist/Export/export-options.d.ts +35 -0
  17. package/dist/Export/export-options.d.ts.map +1 -1
  18. package/dist/Export/export-options.js +6 -1
  19. package/dist/Export/export-options.js.map +1 -1
  20. package/dist/Export/run-export.d.ts.map +1 -1
  21. package/dist/Export/run-export.js +185 -97
  22. package/dist/Export/run-export.js.map +1 -1
  23. package/dist/Export/transform-summaries.d.ts +7 -0
  24. package/dist/Export/transform-summaries.d.ts.map +1 -0
  25. package/dist/Export/transform-summaries.js +6 -0
  26. package/dist/Export/transform-summaries.js.map +1 -0
  27. package/dist/FileExplorer/FileExplorer.d.ts.map +1 -1
  28. package/dist/FileExplorer/FileExplorer.js +17 -2
  29. package/dist/FileExplorer/FileExplorer.js.map +1 -1
  30. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +2 -1
  31. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +1 -1
  32. package/dist/WorkspacePicker/WorkspaceSettingsButton.js +2 -2
  33. package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +1 -1
  34. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts +20 -0
  35. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts.map +1 -0
  36. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js +36 -0
  37. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js.map +1 -0
  38. package/dist/hooks/useAutoSave.d.ts +8 -2
  39. package/dist/hooks/useAutoSave.d.ts.map +1 -1
  40. package/dist/hooks/useAutoSave.js +65 -30
  41. package/dist/hooks/useAutoSave.js.map +1 -1
  42. package/dist/monaco-slim.d.ts +19 -0
  43. package/dist/monaco-slim.d.ts.map +1 -0
  44. package/dist/monaco-slim.js +19 -0
  45. package/dist/monaco-slim.js.map +1 -0
  46. package/dist/preferences/versioning.d.ts +27 -0
  47. package/dist/preferences/versioning.d.ts.map +1 -0
  48. package/dist/preferences/versioning.js +62 -0
  49. package/dist/preferences/versioning.js.map +1 -0
  50. package/package.json +14 -10
  51. package/src/AppMenu/AppMenu.tsx +64 -1
  52. package/src/DocBlocksShell/DocBlocksShell.tsx +839 -116
  53. package/src/Export/ExportDialog.tsx +236 -26
  54. package/src/Export/ExportToolbarControls.tsx +151 -21
  55. package/src/Export/export-options.ts +43 -1
  56. package/src/Export/run-export.ts +208 -97
  57. package/src/Export/transform-summaries.ts +14 -0
  58. package/src/FileExplorer/FileExplorer.tsx +31 -9
  59. package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +9 -0
  60. package/src/WorkspacePicker/WorkspaceSettingsDialog.tsx +121 -0
  61. package/src/hooks/useAutoSave.ts +87 -29
  62. package/src/monaco-slim.ts +20 -0
  63. package/src/preferences/versioning.ts +69 -0
  64. package/src/styles/docblocks.css +995 -53
@@ -4,9 +4,9 @@
4
4
 
5
5
  import { useState, useCallback, useEffect, useRef } from 'react';
6
6
  import { getThemeSummaries } from '@bendyline/squisq/schemas';
7
- import { getTransformStyleSummaries } from '@bendyline/squisq/transform';
8
- import type { ExportFormat, ExportOptions } from './export-options.js';
7
+ import type { ExportFormat, ExportOptions, HtmlBundle, HtmlStyle } from './export-options.js';
9
8
  import { FORMAT_LABELS, saveExportOptions } from './export-options.js';
9
+ import { loadTransformStyleSummaries, type ExportSummaryOption } from './transform-summaries.js';
10
10
 
11
11
  export interface ExportDialogProps {
12
12
  /** Initial options (pre-populated from last export). */
@@ -21,25 +21,167 @@ export interface ExportDialogProps {
21
21
 
22
22
  const FORMATS: ExportFormat[] = ['pdf', 'docx', 'pptx', 'html', 'md'];
23
23
 
24
+ /** Short labels for the Format chip row. The fuller `FORMAT_LABELS` list
25
+ * is still used as a tooltip so users can confirm the file extension. */
26
+ const FORMAT_CHIP_LABELS: Record<ExportFormat, string> = {
27
+ pdf: 'PDF',
28
+ docx: 'Word',
29
+ pptx: 'PowerPoint',
30
+ html: 'HTML',
31
+ md: 'Markdown',
32
+ };
33
+
34
+ const HTML_STYLE_CHIPS: { key: HtmlStyle; label: string; hint: string }[] = [
35
+ {
36
+ key: 'plain',
37
+ label: 'Plain',
38
+ hint: 'A lightweight static HTML document.',
39
+ },
40
+ {
41
+ key: 'rendered',
42
+ label: 'Rendered',
43
+ hint: 'Renders via SquisqPlayer with themes and playback support.',
44
+ },
45
+ ];
46
+
47
+ const HTML_BUNDLE_CHIPS: { key: HtmlBundle; label: string; hint: string }[] = [
48
+ {
49
+ key: 'single',
50
+ label: 'Single file',
51
+ hint: 'One .html file with images embedded as base64 data URIs.',
52
+ },
53
+ {
54
+ key: 'zip',
55
+ label: 'ZIP archive',
56
+ hint: 'A .zip with index.html plus separate image (and JS) files.',
57
+ },
58
+ ];
59
+
60
+ interface ChipOption<T extends string> {
61
+ key: T;
62
+ label: string;
63
+ title?: string;
64
+ }
65
+
66
+ function ChipRadioGroup<T extends string>({
67
+ name,
68
+ value,
69
+ options,
70
+ onChange,
71
+ }: {
72
+ name: string;
73
+ value: T;
74
+ options: ChipOption<T>[];
75
+ onChange: (next: T) => void;
76
+ }) {
77
+ return (
78
+ <div className="db-export-chips" role="radiogroup" aria-label={name}>
79
+ {options.map((opt) => {
80
+ const active = opt.key === value;
81
+ return (
82
+ <button
83
+ key={opt.key}
84
+ type="button"
85
+ role="radio"
86
+ aria-checked={active}
87
+ className={`db-export-chip${active ? ' db-export-chip--active' : ''}`}
88
+ onClick={() => onChange(opt.key)}
89
+ title={opt.title}
90
+ >
91
+ {opt.label}
92
+ </button>
93
+ );
94
+ })}
95
+ </div>
96
+ );
97
+ }
98
+
24
99
  export function ExportDialog({ initial, exporting, onExport, onClose }: ExportDialogProps) {
25
100
  const [format, setFormat] = useState<ExportFormat>(initial.format);
26
101
  const [themeId, setThemeId] = useState(initial.themeId);
27
102
  const [transformStyle, setTransformStyle] = useState(initial.transformStyle);
28
103
  const [pageSize, setPageSize] = useState(initial.pageSize);
104
+ const [htmlStyle, setHtmlStyle] = useState<HtmlStyle>(initial.htmlStyle);
105
+ const [htmlBundle, setHtmlBundle] = useState<HtmlBundle>(initial.htmlBundle);
106
+ const [includeLinkedDocs, setIncludeLinkedDocs] = useState<boolean>(initial.includeLinkedDocs);
107
+ const [entryAsIndex, setEntryAsIndex] = useState<boolean>(initial.entryAsIndex);
108
+ const [transforms, setTransforms] = useState<ExportSummaryOption[]>([]);
29
109
  const dialogRef = useRef<HTMLDivElement>(null);
30
110
 
31
111
  const themes = getThemeSummaries();
32
- const transforms = getTransformStyleSummaries();
33
112
 
34
- const showTheme = format === 'docx' || format === 'pdf' || format === 'pptx';
113
+ // Both HTML styles honor themes now: rendered HTML through the
114
+ // SquisqPlayer's theme system, plain HTML through squisq's
115
+ // `markdownDocToPlainHtml` which emits theme-driven CSS variables
116
+ // and Google Fonts links. Show the dropdown for any export format
117
+ // whose pipeline actually applies the picked theme.
118
+ const showTheme = format === 'docx' || format === 'pdf' || format === 'pptx' || format === 'html';
35
119
  const showTransform = format === 'pptx';
36
120
  const showPageSize = format === 'pdf';
121
+ const showHtmlOptions = format === 'html';
122
+ // Both HTML styles now have a recursive bundle: plain via
123
+ // `markdownDocsToPlainHtmlBundle`, rendered via
124
+ // `markdownDocsToHtmlBundle` (which rewrites Doc-tree links to
125
+ // `.html` before SquisqPlayer serialization). So the option applies
126
+ // to any HTML export.
127
+ const showIncludeLinked = format === 'html';
128
+ // Multi-doc output is inherently a directory tree, so when recursion
129
+ // is on the Bundle row would only confuse — ZIP is the only valid
130
+ // packaging. Hide the chip row and surface the implication in a hint.
131
+ const showHtmlBundle = showHtmlOptions && !includeLinkedDocs;
132
+ // `entryAsIndex` is only honored in pipelines where we control the
133
+ // output filename ourselves:
134
+ // • single-file HTML downloads → renames `<doc>.html` → `index.html`
135
+ // • recursive bundles → passed through to squisq's bundle helpers
136
+ // In the single-doc ZIP path, squisq's `docToHtmlZip` and our plain-
137
+ // HTML zip writer always emit `index.html` inside the archive, so the
138
+ // checkbox has no observable effect — hide it to avoid surfacing an
139
+ // inert control.
140
+ const showEntryAsIndex = showHtmlOptions && (htmlBundle === 'single' || includeLinkedDocs);
141
+
142
+ useEffect(() => {
143
+ if (!showTransform) return;
144
+ let cancelled = false;
145
+
146
+ loadTransformStyleSummaries()
147
+ .then((nextTransforms) => {
148
+ if (!cancelled) setTransforms(nextTransforms);
149
+ })
150
+ .catch(() => {
151
+ if (!cancelled) setTransforms([]);
152
+ });
153
+
154
+ return () => {
155
+ cancelled = true;
156
+ };
157
+ }, [showTransform]);
37
158
 
38
159
  const handleExport = useCallback(() => {
39
- const opts: ExportOptions = { format, themeId, transformStyle, pageSize };
160
+ const opts: ExportOptions = {
161
+ format,
162
+ themeId,
163
+ transformStyle,
164
+ pageSize,
165
+ htmlStyle,
166
+ htmlBundle,
167
+ includeLinkedDocs: showIncludeLinked && includeLinkedDocs,
168
+ entryAsIndex: showEntryAsIndex && entryAsIndex,
169
+ };
40
170
  saveExportOptions(opts);
41
171
  onExport(opts);
42
- }, [format, themeId, transformStyle, pageSize, onExport]);
172
+ }, [
173
+ format,
174
+ themeId,
175
+ transformStyle,
176
+ pageSize,
177
+ htmlStyle,
178
+ htmlBundle,
179
+ includeLinkedDocs,
180
+ entryAsIndex,
181
+ showIncludeLinked,
182
+ showEntryAsIndex,
183
+ onExport,
184
+ ]);
43
185
 
44
186
  // Close on Escape
45
187
  useEffect(() => {
@@ -69,25 +211,89 @@ export function ExportDialog({ initial, exporting, onExport, onClose }: ExportDi
69
211
  </div>
70
212
 
71
213
  <div className="db-dialog-body">
72
- {/* Format */}
214
+ {/* Format — chip radio row for fast switching */}
73
215
  <div className="db-export-field">
74
- <label className="db-export-label" htmlFor="db-export-format">
75
- Format
76
- </label>
77
- <select
78
- id="db-export-format"
79
- className="db-export-select"
216
+ <span className="db-export-label">Format</span>
217
+ <ChipRadioGroup
218
+ name="Format"
80
219
  value={format}
81
- onChange={(e) => setFormat(e.target.value as ExportFormat)}
82
- >
83
- {FORMATS.map((f) => (
84
- <option key={f} value={f}>
85
- {FORMAT_LABELS[f]}
86
- </option>
87
- ))}
88
- </select>
220
+ options={FORMATS.map((f) => ({
221
+ key: f,
222
+ label: FORMAT_CHIP_LABELS[f],
223
+ title: FORMAT_LABELS[f],
224
+ }))}
225
+ onChange={setFormat}
226
+ />
89
227
  </div>
90
228
 
229
+ {/* HTML style + bundle (HTML only) — also chip rows */}
230
+ {showHtmlOptions && (
231
+ <>
232
+ <div className="db-export-field">
233
+ <span className="db-export-label">Style</span>
234
+ <ChipRadioGroup
235
+ name="Style"
236
+ value={htmlStyle}
237
+ options={HTML_STYLE_CHIPS}
238
+ onChange={setHtmlStyle}
239
+ />
240
+ <span className="db-export-hint">
241
+ {HTML_STYLE_CHIPS.find((c) => c.key === htmlStyle)?.hint}
242
+ </span>
243
+ </div>
244
+ {showHtmlBundle && (
245
+ <div className="db-export-field">
246
+ <span className="db-export-label">Bundle</span>
247
+ <ChipRadioGroup
248
+ name="Bundle"
249
+ value={htmlBundle}
250
+ options={HTML_BUNDLE_CHIPS}
251
+ onChange={setHtmlBundle}
252
+ />
253
+ <span className="db-export-hint">
254
+ {HTML_BUNDLE_CHIPS.find((c) => c.key === htmlBundle)?.hint}
255
+ </span>
256
+ </div>
257
+ )}
258
+ {showIncludeLinked && (
259
+ <div className="db-export-field">
260
+ <label className="db-export-checkbox">
261
+ <input
262
+ type="checkbox"
263
+ checked={includeLinkedDocs}
264
+ onChange={(e) => setIncludeLinkedDocs(e.target.checked)}
265
+ />
266
+ <span>Include linked-to documents</span>
267
+ </label>
268
+ <span className="db-export-hint">
269
+ {includeLinkedDocs
270
+ ? 'Follows relative .md links from this page and bundles every reachable document as its own .html in a ZIP. Cross-doc links rewrite from .md to .html.'
271
+ : 'Only this document is exported.'}
272
+ </span>
273
+ </div>
274
+ )}
275
+ {showEntryAsIndex && (
276
+ <div className="db-export-field">
277
+ <label className="db-export-checkbox">
278
+ <input
279
+ type="checkbox"
280
+ checked={entryAsIndex}
281
+ onChange={(e) => setEntryAsIndex(e.target.checked)}
282
+ />
283
+ <span>Name entry page index.html</span>
284
+ </label>
285
+ <span className="db-export-hint">
286
+ {entryAsIndex
287
+ ? includeLinkedDocs
288
+ ? 'Renames this page to index.html in the ZIP and rewrites any cross-doc links pointing back at it. Drops straight into a static-site host.'
289
+ : 'Downloads as index.html instead of the document name. Drops straight into a static-site host.'
290
+ : 'Exports under the document name.'}
291
+ </span>
292
+ </div>
293
+ )}
294
+ </>
295
+ )}
296
+
91
297
  {/* Theme */}
92
298
  {showTheme && (
93
299
  <div className="db-export-field">
@@ -124,11 +330,15 @@ export function ExportDialog({ initial, exporting, onExport, onClose }: ExportDi
124
330
  value={transformStyle}
125
331
  onChange={(e) => setTransformStyle(e.target.value)}
126
332
  >
127
- {transforms.map((t) => (
128
- <option key={t.id} value={t.id}>
129
- {t.name}
130
- </option>
131
- ))}
333
+ {transforms.length === 0 ? (
334
+ <option value={transformStyle}>Loading...</option>
335
+ ) : (
336
+ transforms.map((t) => (
337
+ <option key={t.id} value={t.id}>
338
+ {t.name}
339
+ </option>
340
+ ))
341
+ )}
132
342
  </select>
133
343
  <span className="db-export-hint">
134
344
  {transforms.find((t) => t.id === transformStyle)?.description}
@@ -7,15 +7,12 @@
7
7
  * Must be rendered inside <EditorProvider> so useEditorContext() works.
8
8
  */
9
9
 
10
- import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
10
+ import { useState, useCallback, useEffect, useRef, useMemo, type ComponentType } from 'react';
11
11
  import { useEditorContext } from '@bendyline/squisq-editor-react';
12
12
  import { getThemeSummaries } from '@bendyline/squisq/schemas';
13
- import { getTransformStyleSummaries } from '@bendyline/squisq/transform';
14
13
  import { parseMarkdown } from '@bendyline/squisq/markdown';
15
- import { markdownToDoc } from '@bendyline/squisq/doc';
16
- import { VideoExportModal } from '@bendyline/squisq-video-react';
17
- import { PLAYER_BUNDLE } from '@bendyline/squisq-react/standalone-source';
18
14
  import type { ContentContainer } from '@bendyline/squisq/storage';
15
+ import type { VideoExportModalProps } from '@bendyline/squisq-video-react';
19
16
  import type { ExportOptions } from './export-options.js';
20
17
  import {
21
18
  DEFAULT_OPTIONS,
@@ -25,6 +22,7 @@ import {
25
22
  } from './export-options.js';
26
23
  import { ExportDialog } from './ExportDialog.js';
27
24
  import { runExport } from './run-export.js';
25
+ import { loadTransformStyleSummaries, type ExportSummaryOption } from './transform-summaries.js';
28
26
 
29
27
  export interface ExportToolbarControlsProps {
30
28
  /** Currently selected file path — used to derive the download filename. */
@@ -33,17 +31,55 @@ export interface ExportToolbarControlsProps {
33
31
  mediaContainer?: ContentContainer | null;
34
32
  }
35
33
 
34
+ type ParsedMarkdown = ReturnType<typeof parseMarkdown>;
35
+
36
+ interface VideoExportModules {
37
+ Modal: ComponentType<VideoExportModalProps>;
38
+ markdownToDoc: (doc: ParsedMarkdown) => VideoExportModalProps['doc'];
39
+ playerScript: string;
40
+ }
41
+
42
+ let videoExportModulesPromise: Promise<VideoExportModules> | null = null;
43
+
44
+ function loadVideoExportModules(): Promise<VideoExportModules> {
45
+ videoExportModulesPromise ??= Promise.all([
46
+ import('@bendyline/squisq/doc'),
47
+ import('@bendyline/squisq-video-react'),
48
+ import('@bendyline/squisq-react/standalone-source'),
49
+ ]).then(([docModule, videoModule, playerModule]) => ({
50
+ Modal: videoModule.VideoExportModal,
51
+ markdownToDoc: docModule.markdownToDoc,
52
+ playerScript: playerModule.PLAYER_BUNDLE,
53
+ }));
54
+ return videoExportModulesPromise;
55
+ }
56
+
36
57
  /** Build the quick-export label from saved options. */
37
- function quickLabel(opts: ExportOptions): string {
38
- const ext = FORMAT_EXTENSIONS[opts.format].toUpperCase().replace('.', '');
58
+ function quickLabel(opts: ExportOptions, transformSummaries: ExportSummaryOption[]): string {
59
+ const baseExt = FORMAT_EXTENSIONS[opts.format].toUpperCase().replace('.', '');
60
+ // Recursive HTML always emits a ZIP (multi-doc tree), regardless of
61
+ // the saved `htmlBundle` value. Applies to both plain and rendered
62
+ // styles since both now ship recursive bundle helpers.
63
+ const isRecursiveHtml = opts.format === 'html' && opts.includeLinkedDocs;
64
+ const ext =
65
+ opts.format === 'html' && (opts.htmlBundle === 'zip' || isRecursiveHtml) ? 'ZIP' : baseExt;
39
66
  const parts: string[] = [];
40
67
 
68
+ if (opts.format === 'html' && opts.htmlStyle === 'rendered') {
69
+ parts.push('rendered');
70
+ }
71
+ if (isRecursiveHtml) {
72
+ parts.push('linked docs');
73
+ }
74
+ // Plain HTML also applies themes now (squisq's `markdownDocToPlainHtml`
75
+ // emits theme-driven CSS), so include the theme name in the quick-
76
+ // export label for every HTML style — not just rendered.
41
77
  if (opts.themeId !== 'standard') {
42
78
  const theme = getThemeSummaries().find((t) => t.id === opts.themeId);
43
79
  if (theme) parts.push(theme.name);
44
80
  }
45
81
  if (opts.format === 'pptx' && opts.transformStyle) {
46
- const transform = getTransformStyleSummaries().find((t) => t.id === opts.transformStyle);
82
+ const transform = transformSummaries.find((t) => t.id === opts.transformStyle);
47
83
  if (transform) parts.push(transform.name);
48
84
  }
49
85
 
@@ -57,21 +93,46 @@ export function ExportToolbarControls({
57
93
  selectedFile,
58
94
  mediaContainer,
59
95
  }: ExportToolbarControlsProps) {
60
- const { markdownSource } = useEditorContext();
96
+ const { markdownSource, markdownDoc } = useEditorContext();
61
97
  const [menuOpen, setMenuOpen] = useState(false);
62
98
  const [dialogOpen, setDialogOpen] = useState(false);
63
99
  const [videoModalOpen, setVideoModalOpen] = useState(false);
100
+ const [videoLoading, setVideoLoading] = useState(false);
101
+ const [videoLoadError, setVideoLoadError] = useState<string | null>(null);
102
+ const [videoModules, setVideoModules] = useState<VideoExportModules | null>(null);
103
+ const [videoDoc, setVideoDoc] = useState<VideoExportModalProps['doc'] | null>(null);
104
+ const [transformSummaries, setTransformSummaries] = useState<ExportSummaryOption[]>([]);
64
105
  const [exporting, setExporting] = useState(false);
65
106
  const menuRef = useRef<HTMLDivElement>(null);
66
107
 
67
108
  const lastOptions = loadLastExportOptions();
68
109
 
69
- /** Build a Doc from the current markdown for video export. */
70
- const doc = useMemo(() => {
71
- if (!videoModalOpen) return null;
72
- const mdDoc = parseMarkdown(markdownSource);
73
- return markdownToDoc(mdDoc);
74
- }, [videoModalOpen, markdownSource]);
110
+ /** Doc's currently-set squisq theme, pulled from the markdown
111
+ * frontmatter. The Document Settings dialog and Theme Customizer
112
+ * write the theme under `squisq-theme` (canonical) or `theme`
113
+ * (legacy); some older docs persist it as `themeId`. We honor all
114
+ * three so an author who set their theme anywhere upstream sees it
115
+ * pre-selected in the export dialog. */
116
+ const docThemeId = useMemo(() => {
117
+ const fm = markdownDoc?.frontmatter as Record<string, unknown> | undefined;
118
+ if (!fm) return null;
119
+ const candidates = [fm['squisq-theme'], fm['theme'], fm['themeId']];
120
+ for (const v of candidates) {
121
+ if (typeof v === 'string' && v.trim()) return v.trim();
122
+ }
123
+ return null;
124
+ }, [markdownDoc]);
125
+
126
+ /** Options used to populate the export dialog. Layered: built-in
127
+ * defaults → user's last-chosen export options (if any) → doc's
128
+ * current frontmatter theme (wins). The frontmatter override
129
+ * guarantees that "set theme in the editor, then export" pre-selects
130
+ * the right theme instead of resurrecting whatever the user picked
131
+ * for some unrelated previous doc. */
132
+ const dialogInitial = useMemo(() => {
133
+ const base = lastOptions ?? DEFAULT_OPTIONS;
134
+ return docThemeId ? { ...base, themeId: docThemeId } : base;
135
+ }, [lastOptions, docThemeId]);
75
136
 
76
137
  // Close menu on outside click
77
138
  useEffect(() => {
@@ -85,6 +146,30 @@ export function ExportToolbarControls({
85
146
  return () => document.removeEventListener('pointerdown', onPointerDown);
86
147
  }, [menuOpen]);
87
148
 
149
+ useEffect(() => {
150
+ if (
151
+ !menuOpen ||
152
+ lastOptions?.format !== 'pptx' ||
153
+ !lastOptions.transformStyle ||
154
+ transformSummaries.length > 0
155
+ ) {
156
+ return;
157
+ }
158
+
159
+ let cancelled = false;
160
+ loadTransformStyleSummaries()
161
+ .then((nextSummaries) => {
162
+ if (!cancelled) setTransformSummaries(nextSummaries);
163
+ })
164
+ .catch(() => {
165
+ if (!cancelled) setTransformSummaries([]);
166
+ });
167
+
168
+ return () => {
169
+ cancelled = true;
170
+ };
171
+ }, [lastOptions?.format, lastOptions?.transformStyle, menuOpen, transformSummaries.length]);
172
+
88
173
  const handleToggleMenu = useCallback(() => {
89
174
  setMenuOpen((prev) => !prev);
90
175
  }, []);
@@ -98,13 +183,27 @@ export function ExportToolbarControls({
98
183
  setDialogOpen(false);
99
184
  }, []);
100
185
 
101
- const handleOpenVideoModal = useCallback(() => {
186
+ const handleOpenVideoModal = useCallback(async () => {
102
187
  setMenuOpen(false);
103
188
  setVideoModalOpen(true);
104
- }, []);
189
+ setVideoLoading(true);
190
+ setVideoLoadError(null);
191
+
192
+ try {
193
+ const modules = videoModules ?? (await loadVideoExportModules());
194
+ setVideoModules(modules);
195
+ setVideoDoc(modules.markdownToDoc(parseMarkdown(markdownSource)));
196
+ } catch {
197
+ setVideoLoadError('Video export could not be loaded.');
198
+ } finally {
199
+ setVideoLoading(false);
200
+ }
201
+ }, [markdownSource, videoModules]);
105
202
 
106
203
  const handleCloseVideoModal = useCallback(() => {
107
204
  setVideoModalOpen(false);
205
+ setVideoDoc(null);
206
+ setVideoLoadError(null);
108
207
  }, []);
109
208
 
110
209
  const handleExport = useCallback(
@@ -132,6 +231,8 @@ export function ExportToolbarControls({
132
231
  }
133
232
  }, [lastOptions, markdownSource, selectedFile, mediaContainer]);
134
233
 
234
+ const LoadedVideoExportModal = videoModules?.Modal;
235
+
135
236
  return (
136
237
  <>
137
238
  <div className="db-toolbar-menu" ref={menuRef}>
@@ -152,7 +253,7 @@ export function ExportToolbarControls({
152
253
  onClick={handleQuickExport}
153
254
  disabled={exporting}
154
255
  >
155
- {quickLabel(lastOptions)}
256
+ {quickLabel(lastOptions, transformSummaries)}
156
257
  </button>
157
258
  )}
158
259
  <button className="db-toolbar-menu-item" onClick={handleOpenDialog}>
@@ -168,16 +269,45 @@ export function ExportToolbarControls({
168
269
 
169
270
  {dialogOpen && (
170
271
  <ExportDialog
171
- initial={lastOptions ?? DEFAULT_OPTIONS}
272
+ initial={dialogInitial}
172
273
  exporting={exporting}
173
274
  onExport={handleExport}
174
275
  onClose={handleCloseDialog}
175
276
  />
176
277
  )}
177
278
 
178
- {videoModalOpen && doc && (
179
- <VideoExportModal doc={doc} playerScript={PLAYER_BUNDLE} onClose={handleCloseVideoModal} />
279
+ {videoModalOpen && (videoLoading || videoLoadError) && (
280
+ <div className="db-dialog-overlay">
281
+ <div className="db-dialog">
282
+ <div className="db-dialog-header">
283
+ <h2 className="db-dialog-title">Export Video</h2>
284
+ <button
285
+ className="db-dialog-close"
286
+ onClick={handleCloseVideoModal}
287
+ aria-label="Close"
288
+ >
289
+ &times;
290
+ </button>
291
+ </div>
292
+ <div className="db-dialog-body">
293
+ <p className="db-export-hint">{videoLoadError ?? 'Loading...'}</p>
294
+ </div>
295
+ </div>
296
+ </div>
180
297
  )}
298
+
299
+ {videoModalOpen &&
300
+ videoModules &&
301
+ LoadedVideoExportModal &&
302
+ videoDoc &&
303
+ !videoLoading &&
304
+ !videoLoadError && (
305
+ <LoadedVideoExportModal
306
+ doc={videoDoc}
307
+ playerScript={videoModules.playerScript}
308
+ onClose={handleCloseVideoModal}
309
+ />
310
+ )}
181
311
  </>
182
312
  );
183
313
  }
@@ -4,6 +4,20 @@
4
4
 
5
5
  export type ExportFormat = 'docx' | 'pdf' | 'pptx' | 'md' | 'html';
6
6
 
7
+ /** Visual style for HTML export. */
8
+ export type HtmlStyle =
9
+ /** Render via SquisqPlayer (themed, embeds player bundle). */
10
+ | 'rendered'
11
+ /** Plain semantic HTML — small, no JS, no playback. */
12
+ | 'plain';
13
+
14
+ /** Packaging for HTML export. */
15
+ export type HtmlBundle =
16
+ /** Single self-contained .html file (images base64-embedded). */
17
+ | 'single'
18
+ /** ZIP containing index.html + asset files. */
19
+ | 'zip';
20
+
7
21
  export interface ExportOptions {
8
22
  format: ExportFormat;
9
23
  themeId: string;
@@ -11,6 +25,29 @@ export interface ExportOptions {
11
25
  transformStyle: string;
12
26
  /** Only applies to PDF */
13
27
  pageSize: 'letter' | 'a4';
28
+ /** HTML rendering style. Only applies to format=html. */
29
+ htmlStyle: HtmlStyle;
30
+ /** HTML bundle layout. Only applies to format=html. */
31
+ htmlBundle: HtmlBundle;
32
+ /**
33
+ * Recursive HTML bundle: walk relative `.md` links from the entry
34
+ * document, render every reachable sibling/child page, and rewrite
35
+ * cross-doc links from `.md` → `.html`. The output is a ZIP
36
+ * regardless of `htmlBundle` because multi-doc output needs a
37
+ * directory tree. Supported for both plain (`markdownDocsToPlainHtmlBundle`)
38
+ * and rendered (`markdownDocsToHtmlBundle`) HTML styles. Only
39
+ * meaningful when `format === 'html'`.
40
+ */
41
+ includeLinkedDocs: boolean;
42
+ /**
43
+ * Rename the entry page from `<basename>.html` to `index.html` so the
44
+ * export drops straight into a static-site host that expects an
45
+ * `index.html` landing page. For recursive bundles, cross-doc links
46
+ * targeting the entry also rewrite to `index.html` so siblings don't
47
+ * 404. For a single-doc HTML download, this just renames the file.
48
+ * Only meaningful when `format === 'html'`.
49
+ */
50
+ entryAsIndex: boolean;
14
51
  }
15
52
 
16
53
  export const FORMAT_LABELS: Record<ExportFormat, string> = {
@@ -36,13 +73,18 @@ export const DEFAULT_OPTIONS: ExportOptions = {
36
73
  themeId: 'standard',
37
74
  transformStyle: 'documentary',
38
75
  pageSize: 'letter',
76
+ htmlStyle: 'plain',
77
+ htmlBundle: 'single',
78
+ includeLinkedDocs: false,
79
+ entryAsIndex: false,
39
80
  };
40
81
 
41
82
  export function loadLastExportOptions(): ExportOptions | null {
42
83
  try {
43
84
  const raw = localStorage.getItem(STORAGE_KEY);
44
85
  if (!raw) return null;
45
- return JSON.parse(raw) as ExportOptions;
86
+ const parsed = JSON.parse(raw) as Partial<ExportOptions>;
87
+ return { ...DEFAULT_OPTIONS, ...parsed };
46
88
  } catch {
47
89
  return null;
48
90
  }