Skip to content

Commit a7a5a17

Browse files
committed
feat: add support for image attachments in MarkdownEditor and implement S3 upload functionality
1 parent fca5607 commit a7a5a17

File tree

5 files changed

+312
-25
lines changed

5 files changed

+312
-25
lines changed

custom/MarkdownEditor.vue

Lines changed: 114 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,23 @@
1010

1111
<script setup lang="ts">
1212
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
13-
13+
import { callAdminForthApi } from '@/utils';
1414
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core';
1515
import { gfm } from '@milkdown/kit/preset/gfm';
1616
import { commonmark } from '@milkdown/preset-commonmark';
1717
import { listener, listenerCtx } from '@milkdown/plugin-listener';
1818
import { Crepe } from '@milkdown/crepe';
19-
19+
import { insert } from '@milkdown/kit/utils';
20+
import { AdminForthColumnCommon } from '@/types/Common';
2021
import '@milkdown/crepe/theme/common/style.css';
2122
import '@milkdown/crepe/theme/frame-dark.css';
2223
23-
const props = defineProps<{ column: any; record: any }>();
24+
const props = defineProps<{
25+
column: AdminForthColumn,
26+
record: any,
27+
meta: any,
28+
}>()
29+
2430
const emit = defineEmits(['update:value']);
2531
const editorContainer = ref<HTMLElement | null>(null);
2632
const content = ref(props.record[props.column.name] || '');
@@ -63,32 +69,120 @@ onMounted(async () => {
6369
// Crepe
6470
if (props.column.components.edit.meta.pluginType === 'crepe' || props.column.components.create.meta.pluginType === 'crepe') {
6571
crepeInstance = await new Crepe({
66-
root: editorContainer.value,
67-
defaultValue: content.value,
68-
});
69-
70-
crepeInstance.on((listener) => {
71-
listener.markdownUpdated(() => {
72-
const markdownContent = crepeInstance.getMarkdown();
73-
emit('update:value', markdownContent);
72+
root: editorContainer.value,
73+
defaultValue: content.value,
7474
});
7575
76-
listener.focus(() => {
77-
isFocused.value = true;
78-
});
79-
listener.blur(() => {
80-
isFocused.value = false;
81-
});
82-
});
76+
crepeInstance.on((listener) => {
77+
listener.markdownUpdated(async () => {
78+
let markdownContent = crepeInstance.getMarkdown();
79+
markdownContent = await replaceBlobsWithS3Urls(markdownContent);
80+
emit('update:value', markdownContent);
81+
});
82+
83+
listener.focus(() => {
84+
isFocused.value = true;
85+
});
86+
listener.blur(() => {
87+
isFocused.value = false;
88+
});
89+
});
8390
84-
await crepeInstance.create();
85-
console.log('Crepe editor created');
91+
await crepeInstance.create();
92+
console.log('Crepe editor created');
8693
}
8794
} catch (error) {
8895
console.error('Failed to initialize editor:', error);
8996
}
9097
});
9198
99+
async function replaceBlobsWithS3Urls(markdownContent: string): Promise<string> {
100+
const blobUrls = markdownContent.match(/blob:[^\s)]+/g);
101+
const base64Images = markdownContent.match(/data:image\/[^;]+;base64,[^\s)]+/g);
102+
if (blobUrls) {
103+
for (let blobUrl of blobUrls) {
104+
const file = await getFileFromBlobUrl(blobUrl);
105+
if (file) {
106+
const s3Url = await uploadFileToS3(file);
107+
if (s3Url) {
108+
markdownContent = markdownContent.replace(blobUrl, s3Url);
109+
}
110+
}
111+
}
112+
}
113+
if (base64Images) {
114+
for (let base64Image of base64Images) {
115+
const file = await fetch(base64Image).then(res => res.blob()).then(blob => new File([blob], 'image.jpg', { type: blob.type }));
116+
if (file) {
117+
const s3Url = await uploadFileToS3(file);
118+
if (s3Url) {
119+
markdownContent = markdownContent.replace(base64Image, s3Url);
120+
}
121+
}
122+
}
123+
}
124+
return markdownContent;
125+
}
126+
127+
async function getFileFromBlobUrl(blobUrl: string): Promise<File | null> {
128+
try {
129+
const response = await fetch(blobUrl);
130+
const blob = await response.blob();
131+
const file = new File([blob], 'uploaded-image.jpg', { type: blob.type });
132+
return file;
133+
} catch (error) {
134+
console.error('Failed to get file from blob URL:', error);
135+
return null;
136+
}
137+
}
138+
async function uploadFileToS3(file: File) {
139+
if (!file || !file.name) {
140+
console.error('File or file name is undefined');
141+
return;
142+
}
143+
144+
const formData = new FormData();
145+
formData.append('image', file);
146+
const originalFilename = file.name.split('.').slice(0, -1).join('.');
147+
const originalExtension = file.name.split('.').pop();
148+
149+
const { uploadUrl, tagline, previewUrl, s3Path, error } = await callAdminForthApi({
150+
path: `/plugin/${props.meta.uploadPluginInstanceId}/get_s3_upload_url`,
151+
method: 'POST',
152+
body: {
153+
originalFilename,
154+
contentType: file.type,
155+
size: file.size,
156+
originalExtension,
157+
},
158+
});
159+
160+
if (error) {
161+
console.error('Upload failed:', error);
162+
return;
163+
}
164+
165+
const xhr = new XMLHttpRequest();
166+
xhr.open('PUT', uploadUrl, true);
167+
xhr.setRequestHeader('Content-Type', file.type);
168+
xhr.setRequestHeader('x-amz-tagging', tagline);
169+
xhr.send(file);
170+
171+
return new Promise((resolve, reject) => {
172+
xhr.onload = () => {
173+
if (xhr.status === 200) {
174+
resolve(previewUrl);
175+
} else {
176+
reject('Error uploading to S3');
177+
}
178+
};
179+
180+
xhr.onerror = () => {
181+
reject('Error uploading to S3');
182+
};
183+
});
184+
}
185+
92186
onBeforeUnmount(() => {
93187
milkdownInstance?.destroy();
94188
crepeInstance?.destroy();

index.ts

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { AdminForthPlugin, AdminForthResource, IAdminForth } from "adminforth";
1+
import { AdminForthPlugin, AdminForthResource, IAdminForth, Filters, AdminUser} from "adminforth";
22
import { PluginOptions } from "./types.js";
33

44
export default class MarkdownPlugin extends AdminForthPlugin {
55
options: PluginOptions;
66
resourceConfig!: AdminForthResource;
77
adminforth!: IAdminForth;
8+
uploadPlugin: AdminForthPlugin;
9+
attachmentResource: AdminForthResource = undefined;
810

911
constructor(options: PluginOptions) {
1012
super(options, import.meta.url);
@@ -41,6 +43,31 @@ export default class MarkdownPlugin extends AdminForthPlugin {
4143
if (!column.components) {
4244
column.components = {};
4345
}
46+
if (this.options.attachments) {
47+
const resource = await adminforth.config.resources.find(r => r.resourceId === this.options.attachments!.attachmentResource);
48+
if (!resource) {
49+
throw new Error(`Resource '${this.options.attachments!.attachmentResource}' not found`);
50+
}
51+
this.attachmentResource = resource;
52+
const field = await resource.columns.find(c => c.name === this.options.attachments!.attachmentFieldName);
53+
if (!field) {
54+
throw new Error(`Field '${this.options.attachments!.attachmentFieldName}' not found in resource '${this.options.attachments!.attachmentResource}'`);
55+
}
56+
57+
const plugin = await adminforth.activatedPlugins.find(p =>
58+
p.resourceConfig!.resourceId === this.options.attachments!.attachmentResource &&
59+
p.pluginOptions.pathColumnName === this.options.attachments!.attachmentFieldName
60+
);
61+
if (!plugin) {
62+
throw new Error(`${plugin} Plugin for attachment field '${this.options.attachments!.attachmentFieldName}' not found in resource '${this.options.attachments!.attachmentResource}', please check if Upload Plugin is installed on the field ${this.options.attachments!.attachmentFieldName}`);
63+
}
64+
65+
if (plugin.pluginOptions.s3ACL !== 'public-read') {
66+
throw new Error(`Upload Plugin for attachment field '${this.options.attachments!.attachmentFieldName}' in resource '${this.options.attachments!.attachmentResource}'
67+
should have s3ACL set to 'public-read' (in vast majority of cases signed urls inside of HTML text is not desired behavior, so we did not implement it)`);
68+
}
69+
this.uploadPlugin = plugin;
70+
}
4471

4572
column.components.show = {
4673
file: this.componentPath("MarkdownRenderer.vue"),
@@ -64,6 +91,7 @@ export default class MarkdownPlugin extends AdminForthPlugin {
6491
pluginInstanceId: this.pluginInstanceId,
6592
columnName: fieldName,
6693
pluginType: 'crepe',
94+
uploadPluginInstanceId: this.uploadPlugin?.pluginInstanceId,
6795
},
6896
};
6997

@@ -73,7 +101,138 @@ export default class MarkdownPlugin extends AdminForthPlugin {
73101
pluginInstanceId: this.pluginInstanceId,
74102
columnName: fieldName,
75103
pluginType: 'crepe',
104+
uploadPluginInstanceId: this.uploadPlugin?.pluginInstanceId,
76105
},
77106
};
107+
const editorRecordPkField = resourceConfig.columns.find(c => c.primaryKey);
108+
if (this.options.attachments) {
109+
110+
function getAttachmentPathes(markdown: string): string[] {
111+
if (!markdown) {
112+
return [];
113+
}
114+
115+
const s3PathRegex = /!\[.*?\]\((https:\/\/.*?\/.*?)(\?.*)?\)/g;
116+
117+
const matches = [...markdown.matchAll(s3PathRegex)];
118+
119+
return matches
120+
.map(match => match[1])
121+
.filter(src => src.includes("s3") || src.includes("amazonaws"));
122+
}
123+
124+
const createAttachmentRecords = async (
125+
adminforth: IAdminForth, options: PluginOptions, recordId: any, s3Paths: string[], adminUser: AdminUser
126+
) => {
127+
const extractKey = (s3Paths: string) => s3Paths.replace(/^https:\/\/[^\/]+\/+/, '');
128+
process.env.HEAVY_DEBUG && console.log('📸 Creating attachment records', JSON.stringify(recordId))
129+
try {
130+
await Promise.all(s3Paths.map(async (s3Path) => {
131+
console.log('Processing path:', s3Path);
132+
try {
133+
await adminforth.createResourceRecord(
134+
{
135+
resource: this.attachmentResource,
136+
record: {
137+
[options.attachments.attachmentFieldName]: extractKey(s3Path),
138+
[options.attachments.attachmentRecordIdFieldName]: recordId,
139+
[options.attachments.attachmentResourceIdFieldName]: resourceConfig.resourceId,
140+
},
141+
adminUser
142+
}
143+
);
144+
console.log('Successfully created record for:', s3Path);
145+
} catch (err) {
146+
console.error('Error creating record for', s3Path, err);
147+
}
148+
}));
149+
} catch (err) {
150+
console.error('Error in Promise.all', err);
151+
}
152+
}
153+
154+
const deleteAttachmentRecords = async (
155+
adminforth: IAdminForth, options: PluginOptions, s3Paths: string[], adminUser: AdminUser
156+
) => {
157+
if (!s3Paths.length) {
158+
return;
159+
}
160+
const attachmentPrimaryKeyField = this.attachmentResource.columns.find(c => c.primaryKey);
161+
const attachments = await adminforth.resource(options.attachments.attachmentResource).list(
162+
Filters.IN(options.attachments.attachmentFieldName, s3Paths)
163+
);
164+
await Promise.all(attachments.map(async (a: any) => {
165+
await adminforth.deleteResourceRecord(
166+
{
167+
resource: this.attachmentResource,
168+
recordId: a[attachmentPrimaryKeyField.name],
169+
adminUser,
170+
record: a,
171+
}
172+
)
173+
}))
174+
}
175+
176+
(resourceConfig.hooks.create.afterSave).push(async ({ record, adminUser }: { record: any, adminUser: AdminUser }) => {
177+
// find all s3Paths in the html
178+
const s3Paths = getAttachmentPathes(record[this.options.fieldName])
179+
180+
process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths', s3Paths);
181+
// create attachment records
182+
await createAttachmentRecords(
183+
adminforth, this.options, record[editorRecordPkField.name], s3Paths, adminUser);
184+
185+
return { ok: true };
186+
});
187+
188+
// after edit we need to delete attachments that are not in the html anymore
189+
// and add new ones
190+
(resourceConfig.hooks.edit.afterSave).push(
191+
async ({ recordId, record, adminUser }: { recordId: any, record: any, adminUser: AdminUser }) => {
192+
process.env.HEAVY_DEBUG && console.log('⚓ Cought hook', recordId, 'rec', record);
193+
if (record[this.options.fieldName] === undefined) {
194+
console.log('⚓ Cought hook', recordId, 'rec', record);
195+
// field was not changed, do nothing
196+
return { ok: true };
197+
}
198+
const existingAparts = await adminforth.resource(this.options.attachments.attachmentResource).list([
199+
Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, recordId),
200+
Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
201+
]);
202+
const existingS3Paths = existingAparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
203+
const newS3Paths = getAttachmentPathes(record[this.options.fieldName]);
204+
process.env.HEAVY_DEBUG && console.log('📸 Existing s3Paths (from db)', existingS3Paths)
205+
process.env.HEAVY_DEBUG && console.log('📸 Found new s3Paths (from text)', newS3Paths);
206+
const toDelete = existingS3Paths.filter(s3Path => !newS3Paths.includes(s3Path));
207+
const toAdd = newS3Paths.filter(s3Path => !existingS3Paths.includes(s3Path));
208+
process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', toDelete)
209+
process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to add', toAdd);
210+
await Promise.all([
211+
deleteAttachmentRecords(adminforth, this.options, toDelete, adminUser),
212+
createAttachmentRecords(adminforth, this.options, recordId, toAdd, adminUser)
213+
]);
214+
215+
return { ok: true };
216+
217+
}
218+
);
219+
220+
// after delete we need to delete all attachments
221+
(resourceConfig.hooks.delete.afterSave).push(
222+
async ({ record, adminUser }: { record: any, adminUser: AdminUser }) => {
223+
const existingAparts = await adminforth.resource(this.options.attachments.attachmentResource).list(
224+
[
225+
Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, record[editorRecordPkField.name]),
226+
Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
227+
]
228+
);
229+
const existingS3Paths = existingAparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
230+
process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', existingS3Paths);
231+
await deleteAttachmentRecords(adminforth, this.options, existingS3Paths, adminUser);
232+
233+
return { ok: true };
234+
}
235+
);
236+
}
78237
}
79238
}

package-lock.json

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

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
"type": "module",
66
"main": "dist/index.js",
77
"types": "dist/index.d.ts",
8-
98
"scripts": {
109
"build": "tsc && rsync -av --exclude 'node_modules' custom dist/",
1110
"prepare": "npm link adminforth"

0 commit comments

Comments
 (0)