@sd-angular/core 19.0.0-beta.2 → 19.0.0-beta.20
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/assets/scss/ckeditor5.scss +59 -2
- package/components/avatar/index.d.ts +1 -0
- package/components/avatar/src/avatar.component.d.ts +14 -0
- package/components/document-builder/src/document-builder.component.d.ts +29 -7
- package/components/document-builder/src/document-builder.config.d.ts +21 -0
- package/components/document-builder/src/document-builder.model.d.ts +6 -1
- package/components/document-builder/src/document-builder.utils.d.ts +10 -0
- package/components/document-builder/src/plugins/{table-fit.plugin.d.ts → heading/heading.plugin.d.ts} +1 -1
- package/components/document-builder/src/plugins/highlight-range/highlight-range.plugin.d.ts +4 -0
- package/components/document-builder/src/plugins/image-custom/image-custom.plugin.d.ts +31 -0
- package/components/document-builder/src/plugins/{image-upload.plugin.d.ts → image-upload/image-upload.plugin.d.ts} +0 -4
- package/components/document-builder/src/plugins/index.d.ts +7 -5
- package/components/document-builder/src/plugins/table-fit/table-fit.plugin.d.ts +29 -0
- package/components/index.d.ts +2 -0
- package/components/mini-editor/index.d.ts +2 -0
- package/components/mini-editor/src/mini-editor.component.d.ts +90 -0
- package/components/mini-editor/src/mini-editor.model.d.ts +42 -0
- package/components/table/src/directives/index.d.ts +2 -0
- package/components/table/src/directives/sd-table-column-filter-def.directive.d.ts +9 -0
- package/components/table/src/directives/sticky-shadow.directive.d.ts +17 -0
- package/components/table/src/models/table-column.model.d.ts +7 -7
- package/components/table/src/models/table-command.model.d.ts +4 -0
- package/components/table/src/models/table-item.model.d.ts +2 -1
- package/components/table/src/models/table-option-export.model.d.ts +3 -2
- package/components/table/src/models/table-option.model.d.ts +10 -8
- package/components/table/src/services/table-filter/table-filter.model.d.ts +2 -2
- package/components/view/index.d.ts +1 -0
- package/components/view/src/view.component.d.ts +14 -0
- package/components/workflow/src/models/index.d.ts +1 -0
- package/directives/index.d.ts +1 -0
- package/directives/src/sd-href.directive.d.ts +9 -0
- package/fesm2022/sd-angular-core-components-avatar.mjs +88 -0
- package/fesm2022/sd-angular-core-components-avatar.mjs.map +1 -0
- package/fesm2022/sd-angular-core-components-badge.mjs +2 -2
- package/fesm2022/sd-angular-core-components-badge.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-document-builder.mjs +1329 -557
- package/fesm2022/sd-angular-core-components-document-builder.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-mini-editor.mjs +326 -0
- package/fesm2022/sd-angular-core-components-mini-editor.mjs.map +1 -0
- package/fesm2022/sd-angular-core-components-table.mjs +510 -84
- package/fesm2022/sd-angular-core-components-table.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-view.mjs +88 -0
- package/fesm2022/sd-angular-core-components-view.mjs.map +1 -0
- package/fesm2022/sd-angular-core-components-workflow.mjs +33 -43
- package/fesm2022/sd-angular-core-components-workflow.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components.mjs +2 -0
- package/fesm2022/sd-angular-core-components.mjs.map +1 -1
- package/fesm2022/sd-angular-core-directives.mjs +51 -2
- package/fesm2022/sd-angular-core-directives.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-autocomplete.mjs +24 -2
- package/fesm2022/sd-angular-core-forms-autocomplete.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-date.mjs +15 -3
- package/fesm2022/sd-angular-core-forms-date.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-datetime.mjs +17 -3
- package/fesm2022/sd-angular-core-forms-datetime.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-input-number.mjs +25 -3
- package/fesm2022/sd-angular-core-forms-input-number.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-input.mjs +20 -6
- package/fesm2022/sd-angular-core-forms-input.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-radio.mjs +18 -2
- package/fesm2022/sd-angular-core-forms-radio.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-select.mjs +19 -3
- package/fesm2022/sd-angular-core-forms-select.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-textarea.mjs +21 -2
- package/fesm2022/sd-angular-core-forms-textarea.mjs.map +1 -1
- package/fesm2022/sd-angular-core-modules-auth.mjs +137 -0
- package/fesm2022/sd-angular-core-modules-auth.mjs.map +1 -0
- package/fesm2022/sd-angular-core-modules-layout.mjs +52 -17
- package/fesm2022/sd-angular-core-modules-layout.mjs.map +1 -1
- package/fesm2022/sd-angular-core-modules-oidc.mjs +0 -2
- package/fesm2022/sd-angular-core-modules-oidc.mjs.map +1 -1
- package/fesm2022/sd-angular-core-modules.mjs +1 -0
- package/fesm2022/sd-angular-core-modules.mjs.map +1 -1
- package/fesm2022/sd-angular-core-pipes.mjs +21 -1
- package/fesm2022/sd-angular-core-pipes.mjs.map +1 -1
- package/fesm2022/sd-angular-core-services-confirm.mjs +6 -9
- package/fesm2022/sd-angular-core-services-confirm.mjs.map +1 -1
- package/fesm2022/sd-angular-core-utilities-extensions.mjs +66 -1
- package/fesm2022/sd-angular-core-utilities-extensions.mjs.map +1 -1
- package/fesm2022/sd-angular-core-utilities-models.mjs +12 -3
- package/fesm2022/sd-angular-core-utilities-models.mjs.map +1 -1
- package/forms/autocomplete/src/autocomplete.component.d.ts +5 -1
- package/forms/date/src/date.component.d.ts +4 -1
- package/forms/datetime/src/datetime.component.d.ts +4 -1
- package/forms/input/src/input.component.d.ts +6 -4
- package/forms/input-number/src/input-number.component.d.ts +4 -1
- package/forms/radio/src/radio.component.d.ts +5 -1
- package/forms/select/src/select.component.d.ts +5 -1
- package/forms/textarea/src/textarea.component.d.ts +3 -1
- package/modules/auth/configurations/auth.configuration.d.ts +19 -0
- package/modules/auth/configurations/index.d.ts +1 -0
- package/modules/auth/guards/auth.guard.d.ts +11 -0
- package/modules/auth/guards/index.d.ts +2 -0
- package/modules/auth/guards/portal.guard.d.ts +11 -0
- package/modules/auth/index.d.ts +3 -0
- package/modules/auth/services/auth.model.d.ts +8 -0
- package/modules/auth/services/auth.service.d.ts +17 -0
- package/modules/auth/services/index.d.ts +2 -0
- package/modules/index.d.ts +1 -0
- package/modules/layout/components/sidebar-v1/components/sidebar/sidebar.component.d.ts +1 -0
- package/modules/layout/components/sidebar-v1/components/user/user.component.d.ts +5 -2
- package/modules/layout/configurations/layout.configuration.d.ts +3 -0
- package/modules/layout/services/storage/storage.service.d.ts +1 -0
- package/package.json +70 -54
- package/pipes/index.d.ts +1 -0
- package/pipes/src/empty.pipe.d.ts +7 -0
- package/sd-angular-core-19.0.0-beta.20.tgz +0 -0
- package/services/confirm/src/lib/confirm.service.d.ts +1 -0
- package/utilities/extensions/index.d.ts +1 -0
- package/utilities/extensions/src/color.extension.d.ts +20 -0
- package/utilities/models/index.d.ts +1 -0
- package/utilities/models/src/maybe-async.model.d.ts +1 -0
- package/utilities/models/src/nested-key-of.model.d.ts +5 -0
- package/utilities/models/src/pattern.model.d.ts +2 -2
- /package/components/document-builder/src/plugins/{comment.plugin.d.ts → comment/comment.plugin.d.ts} +0 -0
- /package/components/document-builder/src/plugins/{page-orientation.plugin.d.ts → page-orientation/page-orientation.plugin.d.ts} +0 -0
- /package/components/document-builder/src/plugins/{variable.plugin.d.ts → variable/variable.plugin.d.ts} +0 -0
|
@@ -3,10 +3,10 @@ import { EventEmitter, Output, Input, Component } from '@angular/core';
|
|
|
3
3
|
import { CommonModule } from '@angular/common';
|
|
4
4
|
import * as i1 from '@ckeditor/ckeditor5-angular';
|
|
5
5
|
import { CKEditorModule } from '@ckeditor/ckeditor5-angular';
|
|
6
|
-
import { Plugin, ButtonView, ClassicEditor, Essentials, Paragraph, Bold, Italic, Underline, FontSize, FontColor, FontBackgroundColor, Alignment, Widget, toWidget, GeneralHtmlSupport, FontFamily, Heading, List, Table, TableToolbar, TableProperties, TableCellProperties, TableColumnResize, PasteFromOffice, PageBreak, Undo, Subscript, Superscript, Image, ImageUpload, ImageToolbar, ImageCaption, ImageResize, ImageStyle } from 'ckeditor5';
|
|
7
|
-
import { SdResolveMaybeAsync } from '@sd-angular/core/utilities';
|
|
8
|
-
import { SdUtilities } from '@sd-angular/core/utilities/extensions';
|
|
6
|
+
import { Plugin, ButtonView, ClassicEditor, Essentials, Paragraph, Bold, Italic, Underline, FontSize, FontColor, FontBackgroundColor, Alignment, Widget, toWidget, GeneralHtmlSupport, FontFamily, Heading, List, Table, TableToolbar, TableProperties, TableCellProperties, TableColumnResize, PasteFromOffice, PageBreak, Undo, Subscript, Superscript, Image, ImageUpload, ImageToolbar, ImageCaption, ImageResize, ImageStyle, ImageBlock, Indent, IndentBlock } from 'ckeditor5';
|
|
9
7
|
import { Subscription, Subject, throttleTime } from 'rxjs';
|
|
8
|
+
import { SdResolveMaybeAsync, hslToHex, rgbToHex, SdUtilities } from '@sd-angular/core/utilities';
|
|
9
|
+
import { v4 } from 'uuid';
|
|
10
10
|
|
|
11
11
|
class PageNumberPlugin extends Plugin {
|
|
12
12
|
init() {
|
|
@@ -130,100 +130,42 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImpo
|
|
|
130
130
|
type: Output
|
|
131
131
|
}] } });
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
const ICON_PORTRAIT = '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M14 2H6c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6V4h8v12z"/></svg>';
|
|
135
|
-
// Icon khổ ngang (Mới)
|
|
136
|
-
const ICON_LANDSCAPE = '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M18 4H2C.9 4 0 4.9 0 6v8c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 10H2V6h16v8z"/></svg>';
|
|
137
|
-
class PageOrientationPlugin extends Plugin {
|
|
138
|
-
static pluginName = 'PageOrientationPlugin';
|
|
139
|
-
_currentOrientation = 'PORTRAIT';
|
|
140
|
-
orientationChangeEmitter;
|
|
141
|
-
buttonView;
|
|
133
|
+
class HeadingPlugin extends Plugin {
|
|
142
134
|
init() {
|
|
143
135
|
const editor = this.editor;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
view.set({
|
|
150
|
-
// label: 'Xoay giấy (A4)',
|
|
151
|
-
icon: ICON_PORTRAIT,
|
|
152
|
-
// tooltip: true,
|
|
153
|
-
// withText: true,
|
|
154
|
-
class: 'btn-orientation', // Class để style nếu cần
|
|
155
|
-
});
|
|
156
|
-
// Xử lý khi bấm nút
|
|
157
|
-
view.on('execute', () => {
|
|
158
|
-
this.toggleOrientation();
|
|
159
|
-
});
|
|
160
|
-
return view;
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Toggle between portrait and landscape orientation
|
|
165
|
-
*/
|
|
166
|
-
toggleOrientation() {
|
|
167
|
-
const newOrientation = this._currentOrientation === 'PORTRAIT' ? 'LANDSCAPE' : 'PORTRAIT';
|
|
168
|
-
this.setOrientation(newOrientation);
|
|
169
|
-
}
|
|
170
|
-
/**
|
|
171
|
-
* Set orientation programmatically
|
|
172
|
-
*/
|
|
173
|
-
setOrientation(orientation) {
|
|
174
|
-
const editor = this.editor;
|
|
175
|
-
const editingView = editor.editing.view;
|
|
176
|
-
const rootElement = editingView.document.getRoot();
|
|
177
|
-
editor.editing.view.change(writer => {
|
|
178
|
-
if (orientation === 'LANDSCAPE') {
|
|
179
|
-
writer.addClass('landscape', rootElement);
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
writer.removeClass('landscape', rootElement);
|
|
183
|
-
}
|
|
136
|
+
editor.conversion.for('editingDowncast').markerToHighlight({
|
|
137
|
+
model: 'highlightMarker',
|
|
138
|
+
view: {
|
|
139
|
+
classes: 'ck-heading-highlight',
|
|
140
|
+
},
|
|
184
141
|
});
|
|
185
|
-
// Update button icon
|
|
186
|
-
if (this.buttonView) {
|
|
187
|
-
this.buttonView.icon = orientation === 'LANDSCAPE' ? ICON_LANDSCAPE : ICON_PORTRAIT;
|
|
188
|
-
}
|
|
189
|
-
this._currentOrientation = orientation;
|
|
190
|
-
this.orientationChangeEmitter?.(orientation);
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* Get current orientation
|
|
194
|
-
*/
|
|
195
|
-
getOrientation() {
|
|
196
|
-
return this._currentOrientation;
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* Register callback for orientation changes
|
|
200
|
-
*/
|
|
201
|
-
onOrientationChange(callback) {
|
|
202
|
-
this.orientationChangeEmitter = callback;
|
|
203
142
|
}
|
|
204
143
|
}
|
|
205
144
|
|
|
206
145
|
class CommentPlugin extends Plugin {
|
|
207
146
|
init() {
|
|
208
147
|
const editor = this.editor;
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
148
|
+
// 1. MODEL MARKER -> VIEW CSS
|
|
149
|
+
editor.conversion.for('downcast').markerToHighlight({
|
|
150
|
+
model: 'comment',
|
|
151
|
+
view: data => {
|
|
152
|
+
return {
|
|
153
|
+
classes: 'ck-comment-marker',
|
|
154
|
+
attributes: {
|
|
155
|
+
'data-comment-id': data.markerName,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
215
158
|
},
|
|
216
159
|
});
|
|
217
|
-
//
|
|
160
|
+
// 2. ĐĂNG KÝ UI COMPONENT: 'addCommentBtn'
|
|
218
161
|
editor.ui.componentFactory.add('addCommentBtn', locale => {
|
|
219
162
|
const view = new ButtonView(locale);
|
|
220
163
|
// Lấy config từ Angular
|
|
221
164
|
const config = editor.config;
|
|
222
165
|
const getOption = config.get('getOption');
|
|
223
166
|
const option = getOption?.();
|
|
224
|
-
//
|
|
167
|
+
// Ẩn button nếu không có onAddComment
|
|
225
168
|
if (!option?.onAddComment) {
|
|
226
|
-
// Trả về button rỗng hoặc null để không hiển thị
|
|
227
169
|
view.set({
|
|
228
170
|
label: '',
|
|
229
171
|
isVisible: false,
|
|
@@ -232,19 +174,18 @@ class CommentPlugin extends Plugin {
|
|
|
232
174
|
}
|
|
233
175
|
view.set({
|
|
234
176
|
label: 'Thêm bình luận',
|
|
235
|
-
// Icon SVG comment
|
|
236
177
|
icon: '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M18 13v6l-4-4H4a2 2 0 01-2-2V4a2 2 0 012-2h14a2 2 0 012 2v9zM5 7h10v2H5V7zm0 4h10v2H5v-2z"/></svg>',
|
|
237
178
|
tooltip: true,
|
|
238
|
-
isEnabled: false,
|
|
179
|
+
isEnabled: false,
|
|
239
180
|
});
|
|
240
|
-
//
|
|
181
|
+
// Logic Enable/Disable: Dựa theo Selection
|
|
241
182
|
const selection = editor.model.document.selection;
|
|
242
183
|
// Lắng nghe sự kiện change selection để bật/tắt nút
|
|
243
184
|
this.listenTo(selection, 'change', () => {
|
|
244
185
|
// Enable khi có bôi đen text (không phải collapsed)
|
|
245
186
|
view.isEnabled = !selection.isCollapsed;
|
|
246
187
|
});
|
|
247
|
-
//
|
|
188
|
+
// Logic Execute: Khi bấm nút
|
|
248
189
|
this.listenTo(view, 'execute', () => {
|
|
249
190
|
const range = selection.getFirstRange();
|
|
250
191
|
if (!range || !option?.onAddComment)
|
|
@@ -256,13 +197,41 @@ class CommentPlugin extends Plugin {
|
|
|
256
197
|
selectedText += item.data;
|
|
257
198
|
}
|
|
258
199
|
}
|
|
259
|
-
//
|
|
200
|
+
// BẮN EVENT RA NGOÀI - KHÔNG TỰ ADD MARKER
|
|
260
201
|
// Angular component sẽ xử lý logic (mở modal, validation, etc.)
|
|
261
202
|
// và gọi lại hàm addComment() nếu cần
|
|
262
|
-
option.onAddComment(range);
|
|
203
|
+
option.onAddComment({ range, selectedText });
|
|
263
204
|
});
|
|
264
205
|
return view;
|
|
265
206
|
});
|
|
207
|
+
// 3. Xử lý sự kiện Copy (Clipboard Output)
|
|
208
|
+
this.listenTo(editor.editing.view.document, 'clipboardOutput', (_, data) => {
|
|
209
|
+
const isCopyOrCut = data.method === 'copy' || data.method === 'cut';
|
|
210
|
+
// Nếu không phải hành động copy hoặc cut thì thoát hàm
|
|
211
|
+
if (!isCopyOrCut) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const content = data.content;
|
|
215
|
+
editor.editing.view.change(writer => {
|
|
216
|
+
// Tạo range bao quanh toàn bộ nội dung clipboard
|
|
217
|
+
const range = writer.createRangeIn(content);
|
|
218
|
+
// Mảng chứa các item cần xử lý
|
|
219
|
+
const itemsToClean = [];
|
|
220
|
+
// 1. Duyệt qua để tìm các thẻ có class ck-comment-marker
|
|
221
|
+
for (const item of range.getItems()) {
|
|
222
|
+
if (item.is('element') && item.hasClass('ck-comment-marker')) {
|
|
223
|
+
itemsToClean.push(item);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// 2. Thực hiện xóa Class và Attribute
|
|
227
|
+
for (const item of itemsToClean) {
|
|
228
|
+
// Xóa class 'ck-comment-marker'
|
|
229
|
+
writer.removeClass('ck-comment-marker', item);
|
|
230
|
+
// Xóa thuộc tính 'data-comment-id'
|
|
231
|
+
writer.removeAttribute('data-comment-id', item);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
266
235
|
}
|
|
267
236
|
}
|
|
268
237
|
|
|
@@ -279,56 +248,54 @@ class VariablePlugin extends Plugin {
|
|
|
279
248
|
// 1. Định nghĩa Schema (Model)
|
|
280
249
|
schema.register('variable', {
|
|
281
250
|
inheritAllFrom: '$inlineObject',
|
|
282
|
-
|
|
251
|
+
allowWhere: '$text',
|
|
252
|
+
isInline: true,
|
|
253
|
+
isObject: true,
|
|
254
|
+
allowAttributes: ['id', 'uuid', 'value', 'display'],
|
|
283
255
|
});
|
|
284
|
-
// 2.
|
|
256
|
+
// 2. Model -> HTML
|
|
285
257
|
conversion.for('downcast').elementToElement({
|
|
286
258
|
model: 'variable',
|
|
287
259
|
view: (modelItem, { writer: viewWriter }) => {
|
|
288
260
|
const id = modelItem.getAttribute('id');
|
|
261
|
+
const uuid = modelItem.getAttribute('uuid');
|
|
289
262
|
const display = modelItem.getAttribute('display');
|
|
290
263
|
const value = modelItem.getAttribute('value');
|
|
291
|
-
// Xử lý data (Object -> String)
|
|
292
|
-
const rawData = modelItem.getAttribute('data');
|
|
293
|
-
const dataJson = rawData ? JSON.stringify(rawData) : '';
|
|
294
264
|
const widgetElement = viewWriter.createContainerElement('span', {
|
|
295
265
|
class: 'variable-widget',
|
|
296
266
|
'data-id': id,
|
|
267
|
+
'data-uuid': uuid,
|
|
297
268
|
'data-value': value,
|
|
298
269
|
'data-display': display,
|
|
299
|
-
'data-json': dataJson,
|
|
300
270
|
});
|
|
301
271
|
const innerText = viewWriter.createText(`{{${display}}}`);
|
|
302
272
|
viewWriter.insert(viewWriter.createPositionAt(widgetElement, 0), innerText);
|
|
303
273
|
return toWidget(widgetElement, viewWriter);
|
|
304
274
|
},
|
|
305
275
|
});
|
|
306
|
-
// 3.
|
|
276
|
+
// 3. HTML -> Model
|
|
307
277
|
conversion.for('upcast').elementToElement({
|
|
278
|
+
// NOTE: Khi bổ sung thêm attribute vào element variable, dev nên bổ sung thêm "[atribute]: true" vào view
|
|
279
|
+
// Để:
|
|
280
|
+
// - Nếu lọc chính xác sẽ không sinh ra thẻ span thừa bọc ngoài
|
|
281
|
+
// - Nếu chưa bổ sung thì sẽ sinh ra thẻ <span> bọc ngoài kèm [atribute] chưa lọc
|
|
308
282
|
view: {
|
|
309
283
|
name: 'span',
|
|
310
|
-
classes: 'variable-widget',
|
|
284
|
+
classes: 'variable-widget ck-widget',
|
|
285
|
+
attributes: {
|
|
286
|
+
'data-id': true,
|
|
287
|
+
'data-uuid': true,
|
|
288
|
+
'data-value': true,
|
|
289
|
+
'data-display': true,
|
|
290
|
+
contenteditable: true,
|
|
291
|
+
},
|
|
311
292
|
},
|
|
312
293
|
model: (viewElement, { writer: modelWriter }) => {
|
|
313
|
-
// Lấy text bên trong làm label, bỏ dấu {{ }}
|
|
314
|
-
// const textNode = viewElement.getChild(0);
|
|
315
|
-
// let label = '';
|
|
316
|
-
// if (textNode && textNode.is('$text')) {
|
|
317
|
-
// label = textNode.data.replace(/{{|}}/g, '');
|
|
318
|
-
// }
|
|
319
|
-
const dataJson = viewElement.getAttribute('data-json');
|
|
320
|
-
let parsedData = null;
|
|
321
|
-
try {
|
|
322
|
-
parsedData = dataJson ? JSON.parse(dataJson) : null;
|
|
323
|
-
}
|
|
324
|
-
catch (e) {
|
|
325
|
-
console.error('Lỗi parse variable data', e);
|
|
326
|
-
}
|
|
327
294
|
return modelWriter.createElement('variable', {
|
|
328
295
|
id: viewElement.getAttribute('data-id'),
|
|
296
|
+
uuid: viewElement.getAttribute('data-uuid'),
|
|
329
297
|
value: viewElement.getAttribute('data-value'),
|
|
330
298
|
display: viewElement.getAttribute('data-display'),
|
|
331
|
-
data: parsedData, // Lưu vào model dưới dạng Object gốc
|
|
332
299
|
});
|
|
333
300
|
},
|
|
334
301
|
});
|
|
@@ -338,91 +305,261 @@ class VariablePlugin extends Plugin {
|
|
|
338
305
|
const jsonData = dataTransfer.getData('ck-variable');
|
|
339
306
|
if (!jsonData)
|
|
340
307
|
return;
|
|
341
|
-
//
|
|
308
|
+
// data.dropRange là vị trí con chuột trên View khi thả
|
|
309
|
+
const viewRange = data.dropRange;
|
|
310
|
+
const modelRange = editor.editing.mapper.toModelRange(viewRange);
|
|
342
311
|
evt.stop();
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (
|
|
312
|
+
try {
|
|
313
|
+
let variable = JSON.parse(jsonData);
|
|
314
|
+
const config = editor.config;
|
|
315
|
+
const getOption = config.get('getOption');
|
|
316
|
+
const option = getOption?.();
|
|
317
|
+
if (option?.onDropVariable) {
|
|
318
|
+
const result = await SdResolveMaybeAsync(option.onDropVariable(variable, 0));
|
|
319
|
+
// * Hỗ trợ dữ liệu có sẵn sẽ chỉ cần nhận vào boolean có cho phép thả hay không?
|
|
320
|
+
if (typeof result === 'boolean') {
|
|
321
|
+
if (!result) {
|
|
322
|
+
throw new Error('Không cho phép thêm variable vào văn bản');
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
// * Hỗ trợ dữ liệu lấy từ API (Kiểm tra xem result có đúng định dạng interface SdDocumentBuilderVariable hay không?)
|
|
327
|
+
if (this.#isSdDocumentBuilderVariableResult(result)) {
|
|
328
|
+
variable = result;
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
throw new Error('Dữ liệu variable không hợp lệ');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
editor.model.change(writer => {
|
|
336
|
+
// 4.1. Chèn biến
|
|
337
|
+
const variableElem = writer.createElement('variable', {
|
|
338
|
+
id: variable.id,
|
|
339
|
+
uuid: v4(),
|
|
340
|
+
value: variable.value,
|
|
341
|
+
display: variable.display,
|
|
342
|
+
});
|
|
343
|
+
editor.model.insertContent(variableElem, modelRange);
|
|
344
|
+
// 4.2. Đặt con trỏ ra sau biến
|
|
345
|
+
writer.setSelection(variableElem, 'after');
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
catch (e) {
|
|
349
|
+
// Đặt con trỏ ngay tại vị trí lỗi
|
|
350
|
+
if (modelRange) {
|
|
351
|
+
editor.model.change(writer => {
|
|
352
|
+
writer.setSelection(modelRange);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
console.error(e);
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
// 5. Dọn dẹp drop-target dù thành công hay lỗi
|
|
359
|
+
editor.model.change(writer => {
|
|
360
|
+
for (const marker of editor.model.markers) {
|
|
361
|
+
if (marker.name.startsWith('drop-target')) {
|
|
362
|
+
writer.removeMarker(marker);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
// 5. Lắng nghe sự kiện bàn phím
|
|
369
|
+
let isNavigating = false;
|
|
370
|
+
this.editor.editing.view.document.on('keydown', (evt, data) => {
|
|
371
|
+
// Mã phím mũi tên: 37 (Left), 38 (Up), 39 (Right), 40 (Down)
|
|
372
|
+
const isArrowKey = data.keyCode >= 37 && data.keyCode <= 40;
|
|
373
|
+
if (isArrowKey) {
|
|
374
|
+
isNavigating = true;
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
isNavigating = false;
|
|
378
|
+
}
|
|
379
|
+
}, { priority: 'high' });
|
|
380
|
+
// 6. Lắng nghe sự kiện Click chuột
|
|
381
|
+
this.editor.editing.view.document.on('mousedown', () => {
|
|
382
|
+
isNavigating = true;
|
|
383
|
+
});
|
|
384
|
+
this.listenTo(editor.model.document.selection, 'change:range', () => {
|
|
385
|
+
// Nếu không phải là hành động click hoặc mũi tên thì thoát hàm.
|
|
386
|
+
if (!isNavigating) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const model = editor.model;
|
|
390
|
+
const selection = model.document.selection;
|
|
391
|
+
if (!selection.isCollapsed)
|
|
392
|
+
return;
|
|
393
|
+
const position = selection.getFirstPosition();
|
|
394
|
+
const nodeBefore = position?.nodeBefore;
|
|
395
|
+
if (!position)
|
|
396
|
+
return;
|
|
397
|
+
// Kiểm tra: Node đứng trước con trỏ là variable
|
|
398
|
+
if (nodeBefore && nodeBefore.is('element', 'variable')) {
|
|
399
|
+
// Lấy node ngay sau variable để kiểm tra
|
|
400
|
+
const nextNode = nodeBefore.nextSibling;
|
|
401
|
+
// Logic: Nếu phía sau KHÔNG CÓ GÌ hoặc KHÔNG PHẢI LÀ TEXT
|
|
402
|
+
if (!nextNode || !nextNode.is('$text')) {
|
|
403
|
+
model.change(writer => {
|
|
404
|
+
// Chèn thêm con trỏ variable
|
|
405
|
+
writer.insertText('\u00A0', nodeBefore, 'after');
|
|
406
|
+
// Lấy vị trí ngay sau variable (lúc này đang là đầu của text node mới)
|
|
407
|
+
const posAfterVariable = writer.createPositionAfter(nodeBefore);
|
|
408
|
+
// Dịch chuyển vị trí đó sang phải 1 đơn vị (bỏ qua ký tự vừa thêm)
|
|
409
|
+
const targetPos = posAfterVariable.getShiftedBy(1);
|
|
410
|
+
// 3. Đặt con trỏ vào vị trí đã tính toán
|
|
411
|
+
writer.setSelection(targetPos);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
// 7. Handle xóa variable
|
|
417
|
+
this.editor.editing.view.document.on('keydown', (evt, data) => {
|
|
418
|
+
// Mã phím 8 là Backspace, 46 là Delete
|
|
419
|
+
const btnBackspace = data.keyCode === 8;
|
|
420
|
+
const btnDelete = data.keyCode === 46;
|
|
421
|
+
if (btnBackspace || btnDelete) {
|
|
422
|
+
const selection = editor.model.document.selection;
|
|
423
|
+
const model = editor.model;
|
|
424
|
+
// CASE 1: Nếu con trỏ đang nhấp nháy (Collapsed)
|
|
425
|
+
if (selection.isCollapsed) {
|
|
426
|
+
const position = selection.getFirstPosition();
|
|
427
|
+
// Với Backspace ta kiểm tra nodeBefore, với Delete ta kiểm tra nodeAfter
|
|
428
|
+
const targetNode = data.keyCode === 8 ? position?.nodeBefore : position?.nodeAfter;
|
|
429
|
+
if (targetNode && targetNode.is('element', 'variable')) {
|
|
430
|
+
data.preventDefault();
|
|
431
|
+
evt.stop();
|
|
432
|
+
model.change(writer => {
|
|
433
|
+
// Chọn bao quanh Variable đó
|
|
434
|
+
writer.setSelection(targetNode, 'on');
|
|
435
|
+
});
|
|
352
436
|
return;
|
|
437
|
+
}
|
|
353
438
|
}
|
|
439
|
+
// CASE 2: Nếu đang có một vùng chọn (đã được highlight từ lần bấm trước)
|
|
354
440
|
else {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
441
|
+
const selectedElement = selection.getSelectedElement();
|
|
442
|
+
// Nếu phần tử đang được chọn chính là variable
|
|
443
|
+
if (selectedElement && selectedElement.is('element', 'variable')) {
|
|
444
|
+
// Cho phép hành động mặc định diễn ra (CKEditor sẽ tự xóa phần tử đang được chọn)
|
|
445
|
+
// Hoặc chủ động xóa để chắc chắn:
|
|
446
|
+
data.preventDefault();
|
|
447
|
+
evt.stop();
|
|
448
|
+
model.change(writer => {
|
|
449
|
+
writer.remove(selectedElement);
|
|
450
|
+
});
|
|
358
451
|
}
|
|
359
|
-
|
|
360
|
-
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}, { priority: 'highest' });
|
|
455
|
+
// 8. Xử lý sự kiện Copy (Clipboard Output)
|
|
456
|
+
// Chỉ set thêm text/plain fallback, không thay đổi HTML content
|
|
457
|
+
this.listenTo(editor.editing.view.document, 'clipboardOutput', (evt, data) => {
|
|
458
|
+
const isCopyOrCut = data.method === 'copy' || data.method === 'cut';
|
|
459
|
+
if (!isCopyOrCut)
|
|
460
|
+
return;
|
|
461
|
+
// Set thêm plain text fallback cho external apps
|
|
462
|
+
const dataTransfer = data.dataTransfer;
|
|
463
|
+
const content = data.content;
|
|
464
|
+
// Lấy tất cả text từ content (bao gồm cả variable dạng {{text}})
|
|
465
|
+
let plainText = '';
|
|
466
|
+
const viewRange = editor.editing.view.createRangeIn(content);
|
|
467
|
+
for (const item of viewRange.getItems()) {
|
|
468
|
+
if (item.is('$text') || item.is('element', 'span')) {
|
|
469
|
+
const itemAny = item;
|
|
470
|
+
if (item.is('$text') && itemAny.data) {
|
|
471
|
+
plainText += itemAny.data;
|
|
472
|
+
}
|
|
473
|
+
else if (item.is('element', 'span') && item.hasClass('variable-widget')) {
|
|
474
|
+
const display = item.getAttribute('data-display');
|
|
475
|
+
if (display)
|
|
476
|
+
plainText += `{{${display}}}`;
|
|
361
477
|
}
|
|
362
478
|
}
|
|
363
479
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
480
|
+
if (plainText) {
|
|
481
|
+
dataTransfer.setData('text/plain', plainText);
|
|
482
|
+
}
|
|
483
|
+
// HTML content giữ nguyên - CKEditor sẽ tự xử lý
|
|
484
|
+
}, { priority: 'low' });
|
|
485
|
+
// 9. Xử lý sự kiện Paste (Clipboard Input)
|
|
486
|
+
// Nếu paste từ external source (chỉ có text, không có HTML variable)
|
|
487
|
+
// thì chuyển {{text}} thành variable widget
|
|
488
|
+
this.listenTo(editor.editing.view.document, 'clipboardInput', (evt, data) => {
|
|
489
|
+
const dataTransfer = data.dataTransfer;
|
|
490
|
+
// Nếu có HTML chứa variable-widget thì để CKEditor xử lý (upcast converter)
|
|
491
|
+
const html = dataTransfer.getData('text/html');
|
|
492
|
+
if (html && html.includes('variable-widget')) {
|
|
493
|
+
return; // Để CKEditor upcast converter xử lý
|
|
494
|
+
}
|
|
495
|
+
// Chỉ xử lý nếu chỉ có plain text với pattern {{text}}
|
|
496
|
+
let text = dataTransfer.getData('text/plain');
|
|
497
|
+
if (!text)
|
|
498
|
+
return;
|
|
499
|
+
// Kiểm tra có chứa pattern {{text}} không (không cần id, value)
|
|
500
|
+
const variablePattern = /\{\{([^}]+)\}\}/g;
|
|
501
|
+
if (!variablePattern.test(text)) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
// Reset lastIndex sau khi test
|
|
505
|
+
variablePattern.lastIndex = 0;
|
|
506
|
+
evt.stop();
|
|
368
507
|
editor.model.change(writer => {
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
508
|
+
const selection = editor.model.document.selection;
|
|
509
|
+
const position = selection.getFirstPosition();
|
|
510
|
+
if (!position)
|
|
511
|
+
return;
|
|
512
|
+
// Tách text thành các phần: normal text và variables
|
|
513
|
+
let lastIndex = 0;
|
|
514
|
+
let match;
|
|
515
|
+
const fragments = [];
|
|
516
|
+
while ((match = variablePattern.exec(text)) !== null) {
|
|
517
|
+
// Thêm text trước variable
|
|
518
|
+
if (match.index > lastIndex) {
|
|
519
|
+
fragments.push({
|
|
520
|
+
type: 'text',
|
|
521
|
+
content: text.slice(lastIndex, match.index),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
// Thêm variable
|
|
525
|
+
const display = match[1];
|
|
526
|
+
fragments.push({
|
|
527
|
+
type: 'variable',
|
|
528
|
+
content: match[0],
|
|
529
|
+
display,
|
|
530
|
+
});
|
|
531
|
+
lastIndex = match.index + match[0].length;
|
|
532
|
+
}
|
|
533
|
+
// Thêm text còn lại sau variable cuối cùng
|
|
534
|
+
if (lastIndex < text.length) {
|
|
535
|
+
fragments.push({
|
|
536
|
+
type: 'text',
|
|
537
|
+
content: text.slice(lastIndex),
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
// Chèn từng fragment vào document
|
|
541
|
+
let currentPosition = position;
|
|
542
|
+
for (const fragment of fragments) {
|
|
543
|
+
if (fragment.type === 'text' && fragment.content) {
|
|
544
|
+
const textNode = writer.createText(fragment.content);
|
|
545
|
+
writer.insert(textNode, currentPosition);
|
|
546
|
+
currentPosition = writer.createPositionAfter(textNode);
|
|
547
|
+
}
|
|
548
|
+
else if (fragment.type === 'variable' && fragment.display) {
|
|
549
|
+
const variableElem = writer.createElement('variable', {
|
|
550
|
+
id: v4(),
|
|
551
|
+
uuid: v4(),
|
|
552
|
+
value: fragment.display,
|
|
553
|
+
display: fragment.display,
|
|
554
|
+
});
|
|
555
|
+
writer.insert(variableElem, currentPosition);
|
|
556
|
+
currentPosition = writer.createPositionAfter(variableElem);
|
|
385
557
|
}
|
|
386
558
|
}
|
|
559
|
+
// Đặt con trỏ sau nội dung vừa paste
|
|
560
|
+
writer.setSelection(currentPosition);
|
|
387
561
|
});
|
|
388
|
-
// FIX LỖI: Focus lại vào editor để xóa các artifact của việc kéo thả
|
|
389
|
-
editor.editing.view.focus();
|
|
390
|
-
// [BỔ SUNG] XÓA CLASS RÁC TRÊN VIEW (NẾU MARKER KHÔNG HẾT)
|
|
391
|
-
// Đôi khi View chưa kịp render lại, ta ép xóa class thủ công trên root nếu cần
|
|
392
|
-
// (Thường bước C ở trên là đủ, nhưng đây là chốt chặn cuối cùng bằng JS)
|
|
393
|
-
const viewRoot = editor.editing.view.document.getRoot();
|
|
394
|
-
if (viewRoot) {
|
|
395
|
-
editor.editing.view.change(viewWriter => {
|
|
396
|
-
// Loại bỏ class decoration nếu nó bị dính vào root (hiếm gặp nhưng có thể)
|
|
397
|
-
viewWriter.removeClass('ck-clipboard-drop-target-position', viewRoot);
|
|
398
|
-
viewWriter.removeClass('ck-clipboard-drop-target-line', viewRoot);
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
562
|
});
|
|
402
|
-
// this.listenTo(editingView.document, 'drop', (evt, data) => {
|
|
403
|
-
// const config = editor.config as Config<SdEditorConfig>;
|
|
404
|
-
// const getOption = config.get('getOption') as SdEditorConfig['getOption'];
|
|
405
|
-
// const option = getOption?.();
|
|
406
|
-
// // Dữ liệu kéo thả
|
|
407
|
-
// const dataTransfer = (data as any).dataTransfer;
|
|
408
|
-
// const jsonData = dataTransfer.getData('ck-variable');
|
|
409
|
-
// if (!jsonData) return;
|
|
410
|
-
// evt.stop();
|
|
411
|
-
// const variable: SdDocumentBuilderVariable = JSON.parse(jsonData);
|
|
412
|
-
// // Gọi callback ra ngoài Angular (nếu có)
|
|
413
|
-
// if (option && option.onDropVariable) {
|
|
414
|
-
// const allow = option.onDropVariable(variable, 0);
|
|
415
|
-
// if (allow === false) return; // Angular chặn drop
|
|
416
|
-
// }
|
|
417
|
-
// // Insert vào Model
|
|
418
|
-
// editor.model.change(writer => {
|
|
419
|
-
// const variableElem = writer.createElement('variable', {
|
|
420
|
-
// id: variable.id,
|
|
421
|
-
// label: variable.label
|
|
422
|
-
// });
|
|
423
|
-
// editor.model.insertContent(variableElem);
|
|
424
|
-
// });
|
|
425
|
-
// });
|
|
426
563
|
}
|
|
427
564
|
#isSdDocumentBuilderVariableResult = (obj) => {
|
|
428
565
|
return (obj !== null &&
|
|
@@ -440,70 +577,223 @@ class TableFitPlugin extends Plugin {
|
|
|
440
577
|
const editor = this.editor;
|
|
441
578
|
// Can thiệp vào quá trình convert từ View (HTML Paste) sang Model
|
|
442
579
|
editor.conversion.for('upcast').add(dispatcher => {
|
|
443
|
-
dispatcher.on('element:table', (evt, data
|
|
444
|
-
|
|
445
|
-
if (!conversionApi.consumable.consume(data.viewItem, { name: true })) {
|
|
580
|
+
dispatcher.on('element:table', (evt, data) => {
|
|
581
|
+
if (!data.modelRange)
|
|
446
582
|
return;
|
|
447
|
-
|
|
448
|
-
// 2. Thực hiện chuyển đổi mặc định để tạo ra model element
|
|
449
|
-
const { modelCursor, modelRange } = conversionApi.convertChildren(data.viewItem, data.modelCursor);
|
|
450
|
-
// 3. Bây giờ modelRange chắc chắn tồn tại, ta tìm element table trong đó
|
|
451
|
-
for (const item of modelRange.getItems()) {
|
|
583
|
+
for (const item of data.modelRange.getItems()) {
|
|
452
584
|
if (item.is('element', 'table')) {
|
|
453
585
|
editor.model.change(writer => {
|
|
454
|
-
|
|
586
|
+
this._applyTableDefaults(writer, item);
|
|
587
|
+
this._applyCellBorders(writer, item);
|
|
455
588
|
});
|
|
456
589
|
}
|
|
457
590
|
}
|
|
458
|
-
// 4. Cập nhật modelCursor để dispatcher biết đã xử lý xong tới đâu
|
|
459
|
-
data.modelRange = modelRange;
|
|
460
|
-
data.modelCursor = modelCursor;
|
|
461
591
|
}, { priority: 'low' });
|
|
462
|
-
// Chạy sau cùng để ghi đè các logic mặc định
|
|
463
592
|
});
|
|
464
|
-
|
|
593
|
+
const findInnerTable = (viewElement) => {
|
|
594
|
+
if (!viewElement)
|
|
595
|
+
return null;
|
|
596
|
+
if (viewElement.name === 'table')
|
|
597
|
+
return viewElement;
|
|
598
|
+
for (const child of viewElement.getChildren()) {
|
|
599
|
+
if (child.name === 'table')
|
|
600
|
+
return child;
|
|
601
|
+
const found = findInnerTable(child);
|
|
602
|
+
if (found)
|
|
603
|
+
return found;
|
|
604
|
+
}
|
|
605
|
+
return null;
|
|
606
|
+
};
|
|
607
|
+
editor.conversion.for('downcast').add(dispatcher => {
|
|
608
|
+
dispatcher.on('attribute:tableWidth:table', (evt, data, conversionApi) => {
|
|
609
|
+
const viewWriter = conversionApi.writer;
|
|
610
|
+
const viewElement = conversionApi.mapper.toViewElement(data.item);
|
|
611
|
+
if (!viewElement)
|
|
612
|
+
return;
|
|
613
|
+
const innerTable = findInnerTable(viewElement);
|
|
614
|
+
if (!innerTable)
|
|
615
|
+
return;
|
|
616
|
+
viewWriter.setStyle('border-collapse', 'collapse', innerTable);
|
|
617
|
+
viewWriter.setStyle('margin', '0', innerTable);
|
|
618
|
+
viewWriter.setStyle('width', '100%', innerTable);
|
|
619
|
+
viewWriter.setStyle('width', '100%', viewElement);
|
|
620
|
+
});
|
|
621
|
+
dispatcher.on('insert:table', (evt, data, conversionApi) => {
|
|
622
|
+
const viewWriter = conversionApi.writer;
|
|
623
|
+
const viewElement = conversionApi.mapper.toViewElement(data.item);
|
|
624
|
+
if (!viewElement)
|
|
625
|
+
return;
|
|
626
|
+
const innerTable = findInnerTable(viewElement);
|
|
627
|
+
if (!innerTable)
|
|
628
|
+
return;
|
|
629
|
+
viewWriter.setStyle('border-collapse', 'collapse', innerTable);
|
|
630
|
+
viewWriter.setStyle('margin', '0', innerTable);
|
|
631
|
+
viewWriter.setStyle('width', '100%', innerTable);
|
|
632
|
+
viewWriter.setStyle('width', '100%', viewElement);
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
// Lắng nghe lệnh insertTable
|
|
465
636
|
const insertTableCommand = editor.commands.get('insertTable');
|
|
466
637
|
if (insertTableCommand) {
|
|
467
|
-
|
|
468
|
-
this.listenTo(insertTableCommand, 'execute', (evt, args) => {
|
|
638
|
+
this.listenTo(insertTableCommand, 'execute', () => {
|
|
469
639
|
editor.model.change(writer => {
|
|
470
|
-
|
|
471
|
-
const selection = editor.model.document.selection;
|
|
472
|
-
const position = selection.getFirstPosition();
|
|
640
|
+
const position = editor.model.document.selection.getFirstPosition();
|
|
473
641
|
if (!position)
|
|
474
642
|
return;
|
|
475
|
-
// Tìm element table vừa chèn (nó nằm ở vị trí cha của selection)
|
|
476
|
-
// Khi vừa insert, con trỏ thường nằm trong ô đầu tiên của bảng
|
|
477
643
|
const tableElement = position.findAncestor('table');
|
|
478
644
|
if (tableElement) {
|
|
479
|
-
|
|
480
|
-
writer.setAttribute('
|
|
645
|
+
this._applyTableDefaults(writer, tableElement);
|
|
646
|
+
writer.setAttribute('tableBorderColor', '#000000', tableElement);
|
|
647
|
+
writer.setAttribute('tableBorderStyle', 'solid', tableElement);
|
|
648
|
+
writer.setAttribute('tableBorderWidth', '1pt', tableElement);
|
|
649
|
+
this._applyCellBorders(writer, tableElement);
|
|
481
650
|
}
|
|
482
651
|
});
|
|
483
652
|
});
|
|
484
653
|
}
|
|
654
|
+
// Listen for row/column commands
|
|
655
|
+
const tableCommands = [
|
|
656
|
+
'insertTableRowAbove',
|
|
657
|
+
'insertTableRowBelow',
|
|
658
|
+
'insertTableColumnLeft',
|
|
659
|
+
'insertTableColumnRight',
|
|
660
|
+
'resizeTableRow',
|
|
661
|
+
'resizeTableColumn',
|
|
662
|
+
'setTableColumnWidth',
|
|
663
|
+
'tableColumnWidth'
|
|
664
|
+
];
|
|
665
|
+
tableCommands.forEach(cmdName => {
|
|
666
|
+
const cmd = editor.commands.get(cmdName);
|
|
667
|
+
if (cmd) {
|
|
668
|
+
this.listenTo(cmd, 'execute', () => {
|
|
669
|
+
editor.model.change(writer => {
|
|
670
|
+
const position = editor.model.document.selection.getFirstPosition();
|
|
671
|
+
if (!position)
|
|
672
|
+
return;
|
|
673
|
+
const tableElement = position.findAncestor('table');
|
|
674
|
+
if (tableElement) {
|
|
675
|
+
this._applyTableDefaults(writer, tableElement);
|
|
676
|
+
this._applyCellBorders(writer, tableElement);
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
// Setup style preservation on model change
|
|
683
|
+
this._setupStylePreservationOnModelChange();
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Apply default table width
|
|
687
|
+
*/
|
|
688
|
+
_applyTableDefaults(writer, tableElement) {
|
|
689
|
+
if (!tableElement)
|
|
690
|
+
return;
|
|
691
|
+
writer.setAttribute('tableWidth', '100%', tableElement);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Apply default borders to all cells in a table
|
|
695
|
+
*/
|
|
696
|
+
_applyCellBorders(writer, tableElement) {
|
|
697
|
+
if (!tableElement)
|
|
698
|
+
return;
|
|
699
|
+
for (const row of tableElement.getChildren()) {
|
|
700
|
+
for (const cell of row.getChildren()) {
|
|
701
|
+
if (!cell.getAttribute('tableCellBorderColor')) {
|
|
702
|
+
writer.setAttribute('tableCellBorderColor', '#000000', cell);
|
|
703
|
+
}
|
|
704
|
+
if (!cell.getAttribute('tableCellBorderStyle')) {
|
|
705
|
+
writer.setAttribute('tableCellBorderStyle', 'solid', cell);
|
|
706
|
+
}
|
|
707
|
+
if (!cell.getAttribute('tableCellBorderWidth')) {
|
|
708
|
+
writer.setAttribute('tableCellBorderWidth', '1pt', cell);
|
|
709
|
+
}
|
|
710
|
+
if (!cell.getAttribute('tableCellPadding')) {
|
|
711
|
+
writer.setAttribute('tableCellPadding', '0.4em', cell);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* Setup listener to preserve cell/table styles when model changes
|
|
718
|
+
*/
|
|
719
|
+
_setupStylePreservationOnModelChange() {
|
|
720
|
+
const editor = this.editor;
|
|
721
|
+
// Use listenTo for proper cleanup via destroy()
|
|
722
|
+
this.listenTo(editor.model.document, 'change', (evt, batch) => {
|
|
723
|
+
if (batch?.isLocal === false)
|
|
724
|
+
return;
|
|
725
|
+
const changes = editor.model.document.differ.getChanges();
|
|
726
|
+
const tablesToFix = this._findTablesNeedingFix(changes);
|
|
727
|
+
if (tablesToFix.size > 0) {
|
|
728
|
+
editor.model.enqueueChange(() => {
|
|
729
|
+
editor.model.change(writer => {
|
|
730
|
+
for (const table of tablesToFix) {
|
|
731
|
+
this._applyTableDefaults(writer, table);
|
|
732
|
+
this._applyCellBorders(writer, table);
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Find tables that need border fixes from model changes
|
|
741
|
+
*/
|
|
742
|
+
_findTablesNeedingFix(changes) {
|
|
743
|
+
const tablesToFix = new Set();
|
|
744
|
+
for (const change of changes) {
|
|
745
|
+
if (change.type === 'attribute') {
|
|
746
|
+
const attrKey = change.attributeKey;
|
|
747
|
+
if (attrKey && (attrKey.includes('table') || attrKey.includes('column') || attrKey.includes('width'))) {
|
|
748
|
+
const element = change.item;
|
|
749
|
+
if (element) {
|
|
750
|
+
const parentTable = this._findParentTable(element);
|
|
751
|
+
if (parentTable)
|
|
752
|
+
tablesToFix.add(parentTable);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (change.type === 'insert' && change.position) {
|
|
757
|
+
const tableElement = change.position.findAncestor?.('table') ||
|
|
758
|
+
change.position.parent?.findAncestor?.('table');
|
|
759
|
+
if (tableElement)
|
|
760
|
+
tablesToFix.add(tableElement);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return tablesToFix;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Find parent table element
|
|
767
|
+
*/
|
|
768
|
+
_findParentTable(element) {
|
|
769
|
+
if (!element)
|
|
770
|
+
return null;
|
|
771
|
+
let parent = element;
|
|
772
|
+
while (parent && !parent.is?.('element', 'table')) {
|
|
773
|
+
parent = parent.parent;
|
|
774
|
+
}
|
|
775
|
+
return parent;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Cleanup listeners when plugin is destroyed
|
|
779
|
+
* Note: this.listenTo() listeners are automatically cleaned up by super.destroy()
|
|
780
|
+
*/
|
|
781
|
+
destroy() {
|
|
782
|
+
super.destroy();
|
|
485
783
|
}
|
|
486
784
|
}
|
|
487
785
|
|
|
488
|
-
/**
|
|
489
|
-
* Custom base64 upload adapter plugin for CKEditor 5.
|
|
490
|
-
* Converts uploaded images to base64 data URLs instead of uploading to a server.
|
|
491
|
-
*/
|
|
492
786
|
class ImageUploadPlugin extends Plugin {
|
|
493
787
|
static get pluginName() {
|
|
494
788
|
return 'ImageUploadPlugin';
|
|
495
789
|
}
|
|
496
790
|
init() {
|
|
497
791
|
const editor = this.editor;
|
|
498
|
-
// Register the custom upload adapter
|
|
499
792
|
editor.plugins.get('FileRepository').createUploadAdapter = (loader) => {
|
|
500
793
|
return new Base64UploadAdapter(loader);
|
|
501
794
|
};
|
|
502
795
|
}
|
|
503
796
|
}
|
|
504
|
-
/**
|
|
505
|
-
* Custom upload adapter that converts images to base64 data URLs.
|
|
506
|
-
*/
|
|
507
797
|
class Base64UploadAdapter {
|
|
508
798
|
loader;
|
|
509
799
|
constructor(loader) {
|
|
@@ -528,25 +818,546 @@ class Base64UploadAdapter {
|
|
|
528
818
|
});
|
|
529
819
|
});
|
|
530
820
|
}
|
|
531
|
-
/**
|
|
532
|
-
* Aborts the upload process.
|
|
533
|
-
*/
|
|
534
|
-
abort() {
|
|
535
|
-
// For base64 conversion, there's nothing to abort
|
|
536
|
-
// This method is required by the UploadAdapter interface
|
|
537
|
-
}
|
|
821
|
+
/**
|
|
822
|
+
* Aborts the upload process.
|
|
823
|
+
*/
|
|
824
|
+
abort() {
|
|
825
|
+
// For base64 conversion, there's nothing to abort
|
|
826
|
+
// This method is required by the UploadAdapter interface
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
class ImageCustomPlugin extends Plugin {
|
|
831
|
+
static get pluginName() {
|
|
832
|
+
return 'ImageCustomPlugin';
|
|
833
|
+
}
|
|
834
|
+
init() {
|
|
835
|
+
const editor = this.editor;
|
|
836
|
+
// Thiết lập style mặc định là alignCenter khi chèn ảnh
|
|
837
|
+
editor.commands.get('imageUpload')?.on('execute', (evt, args) => {
|
|
838
|
+
// Đặt style mặc định sau khi ảnh được chèn
|
|
839
|
+
setTimeout(() => {
|
|
840
|
+
const selection = editor.model.document.selection;
|
|
841
|
+
const imageElement = selection.getSelectedElement();
|
|
842
|
+
if (imageElement && (imageElement.name === 'imageBlock' || imageElement.name === 'imageInline')) {
|
|
843
|
+
const currentStyle = imageElement.getAttribute('imageStyle');
|
|
844
|
+
// Chỉ đặt mặc định nếu chưa có style nào
|
|
845
|
+
if (!currentStyle) {
|
|
846
|
+
editor.model.change(writer => {
|
|
847
|
+
writer.setAttribute('imageStyle', 'alignCenter', imageElement);
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}, 0);
|
|
852
|
+
});
|
|
853
|
+
// Downcast: Model -> View (HTML output)
|
|
854
|
+
// CKEditor 5 có 2 loại ảnh: imageBlock và imageInline
|
|
855
|
+
editor.conversion.for('downcast').add(dispatcher => {
|
|
856
|
+
// Xử lý ảnh block (được wrap trong figure)
|
|
857
|
+
dispatcher.on('insert:imageBlock', (evt, data, conversionApi) => {
|
|
858
|
+
this.handleImageInsert(evt, data, conversionApi);
|
|
859
|
+
}, { priority: 'low' });
|
|
860
|
+
// Xử lý ảnh inline
|
|
861
|
+
dispatcher.on('insert:imageInline', (evt, data, conversionApi) => {
|
|
862
|
+
this.handleImageInsert(evt, data, conversionApi);
|
|
863
|
+
}, { priority: 'low' });
|
|
864
|
+
// Xử lý thay đổi attribute cho cả 2 loại ảnh
|
|
865
|
+
['imageBlock', 'imageInline'].forEach(imageType => {
|
|
866
|
+
// Xử lý thay đổi src
|
|
867
|
+
dispatcher.on(`attribute:src:${imageType}`, (evt, data, conversionApi) => {
|
|
868
|
+
this.handleImageAttributeChange(evt, data, conversionApi);
|
|
869
|
+
}, { priority: 'low' });
|
|
870
|
+
// Xử lý thay đổi width
|
|
871
|
+
dispatcher.on(`attribute:width:${imageType}`, (evt, data, conversionApi) => {
|
|
872
|
+
this.handleImageAttributeChange(evt, data, conversionApi);
|
|
873
|
+
}, { priority: 'low' });
|
|
874
|
+
// Xử lý thay đổi height
|
|
875
|
+
dispatcher.on(`attribute:height:${imageType}`, (evt, data, conversionApi) => {
|
|
876
|
+
this.handleImageAttributeChange(evt, data, conversionApi);
|
|
877
|
+
}, { priority: 'low' });
|
|
878
|
+
// Xử lý thay đổi imageStyle (căn chỉnh) - thêm float inline style
|
|
879
|
+
dispatcher.on(`attribute:imageStyle:${imageType}`, (evt, data, conversionApi) => {
|
|
880
|
+
this.handleImageStyleChange(evt, data, conversionApi);
|
|
881
|
+
}, { priority: 'low' });
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
// Xử lý upcast (HTML paste) - xóa aspect-ratio từ HTML đầu vào
|
|
885
|
+
editor.conversion.for('upcast').add(dispatcher => {
|
|
886
|
+
dispatcher.on('element:img', (evt, data, conversionApi) => {
|
|
887
|
+
const viewItem = data.viewItem;
|
|
888
|
+
if (!viewItem)
|
|
889
|
+
return;
|
|
890
|
+
// Kiểm tra viewItem có các method cần thiết
|
|
891
|
+
if (typeof viewItem.getStyle !== 'function')
|
|
892
|
+
return;
|
|
893
|
+
// Xóa aspect-ratio từ inline styles nếu có
|
|
894
|
+
const hasAspectRatio = viewItem.getStyle('aspect-ratio');
|
|
895
|
+
if (hasAspectRatio && typeof viewItem.removeStyle === 'function') {
|
|
896
|
+
viewItem.removeStyle('aspect-ratio');
|
|
897
|
+
}
|
|
898
|
+
// Đặt custom styles nếu _styles map tồn tại
|
|
899
|
+
if (viewItem._styles && typeof viewItem._styles.set === 'function') {
|
|
900
|
+
viewItem._styles.set('margin', '0');
|
|
901
|
+
viewItem._styles.set('border', '0');
|
|
902
|
+
viewItem._styles.set('max-width', '100%');
|
|
903
|
+
viewItem._styles.set('height', 'auto');
|
|
904
|
+
}
|
|
905
|
+
}, { priority: 'high' });
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Xử lý sự kiện chèn ảnh
|
|
910
|
+
*/
|
|
911
|
+
handleImageInsert(evt, data, conversionApi) {
|
|
912
|
+
const viewWriter = conversionApi.writer;
|
|
913
|
+
const viewElement = conversionApi.mapper.toViewElement(data.item);
|
|
914
|
+
if (!viewElement)
|
|
915
|
+
return;
|
|
916
|
+
// viewElement có thể là figure (cho block) hoặc img itself (cho inline)
|
|
917
|
+
// Tìm element img thực tế
|
|
918
|
+
const imgElement = this.findImgElement(viewElement);
|
|
919
|
+
if (!imgElement)
|
|
920
|
+
return;
|
|
921
|
+
this.applyCustomStyles(viewWriter, imgElement);
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Xử lý sự kiện thay đổi attribute ảnh
|
|
925
|
+
*/
|
|
926
|
+
handleImageAttributeChange(evt, data, conversionApi) {
|
|
927
|
+
const viewWriter = conversionApi.writer;
|
|
928
|
+
const viewElement = conversionApi.mapper.toViewElement(data.item);
|
|
929
|
+
if (!viewElement)
|
|
930
|
+
return;
|
|
931
|
+
const imgElement = this.findImgElement(viewElement);
|
|
932
|
+
if (!imgElement)
|
|
933
|
+
return;
|
|
934
|
+
this.applyCustomStyles(viewWriter, imgElement);
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Xử lý sự kiện thay đổi style ảnh - thêm float inline style cho căn chỉnh
|
|
938
|
+
*/
|
|
939
|
+
handleImageStyleChange(evt, data, conversionApi) {
|
|
940
|
+
const viewWriter = conversionApi.writer;
|
|
941
|
+
const viewElement = conversionApi.mapper.toViewElement(data.item);
|
|
942
|
+
if (!viewElement)
|
|
943
|
+
return;
|
|
944
|
+
// Tìm container ck-widget (element figure)
|
|
945
|
+
const widgetElement = this.findWidgetElement(viewElement);
|
|
946
|
+
if (!widgetElement)
|
|
947
|
+
return;
|
|
948
|
+
// Lấy giá trị imageStyle (căn chỉnh)
|
|
949
|
+
const imageStyle = data.item.getAttribute('imageStyle');
|
|
950
|
+
// Xóa các style hiện có trước
|
|
951
|
+
viewWriter.removeStyle('float', widgetElement);
|
|
952
|
+
viewWriter.removeStyle('margin', widgetElement);
|
|
953
|
+
viewWriter.removeStyle('text-align', widgetElement);
|
|
954
|
+
// Áp dụng style dựa trên căn chỉnh ảnh
|
|
955
|
+
// Các options đã cấu hình: ['inline', 'alignLeft', 'alignRight', 'alignCenter']
|
|
956
|
+
switch (imageStyle) {
|
|
957
|
+
case 'inline':
|
|
958
|
+
// Ảnh inline - không style đặc biệt, chỉ flow inline
|
|
959
|
+
break;
|
|
960
|
+
case 'alignLeft':
|
|
961
|
+
// Float trái
|
|
962
|
+
viewWriter.setStyle('float', 'left', widgetElement);
|
|
963
|
+
viewWriter.setStyle('margin', '0 16px 16px 0', widgetElement);
|
|
964
|
+
break;
|
|
965
|
+
case 'alignRight':
|
|
966
|
+
// Float phải
|
|
967
|
+
viewWriter.setStyle('float', 'right', widgetElement);
|
|
968
|
+
viewWriter.setStyle('margin', '0 0 16px 16px', widgetElement);
|
|
969
|
+
break;
|
|
970
|
+
case 'alignCenter':
|
|
971
|
+
case 'block':
|
|
972
|
+
// Căn giữa
|
|
973
|
+
viewWriter.setStyle('text-align', 'center', widgetElement);
|
|
974
|
+
viewWriter.setStyle('margin', '16px auto', widgetElement);
|
|
975
|
+
break;
|
|
976
|
+
default:
|
|
977
|
+
// Mặc định - căn giữa
|
|
978
|
+
viewWriter.setStyle('text-align', 'center', widgetElement);
|
|
979
|
+
viewWriter.setStyle('margin', '16px auto', widgetElement);
|
|
980
|
+
break;
|
|
981
|
+
}
|
|
982
|
+
// Áp dụng custom styles cơ bản cho element img
|
|
983
|
+
const imgElement = this.findImgElement(viewElement);
|
|
984
|
+
if (imgElement) {
|
|
985
|
+
this.applyCustomStyles(viewWriter, imgElement);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Áp dụng custom styles cho element ảnh
|
|
990
|
+
*/
|
|
991
|
+
applyCustomStyles(viewWriter, imgElement) {
|
|
992
|
+
// Xóa aspect-ratio nếu tồn tại
|
|
993
|
+
if (imgElement.getStyle('aspect-ratio')) {
|
|
994
|
+
viewWriter.removeStyle('aspect-ratio', imgElement);
|
|
995
|
+
}
|
|
996
|
+
// Áp dụng custom styles
|
|
997
|
+
viewWriter.setStyle('margin', '0', imgElement);
|
|
998
|
+
viewWriter.setStyle('border', '0', imgElement);
|
|
999
|
+
viewWriter.setStyle('max-width', '100%', imgElement);
|
|
1000
|
+
viewWriter.setStyle('height', 'auto', imgElement);
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Tìm container ck-widget (element figure)
|
|
1004
|
+
* CKEditor wrap ảnh block trong <figure class="ck-widget"><img></figure>
|
|
1005
|
+
*/
|
|
1006
|
+
findWidgetElement(viewElement) {
|
|
1007
|
+
if (!viewElement)
|
|
1008
|
+
return null;
|
|
1009
|
+
// Nếu đây là element figure itself
|
|
1010
|
+
if (viewElement.name === 'figure') {
|
|
1011
|
+
return viewElement;
|
|
1012
|
+
}
|
|
1013
|
+
// Cho ảnh inline, trả về element itself (span wrapper)
|
|
1014
|
+
if (viewElement.name === 'span') {
|
|
1015
|
+
return viewElement;
|
|
1016
|
+
}
|
|
1017
|
+
// Tìm ngược lên tree để tìm figure/ck-widget
|
|
1018
|
+
let current = viewElement;
|
|
1019
|
+
while (current) {
|
|
1020
|
+
if (current.name === 'figure' || current.name === 'span') {
|
|
1021
|
+
return current;
|
|
1022
|
+
}
|
|
1023
|
+
// Di chuyển lên parent
|
|
1024
|
+
if (current.parent) {
|
|
1025
|
+
current = current.parent;
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
break;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
// Nếu không tìm thấy widget, trả về element gốc
|
|
1032
|
+
return viewElement;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Tìm element img thực tế bên trong widget structure
|
|
1036
|
+
* CKEditor wrap ảnh block trong <figure class="ck-widget"><img></figure>
|
|
1037
|
+
*/
|
|
1038
|
+
findImgElement(viewElement) {
|
|
1039
|
+
if (!viewElement)
|
|
1040
|
+
return null;
|
|
1041
|
+
// Nếu đây là element img itself
|
|
1042
|
+
if (viewElement.name === 'img') {
|
|
1043
|
+
return viewElement;
|
|
1044
|
+
}
|
|
1045
|
+
// Cho structure widget của CKEditor, tìm đệ quy
|
|
1046
|
+
// Ảnh block: figure > span > img
|
|
1047
|
+
// Ảnh thường được wrap trong một container
|
|
1048
|
+
const queue = [viewElement];
|
|
1049
|
+
while (queue.length > 0) {
|
|
1050
|
+
const current = queue.shift();
|
|
1051
|
+
if (!current)
|
|
1052
|
+
continue;
|
|
1053
|
+
if (current.name === 'img') {
|
|
1054
|
+
return current;
|
|
1055
|
+
}
|
|
1056
|
+
// Thêm children vào queue
|
|
1057
|
+
if (current.getChildren) {
|
|
1058
|
+
for (const child of current.getChildren()) {
|
|
1059
|
+
queue.push(child);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Icon khổ dọc (Mặc định cũ)
|
|
1068
|
+
const ICON_PORTRAIT = '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M14 2H6c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6V4h8v12z"/></svg>';
|
|
1069
|
+
// Icon khổ ngang (Mới)
|
|
1070
|
+
const ICON_LANDSCAPE = '<svg viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M18 4H2C.9 4 0 4.9 0 6v8c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 10H2V6h16v8z"/></svg>';
|
|
1071
|
+
class PageOrientationPlugin extends Plugin {
|
|
1072
|
+
static pluginName = 'PageOrientationPlugin';
|
|
1073
|
+
_currentOrientation = 'PORTRAIT';
|
|
1074
|
+
orientationChangeEmitter;
|
|
1075
|
+
buttonView;
|
|
1076
|
+
init() {
|
|
1077
|
+
const editor = this.editor;
|
|
1078
|
+
const componentFactory = editor.ui.componentFactory;
|
|
1079
|
+
// Đăng ký nút tên là 'pageOrientation'
|
|
1080
|
+
componentFactory.add('pageOrientation', locale => {
|
|
1081
|
+
const view = new ButtonView(locale);
|
|
1082
|
+
this.buttonView = view;
|
|
1083
|
+
view.set({
|
|
1084
|
+
// label: 'Xoay giấy (A4)',
|
|
1085
|
+
icon: ICON_PORTRAIT,
|
|
1086
|
+
// tooltip: true,
|
|
1087
|
+
// withText: true,
|
|
1088
|
+
class: 'btn-orientation', // Class để style nếu cần
|
|
1089
|
+
});
|
|
1090
|
+
// Xử lý khi bấm nút
|
|
1091
|
+
view.on('execute', () => {
|
|
1092
|
+
this.toggleOrientation();
|
|
1093
|
+
});
|
|
1094
|
+
return view;
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Toggle between portrait and landscape orientation
|
|
1099
|
+
*/
|
|
1100
|
+
toggleOrientation() {
|
|
1101
|
+
const newOrientation = this._currentOrientation === 'PORTRAIT' ? 'LANDSCAPE' : 'PORTRAIT';
|
|
1102
|
+
this.setOrientation(newOrientation);
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Set orientation programmatically
|
|
1106
|
+
*/
|
|
1107
|
+
setOrientation(orientation) {
|
|
1108
|
+
const editor = this.editor;
|
|
1109
|
+
const editingView = editor.editing.view;
|
|
1110
|
+
const rootElement = editingView.document.getRoot();
|
|
1111
|
+
editor.editing.view.change(writer => {
|
|
1112
|
+
if (orientation === 'LANDSCAPE') {
|
|
1113
|
+
writer.addClass('landscape', rootElement);
|
|
1114
|
+
}
|
|
1115
|
+
else {
|
|
1116
|
+
writer.removeClass('landscape', rootElement);
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
// Update button icon
|
|
1120
|
+
if (this.buttonView) {
|
|
1121
|
+
this.buttonView.icon = orientation === 'LANDSCAPE' ? ICON_LANDSCAPE : ICON_PORTRAIT;
|
|
1122
|
+
}
|
|
1123
|
+
this._currentOrientation = orientation;
|
|
1124
|
+
this.orientationChangeEmitter?.(orientation);
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Get current orientation
|
|
1128
|
+
*/
|
|
1129
|
+
getOrientation() {
|
|
1130
|
+
return this._currentOrientation;
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Register callback for orientation changes
|
|
1134
|
+
*/
|
|
1135
|
+
onOrientationChange(callback) {
|
|
1136
|
+
this.orientationChangeEmitter = callback;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Cấu hình màu cho Document Builder
|
|
1142
|
+
* Bảng màu tập trung và cấu hình cho việc lựa chọn màu nhất quán
|
|
1143
|
+
*/
|
|
1144
|
+
/**
|
|
1145
|
+
* Trả về bảng màu chung được sử dụng trong tất cả tính năng của document builder
|
|
1146
|
+
* @returns Mảng các tùy chọn màu được định sẵn với giá trị hex và label
|
|
1147
|
+
*/
|
|
1148
|
+
function getPresetColors() {
|
|
1149
|
+
return [
|
|
1150
|
+
{ color: '#000000', label: 'Black' },
|
|
1151
|
+
{ color: '#4D4D4D', label: 'Dim grey' },
|
|
1152
|
+
{ color: '#999999', label: 'Grey' },
|
|
1153
|
+
{ color: '#E6E6E6', label: 'Light grey' },
|
|
1154
|
+
{ color: '#FFFFFF', label: 'White' },
|
|
1155
|
+
{ color: '#E64D4D', label: 'Red' },
|
|
1156
|
+
{ color: '#E6994D', label: 'Orange' },
|
|
1157
|
+
{ color: '#E6E64D', label: 'Yellow' },
|
|
1158
|
+
{ color: '#99E64D', label: 'Light green' },
|
|
1159
|
+
{ color: '#4DE64D', label: 'Green' },
|
|
1160
|
+
{ color: '#4DE699', label: 'Aquamarine' },
|
|
1161
|
+
{ color: '#4DE6E6', label: 'Turquoise' },
|
|
1162
|
+
{ color: '#4D99E6', label: 'Light blue' },
|
|
1163
|
+
{ color: '#4D4DE6', label: 'Blue' },
|
|
1164
|
+
{ color: '#994DE6', label: 'Purple' },
|
|
1165
|
+
];
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Trả về cấu hình bộ chọn màu với định dạng hex
|
|
1169
|
+
* @returns Đối tượng cấu hình bộ chọn màu
|
|
1170
|
+
*/
|
|
1171
|
+
function getColorPickerConfig() {
|
|
1172
|
+
return {
|
|
1173
|
+
format: 'hex',
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Trả về cấu hình kích thước font cho document builder
|
|
1178
|
+
* @returns Mảng các tùy chọn kích thước font được định sẵn
|
|
1179
|
+
*/
|
|
1180
|
+
function getFontSizeOptions() {
|
|
1181
|
+
return [
|
|
1182
|
+
{
|
|
1183
|
+
title: '9',
|
|
1184
|
+
model: '9pt',
|
|
1185
|
+
view: {
|
|
1186
|
+
name: 'span',
|
|
1187
|
+
styles: { 'font-size': '9pt' },
|
|
1188
|
+
priority: 7,
|
|
1189
|
+
},
|
|
1190
|
+
},
|
|
1191
|
+
{
|
|
1192
|
+
title: '10',
|
|
1193
|
+
model: '10pt',
|
|
1194
|
+
view: {
|
|
1195
|
+
name: 'span',
|
|
1196
|
+
styles: { 'font-size': '10pt' },
|
|
1197
|
+
priority: 7,
|
|
1198
|
+
},
|
|
1199
|
+
},
|
|
1200
|
+
{
|
|
1201
|
+
title: '11',
|
|
1202
|
+
model: '11pt',
|
|
1203
|
+
view: {
|
|
1204
|
+
name: 'span',
|
|
1205
|
+
styles: { 'font-size': '11pt' },
|
|
1206
|
+
priority: 7,
|
|
1207
|
+
},
|
|
1208
|
+
},
|
|
1209
|
+
{
|
|
1210
|
+
title: '12',
|
|
1211
|
+
model: '12pt',
|
|
1212
|
+
view: {
|
|
1213
|
+
name: 'span',
|
|
1214
|
+
styles: { 'font-size': '12pt' },
|
|
1215
|
+
priority: 7,
|
|
1216
|
+
},
|
|
1217
|
+
},
|
|
1218
|
+
{
|
|
1219
|
+
title: '13',
|
|
1220
|
+
model: '13pt',
|
|
1221
|
+
view: {
|
|
1222
|
+
name: 'span',
|
|
1223
|
+
styles: { 'font-size': '13pt' },
|
|
1224
|
+
priority: 7,
|
|
1225
|
+
},
|
|
1226
|
+
},
|
|
1227
|
+
{
|
|
1228
|
+
title: '14',
|
|
1229
|
+
model: '14pt',
|
|
1230
|
+
view: {
|
|
1231
|
+
name: 'span',
|
|
1232
|
+
styles: { 'font-size': '14pt' },
|
|
1233
|
+
priority: 7,
|
|
1234
|
+
},
|
|
1235
|
+
},
|
|
1236
|
+
{
|
|
1237
|
+
title: '16',
|
|
1238
|
+
model: '16pt',
|
|
1239
|
+
view: {
|
|
1240
|
+
name: 'span',
|
|
1241
|
+
styles: { 'font-size': '16pt' },
|
|
1242
|
+
priority: 7,
|
|
1243
|
+
},
|
|
1244
|
+
},
|
|
1245
|
+
{
|
|
1246
|
+
title: '18',
|
|
1247
|
+
model: '18pt',
|
|
1248
|
+
view: {
|
|
1249
|
+
name: 'span',
|
|
1250
|
+
styles: { 'font-size': '18pt' },
|
|
1251
|
+
priority: 7,
|
|
1252
|
+
},
|
|
1253
|
+
},
|
|
1254
|
+
{
|
|
1255
|
+
title: '20',
|
|
1256
|
+
model: '20pt',
|
|
1257
|
+
view: {
|
|
1258
|
+
name: 'span',
|
|
1259
|
+
styles: { 'font-size': '20pt' },
|
|
1260
|
+
priority: 7,
|
|
1261
|
+
},
|
|
1262
|
+
},
|
|
1263
|
+
{
|
|
1264
|
+
title: '24',
|
|
1265
|
+
model: '24pt',
|
|
1266
|
+
view: {
|
|
1267
|
+
name: 'span',
|
|
1268
|
+
styles: { 'font-size': '24pt' },
|
|
1269
|
+
priority: 7,
|
|
1270
|
+
},
|
|
1271
|
+
},
|
|
1272
|
+
];
|
|
1273
|
+
}
|
|
1274
|
+
function getHeadingOptions() {
|
|
1275
|
+
return [
|
|
1276
|
+
{ model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
|
|
1277
|
+
{ model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
|
|
1278
|
+
{ model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
|
|
1279
|
+
{ model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' },
|
|
1280
|
+
];
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Document Builder Utilities
|
|
1285
|
+
* Các hàm tiện ích cho document builder
|
|
1286
|
+
*/
|
|
1287
|
+
/**
|
|
1288
|
+
* Chuẩn hóa nội dung bằng cách chuyển đổi tất cả màu HSL và RGB sang hex
|
|
1289
|
+
* @param content - Nội dung HTML cần chuẩn hóa
|
|
1290
|
+
* @returns Nội dung đã được chuẩn hóa với màu hex
|
|
1291
|
+
*/
|
|
1292
|
+
function normalize(content) {
|
|
1293
|
+
let normalized = content;
|
|
1294
|
+
// Chuyển đổi HSL sang hex
|
|
1295
|
+
const hslRegex = /hsl\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*\)/gi;
|
|
1296
|
+
normalized = normalized.replace(hslRegex, (match, h, s, l) => {
|
|
1297
|
+
try {
|
|
1298
|
+
const hue = parseInt(h, 10);
|
|
1299
|
+
const saturation = parseInt(s, 10);
|
|
1300
|
+
const lightness = parseInt(l, 10);
|
|
1301
|
+
// Kiểm tra giá trị hợp lệ
|
|
1302
|
+
if (hue >= 0 && hue <= 360 && saturation >= 0 && saturation <= 100 && lightness >= 0 && lightness <= 100) {
|
|
1303
|
+
return hslToHex(hue, saturation, lightness);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
catch (error) {
|
|
1307
|
+
console.warn('Failed to convert HSL to hex:', error, match);
|
|
1308
|
+
}
|
|
1309
|
+
return match; // Giữ nguyên nếu không thể chuyển đổi
|
|
1310
|
+
});
|
|
1311
|
+
// Chuyển đổi RGB sang hex
|
|
1312
|
+
const rgbRegex = /rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/gi;
|
|
1313
|
+
normalized = normalized.replace(rgbRegex, (match, r, g, b) => {
|
|
1314
|
+
try {
|
|
1315
|
+
const red = parseInt(r, 10);
|
|
1316
|
+
const green = parseInt(g, 10);
|
|
1317
|
+
const blue = parseInt(b, 10);
|
|
1318
|
+
if (red >= 0 && red <= 255 && green >= 0 && green <= 255 && blue >= 0 && blue <= 255) {
|
|
1319
|
+
return rgbToHex(red, green, blue);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
catch (error) {
|
|
1323
|
+
console.warn('Failed to convert RGB to hex:', error, match);
|
|
1324
|
+
}
|
|
1325
|
+
return match;
|
|
1326
|
+
});
|
|
1327
|
+
return normalized;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
class HighlightRangePlugin extends Plugin {
|
|
1331
|
+
init() {
|
|
1332
|
+
const editor = this.editor;
|
|
1333
|
+
editor.conversion.for('editingDowncast').markerToHighlight({
|
|
1334
|
+
model: 'highlightRange',
|
|
1335
|
+
view: {
|
|
1336
|
+
classes: 'highlight-range',
|
|
1337
|
+
},
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
538
1340
|
}
|
|
539
1341
|
|
|
540
1342
|
class SdDocumentBuilder {
|
|
541
|
-
#id = '1212';
|
|
542
1343
|
option;
|
|
543
1344
|
disabled = false;
|
|
544
1345
|
set _disabled(val) {
|
|
545
1346
|
this.disabled = val === '' || !!val;
|
|
546
1347
|
this.#updateState();
|
|
547
1348
|
}
|
|
1349
|
+
contentChange = new EventEmitter(); // Emit HTML content
|
|
548
1350
|
Editor = ClassicEditor;
|
|
549
1351
|
#editor;
|
|
1352
|
+
#id = '55b0afb0-288d-423c-98b3-5f9db286e16d';
|
|
1353
|
+
#subscription = new Subscription();
|
|
1354
|
+
#sharedColors = getPresetColors();
|
|
1355
|
+
#headingOptions = getHeadingOptions();
|
|
1356
|
+
#fontSizeOptions = getFontSizeOptions();
|
|
1357
|
+
#colorPickerConfig = getColorPickerConfig();
|
|
1358
|
+
#contentChangeSubject = new Subject();
|
|
1359
|
+
#idTimeOutScrollHeading = null;
|
|
1360
|
+
#headingElementsMap = new Map(); // Hash lưu trữ các heading
|
|
550
1361
|
// Config
|
|
551
1362
|
config = {
|
|
552
1363
|
getOption: () => this.option,
|
|
@@ -582,12 +1393,18 @@ class SdDocumentBuilder {
|
|
|
582
1393
|
ImageCaption,
|
|
583
1394
|
ImageResize,
|
|
584
1395
|
ImageStyle,
|
|
1396
|
+
ImageBlock,
|
|
585
1397
|
// Custom Plugin
|
|
586
|
-
|
|
1398
|
+
HeadingPlugin,
|
|
587
1399
|
CommentPlugin,
|
|
588
1400
|
VariablePlugin,
|
|
589
1401
|
TableFitPlugin,
|
|
1402
|
+
Indent,
|
|
1403
|
+
IndentBlock,
|
|
1404
|
+
PageOrientationPlugin,
|
|
590
1405
|
ImageUploadPlugin,
|
|
1406
|
+
ImageCustomPlugin,
|
|
1407
|
+
HighlightRangePlugin,
|
|
591
1408
|
],
|
|
592
1409
|
toolbar: {
|
|
593
1410
|
items: [
|
|
@@ -622,122 +1439,65 @@ class SdDocumentBuilder {
|
|
|
622
1439
|
shouldNotGroupWhenFull: true,
|
|
623
1440
|
},
|
|
624
1441
|
image: {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
title: '9',
|
|
632
|
-
model: '9pt',
|
|
633
|
-
view: {
|
|
634
|
-
name: 'span',
|
|
635
|
-
styles: { 'font-size': '9pt' },
|
|
636
|
-
priority: 7,
|
|
637
|
-
},
|
|
638
|
-
},
|
|
639
|
-
{
|
|
640
|
-
title: '10',
|
|
641
|
-
model: '10pt',
|
|
642
|
-
view: {
|
|
643
|
-
name: 'span',
|
|
644
|
-
styles: { 'font-size': '10pt' },
|
|
645
|
-
priority: 7,
|
|
646
|
-
},
|
|
647
|
-
},
|
|
648
|
-
{
|
|
649
|
-
title: '11',
|
|
650
|
-
model: '11pt',
|
|
651
|
-
view: {
|
|
652
|
-
name: 'span',
|
|
653
|
-
styles: { 'font-size': '11pt' },
|
|
654
|
-
priority: 7,
|
|
655
|
-
},
|
|
656
|
-
},
|
|
657
|
-
{
|
|
658
|
-
title: '12',
|
|
659
|
-
model: '12pt',
|
|
660
|
-
view: {
|
|
661
|
-
name: 'span',
|
|
662
|
-
styles: { 'font-size': '12pt' },
|
|
663
|
-
priority: 7,
|
|
664
|
-
},
|
|
665
|
-
},
|
|
666
|
-
{
|
|
667
|
-
title: '13',
|
|
668
|
-
model: '13pt',
|
|
669
|
-
view: {
|
|
670
|
-
name: 'span',
|
|
671
|
-
styles: { 'font-size': '13pt' },
|
|
672
|
-
priority: 7,
|
|
673
|
-
},
|
|
674
|
-
},
|
|
675
|
-
{
|
|
676
|
-
title: '14',
|
|
677
|
-
model: '14pt',
|
|
678
|
-
view: {
|
|
679
|
-
name: 'span',
|
|
680
|
-
styles: { 'font-size': '14pt' },
|
|
681
|
-
priority: 7,
|
|
682
|
-
},
|
|
683
|
-
},
|
|
684
|
-
{
|
|
685
|
-
title: '16',
|
|
686
|
-
model: '16pt',
|
|
687
|
-
view: {
|
|
688
|
-
name: 'span',
|
|
689
|
-
styles: { 'font-size': '16pt' },
|
|
690
|
-
priority: 7,
|
|
691
|
-
},
|
|
692
|
-
},
|
|
693
|
-
{
|
|
694
|
-
title: '18',
|
|
695
|
-
model: '18pt',
|
|
696
|
-
view: {
|
|
697
|
-
name: 'span',
|
|
698
|
-
styles: { 'font-size': '18pt' },
|
|
699
|
-
priority: 7,
|
|
700
|
-
},
|
|
701
|
-
},
|
|
702
|
-
{
|
|
703
|
-
title: '20',
|
|
704
|
-
model: '20pt',
|
|
705
|
-
view: {
|
|
706
|
-
name: 'span',
|
|
707
|
-
styles: { 'font-size': '20pt' },
|
|
708
|
-
priority: 7,
|
|
709
|
-
},
|
|
710
|
-
},
|
|
1442
|
+
styles: {
|
|
1443
|
+
options: ['inline', 'alignLeft', 'alignRight', 'alignCenter'],
|
|
1444
|
+
},
|
|
1445
|
+
toolbar: [
|
|
1446
|
+
'imageStyle:inline',
|
|
1447
|
+
'imageStyle:alignCenter',
|
|
711
1448
|
{
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
name: 'span',
|
|
716
|
-
styles: { 'font-size': '24pt' },
|
|
717
|
-
priority: 7,
|
|
718
|
-
},
|
|
1449
|
+
name: 'imageStyle:alignDropdown',
|
|
1450
|
+
items: ['imageStyle:alignLeft', 'imageStyle:alignRight'],
|
|
1451
|
+
defaultItem: 'imageStyle:alignLeft',
|
|
719
1452
|
},
|
|
720
1453
|
],
|
|
1454
|
+
},
|
|
1455
|
+
fontSize: {
|
|
1456
|
+
options: this.#fontSizeOptions,
|
|
721
1457
|
supportAllValues: false, // Khuyên dùng false để ép user chọn đúng size chuẩn
|
|
722
1458
|
},
|
|
1459
|
+
heading: {
|
|
1460
|
+
options: this.#headingOptions,
|
|
1461
|
+
},
|
|
723
1462
|
// 4. Cấu hình bảng màu (Tùy chọn)
|
|
724
1463
|
fontColor: {
|
|
725
|
-
columns: 5,
|
|
1464
|
+
// columns: 5,
|
|
726
1465
|
documentColors: 10,
|
|
1466
|
+
colorPicker: this.#colorPickerConfig,
|
|
1467
|
+
colors: this.#sharedColors,
|
|
727
1468
|
},
|
|
728
1469
|
fontBackgroundColor: {
|
|
729
|
-
columns: 5,
|
|
1470
|
+
// columns: 5,
|
|
730
1471
|
documentColors: 10,
|
|
1472
|
+
colorPicker: this.#colorPickerConfig,
|
|
1473
|
+
colors: this.#sharedColors,
|
|
731
1474
|
},
|
|
732
1475
|
table: {
|
|
733
|
-
contentToolbar: [
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1476
|
+
contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells', '|', 'tableProperties', 'tableCellProperties'],
|
|
1477
|
+
tableProperties: {
|
|
1478
|
+
borderColors: this.#sharedColors,
|
|
1479
|
+
backgroundColors: this.#sharedColors,
|
|
1480
|
+
colorPicker: this.#colorPickerConfig,
|
|
1481
|
+
defaultProperties: {
|
|
1482
|
+
borderStyle: 'solid',
|
|
1483
|
+
borderWidth: '1px',
|
|
1484
|
+
borderColor: '#ccc',
|
|
1485
|
+
},
|
|
1486
|
+
},
|
|
1487
|
+
tableCellProperties: {
|
|
1488
|
+
borderColors: this.#sharedColors,
|
|
1489
|
+
backgroundColors: this.#sharedColors,
|
|
1490
|
+
colorPicker: this.#colorPickerConfig,
|
|
1491
|
+
defaultProperties: {
|
|
1492
|
+
borderStyle: 'solid',
|
|
1493
|
+
borderWidth: '1px',
|
|
1494
|
+
borderColor: '#ccc',
|
|
1495
|
+
},
|
|
1496
|
+
},
|
|
1497
|
+
},
|
|
1498
|
+
indentBlock: {
|
|
1499
|
+
offset: 48, // Đơn vị px cho mỗi mức indent (tương đương 0.5 inch)
|
|
1500
|
+
unit: 'px',
|
|
741
1501
|
},
|
|
742
1502
|
// Quan trọng: Cho phép paste style từ Word nhưng bỏ qua margin/padding
|
|
743
1503
|
htmlSupport: {
|
|
@@ -754,15 +1514,10 @@ class SdDocumentBuilder {
|
|
|
754
1514
|
],
|
|
755
1515
|
},
|
|
756
1516
|
};
|
|
757
|
-
contentChange = new EventEmitter(); // Emit HTML content
|
|
758
|
-
#subscription = new Subscription();
|
|
759
|
-
#contentChangeSubject = new Subject();
|
|
760
|
-
#editorChangeRxjs = new Subject();
|
|
761
1517
|
ngOnInit() {
|
|
762
|
-
//
|
|
763
|
-
// Debounce trong rxjs không hỗ trợ leading -->
|
|
1518
|
+
// Debounce trong rxjs không hỗ trợ leading --> throttleTime
|
|
764
1519
|
this.#subscription.add(this.#contentChangeSubject.pipe(throttleTime(500, undefined, { leading: true, trailing: true })).subscribe(content => {
|
|
765
|
-
this.contentChange.emit(content);
|
|
1520
|
+
this.contentChange.emit(normalize(content));
|
|
766
1521
|
}));
|
|
767
1522
|
}
|
|
768
1523
|
ngOnDestroy() {
|
|
@@ -786,49 +1541,38 @@ class SdDocumentBuilder {
|
|
|
786
1541
|
catch (error) {
|
|
787
1542
|
console.warn('PageOrientationPlugin not available:', error);
|
|
788
1543
|
}
|
|
789
|
-
//
|
|
1544
|
+
// Lắng nghe selection
|
|
790
1545
|
editor.model.document.selection.on('change', $event => {
|
|
791
1546
|
this.option.onSelection?.(this.#editor.model.document.selection, $event);
|
|
792
1547
|
});
|
|
793
|
-
//
|
|
1548
|
+
// Lắng nghe sự kiện thay đổi nội dung
|
|
794
1549
|
editor.model.document.on('change:data', () => {
|
|
795
1550
|
const content = editor.getData();
|
|
796
1551
|
this.#contentChangeSubject.next(content);
|
|
797
1552
|
});
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
}
|
|
807
|
-
else {
|
|
808
|
-
// Tắt chế độ chỉ đọc với ID khóa tương ứng
|
|
809
|
-
this.#editor.disableReadOnlyMode(this.#id);
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
scrollToComment = (markerId) => {
|
|
813
|
-
if (!this.#editor)
|
|
814
|
-
return;
|
|
815
|
-
const editor = this.#editor;
|
|
816
|
-
const marker = editor.model.markers.get(markerId);
|
|
817
|
-
if (marker) {
|
|
818
|
-
// 1. Set Selection vào Marker đó trước
|
|
819
|
-
this.#editor.model.change(writer => {
|
|
820
|
-
writer.setSelection(marker.getRange());
|
|
1553
|
+
try {
|
|
1554
|
+
// Manual keybinding cho Tab nếu cần
|
|
1555
|
+
editor.keystrokes.set('Tab', (evt, cancel) => {
|
|
1556
|
+
const command = editor.commands.get('indentBlock');
|
|
1557
|
+
if (command && command.isEnabled) {
|
|
1558
|
+
editor.execute('indentBlock');
|
|
1559
|
+
cancel();
|
|
1560
|
+
}
|
|
821
1561
|
});
|
|
822
|
-
//
|
|
823
|
-
|
|
824
|
-
|
|
1562
|
+
// Manual keybinding cho Shift+Tab
|
|
1563
|
+
editor.keystrokes.set('Shift+Tab', (evt, cancel) => {
|
|
1564
|
+
const command = editor.commands.get('outdentBlock');
|
|
1565
|
+
if (command && command.isEnabled) {
|
|
1566
|
+
editor.execute('outdentBlock');
|
|
1567
|
+
cancel();
|
|
1568
|
+
}
|
|
825
1569
|
});
|
|
826
|
-
editor.editing.view.focus();
|
|
827
1570
|
}
|
|
828
|
-
|
|
829
|
-
console.warn(
|
|
1571
|
+
catch (error) {
|
|
1572
|
+
console.warn('Error setting up indent keybindings:', error);
|
|
830
1573
|
}
|
|
831
|
-
|
|
1574
|
+
this.#updateState();
|
|
1575
|
+
}
|
|
832
1576
|
setContent = (html) => {
|
|
833
1577
|
this.#editor?.setData?.(html);
|
|
834
1578
|
};
|
|
@@ -865,55 +1609,6 @@ class SdDocumentBuilder {
|
|
|
865
1609
|
}
|
|
866
1610
|
return 'PORTRAIT';
|
|
867
1611
|
};
|
|
868
|
-
getVariables = () => {
|
|
869
|
-
if (!this.#editor)
|
|
870
|
-
return [];
|
|
871
|
-
const model = this.#editor.model;
|
|
872
|
-
const root = model.document.getRoot();
|
|
873
|
-
if (!root)
|
|
874
|
-
return [];
|
|
875
|
-
const variables = [];
|
|
876
|
-
// Duyệt qua tất cả các phần tử trong range
|
|
877
|
-
// range.getItems() sẽ trả về từng node (text, element...)
|
|
878
|
-
try {
|
|
879
|
-
const range = model.createRangeIn(root);
|
|
880
|
-
for (const item of range.getItems()) {
|
|
881
|
-
// Sử dụng item.is('element', 'variable') là chính xác
|
|
882
|
-
if (item.is('element', 'variable')) {
|
|
883
|
-
variables.push({
|
|
884
|
-
id: item.getAttribute('id'),
|
|
885
|
-
value: item.getAttribute('value'),
|
|
886
|
-
display: item.getAttribute('display'),
|
|
887
|
-
data: item.getAttribute('data'),
|
|
888
|
-
});
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
catch (e) {
|
|
893
|
-
console.error(e);
|
|
894
|
-
return [];
|
|
895
|
-
}
|
|
896
|
-
return variables;
|
|
897
|
-
};
|
|
898
|
-
getComments() {
|
|
899
|
-
if (!this.#editor)
|
|
900
|
-
return [];
|
|
901
|
-
const markers = this.#editor.model.markers;
|
|
902
|
-
const comments = [];
|
|
903
|
-
// Duyệt qua tất cả markers trong Model
|
|
904
|
-
for (const marker of markers) {
|
|
905
|
-
// Chỉ lấy marker do plugin comment tạo ra (prefix 'comment:')
|
|
906
|
-
if (marker.name.startsWith('comment:')) {
|
|
907
|
-
// Lấy text nằm trong vùng marker đó
|
|
908
|
-
const currentText = this.#getTextFromRange(marker.getRange());
|
|
909
|
-
comments.push({
|
|
910
|
-
markerId: marker.name,
|
|
911
|
-
selectedText: currentText,
|
|
912
|
-
});
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
return comments;
|
|
916
|
-
}
|
|
917
1612
|
scrollToTop() {
|
|
918
1613
|
setTimeout(() => {
|
|
919
1614
|
if (this.#editor) {
|
|
@@ -933,13 +1628,67 @@ class SdDocumentBuilder {
|
|
|
933
1628
|
}
|
|
934
1629
|
}, 100);
|
|
935
1630
|
}
|
|
936
|
-
|
|
937
|
-
|
|
1631
|
+
#updateState() {
|
|
1632
|
+
if (!this.#editor)
|
|
1633
|
+
return;
|
|
1634
|
+
if (this.disabled) {
|
|
1635
|
+
// Bật chế độ chỉ đọc với ID khóa
|
|
1636
|
+
this.#editor.enableReadOnlyMode(this.#id);
|
|
1637
|
+
// Disable page orientation button
|
|
1638
|
+
try {
|
|
1639
|
+
const orientationPlugin = this.#editor.plugins.get('PageOrientationPlugin');
|
|
1640
|
+
if (orientationPlugin && orientationPlugin.buttonView) {
|
|
1641
|
+
orientationPlugin.buttonView.isEnabled = false;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
catch (error) {
|
|
1645
|
+
console.warn('Failed to disable orientation button:', error);
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
else {
|
|
1649
|
+
// Tắt chế độ chỉ đọc với ID khóa tương ứng
|
|
1650
|
+
this.#editor.disableReadOnlyMode(this.#id);
|
|
1651
|
+
// Enable page orientation button
|
|
1652
|
+
try {
|
|
1653
|
+
const orientationPlugin = this.#editor.plugins.get('PageOrientationPlugin');
|
|
1654
|
+
if (orientationPlugin && orientationPlugin.buttonView) {
|
|
1655
|
+
orientationPlugin.buttonView.isEnabled = true;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
catch (error) {
|
|
1659
|
+
console.warn('Failed to enable orientation button:', error);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
#getTextFromElement = (element) => {
|
|
1664
|
+
let text = '';
|
|
1665
|
+
// Heading trong Model chứa các text node con
|
|
1666
|
+
for (const child of element.getChildren()) {
|
|
1667
|
+
if (child.is('$text') || child.is('$textProxy')) {
|
|
1668
|
+
text += child.data;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return text;
|
|
1672
|
+
};
|
|
1673
|
+
#getTextFromRange = (range) => {
|
|
1674
|
+
let text = '';
|
|
1675
|
+
for (const item of range.getItems()) {
|
|
1676
|
+
// TextProxy là một phần của Text Node nằm trong Range
|
|
1677
|
+
if (item.is('$textProxy') || item.is('$text')) {
|
|
1678
|
+
text += item.data;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
return text;
|
|
1682
|
+
};
|
|
1683
|
+
// ========================================================================
|
|
1684
|
+
// 1. QUẢN LÝ HEADING
|
|
1685
|
+
// ========================================================================
|
|
938
1686
|
heading = {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1687
|
+
/**
|
|
1688
|
+
* Lấy tất cả headings trong document
|
|
1689
|
+
* @returns Danh sách tất cả headings
|
|
1690
|
+
*/
|
|
1691
|
+
all: () => {
|
|
943
1692
|
if (!this.#editor)
|
|
944
1693
|
return [];
|
|
945
1694
|
const root = this.#editor.model.document.getRoot();
|
|
@@ -975,37 +1724,50 @@ class SdDocumentBuilder {
|
|
|
975
1724
|
}
|
|
976
1725
|
return headings;
|
|
977
1726
|
},
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1727
|
+
/**
|
|
1728
|
+
* Scroll tới vị trí của heading
|
|
1729
|
+
* @param id - ID của heading cần scroll tới
|
|
1730
|
+
*/
|
|
1731
|
+
scroll: (id) => {
|
|
982
1732
|
if (!this.#editor)
|
|
983
1733
|
return;
|
|
984
|
-
// 1. Lấy Model Element từ kho lưu trữ
|
|
985
1734
|
const modelElement = this.#headingElementsMap.get(id);
|
|
986
1735
|
if (modelElement) {
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1736
|
+
this.#editor.model.change(writer => {
|
|
1737
|
+
// Xóa marker cũ
|
|
1738
|
+
if (this.#idTimeOutScrollHeading) {
|
|
1739
|
+
clearTimeout(this.#idTimeOutScrollHeading);
|
|
1740
|
+
}
|
|
1741
|
+
const currentMarker = this.#editor.model.markers.get('highlightMarker');
|
|
1742
|
+
if (currentMarker) {
|
|
1743
|
+
writer.removeMarker(currentMarker);
|
|
1744
|
+
}
|
|
1745
|
+
// Tạo Range bao trùm highlight
|
|
1746
|
+
const range = writer.createRangeOn(modelElement);
|
|
1747
|
+
// Thêm Marker mới
|
|
1748
|
+
writer.addMarker('highlightMarker', {
|
|
1749
|
+
range: range,
|
|
1750
|
+
usingOperation: false,
|
|
1751
|
+
});
|
|
1752
|
+
});
|
|
1753
|
+
// Scroll tới vị trí tìm được
|
|
1754
|
+
const viewElement = this.#editor.editing.mapper.toViewElement(modelElement);
|
|
992
1755
|
if (viewElement) {
|
|
993
|
-
|
|
994
|
-
const domElement = view.domConverter.mapViewToDom(viewElement);
|
|
1756
|
+
const domElement = this.#editor.editing.view.domConverter.viewToDom(viewElement);
|
|
995
1757
|
if (domElement) {
|
|
996
|
-
|
|
997
|
-
domElement.scrollIntoView({
|
|
998
|
-
behavior: 'smooth',
|
|
999
|
-
block: 'start',
|
|
1000
|
-
inline: 'nearest',
|
|
1001
|
-
});
|
|
1002
|
-
// 5. (Tùy chọn) Focus và đặt con trỏ vào đó
|
|
1003
|
-
view.focus();
|
|
1004
|
-
editor.model.change(writer => {
|
|
1005
|
-
writer.setSelection(modelElement, 'on');
|
|
1006
|
-
});
|
|
1758
|
+
domElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1007
1759
|
}
|
|
1008
1760
|
}
|
|
1761
|
+
// Tự động tắt marker sau 5 giây
|
|
1762
|
+
this.#idTimeOutScrollHeading = setTimeout(() => {
|
|
1763
|
+
if (this.#editor) {
|
|
1764
|
+
this.#editor.model.change(writer => {
|
|
1765
|
+
const marker = this.#editor.model.markers.get('highlightMarker');
|
|
1766
|
+
if (marker)
|
|
1767
|
+
writer.removeMarker(marker);
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
}, 5000);
|
|
1009
1771
|
}
|
|
1010
1772
|
else {
|
|
1011
1773
|
console.warn(`Heading with id ${id} not found.`);
|
|
@@ -1013,30 +1775,7 @@ class SdDocumentBuilder {
|
|
|
1013
1775
|
},
|
|
1014
1776
|
};
|
|
1015
1777
|
// ========================================================================
|
|
1016
|
-
//
|
|
1017
|
-
// ========================================================================
|
|
1018
|
-
#getTextFromElement = (element) => {
|
|
1019
|
-
let text = '';
|
|
1020
|
-
// Heading trong Model chứa các text node con
|
|
1021
|
-
for (const child of element.getChildren()) {
|
|
1022
|
-
if (child.is('$text') || child.is('$textProxy')) {
|
|
1023
|
-
text += child.data;
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
return text;
|
|
1027
|
-
};
|
|
1028
|
-
#getTextFromRange = (range) => {
|
|
1029
|
-
let text = '';
|
|
1030
|
-
for (const item of range.getItems()) {
|
|
1031
|
-
// TextProxy là một phần của Text Node nằm trong Range
|
|
1032
|
-
if (item.is('$textProxy') || item.is('$text')) {
|
|
1033
|
-
text += item.data;
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
return text;
|
|
1037
|
-
};
|
|
1038
|
-
// ========================================================================
|
|
1039
|
-
// COMMENT MANAGEMENT
|
|
1778
|
+
// 2. QUẢN LÝ COMMENT
|
|
1040
1779
|
// ========================================================================
|
|
1041
1780
|
comment = {
|
|
1042
1781
|
/**
|
|
@@ -1046,21 +1785,28 @@ class SdDocumentBuilder {
|
|
|
1046
1785
|
all: () => {
|
|
1047
1786
|
if (!this.#editor)
|
|
1048
1787
|
return [];
|
|
1049
|
-
const
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1788
|
+
const editableElement = this.#editor.ui.view.editable.element;
|
|
1789
|
+
if (!editableElement)
|
|
1790
|
+
return [];
|
|
1791
|
+
const commentMarkers = editableElement.querySelectorAll('.ck-comment-marker[data-comment-id^="comment:"]') || [];
|
|
1792
|
+
const commentsMap = new Map();
|
|
1793
|
+
commentMarkers.forEach(el => {
|
|
1794
|
+
const markerId = el.getAttribute('data-comment-id');
|
|
1795
|
+
if (markerId) {
|
|
1796
|
+
const existing = commentsMap.get(markerId);
|
|
1797
|
+
const text = el.textContent || '';
|
|
1798
|
+
if (existing) {
|
|
1799
|
+
existing.selectedText += text;
|
|
1800
|
+
}
|
|
1801
|
+
else {
|
|
1802
|
+
commentsMap.set(markerId, {
|
|
1803
|
+
markerId,
|
|
1804
|
+
selectedText: text,
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1061
1807
|
}
|
|
1062
|
-
}
|
|
1063
|
-
return
|
|
1808
|
+
});
|
|
1809
|
+
return Array.from(commentsMap.values());
|
|
1064
1810
|
},
|
|
1065
1811
|
/**
|
|
1066
1812
|
* Thêm comment vào vùng text đang được chọn
|
|
@@ -1068,7 +1814,11 @@ class SdDocumentBuilder {
|
|
|
1068
1814
|
* @param data - Dữ liệu extra data
|
|
1069
1815
|
* @returns SdDocumentBuilderComment hoặc null nếu không có text được chọn
|
|
1070
1816
|
*/
|
|
1071
|
-
add: (range, comment,
|
|
1817
|
+
add: (range, comment, args) => {
|
|
1818
|
+
// markerIdExternal khi truyền từ bên ngoài vào sẽ có thể là Date.now() hoặc uuidv4().
|
|
1819
|
+
// Miễn là đảm bảo markerIdExternal là unique.
|
|
1820
|
+
// Phục vụ cho case gọi API comment thành công thì mới sinh ra markerId.
|
|
1821
|
+
const { markerIdExternal, data } = args ?? {};
|
|
1072
1822
|
if (!this.#editor)
|
|
1073
1823
|
return null;
|
|
1074
1824
|
const model = this.#editor.model;
|
|
@@ -1081,7 +1831,7 @@ class SdDocumentBuilder {
|
|
|
1081
1831
|
return null;
|
|
1082
1832
|
}
|
|
1083
1833
|
// 4. Tạo ID unique cho marker
|
|
1084
|
-
const markerId = `comment:${Date.now()}`;
|
|
1834
|
+
const markerId = markerIdExternal ? `comment:${markerIdExternal}` : `comment:${Date.now()}`;
|
|
1085
1835
|
// 5. Tạo marker trong model
|
|
1086
1836
|
model.change(writer => {
|
|
1087
1837
|
writer.addMarker(markerId, {
|
|
@@ -1182,72 +1932,78 @@ class SdDocumentBuilder {
|
|
|
1182
1932
|
}
|
|
1183
1933
|
},
|
|
1184
1934
|
};
|
|
1185
|
-
//
|
|
1186
|
-
//
|
|
1187
|
-
//
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1935
|
+
// ========================================================================
|
|
1936
|
+
// 3. QUẢN LÝ VARIABLE
|
|
1937
|
+
// ========================================================================
|
|
1938
|
+
variable = {
|
|
1939
|
+
/**
|
|
1940
|
+
* Lấy tất cả variabes trong document
|
|
1941
|
+
* @returns Danh sách tất cả variables
|
|
1942
|
+
*/
|
|
1943
|
+
all: () => {
|
|
1944
|
+
if (!this.#editor)
|
|
1945
|
+
return [];
|
|
1946
|
+
const model = this.#editor.model;
|
|
1947
|
+
const root = model.document.getRoot();
|
|
1948
|
+
if (!root)
|
|
1949
|
+
return [];
|
|
1950
|
+
const variables = [];
|
|
1951
|
+
try {
|
|
1952
|
+
const range = model.createRangeIn(root);
|
|
1953
|
+
for (const item of range.getItems()) {
|
|
1954
|
+
// Sử dụng item.is('element', 'variable') là chính xác
|
|
1955
|
+
if (item.is('element', 'variable')) {
|
|
1956
|
+
variables.push({
|
|
1957
|
+
id: item.getAttribute('id'),
|
|
1958
|
+
uuid: item.getAttribute('uuid'),
|
|
1959
|
+
value: item.getAttribute('value'),
|
|
1960
|
+
display: item.getAttribute('display'),
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
catch (e) {
|
|
1966
|
+
console.error(e);
|
|
1967
|
+
return [];
|
|
1968
|
+
}
|
|
1969
|
+
return variables;
|
|
1970
|
+
},
|
|
1971
|
+
/**
|
|
1972
|
+
* Scroll tới vị trí của variable
|
|
1973
|
+
* @param uuid - uuid của variable FE sẽ tự sinh sau mỗi lần drop vào editor
|
|
1974
|
+
*/
|
|
1975
|
+
scroll: (uuid) => {
|
|
1976
|
+
if (!this.#editor)
|
|
1977
|
+
return;
|
|
1978
|
+
const model = this.#editor.model;
|
|
1979
|
+
const root = model.document.getRoot();
|
|
1980
|
+
if (!root)
|
|
1981
|
+
return;
|
|
1982
|
+
let targetElement = null;
|
|
1983
|
+
const range = model.createRangeIn(root);
|
|
1984
|
+
for (const item of range.getItems()) {
|
|
1985
|
+
if (item.is('element', 'variable') && item.getAttribute('uuid') === uuid) {
|
|
1986
|
+
targetElement = item;
|
|
1987
|
+
break;
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
if (targetElement) {
|
|
1991
|
+
const viewElement = this.#editor.editing.mapper.toViewElement(targetElement);
|
|
1992
|
+
if (viewElement) {
|
|
1993
|
+
const domElement = this.#editor.editing.view.domConverter.viewToDom(viewElement);
|
|
1994
|
+
if (domElement) {
|
|
1995
|
+
domElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1996
|
+
model.change(writer => {
|
|
1997
|
+
writer.setSelection(targetElement, 'on');
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
else {
|
|
2003
|
+
console.warn(`Variable với id ${uuid} không tìm thấy trong tài liệu.`);
|
|
2004
|
+
}
|
|
2005
|
+
},
|
|
2006
|
+
};
|
|
1251
2007
|
// ========================================================================
|
|
1252
2008
|
// 4. HÀM EXPORT DOCX (FULL HEADER/FOOTER + PAGE NUMBER)
|
|
1253
2009
|
// ========================================================================
|
|
@@ -1352,22 +2108,38 @@ class SdDocumentBuilder {
|
|
|
1352
2108
|
// Thêm '\ufeff' (BOM) để fix lỗi font tiếng Việt
|
|
1353
2109
|
const blob = new Blob(['\ufeff', fullHtml], { type: 'application/msword' });
|
|
1354
2110
|
SdUtilities.downloadBlob(blob, fileName);
|
|
1355
|
-
// 3. Convert & Save
|
|
1356
|
-
// asBlob(fullHtml, {
|
|
1357
|
-
// orientation: orientation as 'portrait' | 'landscape',
|
|
1358
|
-
// margins: { top: 720, right: 720, bottom: 720, left: 720 },
|
|
1359
|
-
// }).then(blob => {
|
|
1360
|
-
// if (blob instanceof Blob) {
|
|
1361
|
-
// SdUtilities.downloadBlob(blob, fileName);
|
|
1362
|
-
// }
|
|
1363
|
-
// });
|
|
1364
2111
|
}
|
|
2112
|
+
hightSelectRange = (range) => {
|
|
2113
|
+
if (!range)
|
|
2114
|
+
return;
|
|
2115
|
+
const editor = this.#editor;
|
|
2116
|
+
editor.model.change(writer => {
|
|
2117
|
+
// Xóa marker cũ (nếu có)
|
|
2118
|
+
if (editor.model.markers.has('highlightRange')) {
|
|
2119
|
+
writer.removeMarker('highlightRange');
|
|
2120
|
+
}
|
|
2121
|
+
// Tạo marker mới
|
|
2122
|
+
writer.addMarker('highlightRange', {
|
|
2123
|
+
usingOperation: false, // Không lưu vào lịch sử Undo/Redo
|
|
2124
|
+
affectsData: false, // Không ảnh hưởng đến data lấy ra (getData)
|
|
2125
|
+
range: range,
|
|
2126
|
+
});
|
|
2127
|
+
});
|
|
2128
|
+
};
|
|
2129
|
+
removeHighlightSeclectRange = () => {
|
|
2130
|
+
const editor = this.#editor;
|
|
2131
|
+
editor.model.change(writer => {
|
|
2132
|
+
if (editor.model.markers.has('highlightRange')) {
|
|
2133
|
+
writer.removeMarker('highlightRange');
|
|
2134
|
+
}
|
|
2135
|
+
});
|
|
2136
|
+
};
|
|
1365
2137
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: SdDocumentBuilder, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1366
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.17", type: SdDocumentBuilder, isStandalone: true, selector: "sd-document-builder", inputs: { option: "option", _disabled: ["disabled", "_disabled"] }, outputs: { contentChange: "contentChange" }, ngImport: i0, template: "<div class=\"builder-container\">\n <ckeditor\n style=\"width: 100%\"\n [editor]=\"Editor\" \n [config]=\"config\" \n (ready)=\"onReady($event)\"\n [disabled]=\"disabled\">\n </ckeditor>\n</div>", styles: ["@charset \"UTF-8\";.builder-container{background-color:#f3f4f6;height:100%;overflow-y:auto;width:100%;display:flex;flex-direction:column;align-items:center
|
|
2138
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.17", type: SdDocumentBuilder, isStandalone: true, selector: "sd-document-builder", inputs: { option: "option", _disabled: ["disabled", "_disabled"] }, outputs: { contentChange: "contentChange" }, ngImport: i0, template: "<div class=\"builder-container\">\n <ckeditor\n style=\"width: 100%\"\n [editor]=\"Editor\" \n [config]=\"config\" \n (ready)=\"onReady($event)\"\n [disabled]=\"disabled\">\n </ckeditor>\n</div>", styles: ["@charset \"UTF-8\";.builder-container{background-color:#f3f4f6;height:100%;overflow-y:auto;width:100%;display:flex;flex-direction:column;align-items:center}:host{display:inline-block}:host ::ng-deep .ck-editor{display:flex;flex-direction:column;align-items:center;width:100%;--ck-content-font-family: \"Times New Roman\", serif !important;--ck-content-font-size: 13pt;--ck-content-line-height: 1.5}:host ::ng-deep .ck-editor__top,:host ::ng-deep .ck-editor__main{border:none!important;box-shadow:none!important}:host ::ng-deep .ck-editor__top{position:sticky;top:0;z-index:100;width:100%;min-width:600px;margin-bottom:10px}:host ::ng-deep .ck-editor__top .ck-sticky-panel__content{border:none!important}:host ::ng-deep .ck-editor__top .ck-toolbar{background:#fff!important;box-shadow:0 4px 6px -1px #0000001a!important;padding:8px!important}:host ::ng-deep .ck-editor__top .ck-toolbar .ck-toolbar__items{display:flex;justify-content:center;flex-wrap:wrap;align-items:center}:host ::ng-deep .ck-content{background-color:#fff;width:210mm;min-height:1123px;padding:20mm!important;box-sizing:border-box!important;box-shadow:0 10px 15px -3px #0000001a}:host ::ng-deep .ck-content h1,:host ::ng-deep .ck-content h2,:host ::ng-deep .ck-content h3,:host ::ng-deep .ck-content h4,:host ::ng-deep .ck-content h5,:host ::ng-deep .ck-content h6{font-weight:400}:host ::ng-deep .ck-content.landscape{width:297mm}:host ::ng-deep .ck-content>*{max-width:100%!important;box-sizing:border-box!important}: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{padding-left:20px!important;margin-left:0!important}\n", ":host ::ng-deep .ck-heading-highlight{background-color:#fef08a}\n", ":host ::ng-deep .ck-comment-marker{background-color:#ffeb3b80;border-bottom:2px solid #fbc02d;transition:background-color .2s;cursor:pointer}:host ::ng-deep .ck-comment-marker:hover{background-color:#ffeb3bcc}:host ::ng-deep .ck-comment-marker.active-highlight{background-color:#ffeb3b;outline:2px dashed #f57f17}\n", "@charset \"UTF-8\";::ng-deep .ck-clipboard-drop-target-line{display:none!important}:host ::ng-deep .variable-widget{background-color:#e3f2fd;color:#1976d2;border:1px solid #90caf9!important;border-radius:4px;padding:2px 6px;font-weight:600;font-family:Segoe UI,Tahoma,Geneva,Verdana,sans-serif;font-size:10px;-webkit-user-select:none;user-select:none;display:inline-block;margin:0 4px;vertical-align:middle;font-size:0;cursor:default}:host ::ng-deep .variable-widget:before{content:attr(data-display);font-size:10px}:host ::ng-deep .variable-widget:hover{background-color:#bbdefb;box-shadow:0 1px 2px #0000001a}:host ::ng-deep .variable-widget.ck-widget_selected{outline:2px solid #2196f3;background-color:#bbdefb}:host ::ng-deep .ck.ck-content .ck-widget,:host ::ng-deep .ck.ck-content .ck-widget:hover,:host ::ng-deep .ck.ck-content .ck-widget:focus,:host ::ng-deep .ck.ck-content .ck-widget.ck-widget_selected,:host ::ng-deep .ck.ck-content .ck-widget.ck-widget_selected:hover{outline:none!important;box-shadow:none!important}\n", "", ":host ::ng-deep .highlight-range{background-color:#ffeb3b80;border-bottom:2px solid #fbc02d;transition:background-color .2s;cursor:pointer}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { 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"] }] });
|
|
1367
2139
|
}
|
|
1368
2140
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: SdDocumentBuilder, decorators: [{
|
|
1369
2141
|
type: Component,
|
|
1370
|
-
args: [{ selector: 'sd-document-builder', standalone: true, imports: [CommonModule, CKEditorModule], template: "<div class=\"builder-container\">\n <ckeditor\n style=\"width: 100%\"\n [editor]=\"Editor\" \n [config]=\"config\" \n (ready)=\"onReady($event)\"\n [disabled]=\"disabled\">\n </ckeditor>\n</div>", styles: ["@charset \"UTF-8\";.builder-container{background-color:#f3f4f6;height:100%;overflow-y:auto;width:100%;display:flex;flex-direction:column;align-items:center
|
|
2142
|
+
args: [{ selector: 'sd-document-builder', standalone: true, imports: [CommonModule, CKEditorModule], template: "<div class=\"builder-container\">\n <ckeditor\n style=\"width: 100%\"\n [editor]=\"Editor\" \n [config]=\"config\" \n (ready)=\"onReady($event)\"\n [disabled]=\"disabled\">\n </ckeditor>\n</div>", styles: ["@charset \"UTF-8\";.builder-container{background-color:#f3f4f6;height:100%;overflow-y:auto;width:100%;display:flex;flex-direction:column;align-items:center}:host{display:inline-block}:host ::ng-deep .ck-editor{display:flex;flex-direction:column;align-items:center;width:100%;--ck-content-font-family: \"Times New Roman\", serif !important;--ck-content-font-size: 13pt;--ck-content-line-height: 1.5}:host ::ng-deep .ck-editor__top,:host ::ng-deep .ck-editor__main{border:none!important;box-shadow:none!important}:host ::ng-deep .ck-editor__top{position:sticky;top:0;z-index:100;width:100%;min-width:600px;margin-bottom:10px}:host ::ng-deep .ck-editor__top .ck-sticky-panel__content{border:none!important}:host ::ng-deep .ck-editor__top .ck-toolbar{background:#fff!important;box-shadow:0 4px 6px -1px #0000001a!important;padding:8px!important}:host ::ng-deep .ck-editor__top .ck-toolbar .ck-toolbar__items{display:flex;justify-content:center;flex-wrap:wrap;align-items:center}:host ::ng-deep .ck-content{background-color:#fff;width:210mm;min-height:1123px;padding:20mm!important;box-sizing:border-box!important;box-shadow:0 10px 15px -3px #0000001a}:host ::ng-deep .ck-content h1,:host ::ng-deep .ck-content h2,:host ::ng-deep .ck-content h3,:host ::ng-deep .ck-content h4,:host ::ng-deep .ck-content h5,:host ::ng-deep .ck-content h6{font-weight:400}:host ::ng-deep .ck-content.landscape{width:297mm}:host ::ng-deep .ck-content>*{max-width:100%!important;box-sizing:border-box!important}: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{padding-left:20px!important;margin-left:0!important}\n", ":host ::ng-deep .ck-heading-highlight{background-color:#fef08a}\n", ":host ::ng-deep .ck-comment-marker{background-color:#ffeb3b80;border-bottom:2px solid #fbc02d;transition:background-color .2s;cursor:pointer}:host ::ng-deep .ck-comment-marker:hover{background-color:#ffeb3bcc}:host ::ng-deep .ck-comment-marker.active-highlight{background-color:#ffeb3b;outline:2px dashed #f57f17}\n", "@charset \"UTF-8\";::ng-deep .ck-clipboard-drop-target-line{display:none!important}:host ::ng-deep .variable-widget{background-color:#e3f2fd;color:#1976d2;border:1px solid #90caf9!important;border-radius:4px;padding:2px 6px;font-weight:600;font-family:Segoe UI,Tahoma,Geneva,Verdana,sans-serif;font-size:10px;-webkit-user-select:none;user-select:none;display:inline-block;margin:0 4px;vertical-align:middle;font-size:0;cursor:default}:host ::ng-deep .variable-widget:before{content:attr(data-display);font-size:10px}:host ::ng-deep .variable-widget:hover{background-color:#bbdefb;box-shadow:0 1px 2px #0000001a}:host ::ng-deep .variable-widget.ck-widget_selected{outline:2px solid #2196f3;background-color:#bbdefb}:host ::ng-deep .ck.ck-content .ck-widget,:host ::ng-deep .ck.ck-content .ck-widget:hover,:host ::ng-deep .ck.ck-content .ck-widget:focus,:host ::ng-deep .ck.ck-content .ck-widget.ck-widget_selected,:host ::ng-deep .ck.ck-content .ck-widget.ck-widget_selected:hover{outline:none!important;box-shadow:none!important}\n", ":host ::ng-deep .highlight-range{background-color:#ffeb3b80;border-bottom:2px solid #fbc02d;transition:background-color .2s;cursor:pointer}\n"] }]
|
|
1371
2143
|
}], propDecorators: { option: [{
|
|
1372
2144
|
type: Input,
|
|
1373
2145
|
args: [{ required: true }]
|