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