@adminforth/rich-editor 1.0.9 → 1.0.11

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/ChangeLog.md ADDED
@@ -0,0 +1,5 @@
1
+
2
+
3
+ ## [1.0.15]
4
+
5
+ -- Add image uploads support
@@ -34,15 +34,19 @@ function dbg(title: string,...args: any[]) {
34
34
 
35
35
  // blots/embed: Represents inline embed elements, like images or videos that can be inserted into the text flow.
36
36
  const Embed = Quill.import('blots/embed');
37
+ const BlockEmbed = Quill.import('blots/block/embed');
37
38
 
38
39
  // @ts-ignore
39
40
  class CompleteBlot extends Embed {
40
41
  static blotName = 'complete';
41
42
  static tagName = 'span';
43
+ // https://stackoverflow.com/a/78434756/27379293
44
+ static className = "complete-blot";
42
45
 
43
46
  static create(value: { text: string }) {
44
47
  let node = super.create();
45
- node.setAttribute('contenteditable', 'false');
48
+ // we should keep contenteditable=true for case when user clicks on empty area
49
+ // node.setAttribute('contenteditable', 'false');
46
50
  node.setAttribute('completer', '');
47
51
  node.innerText = value.text;
48
52
  return node;
@@ -54,8 +58,33 @@ class CompleteBlot extends Embed {
54
58
  };
55
59
  }
56
60
  }
61
+
62
+ // @ts-ignore
63
+ class ImageBlot extends BlockEmbed {
64
+ static blotName = 'image';
65
+ static tagName = 'img';
66
+
67
+ static create(value) {
68
+ let node = super.create();
69
+ node.setAttribute('alt', value.alt);
70
+ node.setAttribute('src', value.url);
71
+ node.setAttribute('data-s3path', value['s3Path']);
72
+ return node;
73
+ }
74
+
75
+ static value(node) {
76
+ return {
77
+ alt: node.getAttribute('alt'),
78
+ url: node.getAttribute('src'),
79
+ s3Path: node.getAttribute('data-s3path'),
80
+ };
81
+ }
82
+ }
83
+
57
84
  // @ts-ignore
58
85
  Quill.register(CompleteBlot);
86
+ // @ts-ignore
87
+ Quill.register(ImageBlot);
59
88
 
60
89
  const updaterQueue = new AsyncQueue();
61
90
 
@@ -78,6 +107,93 @@ const editorFocused = ref(false);
78
107
 
79
108
  let lastText: string | null = null;
80
109
 
110
+ const imageProgress = ref(0);
111
+
112
+
113
+ async function saveToServer(file: File) {
114
+ const fd = new FormData();
115
+ fd.append('image', file);
116
+
117
+ const originalFilename = file.name.split('.').slice(0, -1).join('.');
118
+ const originalExtension = file.name.split('.').pop();
119
+ // send fd to s3
120
+ const { uploadUrl, tagline, previewUrl, s3Path, error } = await callAdminForthApi({
121
+ path: `/plugin/${props.meta.uploadPluginInstanceId}/get_s3_upload_url`,
122
+ method: 'POST',
123
+ body: {
124
+ originalFilename,
125
+ contentType: file.type,
126
+ size: file.size,
127
+ originalExtension,
128
+ },
129
+ });
130
+
131
+ if (error) {
132
+ window.adminforth.alert({
133
+ message: `File was not uploaded because of error: ${error}`,
134
+ variant: 'danger'
135
+ });
136
+ return;
137
+ }
138
+
139
+ const xhr = new XMLHttpRequest();
140
+ const success = await new Promise((resolve) => {
141
+ xhr.upload.onprogress = (e) => {
142
+ if (e.lengthComputable) {
143
+ imageProgress.value = Math.round((e.loaded / e.total) * 100);
144
+ }
145
+ };
146
+ xhr.addEventListener('loadend', () => {
147
+ const success = xhr.readyState === 4 && xhr.status === 200;
148
+ // try to read response
149
+ resolve(success);
150
+ });
151
+ xhr.open('PUT', uploadUrl, true);
152
+ xhr.setRequestHeader('Content-Type', file.type);
153
+ xhr.setRequestHeader('x-amz-tagging', tagline);
154
+ xhr.send(file);
155
+ });
156
+ if (!success) {
157
+ window.adminforth.alert({
158
+ messageHtml: `<div>Sorry but the file was not uploaded because of S3 Request Error: </div>
159
+ <pre style="white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; max-width: 100%;">${
160
+ xhr.responseText.replace(/</g, '&lt;').replace(/>/g, '&gt;')
161
+ }</pre>`,
162
+ variant: 'danger',
163
+ timeout: 30,
164
+ });
165
+ imageProgress.value = 0;
166
+ return;
167
+ }
168
+
169
+ // here we have s3Path, call createResource to save the image
170
+ const range = quill.getSelection();
171
+ quill.insertEmbed(range.index, 'image', {
172
+ url: previewUrl,
173
+ s3Path: s3Path,
174
+ alt: file.name
175
+ }, 'user');
176
+
177
+ }
178
+
179
+ async function imageHandler() {
180
+ const input = document.createElement('input');
181
+ input.setAttribute('type', 'file');
182
+ input.click();
183
+
184
+ // Listen upload local image and save to server
185
+ input.onchange = () => {
186
+ const file = input.files[0];
187
+
188
+ // file type is only image.
189
+ if (/^image\//.test(file.type)) {
190
+ saveToServer(file);
191
+ } else {
192
+ console.warn('You could only upload images.');
193
+ }
194
+ };
195
+ }
196
+
81
197
  onMounted(() => {
82
198
  currentValue.value = props.record[props.column.name] || '';
83
199
  editor.value.innerHTML = currentValue.value;
@@ -87,31 +203,36 @@ onMounted(() => {
87
203
  placeholder: 'Type here...',
88
204
  // formats : ['complete'],
89
205
  modules: {
90
- toolbar: props.meta.toolbar || [
91
- ['bold', 'italic', 'underline', 'strike'], // toggled buttons
92
- ['blockquote', 'code-block', 'link', ...props.meta.uploadPluginInstanceId ? ['image'] : []],
93
- // [
94
- // // 'image',
95
- // // 'video',
96
- // // 'formula'
97
- // ],
98
-
99
-
100
- [{ 'header': 2 }, { 'header': 3 }], // custom button values
101
- [{ 'list': 'ordered'}, { 'list': 'bullet' }, { 'list': 'check' }],
102
- // [{ 'script': 'sub'}, { 'script': 'super' }], // superscript/subscript
103
- // [{ 'indent': '-1'}, { 'indent': '+1' }], // outdent/indent
104
- // [{ 'direction': 'rtl' }], // text direction
105
-
106
- // [{ 'size': ['small', false, 'large', 'huge'] }], // custom dropdown
107
- // [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
108
-
109
- // [{ 'color': [] }, { 'background': [] }], // dropdown with defaults from theme
110
- // [{ 'font': [] }],
111
- [{ 'align': [] }],
112
-
113
- ['clean'] // remove formatting button
114
- ],
206
+ toolbar: {
207
+ container: props.meta.toolbar || [
208
+ ['bold', 'italic', 'underline', 'strike'], // toggled buttons
209
+ ['blockquote', 'code-block', 'link', ...props.meta.uploadPluginInstanceId ? ['image'] : []],
210
+ // [
211
+ // // 'image',
212
+ // // 'video',
213
+ // // 'formula'
214
+ // ],
215
+
216
+
217
+ [{ 'header': 2 }, { 'header': 3 }], // custom button values
218
+ [{ 'list': 'ordered'}, { 'list': 'bullet' }, { 'list': 'check' }],
219
+ // [{ 'script': 'sub'}, { 'script': 'super' }], // superscript/subscript
220
+ // [{ 'indent': '-1'}, { 'indent': '+1' }], // outdent/indent
221
+ // [{ 'direction': 'rtl' }], // text direction
222
+
223
+ // [{ 'size': ['small', false, 'large', 'huge'] }], // custom dropdown
224
+ // [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
225
+
226
+ // [{ 'color': [] }, { 'background': [] }], // dropdown with defaults from theme
227
+ // [{ 'font': [] }],
228
+ [{ 'align': [] }],
229
+
230
+ ['clean'] // remove formatting button
231
+ ],
232
+ handlers: {
233
+ image: imageHandler,
234
+ },
235
+ },
115
236
  keyboard: {
116
237
  bindings: {
117
238
  tab: {
@@ -148,10 +269,12 @@ onMounted(() => {
148
269
  }
149
270
  const text = quill.getText();
150
271
  // don't allow to select after completion
151
- if (range?.index === text.length) {
152
- dbg('✋ prevent selection after completion');
153
- quill.setSelection(text.length - 1, 0, 'silent');
154
- }
272
+ // TODO
273
+ // if (range?.index === text.length) {
274
+ // console.log('RANGE IDX', range.index, text.length, 'text', JSON.stringify(text, null, 1));
275
+ // dbg('✋ prevent selection after completion');
276
+ // quill.setSelection(text.length - 1, 0, 'silent');
277
+ // }
155
278
  });
156
279
 
157
280
 
@@ -165,8 +288,10 @@ onMounted(() => {
165
288
 
166
289
 
167
290
  async function emitTextUpdate() {
168
- const html = quill.root.innerHTML;
169
-
291
+ const editorHtml = quill.root.innerHTML;
292
+ // remove completion from html
293
+ const html = editorHtml.replace(/<span[^>]*completer[^>]*>.*?<\/span>/g, '');
294
+
170
295
  if (lastText === html) {
171
296
  return;
172
297
  }
@@ -341,8 +466,8 @@ async function startCompletion() {
341
466
 
342
467
  quill.insertEmbed(cursorPosition.index, 'complete', { text: completionAnswer.join('') }, 'silent');
343
468
 
344
- // dbg('👇 set pos', cursorPosition.index, cursorPosition.length)
345
- // quill.setSelection(cursorPosition.index, cursorPosition.length, 'silent');
469
+ //dbg('👇 set pos', cursorPosition.index, cursorPosition.length)
470
+ //quill.setSelection(cursorPosition.index, cursorPosition.length, 'silent');
346
471
 
347
472
  completion.value = completionAnswer;
348
473
 
@@ -391,15 +516,19 @@ function removeCompletionOnBlur() {
391
516
  }
392
517
  }
393
518
 
394
- .ql-editor:not(:focus) [completer] {
395
- display: none;
396
- }
519
+ // .ql-editor:not(:focus) [completer] {
520
+ // display: none;
521
+ // }
397
522
 
398
523
  .ql-editor [completer] {
399
524
  color: gray;
400
525
  font-style: italic;
401
526
  }
402
527
 
528
+ .ql-editor p {
529
+ margin-bottom: 0.5rem;
530
+ }
531
+
403
532
  .ql-snow .ql-stroke {
404
533
  @apply dark:stroke-darkPrimary;
405
534
  @apply stroke-lightPrimary;
@@ -34,15 +34,19 @@ function dbg(title: string,...args: any[]) {
34
34
 
35
35
  // blots/embed: Represents inline embed elements, like images or videos that can be inserted into the text flow.
36
36
  const Embed = Quill.import('blots/embed');
37
+ const BlockEmbed = Quill.import('blots/block/embed');
37
38
 
38
39
  // @ts-ignore
39
40
  class CompleteBlot extends Embed {
40
41
  static blotName = 'complete';
41
42
  static tagName = 'span';
43
+ // https://stackoverflow.com/a/78434756/27379293
44
+ static className = "complete-blot";
42
45
 
43
46
  static create(value: { text: string }) {
44
47
  let node = super.create();
45
- node.setAttribute('contenteditable', 'false');
48
+ // we should keep contenteditable=true for case when user clicks on empty area
49
+ // node.setAttribute('contenteditable', 'false');
46
50
  node.setAttribute('completer', '');
47
51
  node.innerText = value.text;
48
52
  return node;
@@ -54,8 +58,33 @@ class CompleteBlot extends Embed {
54
58
  };
55
59
  }
56
60
  }
61
+
62
+ // @ts-ignore
63
+ class ImageBlot extends BlockEmbed {
64
+ static blotName = 'image';
65
+ static tagName = 'img';
66
+
67
+ static create(value) {
68
+ let node = super.create();
69
+ node.setAttribute('alt', value.alt);
70
+ node.setAttribute('src', value.url);
71
+ node.setAttribute('data-s3path', value['s3Path']);
72
+ return node;
73
+ }
74
+
75
+ static value(node) {
76
+ return {
77
+ alt: node.getAttribute('alt'),
78
+ url: node.getAttribute('src'),
79
+ s3Path: node.getAttribute('data-s3path'),
80
+ };
81
+ }
82
+ }
83
+
57
84
  // @ts-ignore
58
85
  Quill.register(CompleteBlot);
86
+ // @ts-ignore
87
+ Quill.register(ImageBlot);
59
88
 
60
89
  const updaterQueue = new AsyncQueue();
61
90
 
@@ -78,6 +107,93 @@ const editorFocused = ref(false);
78
107
 
79
108
  let lastText: string | null = null;
80
109
 
110
+ const imageProgress = ref(0);
111
+
112
+
113
+ async function saveToServer(file: File) {
114
+ const fd = new FormData();
115
+ fd.append('image', file);
116
+
117
+ const originalFilename = file.name.split('.').slice(0, -1).join('.');
118
+ const originalExtension = file.name.split('.').pop();
119
+ // send fd to s3
120
+ const { uploadUrl, tagline, previewUrl, s3Path, error } = await callAdminForthApi({
121
+ path: `/plugin/${props.meta.uploadPluginInstanceId}/get_s3_upload_url`,
122
+ method: 'POST',
123
+ body: {
124
+ originalFilename,
125
+ contentType: file.type,
126
+ size: file.size,
127
+ originalExtension,
128
+ },
129
+ });
130
+
131
+ if (error) {
132
+ window.adminforth.alert({
133
+ message: `File was not uploaded because of error: ${error}`,
134
+ variant: 'danger'
135
+ });
136
+ return;
137
+ }
138
+
139
+ const xhr = new XMLHttpRequest();
140
+ const success = await new Promise((resolve) => {
141
+ xhr.upload.onprogress = (e) => {
142
+ if (e.lengthComputable) {
143
+ imageProgress.value = Math.round((e.loaded / e.total) * 100);
144
+ }
145
+ };
146
+ xhr.addEventListener('loadend', () => {
147
+ const success = xhr.readyState === 4 && xhr.status === 200;
148
+ // try to read response
149
+ resolve(success);
150
+ });
151
+ xhr.open('PUT', uploadUrl, true);
152
+ xhr.setRequestHeader('Content-Type', file.type);
153
+ xhr.setRequestHeader('x-amz-tagging', tagline);
154
+ xhr.send(file);
155
+ });
156
+ if (!success) {
157
+ window.adminforth.alert({
158
+ messageHtml: `<div>Sorry but the file was not uploaded because of S3 Request Error: </div>
159
+ <pre style="white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; max-width: 100%;">${
160
+ xhr.responseText.replace(/</g, '&lt;').replace(/>/g, '&gt;')
161
+ }</pre>`,
162
+ variant: 'danger',
163
+ timeout: 30,
164
+ });
165
+ imageProgress.value = 0;
166
+ return;
167
+ }
168
+
169
+ // here we have s3Path, call createResource to save the image
170
+ const range = quill.getSelection();
171
+ quill.insertEmbed(range.index, 'image', {
172
+ url: previewUrl,
173
+ s3Path: s3Path,
174
+ alt: file.name
175
+ }, 'user');
176
+
177
+ }
178
+
179
+ async function imageHandler() {
180
+ const input = document.createElement('input');
181
+ input.setAttribute('type', 'file');
182
+ input.click();
183
+
184
+ // Listen upload local image and save to server
185
+ input.onchange = () => {
186
+ const file = input.files[0];
187
+
188
+ // file type is only image.
189
+ if (/^image\//.test(file.type)) {
190
+ saveToServer(file);
191
+ } else {
192
+ console.warn('You could only upload images.');
193
+ }
194
+ };
195
+ }
196
+
81
197
  onMounted(() => {
82
198
  currentValue.value = props.record[props.column.name] || '';
83
199
  editor.value.innerHTML = currentValue.value;
@@ -87,31 +203,36 @@ onMounted(() => {
87
203
  placeholder: 'Type here...',
88
204
  // formats : ['complete'],
89
205
  modules: {
90
- toolbar: props.meta.toolbar || [
91
- ['bold', 'italic', 'underline', 'strike'], // toggled buttons
92
- ['blockquote', 'code-block', 'link', ...props.meta.uploadPluginInstanceId ? ['image'] : []],
93
- // [
94
- // // 'image',
95
- // // 'video',
96
- // // 'formula'
97
- // ],
98
-
99
-
100
- [{ 'header': 2 }, { 'header': 3 }], // custom button values
101
- [{ 'list': 'ordered'}, { 'list': 'bullet' }, { 'list': 'check' }],
102
- // [{ 'script': 'sub'}, { 'script': 'super' }], // superscript/subscript
103
- // [{ 'indent': '-1'}, { 'indent': '+1' }], // outdent/indent
104
- // [{ 'direction': 'rtl' }], // text direction
105
-
106
- // [{ 'size': ['small', false, 'large', 'huge'] }], // custom dropdown
107
- // [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
108
-
109
- // [{ 'color': [] }, { 'background': [] }], // dropdown with defaults from theme
110
- // [{ 'font': [] }],
111
- [{ 'align': [] }],
112
-
113
- ['clean'] // remove formatting button
114
- ],
206
+ toolbar: {
207
+ container: props.meta.toolbar || [
208
+ ['bold', 'italic', 'underline', 'strike'], // toggled buttons
209
+ ['blockquote', 'code-block', 'link', ...props.meta.uploadPluginInstanceId ? ['image'] : []],
210
+ // [
211
+ // // 'image',
212
+ // // 'video',
213
+ // // 'formula'
214
+ // ],
215
+
216
+
217
+ [{ 'header': 2 }, { 'header': 3 }], // custom button values
218
+ [{ 'list': 'ordered'}, { 'list': 'bullet' }, { 'list': 'check' }],
219
+ // [{ 'script': 'sub'}, { 'script': 'super' }], // superscript/subscript
220
+ // [{ 'indent': '-1'}, { 'indent': '+1' }], // outdent/indent
221
+ // [{ 'direction': 'rtl' }], // text direction
222
+
223
+ // [{ 'size': ['small', false, 'large', 'huge'] }], // custom dropdown
224
+ // [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
225
+
226
+ // [{ 'color': [] }, { 'background': [] }], // dropdown with defaults from theme
227
+ // [{ 'font': [] }],
228
+ [{ 'align': [] }],
229
+
230
+ ['clean'] // remove formatting button
231
+ ],
232
+ handlers: {
233
+ image: imageHandler,
234
+ },
235
+ },
115
236
  keyboard: {
116
237
  bindings: {
117
238
  tab: {
@@ -148,10 +269,12 @@ onMounted(() => {
148
269
  }
149
270
  const text = quill.getText();
150
271
  // don't allow to select after completion
151
- if (range?.index === text.length) {
152
- dbg('✋ prevent selection after completion');
153
- quill.setSelection(text.length - 1, 0, 'silent');
154
- }
272
+ // TODO
273
+ // if (range?.index === text.length) {
274
+ // console.log('RANGE IDX', range.index, text.length, 'text', JSON.stringify(text, null, 1));
275
+ // dbg('✋ prevent selection after completion');
276
+ // quill.setSelection(text.length - 1, 0, 'silent');
277
+ // }
155
278
  });
156
279
 
157
280
 
@@ -165,8 +288,10 @@ onMounted(() => {
165
288
 
166
289
 
167
290
  async function emitTextUpdate() {
168
- const html = quill.root.innerHTML;
169
-
291
+ const editorHtml = quill.root.innerHTML;
292
+ // remove completion from html
293
+ const html = editorHtml.replace(/<span[^>]*completer[^>]*>.*?<\/span>/g, '');
294
+
170
295
  if (lastText === html) {
171
296
  return;
172
297
  }
@@ -341,8 +466,8 @@ async function startCompletion() {
341
466
 
342
467
  quill.insertEmbed(cursorPosition.index, 'complete', { text: completionAnswer.join('') }, 'silent');
343
468
 
344
- // dbg('👇 set pos', cursorPosition.index, cursorPosition.length)
345
- // quill.setSelection(cursorPosition.index, cursorPosition.length, 'silent');
469
+ //dbg('👇 set pos', cursorPosition.index, cursorPosition.length)
470
+ //quill.setSelection(cursorPosition.index, cursorPosition.length, 'silent');
346
471
 
347
472
  completion.value = completionAnswer;
348
473
 
@@ -391,15 +516,19 @@ function removeCompletionOnBlur() {
391
516
  }
392
517
  }
393
518
 
394
- .ql-editor:not(:focus) [completer] {
395
- display: none;
396
- }
519
+ // .ql-editor:not(:focus) [completer] {
520
+ // display: none;
521
+ // }
397
522
 
398
523
  .ql-editor [completer] {
399
524
  color: gray;
400
525
  font-style: italic;
401
526
  }
402
527
 
528
+ .ql-editor p {
529
+ margin-bottom: 0.5rem;
530
+ }
531
+
403
532
  .ql-snow .ql-stroke {
404
533
  @apply dark:stroke-darkPrimary;
405
534
  @apply stroke-lightPrimary;
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
- import { AdminForthPlugin } from "adminforth";
10
+ import { AdminForthPlugin, Filters } from "adminforth";
11
+ import * as cheerio from 'cheerio';
11
12
  // options:
12
13
  // attachments: {
13
14
  // attachmentResource: 'description_images',
@@ -18,7 +19,9 @@ import { AdminForthPlugin } from "adminforth";
18
19
  export default class RichEditorPlugin extends AdminForthPlugin {
19
20
  constructor(options) {
20
21
  super(options, import.meta.url);
22
+ this.resourceConfig = undefined;
21
23
  this.activationOrder = 100000;
24
+ this.attachmentResource = undefined;
22
25
  this.options = options;
23
26
  }
24
27
  modifyResourceConfig(adminforth, resourceConfig) {
@@ -37,6 +40,7 @@ export default class RichEditorPlugin extends AdminForthPlugin {
37
40
  if (!resource) {
38
41
  throw new Error(`Resource '${this.options.attachments.attachmentResource}' not found`);
39
42
  }
43
+ this.attachmentResource = resource;
40
44
  const field = resource.columns.find(c => c.name === this.options.attachments.attachmentFieldName);
41
45
  if (!field) {
42
46
  throw new Error(`Field '${this.options.attachments.attachmentFieldName}' not found in resource '${this.options.attachments.attachmentResource}'`);
@@ -46,6 +50,10 @@ export default class RichEditorPlugin extends AdminForthPlugin {
46
50
  if (!plugin) {
47
51
  throw new Error(`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}`);
48
52
  }
53
+ if (plugin.pluginOptions.s3ACL !== 'public-read') {
54
+ throw new Error(`Upload Plugin for attachment field '${this.options.attachments.attachmentFieldName}' in resource '${this.options.attachments.attachmentResource}'
55
+ 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)`);
56
+ }
49
57
  this.uploadPlugin = plugin;
50
58
  }
51
59
  const filed = {
@@ -62,6 +70,93 @@ export default class RichEditorPlugin extends AdminForthPlugin {
62
70
  }
63
71
  c.components.create = filed;
64
72
  c.components.edit = filed;
73
+ const editorRecordPkField = resourceConfig.columns.find(c => c.primaryKey);
74
+ // if attachment configured we need a post-save hook to create attachment records for each image data-s3path
75
+ if (this.options.attachments) {
76
+ function getAttachmentPathes(html) {
77
+ if (!html) {
78
+ return [];
79
+ }
80
+ const $ = cheerio.load(html);
81
+ const s3Paths = [];
82
+ $('img[data-s3path]').each((i, el) => {
83
+ const src = $(el).attr('data-s3path');
84
+ s3Paths.push(src);
85
+ });
86
+ return s3Paths;
87
+ }
88
+ const createAttachmentRecords = (adminforth, options, recordId, s3Paths, adminUser) => __awaiter(this, void 0, void 0, function* () {
89
+ process.env.HEAVY_DEBUG && console.log('📸 Creating attachment records', JSON.stringify(recordId));
90
+ yield Promise.all(s3Paths.map((s3Path) => __awaiter(this, void 0, void 0, function* () {
91
+ yield adminforth.createResourceRecord({
92
+ resource: this.attachmentResource,
93
+ record: {
94
+ [options.attachments.attachmentFieldName]: s3Path,
95
+ [options.attachments.attachmentRecordIdFieldName]: recordId,
96
+ [options.attachments.attachmentResourceIdFieldName]: resourceConfig.resourceId,
97
+ },
98
+ adminUser
99
+ });
100
+ })));
101
+ });
102
+ const deleteAttachmentRecords = (adminforth, options, s3Paths, adminUser) => __awaiter(this, void 0, void 0, function* () {
103
+ const attachmentPrimaryKeyField = this.attachmentResource.columns.find(c => c.primaryKey);
104
+ const attachments = yield adminforth.resource(options.attachments.attachmentResource).list(Filters.IN(options.attachments.attachmentFieldName, s3Paths));
105
+ yield Promise.all(attachments.map((a) => __awaiter(this, void 0, void 0, function* () {
106
+ yield adminforth.deleteResourceRecord({
107
+ resource: this.attachmentResource,
108
+ recordId: a[attachmentPrimaryKeyField.name],
109
+ adminUser,
110
+ record: a,
111
+ });
112
+ })));
113
+ });
114
+ resourceConfig.hooks.create.afterSave.push((_d) => __awaiter(this, [_d], void 0, function* ({ record, adminUser }) {
115
+ // find all s3Paths in the html
116
+ const s3Paths = getAttachmentPathes(record[this.options.htmlFieldName]);
117
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths', s3Paths);
118
+ // create attachment records
119
+ yield createAttachmentRecords(adminforth, this.options, record[editorRecordPkField.name], s3Paths, adminUser);
120
+ return { ok: true };
121
+ }));
122
+ // after edit we need to delete attachments that are not in the html anymore
123
+ // and add new ones
124
+ resourceConfig.hooks.edit.afterSave.push((_e) => __awaiter(this, [_e], void 0, function* ({ recordId, record, adminUser }) {
125
+ process.env.HEAVY_DEBUG && console.log('⚓ Cought hook', recordId, 'rec', record);
126
+ if (record[this.options.htmlFieldName] === undefined) {
127
+ // field was not changed, do nothing
128
+ return { ok: true };
129
+ }
130
+ const existingApparts = yield adminforth.resource(this.options.attachments.attachmentResource).list([
131
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, recordId),
132
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
133
+ ]);
134
+ const existingS3Paths = existingApparts.map((a) => a[this.options.attachments.attachmentFieldName]);
135
+ const newS3Paths = getAttachmentPathes(record[this.options.htmlFieldName]);
136
+ process.env.HEAVY_DEBUG && console.log('📸 Existing s3Paths (from db)', existingS3Paths);
137
+ process.env.HEAVY_DEBUG && console.log('📸 Found new s3Paths (from text)', newS3Paths);
138
+ const toDelete = existingS3Paths.filter(s3Path => !newS3Paths.includes(s3Path));
139
+ const toAdd = newS3Paths.filter(s3Path => !existingS3Paths.includes(s3Path));
140
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', toDelete);
141
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to add', toAdd);
142
+ yield Promise.all([
143
+ deleteAttachmentRecords(adminforth, this.options, toDelete, adminUser),
144
+ createAttachmentRecords(adminforth, this.options, recordId, toAdd, adminUser)
145
+ ]);
146
+ return { ok: true };
147
+ }));
148
+ // after delete we need to delete all attachments
149
+ resourceConfig.hooks.delete.afterSave.push((_f) => __awaiter(this, [_f], void 0, function* ({ record, adminUser }) {
150
+ const existingApparts = yield adminforth.resource(this.options.attachments.attachmentResource).list([
151
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, record[editorRecordPkField.name]),
152
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
153
+ ]);
154
+ const existingS3Paths = existingApparts.map((a) => a[this.options.attachments.attachmentFieldName]);
155
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', existingS3Paths);
156
+ yield deleteAttachmentRecords(adminforth, this.options, existingS3Paths, adminUser);
157
+ return { ok: true };
158
+ }));
159
+ }
65
160
  });
66
161
  }
67
162
  validateConfigAfterDiscover(adminforth, resourceConfig) {
package/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
 
2
- import type { IAdminForth, IHttpServer, AdminForthResource } from "adminforth";
3
- import { AdminForthPlugin } from "adminforth";
2
+ import type { IAdminForth, IHttpServer, AdminForthResource, AdminUser, AfterSaveFunction } from "adminforth";
4
3
  import type { PluginOptions } from './types.js';
4
+ import { AdminForthPlugin, Filters } from "adminforth";
5
+ import * as cheerio from 'cheerio';
6
+
5
7
 
6
8
  // options:
7
9
  // attachments: {
@@ -13,10 +15,14 @@ import type { PluginOptions } from './types.js';
13
15
 
14
16
  export default class RichEditorPlugin extends AdminForthPlugin {
15
17
  options: PluginOptions;
18
+ resourceConfig: AdminForthResource = undefined;
19
+
16
20
  uploadPlugin: AdminForthPlugin;
17
21
 
18
22
  activationOrder: number = 100000;
19
23
 
24
+ attachmentResource: AdminForthResource = undefined;
25
+
20
26
  constructor(options: PluginOptions) {
21
27
  super(options, import.meta.url);
22
28
  this.options = options;
@@ -35,16 +41,23 @@ export default class RichEditorPlugin extends AdminForthPlugin {
35
41
  if (!resource) {
36
42
  throw new Error(`Resource '${this.options.attachments!.attachmentResource}' not found`);
37
43
  }
44
+ this.attachmentResource = resource;
38
45
  const field = resource.columns.find(c => c.name === this.options.attachments!.attachmentFieldName);
39
46
  if (!field) {
40
47
  throw new Error(`Field '${this.options.attachments!.attachmentFieldName}' not found in resource '${this.options.attachments!.attachmentResource}'`);
41
48
  }
42
49
  const plugin = adminforth.activatedPlugins.find(p =>
43
50
  p.resourceConfig!.resourceId === this.options.attachments!.attachmentResource &&
44
- p.pluginOptions.pathColumnName === this.options.attachments!.attachmentFieldName);
51
+ p.pluginOptions.pathColumnName === this.options.attachments!.attachmentFieldName
52
+ );
45
53
  if (!plugin) {
46
54
  throw new Error(`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}`);
47
55
  }
56
+
57
+ if (plugin.pluginOptions.s3ACL !== 'public-read') {
58
+ throw new Error(`Upload Plugin for attachment field '${this.options.attachments!.attachmentFieldName}' in resource '${this.options.attachments!.attachmentResource}'
59
+ 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)`);
60
+ }
48
61
  this.uploadPlugin = plugin;
49
62
  }
50
63
 
@@ -64,6 +77,130 @@ export default class RichEditorPlugin extends AdminForthPlugin {
64
77
  c.components.create = filed;
65
78
  c.components.edit = filed;
66
79
 
80
+ const editorRecordPkField = resourceConfig.columns.find(c => c.primaryKey);
81
+
82
+
83
+ // if attachment configured we need a post-save hook to create attachment records for each image data-s3path
84
+ if (this.options.attachments) {
85
+
86
+ function getAttachmentPathes(html: string) {
87
+ if (!html) {
88
+ return [];
89
+ }
90
+ const $ = cheerio.load(html);
91
+ const s3Paths = [];
92
+ $('img[data-s3path]').each((i, el) => {
93
+ const src = $(el).attr('data-s3path');
94
+ s3Paths.push(src);
95
+ });
96
+ return s3Paths;
97
+ }
98
+
99
+ const createAttachmentRecords = async (
100
+ adminforth: IAdminForth, options: PluginOptions, recordId: any, s3Paths: string[], adminUser: AdminUser
101
+ ) => {
102
+ process.env.HEAVY_DEBUG && console.log('📸 Creating attachment records', JSON.stringify(recordId))
103
+ await Promise.all(s3Paths.map(async (s3Path) => {
104
+ await adminforth.createResourceRecord(
105
+ {
106
+ resource: this.attachmentResource,
107
+ record: {
108
+ [options.attachments.attachmentFieldName]: s3Path,
109
+ [options.attachments.attachmentRecordIdFieldName]: recordId,
110
+ [options.attachments.attachmentResourceIdFieldName]: resourceConfig.resourceId,
111
+ },
112
+ adminUser
113
+ }
114
+ )
115
+ }
116
+ ));
117
+ }
118
+
119
+ const deleteAttachmentRecords = async (
120
+ adminforth: IAdminForth, options: PluginOptions, s3Paths: string[], adminUser: AdminUser
121
+ ) => {
122
+
123
+ const attachmentPrimaryKeyField = this.attachmentResource.columns.find(c => c.primaryKey);
124
+
125
+ const attachments = await adminforth.resource(options.attachments.attachmentResource).list(
126
+ Filters.IN(options.attachments.attachmentFieldName, s3Paths)
127
+ );
128
+
129
+ await Promise.all(attachments.map(async (a: any) => {
130
+ await adminforth.deleteResourceRecord(
131
+ {
132
+ resource: this.attachmentResource,
133
+ recordId: a[attachmentPrimaryKeyField.name],
134
+ adminUser,
135
+ record: a,
136
+ }
137
+ )
138
+ }))
139
+ }
140
+
141
+
142
+ (resourceConfig.hooks.create.afterSave as Array<AfterSaveFunction>).push(async ({ record, adminUser }: { record: any, adminUser: AdminUser }) => {
143
+ // find all s3Paths in the html
144
+ const s3Paths = getAttachmentPathes(record[this.options.htmlFieldName])
145
+
146
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths', s3Paths);
147
+
148
+ // create attachment records
149
+ await createAttachmentRecords(
150
+ adminforth, this.options, record[editorRecordPkField.name], s3Paths, adminUser);
151
+
152
+ return { ok: true };
153
+ });
154
+
155
+ // after edit we need to delete attachments that are not in the html anymore
156
+ // and add new ones
157
+ (resourceConfig.hooks.edit.afterSave as Array<AfterSaveFunction>).push(
158
+ async ({ recordId, record, adminUser }: { recordId: any, record: any, adminUser: AdminUser }) => {
159
+ process.env.HEAVY_DEBUG && console.log('⚓ Cought hook', recordId, 'rec', record);
160
+ if (record[this.options.htmlFieldName] === undefined) {
161
+ // field was not changed, do nothing
162
+ return { ok: true };
163
+ }
164
+ const existingApparts = await adminforth.resource(this.options.attachments.attachmentResource).list([
165
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, recordId),
166
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
167
+ ]);
168
+ const existingS3Paths = existingApparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
169
+ const newS3Paths = getAttachmentPathes(record[this.options.htmlFieldName]);
170
+ process.env.HEAVY_DEBUG && console.log('📸 Existing s3Paths (from db)', existingS3Paths)
171
+ process.env.HEAVY_DEBUG && console.log('📸 Found new s3Paths (from text)', newS3Paths);
172
+ const toDelete = existingS3Paths.filter(s3Path => !newS3Paths.includes(s3Path));
173
+ const toAdd = newS3Paths.filter(s3Path => !existingS3Paths.includes(s3Path));
174
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', toDelete)
175
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to add', toAdd);
176
+
177
+ await Promise.all([
178
+ deleteAttachmentRecords(adminforth, this.options, toDelete, adminUser),
179
+ createAttachmentRecords(adminforth, this.options, recordId, toAdd, adminUser)
180
+ ]);
181
+
182
+ return { ok: true };
183
+
184
+ }
185
+ );
186
+
187
+ // after delete we need to delete all attachments
188
+ (resourceConfig.hooks.delete.afterSave as Array<AfterSaveFunction>).push(
189
+ async ({ record, adminUser }: { record: any, adminUser: AdminUser }) => {
190
+ const existingApparts = await adminforth.resource(this.options.attachments.attachmentResource).list(
191
+ [
192
+ Filters.EQ(this.options.attachments.attachmentRecordIdFieldName, record[editorRecordPkField.name]),
193
+ Filters.EQ(this.options.attachments.attachmentResourceIdFieldName, resourceConfig.resourceId)
194
+ ]
195
+ );
196
+ const existingS3Paths = existingApparts.map((a: any) => a[this.options.attachments.attachmentFieldName]);
197
+ process.env.HEAVY_DEBUG && console.log('📸 Found s3Paths to delete', existingS3Paths);
198
+ await deleteAttachmentRecords(adminforth, this.options, existingS3Paths, adminUser);
199
+
200
+ return { ok: true };
201
+ }
202
+ );
203
+ }
67
204
  }
68
205
 
69
206
  validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
@@ -72,7 +209,6 @@ export default class RichEditorPlugin extends AdminForthPlugin {
72
209
  throw new Error(`Invalid provider ${this.options.completion.provider}`);
73
210
  }
74
211
 
75
-
76
212
  }
77
213
 
78
214
  instanceUniqueRepresentation(pluginOptions: any) : string {
@@ -195,7 +331,7 @@ export default class RichEditorPlugin extends AdminForthPlugin {
195
331
  };
196
332
  }
197
333
  });
198
- }
199
334
 
335
+ }
200
336
 
201
337
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/rich-editor",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,5 +11,8 @@
11
11
  },
12
12
  "keywords": [],
13
13
  "author": "",
14
- "license": "ISC"
14
+ "license": "ISC",
15
+ "dependencies": {
16
+ "cheerio": "^1.0.0"
17
+ }
15
18
  }
package/types.ts CHANGED
@@ -149,20 +149,24 @@ export interface PluginOptions {
149
149
  * Field name in the attachment resource where image is stored. Should point to the existing field in the attachment resource.
150
150
  * Also there should be upload plugin installed on this field.
151
151
  */
152
- attachmentFieldName: 'image_path',
152
+ attachmentFieldName: string; // e.g. 'image_path',
153
153
 
154
154
  /**
155
- * When attachment is created, it will be linked to the record with this field name.
156
- * For example when RichEditor installed on description field of appartment resource,
157
- * field in attachment resource describet hear will store id of appartment record.
155
+ * When attachment is created, it will be linked to the record, by storing id of the record with editor in attachment resource.
156
+ * Here you define the field name where this id will be stored.
157
+ *
158
+ * Linking is needed to remove all attachments when record is deleted.
159
+ *
160
+ * For example when RichEditor installed on description field of apartment resource,
161
+ * field in attachment resource described hear will store id of apartment record.
158
162
  */
159
- attachmentRecordIdFieldName: 'record_id',
163
+ attachmentRecordIdFieldName: string; // e.g. 'apartment_id',
160
164
 
161
165
  /**
162
- * When attachment is created, it will be linked to the resource with this field name.
163
- * For example when RichEditor installed on description field of appartment resource, it will store id of appartment resource.
166
+ * When attachment is created, it will be linked to the resource, by storing id of the resource with editor in attachment resource.
167
+ * For example when RichEditor installed on description field of apartment resource, it will store id of apartment resource.
164
168
  */
165
- attachmentResourceIdFieldName: 'resource_id',
169
+ attachmentResourceIdFieldName: string; // e.g. 'apartment_resource_id',
166
170
  },
167
171
  }
168
172