@sd-angular/core 19.0.0-beta.5 → 19.0.0-beta.51

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