@sd-angular/core 19.0.0-beta.26 → 19.0.0-beta.27
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 +1 -0
- package/components/document-builder/src/document-builder.model.d.ts +8 -1
- 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 +1 -0
- package/components/document-builder/src/plugins/index.d.ts +4 -1
- package/components/document-builder/src/plugins/page-orientation/page-orientation.plugin.d.ts +2 -2
- package/components/document-builder/src/plugins/paste-handler/filters/bookmark.d.ts +14 -0
- package/components/document-builder/src/plugins/paste-handler/filters/br.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/image.d.ts +25 -0
- package/components/document-builder/src/plugins/paste-handler/filters/list.d.ts +29 -0
- package/components/document-builder/src/plugins/paste-handler/filters/parse.d.ts +35 -0
- package/components/document-builder/src/plugins/paste-handler/filters/removeboldwrapper.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/removegooglesheetstag.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/removeinvalidtablewidth.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/removemsattributes.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/removestyleblock.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/removexmlns.d.ts +15 -0
- package/components/document-builder/src/plugins/paste-handler/filters/replacemsfootnotes.d.ts +54 -0
- package/components/document-builder/src/plugins/paste-handler/filters/replacetabswithinprewithspaces.d.ts +24 -0
- package/components/document-builder/src/plugins/paste-handler/filters/space.d.ts +27 -0
- package/components/document-builder/src/plugins/paste-handler/filters/table.d.ts +16 -0
- package/components/document-builder/src/plugins/paste-handler/filters/utils.d.ts +25 -0
- package/components/document-builder/src/plugins/paste-handler/index.d.ts +35 -0
- package/components/document-builder/src/plugins/paste-handler/normalizers/googledocsnormalizer.d.ts +31 -0
- package/components/document-builder/src/plugins/paste-handler/normalizers/googlesheetsnormalizer.d.ts +31 -0
- package/components/document-builder/src/plugins/paste-handler/normalizers/mswordnormalizer.d.ts +29 -0
- package/components/document-builder/src/plugins/paste-handler/types.d.ts +30 -0
- package/components/document-builder/src/plugins/{table-fit/table-fit.plugin.d.ts → table-custom/index.d.ts} +6 -1
- package/fesm2022/sd-angular-core-components-document-builder.mjs +2097 -174
- package/fesm2022/sd-angular-core-components-document-builder.mjs.map +1 -1
- package/fesm2022/sd-angular-core-components-table.mjs +3 -3
- package/fesm2022/sd-angular-core-components-table.mjs.map +1 -1
- package/fesm2022/sd-angular-core-directives.mjs +29 -25
- package/fesm2022/sd-angular-core-directives.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.mjs +1 -0
- package/fesm2022/sd-angular-core-services.mjs.map +1 -1
- package/package.json +44 -36
- package/sd-angular-core-19.0.0-beta.27.tgz +0 -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/sd-angular-core-19.0.0-beta.26.tgz +0 -0
|
@@ -3,7 +3,7 @@ import { EventEmitter, Output, Input, Component } from '@angular/core';
|
|
|
3
3
|
import { CommonModule } from '@angular/common';
|
|
4
4
|
import * as i1 from '@ckeditor/ckeditor5-angular';
|
|
5
5
|
import { CKEditorModule } from '@ckeditor/ckeditor5-angular';
|
|
6
|
-
import { Plugin, ButtonView, ClassicEditor, Essentials, Paragraph, Bold, Italic, Underline, FontSize, FontColor, FontBackgroundColor, Alignment, Widget, toWidget,
|
|
6
|
+
import { Plugin, ButtonView, ClassicEditor, Essentials, Paragraph, Bold, Italic, Underline, FontSize, FontColor, FontBackgroundColor, Alignment, Widget, toWidget, ViewUpcastWriter, Matcher, ViewDocument, ViewDomConverter, ClipboardPipeline, FontFamily, Heading, List, Table, TableToolbar, TableProperties, TableCellProperties, TableColumnResize, PageBreak, Undo, Subscript, Superscript, Image, ImageUpload, ImageToolbar, ImageCaption, ImageResize, ImageStyle, ImageBlock, Indent, IndentBlock } from 'ckeditor5';
|
|
7
7
|
import { Subscription, Subject, throttleTime } from 'rxjs';
|
|
8
8
|
import { SdResolveMaybeAsync, hslToHex, rgbToHex, SdUtilities } from '@sd-angular/core/utilities';
|
|
9
9
|
import { v4 } from 'uuid';
|
|
@@ -130,7 +130,181 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImpo
|
|
|
130
130
|
type: Output
|
|
131
131
|
}] } });
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Cấu hình màu cho Document Builder
|
|
135
|
+
* Bảng màu tập trung và cấu hình cho việc lựa chọn màu nhất quán
|
|
136
|
+
*/
|
|
137
|
+
/**
|
|
138
|
+
* Trả về bảng màu chung được sử dụng trong tất cả tính năng của document builder
|
|
139
|
+
* @returns Mảng các tùy chọn màu được định sẵn với giá trị hex và label
|
|
140
|
+
*/
|
|
141
|
+
function getPresetColors() {
|
|
142
|
+
return [
|
|
143
|
+
{ color: '#000000', label: 'Black' },
|
|
144
|
+
{ color: '#4D4D4D', label: 'Dim grey' },
|
|
145
|
+
{ color: '#999999', label: 'Grey' },
|
|
146
|
+
{ color: '#E6E6E6', label: 'Light grey' },
|
|
147
|
+
{ color: '#FFFFFF', label: 'White' },
|
|
148
|
+
{ color: '#E64D4D', label: 'Red' },
|
|
149
|
+
{ color: '#E6994D', label: 'Orange' },
|
|
150
|
+
{ color: '#E6E64D', label: 'Yellow' },
|
|
151
|
+
{ color: '#99E64D', label: 'Light green' },
|
|
152
|
+
{ color: '#4DE64D', label: 'Green' },
|
|
153
|
+
{ color: '#4DE699', label: 'Aquamarine' },
|
|
154
|
+
{ color: '#4DE6E6', label: 'Turquoise' },
|
|
155
|
+
{ color: '#4D99E6', label: 'Light blue' },
|
|
156
|
+
{ color: '#4D4DE6', label: 'Blue' },
|
|
157
|
+
{ color: '#994DE6', label: 'Purple' },
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Trả về cấu hình bộ chọn màu với định dạng hex
|
|
162
|
+
* @returns Đối tượng cấu hình bộ chọn màu
|
|
163
|
+
*/
|
|
164
|
+
function getColorPickerConfig() {
|
|
165
|
+
return {
|
|
166
|
+
format: 'hex',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Trả về cấu hình kích thước font cho document builder
|
|
171
|
+
* @returns Mảng các tùy chọn kích thước font được định sẵn
|
|
172
|
+
*/
|
|
173
|
+
function getFontSizeOptions() {
|
|
174
|
+
return [
|
|
175
|
+
{
|
|
176
|
+
title: '9',
|
|
177
|
+
model: '9pt',
|
|
178
|
+
view: {
|
|
179
|
+
name: 'span',
|
|
180
|
+
styles: { 'font-size': '9pt' },
|
|
181
|
+
priority: 7,
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
title: '10',
|
|
186
|
+
model: '10pt',
|
|
187
|
+
view: {
|
|
188
|
+
name: 'span',
|
|
189
|
+
styles: { 'font-size': '10pt' },
|
|
190
|
+
priority: 7,
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
title: '11',
|
|
195
|
+
model: '11pt',
|
|
196
|
+
view: {
|
|
197
|
+
name: 'span',
|
|
198
|
+
styles: { 'font-size': '11pt' },
|
|
199
|
+
priority: 7,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
title: '12',
|
|
204
|
+
model: '12pt',
|
|
205
|
+
view: {
|
|
206
|
+
name: 'span',
|
|
207
|
+
styles: { 'font-size': '12pt' },
|
|
208
|
+
priority: 7,
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
title: '13',
|
|
213
|
+
model: '13pt',
|
|
214
|
+
view: {
|
|
215
|
+
name: 'span',
|
|
216
|
+
styles: { 'font-size': '13pt' },
|
|
217
|
+
priority: 7,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
title: '14',
|
|
222
|
+
model: '14pt',
|
|
223
|
+
view: {
|
|
224
|
+
name: 'span',
|
|
225
|
+
styles: { 'font-size': '14pt' },
|
|
226
|
+
priority: 7,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
title: '16',
|
|
231
|
+
model: '16pt',
|
|
232
|
+
view: {
|
|
233
|
+
name: 'span',
|
|
234
|
+
styles: { 'font-size': '16pt' },
|
|
235
|
+
priority: 7,
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
title: '18',
|
|
240
|
+
model: '18pt',
|
|
241
|
+
view: {
|
|
242
|
+
name: 'span',
|
|
243
|
+
styles: { 'font-size': '18pt' },
|
|
244
|
+
priority: 7,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
title: '20',
|
|
249
|
+
model: '20pt',
|
|
250
|
+
view: {
|
|
251
|
+
name: 'span',
|
|
252
|
+
styles: { 'font-size': '20pt' },
|
|
253
|
+
priority: 7,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
title: '24',
|
|
258
|
+
model: '24pt',
|
|
259
|
+
view: {
|
|
260
|
+
name: 'span',
|
|
261
|
+
styles: { 'font-size': '24pt' },
|
|
262
|
+
priority: 7,
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
function getHeadingOptions() {
|
|
268
|
+
return [
|
|
269
|
+
{
|
|
270
|
+
model: 'paragraph',
|
|
271
|
+
title: 'Paragraph',
|
|
272
|
+
class: 'ck-heading_paragraph',
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
model: 'heading1',
|
|
276
|
+
view: {
|
|
277
|
+
name: 'h1',
|
|
278
|
+
styles: { 'font-size': '24pt', 'font-weight': 'bold', 'line-height': '1.15' },
|
|
279
|
+
},
|
|
280
|
+
title: 'Heading 1',
|
|
281
|
+
class: 'ck-heading_heading1',
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
model: 'heading2',
|
|
285
|
+
view: {
|
|
286
|
+
name: 'h2',
|
|
287
|
+
styles: { 'font-size': '20pt', 'font-weight': 'bold', 'line-height': '1.15' },
|
|
288
|
+
},
|
|
289
|
+
title: 'Heading 2',
|
|
290
|
+
class: 'ck-heading_heading2',
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
model: 'heading3',
|
|
294
|
+
view: {
|
|
295
|
+
name: 'h3',
|
|
296
|
+
styles: { 'font-size': '16pt', 'font-weight': 'bold', 'line-height': '1.15' },
|
|
297
|
+
},
|
|
298
|
+
title: 'Heading 3',
|
|
299
|
+
class: 'ck-heading_heading3',
|
|
300
|
+
},
|
|
301
|
+
];
|
|
302
|
+
}
|
|
303
|
+
|
|
133
304
|
class HeadingPlugin extends Plugin {
|
|
305
|
+
static get pluginName() {
|
|
306
|
+
return 'HeadingPlugin';
|
|
307
|
+
}
|
|
134
308
|
init() {
|
|
135
309
|
const editor = this.editor;
|
|
136
310
|
editor.conversion.for('editingDowncast').markerToHighlight({
|
|
@@ -139,8 +313,55 @@ class HeadingPlugin extends Plugin {
|
|
|
139
313
|
classes: 'ck-heading-highlight',
|
|
140
314
|
},
|
|
141
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' });
|
|
332
|
+
});
|
|
333
|
+
});
|
|
142
334
|
}
|
|
143
335
|
}
|
|
336
|
+
function createHeadingHandler(editor, modelName, headingDefaults) {
|
|
337
|
+
return (evt, data, conversionApi) => {
|
|
338
|
+
const element = data.item;
|
|
339
|
+
const viewElement = editor.editing.mapper.toViewElement(element);
|
|
340
|
+
if (!viewElement)
|
|
341
|
+
return;
|
|
342
|
+
const children = Array.from(element.getChildren());
|
|
343
|
+
const hasStyledText = children.some((child) => {
|
|
344
|
+
if (child.is('$text')) {
|
|
345
|
+
const attrs = Array.from(child.getAttributes());
|
|
346
|
+
return attrs.length > 0;
|
|
347
|
+
}
|
|
348
|
+
return !child.is('$text');
|
|
349
|
+
});
|
|
350
|
+
editor.editing.view.change((writer) => {
|
|
351
|
+
if (children.length === 1 && hasStyledText) {
|
|
352
|
+
// Có styled text → bỏ style mặc định
|
|
353
|
+
writer.setStyle('font-size', 'inherit', viewElement);
|
|
354
|
+
writer.setStyle('line-height', 'inherit', viewElement);
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
// Không có styled text → set lại style mặc định từ config
|
|
358
|
+
const defaults = headingDefaults[modelName];
|
|
359
|
+
writer.setStyle('font-size', defaults.fontSize, viewElement);
|
|
360
|
+
writer.setStyle('line-height', defaults.lineHeight, viewElement);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
};
|
|
364
|
+
}
|
|
144
365
|
|
|
145
366
|
class CommentPlugin extends Plugin {
|
|
146
367
|
init() {
|
|
@@ -572,7 +793,7 @@ class VariablePlugin extends Plugin {
|
|
|
572
793
|
};
|
|
573
794
|
}
|
|
574
795
|
|
|
575
|
-
class
|
|
796
|
+
class TableCustom extends Plugin {
|
|
576
797
|
init() {
|
|
577
798
|
const editor = this.editor;
|
|
578
799
|
// Can thiệp vào quá trình convert từ View (HTML Paste) sang Model
|
|
@@ -589,6 +810,51 @@ class TableFitPlugin extends Plugin {
|
|
|
589
810
|
}
|
|
590
811
|
}
|
|
591
812
|
}, { priority: 'low' });
|
|
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' });
|
|
592
858
|
});
|
|
593
859
|
const findInnerTable = (viewElement) => {
|
|
594
860
|
if (!viewElement)
|
|
@@ -631,6 +897,37 @@ class TableFitPlugin extends Plugin {
|
|
|
631
897
|
viewWriter.setStyle('width', '100%', innerTable);
|
|
632
898
|
viewWriter.setStyle('width', '100%', viewElement);
|
|
633
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
|
+
});
|
|
634
931
|
});
|
|
635
932
|
// Lắng nghe lệnh insertTable
|
|
636
933
|
const insertTableCommand = editor.commands.get('insertTable');
|
|
@@ -682,6 +979,24 @@ class TableFitPlugin extends Plugin {
|
|
|
682
979
|
// Setup style preservation on model change
|
|
683
980
|
this._setupStylePreservationOnModelChange();
|
|
684
981
|
}
|
|
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;
|
|
999
|
+
}
|
|
685
1000
|
/**
|
|
686
1001
|
* Apply default table width
|
|
687
1002
|
*/
|
|
@@ -692,22 +1007,31 @@ class TableFitPlugin extends Plugin {
|
|
|
692
1007
|
}
|
|
693
1008
|
/**
|
|
694
1009
|
* Apply default borders to all cells in a table
|
|
1010
|
+
* Nếu cell đã có border rồi thì bỏ qua
|
|
695
1011
|
*/
|
|
696
1012
|
_applyCellBorders(writer, tableElement) {
|
|
697
1013
|
if (!tableElement)
|
|
698
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');
|
|
699
1019
|
for (const row of tableElement.getChildren()) {
|
|
700
1020
|
for (const cell of row.getChildren()) {
|
|
701
|
-
|
|
702
|
-
|
|
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;
|
|
703
1026
|
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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')) {
|
|
711
1035
|
writer.setAttribute('tableCellPadding', '0.4em', cell);
|
|
712
1036
|
}
|
|
713
1037
|
}
|
|
@@ -1068,8 +1392,8 @@ class ImageCustomPlugin extends Plugin {
|
|
|
1068
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>';
|
|
1069
1393
|
// Icon khổ ngang (Mới)
|
|
1070
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>';
|
|
1071
|
-
class
|
|
1072
|
-
static pluginName = '
|
|
1395
|
+
class PageOrientation extends Plugin {
|
|
1396
|
+
static pluginName = 'PageOrientation';
|
|
1073
1397
|
_currentOrientation = 'PORTRAIT';
|
|
1074
1398
|
orientationChangeEmitter;
|
|
1075
1399
|
buttonView;
|
|
@@ -1138,146 +1462,1758 @@ class PageOrientationPlugin extends Plugin {
|
|
|
1138
1462
|
}
|
|
1139
1463
|
|
|
1140
1464
|
/**
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
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
|
|
1143
1467
|
*/
|
|
1144
1468
|
/**
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1469
|
+
* Transforms `<a>` elements which are bookmarks by moving their children after the element.
|
|
1470
|
+
*
|
|
1471
|
+
* @internal
|
|
1147
1472
|
*/
|
|
1148
|
-
function
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
{ color: '#4D4DE6', label: 'Blue' },
|
|
1164
|
-
{ color: '#994DE6', label: 'Purple' },
|
|
1165
|
-
];
|
|
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
|
+
}
|
|
1166
1488
|
}
|
|
1489
|
+
|
|
1167
1490
|
/**
|
|
1168
|
-
*
|
|
1169
|
-
*
|
|
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
|
|
1170
1493
|
*/
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
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
|
+
}
|
|
1175
1518
|
}
|
|
1176
1519
|
/**
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
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.
|
|
1179
1525
|
*/
|
|
1180
|
-
function
|
|
1181
|
-
return
|
|
1182
|
-
{
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
{
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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',
|
|
1262
2769
|
},
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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',
|
|
1266
3167
|
view: {
|
|
1267
|
-
|
|
1268
|
-
styles: { 'font-size': '24pt' },
|
|
1269
|
-
priority: 7,
|
|
3168
|
+
classes: 'highlight-range',
|
|
1270
3169
|
},
|
|
1271
|
-
}
|
|
1272
|
-
|
|
3170
|
+
});
|
|
3171
|
+
}
|
|
1273
3172
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
+
}
|
|
1281
3217
|
}
|
|
1282
3218
|
|
|
1283
3219
|
/**
|
|
@@ -1327,18 +3263,6 @@ function normalize(content) {
|
|
|
1327
3263
|
return normalized;
|
|
1328
3264
|
}
|
|
1329
3265
|
|
|
1330
|
-
class HighlightRangePlugin extends Plugin {
|
|
1331
|
-
init() {
|
|
1332
|
-
const editor = this.editor;
|
|
1333
|
-
editor.conversion.for('editingDowncast').markerToHighlight({
|
|
1334
|
-
model: 'highlightRange',
|
|
1335
|
-
view: {
|
|
1336
|
-
classes: 'highlight-range',
|
|
1337
|
-
},
|
|
1338
|
-
});
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
3266
|
class SdDocumentBuilder {
|
|
1343
3267
|
option;
|
|
1344
3268
|
disabled = false;
|
|
@@ -1363,7 +3287,7 @@ class SdDocumentBuilder {
|
|
|
1363
3287
|
getOption: () => this.option,
|
|
1364
3288
|
licenseKey: 'GPL', // Hoặc key thương mại nếu có
|
|
1365
3289
|
plugins: [
|
|
1366
|
-
GeneralHtmlSupport,
|
|
3290
|
+
// GeneralHtmlSupport,
|
|
1367
3291
|
FontSize,
|
|
1368
3292
|
FontColor,
|
|
1369
3293
|
FontFamily,
|
|
@@ -1380,7 +3304,6 @@ class SdDocumentBuilder {
|
|
|
1380
3304
|
TableProperties,
|
|
1381
3305
|
TableCellProperties,
|
|
1382
3306
|
TableColumnResize,
|
|
1383
|
-
PasteFromOffice,
|
|
1384
3307
|
PageBreak,
|
|
1385
3308
|
Undo,
|
|
1386
3309
|
Alignment, // Canh lề
|
|
@@ -1394,17 +3317,20 @@ class SdDocumentBuilder {
|
|
|
1394
3317
|
ImageResize,
|
|
1395
3318
|
ImageStyle,
|
|
1396
3319
|
ImageBlock,
|
|
3320
|
+
// Indent
|
|
3321
|
+
Indent,
|
|
3322
|
+
IndentBlock,
|
|
1397
3323
|
// Custom Plugin
|
|
1398
3324
|
HeadingPlugin,
|
|
1399
3325
|
CommentPlugin,
|
|
1400
3326
|
VariablePlugin,
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
IndentBlock,
|
|
1404
|
-
PageOrientationPlugin,
|
|
3327
|
+
TableCustom,
|
|
3328
|
+
PageOrientation,
|
|
1405
3329
|
ImageUploadPlugin,
|
|
1406
3330
|
ImageCustomPlugin,
|
|
1407
3331
|
HighlightRangePlugin,
|
|
3332
|
+
PasteHandler,
|
|
3333
|
+
BlockSpace,
|
|
1408
3334
|
],
|
|
1409
3335
|
toolbar: {
|
|
1410
3336
|
items: [
|
|
@@ -1454,7 +3380,7 @@ class SdDocumentBuilder {
|
|
|
1454
3380
|
},
|
|
1455
3381
|
fontSize: {
|
|
1456
3382
|
options: this.#fontSizeOptions,
|
|
1457
|
-
supportAllValues:
|
|
3383
|
+
supportAllValues: true,
|
|
1458
3384
|
},
|
|
1459
3385
|
heading: {
|
|
1460
3386
|
options: this.#headingOptions,
|
|
@@ -1503,13 +3429,10 @@ class SdDocumentBuilder {
|
|
|
1503
3429
|
htmlSupport: {
|
|
1504
3430
|
allow: [
|
|
1505
3431
|
{
|
|
1506
|
-
name: /.*/,
|
|
1507
|
-
attributes:
|
|
1508
|
-
classes:
|
|
1509
|
-
styles:
|
|
1510
|
-
// Cho phép tất cả styles trừ margin và padding
|
|
1511
|
-
match: /^(?!margin|padding).*/,
|
|
1512
|
-
},
|
|
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
|
|
1513
3436
|
},
|
|
1514
3437
|
],
|
|
1515
3438
|
},
|
|
@@ -1527,7 +3450,7 @@ class SdDocumentBuilder {
|
|
|
1527
3450
|
this.#editor = editor;
|
|
1528
3451
|
// Setup orientation plugin callback
|
|
1529
3452
|
try {
|
|
1530
|
-
const orientationPlugin = editor.plugins.get('
|
|
3453
|
+
const orientationPlugin = editor.plugins.get('PageOrientation');
|
|
1531
3454
|
if (orientationPlugin && typeof orientationPlugin.onOrientationChange === 'function') {
|
|
1532
3455
|
orientationPlugin.onOrientationChange(orientation => {
|
|
1533
3456
|
this.option.onOrientation?.(orientation);
|
|
@@ -1539,7 +3462,7 @@ class SdDocumentBuilder {
|
|
|
1539
3462
|
}
|
|
1540
3463
|
}
|
|
1541
3464
|
catch (error) {
|
|
1542
|
-
console.warn('
|
|
3465
|
+
console.warn('PageOrientation not available:', error);
|
|
1543
3466
|
}
|
|
1544
3467
|
// Lắng nghe selection
|
|
1545
3468
|
editor.model.document.selection.on('change', $event => {
|
|
@@ -1586,7 +3509,7 @@ class SdDocumentBuilder {
|
|
|
1586
3509
|
if (!this.#editor)
|
|
1587
3510
|
return;
|
|
1588
3511
|
try {
|
|
1589
|
-
const orientationPlugin = this.#editor.plugins.get('
|
|
3512
|
+
const orientationPlugin = this.#editor.plugins.get('PageOrientation');
|
|
1590
3513
|
if (orientationPlugin && typeof orientationPlugin.setOrientation === 'function') {
|
|
1591
3514
|
orientationPlugin.setOrientation(orientation);
|
|
1592
3515
|
}
|
|
@@ -1599,7 +3522,7 @@ class SdDocumentBuilder {
|
|
|
1599
3522
|
if (!this.#editor)
|
|
1600
3523
|
return 'PORTRAIT';
|
|
1601
3524
|
try {
|
|
1602
|
-
const orientationPlugin = this.#editor.plugins.get('
|
|
3525
|
+
const orientationPlugin = this.#editor.plugins.get('PageOrientation');
|
|
1603
3526
|
if (orientationPlugin && typeof orientationPlugin.getOrientation === 'function') {
|
|
1604
3527
|
return orientationPlugin.getOrientation();
|
|
1605
3528
|
}
|
|
@@ -1636,7 +3559,7 @@ class SdDocumentBuilder {
|
|
|
1636
3559
|
this.#editor.enableReadOnlyMode(this.#id);
|
|
1637
3560
|
// Disable page orientation button
|
|
1638
3561
|
try {
|
|
1639
|
-
const orientationPlugin = this.#editor.plugins.get('
|
|
3562
|
+
const orientationPlugin = this.#editor.plugins.get('PageOrientation');
|
|
1640
3563
|
if (orientationPlugin && orientationPlugin.buttonView) {
|
|
1641
3564
|
orientationPlugin.buttonView.isEnabled = false;
|
|
1642
3565
|
}
|
|
@@ -1650,7 +3573,7 @@ class SdDocumentBuilder {
|
|
|
1650
3573
|
this.#editor.disableReadOnlyMode(this.#id);
|
|
1651
3574
|
// Enable page orientation button
|
|
1652
3575
|
try {
|
|
1653
|
-
const orientationPlugin = this.#editor.plugins.get('
|
|
3576
|
+
const orientationPlugin = this.#editor.plugins.get('PageOrientation');
|
|
1654
3577
|
if (orientationPlugin && orientationPlugin.buttonView) {
|
|
1655
3578
|
orientationPlugin.buttonView.isEnabled = true;
|
|
1656
3579
|
}
|
|
@@ -2135,11 +4058,11 @@ class SdDocumentBuilder {
|
|
|
2135
4058
|
});
|
|
2136
4059
|
};
|
|
2137
4060
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: SdDocumentBuilder, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2138
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.17", type: SdDocumentBuilder, isStandalone: true, selector: "sd-document-builder", inputs: { option: "option", _disabled: ["disabled", "_disabled"] }, outputs: { contentChange: "contentChange" }, ngImport: i0, template: "<div class=\"builder-container\">\n <ckeditor\n style=\"width: 100%\"\n [editor]=\"Editor\" \n [config]=\"config\" \n (ready)=\"onReady($event)\"\n [disabled]=\"disabled\">\n </ckeditor>\n</div>", styles: ["@charset \"UTF-8\";.builder-container{background-color:#f3f4f6;height:100%;overflow-y:auto;width:100%;display:flex;flex-direction:column;align-items:center}:host{display:inline-block}:host ::ng-deep .ck-editor{display:flex;flex-direction:column;align-items:center;width:100%;--ck-content-font-family: \"Times New Roman\", serif !important;--ck-content-font-size: 13pt;--ck-content-line-height: 1.
|
|
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"] }] });
|
|
2139
4062
|
}
|
|
2140
4063
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.17", ngImport: i0, type: SdDocumentBuilder, decorators: [{
|
|
2141
4064
|
type: Component,
|
|
2142
|
-
args: [{ selector: 'sd-document-builder', standalone: true, imports: [CommonModule, CKEditorModule], template: "<div class=\"builder-container\">\n <ckeditor\n style=\"width: 100%\"\n [editor]=\"Editor\" \n [config]=\"config\" \n (ready)=\"onReady($event)\"\n [disabled]=\"disabled\">\n </ckeditor>\n</div>", styles: ["@charset \"UTF-8\";.builder-container{background-color:#f3f4f6;height:100%;overflow-y:auto;width:100%;display:flex;flex-direction:column;align-items:center}:host{display:inline-block}:host ::ng-deep .ck-editor{display:flex;flex-direction:column;align-items:center;width:100%;--ck-content-font-family: \"Times New Roman\", serif !important;--ck-content-font-size: 13pt;--ck-content-line-height: 1.
|
|
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"] }]
|
|
2143
4066
|
}], propDecorators: { option: [{
|
|
2144
4067
|
type: Input,
|
|
2145
4068
|
args: [{ required: true }]
|