Skip to content

Commit 4cdf68d

Browse files
committed
Add export with images (file/inline) and export modal
Fixes #3
1 parent 3f6770b commit 4cdf68d

7 files changed

Lines changed: 398 additions & 62 deletions

File tree

ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"es-toolkit": "^1.39.7",
3030
"gray-matter": "^4.0.3",
3131
"js-yaml": "^4.1.0",
32+
"jszip": "^3.10.1",
3233
"mammoth": "^1.9.1",
3334
"markdown-it": "^14.1.0",
3435
"markdown-it-anchor": "^9.2.0",
@@ -44,6 +45,7 @@
4445
"@rushstack/eslint-patch": "^1.12.0",
4546
"@tsconfig/node20": "^20.1.6",
4647
"@types/js-yaml": "^4.0.9",
48+
"@types/jszip": "^3.4.1",
4749
"@types/markdown-it": "^14.1.2",
4850
"@types/node": "^20.19.8",
4951
"@types/turndown": "^5.0.5",

ui/pnpm-lock.yaml

Lines changed: 29 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/src/class/contentExporter.ts

Lines changed: 151 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,131 @@
1-
import { ContentTypes } from '@/constants';
2-
import { downloadContent } from '@/utils';
1+
import { downloadContent, downloadContentWithImages } from '@/utils';
32
import { processHTMLLinks } from '@/utils/content';
3+
import { downloadImageAsBlob, extractImageReferencesFromContent } from '@/utils/image';
44
import { consoleApiClient, type Post } from '@halo-dev/api-client';
55
import { Toast } from '@halo-dev/components';
66
import { ConverterFactory } from './converterFactory';
77

8-
type ExportType = 'original' | 'markdown';
8+
export type ExportType = 'markdown' | 'html' | 'pdf';
9+
export type ImageExportMode = 'file' | 'inline';
10+
11+
const ExtensionMap: Record<ExportType, string> = {
12+
markdown: 'md',
13+
html: 'html',
14+
pdf: 'pdf',
15+
};
916

