@sd-angular/core 19.0.0-beta.3 → 19.0.0-beta.31

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 (154) hide show
  1. package/assets/scss/ckeditor5.scss +60 -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 +14 -2
  7. package/components/document-builder/src/document-builder.utils.d.ts +10 -0
  8. package/components/document-builder/src/plugins/block-space/block-space.plugin.d.ts +9 -0
  9. package/components/document-builder/src/plugins/heading/heading.plugin.d.ts +5 -0
  10. package/components/document-builder/src/plugins/highlight-range/highlight-range.plugin.d.ts +4 -0
  11. package/components/document-builder/src/plugins/image-custom/image-custom.plugin.d.ts +31 -0
  12. package/components/document-builder/src/plugins/{image-upload.plugin.d.ts → image-upload/image-upload.plugin.d.ts} +0 -4
  13. package/components/document-builder/src/plugins/index.d.ts +10 -5
  14. package/components/document-builder/src/plugins/{page-orientation.plugin.d.ts → page-orientation/page-orientation.plugin.d.ts} +2 -2
  15. package/components/document-builder/src/plugins/paste-handler/filters/bookmark.d.ts +14 -0
  16. package/components/document-builder/src/plugins/paste-handler/filters/br.d.ts +15 -0
  17. package/components/document-builder/src/plugins/paste-handler/filters/image.d.ts +25 -0
  18. package/components/document-builder/src/plugins/paste-handler/filters/list.d.ts +29 -0
  19. package/components/document-builder/src/plugins/paste-handler/filters/parse.d.ts +35 -0
  20. package/components/document-builder/src/plugins/paste-handler/filters/removeboldwrapper.d.ts +15 -0
  21. package/components/document-builder/src/plugins/paste-handler/filters/removegooglesheetstag.d.ts +15 -0
  22. package/components/document-builder/src/plugins/paste-handler/filters/removeinvalidtablewidth.d.ts +15 -0
  23. package/components/document-builder/src/plugins/paste-handler/filters/removemsattributes.d.ts +15 -0
  24. package/components/document-builder/src/plugins/paste-handler/filters/removestyleblock.d.ts +15 -0
  25. package/components/document-builder/src/plugins/paste-handler/filters/removexmlns.d.ts +15 -0
  26. package/components/document-builder/src/plugins/paste-handler/filters/replacemsfootnotes.d.ts +54 -0
  27. package/components/document-builder/src/plugins/paste-handler/filters/replacetabswithinprewithspaces.d.ts +24 -0
  28. package/components/document-builder/src/plugins/paste-handler/filters/space.d.ts +27 -0
  29. package/components/document-builder/src/plugins/paste-handler/filters/table.d.ts +16 -0
  30. package/components/document-builder/src/plugins/paste-handler/filters/utils.d.ts +25 -0
  31. package/components/document-builder/src/plugins/paste-handler/index.d.ts +35 -0
  32. package/components/document-builder/src/plugins/paste-handler/normalizers/googledocsnormalizer.d.ts +31 -0
  33. package/components/document-builder/src/plugins/paste-handler/normalizers/googlesheetsnormalizer.d.ts +31 -0
  34. package/components/document-builder/src/plugins/paste-handler/normalizers/mswordnormalizer.d.ts +29 -0
  35. package/components/document-builder/src/plugins/paste-handler/types.d.ts +30 -0
  36. package/components/document-builder/src/plugins/table-custom/index.d.ts +34 -0
  37. package/components/index.d.ts +3 -0
  38. package/components/mini-editor/index.d.ts +2 -0
  39. package/components/mini-editor/src/mini-editor.component.d.ts +90 -0
  40. package/components/mini-editor/src/mini-editor.model.d.ts +42 -0
  41. package/components/table/src/directives/index.d.ts +2 -0
  42. package/components/table/src/directives/sd-table-column-filter-def.directive.d.ts +9 -0
  43. package/components/table/src/directives/sticky-shadow.directive.d.ts +17 -0
  44. package/components/table/src/models/table-column.model.d.ts +7 -7
  45. package/components/table/src/models/table-command.model.d.ts +4 -0
  46. package/components/table/src/models/table-item.model.d.ts +2 -1
  47. package/components/table/src/models/table-option-export.model.d.ts +3 -2
  48. package/components/table/src/models/table-option.model.d.ts +10 -8
  49. package/components/table/src/services/table-filter/table-filter.model.d.ts +2 -2
  50. package/components/view/index.d.ts +1 -0
  51. package/components/view/src/view.component.d.ts +16 -0
  52. package/components/workflow/src/models/index.d.ts +1 -0
  53. package/directives/index.d.ts +1 -0
  54. package/directives/src/sd-href.directive.d.ts +9 -0
  55. package/fesm2022/sd-angular-core-components-avatar.mjs +88 -0
  56. package/fesm2022/sd-angular-core-components-avatar.mjs.map +1 -0
  57. package/fesm2022/sd-angular-core-components-badge.mjs +2 -2
  58. package/fesm2022/sd-angular-core-components-badge.mjs.map +1 -1
  59. package/fesm2022/sd-angular-core-components-document-builder.mjs +3187 -552
  60. package/fesm2022/sd-angular-core-components-document-builder.mjs.map +1 -1
  61. package/fesm2022/sd-angular-core-components-mini-editor.mjs +326 -0
  62. package/fesm2022/sd-angular-core-components-mini-editor.mjs.map +1 -0
  63. package/fesm2022/sd-angular-core-components-table.mjs +513 -87
  64. package/fesm2022/sd-angular-core-components-table.mjs.map +1 -1
  65. package/fesm2022/sd-angular-core-components-view.mjs +57 -0
  66. package/fesm2022/sd-angular-core-components-view.mjs.map +1 -0
  67. package/fesm2022/sd-angular-core-components-workflow.mjs +33 -43
  68. package/fesm2022/sd-angular-core-components-workflow.mjs.map +1 -1
  69. package/fesm2022/sd-angular-core-components.mjs +3 -0
  70. package/fesm2022/sd-angular-core-components.mjs.map +1 -1
  71. package/fesm2022/sd-angular-core-directives.mjs +80 -27
  72. package/fesm2022/sd-angular-core-directives.mjs.map +1 -1
  73. package/fesm2022/sd-angular-core-forms-autocomplete.mjs +35 -9
  74. package/fesm2022/sd-angular-core-forms-autocomplete.mjs.map +1 -1
  75. package/fesm2022/sd-angular-core-forms-date.mjs +24 -4
  76. package/fesm2022/sd-angular-core-forms-date.mjs.map +1 -1
  77. package/fesm2022/sd-angular-core-forms-datetime.mjs +27 -9
  78. package/fesm2022/sd-angular-core-forms-datetime.mjs.map +1 -1
  79. package/fesm2022/sd-angular-core-forms-input-number.mjs +37 -10
  80. package/fesm2022/sd-angular-core-forms-input-number.mjs.map +1 -1
  81. package/fesm2022/sd-angular-core-forms-input.mjs +29 -11
  82. package/fesm2022/sd-angular-core-forms-input.mjs.map +1 -1
  83. package/fesm2022/sd-angular-core-forms-radio.mjs +18 -2
  84. package/fesm2022/sd-angular-core-forms-radio.mjs.map +1 -1
  85. package/fesm2022/sd-angular-core-forms-select.mjs +27 -9
  86. package/fesm2022/sd-angular-core-forms-select.mjs.map +1 -1
  87. package/fesm2022/sd-angular-core-forms-textarea.mjs +21 -2
  88. package/fesm2022/sd-angular-core-forms-textarea.mjs.map +1 -1
  89. package/fesm2022/sd-angular-core-modules-auth.mjs +5 -5
  90. package/fesm2022/sd-angular-core-modules-auth.mjs.map +1 -1
  91. package/fesm2022/sd-angular-core-modules-keycloak.mjs +126 -0
  92. package/fesm2022/sd-angular-core-modules-keycloak.mjs.map +1 -0
  93. package/fesm2022/sd-angular-core-modules-layout.mjs +52 -17
  94. package/fesm2022/sd-angular-core-modules-layout.mjs.map +1 -1
  95. package/fesm2022/sd-angular-core-modules.mjs +1 -1
  96. package/fesm2022/sd-angular-core-pipes.mjs +21 -1
  97. package/fesm2022/sd-angular-core-pipes.mjs.map +1 -1
  98. package/fesm2022/sd-angular-core-services-confirm.mjs +2 -2
  99. package/fesm2022/sd-angular-core-services-confirm.mjs.map +1 -1
  100. package/fesm2022/sd-angular-core-services-docx.mjs +173 -0
  101. package/fesm2022/sd-angular-core-services-docx.mjs.map +1 -0
  102. package/fesm2022/sd-angular-core-services-notify.mjs +2 -2
  103. package/fesm2022/sd-angular-core-services-notify.mjs.map +1 -1
  104. package/fesm2022/sd-angular-core-services.mjs +1 -0
  105. package/fesm2022/sd-angular-core-services.mjs.map +1 -1
  106. package/fesm2022/sd-angular-core-utilities-extensions.mjs +74 -7
  107. package/fesm2022/sd-angular-core-utilities-extensions.mjs.map +1 -1
  108. package/fesm2022/sd-angular-core-utilities-models.mjs +8 -2
  109. package/fesm2022/sd-angular-core-utilities-models.mjs.map +1 -1
  110. package/forms/autocomplete/src/autocomplete.component.d.ts +9 -4
  111. package/forms/date/src/date.component.d.ts +7 -2
  112. package/forms/datetime/src/datetime.component.d.ts +8 -4
  113. package/forms/input/src/input.component.d.ts +10 -7
  114. package/forms/input-number/src/input-number.component.d.ts +10 -6
  115. package/forms/radio/src/radio.component.d.ts +5 -1
  116. package/forms/select/src/select.component.d.ts +9 -4
  117. package/forms/textarea/src/textarea.component.d.ts +3 -1
  118. package/modules/auth/guards/portal.guard.d.ts +3 -3
  119. package/modules/index.d.ts +1 -1
  120. package/modules/keycloak/index.d.ts +4 -0
  121. package/modules/keycloak/keycloak.configuration.d.ts +11 -0
  122. package/modules/keycloak/keycloak.interceptor.d.ts +2 -0
  123. package/modules/keycloak/keycloak.module.d.ts +18 -0
  124. package/modules/keycloak/keycloak.service.d.ts +14 -0
  125. package/modules/layout/components/sidebar-v1/components/sidebar/sidebar.component.d.ts +1 -0
  126. package/modules/layout/components/sidebar-v1/components/user/user.component.d.ts +5 -2
  127. package/modules/layout/configurations/layout.configuration.d.ts +3 -0
  128. package/modules/layout/services/storage/storage.service.d.ts +1 -0
  129. package/package.json +80 -62
  130. package/pipes/index.d.ts +1 -0
  131. package/pipes/src/empty.pipe.d.ts +7 -0
  132. package/sd-angular-core-19.0.0-beta.31.tgz +0 -0
  133. package/services/confirm/src/lib/confirm.service.d.ts +1 -0
  134. package/services/docx/index.d.ts +1 -0
  135. package/services/docx/src/lib/docx.model.d.ts +9 -0
  136. package/services/docx/src/lib/docx.service.d.ts +13 -0
  137. package/services/docx/src/public-api.d.ts +2 -0
  138. package/services/index.d.ts +1 -0
  139. package/utilities/extensions/index.d.ts +1 -0
  140. package/utilities/extensions/src/color.extension.d.ts +20 -0
  141. package/utilities/extensions/src/string.extension.d.ts +1 -0
  142. package/utilities/models/index.d.ts +1 -0
  143. package/utilities/models/src/filter.model.d.ts +10 -2
  144. package/utilities/models/src/nested-key-of.model.d.ts +5 -0
  145. package/utilities/models/src/pattern.model.d.ts +3 -3
  146. package/components/document-builder/src/plugins/table-fit.plugin.d.ts +0 -4
  147. package/fesm2022/sd-angular-core-modules-oidc.mjs +0 -127
  148. package/fesm2022/sd-angular-core-modules-oidc.mjs.map +0 -1
  149. package/modules/oidc/dynamic-sts.loader.d.ts +0 -11
  150. package/modules/oidc/index.d.ts +0 -2
  151. package/modules/oidc/oidc.configuration.d.ts +0 -11
  152. package/modules/oidc/oidc.module.d.ts +0 -14
  153. /package/components/document-builder/src/plugins/{comment.plugin.d.ts → comment/comment.plugin.d.ts} +0 -0
  154. /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, ViewUpcastWriter, Matcher, ViewDocument, ViewDomConverter, ClipboardPipeline, FontFamily, Heading, List, Table, TableToolbar, TableProperties, TableCellProperties, TableColumnResize, 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,263 @@ 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
+ /**
134
+ * Cấu hình màu cho Document Builder
135
+ * 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
136
+ */
137
+ /**
138
+ * Trả về bảng màu chung được sử dụng trong tất cả tính năng của document builder
139
+ * @returns Mảng các tùy chọn màu được định sẵn với giá trị hex và label
140
+ */
141
+ function getPresetColors() {
142
+ return [
143
+ { color: '#000000', label: 'Black' },
144
+ { color: '#4D4D4D', label: 'Dim grey' },
145
+ { color: '#999999', label: 'Grey' },
146
+ { color: '#E6E6E6', label: 'Light grey' },
147
+ { color: '#FFFFFF', label: 'White' },
148
+ { color: '#E64D4D', label: 'Red' },
149
+ { color: '#E6994D', label: 'Orange' },
150
+ { color: '#E6E64D', label: 'Yellow' },
151
+ { color: '#99E64D', label: 'Light green' },
152
+ { color: '#4DE64D', label: 'Green' },
153
+ { color: '#4DE699', label: 'Aquamarine' },
154
+ { color: '#4DE6E6', label: 'Turquoise' },
155
+ { color: '#4D99E6', label: 'Light blue' },
156
+ { color: '#4D4DE6', label: 'Blue' },
157
+ { color: '#994DE6', label: 'Purple' },
158
+ ];
159
+ }
160
+ /**
161
+ * Trả về cấu hình bộ chọn màu với định dạng hex
162
+ * @returns Đối tượng cấu hình bộ chọn màu
163
+ */
164
+ function getColorPickerConfig() {
165
+ return {
166
+ format: 'hex',
167
+ };
168
+ }
169
+ /**
170
+ * Trả về cấu hình kích thước font cho document builder
171
+ * @returns Mảng các tùy chọn kích thước font được định sẵn
172
+ */
173
+ function getFontSizeOptions() {
174
+ return [
175
+ {
176
+ title: '9',
177
+ model: '9pt',
178
+ view: {
179
+ name: 'span',
180
+ styles: { 'font-size': '9pt' },
181
+ priority: 7,
182
+ },
183
+ },
184
+ {
185
+ title: '10',
186
+ model: '10pt',
187
+ view: {
188
+ name: 'span',
189
+ styles: { 'font-size': '10pt' },
190
+ priority: 7,
191
+ },
192
+ },
193
+ {
194
+ title: '11',
195
+ model: '11pt',
196
+ view: {
197
+ name: 'span',
198
+ styles: { 'font-size': '11pt' },
199
+ priority: 7,
200
+ },
201
+ },
202
+ {
203
+ title: '12',
204
+ model: '12pt',
205
+ view: {
206
+ name: 'span',
207
+ styles: { 'font-size': '12pt' },
208
+ priority: 7,
209
+ },
210
+ },
211
+ {
212
+ title: '13',
213
+ model: '13pt',
214
+ view: {
215
+ name: 'span',
216
+ styles: { 'font-size': '13pt' },
217
+ priority: 7,
218
+ },
219
+ },
220
+ {
221
+ title: '14',
222
+ model: '14pt',
223
+ view: {
224
+ name: 'span',
225
+ styles: { 'font-size': '14pt' },
226
+ priority: 7,
227
+ },
228
+ },
229
+ {
230
+ title: '16',
231
+ model: '16pt',
232
+ view: {
233
+ name: 'span',
234
+ styles: { 'font-size': '16pt' },
235
+ priority: 7,
236
+ },
237
+ },
238
+ {
239
+ title: '18',
240
+ model: '18pt',
241
+ view: {
242
+ name: 'span',
243
+ styles: { 'font-size': '18pt' },
244
+ priority: 7,
245
+ },
246
+ },
247
+ {
248
+ title: '20',
249
+ model: '20pt',
250
+ view: {
251
+ name: 'span',
252
+ styles: { 'font-size': '20pt' },
253
+ priority: 7,
254
+ },
255
+ },
256
+ {
257
+ title: '24',
258
+ model: '24pt',
259
+ view: {
260
+ name: 'span',
261
+ styles: { 'font-size': '24pt' },
262
+ priority: 7,
263
+ },
264
+ },
265
+ ];
266
+ }
267
+ function getHeadingOptions() {
268
+ return [
269
+ {
270
+ model: 'paragraph',
271
+ title: 'Paragraph',
272
+ class: 'ck-heading_paragraph',
273
+ },
274
+ {
275
+ model: 'heading1',
276
+ view: {
277
+ name: 'h1',
278
+ styles: { 'font-size': '24pt', 'font-weight': 'bold', 'line-height': '1.15' },
279
+ },
280
+ title: 'Heading 1',
281
+ class: 'ck-heading_heading1',
282
+ },
283
+ {
284
+ model: 'heading2',
285
+ view: {
286
+ name: 'h2',
287
+ styles: { 'font-size': '20pt', 'font-weight': 'bold', 'line-height': '1.15' },
288
+ },
289
+ title: 'Heading 2',
290
+ class: 'ck-heading_heading2',
291
+ },
292
+ {
293
+ model: 'heading3',
294
+ view: {
295
+ name: 'h3',
296
+ styles: { 'font-size': '16pt', 'font-weight': 'bold', 'line-height': '1.15' },
297
+ },
298
+ title: 'Heading 3',
299
+ class: 'ck-heading_heading3',
300
+ },
301
+ ];
302
+ }
303
+
304
+ class HeadingPlugin extends Plugin {
305
+ static get pluginName() {
306
+ return 'HeadingPlugin';
307
+ }
142
308
  init() {
143
309
  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ử khi bấm nút
157
- view.on('execute', () => {
158
- this.toggleOrientation();
310
+ editor.conversion.for('editingDowncast').markerToHighlight({
311
+ model: 'highlightMarker',
312
+ view: {
313
+ classes: 'ck-heading-highlight',
314
+ },
315
+ });
316
+ // Lấy default styles từ config
317
+ const headingOptions = getHeadingOptions();
318
+ const headingDefaults = {};
319
+ headingOptions?.forEach((opt) => {
320
+ if (opt.model?.match(/^heading[1-3]$/)) {
321
+ headingDefaults[opt.model] = {
322
+ fontSize: opt.view?.styles?.['font-size'] || 'inherit',
323
+ lineHeight: opt.view?.styles?.['line-height'] || 'inherit',
324
+ };
325
+ }
326
+ });
327
+ // Downcast: Kiểm tra heading có styled text và set style inherit
328
+ const downcastConversion = editor.conversion.for('downcast');
329
+ Object.keys(headingDefaults).forEach(modelName => {
330
+ downcastConversion.add(dispatcher => {
331
+ dispatcher.on(`insert:${modelName}`, createHeadingHandler(editor, modelName, headingDefaults), { priority: 'low' });
159
332
  });
160
- return view;
161
333
  });
162
334
  }
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);
335
+ }
336
+ function createHeadingHandler(editor, modelName, headingDefaults) {
337
+ return (evt, data, conversionApi) => {
338
+ const element = data.item;
339
+ const viewElement = editor.editing.mapper.toViewElement(element);
340
+ if (!viewElement)
341
+ return;
342
+ const children = Array.from(element.getChildren());
343
+ const hasStyledText = children.some((child) => {
344
+ if (child.is('$text')) {
345
+ const attrs = Array.from(child.getAttributes());
346
+ return attrs.length > 0;
347
+ }
348
+ return !child.is('$text');
349
+ });
350
+ editor.editing.view.change((writer) => {
351
+ if (children.length === 1 && hasStyledText) {
352
+ // Có styled text → bỏ style mặc định
353
+ writer.setStyle('font-size', 'inherit', viewElement);
354
+ writer.setStyle('line-height', 'inherit', viewElement);
180
355
  }
181
356
  else {
182
- writer.removeClass('landscape', rootElement);
357
+ // Không có styled text → set lại style mặc định từ config
358
+ const defaults = headingDefaults[modelName];
359
+ writer.setStyle('font-size', defaults.fontSize, viewElement);
360
+ writer.setStyle('line-height', defaults.lineHeight, viewElement);
183
361
  }
184
362
  });
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
- }
363
+ };
204
364
  }
205
365
 
