@bendyline/docblocks-react 0.1.0
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/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/AppMenu/AppMenu.d.ts +11 -0
- package/dist/AppMenu/AppMenu.d.ts.map +1 -0
- package/dist/AppMenu/AppMenu.js +27 -0
- package/dist/AppMenu/AppMenu.js.map +1 -0
- package/dist/AppMenu/index.d.ts +3 -0
- package/dist/AppMenu/index.d.ts.map +1 -0
- package/dist/AppMenu/index.js +2 -0
- package/dist/AppMenu/index.js.map +1 -0
- package/dist/DocBlocksShell/DocBlocksShell.d.ts +16 -0
- package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -0
- package/dist/DocBlocksShell/DocBlocksShell.js +487 -0
- package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -0
- package/dist/DocBlocksShell/index.d.ts +3 -0
- package/dist/DocBlocksShell/index.d.ts.map +1 -0
- package/dist/DocBlocksShell/index.js +2 -0
- package/dist/DocBlocksShell/index.js.map +1 -0
- package/dist/Export/ExportDialog.d.ts +16 -0
- package/dist/Export/ExportDialog.d.ts.map +1 -0
- package/dist/Export/ExportDialog.js +42 -0
- package/dist/Export/ExportDialog.js.map +1 -0
- package/dist/Export/ExportToolbarControls.d.ts +17 -0
- package/dist/Export/ExportToolbarControls.d.ts.map +1 -0
- package/dist/Export/ExportToolbarControls.js +109 -0
- package/dist/Export/ExportToolbarControls.js.map +1 -0
- package/dist/Export/export-options.d.ts +18 -0
- package/dist/Export/export-options.d.ts.map +1 -0
- package/dist/Export/export-options.js +44 -0
- package/dist/Export/export-options.js.map +1 -0
- package/dist/Export/index.d.ts +6 -0
- package/dist/Export/index.d.ts.map +1 -0
- package/dist/Export/index.js +3 -0
- package/dist/Export/index.js.map +1 -0
- package/dist/Export/run-export.d.ts +8 -0
- package/dist/Export/run-export.d.ts.map +1 -0
- package/dist/Export/run-export.js +208 -0
- package/dist/Export/run-export.js.map +1 -0
- package/dist/FileExplorer/FileExplorer.d.ts +21 -0
- package/dist/FileExplorer/FileExplorer.d.ts.map +1 -0
- package/dist/FileExplorer/FileExplorer.js +125 -0
- package/dist/FileExplorer/FileExplorer.js.map +1 -0
- package/dist/FileExplorer/FileTreeNode.d.ts +18 -0
- package/dist/FileExplorer/FileTreeNode.d.ts.map +1 -0
- package/dist/FileExplorer/FileTreeNode.js +86 -0
- package/dist/FileExplorer/FileTreeNode.js.map +1 -0
- package/dist/FileExplorer/index.d.ts +6 -0
- package/dist/FileExplorer/index.d.ts.map +1 -0
- package/dist/FileExplorer/index.js +4 -0
- package/dist/FileExplorer/index.js.map +1 -0
- package/dist/FileExplorer/useFileTree.d.ts +35 -0
- package/dist/FileExplorer/useFileTree.d.ts.map +1 -0
- package/dist/FileExplorer/useFileTree.js +124 -0
- package/dist/FileExplorer/useFileTree.js.map +1 -0
- package/dist/WorkspacePicker/WorkspacePicker.d.ts +17 -0
- package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +1 -0
- package/dist/WorkspacePicker/WorkspacePicker.js +57 -0
- package/dist/WorkspacePicker/WorkspacePicker.js.map +1 -0
- package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +10 -0
- package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +1 -0
- package/dist/WorkspacePicker/WorkspaceSettingsButton.js +27 -0
- package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +1 -0
- package/dist/WorkspacePicker/index.d.ts +3 -0
- package/dist/WorkspacePicker/index.d.ts.map +1 -0
- package/dist/WorkspacePicker/index.js +2 -0
- package/dist/WorkspacePicker/index.js.map +1 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/useAutoSave.d.ts +9 -0
- package/dist/hooks/useAutoSave.d.ts.map +1 -0
- package/dist/hooks/useAutoSave.js +48 -0
- package/dist/hooks/useAutoSave.js.map +1 -0
- package/dist/icons.d.ts +6 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +24 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
- package/src/AppMenu/AppMenu.tsx +124 -0
- package/src/AppMenu/index.ts +2 -0
- package/src/DocBlocksShell/DocBlocksShell.tsx +662 -0
- package/src/DocBlocksShell/index.ts +2 -0
- package/src/Export/ExportDialog.tsx +173 -0
- package/src/Export/ExportToolbarControls.tsx +183 -0
- package/src/Export/export-options.ts +57 -0
- package/src/Export/index.ts +5 -0
- package/src/Export/run-export.ts +238 -0
- package/src/FileExplorer/FileExplorer.tsx +260 -0
- package/src/FileExplorer/FileTreeNode.tsx +172 -0
- package/src/FileExplorer/index.ts +5 -0
- package/src/FileExplorer/useFileTree.ts +178 -0
- package/src/WorkspacePicker/WorkspacePicker.tsx +126 -0
- package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +79 -0
- package/src/WorkspacePicker/index.ts +2 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useAutoSave.ts +58 -0
- package/src/icons.tsx +60 -0
- package/src/index.ts +31 -0
- package/src/styles/docblocks.css +961 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExportDialog — modal for choosing export format and options.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
6
|
+
import { getThemeSummaries } from '@bendyline/squisq/schemas';
|
|
7
|
+
import { getTransformStyleSummaries } from '@bendyline/squisq/transform';
|
|
8
|
+
import type { ExportFormat, ExportOptions } from './export-options.js';
|
|
9
|
+
import { FORMAT_LABELS, saveExportOptions } from './export-options.js';
|
|
10
|
+
|
|
11
|
+
export interface ExportDialogProps {
|
|
12
|
+
/** Initial options (pre-populated from last export). */
|
|
13
|
+
initial: ExportOptions;
|
|
14
|
+
/** Whether the export is currently running. */
|
|
15
|
+
exporting: boolean;
|
|
16
|
+
/** Called when the user confirms the export. */
|
|
17
|
+
onExport: (options: ExportOptions) => void;
|
|
18
|
+
/** Called when the dialog is dismissed. */
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FORMATS: ExportFormat[] = ['pdf', 'docx', 'pptx', 'html', 'md'];
|
|
23
|
+
|
|
24
|
+
export function ExportDialog({ initial, exporting, onExport, onClose }: ExportDialogProps) {
|
|
25
|
+
const [format, setFormat] = useState<ExportFormat>(initial.format);
|
|
26
|
+
const [themeId, setThemeId] = useState(initial.themeId);
|
|
27
|
+
const [transformStyle, setTransformStyle] = useState(initial.transformStyle);
|
|
28
|
+
const [pageSize, setPageSize] = useState(initial.pageSize);
|
|
29
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
30
|
+
|
|
31
|
+
const themes = getThemeSummaries();
|
|
32
|
+
const transforms = getTransformStyleSummaries();
|
|
33
|
+
|
|
34
|
+
const showTheme = format === 'docx' || format === 'pdf' || format === 'pptx';
|
|
35
|
+
const showTransform = format === 'pptx';
|
|
36
|
+
const showPageSize = format === 'pdf';
|
|
37
|
+
|
|
38
|
+
const handleExport = useCallback(() => {
|
|
39
|
+
const opts: ExportOptions = { format, themeId, transformStyle, pageSize };
|
|
40
|
+
saveExportOptions(opts);
|
|
41
|
+
onExport(opts);
|
|
42
|
+
}, [format, themeId, transformStyle, pageSize, onExport]);
|
|
43
|
+
|
|
44
|
+
// Close on Escape
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const onKey = (e: KeyboardEvent) => {
|
|
47
|
+
if (e.key === 'Escape') onClose();
|
|
48
|
+
};
|
|
49
|
+
window.addEventListener('keydown', onKey);
|
|
50
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
51
|
+
}, [onClose]);
|
|
52
|
+
|
|
53
|
+
// Close on backdrop click
|
|
54
|
+
const handleBackdropClick = useCallback(
|
|
55
|
+
(e: React.MouseEvent) => {
|
|
56
|
+
if (e.target === e.currentTarget) onClose();
|
|
57
|
+
},
|
|
58
|
+
[onClose],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="db-dialog-overlay" onClick={handleBackdropClick}>
|
|
63
|
+
<div className="db-dialog db-export-dialog" ref={dialogRef}>
|
|
64
|
+
<div className="db-dialog-header">
|
|
65
|
+
<h2 className="db-dialog-title">Export Document</h2>
|
|
66
|
+
<button className="db-dialog-close" onClick={onClose} aria-label="Close">
|
|
67
|
+
×
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div className="db-dialog-body">
|
|
72
|
+
{/* Format */}
|
|
73
|
+
<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"
|
|
80
|
+
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>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Theme */}
|
|
92
|
+
{showTheme && (
|
|
93
|
+
<div className="db-export-field">
|
|
94
|
+
<label className="db-export-label" htmlFor="db-export-theme">
|
|
95
|
+
Theme
|
|
96
|
+
</label>
|
|
97
|
+
<select
|
|
98
|
+
id="db-export-theme"
|
|
99
|
+
className="db-export-select"
|
|
100
|
+
value={themeId}
|
|
101
|
+
onChange={(e) => setThemeId(e.target.value)}
|
|
102
|
+
>
|
|
103
|
+
{themes.map((t) => (
|
|
104
|
+
<option key={t.id} value={t.id}>
|
|
105
|
+
{t.name}
|
|
106
|
+
</option>
|
|
107
|
+
))}
|
|
108
|
+
</select>
|
|
109
|
+
<span className="db-export-hint">
|
|
110
|
+
{themes.find((t) => t.id === themeId)?.description}
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
|
|
115
|
+
{/* Transform (PPTX only) */}
|
|
116
|
+
{showTransform && (
|
|
117
|
+
<div className="db-export-field">
|
|
118
|
+
<label className="db-export-label" htmlFor="db-export-transform">
|
|
119
|
+
Transform
|
|
120
|
+
</label>
|
|
121
|
+
<select
|
|
122
|
+
id="db-export-transform"
|
|
123
|
+
className="db-export-select"
|
|
124
|
+
value={transformStyle}
|
|
125
|
+
onChange={(e) => setTransformStyle(e.target.value)}
|
|
126
|
+
>
|
|
127
|
+
{transforms.map((t) => (
|
|
128
|
+
<option key={t.id} value={t.id}>
|
|
129
|
+
{t.name}
|
|
130
|
+
</option>
|
|
131
|
+
))}
|
|
132
|
+
</select>
|
|
133
|
+
<span className="db-export-hint">
|
|
134
|
+
{transforms.find((t) => t.id === transformStyle)?.description}
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{/* Page size (PDF only) */}
|
|
140
|
+
{showPageSize && (
|
|
141
|
+
<div className="db-export-field">
|
|
142
|
+
<label className="db-export-label" htmlFor="db-export-pagesize">
|
|
143
|
+
Page Size
|
|
144
|
+
</label>
|
|
145
|
+
<select
|
|
146
|
+
id="db-export-pagesize"
|
|
147
|
+
className="db-export-select"
|
|
148
|
+
value={pageSize}
|
|
149
|
+
onChange={(e) => setPageSize(e.target.value as 'letter' | 'a4')}
|
|
150
|
+
>
|
|
151
|
+
<option value="letter">US Letter</option>
|
|
152
|
+
<option value="a4">A4</option>
|
|
153
|
+
</select>
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div className="db-export-footer">
|
|
159
|
+
<button className="db-export-btn db-export-btn--secondary" onClick={onClose}>
|
|
160
|
+
Cancel
|
|
161
|
+
</button>
|
|
162
|
+
<button
|
|
163
|
+
className="db-export-btn db-export-btn--primary"
|
|
164
|
+
onClick={handleExport}
|
|
165
|
+
disabled={exporting}
|
|
166
|
+
>
|
|
167
|
+
{exporting ? 'Exporting...' : 'Export'}
|
|
168
|
+
</button>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExportToolbarControls — toolbar-right slot content.
|
|
3
|
+
*
|
|
4
|
+
* Renders a "..." overflow menu on the right side of the toolbar
|
|
5
|
+
* containing export actions (document export + video export).
|
|
6
|
+
*
|
|
7
|
+
* Must be rendered inside <EditorProvider> so useEditorContext() works.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
|
11
|
+
import { useEditorContext } from '@bendyline/squisq-editor-react';
|
|
12
|
+
import { getThemeSummaries } from '@bendyline/squisq/schemas';
|
|
13
|
+
import { getTransformStyleSummaries } from '@bendyline/squisq/transform';
|
|
14
|
+
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
|
+
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
19
|
+
import type { ExportOptions } from './export-options.js';
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_OPTIONS,
|
|
22
|
+
FORMAT_EXTENSIONS,
|
|
23
|
+
loadLastExportOptions,
|
|
24
|
+
saveExportOptions,
|
|
25
|
+
} from './export-options.js';
|
|
26
|
+
import { ExportDialog } from './ExportDialog.js';
|
|
27
|
+
import { runExport } from './run-export.js';
|
|
28
|
+
|
|
29
|
+
export interface ExportToolbarControlsProps {
|
|
30
|
+
/** Currently selected file path — used to derive the download filename. */
|
|
31
|
+
selectedFile: string | null;
|
|
32
|
+
/** Media container for resolving images during export. */
|
|
33
|
+
mediaContainer?: ContentContainer | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Build the quick-export label from saved options. */
|
|
37
|
+
function quickLabel(opts: ExportOptions): string {
|
|
38
|
+
const ext = FORMAT_EXTENSIONS[opts.format].toUpperCase().replace('.', '');
|
|
39
|
+
const parts: string[] = [];
|
|
40
|
+
|
|
41
|
+
if (opts.themeId !== 'standard') {
|
|
42
|
+
const theme = getThemeSummaries().find((t) => t.id === opts.themeId);
|
|
43
|
+
if (theme) parts.push(theme.name);
|
|
44
|
+
}
|
|
45
|
+
if (opts.format === 'pptx' && opts.transformStyle) {
|
|
46
|
+
const transform = getTransformStyleSummaries().find((t) => t.id === opts.transformStyle);
|
|
47
|
+
if (transform) parts.push(transform.name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (parts.length > 0) {
|
|
51
|
+
return `Export ${ext} with ${parts.join(' + ')}`;
|
|
52
|
+
}
|
|
53
|
+
return `Export ${ext}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ExportToolbarControls({
|
|
57
|
+
selectedFile,
|
|
58
|
+
mediaContainer,
|
|
59
|
+
}: ExportToolbarControlsProps) {
|
|
60
|
+
const { markdownSource } = useEditorContext();
|
|
61
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
62
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
63
|
+
const [videoModalOpen, setVideoModalOpen] = useState(false);
|
|
64
|
+
const [exporting, setExporting] = useState(false);
|
|
65
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
66
|
+
|
|
67
|
+
const lastOptions = loadLastExportOptions();
|
|
68
|
+
|
|
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]);
|
|
75
|
+
|
|
76
|
+
// Close menu on outside click
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!menuOpen) return;
|
|
79
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
80
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
|
81
|
+
setMenuOpen(false);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
85
|
+
return () => document.removeEventListener('pointerdown', onPointerDown);
|
|
86
|
+
}, [menuOpen]);
|
|
87
|
+
|
|
88
|
+
const handleToggleMenu = useCallback(() => {
|
|
89
|
+
setMenuOpen((prev) => !prev);
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const handleOpenDialog = useCallback(() => {
|
|
93
|
+
setMenuOpen(false);
|
|
94
|
+
setDialogOpen(true);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const handleCloseDialog = useCallback(() => {
|
|
98
|
+
setDialogOpen(false);
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const handleOpenVideoModal = useCallback(() => {
|
|
102
|
+
setMenuOpen(false);
|
|
103
|
+
setVideoModalOpen(true);
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const handleCloseVideoModal = useCallback(() => {
|
|
107
|
+
setVideoModalOpen(false);
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
const handleExport = useCallback(
|
|
111
|
+
async (opts: ExportOptions) => {
|
|
112
|
+
setExporting(true);
|
|
113
|
+
try {
|
|
114
|
+
await runExport(markdownSource, selectedFile, opts, mediaContainer);
|
|
115
|
+
} finally {
|
|
116
|
+
setExporting(false);
|
|
117
|
+
setDialogOpen(false);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
[markdownSource, selectedFile, mediaContainer],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const handleQuickExport = useCallback(async () => {
|
|
124
|
+
if (!lastOptions) return;
|
|
125
|
+
setMenuOpen(false);
|
|
126
|
+
setExporting(true);
|
|
127
|
+
try {
|
|
128
|
+
saveExportOptions(lastOptions);
|
|
129
|
+
await runExport(markdownSource, selectedFile, lastOptions, mediaContainer);
|
|
130
|
+
} finally {
|
|
131
|
+
setExporting(false);
|
|
132
|
+
}
|
|
133
|
+
}, [lastOptions, markdownSource, selectedFile, mediaContainer]);
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<>
|
|
137
|
+
<div className="db-toolbar-menu" ref={menuRef}>
|
|
138
|
+
<button
|
|
139
|
+
className="db-toolbar-menu-trigger"
|
|
140
|
+
onClick={handleToggleMenu}
|
|
141
|
+
aria-label="More actions"
|
|
142
|
+
title="More actions"
|
|
143
|
+
>
|
|
144
|
+
···
|
|
145
|
+
</button>
|
|
146
|
+
|
|
147
|
+
{menuOpen && (
|
|
148
|
+
<div className="db-toolbar-menu-dropdown">
|
|
149
|
+
{lastOptions && (
|
|
150
|
+
<button
|
|
151
|
+
className="db-toolbar-menu-item"
|
|
152
|
+
onClick={handleQuickExport}
|
|
153
|
+
disabled={exporting}
|
|
154
|
+
>
|
|
155
|
+
{quickLabel(lastOptions)}
|
|
156
|
+
</button>
|
|
157
|
+
)}
|
|
158
|
+
<button className="db-toolbar-menu-item" onClick={handleOpenDialog}>
|
|
159
|
+
Export...
|
|
160
|
+
</button>
|
|
161
|
+
<div className="db-toolbar-menu-divider" />
|
|
162
|
+
<button className="db-toolbar-menu-item" onClick={handleOpenVideoModal}>
|
|
163
|
+
Export Video...
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{dialogOpen && (
|
|
170
|
+
<ExportDialog
|
|
171
|
+
initial={lastOptions ?? DEFAULT_OPTIONS}
|
|
172
|
+
exporting={exporting}
|
|
173
|
+
onExport={handleExport}
|
|
174
|
+
onClose={handleCloseDialog}
|
|
175
|
+
/>
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
{videoModalOpen && doc && (
|
|
179
|
+
<VideoExportModal doc={doc} playerScript={PLAYER_BUNDLE} onClose={handleCloseVideoModal} />
|
|
180
|
+
)}
|
|
181
|
+
</>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export option types and localStorage persistence for quick-export.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type ExportFormat = 'docx' | 'pdf' | 'pptx' | 'md' | 'html';
|
|
6
|
+
|
|
7
|
+
export interface ExportOptions {
|
|
8
|
+
format: ExportFormat;
|
|
9
|
+
themeId: string;
|
|
10
|
+
/** Transform style for PPTX — controls how content is segmented and styled. */
|
|
11
|
+
transformStyle: string;
|
|
12
|
+
/** Only applies to PDF */
|
|
13
|
+
pageSize: 'letter' | 'a4';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const FORMAT_LABELS: Record<ExportFormat, string> = {
|
|
17
|
+
docx: 'Word Document (.docx)',
|
|
18
|
+
pdf: 'PDF (.pdf)',
|
|
19
|
+
pptx: 'PowerPoint (.pptx)',
|
|
20
|
+
md: 'Markdown (.md)',
|
|
21
|
+
html: 'HTML (.html)',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const FORMAT_EXTENSIONS: Record<ExportFormat, string> = {
|
|
25
|
+
docx: '.docx',
|
|
26
|
+
pdf: '.pdf',
|
|
27
|
+
pptx: '.pptx',
|
|
28
|
+
md: '.md',
|
|
29
|
+
html: '.html',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const STORAGE_KEY = 'docblocks-export-options';
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_OPTIONS: ExportOptions = {
|
|
35
|
+
format: 'pdf',
|
|
36
|
+
themeId: 'standard',
|
|
37
|
+
transformStyle: 'documentary',
|
|
38
|
+
pageSize: 'letter',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function loadLastExportOptions(): ExportOptions | null {
|
|
42
|
+
try {
|
|
43
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
44
|
+
if (!raw) return null;
|
|
45
|
+
return JSON.parse(raw) as ExportOptions;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function saveExportOptions(options: ExportOptions): void {
|
|
52
|
+
try {
|
|
53
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(options));
|
|
54
|
+
} catch {
|
|
55
|
+
// Ignore storage errors
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ExportToolbarControls } from './ExportToolbarControls.js';
|
|
2
|
+
export type { ExportToolbarControlsProps } from './ExportToolbarControls.js';
|
|
3
|
+
export { ExportDialog } from './ExportDialog.js';
|
|
4
|
+
export type { ExportDialogProps } from './ExportDialog.js';
|
|
5
|
+
export type { ExportFormat, ExportOptions } from './export-options.js';
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execute an export using @bendyline/squisq-formats.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
MarkdownDocument,
|
|
7
|
+
MarkdownBlockNode,
|
|
8
|
+
MarkdownInlineNode,
|
|
9
|
+
} from '@bendyline/squisq/markdown';
|
|
10
|
+
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
11
|
+
import { markdownToDoc } from '@bendyline/squisq/doc';
|
|
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';
|
|
16
|
+
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
17
|
+
import type { ExportOptions, ExportFormat } from './export-options.js';
|
|
18
|
+
import { FORMAT_EXTENSIONS } from './export-options.js';
|
|
19
|
+
|
|
20
|
+
const MIME_TYPES: Record<ExportFormat, string> = {
|
|
21
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
22
|
+
pdf: 'application/pdf',
|
|
23
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
24
|
+
md: 'text/markdown',
|
|
25
|
+
html: 'text/html',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Trigger a browser download for a Blob. */
|
|
29
|
+
function downloadBlob(blob: Blob, filename: string): void {
|
|
30
|
+
const url = URL.createObjectURL(blob);
|
|
31
|
+
const a = document.createElement('a');
|
|
32
|
+
a.href = url;
|
|
33
|
+
a.download = filename;
|
|
34
|
+
a.click();
|
|
35
|
+
URL.revokeObjectURL(url);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Derive a filename from the selected file path and export format. */
|
|
39
|
+
function buildFilename(selectedFile: string | null, format: ExportFormat): string {
|
|
40
|
+
const base = selectedFile ? selectedFile.replace(/^\//, '').replace(/\.[^.]+$/, '') : 'document';
|
|
41
|
+
return base + FORMAT_EXTENSIONS[format];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Run the export and trigger a download. */
|
|
45
|
+
export async function runExport(
|
|
46
|
+
markdown: string,
|
|
47
|
+
selectedFile: string | null,
|
|
48
|
+
options: ExportOptions,
|
|
49
|
+
mediaContainer?: ContentContainer | null,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const filename = buildFilename(selectedFile, options.format);
|
|
52
|
+
const themeId = options.themeId !== 'standard' ? options.themeId : undefined;
|
|
53
|
+
|
|
54
|
+
if (options.format === 'md') {
|
|
55
|
+
downloadBlob(new Blob([markdown], { type: MIME_TYPES.md }), filename);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (options.format === 'html') {
|
|
60
|
+
const html = wrapMarkdownAsHtml(markdown);
|
|
61
|
+
downloadBlob(new Blob([html], { type: MIME_TYPES.html }), filename);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const doc = parseMarkdown(markdown);
|
|
66
|
+
|
|
67
|
+
if (options.format === 'docx') {
|
|
68
|
+
const images = mediaContainer ? await resolveImages(doc, mediaContainer) : undefined;
|
|
69
|
+
const buf = await markdownDocToDocx(doc, { themeId, images });
|
|
70
|
+
downloadBlob(new Blob([buf], { type: MIME_TYPES.docx }), filename);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (options.format === 'pdf') {
|
|
75
|
+
const buf = await markdownDocToPdf(doc, {
|
|
76
|
+
themeId,
|
|
77
|
+
pageSize: options.pageSize,
|
|
78
|
+
});
|
|
79
|
+
downloadBlob(new Blob([buf], { type: MIME_TYPES.pdf }), filename);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (options.format === 'pptx') {
|
|
84
|
+
// Use the full transform pipeline: markdown → Doc → transform → PPTX
|
|
85
|
+
const baseDoc = markdownToDoc(doc);
|
|
86
|
+
const transformed = applyTransform(baseDoc, options.transformStyle);
|
|
87
|
+
const enrichedDoc = transformed.doc;
|
|
88
|
+
if (themeId) {
|
|
89
|
+
enrichedDoc.themeId = themeId;
|
|
90
|
+
}
|
|
91
|
+
const buf = await docToPptx(enrichedDoc, { themeId });
|
|
92
|
+
downloadBlob(new Blob([buf], { type: MIME_TYPES.pptx }), filename);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
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
|
+
}
|
|
129
|
+
|
|
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}>`;
|
|
138
|
+
}
|
|
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}>`;
|
|
158
|
+
}
|
|
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 ?? '');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
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
|
+
}
|
|
178
|
+
|
|
179
|
+
function escapeHtml(s: string): string {
|
|
180
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function escapeAttr(s: string): string {
|
|
184
|
+
return escapeHtml(s).replace(/"/g, '"');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const IMAGE_MIME: Record<string, string> = {
|
|
188
|
+
png: 'image/png',
|
|
189
|
+
jpg: 'image/jpeg',
|
|
190
|
+
jpeg: 'image/jpeg',
|
|
191
|
+
gif: 'image/gif',
|
|
192
|
+
webp: 'image/webp',
|
|
193
|
+
bmp: 'image/bmp',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** Walk the markdown AST and collect all image URLs. */
|
|
197
|
+
function collectImageUrls(doc: MarkdownDocument): string[] {
|
|
198
|
+
const urls: string[] = [];
|
|
199
|
+
function walkBlocks(nodes: MarkdownBlockNode[]): void {
|
|
200
|
+
for (const node of nodes) {
|
|
201
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
202
|
+
// Check if children are inline or block nodes
|
|
203
|
+
for (const child of node.children as (MarkdownBlockNode | MarkdownInlineNode)[]) {
|
|
204
|
+
if (child.type === 'image') {
|
|
205
|
+
urls.push((child as { url: string }).url);
|
|
206
|
+
} else if ('children' in child && Array.isArray(child.children)) {
|
|
207
|
+
walkBlocks(child.children as MarkdownBlockNode[]);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
walkBlocks(doc.children);
|
|
214
|
+
return urls;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Resolve image URLs to binary data from a ContentContainer. */
|
|
218
|
+
async function resolveImages(
|
|
219
|
+
doc: MarkdownDocument,
|
|
220
|
+
container: ContentContainer,
|
|
221
|
+
): Promise<Map<string, { data: ArrayBuffer | Uint8Array; contentType: string }>> {
|
|
222
|
+
const urls = collectImageUrls(doc);
|
|
223
|
+
const map = new Map<string, { data: ArrayBuffer | Uint8Array; contentType: string }>();
|
|
224
|
+
|
|
225
|
+
for (const url of urls) {
|
|
226
|
+
if (map.has(url)) continue;
|
|
227
|
+
// Skip external URLs
|
|
228
|
+
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:'))
|
|
229
|
+
continue;
|
|
230
|
+
const data = await container.readFile(url);
|
|
231
|
+
if (!data) continue;
|
|
232
|
+
const ext = url.slice(url.lastIndexOf('.') + 1).toLowerCase();
|
|
233
|
+
const contentType = IMAGE_MIME[ext] || 'image/png';
|
|
234
|
+
map.set(url, { data, contentType });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return map;
|
|
238
|
+
}
|