1017
export class ContentExporter {
11-
static async export(post: Post, exportType: ExportType): Promise<void> {
18+
static async export(
19+
post: Post,
20+
exportType: ExportType,
21+
includeImages: boolean,
22+
imageExportMode: ImageExportMode
23+
): Promise<void> {
24+
if (exportType === 'pdf') {
25+
await this.exportToPdf(post);
26+
return;
27+
}
28+
1229
const { data: content } = await consoleApiClient.content.post.fetchPostHeadContent({
1330
name: post.metadata.name,
1431
});
1532

1633
let exportContent: string;
17-
let fileExtension: string;
1834

19-
if (exportType === 'original') {
20-
exportContent = content.raw || '';
21-
fileExtension =
22-
ContentTypes.find((type) => type.type === content.rawType?.toLowerCase())?.extension || '';
35+
if (exportType === 'html') {
36+
exportContent = content.content || '';
2337
} else if (exportType === 'markdown') {
2438
if (content.rawType?.toLowerCase() === 'html') {
2539
const converter = ConverterFactory.getConverter('html', 'markdown');
2640
exportContent = converter.convert(post, content);
2741
} else {
2842
exportContent = content.raw || '';
2943
}
30-
fileExtension = 'md';
3144
} else {
3245
throw new Error('Unsupported export type');
3346
}
3447

35-
downloadContent(exportContent, post.spec.title, fileExtension);
48+
const fileExtension = ExtensionMap[exportType];
49+
50+
if (!includeImages) {
51+
downloadContent(exportContent, post.spec.title, ExtensionMap[exportType]);
52+
return;
53+
}
54+
55+
if (imageExportMode === 'file') {
56+
await this.exportWithImages(post, exportContent, exportType, fileExtension);
57+
return;
58+
}
59+
60+
if (imageExportMode === 'inline') {
61+
await this.exportWithInlineImages(post, exportContent, exportType, fileExtension);
62+
return;
63+
}
64+
}
65+
66+
private static async exportWithInlineImages(
67+
post: Post,
68+
content: string,
69+
contentType: ExportType,
70+
fileExtension: string
71+
): Promise<void> {
72+
const imageReferences = extractImageReferencesFromContent(content, contentType);
73+
74+
if (imageReferences.length === 0) {
75+
downloadContent(content, post.spec.title, fileExtension);
76+
return;
77+
}
78+
79+
let processedContent = content;
80+
81+
for (const imagePath of imageReferences) {
82+
try {
83+
let absoluteImageUrl: string;
84+
85+
if (imagePath.startsWith('/')) {
86+
absoluteImageUrl = `${location.origin}${imagePath}`;
87+
} else if (!imagePath.startsWith('http')) {
88+
absoluteImageUrl = `${location.origin}/${imagePath}`;
89+
} else {
90+
absoluteImageUrl = imagePath;
91+
}
92+
93+
const imageBlob = await downloadImageAsBlob(absoluteImageUrl);
94+
const base64Data = await this.blobToBase64(imageBlob);
95+
const mimeType = imageBlob.type;
96+
const dataUrl = `data:${mimeType};base64,${base64Data}`;
97+
98+
if (contentType === 'markdown') {
99+
const escapedImagePath = imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
100+
const regex = new RegExp(`!\\[([^\\]]*)\\]\\(${escapedImagePath}\\)`, 'g');
101+
processedContent = processedContent.replace(regex, `![$1](${dataUrl})`);
102+
} else if (contentType === 'html') {
103+
const escapedImagePath = imagePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
104+
const regex = new RegExp(`src=["']${escapedImagePath}["']`, 'g');
105+
processedContent = processedContent.replace(regex, `src="${dataUrl}"`);
106+
}
107+
} catch (error) {
108+
console.warn(`Failed to inline image ${imagePath}:`, error);
109+
}
110+
}
111+
112+
downloadContent(processedContent, post.spec.title, fileExtension);
113+
}
114+
115+
private static blobToBase64(blob: Blob): Promise<string> {
116+
return new Promise((resolve, reject) => {
117+
const reader = new FileReader();
118+
reader.onloadend = () => {
119+
if (reader.result) {
120+
const base64 = (reader.result as string).split(',')[1];
121+
resolve(base64);
122+
} else {
123+
reject(new Error('Failed to convert blob to base64'));
124+
}
125+
};
126+
reader.onerror = reject;
127+
reader.readAsDataURL(blob);
128+
});
36129
}
37130

38131
static async exportToPdf(post: Post): Promise<void> {
@@ -91,6 +184,53 @@ export class ContentExporter {
91184
}
92185
}
93186

187+
private static async exportWithImages(
188+
post: Post,
189+
content: string,
190+
contentType: ExportType,
191+
fileExtension: string
192+
): Promise<void> {
193+
const imageReferences = extractImageReferencesFromContent(content, contentType);
194+
195+
if (imageReferences.length === 0) {
196+
downloadContent(content, post.spec.title, fileExtension);
197+
return;
198+
}
199+
200+
const images: Array<{ blob: Blob; filename: string; path?: string }> = [];
201+
202+
for (const imagePath of imageReferences) {
203+
try {
204+
let absoluteImageUrl: string;
205+
206+
if (imagePath.startsWith('/')) {
207+
absoluteImageUrl = `${location.origin}${imagePath}`;
208+
} else if (!imagePath.startsWith('http')) {
209+
absoluteImageUrl = `${location.origin}/${imagePath}`;
210+
} else {
211+
absoluteImageUrl = imagePath;
212+
}
213+
214+
const imageBlob = await downloadImageAsBlob(absoluteImageUrl);
215+
216+
const encodedFileName = imagePath.split('/').pop() || `image_${images.length + 1}.png`;
217+
const fileName = decodeURIComponent(encodedFileName);
218+
219+
const decodedPath = decodeURIComponent(imagePath);
220+
221+
images.push({
222+
blob: imageBlob,
223+
filename: fileName,
224+
path: decodedPath,
225+
});
226+
} catch (error) {
227+
console.warn(`Failed to download image ${imagePath}:`, error);
228+
}
229+
}
230+
231+
await downloadContentWithImages(content, post.spec.title, fileExtension, images);
232+
}
233+
94234
private static waitForImages(iframe: HTMLIFrameElement): Promise<void> {
95235
return new Promise((resolve) => {
96236
const images = iframe.contentDocument?.images;

0 commit comments

Comments
 (0)