@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.
- package/README.md +686 -33
- package/assets/scss/ckeditor5.scss +60 -2
- package/assets/scss/core/bootstrap.scss +17 -0
- package/assets/scss/core/grid.scss +40 -0
- package/assets/scss/sd-core.scss +1 -0
- package/components/avatar/index.d.ts +1 -0
- package/components/avatar/src/avatar.component.d.ts +15 -0
- package/components/badge/src/badge.component.d.ts +77 -19
- package/components/button/src/button.component.d.ts +26 -28
- package/components/code-editor/index.d.ts +1 -0
- package/components/code-editor/src/code-editor.component.d.ts +25 -0
- package/components/document-builder/index.d.ts +1 -0
- package/components/document-builder/src/document-builder.component.d.ts +18 -36
- package/components/document-builder/src/document-builder.model.d.ts +10 -10
- package/components/document-builder/src/plugins/block-space/block-space.plugin.d.ts +9 -0
- package/components/document-builder/src/plugins/ck-comment/ck-comment.plugin.d.ts +44 -0
- package/components/document-builder/src/plugins/ck-comment/ck-comment.plugin.model.d.ts +56 -0
- package/components/document-builder/src/plugins/heading/heading.plugin.d.ts +1 -0
- package/components/document-builder/src/plugins/highlight-range/highlight-range.plugin.d.ts +4 -0
- package/components/document-builder/src/plugins/image-custom/image-custom.plugin.d.ts +31 -0
- package/components/document-builder/src/plugins/index.d.ts +7 -2
- package/components/document-builder/src/plugins/page-orientation/page-orientation.plugin.d.ts +2 -2
- package/components/document-builder/src/plugins/paste-handler/filters/bookmark.d.ts +14 -0
- package/components/document-builder/src/plugins/paste-handler/filters/br.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/image.d.ts +25 -0
- package/components/document-builder/src/plugins/paste-handler/filters/list.d.ts +29 -0
- package/components/document-builder/src/plugins/paste-handler/filters/parse.d.ts +35 -0
- package/components/document-builder/src/plugins/paste-handler/filters/removeboldwrapper.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/removegooglesheetstag.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/removeinvalidtablewidth.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/removemsattributes.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/removestyleblock.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/removexmlns.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/replacemsfootnotes.d.ts +54 -0
- package/components/document-builder/src/plugins/paste-handler/filters/replacetabswithinprewithspaces.d.ts +24 -0
- package/components/document-builder/src/plugins/paste-handler/filters/space.d.ts +27 -0
- package/components/document-builder/src/plugins/paste-handler/filters/table.d.ts +16 -0
- package/components/document-builder/src/plugins/paste-handler/filters/utils.d.ts +25 -0
- package/components/document-builder/src/plugins/paste-handler/index.d.ts +35 -0
- package/components/document-builder/src/plugins/paste-handler/normalizers/googledocsnormalizer.d.ts +31 -0
- package/components/document-builder/src/plugins/paste-handler/normalizers/googlesheetsnormalizer.d.ts +31 -0
- package/components/document-builder/src/plugins/paste-handler/normalizers/mswordnormalizer.d.ts +29 -0
- package/components/document-builder/src/plugins/paste-handler/types.d.ts +30 -0
- package/components/document-builder/src/plugins/table-custom/index.d.ts +34 -0
- package/components/index.d.ts +4 -0
- package/components/mini-editor/index.d.ts +2 -0
- package/components/mini-editor/src/mini-editor.component.d.ts +90 -0
- package/components/mini-editor/src/mini-editor.model.d.ts +44 -0
- package/components/side-drawer/src/side-drawer.component.d.ts +1 -2
- package/components/tab-router/src/components/tab-router-item/tab-router-item.component.d.ts +4 -1
- package/components/tab-router/src/components/tab-router-outlet/tab-router-outlet.component.d.ts +3 -15
- package/components/table/src/components/column-filter/column-filter.component.d.ts +3 -3
- package/components/table/src/components/external-filter/external-filter.component.d.ts +1 -1
- package/components/table/src/components/selector-action/action-filter.pipe.d.ts +11 -10
- package/components/table/src/directives/index.d.ts +2 -0
- package/components/table/src/directives/sd-table-column-filter-def.directive.d.ts +9 -0
- package/components/table/src/directives/sticky-shadow.directive.d.ts +17 -0
- package/components/table/src/models/table-column.model.d.ts +33 -34
- package/components/table/src/models/table-command.model.d.ts +7 -3
- package/components/table/src/models/table-item.model.d.ts +5 -4
- package/components/table/src/models/table-option-export.model.d.ts +3 -2
- package/components/table/src/models/table-option-selector.model.d.ts +11 -10
- package/components/table/src/models/table-option.model.d.ts +10 -8
- package/components/table/src/services/index.d.ts +3 -0
- package/components/table/src/services/table-export/table-export.service.d.ts +26 -0
- package/components/table/src/services/table-filter/table-filter.model.d.ts +5 -4
- package/components/table/src/services/table-format/table-format.service.d.ts +16 -0
- package/components/table/src/table.component.d.ts +39 -53
- package/components/view/index.d.ts +1 -0
- package/components/view/src/view.component.d.ts +16 -0
- package/components/workflow/src/models/index.d.ts +1 -0
- package/directives/index.d.ts +1 -0
- package/directives/src/sd-href.directive.d.ts +9 -0
- package/fesm2022/sd-angular-core-components-avatar.mjs +90 -0
- package/fesm2022/sd-angular-core-components-avatar.mjs.map +1 -0
- package/fesm2022/sd-angular-core-components-badge.mjs +101 -91
- package/fesm2022/sd-angular-core-components-badge.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-button.mjs +64 -96
- package/fesm2022/sd-angular-core-components-button.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-code-editor.mjs +129 -0
- package/fesm2022/sd-angular-core-components-code-editor.mjs.map +1 -0
- package/fesm2022/sd-angular-core-components-document-builder.mjs +3635 -521
- package/fesm2022/sd-angular-core-components-document-builder.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-history.mjs +1 -1
- package/fesm2022/sd-angular-core-components-history.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-import-excel.mjs +1 -1
- package/fesm2022/sd-angular-core-components-import-excel.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-mini-editor.mjs +326 -0
- package/fesm2022/sd-angular-core-components-mini-editor.mjs.map +1 -0
- package/fesm2022/sd-angular-core-components-preview.mjs +1 -1
- package/fesm2022/sd-angular-core-components-preview.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-side-drawer.mjs +21 -8
- package/fesm2022/sd-angular-core-components-side-drawer.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-tab-router.mjs +152 -226
- package/fesm2022/sd-angular-core-components-tab-router.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-table.mjs +1234 -1076
- package/fesm2022/sd-angular-core-components-table.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-upload-file.mjs +1 -1
- package/fesm2022/sd-angular-core-components-upload-file.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-view.mjs +45 -0
- package/fesm2022/sd-angular-core-components-view.mjs.map +1 -0
- package/fesm2022/sd-angular-core-components-workflow.mjs +33 -43
- package/fesm2022/sd-angular-core-components-workflow.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components.mjs +4 -0
- package/fesm2022/sd-angular-core-components.mjs.map +1 -1
- package/fesm2022/sd-angular-core-directives.mjs +80 -27
- package/fesm2022/sd-angular-core-directives.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-autocomplete.mjs +257 -363
- package/fesm2022/sd-angular-core-forms-autocomplete.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-date-range.mjs +145 -245
- package/fesm2022/sd-angular-core-forms-date-range.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-date.mjs +137 -273
- package/fesm2022/sd-angular-core-forms-date.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-datetime.mjs +136 -288
- package/fesm2022/sd-angular-core-forms-datetime.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-input-number.mjs +174 -337
- package/fesm2022/sd-angular-core-forms-input-number.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-input.mjs +130 -285
- package/fesm2022/sd-angular-core-forms-input.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-radio.mjs +3 -2
- package/fesm2022/sd-angular-core-forms-radio.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-select.mjs +337 -420
- package/fesm2022/sd-angular-core-forms-select.mjs.map +1 -1
- package/fesm2022/sd-angular-core-forms-textarea.mjs +121 -226
- package/fesm2022/sd-angular-core-forms-textarea.mjs.map +1 -1
- package/fesm2022/sd-angular-core-modules-keycloak.mjs +126 -0
- package/fesm2022/sd-angular-core-modules-keycloak.mjs.map +1 -0
- package/fesm2022/sd-angular-core-modules-layout.mjs +440 -431
- package/fesm2022/sd-angular-core-modules-layout.mjs.map +1 -1
- package/fesm2022/sd-angular-core-modules.mjs +1 -1
- package/fesm2022/sd-angular-core-services-api.mjs +5 -10
- package/fesm2022/sd-angular-core-services-api.mjs.map +1 -1
- package/fesm2022/sd-angular-core-services-confirm.mjs +2 -2
- package/fesm2022/sd-angular-core-services-confirm.mjs.map +1 -1
- package/fesm2022/sd-angular-core-services-docx.mjs +173 -0
- package/fesm2022/sd-angular-core-services-docx.mjs.map +1 -0
- package/fesm2022/sd-angular-core-services-notify.mjs +2 -2
- package/fesm2022/sd-angular-core-services-notify.mjs.map +1 -1
- package/fesm2022/sd-angular-core-services.mjs +1 -0
- package/fesm2022/sd-angular-core-services.mjs.map +1 -1
- package/fesm2022/sd-angular-core-utilities-extensions.mjs +21 -45
- package/fesm2022/sd-angular-core-utilities-extensions.mjs.map +1 -1
- package/fesm2022/sd-angular-core-utilities-models.mjs +15 -1
- package/fesm2022/sd-angular-core-utilities-models.mjs.map +1 -1
- package/forms/autocomplete/src/autocomplete.component.d.ts +51 -56
- package/forms/date/src/date.component.d.ts +41 -45
- package/forms/date-range/src/date-range.component.d.ts +28 -33
- package/forms/datetime/src/datetime.component.d.ts +41 -48
- package/forms/input/src/input.component.d.ts +46 -56
- package/forms/input-number/src/input-number.component.d.ts +47 -54
- package/forms/select/src/select.component.d.ts +60 -58
- package/forms/textarea/src/textarea.component.d.ts +34 -41
- package/modules/index.d.ts +1 -1
- package/modules/keycloak/index.d.ts +4 -0
- package/modules/keycloak/keycloak.configuration.d.ts +11 -0
- package/modules/keycloak/keycloak.interceptor.d.ts +2 -0
- package/modules/keycloak/keycloak.module.d.ts +18 -0
- package/modules/keycloak/keycloak.service.d.ts +14 -0
- package/modules/layout/components/layout-main/layout-main.component.d.ts +7 -12
- package/modules/layout/components/sidebar-v1/components/sidebar/sidebar.component.d.ts +22 -29
- package/modules/layout/components/sidebar-v1/components/user/user.component.d.ts +11 -17
- package/modules/layout/components/sidebar-v1/main.component.d.ts +14 -14
- package/modules/layout/configurations/layout.configuration.d.ts +46 -3
- package/modules/layout/modules/forbidden/pages/root/root.component.d.ts +3 -8
- package/modules/layout/modules/home/components/home-page/home-page.component.d.ts +2 -5
- package/modules/layout/modules/not-found/pages/root/root.component.d.ts +3 -8
- package/modules/layout/services/index.d.ts +1 -0
- package/modules/layout/services/layout.service.d.ts +10 -0
- package/modules/layout/services/menu/menu.model.d.ts +2 -0
- package/modules/layout/services/storage/storage.service.d.ts +0 -3
- package/package.json +90 -67
- package/sd-angular-core-19.0.0-beta.51.tgz +0 -0
- package/services/api/src/api.model.d.ts +6 -1
- package/services/confirm/src/lib/confirm.service.d.ts +1 -0
- package/services/docx/index.d.ts +1 -0
- package/services/docx/src/lib/docx.model.d.ts +9 -0
- package/services/docx/src/lib/docx.service.d.ts +13 -0
- package/services/docx/src/public-api.d.ts +2 -0
- package/services/index.d.ts +1 -0
- package/utilities/extensions/src/string.extension.d.ts +2 -0
- package/utilities/extensions/src/utility.extension.d.ts +1 -0
- package/utilities/models/index.d.ts +3 -0
- package/utilities/models/src/filter.model.d.ts +14 -2
- package/utilities/models/src/icon.model.d.ts +2 -0
- package/utilities/models/src/nested-key-of.model.d.ts +5 -0
- package/utilities/models/src/pattern.model.d.ts +1 -1
- package/utilities/models/src/unwrap-signal.model.d.ts +6 -0
- package/components/document-builder/src/plugins/comment/comment.plugin.d.ts +0 -4
- package/components/document-builder/src/plugins/table-fit/table-fit.plugin.d.ts +0 -4
- package/fesm2022/sd-angular-core-modules-oidc.mjs +0 -127
- package/fesm2022/sd-angular-core-modules-oidc.mjs.map +0 -1
- package/modules/oidc/dynamic-sts.loader.d.ts +0 -11
- package/modules/oidc/index.d.ts +0 -2
- package/modules/oidc/oidc.configuration.d.ts +0 -11
- 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,
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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 có styled text và set style inherit
|
|
328
|
+
const downcastConversion = editor.conversion.for('downcast');
|
|
329
|
+
Object.keys(headingDefaults).forEach(modelName => {
|
|
330
|
+
downcastConversion.add(dispatcher => {
|
|
331
|
+
dispatcher.on(`insert:${modelName}`, createHeadingHandler(editor, modelName, headingDefaults), { priority: 'low' });
|
|
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'
|
|
382
|
+
allowAttributes: ['id', 'uuid', 'value', 'display'],
|
|
250
383
|
});
|
|
251
|
-
// 2.
|
|
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.
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
viewWriter.removeClass('ck-clipboard-drop-target-line', viewRoot);
|
|
483
|
+
console.error(e);
|
|
484
|
+
}
|
|
485
|
+
finally {
|
|
486
|
+
// 5. Dọn dẹp drop-target dù 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
|
-
//
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
writer.
|
|
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
|
|
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
|
|
499
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
720
|
+
// Xử lý 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
|
-
|
|
520
|
-
|
|
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
|
-
|
|
523
|
-
this.listenTo(insertTableCommand, 'execute', (evt, args) => {
|
|
842
|
+
this.listenTo(insertTableCommand, 'execute', () => {
|
|
524
843
|
editor.model.change(writer => {
|
|
525
|
-
|
|
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
|
-
|
|
535
|
-
writer.setAttribute('
|
|
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
|
|
592
|
-
static pluginName = '
|
|
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 ` `. 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 .
|
|
2865
|
+
.replace(/<o:p>( |\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 ` ` 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 ` ` 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 / 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
|
-
|
|
4054
|
+
TableCustom,
|
|
4055
|
+
PageOrientation,
|
|
909
4056
|
ImageUploadPlugin,
|
|
910
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
984
|
-
classes:
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
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\"
|
|
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\"
|
|
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 }]
|