@sd-angular/core 19.0.0-beta.2 → 19.0.0-beta.21

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.
Files changed (117) hide show
  1. package/assets/scss/ckeditor5.scss +59 -2
  2. package/components/avatar/index.d.ts +1 -0
  3. package/components/avatar/src/avatar.component.d.ts +14 -0
  4. package/components/document-builder/src/document-builder.component.d.ts +29 -7
  5. package/components/document-builder/src/document-builder.config.d.ts +21 -0
  6. package/components/document-builder/src/document-builder.model.d.ts +6 -1
  7. package/components/document-builder/src/document-builder.utils.d.ts +10 -0
  8. package/components/document-builder/src/plugins/{table-fit.plugin.d.ts → heading/heading.plugin.d.ts} +1 -1
  9. package/components/document-builder/src/plugins/highlight-range/highlight-range.plugin.d.ts +4 -0
  10. package/components/document-builder/src/plugins/image-custom/image-custom.plugin.d.ts +31 -0
  11. package/components/document-builder/src/plugins/{image-upload.plugin.d.ts → image-upload/image-upload.plugin.d.ts} +0 -4
  12. package/components/document-builder/src/plugins/index.d.ts +7 -5
  13. package/components/document-builder/src/plugins/table-fit/table-fit.plugin.d.ts +29 -0
  14. package/components/index.d.ts +2 -0
  15. package/components/mini-editor/index.d.ts +2 -0
  16. package/components/mini-editor/src/mini-editor.component.d.ts +90 -0
  17. package/components/mini-editor/src/mini-editor.model.d.ts +42 -0
  18. package/components/table/src/directives/index.d.ts +2 -0
  19. package/components/table/src/directives/sd-table-column-filter-def.directive.d.ts +9 -0
  20. package/components/table/src/directives/sticky-shadow.directive.d.ts +17 -0
  21. package/components/table/src/models/table-column.model.d.ts +7 -7
  22. package/components/table/src/models/table-command.model.d.ts +4 -0
  23. package/components/table/src/models/table-item.model.d.ts +2 -1
  24. package/components/table/src/models/table-option-export.model.d.ts +3 -2
  25. package/components/table/src/models/table-option.model.d.ts +10 -8
  26. package/components/table/src/services/table-filter/table-filter.model.d.ts +2 -2
  27. package/components/view/index.d.ts +1 -0
  28. package/components/view/src/view.component.d.ts +14 -0
  29. package/components/workflow/src/models/index.d.ts +1 -0
  30. package/directives/index.d.ts +1 -0
  31. package/directives/src/sd-href.directive.d.ts +9 -0
  32. package/fesm2022/sd-angular-core-components-avatar.mjs +88 -0
  33. package/fesm2022/sd-angular-core-components-avatar.mjs.map +1 -0
  34. package/fesm2022/sd-angular-core-components-badge.mjs +2 -2
  35. package/fesm2022/sd-angular-core-components-badge.mjs.map +1 -1
  36. package/fesm2022/sd-angular-core-components-document-builder.mjs +1329 -557
  37. package/fesm2022/sd-angular-core-components-document-builder.mjs.map +1 -1
  38. package/fesm2022/sd-angular-core-components-mini-editor.mjs +326 -0
  39. package/fesm2022/sd-angular-core-components-mini-editor.mjs.map +1 -0
  40. package/fesm2022/sd-angular-core-components-table.mjs +510 -84
  41. package/fesm2022/sd-angular-core-components-table.mjs.map +1 -1
  42. package/fesm2022/sd-angular-core-components-view.mjs +88 -0
  43. package/fesm2022/sd-angular-core-components-view.mjs.map +1 -0
  44. package/fesm2022/sd-angular-core-components-workflow.mjs +33 -43
  45. package/fesm2022/sd-angular-core-components-workflow.mjs.map +1 -1
  46. package/fesm2022/sd-angular-core-components.mjs +2 -0
  47. package/fesm2022/sd-angular-core-components.mjs.map +1 -1
  48. package/fesm2022/sd-angular-core-directives.mjs +51 -2
  49. package/fesm2022/sd-angular-core-directives.mjs.map +1 -1
  50. package/fesm2022/sd-angular-core-forms-autocomplete.mjs +24 -2
  51. package/fesm2022/sd-angular-core-forms-autocomplete.mjs.map +1 -1
  52. package/fesm2022/sd-angular-core-forms-date.mjs +15 -3
  53. package/fesm2022/sd-angular-core-forms-date.mjs.map +1 -1
  54. package/fesm2022/sd-angular-core-forms-datetime.mjs +17 -3
  55. package/fesm2022/sd-angular-core-forms-datetime.mjs.map +1 -1
  56. package/fesm2022/sd-angular-core-forms-input-number.mjs +25 -3
  57. package/fesm2022/sd-angular-core-forms-input-number.mjs.map +1 -1
  58. package/fesm2022/sd-angular-core-forms-input.mjs +20 -6
  59. package/fesm2022/sd-angular-core-forms-input.mjs.map +1 -1
  60. package/fesm2022/sd-angular-core-forms-radio.mjs +18 -2
  61. package/fesm2022/sd-angular-core-forms-radio.mjs.map +1 -1
  62. package/fesm2022/sd-angular-core-forms-select.mjs +19 -3
  63. package/fesm2022/sd-angular-core-forms-select.mjs.map +1 -1
  64. package/fesm2022/sd-angular-core-forms-textarea.mjs +21 -2
  65. package/fesm2022/sd-angular-core-forms-textarea.mjs.map +1 -1
  66. package/fesm2022/sd-angular-core-modules-auth.mjs +137 -0
  67. package/fesm2022/sd-angular-core-modules-auth.mjs.map +1 -0
  68. package/fesm2022/sd-angular-core-modules-layout.mjs +52 -17
  69. package/fesm2022/sd-angular-core-modules-layout.mjs.map +1 -1
  70. package/fesm2022/sd-angular-core-modules-oidc.mjs +0 -2
  71. package/fesm2022/sd-angular-core-modules-oidc.mjs.map +1 -1
  72. package/fesm2022/sd-angular-core-modules.mjs +1 -0
  73. package/fesm2022/sd-angular-core-modules.mjs.map +1 -1
  74. package/fesm2022/sd-angular-core-pipes.mjs +21 -1
  75. package/fesm2022/sd-angular-core-pipes.mjs.map +1 -1
  76. package/fesm2022/sd-angular-core-services-confirm.mjs +6 -9
  77. package/fesm2022/sd-angular-core-services-confirm.mjs.map +1 -1
  78. package/fesm2022/sd-angular-core-utilities-extensions.mjs +66 -1
  79. package/fesm2022/sd-angular-core-utilities-extensions.mjs.map +1 -1
  80. package/fesm2022/sd-angular-core-utilities-models.mjs +12 -3
  81. package/fesm2022/sd-angular-core-utilities-models.mjs.map +1 -1
  82. package/forms/autocomplete/src/autocomplete.component.d.ts +5 -1
  83. package/forms/date/src/date.component.d.ts +4 -1
  84. package/forms/datetime/src/datetime.component.d.ts +4 -1
  85. package/forms/input/src/input.component.d.ts +6 -4
  86. package/forms/input-number/src/input-number.component.d.ts +4 -1
  87. package/forms/radio/src/radio.component.d.ts +5 -1
  88. package/forms/select/src/select.component.d.ts +5 -1
  89. package/forms/textarea/src/textarea.component.d.ts +3 -1
  90. package/modules/auth/configurations/auth.configuration.d.ts +19 -0
  91. package/modules/auth/configurations/index.d.ts +1 -0
  92. package/modules/auth/guards/auth.guard.d.ts +11 -0
  93. package/modules/auth/guards/index.d.ts +2 -0
  94. package/modules/auth/guards/portal.guard.d.ts +11 -0
  95. package/modules/auth/index.d.ts +3 -0
  96. package/modules/auth/services/auth.model.d.ts +8 -0
  97. package/modules/auth/services/auth.service.d.ts +17 -0
  98. package/modules/auth/services/index.d.ts +2 -0
  99. package/modules/index.d.ts +1 -0
  100. package/modules/layout/components/sidebar-v1/components/sidebar/sidebar.component.d.ts +1 -0
  101. package/modules/layout/components/sidebar-v1/components/user/user.component.d.ts +5 -2
  102. package/modules/layout/configurations/layout.configuration.d.ts +3 -0
  103. package/modules/layout/services/storage/storage.service.d.ts +1 -0
  104. package/package.json +80 -64
  105. package/pipes/index.d.ts +1 -0
  106. package/pipes/src/empty.pipe.d.ts +7 -0
  107. package/sd-angular-core-19.0.0-beta.21.tgz +0 -0
  108. package/services/confirm/src/lib/confirm.service.d.ts +1 -0
  109. package/utilities/extensions/index.d.ts +1 -0
  110. package/utilities/extensions/src/color.extension.d.ts +20 -0
  111. package/utilities/models/index.d.ts +1 -0
  112. package/utilities/models/src/maybe-async.model.d.ts +1 -0
  113. package/utilities/models/src/nested-key-of.model.d.ts +5 -0
  114. package/utilities/models/src/pattern.model.d.ts +2 -2
  115. /package/components/document-builder/src/plugins/{comment.plugin.d.ts → comment/comment.plugin.d.ts} +0 -0
  116. /package/components/document-builder/src/plugins/{page-orientation.plugin.d.ts → page-orientation/page-orientation.plugin.d.ts} +0 -0
  117. /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