206
366
  class CommentPlugin extends Plugin {
207
367
  init() {
208
368
  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
369
+ // 1. MODEL MARKER -> VIEW CSS
370
+ editor.conversion.for('downcast').markerToHighlight({
371
+ model: 'comment',
372
+ view: data => {
373
+ return {
374
+ classes: 'ck-comment-marker',
375
+ attributes: {
376
+ 'data-comment-id': data.markerName,
377
+ },
378
+ };
215
379
  },
216
380
  });
217
- // --- 3. ĐĂNG KÝ UI COMPONENT: 'addCommentBtn' ---
381
+ // 2. ĐĂNG KÝ UI COMPONENT: 'addCommentBtn'
218
382
  editor.ui.componentFactory.add('addCommentBtn', locale => {
219
383
  const view = new ButtonView(locale);
220
384
  // Lấy config từ Angular
221
385
  const config = editor.config;
222
386
  const getOption = config.get('getOption');
223
387
  const option = getOption?.();
224
- // ẨN BUTTON NẾU KHÔNG onAddComment
388
+ // Ẩn button nếu không onAddComment
225
389
  if (!option?.onAddComment) {
226
- // Trả về button rỗng hoặc null để không hiển thị
227
390
  view.set({
228
391
  label: '',
229
392
  isVisible: false,
@@ -232,19 +395,18 @@ class CommentPlugin extends Plugin {
232
395
  }
233
396
  view.set({
234
397
  label: 'Thêm bình luận',
235
- // Icon SVG comment
236
398
  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
399
  tooltip: true,
238
- isEnabled: false, // Mặc định disable
400
+ isEnabled: false,
239
401
  });
240
- // 4. Logic Enable/Disable: Dựa theo Selection
402
+ // Logic Enable/Disable: Dựa theo Selection
241
403
  const selection = editor.model.document.selection;
242
404
  // Lắng nghe sự kiện change selection để bật/tắt nút
243
405
  this.listenTo(selection, 'change', () => {
244
406
  // Enable khi có bôi đen text (không phải collapsed)
245
407
  view.isEnabled = !selection.isCollapsed;
246
408
  });
247
- // 5. Logic Execute: Khi bấm nút
409
+ // Logic Execute: Khi bấm nút
248
410
  this.listenTo(view, 'execute', () => {
249
411
  const range = selection.getFirstRange();
250
412
  if (!range || !option?.onAddComment)
@@ -256,13 +418,41 @@ class CommentPlugin extends Plugin {
256
418
  selectedText += item.data;
257
419
  }
258
420
  }
259
- // BẮN EVENT RA NGOÀI - KHÔNG TỰ ADD MARKER
421
+ // BẮN EVENT RA NGOÀI - KHÔNG TỰ ADD MARKER
260
422
  // Angular component sẽ xử lý logic (mở modal, validation, etc.)
261
423
  // và gọi lại hàm addComment() nếu cần
262
- option.onAddComment(range);
424
+ option.onAddComment({ range, selectedText });
263
425
  });
264
426
  return view;
265
427
  });
428
+ // 3. Xử lý sự kiện Copy (Clipboard Output)
429
+ this.listenTo(editor.editing.view.document, 'clipboardOutput', (_, data) => {
430
+ const isCopyOrCut = data.method === 'copy' || data.method === 'cut';
431
+ // Nếu không phải hành động copy hoặc cut thì thoát hàm
432
+ if (!isCopyOrCut) {
433
+ return;
434
+ }
435
+ const content = data.content;
436
+ editor.editing.view.change(writer => {
437
+ // Tạo range bao quanh toàn bộ nội dung clipboard
438
+ const range = writer.createRangeIn(content);
439
+ // Mảng chứa các item cần xử lý
440
+ const itemsToClean = [];
441
+ // 1. Duyệt qua để tìm các thẻ có class ck-comment-marker
442
+ for (const item of range.getItems()) {
443
+ if (item.is('element') && item.hasClass('ck-comment-marker')) {
444
+ itemsToClean.push(item);
445
+ }
446
+ }
447
+ // 2. Thực hiện xóa Class và Attribute
448
+ for (const item of itemsToClean) {
449
+ // Xóa class 'ck-comment-marker'
450
+ writer.removeClass('ck-comment-marker', item);
451
+ // Xóa thuộc tính 'data-comment-id'
452
+ writer.removeAttribute('data-comment-id', item);
453
+ }
454
+ });
455
+ });
266
456
  }
267
457
  }
268
458
 
@@ -282,50 +472,51 @@ class VariablePlugin extends Plugin {
282
472
  allowWhere: '$text',
283
473
  isInline: true,
284
474
  isObject: true,
285
- allowAttributes: ['id', 'value', 'display', 'data'],
475
+ allowAttributes: ['id', 'uuid', 'value', 'display'],
286
476
  });
287
- // 2. Conversion: Model -> View (Hiển thị ra HTML)
477
+ // 2. Model -> HTML
288
478
  conversion.for('downcast').elementToElement({
289
479
  model: 'variable',
290
480
  view: (modelItem, { writer: viewWriter }) => {
291
481
  const id = modelItem.getAttribute('id');
482
+ const uuid = modelItem.getAttribute('uuid');
292
483
  const display = modelItem.getAttribute('display');
293
484
  const value = modelItem.getAttribute('value');
294
- // Xử lý data (Object -> String)
295
- const rawData = modelItem.getAttribute('data');
296
- const dataJson = rawData ? JSON.stringify(rawData) : '';
297
485
  const widgetElement = viewWriter.createContainerElement('span', {
298
486
  class: 'variable-widget',
299
487
  'data-id': id,
488
+ 'data-uuid': uuid,
300
489
  'data-value': value,
301
490
  'data-display': display,
302
- 'data-json': dataJson,
303
491
  });
304
492
  const innerText = viewWriter.createText(`{{${display}}}`);
305
493
  viewWriter.insert(viewWriter.createPositionAt(widgetElement, 0), innerText);
306
494
  return toWidget(widgetElement, viewWriter);
307
495
  },
308
496
  });
309
- // 3. Conversion: View -> Model (Khi load data hoặc paste)
497
+ // 3. HTML -> Model
310
498
  conversion.for('upcast').elementToElement({
499
+ // NOTE: Khi bổ sung thêm attribute vào element variable, dev nên bổ sung thêm "[atribute]: true" vào view
500
+ // Để:
501
+ // - Nếu lọc chính xác sẽ không sinh ra thẻ span thừa bọc ngoài
502
+ // - Nếu chưa bổ sung thì sẽ sinh ra thẻ <span> bọc ngoài kèm [atribute] chưa lọc
311
503
  view: {
312
504
  name: 'span',
313
- classes: 'variable-widget',
505
+ classes: 'variable-widget ck-widget',
506
+ attributes: {
507
+ 'data-id': true,
508
+ 'data-uuid': true,
509
+ 'data-value': true,
510
+ 'data-display': true,
511
+ contenteditable: true,
512
+ },
314
513
  },
315
514
  model: (viewElement, { writer: modelWriter }) => {
316
- const dataJson = viewElement.getAttribute('data-json');
317
- let parsedData = null;
318
- try {
319
- parsedData = dataJson ? JSON.parse(dataJson) : null;
320
- }
321
- catch (e) {
322
- console.error('Lỗi parse variable data', e);
323
- }
324
515
  return modelWriter.createElement('variable', {
325
516
  id: viewElement.getAttribute('data-id'),
517
+ uuid: viewElement.getAttribute('data-uuid'),
326
518
  value: viewElement.getAttribute('data-value'),
327
519
  display: viewElement.getAttribute('data-display'),
328
- data: parsedData, // Lưu vào model dưới dạng Object gốc
329
520
  });
330
521
  },
331
522
  });
@@ -335,64 +526,63 @@ class VariablePlugin extends Plugin {
335
526
  const jsonData = dataTransfer.getData('ck-variable');
336
527
  if (!jsonData)
337
528
  return;
338
- // Ngăn trình duyệt xử mặc định
529
+ // data.dropRange vị trí con chuột trên View khi thả
530
+ const viewRange = data.dropRange;
531
+ const modelRange = editor.editing.mapper.toModelRange(viewRange);
339
532
  evt.stop();
340
- let variable = JSON.parse(jsonData);
341
- const config = editor.config;
342
- const getOption = config.get('getOption');
343
- const option = getOption?.();
344
- if (option?.onDropVariable) {
345
- const result = await SdResolveMaybeAsync(option.onDropVariable(variable, 0));
346
- //* Hỗ trợ dữ liệu có sẵn sẽ chỉ cần nhận vào boolean
347
- if (typeof result === 'boolean') {
348
- if (!result)
349
- return;
350
- }
351
- else {
352
- //* Hỗ trợ dữ liệu lấy từ API (Kiểm tra xem result có đúng định dạng interface SdDocumentBuilderVariable hay không?)
353
- if (this.#isSdDocumentBuilderVariableResult(result)) {
354
- variable = result;
533
+ try {
534
+ let variable = JSON.parse(jsonData);
535
+ const config = editor.config;
536
+ const getOption = config.get('getOption');
537
+ const option = getOption?.();
538
+ if (option?.onDropVariable) {
539
+ const result = await SdResolveMaybeAsync(option.onDropVariable(variable, 0));
540
+ // * 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?
541
+ if (typeof result === 'boolean') {
542
+ if (!result) {
543
+ throw new Error('Không cho phép thêm variable vào văn bản');
544
+ }
355
545
  }
356
546
  else {
357
- throw new Error('Dữ liệu variable không hợp lệ');
547
+ // * Hỗ trợ dữ liệu lấy từ API (Kiểm tra xem result có đúng định dạng interface SdDocumentBuilderVariable hay không?)
548
+ if (this.#isSdDocumentBuilderVariableResult(result)) {
549
+ variable = result;
550
+ }
551
+ else {
552
+ throw new Error('Dữ liệu variable không hợp lệ');
553
+ }
358
554
  }
359
555
  }
360
- }
361
- // Xác định vị trí thả chính xác (Quan trọng)
362
- // data.dropRange vị trí con chuột trên View khi thả
363
- const viewRange = data.dropRange;
364
- const modelRange = editor.editing.mapper.toModelRange(viewRange);
365
- editor.model.change(writer => {
366
- // A. Chèn biến
367
- const variableElem = writer.createElement('variable', {
368
- id: variable.id,
369
- value: variable.value,
370
- display: variable.display,
371
- data: variable.data,
556
+ editor.model.change(writer => {
557
+ // 4.1. Chèn biến
558
+ const variableElem = writer.createElement('variable', {
559
+ id: variable.id,
560
+ uuid: v4(),
561
+ value: variable.value,
562
+ display: variable.display,
563
+ });
564
+ editor.model.insertContent(variableElem, modelRange);
565
+ // 4.2. Đặt con trỏ ra sau biến
566
+ writer.setSelection(variableElem, 'after');
372
567
  });
373
- editor.model.insertContent(variableElem, modelRange);
374
- // B. Đặt con trỏ ra sau biến
375
- writer.setSelection(variableElem, 'after');
376
- // C. [QUAN TRỌNG] XÓA SẠCH CÁC MARKER DROP
377
- // 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'
378
- // Vì đôi khi CKEditor tạo ra các biến thể khác nhau
379
- for (const marker of editor.model.markers) {
380
- if (marker.name.startsWith('drop-target')) {
381
- writer.removeMarker(marker);
382
- }
568
+ }
569
+ catch (e) {
570
+ // Đặt con trỏ ngay tại vị trí lỗi
571
+ if (modelRange) {
572
+ editor.model.change(writer => {
573
+ writer.setSelection(modelRange);
574
+ });
383
575
  }
384
- });
385
- // FIX LỖI: Focus lại vào editor để xóa các artifact của việc kéo thả
386
- editor.editing.view.focus();
387
- // [BỔ SUNG] XÓA CLASS RÁC TRÊN VIEW (NẾU MARKER KHÔNG HẾT)
388
- // Đô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
389
- // (Thường bước C trên là đủ, nhưng đây là chốt chặn cuối cùng bằng JS)
390
- const viewRoot = editor.editing.view.document.getRoot();
391
- if (viewRoot) {
392
- editor.editing.view.change(viewWriter => {
393
- // Loại bỏ class decoration nếu nó bị dính vào root (hiếm gặp nhưng có thể)
394
- viewWriter.removeClass('ck-clipboard-drop-target-position', viewRoot);
395
- viewWriter.removeClass('ck-clipboard-drop-target-line', viewRoot);
576
+ console.error(e);
577
+ }
578
+ finally {
579
+ // 5. Dọn dẹp drop-target thành công hay lỗi
580
+ editor.model.change(writer => {
581
+ for (const marker of editor.model.markers) {
582
+ if (marker.name.startsWith('drop-target')) {
583
+ writer.removeMarker(marker);
584
+ }
585
+ }
396
586
  });
397
587
  }
398
588
  });
@@ -483,6 +673,114 @@ class VariablePlugin extends Plugin {
483
673
  }
484
674
  }
485
675
  }, { priority: 'highest' });
676
+ // 8. Xử lý sự kiện Copy (Clipboard Output)
677
+ // Chỉ set thêm text/plain fallback, không thay đổi HTML content
678
+ this.listenTo(editor.editing.view.document, 'clipboardOutput', (evt, data) => {
679
+ const isCopyOrCut = data.method === 'copy' || data.method === 'cut';
680
+ if (!isCopyOrCut)
681
+ return;
682
+ // Set thêm plain text fallback cho external apps
683
+ const dataTransfer = data.dataTransfer;
684
+ const content = data.content;
685
+ // Lấy tất cả text từ content (bao gồm cả variable dạng {{text}})
686
+ let plainText = '';
687
+ const viewRange = editor.editing.view.createRangeIn(content);
688
+ for (const item of viewRange.getItems()) {
689
+ if (item.is('$text') || item.is('element', 'span')) {
690
+ const itemAny = item;
691
+ if (item.is('$text') && itemAny.data) {
692
+ plainText += itemAny.data;
693
+ }
694
+ else if (item.is('element', 'span') && item.hasClass('variable-widget')) {
695
+ const display = item.getAttribute('data-display');
696
+ if (display)
697
+ plainText += `{{${display}}}`;
698
+ }
699
+ }
700
+ }
701
+ if (plainText) {
702
+ dataTransfer.setData('text/plain', plainText);
703
+ }
704
+ // HTML content giữ nguyên - CKEditor sẽ tự xử lý
705
+ }, { priority: 'low' });
706
+ // 9. Xử lý sự kiện Paste (Clipboard Input)
707
+ // Nếu paste từ external source (chỉ có text, không có HTML variable)
708
+ // thì chuyển {{text}} thành variable widget
709
+ this.listenTo(editor.editing.view.document, 'clipboardInput', (evt, data) => {
710
+ const dataTransfer = data.dataTransfer;
711
+ // Nếu có HTML chứa variable-widget thì để CKEditor xử lý (upcast converter)
712
+ const html = dataTransfer.getData('text/html');
713
+ if (html && html.includes('variable-widget')) {
714
+ return; // Để CKEditor upcast converter xử lý
715
+ }
716
+ // Chỉ xử lý nếu chỉ có plain text với pattern {{text}}
717
+ let text = dataTransfer.getData('text/plain');
718
+ if (!text)
719
+ return;
720
+ // Kiểm tra có chứa pattern {{text}} không (không cần id, value)
721
+ const variablePattern = /\{\{([^}]+)\}\}/g;
722
+ if (!variablePattern.test(text)) {
723
+ return;
724
+ }
725
+ // Reset lastIndex sau khi test
726
+ variablePattern.lastIndex = 0;
727
+ evt.stop();
728
+ editor.model.change(writer => {
729
+ const selection = editor.model.document.selection;
730
+ const position = selection.getFirstPosition();
731
+ if (!position)
732
+ return;
733
+ // Tách text thành các phần: normal text và variables
734
+ let lastIndex = 0;
735
+ let match;
736
+ const fragments = [];
737
+ while ((match = variablePattern.exec(text)) !== null) {
738
+ // Thêm text trước variable
739
+ if (match.index > lastIndex) {
740
+ fragments.push({
741
+ type: 'text',
742
+ content: text.slice(lastIndex, match.index),
743
+ });
744
+ }
745
+ // Thêm variable
746
+ const display = match[1];
747
+ fragments.push({
748
+ type: 'variable',
749
+ content: match[0],
750
+ display,
751
+ });
752
+ lastIndex = match.index + match[0].length;
753
+ }
754
+ // Thêm text còn lại sau variable cuối cùng
755
+ if (lastIndex < text.length) {
756
+ fragments.push({
757
+ type: 'text',
758
+ content: text.slice(lastIndex),
759
+ });
760
+ }
761
+ // Chèn từng fragment vào document
762
+ let currentPosition = position;
763
+ for (const fragment of fragments) {
764
+ if (fragment.type === 'text' && fragment.content) {
765
+ const textNode = writer.createText(fragment.content);
766
+ writer.insert(textNode, currentPosition);
767
+ currentPosition = writer.createPositionAfter(textNode);
768
+ }
769
+ else if (fragment.type === 'variable' && fragment.display) {
770
+ const variableElem = writer.createElement('variable', {
771
+ id: v4(),
772
+ uuid: v4(),
773
+ value: fragment.display,
774
+ display: fragment.display,
775
+ });
776
+ writer.insert(variableElem, currentPosition);
777
+ currentPosition = writer.createPositionAfter(variableElem);
778
+ }
779
+ }
780
+ // Đặt con trỏ sau nội dung vừa paste
781
+ writer.setSelection(currentPosition);
782
+ });
783
+ });
486
784
  }
487
785
  #isSdDocumentBuilderVariableResult = (obj) => {
488
786
  return (obj !== null &&
@@ -495,75 +793,331 @@ class VariablePlugin extends Plugin {
495
793
  };
496
794
  }
497
795
 
498
- class TableFitPlugin extends Plugin {
796
+ class TableCustom extends Plugin {
499
797
  init() {
500
798
  const editor = this.editor;
501
799
  // Can thiệp vào quá trình convert từ View (HTML Paste) sang Model
502
800
  editor.conversion.for('upcast').add(dispatcher => {
503
- dispatcher.on('element:table', (evt, data, conversionApi) => {
504
- // 1. Gọi consume để báo với CKEditor là chúng ta sẽ xử lý element này
505
- if (!conversionApi.consumable.consume(data.viewItem, { name: true })) {
801
+ dispatcher.on('element:table', (evt, data) => {
802
+ if (!data.modelRange)
506
803
  return;
507
- }
508
- // 2. Thực hiện chuyển đổi mặc định để tạo ra model element
509
- const { modelCursor, modelRange } = conversionApi.convertChildren(data.viewItem, data.modelCursor);
510
- // 3. Bây giờ modelRange chắc chắn tồn tại, ta tìm element table trong đó
511
- for (const item of modelRange.getItems()) {
804
+ for (const item of data.modelRange.getItems()) {
512
805
  if (item.is('element', 'table')) {
513
806
  editor.model.change(writer => {
514
- writer.setAttribute('tableWidth', '100%', item);
807
+ this._applyTableDefaults(writer, item);
808
+ this._applyCellBorders(writer, item);
515
809
  });
516
810
  }
517
811
  }
518
- // 4. Cập nhật modelCursor để dispatcher biết đã xử lý xong tới đâu
519
- data.modelRange = modelRange;
520
- data.modelCursor = modelCursor;
521
812
  }, { priority: 'low' });
522
- // Chạy sau cùng để ghi đè các logic mặc định
813
+ // Xử data-column-widths từ colgroup preservation
814
+ editor.conversion.for('upcast').attributeToAttribute({
815
+ view: 'data-column-widths',
816
+ model: 'tableColumnWidth'
817
+ });
818
+ console.log('[TableCustom] Registered data-column-widths upcast converter');
819
+ // Xử lý border-style: none cho tableCell - phải explicit set 'none'
820
+ dispatcher.on('element:td', (evt, data) => {
821
+ if (!data.modelRange || !data.viewItem)
822
+ return;
823
+ const viewElement = data.viewItem;
824
+ const borderStyle = viewElement.getStyle('border-style') ||
825
+ this._parseBorderStyleFromShorthand(viewElement.getStyle('border'));
826
+ // Nếu border-style là none hoặc border shorthand là none/0, explicit set 'none'
827
+ if (borderStyle === 'none' || borderStyle === 'hidden') {
828
+ for (const item of data.modelRange.getItems()) {
829
+ if (item.is('element', 'tableCell')) {
830
+ editor.model.change(writer => {
831
+ writer.setAttribute('tableCellBorderStyle', 'none', item);
832
+ // Khi border là none, set width về 0 để không có border
833
+ writer.setAttribute('tableCellBorderWidth', '0pt', item);
834
+ });
835
+ }
836
+ }
837
+ }
838
+ }, { priority: 'high' });
839
+ dispatcher.on('element:th', (evt, data) => {
840
+ if (!data.modelRange || !data.viewItem)
841
+ return;
842
+ const viewElement = data.viewItem;
843
+ const borderStyle = viewElement.getStyle('border-style') ||
844
+ this._parseBorderStyleFromShorthand(viewElement.getStyle('border'));
845
+ // Nếu border-style là none hoặc border shorthand là none/0, explicit set 'none'
846
+ if (borderStyle === 'none' || borderStyle === 'hidden') {
847
+ for (const item of data.modelRange.getItems()) {
848
+ if (item.is('element', 'tableCell')) {
849
+ editor.model.change(writer => {
850
+ writer.setAttribute('tableCellBorderStyle', 'none', item);
851
+ // Khi border là none, set width về 0 để không có border
852
+ writer.setAttribute('tableCellBorderWidth', '0pt', item);
853
+ });
854
+ }
855
+ }
856
+ }
857
+ }, { priority: 'high' });
858
+ });
859
+ const findInnerTable = (viewElement) => {
860
+ if (!viewElement)
861
+ return null;
862
+ if (viewElement.name === 'table')
863
+ return viewElement;
864
+ for (const child of viewElement.getChildren()) {
865
+ if (child.name === 'table')
866
+ return child;
867
+ const found = findInnerTable(child);
868
+ if (found)
869
+ return found;
870
+ }
871
+ return null;
872
+ };
873
+ editor.conversion.for('downcast').add(dispatcher => {
874
+ dispatcher.on('attribute:tableWidth:table', (evt, data, conversionApi) => {
875
+ const viewWriter = conversionApi.writer;
876
+ const viewElement = conversionApi.mapper.toViewElement(data.item);
877
+ if (!viewElement)
878
+ return;
879
+ const innerTable = findInnerTable(viewElement);
880
+ if (!innerTable)
881
+ return;
882
+ viewWriter.setStyle('border-collapse', 'collapse', innerTable);
883
+ viewWriter.setStyle('margin', '0', innerTable);
884
+ viewWriter.setStyle('width', '100%', innerTable);
885
+ viewWriter.setStyle('width', '100%', viewElement);
886
+ });
887
+ dispatcher.on('insert:table', (evt, data, conversionApi) => {
888
+ const viewWriter = conversionApi.writer;
889
+ const viewElement = conversionApi.mapper.toViewElement(data.item);
890
+ if (!viewElement)
891
+ return;
892
+ const innerTable = findInnerTable(viewElement);
893
+ if (!innerTable)
894
+ return;
895
+ viewWriter.setStyle('border-collapse', 'collapse', innerTable);
896
+ viewWriter.setStyle('margin', '0', innerTable);
897
+ viewWriter.setStyle('width', '100%', innerTable);
898
+ viewWriter.setStyle('width', '100%', viewElement);
899
+ });
900
+ // Downcast tableColumnWidth to colgroup
901
+ dispatcher.on('attribute:tableColumnWidth:table', (evt, data, conversionApi) => {
902
+ const viewWriter = conversionApi.writer;
903
+ const viewElement = conversionApi.mapper.toViewElement(data.item);
904
+ if (!viewElement)
905
+ return;
906
+ const innerTable = findInnerTable(viewElement);
907
+ if (!innerTable)
908
+ return;
909
+ const columnWidths = data.item.getAttribute('tableColumnWidth');
910
+ if (!columnWidths)
911
+ return;
912
+ // Remove existing colgroup if any
913
+ for (const child of innerTable.getChildren()) {
914
+ if (child.is('element', 'colgroup')) {
915
+ viewWriter.remove(child);
916
+ break;
917
+ }
918
+ }
919
+ // Create new colgroup with col elements
920
+ const colgroup = viewWriter.createContainerElement('colgroup');
921
+ const widths = columnWidths.split(',');
922
+ for (const width of widths) {
923
+ const col = viewWriter.createEmptyElement('col');
924
+ viewWriter.setAttribute('width', width.trim(), col);
925
+ viewWriter.setStyle('width', width.trim(), col);
926
+ viewWriter.insertChild(0, col, colgroup);
927
+ }
928
+ // Insert colgroup as first child of table
929
+ viewWriter.insertChild(0, colgroup, innerTable);
930
+ });
523
931
  });
524
- // Lắng nghe lệnh insertTable để can thiệp ngay sau khi bảng được tạo
932
+ // Lắng nghe lệnh insertTable
525
933
  const insertTableCommand = editor.commands.get('insertTable');
526
934
  if (insertTableCommand) {
527
- // Dùng 'on' event để hook vào sau khi lệnh thực thi
528
- this.listenTo(insertTableCommand, 'execute', (evt, args) => {
935
+ this.listenTo(insertTableCommand, 'execute', () => {
529
936
  editor.model.change(writer => {
530
- // Lấy vị trí con trỏ hiện tại (nơi bảng vừa được chèn)
531
- const selection = editor.model.document.selection;
532
- const position = selection.getFirstPosition();
937
+ const position = editor.model.document.selection.getFirstPosition();
533
938
  if (!position)
534
939
  return;
535
- // Tìm element table vừa chèn (nó nằm ở vị trí cha của selection)
536
- // Khi vừa insert, con trỏ thường nằm trong ô đầu tiên của bảng
537
940
  const tableElement = position.findAncestor('table');
538
941
  if (tableElement) {
539
- // Ép width 100% cho bảng mới vẽ
540
- writer.setAttribute('tableWidth', '100%', tableElement);
942
+ this._applyTableDefaults(writer, tableElement);
943
+ writer.setAttribute('tableBorderColor', '#000000', tableElement);
944
+ writer.setAttribute('tableBorderStyle', 'solid', tableElement);
945
+ writer.setAttribute('tableBorderWidth', '1pt', tableElement);
946
+ this._applyCellBorders(writer, tableElement);
541
947
  }
542
948
  });
543
949
  });
544
950
  }
951
+ // Listen for row/column commands
952
+ const tableCommands = [
953
+ 'insertTableRowAbove',
954
+ 'insertTableRowBelow',
955
+ 'insertTableColumnLeft',
956
+ 'insertTableColumnRight',
957
+ 'resizeTableRow',
958
+ 'resizeTableColumn',
959
+ 'setTableColumnWidth',
960
+ 'tableColumnWidth'
961
+ ];
962
+ tableCommands.forEach(cmdName => {
963
+ const cmd = editor.commands.get(cmdName);
964
+ if (cmd) {
965
+ this.listenTo(cmd, 'execute', () => {
966
+ editor.model.change(writer => {
967
+ const position = editor.model.document.selection.getFirstPosition();
968
+ if (!position)
969
+ return;
970
+ const tableElement = position.findAncestor('table');
971
+ if (tableElement) {
972
+ this._applyTableDefaults(writer, tableElement);
973
+ this._applyCellBorders(writer, tableElement);
974
+ }
975
+ });
976
+ });
977
+ }
978
+ });
979
+ // Setup style preservation on model change
980
+ this._setupStylePreservationOnModelChange();
545
981
  }
546
- }
547
-
548
- /**
549
- * Custom base64 upload adapter plugin for CKEditor 5.
550
- * Converts uploaded images to base64 data URLs instead of uploading to a server.
551
- */
552
- class ImageUploadPlugin extends Plugin {
553
- static get pluginName() {
554
- return 'ImageUploadPlugin';
982
+ /**
983
+ * Parse border style from CSS shorthand (e.g., "1px solid red" or "none")
984
+ */
985
+ _parseBorderStyleFromShorthand(borderValue) {
986
+ if (!borderValue)
987
+ return null;
988
+ const val = borderValue.toLowerCase().trim();
989
+ if (val === 'none' || val === '0')
990
+ return 'none';
991
+ // Parse shorthand: width style color (e.g., "1px solid red")
992
+ const parts = val.split(/\s+/);
993
+ for (const part of parts) {
994
+ if (['none', 'hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'].includes(part)) {
995
+ return part;
996
+ }
997
+ }
998
+ return null;
555
999
  }
556
- init() {
557
- const editor = this.editor;
558
- // Register the custom upload adapter
559
- editor.plugins.get('FileRepository').createUploadAdapter = (loader) => {
560
- return new Base64UploadAdapter(loader);
561
- };
1000
+ /**
1001
+ * Apply default table width
1002
+ */
1003
+ _applyTableDefaults(writer, tableElement) {
1004
+ if (!tableElement)
1005
+ return;
1006
+ writer.setAttribute('tableWidth', '100%', tableElement);
562
1007
  }
563
- }
564
- /**
565
- * Custom upload adapter that converts images to base64 data URLs.
566
- */
1008
+ /**
1009
+ * Apply default borders to all cells in a table
1010
+ * Nếu cell đã border rồi thì bỏ qua
1011
+ */
1012
+ _applyCellBorders(writer, tableElement) {
1013
+ if (!tableElement)
1014
+ return;
1015
+ // Lấy border info từ table để cell kế thừa
1016
+ const tableBorderColor = tableElement.getAttribute('tableBorderColor');
1017
+ const tableBorderStyle = tableElement.getAttribute('tableBorderStyle');
1018
+ const tableBorderWidth = tableElement.getAttribute('tableBorderWidth');
1019
+ for (const row of tableElement.getChildren()) {
1020
+ for (const cell of row.getChildren()) {
1021
+ // Nếu cell đã có border attribute nào rồi thì bỏ qua
1022
+ if (cell.hasAttribute('tableCellBorderStyle') ||
1023
+ cell.hasAttribute('tableCellBorderColor') ||
1024
+ cell.hasAttribute('tableCellBorderWidth')) {
1025
+ continue;
1026
+ }
1027
+ // Áp dụng border từ table hoặc default
1028
+ const inheritedStyle = tableBorderStyle || 'solid';
1029
+ const inheritedColor = tableBorderColor || '#000000';
1030
+ const inheritedWidth = tableBorderWidth || '1pt';
1031
+ writer.setAttribute('tableCellBorderStyle', inheritedStyle, cell);
1032
+ writer.setAttribute('tableCellBorderColor', inheritedColor, cell);
1033
+ writer.setAttribute('tableCellBorderWidth', inheritedWidth, cell);
1034
+ if (!cell.hasAttribute('tableCellPadding')) {
1035
+ writer.setAttribute('tableCellPadding', '0.4em', cell);
1036
+ }
1037
+ }
1038
+ }
1039
+ }
1040
+ /**
1041
+ * Setup listener to preserve cell/table styles when model changes
1042
+ */
1043
+ _setupStylePreservationOnModelChange() {
1044
+ const editor = this.editor;
1045
+ // Use listenTo for proper cleanup via destroy()
1046
+ this.listenTo(editor.model.document, 'change', (evt, batch) => {
1047
+ if (batch?.isLocal === false)
1048
+ return;
1049
+ const changes = editor.model.document.differ.getChanges();
1050
+ const tablesToFix = this._findTablesNeedingFix(changes);
1051
+ if (tablesToFix.size > 0) {
1052
+ editor.model.enqueueChange(() => {
1053
+ editor.model.change(writer => {
1054
+ for (const table of tablesToFix) {
1055
+ this._applyTableDefaults(writer, table);
1056
+ this._applyCellBorders(writer, table);
1057
+ }
1058
+ });
1059
+ });
1060
+ }
1061
+ });
1062
+ }
1063
+ /**
1064
+ * Find tables that need border fixes from model changes
1065
+ */
1066
+ _findTablesNeedingFix(changes) {
1067
+ const tablesToFix = new Set();
1068
+ for (const change of changes) {
1069
+ if (change.type === 'attribute') {
1070
+ const attrKey = change.attributeKey;
1071
+ if (attrKey && (attrKey.includes('table') || attrKey.includes('column') || attrKey.includes('width'))) {
1072
+ const element = change.item;
1073
+ if (element) {
1074
+ const parentTable = this._findParentTable(element);
1075
+ if (parentTable)
1076
+ tablesToFix.add(parentTable);
1077
+ }
1078
+ }
1079
+ }
1080
+ if (change.type === 'insert' && change.position) {
1081
+ const tableElement = change.position.findAncestor?.('table') ||
1082
+ change.position.parent?.findAncestor?.('table');
1083
+ if (tableElement)
1084
+ tablesToFix.add(tableElement);
1085
+ }
1086
+ }
1087
+ return tablesToFix;
1088
+ }
1089
+ /**
1090
+ * Find parent table element
1091
+ */
1092
+ _findParentTable(element) {
1093
+ if (!element)
1094
+ return null;
1095
+ let parent = element;
1096
+ while (parent && !parent.is?.('element', 'table')) {
1097
+ parent = parent.parent;
1098
+ }
1099
+ return parent;
1100
+ }
1101
+ /**
1102
+ * Cleanup listeners when plugin is destroyed
1103
+ * Note: this.listenTo() listeners are automatically cleaned up by super.destroy()
1104
+ */
1105
+ destroy() {
1106
+ super.destroy();
1107
+ }
1108
+ }
1109
+
1110
+ class ImageUploadPlugin extends Plugin {
1111
+ static get pluginName() {
1112
+ return 'ImageUploadPlugin';
1113
+ }
1114
+ init() {
1115
+ const editor = this.editor;
1116
+ editor.plugins.get('FileRepository').createUploadAdapter = (loader) => {
1117
+ return new Base64UploadAdapter(loader);
1118
+ };
1119
+ }
1120
+ }
567
1121
  class Base64UploadAdapter {
568
1122
  loader;
569
1123
  constructor(loader) {
@@ -588,31 +1142,2152 @@ class Base64UploadAdapter {
588
1142
  });
589
1143
  });
590
1144
  }
591
- /**
592
- * Aborts the upload process.
593
- */
594
- abort() {
595
- // For base64 conversion, there's nothing to abort
596
- // This method is required by the UploadAdapter interface
597
- }
1145
+ /**
1146
+ * Aborts the upload process.
1147
+ */
1148
+ abort() {
1149
+ // For base64 conversion, there's nothing to abort
1150
+ // This method is required by the UploadAdapter interface
1151
+ }
1152
+ }
1153
+
1154
+ class ImageCustomPlugin extends Plugin {
1155
+ static get pluginName() {
1156
+ return 'ImageCustomPlugin';
1157
+ }
1158
+ init() {
1159
+ const editor = this.editor;
1160
+ // Thiết lập style mặc định là alignCenter khi chèn ảnh
1161
+ editor.commands.get('imageUpload')?.on('execute', (evt, args) => {
1162
+ // Đặt style mặc định sau khi ảnh được chèn
1163
+ setTimeout(() => {
1164
+ const selection = editor.model.document.selection;
1165
+ const imageElement = selection.getSelectedElement();
1166
+ if (imageElement && (imageElement.name === 'imageBlock' || imageElement.name === 'imageInline')) {
1167
+ const currentStyle = imageElement.getAttribute('imageStyle');
1168
+ // Chỉ đặt mặc định nếu chưa có style nào
1169
+ if (!currentStyle) {
1170
+ editor.model.change(writer => {
1171
+ writer.setAttribute('imageStyle', 'alignCenter', imageElement);
1172
+ });
1173
+ }
1174
+ }
1175
+ }, 0);
1176
+ });
1177
+ // Downcast: Model -> View (HTML output)
1178
+ // CKEditor 5 có 2 loại ảnh: imageBlock và imageInline
1179
+ editor.conversion.for('downcast').add(dispatcher => {
1180
+ // Xử lý ảnh block (được wrap trong figure)
1181
+ dispatcher.on('insert:imageBlock', (evt, data, conversionApi) => {
1182
+ this.handleImageInsert(evt, data, conversionApi);
1183
+ }, { priority: 'low' });
1184
+ // Xử lý ảnh inline
1185
+ dispatcher.on('insert:imageInline', (evt, data, conversionApi) => {
1186
+ this.handleImageInsert(evt, data, conversionApi);
1187
+ }, { priority: 'low' });
1188
+ // Xử lý thay đổi attribute cho cả 2 loại ảnh
1189
+ ['imageBlock', 'imageInline'].forEach(imageType => {
1190
+ // Xử lý thay đổi src
1191
+ dispatcher.on(`attribute:src:${imageType}`, (evt, data, conversionApi) => {
1192
+ this.handleImageAttributeChange(evt, data, conversionApi);
1193
+ }, { priority: 'low' });
1194
+ // Xử lý thay đổi width
1195
+ dispatcher.on(`attribute:width:${imageType}`, (evt, data, conversionApi) => {
1196
+ this.handleImageAttributeChange(evt, data, conversionApi);
1197
+ }, { priority: 'low' });
1198
+ // Xử lý thay đổi height
1199
+ dispatcher.on(`attribute:height:${imageType}`, (evt, data, conversionApi) => {
1200
+ this.handleImageAttributeChange(evt, data, conversionApi);
1201
+ }, { priority: 'low' });
1202
+ // Xử lý thay đổi imageStyle (căn chỉnh) - thêm float inline style
1203
+ dispatcher.on(`attribute:imageStyle:${imageType}`, (evt, data, conversionApi) => {
1204
+ this.handleImageStyleChange(evt, data, conversionApi);
1205
+ }, { priority: 'low' });
1206
+ });
1207
+ });
1208
+ // Xử lý upcast (HTML paste) - xóa aspect-ratio từ HTML đầu vào
1209
+ editor.conversion.for('upcast').add(dispatcher => {
1210
+ dispatcher.on('element:img', (evt, data, conversionApi) => {
1211
+ const viewItem = data.viewItem;
1212
+ if (!viewItem)
1213
+ return;
1214
+ // Kiểm tra viewItem có các method cần thiết
1215
+ if (typeof viewItem.getStyle !== 'function')
1216
+ return;
1217
+ // Xóa aspect-ratio từ inline styles nếu có
1218
+ const hasAspectRatio = viewItem.getStyle('aspect-ratio');
1219
+ if (hasAspectRatio && typeof viewItem.removeStyle === 'function') {
1220
+ viewItem.removeStyle('aspect-ratio');
1221
+ }
1222
+ // Đặt custom styles nếu _styles map tồn tại
1223
+ if (viewItem._styles && typeof viewItem._styles.set === 'function') {
1224
+ viewItem._styles.set('margin', '0');
1225
+ viewItem._styles.set('border', '0');
1226
+ viewItem._styles.set('max-width', '100%');
1227
+ viewItem._styles.set('height', 'auto');
1228
+ }
1229
+ }, { priority: 'high' });
1230
+ });
1231
+ }
1232
+ /**
1233
+ * Xử lý sự kiện chèn ảnh
1234
+ */
1235
+ handleImageInsert(evt, data, conversionApi) {
1236
+ const viewWriter = conversionApi.writer;
1237
+ const viewElement = conversionApi.mapper.toViewElement(data.item);
1238
+ if (!viewElement)
1239
+ return;
1240
+ // viewElement có thể là figure (cho block) hoặc img itself (cho inline)
1241
+ // Tìm element img thực tế
1242
+ const imgElement = this.findImgElement(viewElement);
1243
+ if (!imgElement)
1244
+ return;
1245
+ this.applyCustomStyles(viewWriter, imgElement);
1246
+ }
1247
+ /**
1248
+ * Xử lý sự kiện thay đổi attribute ảnh
1249
+ */
1250
+ handleImageAttributeChange(evt, data, conversionApi) {
1251
+ const viewWriter = conversionApi.writer;
1252
+ const viewElement = conversionApi.mapper.toViewElement(data.item);
1253
+ if (!viewElement)
1254
+ return;
1255
+ const imgElement = this.findImgElement(viewElement);
1256
+ if (!imgElement)
1257
+ return;
1258
+ this.applyCustomStyles(viewWriter, imgElement);
1259
+ }
1260
+ /**
1261
+ * Xử lý sự kiện thay đổi style ảnh - thêm float inline style cho căn chỉnh
1262
+ */
1263
+ handleImageStyleChange(evt, data, conversionApi) {
1264
+ const viewWriter = conversionApi.writer;
1265
+ const viewElement = conversionApi.mapper.toViewElement(data.item);
1266
+ if (!viewElement)
1267
+ return;
1268
+ // Tìm container ck-widget (element figure)
1269
+ const widgetElement = this.findWidgetElement(viewElement);
1270
+ if (!widgetElement)
1271
+ return;
1272
+ // Lấy giá trị imageStyle (căn chỉnh)
1273
+ const imageStyle = data.item.getAttribute('imageStyle');
1274
+ // Xóa các style hiện có trước
1275
+ viewWriter.removeStyle('float', widgetElement);
1276
+ viewWriter.removeStyle('margin', widgetElement);
1277
+ viewWriter.removeStyle('text-align', widgetElement);
1278
+ // Áp dụng style dựa trên căn chỉnh ảnh
1279
+ // Các options đã cấu hình: ['inline', 'alignLeft', 'alignRight', 'alignCenter']
1280
+ switch (imageStyle) {
1281
+ case 'inline':
1282
+ // Ảnh inline - không style đặc biệt, chỉ flow inline
1283
+ break;
1284
+ case 'alignLeft':
1285
+ // Float trái
1286
+ viewWriter.setStyle('float', 'left', widgetElement);
1287
+ viewWriter.setStyle('margin', '0 16px 16px 0', widgetElement);
1288
+ break;
1289
+ case 'alignRight':
1290
+ // Float phải
1291
+ viewWriter.setStyle('float', 'right', widgetElement);
1292
+ viewWriter.setStyle('margin', '0 0 16px 16px', widgetElement);
1293
+ break;
1294
+ case 'alignCenter':
1295
+ case 'block':
1296
+ // Căn giữa
1297
+ viewWriter.setStyle('text-align', 'center', widgetElement);
1298
+ viewWriter.setStyle('margin', '16px auto', widgetElement);
1299
+ break;
1300
+ default:
1301
+ // Mặc định - căn giữa
1302
+ viewWriter.setStyle('text-align', 'center', widgetElement);
1303
+ viewWriter.setStyle('margin', '16px auto', widgetElement);
1304
+ break;
1305
+ }
1306
+ // Áp dụng custom styles cơ bản cho element img
1307
+ const imgElement = this.findImgElement(viewElement);
1308
+ if (imgElement) {
1309
+ this.applyCustomStyles(viewWriter, imgElement);
1310
+ }
1311
+ }
1312
+ /**
1313
+ * Áp dụng custom styles cho element ảnh
1314
+ */
1315
+ applyCustomStyles(viewWriter, imgElement) {
1316
+ // Xóa aspect-ratio nếu tồn tại
1317
+ if (imgElement.getStyle('aspect-ratio')) {
1318
+ viewWriter.removeStyle('aspect-ratio', imgElement);
1319
+ }
1320
+ // Áp dụng custom styles
1321
+ viewWriter.setStyle('margin', '0', imgElement);
1322
+ viewWriter.setStyle('border', '0', imgElement);
1323
+ viewWriter.setStyle('max-width', '100%', imgElement);
1324
+ viewWriter.setStyle('height', 'auto', imgElement);
1325
+ }
1326
+ /**
1327
+ * Tìm container ck-widget (element figure)
1328
+ * CKEditor wrap ảnh block trong <figure class="ck-widget"><img></figure>
1329
+ */
1330
+ findWidgetElement(viewElement) {
1331
+ if (!viewElement)
1332
+ return null;
1333
+ // Nếu đây là element figure itself
1334
+ if (viewElement.name === 'figure') {
1335
+ return viewElement;
1336
+ }
1337
+ // Cho ảnh inline, trả về element itself (span wrapper)
1338
+ if (viewElement.name === 'span') {
1339
+ return viewElement;
1340
+ }
1341
+ // Tìm ngược lên tree để tìm figure/ck-widget
1342
+ let current = viewElement;
1343
+ while (current) {
1344
+ if (current.name === 'figure' || current.name === 'span') {
1345
+ return current;
1346
+ }
1347
+ // Di chuyển lên parent
1348
+ if (current.parent) {
1349
+ current = current.parent;
1350
+ }
1351
+ else {
1352
+ break;
1353
+ }
1354
+ }
1355
+ // Nếu không tìm thấy widget, trả về element gốc
1356
+ return viewElement;
1357
+ }
1358
+ /**
1359
+ * Tìm element img thực tế bên trong widget structure
1360
+ * CKEditor wrap ảnh block trong <figure class="ck-widget"><img></figure>
1361
+ */
1362
+ findImgElement(viewElement) {
1363
+ if (!viewElement)
1364
+ return null;
1365
+ // Nếu đây là element img itself
1366
+ if (viewElement.name === 'img') {
1367
+ return viewElement;
1368
+ }
1369
+ // Cho structure widget của CKEditor, tìm đệ quy
1370
+ // Ảnh block: figure > span > img
1371
+ // Ảnh thường được wrap trong một container
1372
+ const queue = [viewElement];
1373
+ while (queue.length > 0) {
1374
+ const current = queue.shift();
1375
+ if (!current)
1376
+ continue;
1377
+ if (current.name === 'img') {
1378
+ return current;
1379
+ }
1380
+ // Thêm children vào queue
1381
+ if (current.getChildren) {
1382
+ for (const child of current.getChildren()) {
1383
+ queue.push(child);
1384
+ }
1385
+ }
1386
+ }
1387
+ return null;
1388
+ }
1389
+ }
1390
+
1391
+ // Icon khổ dọc (Mặc định cũ)
1392
+ 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>';
1393
+ // Icon khổ ngang (Mới)
1394
+ 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>';
1395
+ class PageOrientation extends Plugin {
1396
+ static pluginName = 'PageOrientation';
1397
+ _currentOrientation = 'PORTRAIT';
1398
+ orientationChangeEmitter;
1399
+ buttonView;
1400
+ init() {
1401
+ const editor = this.editor;
1402
+ const componentFactory = editor.ui.componentFactory;
1403
+ // Đăng ký nút tên là 'pageOrientation'
1404
+ componentFactory.add('pageOrientation', locale => {
1405
+ const view = new ButtonView(locale);
1406
+ this.buttonView = view;
1407
+ view.set({
1408
+ // label: 'Xoay giấy (A4)',
1409
+ icon: ICON_PORTRAIT,
1410
+ // tooltip: true,
1411
+ // withText: true,
1412
+ class: 'btn-orientation', // Class để style nếu cần
1413
+ });
1414
+ // Xử lý khi bấm nút
1415
+ view.on('execute', () => {
1416
+ this.toggleOrientation();
1417
+ });
1418
+ return view;
1419
+ });
1420
+ }
1421
+ /**
1422
+ * Toggle between portrait and landscape orientation
1423
+ */
1424
+ toggleOrientation() {
1425
+ const newOrientation = this._currentOrientation === 'PORTRAIT' ? 'LANDSCAPE' : 'PORTRAIT';
1426
+ this.setOrientation(newOrientation);
1427
+ }
1428
+ /**
1429
+ * Set orientation programmatically
1430
+ */
1431
+ setOrientation(orientation) {
1432
+ const editor = this.editor;
1433
+ const editingView = editor.editing.view;
1434
+ const rootElement = editingView.document.getRoot();
1435
+ editor.editing.view.change(writer => {
1436
+ if (orientation === 'LANDSCAPE') {
1437
+ writer.addClass('landscape', rootElement);
1438
+ }
1439
+ else {
1440
+ writer.removeClass('landscape', rootElement);
1441
+ }
1442
+ });
1443
+ // Update button icon
1444
+ if (this.buttonView) {
1445
+ this.buttonView.icon = orientation === 'LANDSCAPE' ? ICON_LANDSCAPE : ICON_PORTRAIT;
1446
+ }
1447
+ this._currentOrientation = orientation;
1448
+ this.orientationChangeEmitter?.(orientation);
1449
+ }
1450
+ /**
1451
+ * Get current orientation
1452
+ */
1453
+ getOrientation() {
1454
+ return this._currentOrientation;
1455
+ }
1456
+ /**
1457
+ * Register callback for orientation changes
1458
+ */
1459
+ onOrientationChange(callback) {
1460
+ this.orientationChangeEmitter = callback;
1461
+ }
1462
+ }
1463
+
1464
+ /**
1465
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1466
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
1467
+ */
1468
+ /**
1469
+ * Transforms `<a>` elements which are bookmarks by moving their children after the element.
1470
+ *
1471
+ * @internal
1472
+ */
1473
+ function transformBookmarks(documentFragment, writer) {
1474
+ const elementsToChange = [];
1475
+ for (const value of writer.createRangeIn(documentFragment)) {
1476
+ const element = value.item;
1477
+ if (element.is('element', 'a') &&
1478
+ !element.hasAttribute('href') &&
1479
+ (element.hasAttribute('id') || element.hasAttribute('name'))) {
1480
+ elementsToChange.push(element);
1481
+ }
1482
+ }
1483
+ for (const element of elementsToChange) {
1484
+ const index = element.parent.getChildIndex(element) + 1;
1485
+ const children = element.getChildren();
1486
+ writer.insertChild(index, children, element.parent);
1487
+ }
1488
+ }
1489
+
1490
+ /**
1491
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1492
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
1493
+ */
1494
+ /**
1495
+ * @module paste-from-office/filters/image
1496
+ */
1497
+ /**
1498
+ * Replaces source attribute of all `<img>` elements representing regular
1499
+ * images (not the Word shapes) with inlined base64 image representation extracted from RTF or Blob data.
1500
+ *
1501
+ * @param documentFragment Document fragment on which transform images.
1502
+ * @param rtfData The RTF data from which images representation will be used.
1503
+ * @internal
1504
+ */
1505
+ function replaceImagesSourceWithBase64(documentFragment, rtfData) {
1506
+ if (!documentFragment.childCount) {
1507
+ return;
1508
+ }
1509
+ const upcastWriter = new ViewUpcastWriter(documentFragment.document);
1510
+ const shapesIds = findAllShapesIds(documentFragment, upcastWriter);
1511
+ removeAllImgElementsRepresentingShapes(shapesIds, documentFragment, upcastWriter);
1512
+ insertMissingImgs(shapesIds, documentFragment, upcastWriter);
1513
+ removeAllShapeElements(documentFragment, upcastWriter);
1514
+ const images = findAllImageElementsWithLocalSource(documentFragment, upcastWriter);
1515
+ if (images.length) {
1516
+ replaceImagesFileSourceWithInlineRepresentation(images, extractImageDataFromRtf(rtfData), upcastWriter);
1517
+ }
1518
+ }
1519
+ /**
1520
+ * Converts given HEX string to base64 representation.
1521
+ *
1522
+ * @internal
1523
+ * @param hexString The HEX string to be converted.
1524
+ * @returns Base64 representation of a given HEX string.
1525
+ */
1526
+ function _convertHexToBase64(hexString) {
1527
+ return btoa(hexString
1528
+ .match(/\w{2}/g)
1529
+ .map(char => {
1530
+ return String.fromCharCode(parseInt(char, 16));
1531
+ })
1532
+ .join(''));
1533
+ }
1534
+ /**
1535
+ * Finds all shapes (`<v:*>...</v:*>`) ids. Shapes can represent images (canvas)
1536
+ * or Word shapes (which does not have RTF or Blob representation).
1537
+ *
1538
+ * @param documentFragment Document fragment from which to extract shape ids.
1539
+ * @returns Array of shape ids.
1540
+ */
1541
+ function findAllShapesIds(documentFragment, writer) {
1542
+ const range = writer.createRangeIn(documentFragment);
1543
+ const shapeElementsMatcher = new Matcher({
1544
+ name: /v:(.+)/,
1545
+ });
1546
+ const shapesIds = [];
1547
+ for (const value of range) {
1548
+ if (value.type != 'elementStart') {
1549
+ continue;
1550
+ }
1551
+ const el = value.item;
1552
+ const previousSibling = el.previousSibling;
1553
+ const prevSiblingName = previousSibling && previousSibling.is('element') ? previousSibling.name : null;
1554
+ // List of ids which should not be considered as shapes.
1555
+ // https://github.com/ckeditor/ckeditor5/pull/15847#issuecomment-1941543983
1556
+ const exceptionIds = ['Chart'];
1557
+ const isElementAShape = shapeElementsMatcher.match(el);
1558
+ const hasElementGfxdataAttribute = el.getAttribute('o:gfxdata');
1559
+ const isPreviousSiblingAShapeType = prevSiblingName === 'v:shapetype';
1560
+ const isElementIdInExceptionsArray = hasElementGfxdataAttribute && exceptionIds.some(item => el.getAttribute('id').includes(item));
1561
+ // If shape element has 'o:gfxdata' attribute and is not directly before
1562
+ // `<v:shapetype>` element it means that it represents a Word shape.
1563
+ if (isElementAShape && hasElementGfxdataAttribute && !isPreviousSiblingAShapeType && !isElementIdInExceptionsArray) {
1564
+ shapesIds.push(value.item.getAttribute('id'));
1565
+ }
1566
+ }
1567
+ return shapesIds;
1568
+ }
1569
+ /**
1570
+ * Removes all `<img>` elements which represents Word shapes and not regular images.
1571
+ *
1572
+ * @param shapesIds Shape ids which will be checked against `<img>` elements.
1573
+ * @param documentFragment Document fragment from which to remove `<img>` elements.
1574
+ */
1575
+ function removeAllImgElementsRepresentingShapes(shapesIds, documentFragment, writer) {
1576
+ const range = writer.createRangeIn(documentFragment);
1577
+ const imageElementsMatcher = new Matcher({
1578
+ name: 'img',
1579
+ });
1580
+ const imgs = [];
1581
+ for (const value of range) {
1582
+ if (value.item.is('element') && imageElementsMatcher.match(value.item)) {
1583
+ const el = value.item;
1584
+ const shapes = el.getAttribute('v:shapes') ? el.getAttribute('v:shapes').split(' ') : [];
1585
+ if (shapes.length && shapes.every(shape => shapesIds.indexOf(shape) > -1)) {
1586
+ imgs.push(el);
1587
+ // Shapes may also have empty source while content is paste in some browsers (Safari).
1588
+ }
1589
+ else if (!el.getAttribute('src')) {
1590
+ imgs.push(el);
1591
+ }
1592
+ }
1593
+ }
1594
+ for (const img of imgs) {
1595
+ writer.remove(img);
1596
+ }
1597
+ }
1598
+ /**
1599
+ * Removes all shape elements (`<v:*>...</v:*>`) so they do not pollute the output structure.
1600
+ *
1601
+ * @param documentFragment Document fragment from which to remove shape elements.
1602
+ */
1603
+ function removeAllShapeElements(documentFragment, writer) {
1604
+ const range = writer.createRangeIn(documentFragment);
1605
+ const shapeElementsMatcher = new Matcher({
1606
+ name: /v:(.+)/,
1607
+ });
1608
+ const shapes = [];
1609
+ for (const value of range) {
1610
+ if (value.type == 'elementStart' && shapeElementsMatcher.match(value.item)) {
1611
+ shapes.push(value.item);
1612
+ }
1613
+ }
1614
+ for (const shape of shapes) {
1615
+ writer.remove(shape);
1616
+ }
1617
+ }
1618
+ /**
1619
+ * Inserts `img` tags if there is none after a shape.
1620
+ */
1621
+ function insertMissingImgs(shapeIds, documentFragment, writer) {
1622
+ const range = writer.createRangeIn(documentFragment);
1623
+ const shapes = [];
1624
+ for (const value of range) {
1625
+ if (value.type == 'elementStart' && value.item.is('element', 'v:shape')) {
1626
+ const id = value.item.getAttribute('id');
1627
+ if (shapeIds.includes(id)) {
1628
+ continue;
1629
+ }
1630
+ if (!containsMatchingImg(value.item.parent.getChildren(), id)) {
1631
+ shapes.push(value.item);
1632
+ }
1633
+ }
1634
+ }
1635
+ for (const shape of shapes) {
1636
+ const attrs = {
1637
+ src: findSrc(shape),
1638
+ };
1639
+ if (shape.hasAttribute('alt')) {
1640
+ attrs['alt'] = shape.getAttribute('alt');
1641
+ }
1642
+ const img = writer.createElement('img', attrs);
1643
+ writer.insertChild(shape.index + 1, img, shape.parent);
1644
+ }
1645
+ function containsMatchingImg(nodes, id) {
1646
+ for (const node of nodes) {
1647
+ /* istanbul ignore else -- @preserve */
1648
+ if (node.is('element')) {
1649
+ if (node.name == 'img' && node.getAttribute('v:shapes') == id) {
1650
+ return true;
1651
+ }
1652
+ if (containsMatchingImg(node.getChildren(), id)) {
1653
+ return true;
1654
+ }
1655
+ }
1656
+ }
1657
+ return false;
1658
+ }
1659
+ function findSrc(shape) {
1660
+ for (const child of shape.getChildren()) {
1661
+ /* istanbul ignore else -- @preserve */
1662
+ if (child.is('element') && child.getAttribute('src')) {
1663
+ return child.getAttribute('src');
1664
+ }
1665
+ }
1666
+ return '';
1667
+ }
1668
+ }
1669
+ /**
1670
+ * Finds all `<img>` elements in a given document fragment which have source pointing to local `file://` resource.
1671
+ * This function also tracks the index position of each image in the document, which is essential for
1672
+ * precise matching with hexadecimal representations in RTF data.
1673
+ *
1674
+ * @param documentFragment Document fragment in which to look for `<img>` elements.
1675
+ * @returns Array of found images along with their position index in the document.
1676
+ */
1677
+ function findAllImageElementsWithLocalSource(documentFragment, writer) {
1678
+ const range = writer.createRangeIn(documentFragment);
1679
+ const imageElementsMatcher = new Matcher({
1680
+ name: 'img',
1681
+ });
1682
+ const imgs = [];
1683
+ let currentImageIndex = 0;
1684
+ for (const value of range) {
1685
+ if (value.item.is('element') && imageElementsMatcher.match(value.item)) {
1686
+ if (value.item.getAttribute('src').startsWith('file://')) {
1687
+ imgs.push({
1688
+ element: value.item,
1689
+ imageIndex: currentImageIndex,
1690
+ });
1691
+ }
1692
+ currentImageIndex++;
1693
+ }
1694
+ }
1695
+ return imgs;
1696
+ }
1697
+ /**
1698
+ * Extracts all images HEX representations from a given RTF data.
1699
+ *
1700
+ * @param rtfData The RTF data from which to extract images HEX representation.
1701
+ * @returns Array of found HEX representations. Each array item is an object containing:
1702
+ *
1703
+ * * hex Image representation in HEX format.
1704
+ * * type Type of image, `image/png` or `image/jpeg`.
1705
+ */
1706
+ function extractImageDataFromRtf(rtfData) {
1707
+ if (!rtfData) {
1708
+ return [];
1709
+ }
1710
+ const regexPictureHeader = /{\\pict[\s\S]+?\\bliptag-?\d+(\\blipupi-?\d+)?({\\\*\\blipuid\s?[\da-fA-F]+)?[\s}]*?/;
1711
+ const regexPicture = new RegExp('(?:(' + regexPictureHeader.source + '))([\\da-fA-F\\s]+)\\}', 'g');
1712
+ const images = rtfData.match(regexPicture);
1713
+ const result = [];
1714
+ if (images) {
1715
+ for (const image of images) {
1716
+ let imageType = false;
1717
+ if (image.includes('\\pngblip')) {
1718
+ imageType = 'image/png';
1719
+ }
1720
+ else if (image.includes('\\jpegblip')) {
1721
+ imageType = 'image/jpeg';
1722
+ }
1723
+ if (imageType) {
1724
+ result.push({
1725
+ hex: image.replace(regexPictureHeader, '').replace(/[^\da-fA-F]/g, ''),
1726
+ type: imageType,
1727
+ });
1728
+ }
1729
+ }
1730
+ }
1731
+ return result;
1732
+ }
1733
+ /**
1734
+ * Replaces `src` attribute value of all given images with the corresponding base64 image representation.
1735
+ * Uses the image index to precisely match with the correct hexadecimal representation from RTF data.
1736
+ *
1737
+ * @param imageElements Array of image elements along with their indices which will have their sources replaced.
1738
+ * @param imagesHexSources Array of images hex sources (usually the result of `extractImageDataFromRtf()` function).
1739
+ * Contains hexadecimal representations of ALL images in the document, not just those with `file://` URLs.
1740
+ * In XML documents, the same image might be defined both as base64 in HTML and as hexadecimal in RTF data.
1741
+ */
1742
+ function replaceImagesFileSourceWithInlineRepresentation(imageElements, imagesHexSources, writer) {
1743
+ for (let i = 0; i < imageElements.length; i++) {
1744
+ const { element, imageIndex } = imageElements[i];
1745
+ const rtfHexSource = imagesHexSources[imageIndex];
1746
+ if (rtfHexSource) {
1747
+ const newSrc = `data:${rtfHexSource.type};base64,${_convertHexToBase64(rtfHexSource.hex)}`;
1748
+ writer.setAttribute('src', newSrc, element);
1749
+ }
1750
+ }
1751
+ }
1752
+
1753
+ /**
1754
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1755
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
1756
+ */
1757
+ /**
1758
+ * @module paste-from-office/filters/removemsattributes
1759
+ */
1760
+ /**
1761
+ * Cleanup MS attributes like styles, attributes and elements.
1762
+ *
1763
+ * @param documentFragment element `data.content` obtained from clipboard.
1764
+ * @internal
1765
+ */
1766
+ function removeMSAttributes(documentFragment) {
1767
+ const elementsToUnwrap = [];
1768
+ const writer = new ViewUpcastWriter(documentFragment.document);
1769
+ for (const { item } of writer.createRangeIn(documentFragment)) {
1770
+ if (!item.is('element')) {
1771
+ continue;
1772
+ }
1773
+ for (const className of item.getClassNames()) {
1774
+ if (/\bmso/gi.exec(className)) {
1775
+ writer.removeClass(className, item);
1776
+ }
1777
+ }
1778
+ for (const styleName of item.getStyleNames()) {
1779
+ if (/\bmso/gi.exec(styleName)) {
1780
+ writer.removeStyle(styleName, item);
1781
+ }
1782
+ }
1783
+ if (item.is('element', 'w:sdt') ||
1784
+ item.is('element', 'w:sdtpr') && item.isEmpty ||
1785
+ item.is('element', 'o:p') && item.isEmpty) {
1786
+ elementsToUnwrap.push(item);
1787
+ }
1788
+ }
1789
+ for (const item of elementsToUnwrap) {
1790
+ const itemParent = item.parent;
1791
+ const childIndex = itemParent.getChildIndex(item);
1792
+ writer.insertChild(childIndex, item.getChildren(), itemParent);
1793
+ writer.remove(item);
1794
+ }
1795
+ }
1796
+
1797
+ /**
1798
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1799
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
1800
+ */
1801
+ /**
1802
+ * @module paste-from-office/filters/utils
1803
+ */
1804
+ /**
1805
+ * Normalizes CSS length value to 'px'.
1806
+ *
1807
+ * @internal
1808
+ */
1809
+ function convertCssLengthToPx(value) {
1810
+ const numericValue = parseFloat(value);
1811
+ if (value.endsWith('pt')) {
1812
+ // 1pt = 1in / 72
1813
+ return toPx(numericValue * 96 / 72);
1814
+ }
1815
+ else if (value.endsWith('pc')) {
1816
+ // 1pc = 12pt = 1in / 6.
1817
+ return toPx(numericValue * 12 * 96 / 72);
1818
+ }
1819
+ else if (value.endsWith('in')) {
1820
+ // 1in = 2.54cm = 96px
1821
+ return toPx(numericValue * 96);
1822
+ }
1823
+ else if (value.endsWith('cm')) {
1824
+ // 1cm = 96px / 2.54
1825
+ return toPx(numericValue * 96 / 2.54);
1826
+ }
1827
+ else if (value.endsWith('mm')) {
1828
+ // 1mm = 1cm / 10
1829
+ return toPx(numericValue / 10 * 96 / 2.54);
1830
+ }
1831
+ return value;
1832
+ }
1833
+ /**
1834
+ * Returns true for value with 'px' unit.
1835
+ *
1836
+ * @internal
1837
+ */
1838
+ function isPx(value) {
1839
+ return value !== undefined && value.endsWith('px');
1840
+ }
1841
+ /**
1842
+ * Returns a rounded 'px' value.
1843
+ *
1844
+ * @internal
1845
+ */
1846
+ function toPx(value) {
1847
+ return Math.round(value) + 'px';
1848
+ }
1849
+
1850
+ /**
1851
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1852
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
1853
+ */
1854
+ /**
1855
+ * Applies border none for table and cells without a border specified.
1856
+ * Normalizes style length units to px.
1857
+ * Handles left block table alignment.
1858
+ *
1859
+ * @internal
1860
+ */
1861
+ function transformTables(documentFragment, writer, hasTablePropertiesPlugin = false, hasExtendedTableBlockAlignment = false) {
1862
+ for (const item of writer.createRangeIn(documentFragment).getItems()) {
1863
+ if (!item.is('element', 'table') && !item.is('element', 'td') && !item.is('element', 'th')) {
1864
+ continue;
1865
+ }
1866
+ // In MS Word, left-aligned tables (default) have no align attribute on the `<table>` and are not wrapped in a `<div>`.
1867
+ // In such cases, we need to set `margin-left: 0` and `margin-right: auto` to indicate to the editor that
1868
+ // the table is block-aligned to the left.
1869
+ //
1870
+ // Center- and right-aligned tables in MS Word are wrapped in a `<div>` with the `align` attribute set to
1871
+ // `center` or `right`, respectively with no align attribute on the `<table>` itself.
1872
+ //
1873
+ // Additionally, the structure may change when pasting content from MS Word.
1874
+ // Some browsers (e.g., Safari) may insert extra elements around the table (e.g., a <span>),
1875
+ // so the surrounding `<div>` with the `align` attribute may end up being the table's grandparent.
1876
+ if (hasTablePropertiesPlugin && hasExtendedTableBlockAlignment && item.is('element', 'table')) {
1877
+ const directParent = item.parent?.is('element', 'div') ? item.parent : null;
1878
+ const grandParent = item.parent?.parent?.is('element', 'div') ? item.parent.parent : null;
1879
+ const divParent = directParent ?? grandParent;
1880
+ // Center block table alignment.
1881
+ if (divParent && divParent.getAttribute('align') === 'center' && !item.getAttribute('align')) {
1882
+ writer.setStyle('margin-left', 'auto', item);
1883
+ writer.setStyle('margin-right', 'auto', item);
1884
+ }
1885
+ // Right block table alignment.
1886
+ else if (divParent && divParent.getAttribute('align') === 'right' && !item.getAttribute('align')) {
1887
+ writer.setStyle('margin-left', 'auto', item);
1888
+ writer.setStyle('margin-right', '0', item);
1889
+ }
1890
+ // Left block table alignment.
1891
+ else if (!divParent && !item.getAttribute('align')) {
1892
+ writer.setStyle('margin-left', '0', item);
1893
+ writer.setStyle('margin-right', 'auto', item);
1894
+ }
1895
+ }
1896
+ const sides = ['left', 'top', 'right', 'bottom'];
1897
+ // As this is a pasted table, we do not want default table styles to apply here
1898
+ // so we set border node for sides that does not have any border style.
1899
+ // It is enough to verify border style as border color and border width properties have default values in DOM.
1900
+ if (sides.every(side => !item.hasStyle(`border-${side}-style`))) {
1901
+ writer.setStyle('border-style', 'none', item);
1902
+ }
1903
+ else {
1904
+ for (const side of sides) {
1905
+ if (!item.hasStyle(`border-${side}-style`)) {
1906
+ writer.setStyle(`border-${side}-style`, 'none', item);
1907
+ }
1908
+ }
1909
+ }
1910
+ // Translate style length units to px.
1911
+ const props = ['width', 'height', ...sides.map(side => `border-${side}-width`), ...sides.map(side => `padding-${side}`)];
1912
+ for (const prop of props) {
1913
+ if (item.hasStyle(prop)) {
1914
+ writer.setStyle(prop, convertCssLengthToPx(item.getStyle(prop)), item);
1915
+ }
1916
+ }
1917
+ }
1918
+ }
1919
+
1920
+ /**
1921
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1922
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
1923
+ */
1924
+ /**
1925
+ * Removes the `width:0px` style from table pasted from Google Sheets and `width="0"` attribute from Word tables.
1926
+ *
1927
+ * @param documentFragment element `data.content` obtained from clipboard
1928
+ * @internal
1929
+ */
1930
+ function removeInvalidTableWidth(documentFragment, writer) {
1931
+ for (const child of writer.createRangeIn(documentFragment).getItems()) {
1932
+ if (child.is('element', 'table')) {
1933
+ // Remove invalid width style (Google Sheets: width:0px).
1934
+ if (child.getStyle('width') === '0px') {
1935
+ writer.removeStyle('width', child);
1936
+ }
1937
+ // Remove invalid width attribute (Word: width="0").
1938
+ if (child.getAttribute('width') === '0') {
1939
+ writer.removeAttribute('width', child);
1940
+ }
1941
+ }
1942
+ }
1943
+ }
1944
+
1945
+ /**
1946
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
1947
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
1948
+ */
1949
+ /**
1950
+ * Replaces MS Word specific footnotes references and definitions with proper elements.
1951
+ *
1952
+ * Things to know about MS Word footnotes:
1953
+ *
1954
+ * * Footnote references in Word are marked with `mso-footnote-id` style.
1955
+ * * Word does not support nested footnotes, so references within definitions are ignored.
1956
+ * * Word appends extra spaces after footnote references within definitions, which are trimmed.
1957
+ * * Footnote definitions list is marked with `mso-element: footnote-list` style it contain `mso-element: footnote` elements.
1958
+ * * Footnote definition might contain tables, lists and other elements, not only text. They are placed directly within `li` element,
1959
+ * without any wrapper (in opposition to text content of the definition, which is placed within `MsoFootnoteText` element).
1960
+ *
1961
+ * Example pseudo document showing MS Word footnote structure:
1962
+ *
1963
+ * ```html
1964
+ * <p>Text with footnote<a style='mso-footnote-id:ftn1'>[1]</a> reference.</p>
1965
+ *
1966
+ * <div style='mso-element:footnote-list'>
1967
+ * <div style='mso-element:footnote' id=ftn1>
1968
+ * <p class=MsoFootnoteText><a style='mso-footnote-id:ftn1'>[1]</a> Footnote content</p>
1969
+ * <table class="MsoTableGrid">...</table>
1970
+ * </div>
1971
+ * </div>
1972
+ * ```
1973
+ *
1974
+ * Will be transformed into:
1975
+ *
1976
+ * ```html
1977
+ * <p>Text with footnote<sup class="footnote"><a id="ref-footnote-ftn1" href="#footnote-ftn1">1</a></sup> reference.</p>
1978
+ *
1979
+ * <ol class="footnotes">
1980
+ * <li class="footnote-definition" id="footnote-ftn1">
1981
+ * <a href="#ref-footnote-ftn1" class="footnote-backlink">^</a>
1982
+ * <div class="footnote-content">
1983
+ * <p>Footnote content</p>
1984
+ * <table>...</table>
1985
+ * </div>
1986
+ * </li>
1987
+ * </ol>
1988
+ * ```
1989
+ *
1990
+ * @param documentFragment `data.content` obtained from clipboard.
1991
+ * @param writer The view writer instance.
1992
+ * @internal
1993
+ */
1994
+ function replaceMSFootnotes(documentFragment, writer) {
1995
+ const msFootnotesRefs = new Map();
1996
+ const msFootnotesDefs = new Map();
1997
+ let msFootnotesDefinitionsList = null;
1998
+ // Phase 1: Collect all footnotes references and definitions. Find the footnotes definitions list element.
1999
+ for (const { item } of writer.createRangeIn(documentFragment)) {
2000
+ if (!item.is('element')) {
2001
+ continue;
2002
+ }
2003
+ // If spot a footnotes definitions element, let's store it. It'll be replaced later.
2004
+ // There should be only one such element in the document.
2005
+ if (item.getStyle('mso-element') === 'footnote-list') {
2006
+ msFootnotesDefinitionsList = item;
2007
+ continue;
2008
+ }
2009
+ // If spot a footnote reference or definition, store it in the corresponding map.
2010
+ if (item.hasStyle('mso-footnote-id')) {
2011
+ const msFootnoteDef = item.findAncestor('element', el => el.getStyle('mso-element') === 'footnote');
2012
+ if (msFootnoteDef) {
2013
+ // If it's a reference within a definition, ignore it and track only the definition.
2014
+ // MS Word do not support nested footnotes, so it's safe to assume that all references within
2015
+ // a definition point to the same definition.
2016
+ const msFootnoteDefId = msFootnoteDef.getAttribute('id');
2017
+ msFootnotesDefs.set(msFootnoteDefId, msFootnoteDef);
2018
+ }
2019
+ else {
2020
+ // If it's a reference outside of a definition, track it as a reference.
2021
+ const msFootnoteRefId = item.getStyle('mso-footnote-id');
2022
+ msFootnotesRefs.set(msFootnoteRefId, item);
2023
+ }
2024
+ continue;
2025
+ }
2026
+ }
2027
+ // If there are no footnotes references or definitions, or no definitions list, there's nothing to normalize.
2028
+ if (!msFootnotesRefs.size || !msFootnotesDefinitionsList) {
2029
+ return;
2030
+ }
2031
+ // Phase 2: Replace footnotes definitions list with proper element.
2032
+ const footnotesDefinitionsList = createFootnotesListViewElement(writer);
2033
+ writer.replace(msFootnotesDefinitionsList, footnotesDefinitionsList);
2034
+ // Phase 3: Replace all footnotes references and add matching definitions to the definitions list.
2035
+ for (const [footnoteId, msFootnoteRef] of msFootnotesRefs) {
2036
+ const msFootnoteDef = msFootnotesDefs.get(footnoteId);
2037
+ if (!msFootnoteDef) {
2038
+ continue;
2039
+ }
2040
+ // Replace footnote reference.
2041
+ writer.replace(msFootnoteRef, createFootnoteRefViewElement(writer, footnoteId));
2042
+ // Append found matching definition to the definitions list.
2043
+ // Order doesn't matter here, as it'll be fixed in the post-fixer.
2044
+ const defElements = createFootnoteDefViewElement(writer, footnoteId);
2045
+ removeMSReferences(writer, msFootnoteDef);
2046
+ // Insert content within the `MsoFootnoteText` element. It's usually a definition text content.
2047
+ for (const child of msFootnoteDef.getChildren()) {
2048
+ let clonedChild = child;
2049
+ if (child.is('element')) {
2050
+ clonedChild = writer.clone(child, true);
2051
+ }
2052
+ writer.appendChild(clonedChild, defElements.content);
2053
+ }
2054
+ writer.appendChild(defElements.listItem, footnotesDefinitionsList);
2055
+ }
2056
+ }
2057
+ /**
2058
+ * Removes all MS Office specific references from the given element.
2059
+ *
2060
+ * It also removes leading space from text nodes following the references, as MS Word adds
2061
+ * them to separate the reference from the rest of the text.
2062
+ *
2063
+ * @param writer The view writer.
2064
+ * @param element The element to trim.
2065
+ * @returns The trimmed element.
2066
+ */
2067
+ function removeMSReferences(writer, element) {
2068
+ const elementsToRemove = [];
2069
+ const textNodesToTrim = [];
2070
+ for (const { item } of writer.createRangeIn(element)) {
2071
+ if (item.is('element') && item.getStyle('mso-footnote-id')) {
2072
+ elementsToRemove.unshift(item);
2073
+ // MS Word used to add spaces after footnote references within definitions. Let's check if there's a space after
2074
+ // the footnote reference and mark it for trimming.
2075
+ const { nextSibling } = item;
2076
+ if (nextSibling?.is('$text') && nextSibling.data.startsWith(' ')) {
2077
+ textNodesToTrim.unshift(nextSibling);
2078
+ }
2079
+ }
2080
+ }
2081
+ for (const element of elementsToRemove) {
2082
+ writer.remove(element);
2083
+ }
2084
+ // Remove only the leading space from text nodes following reference within definition, preserve the rest of the text.
2085
+ for (const textNode of textNodesToTrim) {
2086
+ const trimmedData = textNode.data.substring(1);
2087
+ if (trimmedData.length > 0) {
2088
+ // Create a new text node and replace the old one.
2089
+ const parent = textNode.parent;
2090
+ const index = parent.getChildIndex(textNode);
2091
+ const newTextNode = writer.createText(trimmedData);
2092
+ writer.remove(textNode);
2093
+ writer.insertChild(index, newTextNode, parent);
2094
+ }
2095
+ else {
2096
+ // If the text node contained only a space, remove it entirely.
2097
+ writer.remove(textNode);
2098
+ }
2099
+ }
2100
+ return element;
2101
+ }
2102
+ /**
2103
+ * Creates a footnotes list view element.
2104
+ *
2105
+ * @param writer The view writer instance.
2106
+ * @returns The footnotes list view element.
2107
+ */
2108
+ function createFootnotesListViewElement(writer) {
2109
+ return writer.createElement('ol', { class: 'footnotes' });
2110
+ }
2111
+ /**
2112
+ * Creates a footnote reference view element.
2113
+ *
2114
+ * @param writer The view writer instance.
2115
+ * @param footnoteId The footnote ID.
2116
+ * @returns The footnote reference view element.
2117
+ */
2118
+ function createFootnoteRefViewElement(writer, footnoteId) {
2119
+ const sup = writer.createElement('sup', { class: 'footnote' });
2120
+ const link = writer.createElement('a', {
2121
+ id: `ref-${footnoteId}`,
2122
+ href: `#${footnoteId}`,
2123
+ });
2124
+ writer.appendChild(link, sup);
2125
+ return sup;
2126
+ }
2127
+ /**
2128
+ * Creates a footnote definition view element with a backlink and a content container.
2129
+ *
2130
+ * @param writer The view writer instance.
2131
+ * @param footnoteId The footnote ID.
2132
+ * @returns An object containing the list item element, backlink and content container.
2133
+ */
2134
+ function createFootnoteDefViewElement(writer, footnoteId) {
2135
+ const listItem = writer.createElement('li', {
2136
+ id: footnoteId,
2137
+ class: 'footnote-definition',
2138
+ });
2139
+ const backLink = writer.createElement('a', {
2140
+ href: `#ref-${footnoteId}`,
2141
+ class: 'footnote-backlink',
2142
+ });
2143
+ const content = writer.createElement('div', {
2144
+ class: 'footnote-content',
2145
+ });
2146
+ writer.appendChild(writer.createText('^'), backLink);
2147
+ writer.appendChild(backLink, listItem);
2148
+ writer.appendChild(content, listItem);
2149
+ return {
2150
+ listItem,
2151
+ content,
2152
+ };
2153
+ }
2154
+
2155
+ /**
2156
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
2157
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
2158
+ */
2159
+ /**
2160
+ * @module paste-from-office/normalizers/mswordnormalizer
2161
+ */
2162
+ const msWordMatch1 = /<meta\s*name="?generator"?\s*content="?microsoft\s*word\s*\d+"?\/?>/i;
2163
+ const msWordMatch2 = /xmlns:o="urn:schemas-microsoft-com/i;
2164
+ /**
2165
+ * Normalizer for the content pasted from Microsoft Word.
2166
+ */
2167
+ class PasteFromOfficeMSWordNormalizer {
2168
+ document;
2169
+ hasMultiLevelListPlugin;
2170
+ hasTablePropertiesPlugin;
2171
+ hasExtendedTableBlockAlignment;
2172
+ /**
2173
+ * Creates a new `PasteFromOfficeMSWordNormalizer` instance.
2174
+ *
2175
+ * @param document View document.
2176
+ */
2177
+ constructor(document, hasMultiLevelListPlugin = false, hasTablePropertiesPlugin = false, hasExtendedTableBlockAlignment = false) {
2178
+ this.document = document;
2179
+ this.hasMultiLevelListPlugin = hasMultiLevelListPlugin;
2180
+ this.hasTablePropertiesPlugin = hasTablePropertiesPlugin;
2181
+ this.hasExtendedTableBlockAlignment = hasExtendedTableBlockAlignment;
2182
+ }
2183
+ /**
2184
+ * @inheritDoc
2185
+ */
2186
+ isActive(htmlString) {
2187
+ return msWordMatch1.test(htmlString) || msWordMatch2.test(htmlString);
2188
+ }
2189
+ /**
2190
+ * @inheritDoc
2191
+ */
2192
+ execute(data) {
2193
+ const writer = new ViewUpcastWriter(this.document);
2194
+ const { body: documentFragment, stylesString } = data._parsedData;
2195
+ transformBookmarks(documentFragment, writer);
2196
+ // transformListItemLikeElementsIntoLists(documentFragment, stylesString, this.hasMultiLevelListPlugin);
2197
+ replaceImagesSourceWithBase64(documentFragment, data.dataTransfer.getData('text/rtf'));
2198
+ transformTables(documentFragment, writer, this.hasTablePropertiesPlugin, this.hasExtendedTableBlockAlignment);
2199
+ removeInvalidTableWidth(documentFragment, writer);
2200
+ replaceMSFootnotes(documentFragment, writer);
2201
+ removeMSAttributes(documentFragment);
2202
+ data.content = documentFragment;
2203
+ }
2204
+ }
2205
+
2206
+ /**
2207
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
2208
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
2209
+ */
2210
+ /**
2211
+ * Removes the `<b>` tag wrapper added by Google Docs to a copied content.
2212
+ *
2213
+ * @param documentFragment element `data.content` obtained from clipboard
2214
+ * @internal
2215
+ */
2216
+ function removeBoldWrapper(documentFragment, writer) {
2217
+ for (const child of documentFragment.getChildren()) {
2218
+ if (child.is('element', 'b') && child.getStyle('font-weight') === 'normal') {
2219
+ const childIndex = documentFragment.getChildIndex(child);
2220
+ writer.remove(child);
2221
+ writer.insertChild(childIndex, child.getChildren(), documentFragment);
2222
+ }
2223
+ }
2224
+ }
2225
+
2226
+ /**
2227
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
2228
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
2229
+ */
2230
+ /**
2231
+ * @module paste-from-office/filters/br
2232
+ */
2233
+ /**
2234
+ * Transforms `<br>` elements that are siblings to some block element into a paragraphs.
2235
+ *
2236
+ * @param documentFragment The view structure to be transformed.
2237
+ * @internal
2238
+ */
2239
+ function transformBlockBrsToParagraphs(documentFragment, writer) {
2240
+ const viewDocument = new ViewDocument(writer.document.stylesProcessor);
2241
+ const domConverter = new ViewDomConverter(viewDocument, { renderingMode: 'data' });
2242
+ const blockElements = domConverter.blockElements;
2243
+ const inlineObjectElements = domConverter.inlineObjectElements;
2244
+ const elementsToReplace = [];
2245
+ for (const value of writer.createRangeIn(documentFragment)) {
2246
+ const element = value.item;
2247
+ if (element.is('element', 'br')) {
2248
+ const nextSibling = findSibling(element, 'forward', writer, { blockElements, inlineObjectElements });
2249
+ const previousSibling = findSibling(element, 'backward', writer, { blockElements, inlineObjectElements });
2250
+ const nextSiblingIsBlock = isBlockViewElement(nextSibling, blockElements);
2251
+ const previousSiblingIsBlock = isBlockViewElement(previousSibling, blockElements);
2252
+ // If the <br> is surrounded by blocks then convert it to a paragraph:
2253
+ // * <p>foo</p>[<br>]<p>bar</p> -> <p>foo</p>[<p></p>]<p>bar</p>
2254
+ // * <p>foo</p>[<br>] -> <p>foo</p>[<p></p>]
2255
+ // * [<br>]<p>foo</p> -> [<p></p>]<p>foo</p>
2256
+ if (previousSiblingIsBlock || nextSiblingIsBlock) {
2257
+ elementsToReplace.push(element);
2258
+ }
2259
+ }
2260
+ }
2261
+ for (const element of elementsToReplace) {
2262
+ if (element.hasClass('Apple-interchange-newline')) {
2263
+ writer.remove(element);
2264
+ }
2265
+ else {
2266
+ writer.replace(element, writer.createElement('p'));
2267
+ }
2268
+ }
2269
+ }
2270
+ /**
2271
+ * Returns sibling node, threats inline elements as transparent (but should stop on an inline objects).
2272
+ */
2273
+ function findSibling(viewElement, direction, writer, { blockElements, inlineObjectElements }) {
2274
+ let position = writer.createPositionAt(viewElement, direction == 'forward' ? 'after' : 'before');
2275
+ // Find first position that is just before a first:
2276
+ // * text node,
2277
+ // * block element,
2278
+ // * inline object element.
2279
+ // It's ignoring any inline (non-object) elements like span, strong, etc.
2280
+ position = position.getLastMatchingPosition(({ item }) => (item.is('element') &&
2281
+ !blockElements.includes(item.name) &&
2282
+ !inlineObjectElements.includes(item.name)), { direction });
2283
+ return direction == 'forward' ? position.nodeAfter : position.nodeBefore;
2284
+ }
2285
+ /**
2286
+ * Returns true for view elements that are listed as block view elements.
2287
+ */
2288
+ function isBlockViewElement(node, blockElements) {
2289
+ return !!node && node.is('element') && blockElements.includes(node.name);
2290
+ }
2291
+
2292
+ /**
2293
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
2294
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
2295
+ */
2296
+ /**
2297
+ * @module paste-from-office/filters/list
2298
+ */
2299
+ /**
2300
+ * Transforms Word specific list-like elements to the semantic HTML lists.
2301
+ *
2302
+ * Lists in Word are represented by block elements with special attributes like:
2303
+ *
2304
+ * ```xml
2305
+ * <p class=MsoListParagraphCxSpFirst style='mso-list:l1 level1 lfo1'>...</p> // Paragraph based list.
2306
+ * <h1 style='mso-list:l0 level1 lfo1'>...</h1> // Heading 1 based list.
2307
+ * ```
2308
+ *
2309
+ * @param documentFragment The view structure to be transformed.
2310
+ * @param stylesString Styles from which list-like elements styling will be extracted.
2311
+ * @internal
2312
+ */
2313
+ function transformListItemLikeElementsIntoLists(documentFragment, stylesString, hasMultiLevelListPlugin) {
2314
+ if (!documentFragment.childCount) {
2315
+ return;
2316
+ }
2317
+ const writer = new ViewUpcastWriter(documentFragment.document);
2318
+ const itemLikeElements = findAllItemLikeElements(documentFragment, writer);
2319
+ if (!itemLikeElements.length) {
2320
+ return;
2321
+ }
2322
+ const encounteredLists = {};
2323
+ const stack = [];
2324
+ let topLevelListInfo = createTopLevelListInfo();
2325
+ for (const itemLikeElement of itemLikeElements) {
2326
+ if (itemLikeElement.indent !== undefined) {
2327
+ if (!isListContinuation(itemLikeElement)) {
2328
+ applyIndentationToTopLevelList(writer, stack, topLevelListInfo);
2329
+ topLevelListInfo = createTopLevelListInfo();
2330
+ stack.length = 0;
2331
+ }
2332
+ // Combined list ID for addressing encounter lists counters.
2333
+ const originalListId = `${itemLikeElement.id}:${itemLikeElement.indent}`;
2334
+ // Normalized list item indentation.
2335
+ const indent = Math.min(itemLikeElement.indent - 1, stack.length);
2336
+ // Trimming of the list stack on list ID change.
2337
+ if (indent < stack.length && stack[indent].id !== itemLikeElement.id) {
2338
+ stack.length = indent;
2339
+ }
2340
+ // Trimming of the list stack on lower indent list encountered.
2341
+ if (indent < stack.length - 1) {
2342
+ stack.length = indent + 1;
2343
+ }
2344
+ else {
2345
+ const listStyle = detectListStyle(itemLikeElement, stylesString);
2346
+ // Create a new OL/UL if required (greater indent or different list type).
2347
+ if (indent > stack.length - 1 || stack[indent].listElement.name != listStyle.type) {
2348
+ // Check if there is some start index to set from a previous list.
2349
+ if (indent == 0 && listStyle.type == 'ol' && itemLikeElement.id !== undefined && encounteredLists[originalListId]) {
2350
+ listStyle.startIndex = encounteredLists[originalListId];
2351
+ }
2352
+ const listElement = createNewEmptyList(listStyle, writer, hasMultiLevelListPlugin);
2353
+ // Insert the new OL/UL.
2354
+ if (stack.length == 0) {
2355
+ const parent = itemLikeElement.element.parent;
2356
+ const index = parent.getChildIndex(itemLikeElement.element) + 1;
2357
+ writer.insertChild(index, listElement, parent);
2358
+ }
2359
+ else {
2360
+ const parentListItems = stack[indent - 1].listItemElements;
2361
+ writer.appendChild(listElement, parentListItems[parentListItems.length - 1]);
2362
+ }
2363
+ // Update the list stack for other items to reference.
2364
+ stack[indent] = {
2365
+ ...itemLikeElement,
2366
+ listElement,
2367
+ listItemElements: [],
2368
+ };
2369
+ // Prepare list counter for start index.
2370
+ if (indent == 0 && itemLikeElement.id !== undefined) {
2371
+ encounteredLists[originalListId] = listStyle.startIndex || 1;
2372
+ }
2373
+ }
2374
+ }
2375
+ // Use LI if it is already it or create a new LI element.
2376
+ // https://github.com/ckeditor/ckeditor5/issues/15964
2377
+ const listItem = itemLikeElement.element.name == 'li' ? itemLikeElement.element : writer.createElement('li');
2378
+ applyListItemMarginLeftAndUpdateTopLevelInfo(writer, stack, topLevelListInfo, itemLikeElement, listItem, indent);
2379
+ // Append the LI to OL/UL.
2380
+ writer.appendChild(listItem, stack[indent].listElement);
2381
+ stack[indent].listItemElements.push(listItem);
2382
+ // Increment list counter.
2383
+ if (indent == 0 && itemLikeElement.id !== undefined) {
2384
+ encounteredLists[originalListId]++;
2385
+ }
2386
+ // Append list block to LI.
2387
+ if (itemLikeElement.element != listItem) {
2388
+ writer.appendChild(itemLikeElement.element, listItem);
2389
+ }
2390
+ // Clean list block.
2391
+ removeBulletElement(itemLikeElement.element, writer);
2392
+ writer.removeStyle('text-indent', itemLikeElement.element); // #12361
2393
+ writer.removeStyle('margin-left', itemLikeElement.element);
2394
+ }
2395
+ else {
2396
+ // Other blocks in a list item.
2397
+ const stackItem = stack.find(stackItem => stackItem.marginLeft == itemLikeElement.marginLeft);
2398
+ // This might be a paragraph that has known margin, but it is not a real list block.
2399
+ if (stackItem) {
2400
+ const listItems = stackItem.listItemElements;
2401
+ // Append block to LI.
2402
+ writer.appendChild(itemLikeElement.element, listItems[listItems.length - 1]);
2403
+ writer.removeStyle('margin-left', itemLikeElement.element);
2404
+ }
2405
+ else {
2406
+ stack.length = 0;
2407
+ }
2408
+ }
2409
+ }
2410
+ applyIndentationToTopLevelList(writer, stack, topLevelListInfo);
2411
+ }
2412
+ function applyListItemMarginLeftAndUpdateTopLevelInfo(writer, stack, topLevelListInfo, itemLikeElement, listItem, indent) {
2413
+ if (itemLikeElement.marginLeft === undefined) {
2414
+ // If at least one of the list items at indent = 0 does not have margin-left style, we cannot set margin-left on the list.
2415
+ if (indent == 0) {
2416
+ topLevelListInfo.canApplyMarginOnList = false;
2417
+ }
2418
+ return;
2419
+ }
2420
+ const listItemBlockMarginLeft = parseFloat(itemLikeElement.marginLeft);
2421
+ let currentListBlockIndent = 0;
2422
+ if (stack.length > 1) {
2423
+ const prevStackLevelItems = stack[stack.length - 2].listItemElements;
2424
+ if (prevStackLevelItems.length > 0) {
2425
+ // The margin-left style of the previous indent level last item is already a relative value applied in the previous iteration.
2426
+ const lastItemMargin = prevStackLevelItems[prevStackLevelItems.length - 1].getStyle('margin-left');
2427
+ if (lastItemMargin !== undefined) {
2428
+ currentListBlockIndent += parseFloat(lastItemMargin);
2429
+ }
2430
+ }
2431
+ }
2432
+ // Add 40px for each indent level because by default HTML lists have 40px indentation (padding-inline-start: 40px).
2433
+ // So every nested list is indented by another 40px.
2434
+ // Additionally, the nested list itself may be placed in a list item with margin-left style.
2435
+ currentListBlockIndent += stack.length * 40;
2436
+ // Calculate relative list item indentation to the list it is in.
2437
+ const adjustedListItemIndent = listItemBlockMarginLeft - currentListBlockIndent;
2438
+ const listItemBlockMarginLeftPx = adjustedListItemIndent !== 0 ? toPx(adjustedListItemIndent) : undefined;
2439
+ if (listItemBlockMarginLeftPx) {
2440
+ writer.setStyle('margin-left', listItemBlockMarginLeftPx, listItem);
2441
+ if (indent == 0 && topLevelListInfo.canApplyMarginOnList) {
2442
+ if (topLevelListInfo.marginLeft === undefined) {
2443
+ topLevelListInfo.marginLeft = listItemBlockMarginLeftPx;
2444
+ }
2445
+ if (listItemBlockMarginLeftPx !== topLevelListInfo.marginLeft) {
2446
+ topLevelListInfo.canApplyMarginOnList = false;
2447
+ }
2448
+ topLevelListInfo.topLevelListItemElements.push(listItem);
2449
+ }
2450
+ }
2451
+ }
2452
+ function createTopLevelListInfo() {
2453
+ return {
2454
+ marginLeft: undefined,
2455
+ canApplyMarginOnList: true,
2456
+ topLevelListItemElements: [],
2457
+ };
2458
+ }
2459
+ /**
2460
+ * Sets margin-left style to the top-level list if all its items have the same margin-left.
2461
+ * If margin-left is set on the list, it is removed from all its items to avoid doubling of margins.
2462
+ */
2463
+ function applyIndentationToTopLevelList(writer, stack, topLevelListInfo) {
2464
+ if (topLevelListInfo.canApplyMarginOnList && topLevelListInfo.marginLeft && topLevelListInfo.topLevelListItemElements.length > 0) {
2465
+ // Apply margin-left to the top-level list if all its items have the same margin-left.
2466
+ writer.setStyle('margin-left', topLevelListInfo.marginLeft, stack[0].listElement);
2467
+ // Remove margin-left from all top-level list items.
2468
+ for (const topLevelListItem of topLevelListInfo.topLevelListItemElements) {
2469
+ writer.removeStyle('margin-left', topLevelListItem);
2470
+ }
2471
+ }
2472
+ }
2473
+ /**
2474
+ * Removes paragraph wrapping content inside a list item.
2475
+ *
2476
+ * @internal
2477
+ */
2478
+ function unwrapParagraphInListItem(documentFragment, writer) {
2479
+ for (const value of writer.createRangeIn(documentFragment)) {
2480
+ const element = value.item;
2481
+ if (element.is('element', 'li')) {
2482
+ // Google Docs allows for single paragraph inside LI.
2483
+ const firstChild = element.getChild(0);
2484
+ if (firstChild && firstChild.is('element', 'p')) {
2485
+ writer.unwrapElement(firstChild);
2486
+ }
2487
+ }
2488
+ }
2489
+ }
2490
+ /**
2491
+ * Finds all list-like elements in a given document fragment.
2492
+ *
2493
+ * @param documentFragment Document fragment in which to look for list-like nodes.
2494
+ * @returns Array of found list-like items. Each item is an object containing
2495
+ * @internal
2496
+ */
2497
+ function findAllItemLikeElements(documentFragment, writer) {
2498
+ const range = writer.createRangeIn(documentFragment);
2499
+ const itemLikeElements = [];
2500
+ const foundMargins = new Set();
2501
+ for (const item of range.getItems()) {
2502
+ // https://github.com/ckeditor/ckeditor5/issues/15964
2503
+ if (!item.is('element') || !item.name.match(/^(p|h\d+|li|div)$/)) {
2504
+ continue;
2505
+ }
2506
+ // Try to rely on margin-left style to find paragraphs visually aligned with previously encountered list item.
2507
+ let marginLeft = getMarginLeftNormalized(item);
2508
+ // Ignore margin-left 0 style if there is no MsoList... class.
2509
+ if (marginLeft !== undefined &&
2510
+ parseFloat(marginLeft) == 0 &&
2511
+ !Array.from(item.getClassNames()).find(className => className.startsWith('MsoList'))) {
2512
+ marginLeft = undefined;
2513
+ }
2514
+ // List item or a following list item block.
2515
+ if ((item.hasStyle('mso-list') && item.getStyle('mso-list') !== 'none') || (marginLeft !== undefined && foundMargins.has(marginLeft))) {
2516
+ const itemData = getListItemData(item);
2517
+ itemLikeElements.push({
2518
+ element: item,
2519
+ id: itemData.id,
2520
+ order: itemData.order,
2521
+ indent: itemData.indent,
2522
+ marginLeft,
2523
+ });
2524
+ if (marginLeft !== undefined) {
2525
+ foundMargins.add(marginLeft);
2526
+ }
2527
+ }
2528
+ // Clear found margins as we found block after a list.
2529
+ else {
2530
+ foundMargins.clear();
2531
+ }
2532
+ }
2533
+ return itemLikeElements;
2534
+ }
2535
+ /**
2536
+ * Whether the given element is possibly a list continuation. Previous element was wrapped into a list
2537
+ * or the current element already is inside a list.
2538
+ */
2539
+ function isListContinuation(currentItem) {
2540
+ const previousSibling = currentItem.element.previousSibling;
2541
+ if (!previousSibling) {
2542
+ const parent = currentItem.element.parent;
2543
+ // If it's a li inside ul or ol like in here: https://github.com/ckeditor/ckeditor5/issues/15964.
2544
+ // If the parent has previous sibling, which is not a list, then it is not a continuation.
2545
+ return isList(parent) && (!parent.previousSibling || isList(parent.previousSibling));
2546
+ }
2547
+ // Even with the same id the list does not have to be continuous (#43).
2548
+ return isList(previousSibling);
2549
+ }
2550
+ function isList(element) {
2551
+ return element.is('element', 'ol') || element.is('element', 'ul');
2552
+ }
2553
+ /**
2554
+ * Extracts list item style from the provided CSS.
2555
+ *
2556
+ * List item style is extracted from the CSS stylesheet. Each list with its specific style attribute
2557
+ * value (`mso-list:l1 level1 lfo1`) has its dedicated properties in a CSS stylesheet defined with a selector like:
2558
+ *
2559
+ * ```css
2560
+ * @list l1:level1 { ... }
2561
+ * ```
2562
+ *
2563
+ * It contains `mso-level-number-format` property which defines list numbering/bullet style. If this property
2564
+ * is not defined it means default `decimal` numbering.
2565
+ *
2566
+ * Here CSS string representation is used as `mso-level-number-format` property is an invalid CSS property
2567
+ * and will be removed during CSS parsing.
2568
+ *
2569
+ * @param listLikeItem List-like item for which list style will be searched for. Usually
2570
+ * a result of `findAllItemLikeElements()` function.
2571
+ * @param stylesString CSS stylesheet.
2572
+ * @returns An object with properties:
2573
+ *
2574
+ * * type - List type, could be `ul` or `ol`.
2575
+ * * startIndex - List start index, valid only for ordered lists.
2576
+ * * style - List style, for example: `decimal`, `lower-roman`, etc. It is extracted
2577
+ * directly from Word stylesheet and adjusted to represent proper values for the CSS `list-style-type` property.
2578
+ * If it cannot be adjusted, the `null` value is returned.
2579
+ */
2580
+ function detectListStyle(listLikeItem, stylesString) {
2581
+ const listStyleRegexp = new RegExp(`@list l${listLikeItem.id}:level${listLikeItem.indent}\\s*({[^}]*)`, 'gi');
2582
+ const listStyleTypeRegex = /mso-level-number-format:([^;]{0,100});/gi;
2583
+ const listStartIndexRegex = /mso-level-start-at:\s{0,100}([0-9]{0,10})\s{0,100};/gi;
2584
+ const legalStyleListRegex = new RegExp(`@list\\s+l${listLikeItem.id}:level\\d\\s*{[^{]*mso-level-text:"%\\d\\\\.`, 'gi');
2585
+ const multiLevelNumberFormatTypeRegex = new RegExp(`@list l${listLikeItem.id}:level\\d\\s*{[^{]*mso-level-number-format:`, 'gi');
2586
+ const legalStyleListMatch = legalStyleListRegex.exec(stylesString);
2587
+ const multiLevelNumberFormatMatch = multiLevelNumberFormatTypeRegex.exec(stylesString);
2588
+ // Multi level lists in Word have mso-level-number-format attribute except legal lists,
2589
+ // so we used that. If list has legal list match and doesn't has mso-level-number-format
2590
+ // then this is legal-list.
2591
+ const islegalStyleList = legalStyleListMatch && !multiLevelNumberFormatMatch;
2592
+ const listStyleMatch = listStyleRegexp.exec(stylesString);
2593
+ let listStyleType = 'decimal'; // Decimal is default one.
2594
+ let type = 'ol'; // <ol> is default list.
2595
+ let startIndex = null;
2596
+ if (listStyleMatch && listStyleMatch[1]) {
2597
+ const listStyleTypeMatch = listStyleTypeRegex.exec(listStyleMatch[1]);
2598
+ if (listStyleTypeMatch && listStyleTypeMatch[1]) {
2599
+ listStyleType = listStyleTypeMatch[1].trim();
2600
+ type = listStyleType !== 'bullet' && listStyleType !== 'image' ? 'ol' : 'ul';
2601
+ }
2602
+ // Styles for the numbered lists are always defined in the Word CSS stylesheet.
2603
+ // Unordered lists MAY contain a value for the Word CSS definition `mso-level-text` but sometimes
2604
+ // this tag is missing. And because of that, we cannot depend on that. We need to predict the list style value
2605
+ // based on the list style marker element.
2606
+ if (listStyleType === 'bullet') {
2607
+ const bulletedStyle = findBulletedListStyle(listLikeItem.element);
2608
+ if (bulletedStyle) {
2609
+ listStyleType = bulletedStyle;
2610
+ }
2611
+ }
2612
+ else {
2613
+ const listStartIndexMatch = listStartIndexRegex.exec(listStyleMatch[1]);
2614
+ if (listStartIndexMatch && listStartIndexMatch[1]) {
2615
+ startIndex = parseInt(listStartIndexMatch[1]);
2616
+ }
2617
+ }
2618
+ if (islegalStyleList) {
2619
+ type = 'ol';
2620
+ }
2621
+ }
2622
+ return {
2623
+ type,
2624
+ startIndex,
2625
+ style: mapListStyleDefinition(listStyleType),
2626
+ isLegalStyleList: islegalStyleList,
2627
+ };
2628
+ }
2629
+ /**
2630
+ * Tries to extract the `list-style-type` value based on the marker element for bulleted list.
2631
+ */
2632
+ function findBulletedListStyle(element) {
2633
+ // https://github.com/ckeditor/ckeditor5/issues/15964
2634
+ if (element.name == 'li' && element.parent.name == 'ul' && element.parent.hasAttribute('type')) {
2635
+ return element.parent.getAttribute('type');
2636
+ }
2637
+ const listMarkerElement = findListMarkerNode(element);
2638
+ if (!listMarkerElement) {
2639
+ return null;
2640
+ }
2641
+ const listMarker = listMarkerElement._data;
2642
+ if (listMarker === 'o') {
2643
+ return 'circle';
2644
+ }
2645
+ else if (listMarker === '·') {
2646
+ return 'disc';
2647
+ }
2648
+ // Word returns '§' instead of '■' for the square list style.
2649
+ else if (listMarker === '§') {
2650
+ return 'square';
2651
+ }
2652
+ return null;
2653
+ }
2654
+ /**
2655
+ * Tries to find a text node that represents the marker element (list-style-type).
2656
+ */
2657
+ function findListMarkerNode(element) {
2658
+ // If the first child is a text node, it is the data for the element.
2659
+ // The list-style marker is not present here.
2660
+ if (element.getChild(0).is('$text')) {
2661
+ return null;
2662
+ }
2663
+ for (const childNode of element.getChildren()) {
2664
+ // The list-style marker will be inside the `<span>` element. Let's ignore all non-span elements.
2665
+ // It may happen that the `<a>` element is added as the first child. Most probably, it's an anchor element.
2666
+ if (!childNode.is('element', 'span')) {
2667
+ continue;
2668
+ }
2669
+ const textNodeOrElement = childNode.getChild(0);
2670
+ if (!textNodeOrElement) {
2671
+ continue;
2672
+ }
2673
+ // If already found the marker element, use it.
2674
+ if (textNodeOrElement.is('$text')) {
2675
+ return textNodeOrElement;
2676
+ }
2677
+ return textNodeOrElement.getChild(0);
2678
+ }
2679
+ /* istanbul ignore next -- @preserve */
2680
+ return null;
2681
+ }
2682
+ /**
2683
+ * Parses the `list-style-type` value extracted directly from the Word CSS stylesheet and returns proper CSS definition.
2684
+ */
2685
+ function mapListStyleDefinition(value) {
2686
+ if (value.startsWith('arabic-leading-zero')) {
2687
+ return 'decimal-leading-zero';
2688
+ }
2689
+ switch (value) {
2690
+ case 'alpha-upper':
2691
+ return 'upper-alpha';
2692
+ case 'alpha-lower':
2693
+ return 'lower-alpha';
2694
+ case 'roman-upper':
2695
+ return 'upper-roman';
2696
+ case 'roman-lower':
2697
+ return 'lower-roman';
2698
+ case 'circle':
2699
+ case 'disc':
2700
+ case 'square':
2701
+ return value;
2702
+ default:
2703
+ return null;
2704
+ }
2705
+ }
2706
+ /**
2707
+ * Creates a new list OL/UL element.
2708
+ */
2709
+ function createNewEmptyList(listStyle, writer, hasMultiLevelListPlugin) {
2710
+ const list = writer.createElement(listStyle.type);
2711
+ // We do not support modifying the marker for a particular list item.
2712
+ // Set the value for the `list-style-type` property directly to the list container.
2713
+ if (listStyle.style) {
2714
+ writer.setStyle('list-style-type', listStyle.style, list);
2715
+ }
2716
+ if (listStyle.startIndex && listStyle.startIndex > 1) {
2717
+ writer.setAttribute('start', listStyle.startIndex, list);
2718
+ }
2719
+ if (listStyle.isLegalStyleList && hasMultiLevelListPlugin) {
2720
+ writer.addClass('legal-list', list);
2721
+ }
2722
+ return list;
2723
+ }
2724
+ /**
2725
+ * Extracts list item information from Word specific list-like element style:
2726
+ *
2727
+ * ```
2728
+ * `style="mso-list:l1 level1 lfo1"`
2729
+ * ```
2730
+ *
2731
+ * where:
2732
+ *
2733
+ * ```
2734
+ * * `l1` is a list id (however it does not mean this is a continuous list - see #43),
2735
+ * * `level1` is a list item indentation level,
2736
+ * * `lfo1` is a list insertion order in a document.
2737
+ * ```
2738
+ *
2739
+ * @param element Element from which style data is extracted.
2740
+ */
2741
+ function getListItemData(element) {
2742
+ const listStyle = element.getStyle('mso-list');
2743
+ if (listStyle === undefined) {
2744
+ return {};
2745
+ }
2746
+ const idMatch = listStyle.match(/(^|\s{1,100})l(\d+)/i);
2747
+ const orderMatch = listStyle.match(/\s{0,100}lfo(\d+)/i);
2748
+ const indentMatch = listStyle.match(/\s{0,100}level(\d+)/i);
2749
+ if (idMatch && orderMatch && indentMatch) {
2750
+ return {
2751
+ id: idMatch[2],
2752
+ order: orderMatch[1],
2753
+ indent: parseInt(indentMatch[1]),
2754
+ };
2755
+ }
2756
+ return {
2757
+ indent: 1, // Handle empty mso-list style as a marked for default list item.
2758
+ };
2759
+ }
2760
+ /**
2761
+ * Removes span with a numbering/bullet from a given element.
2762
+ */
2763
+ function removeBulletElement(element, writer) {
2764
+ // Matcher for finding `span` elements holding lists numbering/bullets.
2765
+ const bulletMatcher = new Matcher({
2766
+ name: 'span',
2767
+ styles: {
2768
+ 'mso-list': 'Ignore',
2769
+ },
2770
+ });
2771
+ const range = writer.createRangeIn(element);
2772
+ for (const value of range) {
2773
+ if (value.type === 'elementStart' && bulletMatcher.match(value.item)) {
2774
+ writer.remove(value.item);
2775
+ }
2776
+ }
2777
+ }
2778
+ /**
2779
+ * Returns element left margin normalized to 'px' if possible.
2780
+ */
2781
+ function getMarginLeftNormalized(element) {
2782
+ const value = element.getStyle('margin-left');
2783
+ if (value === undefined || value.endsWith('px')) {
2784
+ return value;
2785
+ }
2786
+ return convertCssLengthToPx(value);
2787
+ }
2788
+
2789
+ /**
2790
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
2791
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
2792
+ */
2793
+ /**
2794
+ * @module paste-from-office/normalizers/googledocsnormalizer
2795
+ */
2796
+ const googleDocsMatch = /id=("|')docs-internal-guid-[-0-9a-f]+("|')/i;
2797
+ /**
2798
+ * Normalizer for the content pasted from Google Docs.
2799
+ *
2800
+ * @internal
2801
+ */
2802
+ class GoogleDocsNormalizer {
2803
+ document;
2804
+ /**
2805
+ * Creates a new `GoogleDocsNormalizer` instance.
2806
+ *
2807
+ * @param document View document.
2808
+ */
2809
+ constructor(document) {
2810
+ this.document = document;
2811
+ }
2812
+ /**
2813
+ * @inheritDoc
2814
+ */
2815
+ isActive(htmlString) {
2816
+ return googleDocsMatch.test(htmlString);
2817
+ }
2818
+ /**
2819
+ * @inheritDoc
2820
+ */
2821
+ execute(data) {
2822
+ const writer = new ViewUpcastWriter(this.document);
2823
+ const { body: documentFragment } = data._parsedData;
2824
+ removeBoldWrapper(documentFragment, writer);
2825
+ unwrapParagraphInListItem(documentFragment, writer);
2826
+ transformBlockBrsToParagraphs(documentFragment, writer);
2827
+ // replaceTabsWithinPreWithSpaces( documentFragment, writer, 8 );
2828
+ data.content = documentFragment;
2829
+ }
2830
+ }
2831
+
2832
+ /**
2833
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
2834
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
2835
+ */
2836
+ /**
2837
+ * Removes the `xmlns` attribute from table pasted from Google Sheets.
2838
+ *
2839
+ * @param documentFragment element `data.content` obtained from clipboard
2840
+ * @internal
2841
+ */
2842
+ function removeXmlns(documentFragment, writer) {
2843
+ for (const child of documentFragment.getChildren()) {
2844
+ if (child.is('element', 'table') && child.hasAttribute('xmlns')) {
2845
+ writer.removeAttribute('xmlns', child);
2846
+ }
2847
+ }
2848
+ }
2849
+
2850
+ /**
2851
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
2852
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
2853
+ */
2854
+ /**
2855
+ * Removes the `<google-sheets-html-origin>` tag wrapper added by Google Sheets to a copied content.
2856
+ *
2857
+ * @param documentFragment element `data.content` obtained from clipboard
2858
+ * @internal
2859
+ */
2860
+ function removeGoogleSheetsTag(documentFragment, writer) {
2861
+ for (const child of documentFragment.getChildren()) {
2862
+ if (child.is('element', 'google-sheets-html-origin')) {
2863
+ const childIndex = documentFragment.getChildIndex(child);
2864
+ writer.remove(child);
2865
+ writer.insertChild(childIndex, child.getChildren(), documentFragment);
2866
+ }
2867
+ }
2868
+ }
2869
+
2870
+ /**
2871
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
2872
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
2873
+ */
2874
+ /**
2875
+ * Removes `<style>` block added by Google Sheets to a copied content.
2876
+ *
2877
+ * @param documentFragment element `data.content` obtained from clipboard
2878
+ * @internal
2879
+ */
2880
+ function removeStyleBlock(documentFragment, writer) {
2881
+ for (const child of Array.from(documentFragment.getChildren())) {
2882
+ if (child.is('element', 'style')) {
2883
+ writer.remove(child);
2884
+ }
2885
+ }
2886
+ }
2887
+
2888
+ /**
2889
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
2890
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
2891
+ */
2892
+ /**
2893
+ * @module paste-from-office/normalizers/googlesheetsnormalizer
2894
+ */
2895
+ const googleSheetsMatch = /<google-sheets-html-origin/i;
2896
+ /**
2897
+ * Normalizer for the content pasted from Google Sheets.
2898
+ *
2899
+ * @internal
2900
+ */
2901
+ class GoogleSheetsNormalizer {
2902
+ document;
2903
+ /**
2904
+ * Creates a new `GoogleSheetsNormalizer` instance.
2905
+ *
2906
+ * @param document View document.
2907
+ */
2908
+ constructor(document) {
2909
+ this.document = document;
2910
+ }
2911
+ /**
2912
+ * @inheritDoc
2913
+ */
2914
+ isActive(htmlString) {
2915
+ return googleSheetsMatch.test(htmlString);
2916
+ }
2917
+ /**
2918
+ * @inheritDoc
2919
+ */
2920
+ execute(data) {
2921
+ const writer = new ViewUpcastWriter(this.document);
2922
+ const { body: documentFragment } = data._parsedData;
2923
+ removeGoogleSheetsTag(documentFragment, writer);
2924
+ removeXmlns(documentFragment, writer);
2925
+ removeInvalidTableWidth(documentFragment, writer);
2926
+ removeStyleBlock(documentFragment, writer);
2927
+ data.content = documentFragment;
2928
+ }
2929
+ }
2930
+
2931
+ /**
2932
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
2933
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
2934
+ */
2935
+ /**
2936
+ * @module paste-from-office/filters/space
2937
+ */
2938
+ /**
2939
+ * Replaces last space preceding elements closing tag with `&nbsp;`. Such operation prevents spaces from being removed
2940
+ * during further DOM/View processing (see especially {@link module:engine/view/domconverter~ViewDomConverter#_processDomInlineNodes}).
2941
+ * This method also takes into account Word specific `<o:p></o:p>` empty tags.
2942
+ * Additionally multiline sequences of spaces and new lines between tags are removed (see #39 and #40).
2943
+ *
2944
+ * @param htmlString HTML string in which spacing should be normalized.
2945
+ * @returns Input HTML with spaces normalized.
2946
+ * @internal
2947
+ */
2948
+ function normalizeSpacing(htmlString) {
2949
+ // Run normalizeSafariSpaceSpans() two times to cover nested spans.
2950
+ return (normalizeSafariSpaceSpans(normalizeSafariSpaceSpans(htmlString))
2951
+ // Remove all \r\n from "spacerun spans" so the last replace line doesn't strip all whitespaces.
2952
+ .replace(/(<span\s+style=['"]mso-spacerun:yes['"]>[^\S\r\n]*?)[\r\n]+([^\S\r\n]*<\/span>)/g, '$1$2')
2953
+ .replace(/<span\s+style=['"]mso-spacerun:yes['"]><\/span>/g, '')
2954
+ .replace(/(<span\s+style=['"]letter-spacing:[^'"]+?['"]>)[\r\n]+(<\/span>)/g, '$1 $2')
2955
+ .replace(/ <\//g, '\u00A0</')
2956
+ .replace(/ <o:p><\/o:p>/g, '\u00A0<o:p></o:p>')
2957
+ // Remove <o:p> block filler from empty paragraph. Safari uses \u00A0 instead of &nbsp;.
2958
+ .replace(/<o:p>(&nbsp;|\u00A0)<\/o:p>/g, '')
2959
+ // Remove all whitespaces when they contain any \r or \n.
2960
+ .replace(/>([^\S\r\n]*[\r\n]\s*)</g, '><'));
2961
+ }
2962
+ /**
2963
+ * Normalizes spacing in special Word `spacerun spans` (`<span style='mso-spacerun:yes'>\s+</span>`) by replacing
2964
+ * all spaces with `&nbsp; ` pairs. This prevents spaces from being removed during further DOM/View processing
2965
+ * (see especially {@link module:engine/view/domconverter~ViewDomConverter#_processDomInlineNodes}).
2966
+ *
2967
+ * @param htmlDocument Native `Document` object in which spacing should be normalized.
2968
+ * @internal
2969
+ */
2970
+ function normalizeSpacerunSpans(htmlDocument) {
2971
+ htmlDocument.querySelectorAll('span[style*=spacerun]').forEach(el => {
2972
+ const htmlElement = el;
2973
+ const innerTextLength = htmlElement.innerText.length || 0;
2974
+ htmlElement.innerText = Array(innerTextLength + 1)
2975
+ .join('\u00A0 ')
2976
+ .substr(0, innerTextLength);
2977
+ });
2978
+ }
2979
+ /**
2980
+ * Normalizes specific spacing generated by Safari when content pasted from Word (`<span class="Apple-converted-space"> </span>`)
2981
+ * by replacing all spaces sequences longer than 1 space with `&nbsp; ` pairs. This prevents spaces from being removed during
2982
+ * further DOM/View processing (see especially {@link module:engine/view/domconverter~ViewDomConverter#_processDataFromDomText}).
2983
+ *
2984
+ * This function is similar to {@link module:clipboard/utils/normalizeclipboarddata normalizeClipboardData util} but uses
2985
+ * regular spaces / &nbsp; sequence for replacement.
2986
+ *
2987
+ * @param htmlString HTML string in which spacing should be normalized
2988
+ * @returns Input HTML with spaces normalized.
2989
+ * @internal
2990
+ */
2991
+ function normalizeSafariSpaceSpans(htmlString) {
2992
+ return htmlString.replace(/<span(?: class="Apple-converted-space"|)>(\s+)<\/span>/g, (fullMatch, spaces) => {
2993
+ return spaces.length === 1
2994
+ ? ' '
2995
+ : Array(spaces.length + 1)
2996
+ .join('\u00A0 ')
2997
+ .substr(0, spaces.length);
2998
+ });
2999
+ }
3000
+
3001
+ /**
3002
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
3003
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
3004
+ */
3005
+ /**
3006
+ * @module paste-from-office/filters/parse
3007
+ */
3008
+ /**
3009
+ * Parses the provided HTML extracting contents of `<body>` and `<style>` tags.
3010
+ *
3011
+ * @param htmlString HTML string to be parsed.
3012
+ */
3013
+ function parsePasteOfficeHtml(htmlString, stylesProcessor) {
3014
+ const domParser = new DOMParser();
3015
+ // Remove Word specific "if comments" so content inside is not omitted by the parser.
3016
+ htmlString = htmlString.replace(/<!--\[if gte vml 1]>/g, '');
3017
+ // Clean the <head> section of MS Windows specific tags. See https://github.com/ckeditor/ckeditor5/issues/15333.
3018
+ // The regular expression matches the <o:SmartTagType> tag with optional attributes (with or without values).
3019
+ htmlString = htmlString.replace(/<o:SmartTagType(?:\s+[^\s>=]+(?:="[^"]*")?)*\s*\/?>/gi, '');
3020
+ const normalizedHtml = normalizeSpacing(cleanContentAfterBody(htmlString));
3021
+ // Parse htmlString as native Document object.
3022
+ const htmlDocument = domParser.parseFromString(normalizedHtml, 'text/html');
3023
+ normalizeSpacerunSpans(htmlDocument);
3024
+ // Get `innerHTML` first as transforming to View modifies the source document.
3025
+ const bodyString = htmlDocument.body.innerHTML;
3026
+ // Transform document.body to View.
3027
+ const bodyView = documentToView(htmlDocument, stylesProcessor);
3028
+ // Extract stylesheets.
3029
+ const stylesObject = extractStyles(htmlDocument);
3030
+ return {
3031
+ body: bodyView,
3032
+ bodyString,
3033
+ styles: stylesObject.styles,
3034
+ stylesString: stylesObject.stylesString,
3035
+ };
3036
+ }
3037
+ /**
3038
+ * Transforms native `Document` object into {@link module:engine/view/documentfragment~ViewDocumentFragment}. Comments are skipped.
3039
+ *
3040
+ * @param htmlDocument Native `Document` object to be transformed.
3041
+ */
3042
+ function documentToView(htmlDocument, stylesProcessor) {
3043
+ const viewDocument = new ViewDocument(stylesProcessor);
3044
+ const domConverter = new ViewDomConverter(viewDocument, { renderingMode: 'data' });
3045
+ const fragment = htmlDocument.createDocumentFragment();
3046
+ const nodes = htmlDocument.body.childNodes;
3047
+ while (nodes.length > 0) {
3048
+ fragment.appendChild(nodes[0]);
3049
+ }
3050
+ return domConverter.domToView(fragment, { skipComments: true });
3051
+ }
3052
+ /**
3053
+ * Extracts both `CSSStyleSheet` and string representation from all `style` elements available in a provided `htmlDocument`.
3054
+ *
3055
+ * @param htmlDocument Native `Document` object from which styles will be extracted.
3056
+ */
3057
+ function extractStyles(htmlDocument) {
3058
+ const styles = [];
3059
+ const stylesString = [];
3060
+ const styleTags = Array.from(htmlDocument.getElementsByTagName('style'));
3061
+ for (const style of styleTags) {
3062
+ if (style.sheet && style.sheet.cssRules && style.sheet.cssRules.length) {
3063
+ styles.push(style.sheet);
3064
+ stylesString.push(style.innerHTML);
3065
+ }
3066
+ }
3067
+ return {
3068
+ styles,
3069
+ stylesString: stylesString.join(' '),
3070
+ };
3071
+ }
3072
+ /**
3073
+ * Removes leftover content from between closing </body> and closing </html> tag:
3074
+ *
3075
+ * ```html
3076
+ * <html><body><p>Foo Bar</p></body><span>Fo</span></html> -> <html><body><p>Foo Bar</p></body></html>
3077
+ * ```
3078
+ *
3079
+ * This function is used as specific browsers (Edge) add some random content after `body` tag when pasting from Word.
3080
+ * @param htmlString The HTML string to be cleaned.
3081
+ * @returns The HTML string with leftover content removed.
3082
+ */
3083
+ function cleanContentAfterBody(htmlString) {
3084
+ const bodyCloseTag = '</body>';
3085
+ const htmlCloseTag = '</html>';
3086
+ const bodyCloseIndex = htmlString.indexOf(bodyCloseTag);
3087
+ if (bodyCloseIndex < 0) {
3088
+ return htmlString;
3089
+ }
3090
+ const htmlCloseIndex = htmlString.indexOf(htmlCloseTag, bodyCloseIndex + bodyCloseTag.length);
3091
+ return htmlString.substring(0, bodyCloseIndex + bodyCloseTag.length) + (htmlCloseIndex >= 0 ? htmlString.substring(htmlCloseIndex) : '');
3092
+ }
3093
+
3094
+ /**
3095
+ * @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
3096
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
3097
+ */
3098
+ /**
3099
+ * @module paste-from-office/pastefromoffice
3100
+ */
3101
+ /**
3102
+ * The Paste from Office plugin.
3103
+ *
3104
+ * This plugin handles content pasted from Office apps and transforms it (if necessary)
3105
+ * to a valid structure which can then be understood by the editor features.
3106
+ *
3107
+ * Transformation is made by a set of predefined {@link module:paste-from-office/normalizer~PasteFromOfficeNormalizer normalizers}.
3108
+ * This plugin includes following normalizers:
3109
+ * * {@link module:paste-from-office/normalizers/mswordnormalizer~PasteFromOfficeMSWordNormalizer Microsoft Word normalizer}
3110
+ * * {@link module:paste-from-office/normalizers/googledocsnormalizer~GoogleDocsNormalizer Google Docs normalizer}
3111
+ *
3112
+ * For more information about this feature check the {@glink api/paste-from-office package page}.
3113
+ */
3114
+ class PasteHandler extends Plugin {
3115
+ /**
3116
+ * @inheritDoc
3117
+ */
3118
+ static get pluginName() {
3119
+ return 'PasteHandler';
3120
+ }
3121
+ /**
3122
+ * @inheritDoc
3123
+ */
3124
+ static get requires() {
3125
+ return [ClipboardPipeline];
3126
+ }
3127
+ /**
3128
+ * @inheritDoc
3129
+ */
3130
+ init() {
3131
+ const editor = this.editor;
3132
+ const clipboardPipeline = editor.plugins.get('ClipboardPipeline');
3133
+ const viewDocument = editor.editing.view.document;
3134
+ const normalizers = [];
3135
+ const hasMultiLevelListPlugin = this.editor.plugins.has('MultiLevelListEditing');
3136
+ const hasTablePropertiesPlugin = this.editor.plugins.has('TablePropertiesEditing');
3137
+ const hasExtendedTableBlockAlignment = !!this.editor.config.get('experimentalFlags.useExtendedTableBlockAlignment');
3138
+ normalizers.push(new PasteFromOfficeMSWordNormalizer(viewDocument, hasMultiLevelListPlugin, hasTablePropertiesPlugin, hasExtendedTableBlockAlignment));
3139
+ normalizers.push(new GoogleDocsNormalizer(viewDocument));
3140
+ normalizers.push(new GoogleSheetsNormalizer(viewDocument));
3141
+ clipboardPipeline.on('inputTransformation', (evt, data) => {
3142
+ if (data._isTransformedWithPasteFromOffice) {
3143
+ return;
3144
+ }
3145
+ const codeBlock = editor.model.document.selection.getFirstPosition().parent;
3146
+ if (codeBlock.is('element', 'codeBlock')) {
3147
+ return;
3148
+ }
3149
+ const htmlString = data.dataTransfer.getData('text/html');
3150
+ const activeNormalizer = normalizers.find(normalizer => normalizer.isActive(htmlString));
3151
+ if (activeNormalizer) {
3152
+ if (!data._parsedData) {
3153
+ data._parsedData = parsePasteOfficeHtml(htmlString, viewDocument.stylesProcessor);
3154
+ }
3155
+ activeNormalizer.execute(data);
3156
+ data._isTransformedWithPasteFromOffice = true;
3157
+ }
3158
+ }, { priority: 'high' });
3159
+ }
3160
+ }
3161
+
3162
+ class HighlightRangePlugin extends Plugin {
3163
+ init() {
3164
+ const editor = this.editor;
3165
+ editor.conversion.for('editingDowncast').markerToHighlight({
3166
+ model: 'highlightRange',
3167
+ view: {
3168
+ classes: 'highlight-range',
3169
+ },
3170
+ });
3171
+ }
3172
+ }
3173
+
3174
+ /**
3175
+ * Plugin để thêm margin-bottom: 4px cho các block elements
3176
+ * (paragraph, heading, list, table) thông qua downcast conversion
3177
+ */
3178
+ class BlockSpace extends Plugin {
3179
+ static get pluginName() {
3180
+ return 'BlockSpace';
3181
+ }
3182
+ init() {
3183
+ const editor = this.editor;
3184
+ const conversion = editor.conversion;
3185
+ // Handler factory để áp dụng margin-bottom: 4px
3186
+ const makeHandler = (elementType) => {
3187
+ return (evt, data, conversionApi) => {
3188
+ const viewElement = conversionApi.mapper.toViewElement(data.item);
3189
+ if (viewElement) {
3190
+ conversionApi.writer.setStyle('margin-top', '0', viewElement);
3191
+ conversionApi.writer.setStyle('margin-bottom', '6pt', viewElement);
3192
+ conversionApi.writer.setStyle('padding-top', '0', viewElement);
3193
+ conversionApi.writer.setStyle('padding-bottom', '0', viewElement);
3194
+ conversionApi.writer.setStyle('line-height', '1.15', viewElement);
3195
+ }
3196
+ };
3197
+ };
3198
+ // Áp dụng margin-bottom cho tất cả block elements
3199
+ conversion.for('downcast').add(dispatcher => {
3200
+ // Paragraph
3201
+ dispatcher.on('insert:paragraph', makeHandler('paragraph'), { priority: 'low' });
3202
+ // Heading (h1-h6)
3203
+ ['heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6'].forEach(heading => {
3204
+ dispatcher.on(`insert:${heading}`, makeHandler(heading), { priority: 'low' });
3205
+ });
3206
+ // Table
3207
+ dispatcher.on('insert:table', makeHandler('table'), { priority: 'low' });
3208
+ // PageBreak - add page-break-before style
3209
+ dispatcher.on('insert:pageBreak', (evt, data, conversionApi) => {
3210
+ const viewElement = conversionApi.mapper.toViewElement(data.item);
3211
+ if (viewElement) {
3212
+ conversionApi.writer.setStyle('page-break-before', 'always', viewElement);
3213
+ }
3214
+ }, { priority: 'low' });
3215
+ });
3216
+ }
3217
+ }
3218
+
3219
+ /**
3220
+ * Document Builder Utilities
3221
+ * Các hàm tiện ích cho document builder
3222
+ */
3223
+ /**
3224
+ * Chuẩn hóa nội dung bằng cách chuyển đổi tất cả màu HSL và RGB sang hex
3225
+ * @param content - Nội dung HTML cần chuẩn hóa
3226
+ * @returns Nội dung đã được chuẩn hóa với màu hex
3227
+ */
3228
+ function normalize(content) {
3229
+ let normalized = content;
3230
+ // Chuyển đổi HSL sang hex
3231
+ const hslRegex = /hsl\(\s*(\d+)\s*,\s*(\d+)%?\s*,\s*(\d+)%?\s*\)/gi;
3232
+ normalized = normalized.replace(hslRegex, (match, h, s, l) => {
3233
+ try {
3234
+ const hue = parseInt(h, 10);
3235
+ const saturation = parseInt(s, 10);
3236
+ const lightness = parseInt(l, 10);
3237
+ // Kiểm tra giá trị hợp lệ
3238
+ if (hue >= 0 && hue <= 360 && saturation >= 0 && saturation <= 100 && lightness >= 0 && lightness <= 100) {
3239
+ return hslToHex(hue, saturation, lightness);
3240
+ }
3241
+ }
3242
+ catch (error) {
3243
+ console.warn('Failed to convert HSL to hex:', error, match);
3244
+ }
3245
+ return match; // Giữ nguyên nếu không thể chuyển đổi
3246
+ });
3247
+ // Chuyển đổi RGB sang hex
3248
+ const rgbRegex = /rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/gi;
3249
+ normalized = normalized.replace(rgbRegex, (match, r, g, b) => {
3250
+ try {
3251
+ const red = parseInt(r, 10);
3252
+ const green = parseInt(g, 10);
3253
+ const blue = parseInt(b, 10);
3254
+ if (red >= 0 && red <= 255 && green >= 0 && green <= 255 && blue >= 0 && blue <= 255) {
3255
+ return rgbToHex(red, green, blue);
3256
+ }
3257
+ }
3258
+ catch (error) {
3259
+ console.warn('Failed to convert RGB to hex:', error, match);
3260
+ }
3261
+ return match;
3262
+ });
3263
+ return normalized;
598
3264
  }
599
3265
 
600
3266
  class SdDocumentBuilder {
601
- #id = '1212';
602
3267
  option;
603
3268
  disabled = false;
604
3269
  set _disabled(val) {
605
3270
  this.disabled = val === '' || !!val;
606
3271
  this.#updateState();
607
3272
  }
3273
+ contentChange = new EventEmitter(); // Emit HTML content
608
3274
  Editor = ClassicEditor;
609
3275
  #editor;
3276
+ #id = '55b0afb0-288d-423c-98b3-5f9db286e16d';
3277
+ #subscription = new Subscription();
3278
+ #sharedColors = getPresetColors();
3279
+ #headingOptions = getHeadingOptions();
3280
+ #fontSizeOptions = getFontSizeOptions();
3281
+ #colorPickerConfig = getColorPickerConfig();
3282
+ #contentChangeSubject = new Subject();
3283
+ #idTimeOutScrollHeading = null;
3284
+ #headingElementsMap = new Map(); // Hash lưu trữ các heading
610
3285
  // Config
611
3286
  config = {
612
3287
  getOption: () => this.option,
613
3288
  licenseKey: 'GPL', // Hoặc key thương mại nếu có
614
3289
  plugins: [
615
- GeneralHtmlSupport,
3290
+ // GeneralHtmlSupport,
616
3291
  FontSize,
617
3292
  FontColor,
618
3293
  FontFamily,
@@ -629,7 +3304,6 @@ class SdDocumentBuilder {
629
3304
  TableProperties,
630
3305
  TableCellProperties,
631
3306
  TableColumnResize,
632
- PasteFromOffice,
633
3307
  PageBreak,
634
3308
  Undo,
635
3309
  Alignment, // Canh lề
@@ -642,12 +3316,21 @@ class SdDocumentBuilder {
642
3316
  ImageCaption,
643
3317
  ImageResize,
644
3318
  ImageStyle,
3319
+ ImageBlock,
3320
+ // Indent
3321
+ Indent,
3322
+ IndentBlock,
645
3323
  // Custom Plugin
646
- PageOrientationPlugin,
3324
+ HeadingPlugin,
647
3325
  CommentPlugin,
648
3326
  VariablePlugin,
649
- TableFitPlugin,
3327
+ TableCustom,
3328
+ PageOrientation,
650
3329
  ImageUploadPlugin,
3330
+ ImageCustomPlugin,
3331
+ HighlightRangePlugin,
3332
+ PasteHandler,
3333
+ BlockSpace,
651
3334
  ],
652
3335
  toolbar: {
653
3336
  items: [
@@ -682,147 +3365,82 @@ class SdDocumentBuilder {
682
3365
  shouldNotGroupWhenFull: true,
683
3366
  },
684
3367
  image: {
685
- toolbar: ['toggleImageCaption', '|', 'imageStyle:inline', 'imageStyle:block', 'imageStyle:side'],
686
- },
687
- fontSize: {
688
- options: [
689
- // Định nghĩa từng size một cách tường minh
690
- {
691
- title: '9',
692
- model: '9pt',
693
- view: {
694
- name: 'span',
695
- styles: { 'font-size': '9pt' },
696
- priority: 7,
697
- },
698
- },
699
- {
700
- title: '10',
701
- model: '10pt',
702
- view: {
703
- name: 'span',
704
- styles: { 'font-size': '10pt' },
705
- priority: 7,
706
- },
707
- },
708
- {
709
- title: '11',
710
- model: '11pt',
711
- view: {
712
- name: 'span',
713
- styles: { 'font-size': '11pt' },
714
- priority: 7,
715
- },
716
- },
717
- {
718
- title: '12',
719
- model: '12pt',
720
- view: {
721
- name: 'span',
722
- styles: { 'font-size': '12pt' },
723
- priority: 7,
724
- },
725
- },
726
- {
727
- title: '13',
728
- model: '13pt',
729
- view: {
730
- name: 'span',
731
- styles: { 'font-size': '13pt' },
732
- priority: 7,
733
- },
734
- },
735
- {
736
- title: '14',
737
- model: '14pt',
738
- view: {
739
- name: 'span',
740
- styles: { 'font-size': '14pt' },
741
- priority: 7,
742
- },
743
- },
744
- {
745
- title: '16',
746
- model: '16pt',
747
- view: {
748
- name: 'span',
749
- styles: { 'font-size': '16pt' },
750
- priority: 7,
751
- },
752
- },
753
- {
754
- title: '18',
755
- model: '18pt',
756
- view: {
757
- name: 'span',
758
- styles: { 'font-size': '18pt' },
759
- priority: 7,
760
- },
761
- },
762
- {
763
- title: '20',
764
- model: '20pt',
765
- view: {
766
- name: 'span',
767
- styles: { 'font-size': '20pt' },
768
- priority: 7,
769
- },
770
- },
3368
+ styles: {
3369
+ options: ['inline', 'alignLeft', 'alignRight', 'alignCenter'],
3370
+ },
3371
+ toolbar: [
3372
+ 'imageStyle:inline',
3373
+ 'imageStyle:alignCenter',
771
3374
  {
772
- title: '24',
773
- model: '24pt',
774
- view: {
775
- name: 'span',
776
- styles: { 'font-size': '24pt' },
777
- priority: 7,
778
- },
3375
+ name: 'imageStyle:alignDropdown',
3376
+ items: ['imageStyle:alignLeft', 'imageStyle:alignRight'],
3377
+ defaultItem: 'imageStyle:alignLeft',
779
3378
  },
780
3379
  ],
781
- supportAllValues: false, // Khuyên dùng false để ép user chọn đúng size chuẩn
3380
+ },
3381
+ fontSize: {
3382
+ options: this.#fontSizeOptions,
3383
+ supportAllValues: true,
3384
+ },
3385
+ heading: {
3386
+ options: this.#headingOptions,
782
3387
  },
783
3388
  // 4. Cấu hình bảng màu (Tùy chọn)
784
3389
  fontColor: {
785
- columns: 5,
3390
+ // columns: 5,
786
3391
  documentColors: 10,
3392
+ colorPicker: this.#colorPickerConfig,
3393
+ colors: this.#sharedColors,
787
3394
  },
788
3395
  fontBackgroundColor: {
789
- columns: 5,
3396
+ // columns: 5,
790
3397
  documentColors: 10,
3398
+ colorPicker: this.#colorPickerConfig,
3399
+ colors: this.#sharedColors,
791
3400
  },
792
3401
  table: {
793
- contentToolbar: [
794
- 'tableColumn',
795
- 'tableRow',
796
- 'mergeTableCells',
797
- '|',
798
- 'tableProperties', // <--- Nút chỉnh thuộc tính bảng (Viền, Màu, Width)
799
- 'tableCellProperties',
800
- ],
3402
+ contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells', '|', 'tableProperties', 'tableCellProperties'],
3403
+ tableProperties: {
3404
+ borderColors: this.#sharedColors,
3405
+ backgroundColors: this.#sharedColors,
3406
+ colorPicker: this.#colorPickerConfig,
3407
+ defaultProperties: {
3408
+ borderStyle: 'solid',
3409
+ borderWidth: '1px',
3410
+ borderColor: '#ccc',
3411
+ },
3412
+ },
3413
+ tableCellProperties: {
3414
+ borderColors: this.#sharedColors,
3415
+ backgroundColors: this.#sharedColors,
3416
+ colorPicker: this.#colorPickerConfig,
3417
+ defaultProperties: {
3418
+ borderStyle: 'solid',
3419
+ borderWidth: '1px',
3420
+ borderColor: '#ccc',
3421
+ },
3422
+ },
3423
+ },
3424
+ indentBlock: {
3425
+ offset: 48, // Đơn vị px cho mỗi mức indent (tương đương 0.5 inch)
3426
+ unit: 'px',
801
3427
  },
802
3428
  // Quan trọng: Cho phép paste style từ Word nhưng bỏ qua margin/padding
803
3429
  htmlSupport: {
804
3430
  allow: [
805
3431
  {
806
- name: /.*/,
807
- attributes: /.*/, // attributes chấp nhận boolean
808
- classes: /.*/, // Cho phép mọi class
809
- styles: {
810
- // Cho phép tất cả styles trừ margin và padding
811
- match: /^(?!margin|padding).*/,
812
- },
3432
+ name: /.*/, // Cho phép tất cả tên thẻ HTML
3433
+ attributes: true, // Cho phép tất cả attributes
3434
+ classes: true, // Cho phép tất cả classes
3435
+ styles: true, // Cho phép tất cả styles
813
3436
  },
814
3437
  ],
815
3438
  },
816
3439
  };
817
- contentChange = new EventEmitter(); // Emit HTML content
818
- #subscription = new Subscription();
819
- #contentChangeSubject = new Subject();
820
- #editorChangeRxjs = new Subject();
821
3440
  ngOnInit() {
822
- // https://onemount.atlassian.net/browse/SM-1862
823
- // Debounce trong rxjs không hỗ trợ leading -->
3441
+ // Debounce trong rxjs không hỗ trợ leading --> throttleTime
824
3442
  this.#subscription.add(this.#contentChangeSubject.pipe(throttleTime(500, undefined, { leading: true, trailing: true })).subscribe(content => {
825
- this.contentChange.emit(content);
3443
+ this.contentChange.emit(normalize(content));
826
3444
  }));
827
3445
  }
828
3446
  ngOnDestroy() {
@@ -832,7 +3450,7 @@ class SdDocumentBuilder {
832
3450
  this.#editor = editor;
833
3451
  // Setup orientation plugin callback
834
3452
  try {
835
- const orientationPlugin = editor.plugins.get('PageOrientationPlugin');
3453
+ const orientationPlugin = editor.plugins.get('PageOrientation');
836
3454
  if (orientationPlugin && typeof orientationPlugin.onOrientationChange === 'function') {
837
3455
  orientationPlugin.onOrientationChange(orientation => {
838
3456
  this.option.onOrientation?.(orientation);
@@ -844,51 +3462,40 @@ class SdDocumentBuilder {
844
3462
  }
845
3463
  }
846
3464
  catch (error) {
847
- console.warn('PageOrientationPlugin not available:', error);
3465
+ console.warn('PageOrientation not available:', error);
848
3466
  }
849
- // Đăng ký sự kiện lắng nghe Selection để làm Comment
3467
+ // Lắng nghe selection
850
3468
  editor.model.document.selection.on('change', $event => {
851
3469
  this.option.onSelection?.(this.#editor.model.document.selection, $event);
852
3470
  });
853
- // ĐĂNG SỰ KIỆN LẮNG NGHE THAY ĐỔI NỘI DUNG
3471
+ // Lắng nghe sự kiện thay đổi nội dung
854
3472
  editor.model.document.on('change:data', () => {
855
3473
  const content = editor.getData();
856
3474
  this.#contentChangeSubject.next(content);
857
3475
  });
858
- this.#updateState();
859
- }
860
- #updateState() {
861
- if (!this.#editor)
862
- return;
863
- if (this.disabled) {
864
- // Bật chế độ chỉ đọc với ID khóa
865
- this.#editor.enableReadOnlyMode(this.#id);
866
- }
867
- else {
868
- // Tắt chế độ chỉ đọc với ID khóa tương ứng
869
- this.#editor.disableReadOnlyMode(this.#id);
870
- }
871
- }
872
- scrollToComment = (markerId) => {
873
- if (!this.#editor)
874
- return;
875
- const editor = this.#editor;
876
- const marker = editor.model.markers.get(markerId);
877
- if (marker) {
878
- // 1. Set Selection vào Marker đó trước
879
- this.#editor.model.change(writer => {
880
- writer.setSelection(marker.getRange());
3476
+ try {
3477
+ // Manual keybinding cho Tab nếu cần
3478
+ editor.keystrokes.set('Tab', (evt, cancel) => {
3479
+ const command = editor.commands.get('indentBlock');
3480
+ if (command && command.isEnabled) {
3481
+ editor.execute('indentBlock');
3482
+ cancel();
3483
+ }
881
3484
  });
882
- // 2. Sau đó gọi scrollToTheSelection
883
- this.#editor.editing.view.scrollToTheSelection({
884
- alignToTop: true,
3485
+ // Manual keybinding cho Shift+Tab
3486
+ editor.keystrokes.set('Shift+Tab', (evt, cancel) => {
3487
+ const command = editor.commands.get('outdentBlock');
3488
+ if (command && command.isEnabled) {
3489
+ editor.execute('outdentBlock');
3490
+ cancel();
3491
+ }
885
3492
  });
886
- editor.editing.view.focus();
887
3493
  }
888
- else {
889
- console.warn(`Marker with id ${markerId} not found.`);
3494
+ catch (error) {
3495
+ console.warn('Error setting up indent keybindings:', error);
890
3496
  }
891
- };
3497
+ this.#updateState();
3498
+ }
892
3499
  setContent = (html) => {
893
3500
  this.#editor?.setData?.(html);
894
3501
  };
@@ -902,7 +3509,7 @@ class SdDocumentBuilder {
902
3509
  if (!this.#editor)
903
3510
  return;
904
3511
  try {
905
- const orientationPlugin = this.#editor.plugins.get('PageOrientationPlugin');
3512
+ const orientationPlugin = this.#editor.plugins.get('PageOrientation');
906
3513
  if (orientationPlugin && typeof orientationPlugin.setOrientation === 'function') {
907
3514
  orientationPlugin.setOrientation(orientation);
908
3515
  }
@@ -915,7 +3522,7 @@ class SdDocumentBuilder {
915
3522
  if (!this.#editor)
916
3523
  return 'PORTRAIT';
917
3524
  try {
918
- const orientationPlugin = this.#editor.plugins.get('PageOrientationPlugin');
3525
+ const orientationPlugin = this.#editor.plugins.get('PageOrientation');
919
3526
  if (orientationPlugin && typeof orientationPlugin.getOrientation === 'function') {
920
3527
  return orientationPlugin.getOrientation();
921
3528
  }
@@ -925,55 +3532,6 @@ class SdDocumentBuilder {
925
3532
  }
926
3533
  return 'PORTRAIT';
927
3534
  };
928
- getVariables = () => {
929
- if (!this.#editor)
930
- return [];
931
- const model = this.#editor.model;
932
- const root = model.document.getRoot();
933
- if (!root)
934
- return [];
935
- const variables = [];
936
- // Duyệt qua tất cả các phần tử trong range
937
- // range.getItems() sẽ trả về từng node (text, element...)
938
- try {
939
- const range = model.createRangeIn(root);
940
- for (const item of range.getItems()) {
941
- // Sử dụng item.is('element', 'variable') là chính xác
942
- if (item.is('element', 'variable')) {
943
- variables.push({
944
- id: item.getAttribute('id'),
945
- value: item.getAttribute('value'),
946
- display: item.getAttribute('display'),
947
- data: item.getAttribute('data'),
948
- });
949
- }
950
- }
951
- }
952
- catch (e) {
953
- console.error(e);
954
- return [];
955
- }
956
- return variables;
957
- };
958
- getComments() {
959
- if (!this.#editor)
960
- return [];
961
- const markers = this.#editor.model.markers;
962
- const comments = [];
963
- // Duyệt qua tất cả markers trong Model
964
- for (const marker of markers) {
965
- // Chỉ lấy marker do plugin comment tạo ra (prefix 'comment:')
966
- if (marker.name.startsWith('comment:')) {
967
- // Lấy text nằm trong vùng marker đó
968
- const currentText = this.#getTextFromRange(marker.getRange());
969
- comments.push({
970
- markerId: marker.name,
971
- selectedText: currentText,
972
- });
973
- }
974
- }
975
- return comments;
976
- }
977
3535
  scrollToTop() {
978
3536
  setTimeout(() => {
979
3537
  if (this.#editor) {
@@ -993,13 +3551,67 @@ class SdDocumentBuilder {
993
3551
  }
994
3552
  }, 100);
995
3553
  }
996
- // Kho lưu trữ tham chiếu Model của Heading (để phục vụ scroll)
997
- #headingElementsMap = new Map();
3554
+ #updateState() {
3555
+ if (!this.#editor)
3556
+ return;
3557
+ if (this.disabled) {
3558
+ // Bật chế độ chỉ đọc với ID khóa
3559
+ this.#editor.enableReadOnlyMode(this.#id);
3560
+ // Disable page orientation button
3561
+ try {
3562
+ const orientationPlugin = this.#editor.plugins.get('PageOrientation');
3563
+ if (orientationPlugin && orientationPlugin.buttonView) {
3564
+ orientationPlugin.buttonView.isEnabled = false;
3565
+ }
3566
+ }
3567
+ catch (error) {
3568
+ console.warn('Failed to disable orientation button:', error);
3569
+ }
3570
+ }
3571
+ else {
3572
+ // Tắt chế độ chỉ đọc với ID khóa tương ứng
3573
+ this.#editor.disableReadOnlyMode(this.#id);
3574
+ // Enable page orientation button
3575
+ try {
3576
+ const orientationPlugin = this.#editor.plugins.get('PageOrientation');
3577
+ if (orientationPlugin && orientationPlugin.buttonView) {
3578
+ orientationPlugin.buttonView.isEnabled = true;
3579
+ }
3580
+ }
3581
+ catch (error) {
3582
+ console.warn('Failed to enable orientation button:', error);
3583
+ }
3584
+ }
3585
+ }
3586
+ #getTextFromElement = (element) => {
3587
+ let text = '';
3588
+ // Heading trong Model chứa các text node con
3589
+ for (const child of element.getChildren()) {
3590
+ if (child.is('$text') || child.is('$textProxy')) {
3591
+ text += child.data;
3592
+ }
3593
+ }
3594
+ return text;
3595
+ };
3596
+ #getTextFromRange = (range) => {
3597
+ let text = '';
3598
+ for (const item of range.getItems()) {
3599
+ // TextProxy là một phần của Text Node nằm trong Range
3600
+ if (item.is('$textProxy') || item.is('$text')) {
3601
+ text += item.data;
3602
+ }
3603
+ }
3604
+ return text;
3605
+ };
3606
+ // ========================================================================
3607
+ // 1. QUẢN LÝ HEADING
3608
+ // ========================================================================
998
3609
  heading = {
999
- // ========================================================================
1000
- // HÀM LẤY DANH SÁCH HEADING (TOC)
1001
- // ========================================================================
1002
- getHeadings: () => {
3610
+ /**
3611
+ * Lấy tất cả headings trong document
3612
+ * @returns Danh sách tất cả headings
3613
+ */
3614
+ all: () => {
1003
3615
  if (!this.#editor)
1004
3616
  return [];
1005
3617
  const root = this.#editor.model.document.getRoot();
@@ -1035,37 +3647,50 @@ class SdDocumentBuilder {
1035
3647
  }
1036
3648
  return headings;
1037
3649
  },
1038
- // ========================================================================
1039
- // HÀM SCROLL TO HEADING
1040
- // ========================================================================
1041
- scrollToHeading: (id) => {
3650
+ /**
3651
+ * Scroll tới vị trí của heading
3652
+ * @param id - ID của heading cần scroll tới
3653
+ */
3654
+ scroll: (id) => {
1042
3655
  if (!this.#editor)
1043
3656
  return;
1044
- // 1. Lấy Model Element từ kho lưu trữ
1045
3657
  const modelElement = this.#headingElementsMap.get(id);
1046
3658
  if (modelElement) {
1047
- const editor = this.#editor;
1048
- const view = editor.editing.view;
1049
- // 2. Chuyển đổi Model Element -> View Element
1050
- // mapper.toViewElement sẽ trả về thẻ HTML ảo (View Element) tương ứng (ví dụ thẻ <h2>)
1051
- const viewElement = editor.editing.mapper.toViewElement(modelElement);
3659
+ this.#editor.model.change(writer => {
3660
+ // Xóa marker
3661
+ if (this.#idTimeOutScrollHeading) {
3662
+ clearTimeout(this.#idTimeOutScrollHeading);
3663
+ }
3664
+ const currentMarker = this.#editor.model.markers.get('highlightMarker');
3665
+ if (currentMarker) {
3666
+ writer.removeMarker(currentMarker);
3667
+ }
3668
+ // Tạo Range bao trùm highlight
3669
+ const range = writer.createRangeOn(modelElement);
3670
+ // Thêm Marker mới
3671
+ writer.addMarker('highlightMarker', {
3672
+ range: range,
3673
+ usingOperation: false,
3674
+ });
3675
+ });
3676
+ // Scroll tới vị trí tìm được
3677
+ const viewElement = this.#editor.editing.mapper.toViewElement(modelElement);
1052
3678
  if (viewElement) {
1053
- // 3. Từ View Element ảo -> Lấy ra DOM thật (HTMLElement)
1054
- const domElement = view.domConverter.mapViewToDom(viewElement);
3679
+ const domElement = this.#editor.editing.view.domConverter.viewToDom(viewElement);
1055
3680
  if (domElement) {
1056
- // 4. Dùng hàm native của trình duyệt để cuộn
1057
- domElement.scrollIntoView({
1058
- behavior: 'smooth',
1059
- block: 'start',
1060
- inline: 'nearest',
1061
- });
1062
- // 5. (Tùy chọn) Focus và đặt con trỏ vào đó
1063
- view.focus();
1064
- editor.model.change(writer => {
1065
- writer.setSelection(modelElement, 'on');
1066
- });
3681
+ domElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
1067
3682
  }
1068
3683
  }
3684
+ // Tự động tắt marker sau 5 giây
3685
+ this.#idTimeOutScrollHeading = setTimeout(() => {
3686
+ if (this.#editor) {
3687
+ this.#editor.model.change(writer => {
3688
+ const marker = this.#editor.model.markers.get('highlightMarker');
3689
+ if (marker)
3690
+ writer.removeMarker(marker);
3691
+ });
3692
+ }
3693
+ }, 5000);
1069
3694
  }
1070
3695
  else {
1071
3696
  console.warn(`Heading with id ${id} not found.`);
@@ -1073,30 +3698,7 @@ class SdDocumentBuilder {
1073
3698
  },
1074
3699
  };
1075
3700
  // ========================================================================
1076
- // HELPER: LẤY TEXT TỪ ELEMENT (Đệ quy nhẹ)
1077
- // ========================================================================
1078
- #getTextFromElement = (element) => {
1079
- let text = '';
1080
- // Heading trong Model chứa các text node con
1081
- for (const child of element.getChildren()) {
1082
- if (child.is('$text') || child.is('$textProxy')) {
1083
- text += child.data;
1084
- }
1085
- }
1086
- return text;
1087
- };
1088
- #getTextFromRange = (range) => {
1089
- let text = '';
1090
- for (const item of range.getItems()) {
1091
- // TextProxy là một phần của Text Node nằm trong Range
1092
- if (item.is('$textProxy') || item.is('$text')) {
1093
- text += item.data;
1094
- }
1095
- }
1096
- return text;
1097
- };
1098
- // ========================================================================
1099
- // COMMENT MANAGEMENT
3701
+ // 2. QUẢN COMMENT
1100
3702
  // ========================================================================
1101
3703
  comment = {
1102
3704
  /**
@@ -1106,21 +3708,28 @@ class SdDocumentBuilder {
1106
3708
  all: () => {
1107
3709
  if (!this.#editor)
1108
3710
  return [];
1109
- const markers = this.#editor.model.markers;
1110
- const comments = [];
1111
- // Duyệt qua tất cả markers trong Model
1112
- for (const marker of markers) {
1113
- // Chỉ lấy marker do plugin comment tạo ra (prefix 'comment:')
1114
- if (marker.name.startsWith('comment:')) {
1115
- // Lấy text nằm trong vùng marker đó
1116
- const currentText = this.#getTextFromRange(marker.getRange());
1117
- comments.push({
1118
- markerId: marker.name,
1119
- selectedText: currentText,
1120
- });
3711
+ const editableElement = this.#editor.ui.view.editable.element;
3712
+ if (!editableElement)
3713
+ return [];
3714
+ const commentMarkers = editableElement.querySelectorAll('.ck-comment-marker[data-comment-id^="comment:"]') || [];
3715
+ const commentsMap = new Map();
3716
+ commentMarkers.forEach(el => {
3717
+ const markerId = el.getAttribute('data-comment-id');
3718
+ if (markerId) {
3719
+ const existing = commentsMap.get(markerId);
3720
+ const text = el.textContent || '';
3721
+ if (existing) {
3722
+ existing.selectedText += text;
3723
+ }
3724
+ else {
3725
+ commentsMap.set(markerId, {
3726
+ markerId,
3727
+ selectedText: text,
3728
+ });
3729
+ }
1121
3730
  }
1122
- }
1123
- return comments;
3731
+ });
3732
+ return Array.from(commentsMap.values());
1124
3733
  },
1125
3734
  /**
1126
3735
  * Thêm comment vào vùng text đang được chọn
@@ -1128,7 +3737,11 @@ class SdDocumentBuilder {
1128
3737
  * @param data - Dữ liệu extra data
1129
3738
  * @returns SdDocumentBuilderComment hoặc null nếu không có text được chọn
1130
3739
  */
1131
- add: (range, comment, data) => {
3740
+ add: (range, comment, args) => {
3741
+ // markerIdExternal khi truyền từ bên ngoài vào sẽ có thể là Date.now() hoặc uuidv4().
3742
+ // Miễn là đảm bảo markerIdExternal là unique.
3743
+ // Phục vụ cho case gọi API comment thành công thì mới sinh ra markerId.
3744
+ const { markerIdExternal, data } = args ?? {};
1132
3745
  if (!this.#editor)
1133
3746
  return null;
1134
3747
  const model = this.#editor.model;
@@ -1141,7 +3754,7 @@ class SdDocumentBuilder {
1141
3754
  return null;
1142
3755
  }
1143
3756
  // 4. Tạo ID unique cho marker
1144
- const markerId = `comment:${Date.now()}`;
3757
+ const markerId = markerIdExternal ? `comment:${markerIdExternal}` : `comment:${Date.now()}`;
1145
3758
  // 5. Tạo marker trong model
1146
3759
  model.change(writer => {
1147
3760
  writer.addMarker(markerId, {
@@ -1242,72 +3855,78 @@ class SdDocumentBuilder {
1242
3855
  }
1243
3856
  },
1244
3857
  };
1245
- // exportDocx(fileName: string = 'document.docx'): void {
1246
- // if (!this.#editor) return;
1247
- // // 1. Kiểm tra xem Editor đang ở chế độ Landscape hay Portrait
1248
- // // (Dựa vào class 'landscape' mà Plugin PageOrientation đã toggle trên View)
1249
- // const rootElement = this.#editor.editing.view.document.getRoot();
1250
- // const isLandscape = rootElement?.hasClass('landscape');
1251
- // const orientation = isLandscape ? 'landscape' : 'portrait';
1252
- // // 2. Lấy nội dung HTML
1253
- // const contentHtml = this.#editor.getData();
1254
- // // 3. Chuẩn bị HTML với CSS @page động
1255
- // const fullHtml = `
1256
- // <!DOCTYPE html>
1257
- // <html>
1258
- // <head>
1259
- // <meta charset="UTF-8">
1260
- // <style>
1261
- // /* --- CẤU HÌNH KHỔ GIẤY DỰA TRÊN TRẠNG THÁI --- */
1262
- // @page {
1263
- // size: A4 ${orientation}; /* Thêm portrait hoặc landscape vào đây */
1264
- // margin: 20mm;
1265
- // }
1266
- // body {
1267
- // font-family: 'Times New Roman', serif;
1268
- // font-size: 13pt;
1269
- // line-height: 1.5;
1270
- // }
1271
- // /* --- STYLE CHO BẢNG BIỂU --- */
1272
- // table {
1273
- // width: 100%;
1274
- // border-collapse: collapse;
1275
- // }
1276
- // td, th {
1277
- // border: 1px solid black;
1278
- // padding: 5px;
1279
- // }
1280
- // /* --- STYLE CHO BIẾN --- */
1281
- // .variable-widget {
1282
- // color: #1565c0;
1283
- // background-color: #e3f2fd;
1284
- // font-weight: bold;
1285
- // border: 1px solid #90caf9;
1286
- // padding: 0 4px;
1287
- // border-radius: 4px;
1288
- // }
1289
- // /* --- ẨN COMMENT KHI IN --- */
1290
- // .ck-comment-marker {
1291
- // background-color: transparent;
1292
- // border: none;
1293
- // }
1294
- // </style>
1295
- // </head>
1296
- // <body>
1297
- // ${contentHtml}
1298
- // </body>
1299
- // </html>
1300
- // `;
1301
- // // 3. Convert sang Blob (Dạng file Binary)
1302
- // asBlob(fullHtml, {
1303
- // orientation: 'portrait', // 'portrait' hoặc 'landscape'
1304
- // margins: { top: 720, right: 720, bottom: 720, left: 720 }, // Đơn vị twips (1440 twips = 1 inch)
1305
- // }).then(blob => {
1306
- // if (blob instanceof Blob) {
1307
- // SdUtilities.downloadBlob(blob, fileName);
1308
- // }
1309
- // });
1310
- // }
3858
+ // ========================================================================
3859
+ // 3. QUẢN LÝ VARIABLE
3860
+ // ========================================================================
3861
+ variable = {
3862
+ /**
3863
+ * Lấy tất cả variabes trong document
3864
+ * @returns Danh sách tất cả variables
3865
+ */
3866
+ all: () => {
3867
+ if (!this.#editor)
3868
+ return [];
3869
+ const model = this.#editor.model;
3870
+ const root = model.document.getRoot();
3871
+ if (!root)
3872
+ return [];
3873
+ const variables = [];
3874
+ try {
3875
+ const range = model.createRangeIn(root);
3876
+ for (const item of range.getItems()) {
3877
+ // Sử dụng item.is('element', 'variable') là chính xác
3878
+ if (item.is('element', 'variable')) {
3879
+ variables.push({
3880
+ id: item.getAttribute('id'),
3881
+ uuid: item.getAttribute('uuid'),
3882
+ value: item.getAttribute('value'),
3883
+ display: item.getAttribute('display'),
3884
+ });
3885
+ }
3886
+ }
3887
+ }
3888
+ catch (e) {
3889
+ console.error(e);
3890
+ return [];
3891
+ }
3892
+ return variables;
3893
+ },
3894
+ /**
3895
+ * Scroll tới vị trí của variable
3896
+ * @param uuid - uuid của variable FE sẽ tự sinh sau mỗi lần drop vào editor
3897
+ */
3898
+ scroll: (uuid) => {
3899
+ if (!this.#editor)
3900
+ return;
3901
+ const model = this.#editor.model;
3902
+ const root = model.document.getRoot();
3903
+ if (!root)
3904
+ return;
3905
+ let targetElement = null;
3906
+ const range = model.createRangeIn(root);
3907
+ for (const item of range.getItems()) {
3908
+ if (item.is('element', 'variable') && item.getAttribute('uuid') === uuid) {
3909
+ targetElement = item;
3910
+ break;
3911
+ }
3912
+ }
3913
+ if (targetElement) {
3914
+ const viewElement = this.#editor.editing.mapper.toViewElement(targetElement);
3915
+ if (viewElement) {
3916
+ const domElement = this.#editor.editing.view.domConverter.viewToDom(viewElement);
3917
+ if (domElement) {
3918
+ domElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
3919
+ model.change(writer => {
3920
+ writer.setSelection(targetElement, 'on');
3921
+ });
3922
+ }
3923
+ }
3924
+ }
3925
+ else {
3926
+ console.warn(`Variable với id ${uuid} không tìm thấy trong tài liệu.`);
3927
+ }
3928
+ },
3929
+ };
1311
3930
  // ========================================================================
1312
3931
  // 4. HÀM EXPORT DOCX (FULL HEADER/FOOTER + PAGE NUMBER)
1313
3932
  // ========================================================================
@@ -1412,22 +4031,38 @@ class SdDocumentBuilder {
1412
4031
  // Thêm '\ufeff' (BOM) để fix lỗi font tiếng Việt
1413
4032
  const blob = new Blob(['\ufeff', fullHtml], { type: 'application/msword' });
1414
4033
  SdUtilities.downloadBlob(blob, fileName);
1415
- // 3. Convert & Save
1416
- // asBlob(fullHtml, {
1417
- // orientation: orientation as 'portrait' | 'landscape',
1418
- // margins: { top: 720, right: 720, bottom: 720, left: 720 },
1419
- // }).then(blob => {
1420
- // if (blob instanceof Blob) {
1421
- // SdUtilities.downloadBlob(blob, fileName);
1422
- // }
1423
- // });
1424
4034
  }
4035
+ hightSelectRange = (range) => {
4036
+ if (!range)
4037
+ return;
4038
+ const editor = this.#editor;
4039
+ editor.model.change(writer => {
4040
+ // Xóa marker cũ (nếu có)
4041
+ if (editor.model.markers.has('highlightRange')) {
4042
+ writer.removeMarker('highlightRange');
4043
+ }
4044
+ // Tạo marker mới
4045
+ writer.addMarker('highlightRange', {
4046
+ usingOperation: false, // Không lưu vào lịch sử Undo/Redo
4047
+ affectsData: false, // Không ảnh hưởng đến data lấy ra (getData)
4048
+ range: range,
4049
+ });
4050
+ });
4051
+ };
4052
+ removeHighlightSeclectRange = () => {
4053
+ const editor = this.#editor;
4054
+ editor.model.change(writer => {
4055
+ if (editor.model.markers.has('highlightRange')) {
4056
+ writer.removeMarker('highlightRange');
4057
+ }
4058
+ });
4059
+ };
1425
4060
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: SdDocumentBuilder, deps: [], target: i0.ɵɵFactoryTarget.Component });
1426
- 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!important;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 4px;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}: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", "@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"] }] });
4061
+ 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.15}: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;font-size:inherit;line-height:inherit;margin-bottom:4px}: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:4px;text-indent:0}:host ::ng-deep .ck-content ul,:host ::ng-deep .ck-content ol{padding-left:20px!important;margin-left:0!important;margin-bottom:4px}:host ::ng-deep .ck-content li{margin-bottom:0}:host ::ng-deep .ck-content table{margin-bottom:4px}\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"] }] });
1427
4062
  }
1428
4063
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: SdDocumentBuilder, decorators: [{
1429
4064
  type: Component,
1430
- 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!important;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 4px;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}: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", "@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"] }]
4065
+ 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.15}: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;font-size:inherit;line-height:inherit;margin-bottom:4px}: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:4px;text-indent:0}:host ::ng-deep .ck-content ul,:host ::ng-deep .ck-content ol{padding-left:20px!important;margin-left:0!important;margin-bottom:4px}:host ::ng-deep .ck-content li{margin-bottom:0}:host ::ng-deep .ck-content table{margin-bottom:4px}\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"] }]
1431
4066
  }], propDecorators: { option: [{
1432
4067
  type: Input,
1433
4068
  args: [{ required: true }]