@sd-angular/core 19.0.0-beta.80 → 19.0.0-beta.81
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/components/document-builder/src/document-builder.component.d.ts +2 -2
- package/components/document-builder/src/document-builder.model.d.ts +1 -1
- package/components/editor/index.d.ts +3 -0
- package/components/editor/src/configurations/editor.configuration.d.ts +12 -0
- package/components/editor/src/configurations/index.d.ts +1 -0
- package/components/editor/src/editor.component.d.ts +42 -0
- package/components/editor/src/models/editor.model.d.ts +8 -0
- package/components/editor/src/models/image-upload.plugin.model.d.ts +20 -0
- package/components/editor/src/models/index.d.ts +2 -0
- package/components/editor/src/plugins/image-upload/image-upload.plugin.d.ts +22 -0
- package/components/editor/src/plugins/image-upload/utils/batch.utils.d.ts +14 -0
- package/components/editor/src/plugins/image-upload/utils/index.d.ts +3 -0
- package/components/editor/src/plugins/image-upload/utils/style.utils.d.ts +2 -0
- package/components/editor/src/plugins/image-upload/utils/validate.utils.d.ts +3 -0
- package/components/index.d.ts +1 -0
- package/fesm2022/sd-angular-core-components-document-builder.mjs +0 -1
- package/fesm2022/sd-angular-core-components-document-builder.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-editor.mjs +933 -0
- package/fesm2022/sd-angular-core-components-editor.mjs.map +1 -0
- package/fesm2022/sd-angular-core-components.mjs +1 -0
- package/fesm2022/sd-angular-core-components.mjs.map +1 -1
- package/package.json +61 -57
- package/sd-angular-core-19.0.0-beta.81.tgz +0 -0
- package/sd-angular-core-19.0.0-beta.80.tgz +0 -0
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { InjectionToken, inject, DestroyRef, ChangeDetectorRef, input, booleanAttribute, computed, model, output, signal, effect, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
4
|
+
import { NgForm, FormGroup } from '@angular/forms';
|
|
5
|
+
import * as i2 from '@angular/material/form-field';
|
|
6
|
+
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
7
|
+
import * as i3 from '@angular/material/icon';
|
|
8
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
9
|
+
import * as i4 from '@angular/material/tooltip';
|
|
10
|
+
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
11
|
+
import * as i1 from '@ckeditor/ckeditor5-angular';
|
|
12
|
+
import { CKEditorModule } from '@ckeditor/ckeditor5-angular';
|
|
13
|
+
import { Plugin, FileRepository, Image, ImageResize, ImageUpload, ImageStyle, ImageToolbar, ClassicEditor, Essentials, FontColor, FontSize, Paragraph, Bold, Italic, Underline, List, Undo, Widget, Alignment } from 'ckeditor5';
|
|
14
|
+
import { Subject } from 'rxjs';
|
|
15
|
+
import { debounceTime } from 'rxjs/operators';
|
|
16
|
+
import * as uuid from 'uuid';
|
|
17
|
+
import { SdNotifyService } from '@sd-angular/core/services';
|
|
18
|
+
import { SdFormControl, HandleSdCustomValidator } from '@sd-angular/core/forms/models';
|
|
19
|
+
import { SdLabel } from '@sd-angular/core/forms/label';
|
|
20
|
+
|
|
21
|
+
const SD_EDITOR_CONFIGURATION = new InjectionToken('sd.editor.configuration');
|
|
22
|
+
|
|
23
|
+
// Dựa vào file để detect định dạng thay vì chỉ check tên
|
|
24
|
+
// Tránh user gửi file không đúng, ví dụ file docx nhưng đổi tên thành ảnh docx.jpg
|
|
25
|
+
const detectFormatFromBytes = async (file) => {
|
|
26
|
+
const buffer = await file.slice(0, 12).arrayBuffer();
|
|
27
|
+
const b = new Uint8Array(buffer);
|
|
28
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
|
|
29
|
+
return 'jpg';
|
|
30
|
+
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47)
|
|
31
|
+
return 'png';
|
|
32
|
+
if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57)
|
|
33
|
+
return 'webp';
|
|
34
|
+
if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38)
|
|
35
|
+
return 'gif';
|
|
36
|
+
if (b[4] === 0x66 && b[5] === 0x74 && b[6] === 0x79 && b[7] === 0x70) {
|
|
37
|
+
const brand = String.fromCharCode(b[8], b[9], b[10], b[11]);
|
|
38
|
+
if (brand === 'avif')
|
|
39
|
+
return 'avif';
|
|
40
|
+
if (brand.startsWith('hei'))
|
|
41
|
+
return 'heic';
|
|
42
|
+
}
|
|
43
|
+
return '';
|
|
44
|
+
};
|
|
45
|
+
const getImageInfo = async (file) => {
|
|
46
|
+
const sizeMB = file.size / (1024 * 1024);
|
|
47
|
+
const loadDimensions = new Promise(resolve => {
|
|
48
|
+
const url = URL.createObjectURL(file);
|
|
49
|
+
const img = new window.Image();
|
|
50
|
+
img.onload = () => {
|
|
51
|
+
URL.revokeObjectURL(url);
|
|
52
|
+
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
|
53
|
+
};
|
|
54
|
+
img.onerror = () => {
|
|
55
|
+
URL.revokeObjectURL(url);
|
|
56
|
+
resolve({ width: 0, height: 0 });
|
|
57
|
+
};
|
|
58
|
+
img.src = url;
|
|
59
|
+
});
|
|
60
|
+
const [format, { width, height }] = await Promise.all([detectFormatFromBytes(file), loadDimensions]);
|
|
61
|
+
return { width, height, sizeMB, format };
|
|
62
|
+
};
|
|
63
|
+
const validateImageFile = async (file, validation, onWarning) => {
|
|
64
|
+
const { width, height, sizeMB, format } = await getImageInfo(file);
|
|
65
|
+
if (validation.allowedFormats !== undefined) {
|
|
66
|
+
const normalizedFormats = validation.allowedFormats.map((f) => {
|
|
67
|
+
const fmt = f.replace(/^\./, '').toLowerCase();
|
|
68
|
+
return fmt === 'jpeg' ? 'jpg' : fmt;
|
|
69
|
+
});
|
|
70
|
+
if (!normalizedFormats.includes(format)) {
|
|
71
|
+
onWarning?.(`Định dạng file ".${format}" không được phép. Các định dạng hợp lệ: ${normalizedFormats.join(', ')}`);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (validation.maxSizeMB !== undefined && sizeMB > validation.maxSizeMB) {
|
|
76
|
+
onWarning?.(`Dung lượng file ${sizeMB.toFixed(2)}MB vượt quá giới hạn ${validation.maxSizeMB}MB`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (validation.minWidth !== undefined && width < validation.minWidth) {
|
|
80
|
+
onWarning?.(`Chiều rộng ảnh ${width}px nhỏ hơn giới hạn tối thiểu ${validation.minWidth}px`);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (validation.minHeight !== undefined && height < validation.minHeight) {
|
|
84
|
+
onWarning?.(`Chiều cao ảnh ${height}px nhỏ hơn giới hạn tối thiểu ${validation.minHeight}px`);
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (validation.maxWidth !== undefined && width > validation.maxWidth) {
|
|
88
|
+
onWarning?.(`Chiều rộng ảnh ${width}px vượt quá giới hạn ${validation.maxWidth}px`);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (validation.maxHeight !== undefined && height > validation.maxHeight) {
|
|
92
|
+
onWarning?.(`Chiều cao ảnh ${height}px vượt quá giới hạn ${validation.maxHeight}px`);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
};
|
|
97
|
+
const validateAndGetFile = async (loader, validation, onWarning) => {
|
|
98
|
+
const file = (await loader.file);
|
|
99
|
+
if (!file) {
|
|
100
|
+
throw new Error('No file found');
|
|
101
|
+
}
|
|
102
|
+
if (validation) {
|
|
103
|
+
const valid = await validateImageFile(file, validation, onWarning);
|
|
104
|
+
if (!valid) {
|
|
105
|
+
throw new Error('Image validation failed');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return file;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const getBatchConfig = (option) => {
|
|
112
|
+
const imageConfig = option?.imageConfig;
|
|
113
|
+
return {
|
|
114
|
+
batchSize: Math.max(1, imageConfig?.batchSize ?? 2),
|
|
115
|
+
maxConcurrent: Math.max(1, imageConfig?.maxConcurrentUploads ?? 2),
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
const getActiveBlobUrls = (editor) => {
|
|
119
|
+
const urls = new Set();
|
|
120
|
+
const root = editor.model.document.getRoot();
|
|
121
|
+
if (!root)
|
|
122
|
+
return urls;
|
|
123
|
+
for (const item of editor.model.createRangeIn(root).getItems()) {
|
|
124
|
+
if (item.is('element', 'imageBlock') || item.is('element', 'imageInline')) {
|
|
125
|
+
const src = item.getAttribute('src');
|
|
126
|
+
if (src?.startsWith('blob:'))
|
|
127
|
+
urls.add(src);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return urls;
|
|
131
|
+
};
|
|
132
|
+
const filterActivePendingFiles = (pendingFiles, activeBlobUrls) => {
|
|
133
|
+
const toUpload = [];
|
|
134
|
+
for (const [blobUrl, file] of pendingFiles) {
|
|
135
|
+
if (activeBlobUrls.has(blobUrl)) {
|
|
136
|
+
toUpload.push([blobUrl, file]);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
URL.revokeObjectURL(blobUrl);
|
|
140
|
+
pendingFiles.delete(blobUrl);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return toUpload;
|
|
144
|
+
};
|
|
145
|
+
const runBatchUploads = async (toUpload, uploadFn, batchSize, maxConcurrent) => {
|
|
146
|
+
const batches = [];
|
|
147
|
+
for (let i = 0; i < toUpload.length; i += batchSize) {
|
|
148
|
+
batches.push(toUpload.slice(i, i + batchSize));
|
|
149
|
+
}
|
|
150
|
+
const replacements = new Map();
|
|
151
|
+
const failedBatches = [];
|
|
152
|
+
let head = 0;
|
|
153
|
+
const runWorker = async () => {
|
|
154
|
+
while (head < batches.length) {
|
|
155
|
+
const batch = batches[head++];
|
|
156
|
+
try {
|
|
157
|
+
const details = await uploadFn(batch.map(([, file]) => file));
|
|
158
|
+
if (details.length !== batch.length) {
|
|
159
|
+
throw new Error(`API returned ${details.length} results for ${batch.length} files`);
|
|
160
|
+
}
|
|
161
|
+
batch.forEach(([blobUrl], idx) => {
|
|
162
|
+
replacements.set(blobUrl, details[idx]);
|
|
163
|
+
URL.revokeObjectURL(blobUrl);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
failedBatches.push(batch);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
await Promise.all(Array.from({ length: Math.min(maxConcurrent, batches.length) }, runWorker));
|
|
172
|
+
return { replacements, failedBatches };
|
|
173
|
+
};
|
|
174
|
+
const applyReplacementsToEditor = (editor, replacements) => {
|
|
175
|
+
if (replacements.size === 0)
|
|
176
|
+
return;
|
|
177
|
+
editor.model.change(writer => {
|
|
178
|
+
const root = editor.model.document.getRoot();
|
|
179
|
+
for (const item of editor.model.createRangeIn(root).getItems()) {
|
|
180
|
+
if (!item.is('element', 'imageBlock') && !item.is('element', 'imageInline'))
|
|
181
|
+
continue;
|
|
182
|
+
const detail = replacements.get(item.getAttribute('src'));
|
|
183
|
+
if (detail)
|
|
184
|
+
writer.setAttribute('src', detail.cdn, item);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const parseStyle = (style) => {
|
|
190
|
+
const map = new Map();
|
|
191
|
+
for (const decl of style.split(';')) {
|
|
192
|
+
const colon = decl.indexOf(':');
|
|
193
|
+
if (colon === -1)
|
|
194
|
+
continue;
|
|
195
|
+
const key = decl.slice(0, colon).trim();
|
|
196
|
+
const value = decl.slice(colon + 1).trim();
|
|
197
|
+
if (key && value)
|
|
198
|
+
map.set(key, value);
|
|
199
|
+
}
|
|
200
|
+
return map;
|
|
201
|
+
};
|
|
202
|
+
const serializeStyle = (map) => [...map.entries()].map(([k, v]) => `${k}:${v}`).join(';');
|
|
203
|
+
const countTextLength = (content) => {
|
|
204
|
+
if (!content)
|
|
205
|
+
return 0;
|
|
206
|
+
const doc = new DOMParser().parseFromString(content, 'text/html');
|
|
207
|
+
doc.querySelectorAll('img, figure').forEach(el => el.remove());
|
|
208
|
+
return doc.body.textContent?.length ?? 0;
|
|
209
|
+
};
|
|
210
|
+
const imageClassesToInlineStyles = (html) => {
|
|
211
|
+
if (!html)
|
|
212
|
+
return html;
|
|
213
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
214
|
+
doc.querySelectorAll('figure.image').forEach(figure => {
|
|
215
|
+
const styleMap = parseStyle(figure.getAttribute('style') || '');
|
|
216
|
+
const classList = figure.classList;
|
|
217
|
+
const hasResizeWidth = styleMap.has('width') && /\d/.test(styleMap.get('width'));
|
|
218
|
+
styleMap.set('display', 'block');
|
|
219
|
+
styleMap.set('max-width', '100%');
|
|
220
|
+
if (classList.contains('image-style-align-left')) {
|
|
221
|
+
if (hasResizeWidth && styleMap.get('width') === '100%') {
|
|
222
|
+
styleMap.delete('float');
|
|
223
|
+
styleMap.set('margin', '0 auto 0 0');
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
styleMap.set('float', 'left');
|
|
227
|
+
styleMap.set('margin', '0 1em 0 0');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else if (classList.contains('image-style-align-right')) {
|
|
231
|
+
if (hasResizeWidth && styleMap.get('width') === '100%') {
|
|
232
|
+
styleMap.delete('float');
|
|
233
|
+
styleMap.set('margin', '0 0 0 auto');
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
styleMap.set('float', 'right');
|
|
237
|
+
styleMap.set('margin', '0 0 0 1em');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else if (classList.contains('image-style-block-align-left')) {
|
|
241
|
+
styleMap.delete('float');
|
|
242
|
+
styleMap.set('margin', '0 auto 0 0');
|
|
243
|
+
}
|
|
244
|
+
else if (classList.contains('image-style-block-align-right')) {
|
|
245
|
+
styleMap.delete('float');
|
|
246
|
+
styleMap.set('margin', '0 0 0 auto');
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
styleMap.delete('float');
|
|
250
|
+
styleMap.set('margin', '0 auto');
|
|
251
|
+
}
|
|
252
|
+
if (!hasResizeWidth)
|
|
253
|
+
styleMap.set('width', 'fit-content');
|
|
254
|
+
figure.setAttribute('style', serializeStyle(styleMap));
|
|
255
|
+
if (hasResizeWidth) {
|
|
256
|
+
const img = figure.querySelector('img');
|
|
257
|
+
if (img) {
|
|
258
|
+
const imgStyle = parseStyle(img.getAttribute('style') || '');
|
|
259
|
+
imgStyle.set('width', '100%');
|
|
260
|
+
imgStyle.set('height', 'auto');
|
|
261
|
+
img.setAttribute('style', serializeStyle(imgStyle));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
return doc.body.innerHTML;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
class UploadQueue {
|
|
269
|
+
#queue = [];
|
|
270
|
+
#running = 0;
|
|
271
|
+
maxConcurrent;
|
|
272
|
+
constructor(maxConcurrent) {
|
|
273
|
+
this.maxConcurrent = maxConcurrent;
|
|
274
|
+
}
|
|
275
|
+
async run(task) {
|
|
276
|
+
if (this.#running >= this.maxConcurrent) {
|
|
277
|
+
await new Promise(resolve => {
|
|
278
|
+
this.#queue.push(resolve);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
this.#running++;
|
|
282
|
+
try {
|
|
283
|
+
return await task();
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
this.#running--;
|
|
287
|
+
this.#queue.shift()?.();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
class BatchUploadScheduler {
|
|
292
|
+
#pending = [];
|
|
293
|
+
#timer = null;
|
|
294
|
+
#batchSize;
|
|
295
|
+
#uploadFn;
|
|
296
|
+
#queue;
|
|
297
|
+
constructor(batchSize, uploadFn, queue) {
|
|
298
|
+
this.#batchSize = batchSize;
|
|
299
|
+
this.#uploadFn = uploadFn;
|
|
300
|
+
this.#queue = queue;
|
|
301
|
+
}
|
|
302
|
+
enqueue(file) {
|
|
303
|
+
return new Promise((resolve, reject) => {
|
|
304
|
+
this.#pending.push({ file, resolve, reject });
|
|
305
|
+
if (this.#timer) {
|
|
306
|
+
clearTimeout(this.#timer);
|
|
307
|
+
}
|
|
308
|
+
if (this.#pending.length >= this.#batchSize) {
|
|
309
|
+
this.#timer = null;
|
|
310
|
+
this.#flush();
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
this.#timer = setTimeout(() => this.#flush(), 50);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
destroy() {
|
|
318
|
+
if (this.#timer !== null) {
|
|
319
|
+
clearTimeout(this.#timer);
|
|
320
|
+
this.#timer = null;
|
|
321
|
+
}
|
|
322
|
+
this.#pending.splice(0).forEach(p => p.reject(new Error('Upload cancelled')));
|
|
323
|
+
}
|
|
324
|
+
#flush() {
|
|
325
|
+
this.#timer = null;
|
|
326
|
+
const all = this.#pending.splice(0);
|
|
327
|
+
if (all.length === 0) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
for (let i = 0; i < all.length; i += this.#batchSize) {
|
|
331
|
+
const batch = all.slice(i, i + this.#batchSize);
|
|
332
|
+
this.#queue
|
|
333
|
+
.run(() => this.#uploadFn(batch.map(p => p.file)))
|
|
334
|
+
.then(details => {
|
|
335
|
+
batch.forEach((p, idx) => {
|
|
336
|
+
if (details && details[idx]) {
|
|
337
|
+
p.resolve(details[idx]);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
p.reject(new Error('Upload failed: Missing details from server'));
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
})
|
|
344
|
+
.catch(err => {
|
|
345
|
+
batch.forEach(p => p.reject(err));
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
class DeferredImageUploadAdapter {
|
|
351
|
+
#loader;
|
|
352
|
+
#pendingFiles;
|
|
353
|
+
#imageMetaMap;
|
|
354
|
+
#validation;
|
|
355
|
+
#onWarning;
|
|
356
|
+
#lazyLoad;
|
|
357
|
+
#currentBlobUrl = null;
|
|
358
|
+
constructor(loader, pendingFiles, imageMetaMap, options) {
|
|
359
|
+
this.#loader = loader;
|
|
360
|
+
this.#pendingFiles = pendingFiles;
|
|
361
|
+
this.#imageMetaMap = imageMetaMap;
|
|
362
|
+
this.#validation = options.validation;
|
|
363
|
+
this.#onWarning = options.onWarning;
|
|
364
|
+
this.#lazyLoad = options.lazyLoad;
|
|
365
|
+
}
|
|
366
|
+
upload = async () => {
|
|
367
|
+
const file = await validateAndGetFile(this.#loader, this.#validation, this.#onWarning);
|
|
368
|
+
if (!file)
|
|
369
|
+
throw new Error('Image validation failed');
|
|
370
|
+
this.#currentBlobUrl = URL.createObjectURL(file);
|
|
371
|
+
this.#pendingFiles.set(this.#currentBlobUrl, file);
|
|
372
|
+
this.#imageMetaMap.set(this.#currentBlobUrl, { fileName: file.name, lazyLoad: this.#lazyLoad });
|
|
373
|
+
return { default: this.#currentBlobUrl };
|
|
374
|
+
};
|
|
375
|
+
abort() {
|
|
376
|
+
if (this.#currentBlobUrl) {
|
|
377
|
+
URL.revokeObjectURL(this.#currentBlobUrl);
|
|
378
|
+
this.#pendingFiles.delete(this.#currentBlobUrl);
|
|
379
|
+
this.#imageMetaMap.delete(this.#currentBlobUrl);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
class ImmediateImageUploadAdapter {
|
|
384
|
+
#loader;
|
|
385
|
+
#validation;
|
|
386
|
+
#onWarning;
|
|
387
|
+
#scheduler;
|
|
388
|
+
#imageMetaMap;
|
|
389
|
+
#lazyLoad;
|
|
390
|
+
constructor(loader, scheduler, imageMetaMap, options) {
|
|
391
|
+
this.#loader = loader;
|
|
392
|
+
this.#scheduler = scheduler;
|
|
393
|
+
this.#imageMetaMap = imageMetaMap;
|
|
394
|
+
this.#validation = options.validation;
|
|
395
|
+
this.#onWarning = options.onWarning;
|
|
396
|
+
this.#lazyLoad = options.lazyLoad;
|
|
397
|
+
}
|
|
398
|
+
upload = async () => {
|
|
399
|
+
const file = await validateAndGetFile(this.#loader, this.#validation, this.#onWarning);
|
|
400
|
+
if (!file)
|
|
401
|
+
throw new Error('Image validation failed');
|
|
402
|
+
const detail = await this.#scheduler.enqueue(file);
|
|
403
|
+
this.#imageMetaMap.set(detail.cdn, {
|
|
404
|
+
name: detail.name,
|
|
405
|
+
fileName: file.name,
|
|
406
|
+
idOrKey: detail.idOrKey,
|
|
407
|
+
lazyLoad: this.#lazyLoad,
|
|
408
|
+
});
|
|
409
|
+
return { default: detail.cdn };
|
|
410
|
+
};
|
|
411
|
+
abort() { }
|
|
412
|
+
}
|
|
413
|
+
class EditorImageUploadPlugin extends Plugin {
|
|
414
|
+
pendingFiles = new Map();
|
|
415
|
+
uploadQueue;
|
|
416
|
+
#batchScheduler;
|
|
417
|
+
#imageMetaMap = new Map();
|
|
418
|
+
setMeta(cdnUrl, meta) {
|
|
419
|
+
this.#imageMetaMap.set(cdnUrl, { name: meta.name, idOrKey: meta.idOrKey, lazyLoad: meta.lazyLoad });
|
|
420
|
+
}
|
|
421
|
+
static get pluginName() {
|
|
422
|
+
return 'EditorImageUploadPlugin';
|
|
423
|
+
}
|
|
424
|
+
static get requires() {
|
|
425
|
+
return [FileRepository, Image, ImageResize, ImageUpload, ImageStyle, ImageToolbar];
|
|
426
|
+
}
|
|
427
|
+
init() {
|
|
428
|
+
const editor = this.editor;
|
|
429
|
+
const getOption = editor.config.get('getOption');
|
|
430
|
+
const maxConcurrent = getOption?.()?.imageConfig?.maxConcurrentUploads ?? 2;
|
|
431
|
+
this.uploadQueue = new UploadQueue(maxConcurrent);
|
|
432
|
+
this.#registerSchemaAndConverters();
|
|
433
|
+
this.#listenForSrcChanges();
|
|
434
|
+
editor.editing.view.document.on('clipboardInput', (evt, data) => {
|
|
435
|
+
const dataTransfer = data.dataTransfer;
|
|
436
|
+
if (!dataTransfer)
|
|
437
|
+
return;
|
|
438
|
+
const hasImageFiles = Array.from(dataTransfer.files ?? []).some(f => f.type.startsWith('image/'));
|
|
439
|
+
const hasImageInHtml = /<img[\s>]/i.test(dataTransfer.getData('text/html') ?? '');
|
|
440
|
+
if (hasImageFiles || hasImageInHtml) {
|
|
441
|
+
evt.stop();
|
|
442
|
+
}
|
|
443
|
+
}, { priority: 'highest' });
|
|
444
|
+
const uploadImageCommand = editor.commands.get('uploadImage');
|
|
445
|
+
if (uploadImageCommand) {
|
|
446
|
+
uploadImageCommand.on('execute', (evt, args) => {
|
|
447
|
+
const options = args[0] || {};
|
|
448
|
+
const option = getOption?.();
|
|
449
|
+
const maxPerSelection = option?.imageConfig?.maxImagesPerSelection;
|
|
450
|
+
if (maxPerSelection !== undefined) {
|
|
451
|
+
const files = Array.isArray(options?.file) ? options.file : options?.file ? [options.file] : [];
|
|
452
|
+
if (files.length > maxPerSelection) {
|
|
453
|
+
option?.onWarning?.(`Chỉ được phép chọn tối đa ${maxPerSelection} ảnh mỗi lần`);
|
|
454
|
+
evt.stop();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}, { priority: 'high' });
|
|
458
|
+
}
|
|
459
|
+
editor.once('ready', () => {
|
|
460
|
+
const notification = editor.plugins.get('Notification');
|
|
461
|
+
notification?.on('show:warning', (evt) => {
|
|
462
|
+
evt.stop();
|
|
463
|
+
}, { priority: 'highest' });
|
|
464
|
+
});
|
|
465
|
+
editor.plugins.get('FileRepository').createUploadAdapter = (loader) => {
|
|
466
|
+
const option = getOption?.();
|
|
467
|
+
const imageConfig = option?.imageConfig;
|
|
468
|
+
const uploadMode = imageConfig?.uploadMode ?? 'deferred';
|
|
469
|
+
const validation = imageConfig?.validation;
|
|
470
|
+
const onWarning = option?.onWarning;
|
|
471
|
+
const lazyLoad = imageConfig?.lazyLoad ?? true;
|
|
472
|
+
if (uploadMode === 'deferred') {
|
|
473
|
+
return new DeferredImageUploadAdapter(loader, this.pendingFiles, this.#imageMetaMap, { validation, onWarning, lazyLoad });
|
|
474
|
+
}
|
|
475
|
+
if (!this.#batchScheduler) {
|
|
476
|
+
const uploadFn = option?.uploadFn;
|
|
477
|
+
if (uploadFn) {
|
|
478
|
+
const batchSize = imageConfig?.batchSize ?? 2;
|
|
479
|
+
this.#batchScheduler = new BatchUploadScheduler(batchSize, uploadFn, this.uploadQueue);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (!this.#batchScheduler) {
|
|
483
|
+
throw new Error('No upload function configured');
|
|
484
|
+
}
|
|
485
|
+
return new ImmediateImageUploadAdapter(loader, this.#batchScheduler, this.#imageMetaMap, { validation, onWarning, lazyLoad });
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
destroy() {
|
|
489
|
+
super.destroy();
|
|
490
|
+
this.#batchScheduler?.destroy();
|
|
491
|
+
for (const blobUrl of this.pendingFiles.keys()) {
|
|
492
|
+
URL.revokeObjectURL(blobUrl);
|
|
493
|
+
}
|
|
494
|
+
this.pendingFiles.clear();
|
|
495
|
+
this.#imageMetaMap.clear();
|
|
496
|
+
}
|
|
497
|
+
#registerSchemaAndConverters() {
|
|
498
|
+
const editor = this.editor;
|
|
499
|
+
const schema = editor.model.schema;
|
|
500
|
+
for (const type of ['imageBlock', 'imageInline']) {
|
|
501
|
+
if (schema.isRegistered(type)) {
|
|
502
|
+
schema.extend(type, { allowAttributes: ['loading', 'imageId'] });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// Downcast: model → HTML view
|
|
506
|
+
editor.conversion.for('downcast').add((dispatcher) => {
|
|
507
|
+
for (const [modelAttr, htmlAttr] of [
|
|
508
|
+
['loading', 'loading'],
|
|
509
|
+
['imageId', 'id'],
|
|
510
|
+
]) {
|
|
511
|
+
const handleAttribute = (isBlock) => (evt, data, conversionApi) => {
|
|
512
|
+
if (!conversionApi.consumable.consume(data.item, evt.name))
|
|
513
|
+
return;
|
|
514
|
+
const viewElement = conversionApi.mapper.toViewElement(data.item);
|
|
515
|
+
const viewImg = isBlock ? this.#findImg(viewElement) : viewElement;
|
|
516
|
+
if (!viewImg)
|
|
517
|
+
return;
|
|
518
|
+
if (data.attributeNewValue != null) {
|
|
519
|
+
conversionApi.writer.setAttribute(htmlAttr, String(data.attributeNewValue), viewImg);
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
conversionApi.writer.removeAttribute(htmlAttr, viewImg);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
dispatcher.on(`attribute:${modelAttr}:imageBlock`, handleAttribute(true));
|
|
526
|
+
dispatcher.on(`attribute:${modelAttr}:imageInline`, handleAttribute(false));
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
// Upcast: HTML → model
|
|
530
|
+
editor.conversion.for('upcast').add((dispatcher) => {
|
|
531
|
+
dispatcher.on('element:figure', (_evt, data, conversionApi) => {
|
|
532
|
+
if (!data.viewItem.hasClass('image'))
|
|
533
|
+
return;
|
|
534
|
+
const modelElement = data.modelRange?.start?.nodeAfter;
|
|
535
|
+
if (!modelElement)
|
|
536
|
+
return;
|
|
537
|
+
const viewImg = this.#findImg(data.viewItem);
|
|
538
|
+
if (!viewImg)
|
|
539
|
+
return;
|
|
540
|
+
this.#transferHtmlAttrsToModel(viewImg, modelElement, conversionApi);
|
|
541
|
+
}, { priority: 'low' });
|
|
542
|
+
dispatcher.on('element:img', (_evt, data, conversionApi) => {
|
|
543
|
+
const modelElement = data.modelRange?.start?.nodeAfter;
|
|
544
|
+
if (!modelElement)
|
|
545
|
+
return;
|
|
546
|
+
this.#transferHtmlAttrsToModel(data.viewItem, modelElement, conversionApi);
|
|
547
|
+
}, { priority: 'low' });
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
#listenForSrcChanges() {
|
|
551
|
+
const editor = this.editor;
|
|
552
|
+
editor.model.document.on('change', () => {
|
|
553
|
+
const changes = editor.model.document.differ.getChanges();
|
|
554
|
+
const updates = [];
|
|
555
|
+
for (const change of changes) {
|
|
556
|
+
if (change.type !== 'attribute' || change.attributeKey !== 'src')
|
|
557
|
+
continue;
|
|
558
|
+
const newSrc = change.attributeNewValue;
|
|
559
|
+
const meta = this.#imageMetaMap.get(newSrc);
|
|
560
|
+
if (!meta)
|
|
561
|
+
continue;
|
|
562
|
+
const node = change.range?.start?.nodeAfter;
|
|
563
|
+
if (node?.is('element')) {
|
|
564
|
+
updates.push({ element: node, meta });
|
|
565
|
+
this.#imageMetaMap.delete(newSrc);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (updates.length === 0)
|
|
569
|
+
return;
|
|
570
|
+
editor.model.enqueueChange('transparent', (writer) => {
|
|
571
|
+
for (const { element, meta } of updates) {
|
|
572
|
+
if (meta.name || meta.fileName) {
|
|
573
|
+
writer.setAttribute('alt', meta.name || this.#generateAlt(meta.fileName), element);
|
|
574
|
+
}
|
|
575
|
+
if (meta.lazyLoad) {
|
|
576
|
+
writer.setAttribute('loading', 'lazy', element);
|
|
577
|
+
}
|
|
578
|
+
if (meta.idOrKey) {
|
|
579
|
+
writer.setAttribute('imageId', meta.idOrKey, element);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
#findImg(viewElement) {
|
|
586
|
+
if (!viewElement)
|
|
587
|
+
return null;
|
|
588
|
+
if (viewElement.is?.('element', 'img'))
|
|
589
|
+
return viewElement;
|
|
590
|
+
for (const child of viewElement.getChildren?.() ?? []) {
|
|
591
|
+
if (child.is?.('element', 'img'))
|
|
592
|
+
return child;
|
|
593
|
+
}
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
#transferHtmlAttrsToModel(viewImg, modelElement, conversionApi) {
|
|
597
|
+
const schema = this.editor.model.schema;
|
|
598
|
+
if (viewImg.hasAttribute('loading') && schema.checkAttribute(modelElement, 'loading')) {
|
|
599
|
+
conversionApi.writer.setAttribute('loading', viewImg.getAttribute('loading'), modelElement);
|
|
600
|
+
}
|
|
601
|
+
if (viewImg.hasAttribute('id') && schema.checkAttribute(modelElement, 'imageId')) {
|
|
602
|
+
conversionApi.writer.setAttribute('imageId', viewImg.getAttribute('id'), modelElement);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
#generateAlt(fileName) {
|
|
606
|
+
if (!fileName)
|
|
607
|
+
return 'Image';
|
|
608
|
+
const nameWithoutExt = fileName.replace(/\.[^.]+$/, '');
|
|
609
|
+
const result = nameWithoutExt
|
|
610
|
+
.replace(/[_\-\.]+/g, ' ')
|
|
611
|
+
.replace(/\s+/g, ' ')
|
|
612
|
+
.trim();
|
|
613
|
+
return result || 'Image';
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
class SdEditor {
|
|
618
|
+
// Injected
|
|
619
|
+
#destroyRef = inject(DestroyRef);
|
|
620
|
+
#cdRef = inject(ChangeDetectorRef);
|
|
621
|
+
#uploadConfig = inject(SD_EDITOR_CONFIGURATION, { optional: true });
|
|
622
|
+
#notifyService = inject(SdNotifyService);
|
|
623
|
+
// Input
|
|
624
|
+
option = input({});
|
|
625
|
+
height = input('250px');
|
|
626
|
+
maxHeight = input('250px');
|
|
627
|
+
maxlength = input(undefined);
|
|
628
|
+
label = input();
|
|
629
|
+
helperText = input();
|
|
630
|
+
required = input(false, { transform: booleanAttribute });
|
|
631
|
+
disabled = input(false, { transform: booleanAttribute });
|
|
632
|
+
readonly = input(false, { transform: booleanAttribute });
|
|
633
|
+
hideInlineError = input(false, { transform: booleanAttribute });
|
|
634
|
+
inlineError = input();
|
|
635
|
+
placeholder = input();
|
|
636
|
+
validator = input();
|
|
637
|
+
form = input(undefined, {
|
|
638
|
+
transform: (val) => {
|
|
639
|
+
if (!val)
|
|
640
|
+
return undefined;
|
|
641
|
+
if (val instanceof NgForm)
|
|
642
|
+
return val.form;
|
|
643
|
+
if (val instanceof FormGroup)
|
|
644
|
+
return val;
|
|
645
|
+
if (val?.form instanceof FormGroup)
|
|
646
|
+
return val.form;
|
|
647
|
+
return undefined;
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
key = input(undefined);
|
|
651
|
+
autoIdInput = input(undefined, { alias: 'autoId' });
|
|
652
|
+
autoId = computed(() => (this.autoIdInput() ? `forms-editor-${this.autoIdInput()}` : undefined));
|
|
653
|
+
name = input(uuid.v4());
|
|
654
|
+
valueModel = model('', { alias: 'model' });
|
|
655
|
+
// Output
|
|
656
|
+
sdChange = output();
|
|
657
|
+
sdBlur = output();
|
|
658
|
+
sdFocus = output();
|
|
659
|
+
// State
|
|
660
|
+
_textLength = signal(0);
|
|
661
|
+
formControl = new SdFormControl();
|
|
662
|
+
isOverLimit = computed(() => {
|
|
663
|
+
const max = this.maxlength();
|
|
664
|
+
return max !== undefined && this._textLength() > max;
|
|
665
|
+
});
|
|
666
|
+
get errorMessage() {
|
|
667
|
+
const errors = this.formControl.errors;
|
|
668
|
+
if (!errors)
|
|
669
|
+
return undefined;
|
|
670
|
+
if (errors['required'])
|
|
671
|
+
return 'Vui lòng nhập nội dung';
|
|
672
|
+
if (errors['maxlength'])
|
|
673
|
+
return `Số ký tự tối đa: ${this.maxlength()}`;
|
|
674
|
+
if (errors['customValidator'])
|
|
675
|
+
return errors['customValidator'];
|
|
676
|
+
if (errors['inlineError'])
|
|
677
|
+
return this.inlineError();
|
|
678
|
+
return undefined;
|
|
679
|
+
}
|
|
680
|
+
Editor = ClassicEditor;
|
|
681
|
+
#editor;
|
|
682
|
+
#contentChangeSubject = new Subject();
|
|
683
|
+
editorConfig = computed(() => {
|
|
684
|
+
const opt = this.option();
|
|
685
|
+
const uploadFn = this.#buildUploadFn();
|
|
686
|
+
const enableImageUpload = !!uploadFn;
|
|
687
|
+
const plugins = [Essentials, FontColor, FontSize, Paragraph, Bold, Italic, Underline, List, Undo, Widget, Alignment];
|
|
688
|
+
if (enableImageUpload)
|
|
689
|
+
plugins.push(EditorImageUploadPlugin);
|
|
690
|
+
const toolbarItems = [
|
|
691
|
+
'bold',
|
|
692
|
+
'italic',
|
|
693
|
+
'underline',
|
|
694
|
+
'|',
|
|
695
|
+
'fontSize',
|
|
696
|
+
'fontColor',
|
|
697
|
+
'|',
|
|
698
|
+
'alignment:left',
|
|
699
|
+
'alignment:center',
|
|
700
|
+
'alignment:right',
|
|
701
|
+
'alignment:justify',
|
|
702
|
+
'|',
|
|
703
|
+
'bulletedList',
|
|
704
|
+
'numberedList',
|
|
705
|
+
];
|
|
706
|
+
if (enableImageUpload)
|
|
707
|
+
toolbarItems.push('|', 'uploadImage');
|
|
708
|
+
const config = {
|
|
709
|
+
licenseKey: 'GPL',
|
|
710
|
+
getOption: () => ({
|
|
711
|
+
...opt,
|
|
712
|
+
uploadFn,
|
|
713
|
+
onWarning: (msg) => this.#notifyService.warning(msg),
|
|
714
|
+
}),
|
|
715
|
+
plugins,
|
|
716
|
+
toolbar: { items: toolbarItems, shouldNotGroupWhenFull: true },
|
|
717
|
+
placeholder: this.placeholder(),
|
|
718
|
+
fontColor: { columns: 5, documentColors: 10, colorPicker: { format: 'hex' } },
|
|
719
|
+
fontSize: { options: [10, 11, 12, 14, 16, 18, 20], supportAllValues: true },
|
|
720
|
+
};
|
|
721
|
+
if (enableImageUpload) {
|
|
722
|
+
config.image = {
|
|
723
|
+
resizeUnit: '%',
|
|
724
|
+
resizeOptions: [
|
|
725
|
+
{ name: 'resizeImage:100', value: '100', label: '100%' },
|
|
726
|
+
{ name: 'resizeImage:75', value: '75', label: '75%' },
|
|
727
|
+
{ name: 'resizeImage:50', value: '50', label: '50%' },
|
|
728
|
+
{ name: 'resizeImage:25', value: '25', label: '25%' },
|
|
729
|
+
{ name: 'resizeImage:original', value: null, label: 'Original' },
|
|
730
|
+
],
|
|
731
|
+
toolbar: ['resizeImage', '|', 'imageStyle:alignBlockLeft', 'imageStyle:alignCenter', 'imageStyle:alignBlockRight'],
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
return config;
|
|
735
|
+
});
|
|
736
|
+
constructor() {
|
|
737
|
+
this.#validateDuplicateConfigKeys();
|
|
738
|
+
this.#setupEffects();
|
|
739
|
+
this.#setupSubscriptions();
|
|
740
|
+
this.#setupDestroy();
|
|
741
|
+
}
|
|
742
|
+
// Public API
|
|
743
|
+
onReady = (editor) => {
|
|
744
|
+
this.#editor = editor;
|
|
745
|
+
const currentValue = this.valueModel();
|
|
746
|
+
if (currentValue)
|
|
747
|
+
this.#setContent(currentValue);
|
|
748
|
+
if (this.formControl.disabled)
|
|
749
|
+
editor.enableReadOnlyMode('sd-editor');
|
|
750
|
+
if (this.readonly())
|
|
751
|
+
editor.enableReadOnlyMode('sd-editor-readonly');
|
|
752
|
+
editor.model.document.registerPostFixer(writer => {
|
|
753
|
+
let changed = false;
|
|
754
|
+
for (const change of editor.model.document.differ.getChanges()) {
|
|
755
|
+
if (change.type === 'insert' && (change.name === 'imageBlock' || change.name === 'imageInline')) {
|
|
756
|
+
const element = change.position.nodeAfter;
|
|
757
|
+
if (element && !element.hasAttribute('resizedWidth')) {
|
|
758
|
+
writer.setAttribute('resizedWidth', '100%', element);
|
|
759
|
+
changed = true;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return changed;
|
|
764
|
+
});
|
|
765
|
+
editor.model.document.on('change:data', () => this.#contentChangeSubject.next(editor.getData()));
|
|
766
|
+
editor.editing.view.document.on('focus', evt => this.sdFocus.emit(evt.domEvent));
|
|
767
|
+
editor.editing.view.document.on('blur', evt => {
|
|
768
|
+
this.sdBlur.emit(evt.domEvent);
|
|
769
|
+
this.formControl.markAsTouched();
|
|
770
|
+
this.#cdRef.markForCheck();
|
|
771
|
+
});
|
|
772
|
+
};
|
|
773
|
+
focusEditor = () => this.#editor?.editing?.view?.focus?.();
|
|
774
|
+
#uploadImages = async () => {
|
|
775
|
+
const uploadMode = this.option()?.imageConfig?.uploadMode;
|
|
776
|
+
if (uploadMode !== 'deferred')
|
|
777
|
+
return;
|
|
778
|
+
const uploadFn = this.#buildUploadFn();
|
|
779
|
+
if (!uploadFn) {
|
|
780
|
+
throw new Error('No upload function configured for image upload');
|
|
781
|
+
}
|
|
782
|
+
const plugin = this.#editor?.plugins?.get(EditorImageUploadPlugin);
|
|
783
|
+
if (!plugin || plugin.pendingFiles.size === 0)
|
|
784
|
+
return;
|
|
785
|
+
const { batchSize, maxConcurrent } = getBatchConfig(this.option());
|
|
786
|
+
const activeBlobUrls = this.#editor ? getActiveBlobUrls(this.#editor) : new Set();
|
|
787
|
+
const toUpload = filterActivePendingFiles(plugin.pendingFiles, activeBlobUrls);
|
|
788
|
+
// Capture trước await để tránh race condition: ảnh được chèn trong lúc chờ API
|
|
789
|
+
const processedBlobUrls = new Set(toUpload.map(([blobUrl]) => blobUrl));
|
|
790
|
+
const { replacements, failedBatches } = await runBatchUploads(toUpload, uploadFn, batchSize, maxConcurrent);
|
|
791
|
+
if (this.#editor) {
|
|
792
|
+
const lazyLoad = this.option()?.imageConfig?.lazyLoad ?? true;
|
|
793
|
+
for (const detail of replacements.values()) {
|
|
794
|
+
plugin.setMeta(detail.cdn, { name: detail.name, idOrKey: detail.idOrKey, lazyLoad });
|
|
795
|
+
}
|
|
796
|
+
applyReplacementsToEditor(this.#editor, replacements);
|
|
797
|
+
}
|
|
798
|
+
// Chỉ xóa đúng các entries đã được xử lý
|
|
799
|
+
for (const blobUrl of processedBlobUrls) {
|
|
800
|
+
plugin.pendingFiles.delete(blobUrl);
|
|
801
|
+
}
|
|
802
|
+
for (const [blobUrl, file] of failedBatches.flat()) {
|
|
803
|
+
plugin.pendingFiles.set(blobUrl, file);
|
|
804
|
+
}
|
|
805
|
+
if (failedBatches.length > 0) {
|
|
806
|
+
throw new Error(`Upload failed for ${failedBatches.flat().length} image(s)`);
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
upload = async () => {
|
|
810
|
+
await this.#uploadImages();
|
|
811
|
+
return this.#getContent();
|
|
812
|
+
};
|
|
813
|
+
// Setup
|
|
814
|
+
#setupEffects = () => {
|
|
815
|
+
effect(() => {
|
|
816
|
+
this.disabled() ? this.formControl.disable({ emitEvent: false }) : this.formControl.enable({ emitEvent: false });
|
|
817
|
+
});
|
|
818
|
+
effect(() => {
|
|
819
|
+
if (!this.#editor)
|
|
820
|
+
return;
|
|
821
|
+
this.readonly() ? this.#editor.enableReadOnlyMode('sd-editor-readonly') : this.#editor.disableReadOnlyMode('sd-editor-readonly');
|
|
822
|
+
});
|
|
823
|
+
effect(() => {
|
|
824
|
+
const val = this.valueModel();
|
|
825
|
+
if (this.formControl.value !== val) {
|
|
826
|
+
this.formControl.setValue(val, { emitEvent: false });
|
|
827
|
+
if (this.#editor)
|
|
828
|
+
this.#setContent(val);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
effect(() => this.form()?.addControl(this.name(), this.formControl));
|
|
832
|
+
effect(() => {
|
|
833
|
+
this.required();
|
|
834
|
+
this.maxlength();
|
|
835
|
+
this.inlineError();
|
|
836
|
+
this.validator();
|
|
837
|
+
this.#updateValidators();
|
|
838
|
+
});
|
|
839
|
+
};
|
|
840
|
+
#setupSubscriptions = () => {
|
|
841
|
+
this.formControl.valueChanges.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((val) => {
|
|
842
|
+
const v = val ?? '';
|
|
843
|
+
this.valueModel.set(v);
|
|
844
|
+
this._textLength.set(countTextLength(v));
|
|
845
|
+
if (this.#editor) {
|
|
846
|
+
this.#setContent(v);
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
this.formControl.sdChanges.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe(() => this.#cdRef.markForCheck());
|
|
850
|
+
this.#contentChangeSubject.pipe(debounceTime(100), takeUntilDestroyed(this.#destroyRef)).subscribe(content => {
|
|
851
|
+
const out = imageClassesToInlineStyles(content);
|
|
852
|
+
this._textLength.set(countTextLength(out));
|
|
853
|
+
if (this.formControl.value !== out)
|
|
854
|
+
this.formControl.setValue(out, { emitEvent: false });
|
|
855
|
+
this.formControl.updateValueAndValidity({ emitEvent: false });
|
|
856
|
+
this.formControl.sdChanges.next(true);
|
|
857
|
+
this.valueModel.set(out);
|
|
858
|
+
this.sdChange.emit(out);
|
|
859
|
+
});
|
|
860
|
+
};
|
|
861
|
+
#setupDestroy = () => {
|
|
862
|
+
this.#destroyRef.onDestroy(() => {
|
|
863
|
+
this.form()?.removeControl(this.name());
|
|
864
|
+
this.#editor?.destroy?.();
|
|
865
|
+
});
|
|
866
|
+
};
|
|
867
|
+
// Helpers
|
|
868
|
+
#setContent = (content) => this.#editor?.setData?.(content);
|
|
869
|
+
#getContent = () => (this.#editor ? imageClassesToInlineStyles(this.#editor.getData()) : '');
|
|
870
|
+
#getConfigurations = () => {
|
|
871
|
+
const config = this.#uploadConfig;
|
|
872
|
+
if (!config)
|
|
873
|
+
return [];
|
|
874
|
+
return Array.isArray(config) ? config : [config];
|
|
875
|
+
};
|
|
876
|
+
#validateDuplicateConfigKeys() {
|
|
877
|
+
const config = this.#uploadConfig;
|
|
878
|
+
if (!config)
|
|
879
|
+
return;
|
|
880
|
+
const configurations = Array.isArray(config) ? config : [config];
|
|
881
|
+
const seen = new Set();
|
|
882
|
+
for (const cfg of configurations) {
|
|
883
|
+
const normalizedKey = cfg.key === undefined ? '__undefined__' : cfg.key;
|
|
884
|
+
if (seen.has(normalizedKey)) {
|
|
885
|
+
const label = cfg.key === undefined ? 'undefined' : cfg.key;
|
|
886
|
+
throw new Error(`[sd-editor] Duplicate upload configuration key detected: ${label}`);
|
|
887
|
+
}
|
|
888
|
+
seen.add(normalizedKey);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
#getSelectedConfig = () => {
|
|
892
|
+
const configurations = this.#getConfigurations();
|
|
893
|
+
if (!configurations.length)
|
|
894
|
+
return undefined;
|
|
895
|
+
return configurations.find(cfg => cfg.key === this.key());
|
|
896
|
+
};
|
|
897
|
+
#buildUploadFn = () => this.option()?.imageConfig?.uploadFn ?? this.#getSelectedConfig()?.upload;
|
|
898
|
+
#updateValidators = () => {
|
|
899
|
+
const validators = [];
|
|
900
|
+
const asyncValidators = [];
|
|
901
|
+
if (this.required()) {
|
|
902
|
+
validators.push(() => (this._textLength() ? null : { required: true }));
|
|
903
|
+
}
|
|
904
|
+
const max = this.maxlength();
|
|
905
|
+
if (max !== undefined) {
|
|
906
|
+
validators.push(() => (this._textLength() > max ? { maxlength: { requiredLength: max, actualLength: this._textLength() } } : null));
|
|
907
|
+
}
|
|
908
|
+
const inl = this.inlineError();
|
|
909
|
+
if (inl) {
|
|
910
|
+
validators.push(() => ({ inlineError: true }));
|
|
911
|
+
}
|
|
912
|
+
const val = this.validator();
|
|
913
|
+
if (val) {
|
|
914
|
+
asyncValidators.push(HandleSdCustomValidator(val));
|
|
915
|
+
}
|
|
916
|
+
this.formControl.setValidators(validators.length ? validators : null);
|
|
917
|
+
this.formControl.setAsyncValidators(asyncValidators.length ? asyncValidators : null);
|
|
918
|
+
this.formControl.updateValueAndValidity({ emitEvent: false });
|
|
919
|
+
};
|
|
920
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: SdEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
921
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.17", type: SdEditor, isStandalone: true, selector: "sd-editor", inputs: { option: { classPropertyName: "option", publicName: "option", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, maxHeight: { classPropertyName: "maxHeight", publicName: "maxHeight", isSignal: true, isRequired: false, transformFunction: null }, maxlength: { classPropertyName: "maxlength", publicName: "maxlength", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, helperText: { classPropertyName: "helperText", publicName: "helperText", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, readonly: { classPropertyName: "readonly", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, hideInlineError: { classPropertyName: "hideInlineError", publicName: "hideInlineError", isSignal: true, isRequired: false, transformFunction: null }, inlineError: { classPropertyName: "inlineError", publicName: "inlineError", isSignal: true, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: true, isRequired: false, transformFunction: null }, validator: { classPropertyName: "validator", publicName: "validator", isSignal: true, isRequired: false, transformFunction: null }, form: { classPropertyName: "form", publicName: "form", isSignal: true, isRequired: false, transformFunction: null }, key: { classPropertyName: "key", publicName: "key", isSignal: true, isRequired: false, transformFunction: null }, autoIdInput: { classPropertyName: "autoIdInput", publicName: "autoId", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, valueModel: { classPropertyName: "valueModel", publicName: "model", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { valueModel: "modelChange", sdChange: "sdChange", sdBlur: "sdBlur", sdFocus: "sdFocus" }, ngImport: i0, template: "@let lbl = label();\r\n@let hText = helperText();\r\n@let hideErr = hideInlineError();\r\n@let errMsg = errorMessage;\r\n@let req = required();\r\n@let maxLen = maxlength();\r\n@let showCounter = !!maxLen && !formControl.disabled && !hideErr;\r\n\r\n@if (lbl) {\r\n <sd-label [label]=\"lbl\" [required]=\"req\" [helperText]=\"hText\"></sd-label>\r\n}\r\n\r\n<div\r\n class=\"sd-editor\"\r\n [class.sd-editor--readonly]=\"readonly()\"\r\n [class.sd-editor--error]=\"formControl.invalid && formControl.touched\"\r\n [class.has-counter]=\"showCounter\"\r\n [style.--sd-editor-content-min-height]=\"height()\"\r\n [style.--sd-editor-content-max-height]=\"maxHeight()\"\r\n [attr.data-autoId]=\"autoId()\">\r\n <ckeditor [editor]=\"Editor\" [config]=\"editorConfig()\" [disabled]=\"formControl.disabled\" (ready)=\"onReady($event)\"></ckeditor>\r\n\r\n @if (showCounter) {\r\n <div class=\"sd-editor-counter-row\">\r\n <span class=\"sd-editor-counter\" [class.sd-editor-counter--over]=\"isOverLimit()\"> {{ _textLength() }}/{{ maxLen }} </span>\r\n </div>\r\n }\r\n\r\n @if (hideErr && errMsg && formControl.touched) {\r\n <mat-icon class=\"sd-error-icon\" [matTooltip]=\"errMsg\" matTooltipPosition=\"above\">error</mat-icon>\r\n }\r\n</div>\r\n\r\n<div class=\"sd-editor-footer\">\r\n @if (!hideErr && errMsg && formControl.touched) {\r\n <mat-error>{{ errMsg }}</mat-error>\r\n }\r\n</div>\r\n", styles: [".sd-editor{display:flex;flex-direction:column;width:100%;position:relative}.sd-editor-counter-row{display:flex;justify-content:flex-end;align-items:center;padding:4px 12px;border:1px solid var(--ck-color-base-border);border-top:none}.sd-editor-footer{min-height:20px;margin-top:4px}.sd-editor-footer mat-error{font-size:12px;color:var(--mdc-theme-error, #f44336)}.sd-error-icon{position:absolute;right:8px;bottom:8px;z-index:2;color:var(--mdc-theme-error, #f44336);height:20px;width:20px;font-size:20px;cursor:default}.sd-editor--readonly :host ::ng-deep .ck-editor__editable{background:#0000000a;cursor:default}.sd-editor-counter{font-size:12px;color:#0009}.sd-editor-counter--over{color:var(--mdc-theme-error, #f44336)}::ng-deep .ck-powered-by{display:none}:host ::ng-deep ckeditor,:host ::ng-deep .ck-editor{display:flex;flex-direction:column}:host ::ng-deep .ck-editor{--ck-content-font-size: 14px;--ck-content-line-height: 1.5}:host ::ng-deep .ck-editor__main{overflow:visible}:host ::ng-deep .ck-editor__editable{min-height:var(--sd-editor-content-min-height, 200px)!important;max-height:var(--sd-editor-content-max-height, none)!important;padding:24px!important;overflow-y:auto!important;border:1px solid var(--ck-color-base-border)!important}:host ::ng-deep .sd-editor.has-counter .ck-editor__editable{border-bottom:none!important}:host ::ng-deep .sd-editor--error .ck-editor__editable,:host ::ng-deep .sd-editor--error .ck-toolbar{border-color:var(--mdc-theme-error, #f44336)!important}:host ::ng-deep .sd-editor--error.has-counter .sd-editor-counter-row{border-color:var(--mdc-theme-error, #f44336)}:host ::ng-deep .ck-content p{margin-bottom:var(--ck-spacing-large);text-indent:0}:host ::ng-deep .ck-content ul,:host ::ng-deep .ck-content ol{margin-left:0!important;padding-left:20px!important}\n", ":host ::ng-deep .ck-content img{max-width:100%!important;height:auto!important;object-fit:contain}:host ::ng-deep .ck-content figure.image{position:relative;display:block;width:fit-content;max-width:100%;margin:0 auto;overflow:visible}:host ::ng-deep .ck-content figure.image:hover,:host ::ng-deep .ck-content figure.image.ck-widget_selected{z-index:99999!important}:host ::ng-deep .ck-content figure.image.image-style-align-left{float:left;margin:0 1em 0 0}:host ::ng-deep .ck-content figure.image.image-style-align-left[style*=\"width:100\"]{float:none;margin:0 auto 0 0}:host ::ng-deep .ck-content figure.image.image-style-align-right{float:right;margin:0 0 0 1em}:host ::ng-deep .ck-content figure.image.image-style-align-right[style*=\"width:100\"]{float:none;margin:0 0 0 auto}:host ::ng-deep .ck-content figure.image.image-style-block-align-left{margin:0 auto 0 0}:host ::ng-deep .ck-content figure.image.image-style-block-align-right{margin:0 0 0 auto}:host ::ng-deep .ck-content figure.image .ck-progress-bar{position:absolute;bottom:0;left:0;width:30%!important;height:3px;background:var(--sd-color-primary, #1565c0);border-radius:2px;z-index:10;pointer-events:none;animation:sd-progress-indeterminate 1.4s ease-in-out infinite}:host ::ng-deep .ck-content figure.image:has(.ck-progress-bar){overflow:hidden}:host ::ng-deep .ck-content figure.image:has(.ck-progress-bar) img{filter:blur(1px) brightness(.85);transition:filter .3s ease}:host ::ng-deep .ck-content figure.image:has(.ck-progress-bar):after{content:\"\";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:9;background:#ffffff40;border-radius:inherit}:host ::ng-deep .ck-content figure.image:has(.ck-progress-bar):before{content:\"\";position:absolute;top:50%;left:50%;width:28px;height:28px;margin:-14px 0 0 -14px;border:3px solid rgba(255,255,255,.5);border-top-color:var(--sd-color-primary, #1565c0);border-radius:50%;animation:sd-spinner .75s linear infinite;z-index:11;pointer-events:none}@keyframes sd-spinner{to{transform:rotate(360deg)}}@keyframes sd-progress-indeterminate{0%{left:-30%}to{left:100%}}\n"], dependencies: [{ kind: "ngmodule", type: CKEditorModule }, { kind: "component", type: i1.CKEditorComponent, selector: "ckeditor", inputs: ["editor", "config", "data", "tagName", "watchdog", "editorWatchdogConfig", "disableWatchdog", "disableTwoWayDataBinding", "disabled"], outputs: ["ready", "change", "blur", "focus", "error"] }, { kind: "component", type: SdLabel, selector: "sd-label", inputs: ["label", "description", "required", "helperText"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "directive", type: i2.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i3.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
922
|
+
}
|
|
923
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: SdEditor, decorators: [{
|
|
924
|
+
type: Component,
|
|
925
|
+
args: [{ selector: 'sd-editor', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [CKEditorModule, SdLabel, MatFormFieldModule, MatIconModule, MatTooltipModule], template: "@let lbl = label();\r\n@let hText = helperText();\r\n@let hideErr = hideInlineError();\r\n@let errMsg = errorMessage;\r\n@let req = required();\r\n@let maxLen = maxlength();\r\n@let showCounter = !!maxLen && !formControl.disabled && !hideErr;\r\n\r\n@if (lbl) {\r\n <sd-label [label]=\"lbl\" [required]=\"req\" [helperText]=\"hText\"></sd-label>\r\n}\r\n\r\n<div\r\n class=\"sd-editor\"\r\n [class.sd-editor--readonly]=\"readonly()\"\r\n [class.sd-editor--error]=\"formControl.invalid && formControl.touched\"\r\n [class.has-counter]=\"showCounter\"\r\n [style.--sd-editor-content-min-height]=\"height()\"\r\n [style.--sd-editor-content-max-height]=\"maxHeight()\"\r\n [attr.data-autoId]=\"autoId()\">\r\n <ckeditor [editor]=\"Editor\" [config]=\"editorConfig()\" [disabled]=\"formControl.disabled\" (ready)=\"onReady($event)\"></ckeditor>\r\n\r\n @if (showCounter) {\r\n <div class=\"sd-editor-counter-row\">\r\n <span class=\"sd-editor-counter\" [class.sd-editor-counter--over]=\"isOverLimit()\"> {{ _textLength() }}/{{ maxLen }} </span>\r\n </div>\r\n }\r\n\r\n @if (hideErr && errMsg && formControl.touched) {\r\n <mat-icon class=\"sd-error-icon\" [matTooltip]=\"errMsg\" matTooltipPosition=\"above\">error</mat-icon>\r\n }\r\n</div>\r\n\r\n<div class=\"sd-editor-footer\">\r\n @if (!hideErr && errMsg && formControl.touched) {\r\n <mat-error>{{ errMsg }}</mat-error>\r\n }\r\n</div>\r\n", styles: [".sd-editor{display:flex;flex-direction:column;width:100%;position:relative}.sd-editor-counter-row{display:flex;justify-content:flex-end;align-items:center;padding:4px 12px;border:1px solid var(--ck-color-base-border);border-top:none}.sd-editor-footer{min-height:20px;margin-top:4px}.sd-editor-footer mat-error{font-size:12px;color:var(--mdc-theme-error, #f44336)}.sd-error-icon{position:absolute;right:8px;bottom:8px;z-index:2;color:var(--mdc-theme-error, #f44336);height:20px;width:20px;font-size:20px;cursor:default}.sd-editor--readonly :host ::ng-deep .ck-editor__editable{background:#0000000a;cursor:default}.sd-editor-counter{font-size:12px;color:#0009}.sd-editor-counter--over{color:var(--mdc-theme-error, #f44336)}::ng-deep .ck-powered-by{display:none}:host ::ng-deep ckeditor,:host ::ng-deep .ck-editor{display:flex;flex-direction:column}:host ::ng-deep .ck-editor{--ck-content-font-size: 14px;--ck-content-line-height: 1.5}:host ::ng-deep .ck-editor__main{overflow:visible}:host ::ng-deep .ck-editor__editable{min-height:var(--sd-editor-content-min-height, 200px)!important;max-height:var(--sd-editor-content-max-height, none)!important;padding:24px!important;overflow-y:auto!important;border:1px solid var(--ck-color-base-border)!important}:host ::ng-deep .sd-editor.has-counter .ck-editor__editable{border-bottom:none!important}:host ::ng-deep .sd-editor--error .ck-editor__editable,:host ::ng-deep .sd-editor--error .ck-toolbar{border-color:var(--mdc-theme-error, #f44336)!important}:host ::ng-deep .sd-editor--error.has-counter .sd-editor-counter-row{border-color:var(--mdc-theme-error, #f44336)}:host ::ng-deep .ck-content p{margin-bottom:var(--ck-spacing-large);text-indent:0}:host ::ng-deep .ck-content ul,:host ::ng-deep .ck-content ol{margin-left:0!important;padding-left:20px!important}\n", ":host ::ng-deep .ck-content img{max-width:100%!important;height:auto!important;object-fit:contain}:host ::ng-deep .ck-content figure.image{position:relative;display:block;width:fit-content;max-width:100%;margin:0 auto;overflow:visible}:host ::ng-deep .ck-content figure.image:hover,:host ::ng-deep .ck-content figure.image.ck-widget_selected{z-index:99999!important}:host ::ng-deep .ck-content figure.image.image-style-align-left{float:left;margin:0 1em 0 0}:host ::ng-deep .ck-content figure.image.image-style-align-left[style*=\"width:100\"]{float:none;margin:0 auto 0 0}:host ::ng-deep .ck-content figure.image.image-style-align-right{float:right;margin:0 0 0 1em}:host ::ng-deep .ck-content figure.image.image-style-align-right[style*=\"width:100\"]{float:none;margin:0 0 0 auto}:host ::ng-deep .ck-content figure.image.image-style-block-align-left{margin:0 auto 0 0}:host ::ng-deep .ck-content figure.image.image-style-block-align-right{margin:0 0 0 auto}:host ::ng-deep .ck-content figure.image .ck-progress-bar{position:absolute;bottom:0;left:0;width:30%!important;height:3px;background:var(--sd-color-primary, #1565c0);border-radius:2px;z-index:10;pointer-events:none;animation:sd-progress-indeterminate 1.4s ease-in-out infinite}:host ::ng-deep .ck-content figure.image:has(.ck-progress-bar){overflow:hidden}:host ::ng-deep .ck-content figure.image:has(.ck-progress-bar) img{filter:blur(1px) brightness(.85);transition:filter .3s ease}:host ::ng-deep .ck-content figure.image:has(.ck-progress-bar):after{content:\"\";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;z-index:9;background:#ffffff40;border-radius:inherit}:host ::ng-deep .ck-content figure.image:has(.ck-progress-bar):before{content:\"\";position:absolute;top:50%;left:50%;width:28px;height:28px;margin:-14px 0 0 -14px;border:3px solid rgba(255,255,255,.5);border-top-color:var(--sd-color-primary, #1565c0);border-radius:50%;animation:sd-spinner .75s linear infinite;z-index:11;pointer-events:none}@keyframes sd-spinner{to{transform:rotate(360deg)}}@keyframes sd-progress-indeterminate{0%{left:-30%}to{left:100%}}\n"] }]
|
|
926
|
+
}], ctorParameters: () => [] });
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Generated bundle index. Do not edit.
|
|
930
|
+
*/
|
|
931
|
+
|
|
932
|
+
export { SD_EDITOR_CONFIGURATION, SdEditor };
|
|
933
|
+
//# sourceMappingURL=sd-angular-core-components-editor.mjs.map
|