- // Icon khổ dọc (Mặc định cũ)
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
- const componentFactory = editor.ui.componentFactory;
145
- // Đăng ký nút tên là 'pageOrientation'
146
- componentFactory.add('pageOrientation', locale => {
147
- const view = new ButtonView(locale);
148
- this.buttonView = view;
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
- // --- 1. CONVERSION: MODEL MARKER -> VIEW CSS ---
210
- // Biến Marker thành Highlight màu vàng
211
- editor.conversion.for('editingDowncast').markerToHighlight({
212
- model: 'comment', // Khớp với prefix của markerId (ví dụ: comment:12345)
213
- view: {
214
- classes: 'ck-comment-marker', // Class CSS sẽ được gắn vào thẻ <span> bao quanh text
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
- // --- 3. ĐĂNG KÝ UI COMPONENT: 'addCommentBtn' ---
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
- // ẨN BUTTON NẾU KHÔNG onAddComment
167
+ // Ẩn button nếu không 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, // Mặc định disable
179
+ isEnabled: false,
239
180
  });
240
- // 4. Logic Enable/Disable: Dựa theo Selection
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
- // 5. Logic Execute: Khi bấm nút
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
- // BẮN EVENT RA NGOÀI - KHÔNG TỰ ADD MARKER
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
- allowAttributes: ['id', 'value', 'display', 'data'],
251
+ allowWhere: '$text',
252
+ isInline: true,
253
+ isObject: true,
254
+ allowAttributes: ['id', 'uuid', 'value', 'display'],
283
255
  });
284
- // 2. Conversion: Model -> View (Hiển thị ra HTML)
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. Conversion: View -> Model (Khi load data hoặc paste)
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
- // Ngăn trình duyệt xử mặc định
308
+ // data.dropRange 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
- let variable = JSON.parse(jsonData);
344
- const config = editor.config;
345
- const getOption = config.get('getOption');
346
- const option = getOption?.();
347
- if (option?.onDropVariable) {
348
- const result = await SdResolveMaybeAsync(option.onDropVariable(variable, 0));
349
- //* Hỗ trợ dữ liệu có sẵn sẽ chỉ cần nhận vào boolean
350
- if (typeof result === 'boolean') {
351
- if (!result)
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
- //* Hỗ trợ dữ liệu lấy từ API (Kiểm tra xem result có đúng định dạng interface SdDocumentBuilderVariable hay không?)
356
- if (this.#isSdDocumentBuilderVariableResult(result)) {
357
- variable = result;
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
- else {
360
- throw new Error('Dữ liệu variable không hợp lệ');
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
- // Xác định vị trí thả chính xác (Quan trọng)
365
- // data.dropRange là vị trí con chuột trên View khi thả
366
- const viewRange = data.dropRange;
367
- const modelRange = editor.editing.mapper.toModelRange(viewRange);
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
- // A. Chèn biến
370
- const variableElem = writer.createElement('variable', {
371
- id: variable.id,
372
- value: variable.value,
373
- display: variable.display,
374
- data: variable.data,
375
- });
376
- editor.model.insertContent(variableElem, modelRange);
377
- // B. Đặt con trỏ ra sau biến
378
- writer.setSelection(variableElem, 'after');
379
- // C. [QUAN TRỌNG] XÓA SẠCH CÁC MARKER DROP
380
- // Thay vì chỉ xóa 'drop-target', ta duyệt tìm tất cả marker có tên bắt đầu bằng 'drop-target'
381
- // Vì đôi khi CKEditor tạo ra các biến thể khác nhau
382
- for (const marker of editor.model.markers) {
383
- if (marker.name.startsWith('drop-target')) {
384
- writer.removeMarker(marker);
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, conversionApi) => {
444
- // 1. Gọi consume để báo với CKEditor là chúng ta sẽ xử lý element này
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
- writer.setAttribute('tableWidth', '100%', item);
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
- // Lắng nghe lệnh insertTable để can thiệp ngay sau khi bảng được tạo
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
- // Dùng 'on' event để hook vào sau khi lệnh thực thi
468
- this.listenTo(insertTableCommand, 'execute', (evt, args) => {
638
+ this.listenTo(insertTableCommand, 'execute', () => {
469
639
  editor.model.change(writer => {
470
- // Lấy vị trí con trỏ hiện tại (nơi bảng vừa được chèn)
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
- // Ép width 100% cho bảng mới vẽ
480
- writer.setAttribute('tableWidth', '100%', tableElement);
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
- PageOrientationPlugin,
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
- toolbar: ['toggleImageCaption', '|', 'imageStyle:inline', 'imageStyle:block', 'imageStyle:side'],
626
- },
627
- fontSize: {
628
- options: [
629
- // Định nghĩa từng size một cách tường minh
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
- title: '24',
713
- model: '24pt',
714
- view: {
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
- 'tableColumn',
735
- 'tableRow',
736
- 'mergeTableCells',
737
- '|',
738
- 'tableProperties', // <--- Nút chỉnh thuộc tính bảng (Viền, Màu, Width)
739
- 'tableCellProperties',
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
- // https://onemount.atlassian.net/browse/SM-1862
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
- // Đăng ký sự kiện lắng nghe Selection để làm Comment
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
- // ĐĂNG SỰ KIỆN LẮNG NGHE THAY ĐỔI NỘI DUNG
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
- this.#updateState();
799
- }
800
- #updateState() {
801
- if (!this.#editor)
802
- return;
803
- if (this.disabled) {
804
- // Bật chế độ chỉ đọc với ID khóa
805
- this.#editor.enableReadOnlyMode(this.#id);
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
- // 2. Sau đó gọi scrollToTheSelection
823
- this.#editor.editing.view.scrollToTheSelection({
824
- alignToTop: true,
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
- else {
829
- console.warn(`Marker with id ${markerId} not found.`);
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
- // Kho lưu trữ tham chiếu Model của Heading (để phục vụ scroll)
937
- #headingElementsMap = new Map();
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
- // HÀM LẤY DANH SÁCH HEADING (TOC)
941
- // ========================================================================
942
- getHeadings: () => {
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
- // HÀM SCROLL TO HEADING
980
- // ========================================================================
981
- scrollToHeading: (id) => {
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
- const editor = this.#editor;
988
- const view = editor.editing.view;
989
- // 2. Chuyển đổi Model Element -> View Element
990
- // mapper.toViewElement sẽ trả về thẻ HTML ảo (View Element) tương ứng (ví dụ thẻ <h2>)
991
- const viewElement = editor.editing.mapper.toViewElement(modelElement);
1736
+ this.#editor.model.change(writer => {
1737
+ // Xóa marker
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
- // 3. Từ View Element ảo -> Lấy ra DOM thật (HTMLElement)
994
- const domElement = view.domConverter.mapViewToDom(viewElement);
1756
+ const domElement = this.#editor.editing.view.domConverter.viewToDom(viewElement);
995
1757
  if (domElement) {
996
- // 4. Dùng hàm native của trình duyệt để cuộn
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
- // HELPER: LẤY TEXT TỪ ELEMENT (Đệ quy nhẹ)
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 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 markers = this.#editor.model.markers;
1050
- const comments = [];
1051
- // Duyệt qua tất cả markers trong Model
1052
- for (const marker of markers) {
1053
- // Chỉ lấy marker do plugin comment tạo ra (prefix 'comment:')
1054
- if (marker.name.startsWith('comment:')) {
1055
- // Lấy text nằm trong vùng marker đó
1056
- const currentText = this.#getTextFromRange(marker.getRange());
1057
- comments.push({
1058
- markerId: marker.name,
1059
- selectedText: currentText,
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 comments;
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, data) => {
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
- // exportDocx(fileName: string = 'document.docx'): void {
1186
- // if (!this.#editor) return;
1187
- // // 1. Kiểm tra xem Editor đang ở chế độ Landscape hay Portrait
1188
- // // (Dựa vào class 'landscape' mà Plugin PageOrientation đã toggle trên View)
1189
- // const rootElement = this.#editor.editing.view.document.getRoot();
1190
- // const isLandscape = rootElement?.hasClass('landscape');
1191
- // const orientation = isLandscape ? 'landscape' : 'portrait';
1192
- // // 2. Lấy nội dung HTML
1193
- // const contentHtml = this.#editor.getData();
1194
- // // 3. Chuẩn bị HTML với CSS @page động
1195
- // const fullHtml = `
1196
- // <!DOCTYPE html>
1197
- // <html>
1198
- // <head>
1199
- // <meta charset="UTF-8">
1200
- // <style>
1201
- // /* --- CẤU HÌNH KHỔ GIẤY DỰA TRÊN TRẠNG THÁI --- */
1202
- // @page {
1203
- // size: A4 ${orientation}; /* Thêm portrait hoặc landscape vào đây */
1204
- // margin: 20mm;
1205
- // }
1206
- // body {
1207
- // font-family: 'Times New Roman', serif;
1208
- // font-size: 13pt;
1209
- // line-height: 1.5;
1210
- // }
1211
- // /* --- STYLE CHO BẢNG BIỂU --- */
1212
- // table {
1213
- // width: 100%;
1214
- // border-collapse: collapse;
1215
- // }
1216
- // td, th {
1217
- // border: 1px solid black;
1218
- // padding: 5px;
1219
- // }
1220
- // /* --- STYLE CHO BIẾN --- */
1221
- // .variable-widget {
1222
- // color: #1565c0;
1223
- // background-color: #e3f2fd;
1224
- // font-weight: bold;
1225
- // border: 1px solid #90caf9;
1226
- // padding: 0 4px;
1227
- // border-radius: 4px;
1228
- // }
1229
- // /* --- ẨN COMMENT KHI IN --- */
1230
- // .ck-comment-marker {
1231
- // background-color: transparent;
1232
- // border: none;
1233
- // }
1234
- // </style>
1235
- // </head>
1236
- // <body>
1237
- // ${contentHtml}
1238
- // </body>
1239
- // </html>
1240
- // `;
1241
- // // 3. Convert sang Blob (Dạng file Binary)
1242
- // asBlob(fullHtml, {
1243
- // orientation: 'portrait', // 'portrait' hoặc 'landscape'
1244
- // margins: { top: 720, right: 720, bottom: 720, left: 720 }, // Đơn vị twips (1440 twips = 1 inch)
1245
- // }).then(blob => {
1246
- // if (blob instanceof Blob) {
1247
- // SdUtilities.downloadBlob(blob, fileName);
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;padding-bottom:20px}:host{display:inline-block}:host ::ng-deep .ck-editor{display:flex;flex-direction:column;align-items:center;width:100%}:host ::ng-deep .ck-editor .ck-editor__top,:host ::ng-deep .ck-editor .ck-editor__main{border:none!important;box-shadow:none!important}: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.ck-focused{outline:none!important;border-color:#d1d5db!important}: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 img{max-width:100%!important;height:auto!important;object-fit:contain}:host ::ng-deep .ck-content p{margin-left:0!important;margin-right:0!important;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", "@charset \"UTF-8\";:host ::ng-deep .ck-comment-marker{background-color:#ffeb3b80;border-bottom:2px solid #fbc02d;cursor:pointer;transition:background-color .2s}: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\";:host ::ng-deep .variable-widget{background-color:#e3f2fd;color:#1976d2;border:1px solid #90caf9;border-radius:4px;padding:2px 6px;font-weight:600;font-family:Segoe UI,Tahoma,Geneva,Verdana,sans-serif;font-size:10px;cursor:default;-webkit-user-select:none;user-select:none;display:inline-block;margin:0 2px;vertical-align:middle;font-size:0}: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-clipboard-drop-target-line{display:none!important}\n", "@charset \"UTF-8\";:host ::ng-deep .ck-editor__editable .ck-widget.table{float:none!important;display:block!important;max-width:100%!important;width:100%!important;margin:0!important;clear:both}:host ::ng-deep .ck-editor__editable table{table-layout:auto!important;width:100%!important;border-collapse:collapse;margin:0!important}:host ::ng-deep .ck-editor__editable table td,:host ::ng-deep .ck-editor__editable table th{word-wrap:break-word;white-space:normal!important;padding:.4em!important}:host ::ng-deep .ck-editor__editable table td img,:host ::ng-deep .ck-editor__editable table th img{max-width:100%;height:auto}\n", "@charset \"UTF-8\";::ng-deep .ck-editor{--ck-font-size-base: 11px !important;--ck-icon-size: 16px !important;--ck-content-font-family: \"Times New Roman\", serif !important;--ck-content-font-size: 13pt;--ck-content-line-height: 1.5;--ck-spacing-small: 2px !important;--ck-spacing-standard: 4px !important;--ck-spacing-large: 8px !important}::ng-deep .ck-editor .ck-editor__top{position:sticky;top:0;z-index:100;width:100%;min-width:600px;margin-bottom:10px}::ng-deep .ck-editor .ck-editor__top .ck-sticky-panel__content{border:none!important}::ng-deep .ck-editor .ck-editor__top .ck-toolbar{background:#fff!important;box-shadow:0 4px 6px -1px #0000001a!important;padding:8px!important}::ng-deep .ck-editor .ck-editor__top .ck-toolbar .ck-toolbar__items{display:flex;justify-content:center;flex-wrap:wrap;align-items:center}::ng-deep .ck-editor .ck-toolbar{min-height:32px!important;padding:2px!important}::ng-deep .ck-editor .ck-button{padding:2px 4px!important;min-height:24px!important}::ng-deep .ck-editor .ck-dropdown__button{min-height:24px!important}::ng-deep .ck.ck-toolbar{background:#f8f9fa!important;border-bottom:1px solid #e0e0e0!important}\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"] }] });
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;padding-bottom:20px}:host{display:inline-block}:host ::ng-deep .ck-editor{display:flex;flex-direction:column;align-items:center;width:100%}:host ::ng-deep .ck-editor .ck-editor__top,:host ::ng-deep .ck-editor .ck-editor__main{border:none!important;box-shadow:none!important}: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.ck-focused{outline:none!important;border-color:#d1d5db!important}: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 img{max-width:100%!important;height:auto!important;object-fit:contain}:host ::ng-deep .ck-content p{margin-left:0!important;margin-right:0!important;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", "@charset \"UTF-8\";:host ::ng-deep .ck-comment-marker{background-color:#ffeb3b80;border-bottom:2px solid #fbc02d;cursor:pointer;transition:background-color .2s}: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\";:host ::ng-deep .variable-widget{background-color:#e3f2fd;color:#1976d2;border:1px solid #90caf9;border-radius:4px;padding:2px 6px;font-weight:600;font-family:Segoe UI,Tahoma,Geneva,Verdana,sans-serif;font-size:10px;cursor:default;-webkit-user-select:none;user-select:none;display:inline-block;margin:0 2px;vertical-align:middle;font-size:0}: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-clipboard-drop-target-line{display:none!important}\n", "@charset \"UTF-8\";:host ::ng-deep .ck-editor__editable .ck-widget.table{float:none!important;display:block!important;max-width:100%!important;width:100%!important;margin:0!important;clear:both}:host ::ng-deep .ck-editor__editable table{table-layout:auto!important;width:100%!important;border-collapse:collapse;margin:0!important}:host ::ng-deep .ck-editor__editable table td,:host ::ng-deep .ck-editor__editable table th{word-wrap:break-word;white-space:normal!important;padding:.4em!important}:host ::ng-deep .ck-editor__editable table td img,:host ::ng-deep .ck-editor__editable table th img{max-width:100%;height:auto}\n", "@charset \"UTF-8\";::ng-deep .ck-editor{--ck-font-size-base: 11px !important;--ck-icon-size: 16px !important;--ck-content-font-family: \"Times New Roman\", serif !important;--ck-content-font-size: 13pt;--ck-content-line-height: 1.5;--ck-spacing-small: 2px !important;--ck-spacing-standard: 4px !important;--ck-spacing-large: 8px !important}::ng-deep .ck-editor .ck-editor__top{position:sticky;top:0;z-index:100;width:100%;min-width:600px;margin-bottom:10px}::ng-deep .ck-editor .ck-editor__top .ck-sticky-panel__content{border:none!important}::ng-deep .ck-editor .ck-editor__top .ck-toolbar{background:#fff!important;box-shadow:0 4px 6px -1px #0000001a!important;padding:8px!important}::ng-deep .ck-editor .ck-editor__top .ck-toolbar .ck-toolbar__items{display:flex;justify-content:center;flex-wrap:wrap;align-items:center}::ng-deep .ck-editor .ck-toolbar{min-height:32px!important;padding:2px!important}::ng-deep .ck-editor .ck-button{padding:2px 4px!important;min-height:24px!important}::ng-deep .ck-editor .ck-dropdown__button{min-height:24px!important}::ng-deep .ck.ck-toolbar{background:#f8f9fa!important;border-bottom:1px solid #e0e0e0!important}\n"] }]
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 }]