Skip to content

Commit 96c0a09

Browse files
authored
Merge pull request #1 from devforth/add-img-support
feat: add support for image attachments in MarkdownEditor and implement S3 upload functionality
2 parents fca5607 + 010f9ca commit 96c0a09

File tree

5 files changed

+313
-30
lines changed

5 files changed

+313
-30
lines changed

custom/MarkdownEditor.vue

Lines changed: 115 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,20 @@
99
</template>
1010

1111
<script setup lang="ts">
12-
import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
13-
14-
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core';
15-
import { gfm } from '@milkdown/kit/preset/gfm';
16-
import { commonmark } from '@milkdown/preset-commonmark';
17-
import { listener, listenerCtx } from '@milkdown/plugin-listener';
12+
import { ref, onMounted, onBeforeUnmount } from 'vue';
13+
import { callAdminForthApi } from '@/utils';
14+
import { Editor } from '@milkdown/core';
1815
import { Crepe } from '@milkdown/crepe';
19-
16+
import type { AdminForthColumn } from '@/types/Common';
2017
import '@milkdown/crepe/theme/common/style.css';
2118
import '@milkdown/crepe/theme/frame-dark.css';
2219
23-
const props = defineProps<{ column: any; record: any }>();
20+
const props = defineProps<{
21+
column: AdminForthColumn,
22+
record: any,
23+
meta: any,
24+
}>()
25+
2426
const emit = defineEmits(['update:value']);
2527
const editorContainer = ref<HTMLElement | null>(null);
2628
const content = ref(props.record[props.column.name] || '');
@@ -63,32 +65,120 @@ onMounted(async () => {
6365
// Crepe
6466
if (props.column.components.edit.meta.pluginType === 'crepe' || props.column.components.create.meta.pluginType === 'crepe') {
6567
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);
68+
root: editorContainer.value,
69+
defaultValue: content.value,
7470
});
7571
76-
listener.focus(() => {
77-
isFocused.value = true;
78-
});
79-
listener.blur(() => {
80-
isFocused.value = false;
81-
});
82-
});
72+
crepeInstance.on((listener) => {
73+
listener.markdownUpdated(async () => {
74+
let markdownContent = crepeInstance.getMarkdown();
75+
markdownContent = await replaceBlobsWithS3Urls(markdownContent);
76+
emit('update:value', markdownContent);
77+
});
78+
79+
listener.focus(() => {
80+
isFocused.value = true;
81+
});
82+
listener.blur(() => {
83+
isFocused.value = false;
84+
});
85+
});
8386
84-
await crepeInstance.create();
85-
console.log('Crepe editor created');
87+
await crepeInstance.create();
88+
console.log('Crepe editor created');
8689
}
8790
} catch (error) {
8891
console.error('Failed to initialize editor:', error);
8992
}
9093
});
9194
95+
async function replaceBlobsWithS3Urls(markdownContent: string): Promise<string> {
96+
const blobUrls = markdownContent.match(/blob:[^\s)]+/g);
97+
const base64Images = markdownContent.match(/data:image\/[^;]+;base64,[^\s)]+/g);
98+
if (blobUrls) {
99+
for (let blobUrl of blobUrls) {
100+
const file = await getFileFromBlobUrl(blobUrl);
101+
if (file) {
102+
const s3Url = await uploadFileToS3(file);
103+
if (s3Url) {
104+
markdownContent = markdownContent.replace(blobUrl, s3Url);
105+
}
106+
}
107+
}
108+
}
109+
if (base64Images) {
110+
for (let base64Image of base64Images) {
111+
const file = await fetch(base64Image).then(res => res.blob()).then(blob => new File([blob], 'image.jpg', { type: blob.type }));
112+
if (file) {
113+
const s3Url = await uploadFileToS3(file);
114+
if (s3Url) {
115+
markdownContent = markdownContent.replace(base64Image, s3Url);
116+
}
117+
}
118+
}
119+
}
120+
return markdownContent;
121+
}
122+
123+
async function getFileFromBlobUrl(blobUrl: string): Promise<File | null> {
124+
try {
125+
const response = await fetch(blobUrl);
126+
const blob = await response.blob();
127+
const file = new File([blob], 'uploaded-image.jpg', { type: blob.type });
128+
return file;
129+
} catch (error) {
130+
console.error('Failed to get file from blob URL:', error);
131+
return null;
132+
}
133+
}
134+
async function uploadFileToS3(file: File) {
135+
if (!file || !file.name) {
136+
console.error('File or file name is undefined');
137+
return;
138+
}
139+
140+
const formData = new FormData();
141+
formData.append('image', file);
142+
const originalFilename = file.name.split('.').slice(0, -1).join('.');
143+
const originalExtension = file.name.split('.').pop();
144+
145+
const { uploadUrl, tagline, previewUrl, s3Path, error } = await callAdminForthApi({
146+
path: `/plugin/${props.meta.uploadPluginInstanceId}/get_s3_upload_url`,
147+
method: 'POST',
148+
body: {
149+
originalFilename,
150+
contentType: file.type,
151+
size: file.size,
152+
originalExtension,
153+
},
154+
});
155+
156+
if (error) {
157+
console.error('Upload failed:', error);
158+
return;
159+
}
160+
161+
const xhr = new XMLHttpRequest();
162+
xhr.open('PUT', uploadUrl, true);
163+
xhr.setRequestHeader('Content-Type', file.type);
164+
xhr.setRequestHeader('x-amz-tagging', tagline);
165+
xhr.send(file);
166+
167+
return new Promise((resolve, reject) => {
168+
xhr.onload = () => {
169+
if (xhr.status === 200) {
170+
resolve(previewUrl);
171+
} else {
172+
reject('Error uploading to S3');
173+
}
174+
};
175+
176+
xhr.onerror = () => {
177+
reject('Error uploading to S3');
178+
};
179+
});
180+
}
181+
92182
onBeforeUnmount(() => {
93183
milkdownInstance?.destroy();
94184
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)