@phuong-tran-redoc/document-engine-core 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/index.esm.js +173 -9
- package/package.json +1 -1
- package/src/compat/ckeditor/ck-html.d.ts +8 -1
package/README.md
CHANGED
|
@@ -117,6 +117,14 @@ regions when `EditableRegion` and `RestrictedEditing` are registered, and are sa
|
|
|
117
117
|
restricted mode, set content with `editor.chain().setMeta('restrictedEditing', { allow: true }).setContent(html).run()`
|
|
118
118
|
— otherwise the restriction blocks the change once the document is not empty.
|
|
119
119
|
|
|
120
|
+
A document stored as several CKEditor wrappers side by side (sections joined after CKEditor saved them) loads
|
|
121
|
+
as one document. `wrapperClass` is the first wrapper's class; the block that opens each later section records
|
|
122
|
+
its wrapper (and the whitespace stored before it), and `toCkHtml` splits the document there again. Deleting that
|
|
123
|
+
block merges its section into the one before; a section whose content is all deleted loses its wrapper, so when
|
|
124
|
+
that is the first section the saved document starts with the next section's wrapper class. A pasted or dropped
|
|
125
|
+
copy of an opening block does not open a section. A section that opens with a numbered list cannot be split
|
|
126
|
+
again, so such a document keeps the wrappers it had (and does not round-trip).
|
|
127
|
+
|
|
120
128
|
On a backend (no DOM) pass a parser. `createCkDomParser()` uses happy-dom, which `@tiptap/html` already installs:
|
|
121
129
|
|
|
122
130
|
```typescript
|
package/index.esm.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { Extension, Node, mergeAttributes } from '@tiptap/core';
|
|
2
|
+
import { Slice, Fragment, DOMSerializer } from '@tiptap/pm/model';
|
|
3
|
+
import { Plugin, PluginKey, TextSelection, NodeSelection } from '@tiptap/pm/state';
|
|
2
4
|
import { __awaiter } from 'tslib';
|
|
3
5
|
import { OrderedList, BulletList, ListItem } from '@tiptap/extension-list';
|
|
4
|
-
import { PluginKey, Plugin, TextSelection, NodeSelection } from '@tiptap/pm/state';
|
|
5
6
|
import { TableCell, TableHeader, Table, TableRow } from '@tiptap/extension-table';
|
|
6
7
|
import { selectionCell, findTable, TableMap, CellSelection, tableNodeTypes, deleteColumn, addColumnBefore, addColumnAfter } from '@tiptap/pm/tables';
|
|
7
8
|
import { isEqual } from 'lodash-es';
|
|
8
|
-
import { DOMSerializer } from '@tiptap/pm/model';
|
|
9
9
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
|
10
10
|
import { Blockquote } from '@tiptap/extension-blockquote';
|
|
11
11
|
import { Bold } from '@tiptap/extension-bold';
|
|
@@ -65,6 +65,10 @@ function absoluteLengthToPx(value) {
|
|
|
65
65
|
* restricted-editing exceptions and the wrapper. Anything the user changed is taken from the editor; anything the editor does
|
|
66
66
|
* not model is kept verbatim.
|
|
67
67
|
*
|
|
68
|
+
* A document stored as several wrappers side by side (sections joined after CKEditor saved them) loads
|
|
69
|
+
* as one document; the first block of each later section records its wrapper, and saving splits the
|
|
70
|
+
* document there again.
|
|
71
|
+
*
|
|
68
72
|
* Both functions need a `DOMParser`. In the browser the global one is used. In Node (no global DOM)
|
|
69
73
|
* pass one in, e.g. from {@link createCkDomParser}:
|
|
70
74
|
*
|
|
@@ -93,6 +97,11 @@ const CK_UNSUPPORTED_CONTENT = {
|
|
|
93
97
|
};
|
|
94
98
|
/** Elements whose original attributes are recorded on load. */
|
|
95
99
|
const TRACKED_SELECTOR = 'p,h1,h2,h3,h4,h5,h6,span,table,tr,td,th,blockquote,li,ol,ul,a,div.page-break';
|
|
100
|
+
/**
|
|
101
|
+
* Top-level blocks that can carry the record of the wrapper they open. Not `ol`: the numbered list
|
|
102
|
+
* rewrites its markup and does not keep its record.
|
|
103
|
+
*/
|
|
104
|
+
const SECTION_START_SELECTOR = 'p,h1,h2,h3,h4,h5,h6,table,blockquote,ul,div.page-break';
|
|
96
105
|
/** Style properties the editor itself writes, per element. A recorded value for one of these
|
|
97
106
|
* that the editor no longer emits was removed by the user and is dropped. */
|
|
98
107
|
const OWNED_STYLES = {
|
|
@@ -217,6 +226,16 @@ function readOrigin(el) {
|
|
|
217
226
|
return null;
|
|
218
227
|
origin.c = c;
|
|
219
228
|
}
|
|
229
|
+
if (record['w'] !== undefined) {
|
|
230
|
+
if (typeof record['w'] !== 'string')
|
|
231
|
+
return null;
|
|
232
|
+
origin.w = record['w'];
|
|
233
|
+
}
|
|
234
|
+
if (record['s'] !== undefined) {
|
|
235
|
+
if (typeof record['s'] !== 'string' || !WHITESPACE.test(record['s']))
|
|
236
|
+
return null;
|
|
237
|
+
origin.s = record['s'];
|
|
238
|
+
}
|
|
220
239
|
return origin;
|
|
221
240
|
}
|
|
222
241
|
function writeOrigin(el, origin) {
|
|
@@ -232,11 +251,27 @@ function writeOrigin(el, origin) {
|
|
|
232
251
|
function fromCkHtml(html, options = {}) {
|
|
233
252
|
const body = parse(html !== null && html !== void 0 ? html : '', options.domParser);
|
|
234
253
|
let wrapperClass = null;
|
|
254
|
+
// The block that opens each wrapper after the first, with that wrapper's class list.
|
|
255
|
+
const sectionStarts = [];
|
|
235
256
|
const only = body.children.length === 1 ? body.firstElementChild : null;
|
|
236
|
-
|
|
257
|
+
const sections = only ? null : sideBySideWrappers(body);
|
|
258
|
+
if (only && isCkWrapper(only)) {
|
|
237
259
|
wrapperClass = only.getAttribute('class');
|
|
238
260
|
only.replaceWith(...Array.from(only.childNodes));
|
|
239
261
|
}
|
|
262
|
+
else if (sections) {
|
|
263
|
+
wrapperClass = sections[0].wrapper.getAttribute('class');
|
|
264
|
+
sections.forEach(({ wrapper, separator }, i) => {
|
|
265
|
+
var _a;
|
|
266
|
+
const start = wrapper.firstElementChild;
|
|
267
|
+
const block = start.matches('figure.table') ? start.querySelector(':scope > table') : start;
|
|
268
|
+
if (i > 0)
|
|
269
|
+
sectionStarts.push([block, { cls: (_a = wrapper.getAttribute('class')) !== null && _a !== void 0 ? _a : '', separator }]);
|
|
270
|
+
wrapper.replaceWith(...Array.from(wrapper.childNodes));
|
|
271
|
+
});
|
|
272
|
+
// The whitespace between the wrappers is in the records; between blocks it is not content.
|
|
273
|
+
Array.from(body.childNodes).forEach((node) => isWhitespaceText(node) && node.remove());
|
|
274
|
+
}
|
|
240
275
|
body.querySelectorAll(TRACKED_SELECTOR).forEach((el) => {
|
|
241
276
|
// Dynamic-field spans keep their attributes in their own record (see DynamicField export).
|
|
242
277
|
writeOrigin(el, { a: attrsOf(el) });
|
|
@@ -260,9 +295,56 @@ function fromCkHtml(html, options = {}) {
|
|
|
260
295
|
origin.c = Array.from(cols).map(attrsOf);
|
|
261
296
|
writeOrigin(table, origin);
|
|
262
297
|
});
|
|
298
|
+
sectionStarts.forEach(([block, { cls, separator }]) => {
|
|
299
|
+
var _a;
|
|
300
|
+
const origin = (_a = readOrigin(block)) !== null && _a !== void 0 ? _a : { a: attrsOf(block) };
|
|
301
|
+
origin.w = cls;
|
|
302
|
+
if (separator)
|
|
303
|
+
origin.s = separator;
|
|
304
|
+
writeOrigin(block, origin);
|
|
305
|
+
});
|
|
263
306
|
const unsupported = Object.keys(CK_UNSUPPORTED_CONTENT).filter((key) => key !== 'restrictedEditingException' && body.querySelector(CK_UNSUPPORTED_CONTENT[key]));
|
|
264
307
|
return { html: body.innerHTML, wrapperClass, unsupported };
|
|
265
308
|
}
|
|
309
|
+
function isCkWrapper(el) {
|
|
310
|
+
return el.tagName === 'DIV' && el.classList.contains('ck-content') && el.classList.contains('ck');
|
|
311
|
+
}
|
|
312
|
+
/** Whitespace only (no ` `): what an HTML serializer or a string join puts between documents. */
|
|
313
|
+
const WHITESPACE = /^[ \t\n\r\f]*$/;
|
|
314
|
+
const isWhitespaceText = (node) => { var _a; return node.nodeType === 3 && WHITESPACE.test((_a = node.nodeValue) !== null && _a !== void 0 ? _a : ''); };
|
|
315
|
+
/**
|
|
316
|
+
* The wrappers of a document stored as several CKEditor documents side by side, with the whitespace
|
|
317
|
+
* between each one and the one before it, or `null` when the body is anything else. Whitespace around
|
|
318
|
+
* the wrappers is allowed (as with a single wrapper). Each wrapper must open with a block that can
|
|
319
|
+
* carry its record, so that saving can split the document at the same places.
|
|
320
|
+
*/
|
|
321
|
+
function sideBySideWrappers(body) {
|
|
322
|
+
const sections = [];
|
|
323
|
+
let separator = '';
|
|
324
|
+
for (const node of Array.from(body.childNodes)) {
|
|
325
|
+
if (isWhitespaceText(node)) {
|
|
326
|
+
separator += node.nodeValue;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (node.nodeType !== 1 || !isCkWrapper(node))
|
|
330
|
+
return null;
|
|
331
|
+
const block = node.firstElementChild;
|
|
332
|
+
if (!block || Array.from(node.childNodes).indexOf(block) !== leadingWhitespace(node))
|
|
333
|
+
return null;
|
|
334
|
+
const opensWithTable = block.matches('figure.table') && !!block.querySelector(':scope > table');
|
|
335
|
+
if (!opensWithTable && !block.matches(SECTION_START_SELECTOR))
|
|
336
|
+
return null;
|
|
337
|
+
sections.push({ wrapper: node, separator });
|
|
338
|
+
separator = '';
|
|
339
|
+
}
|
|
340
|
+
return sections.length > 1 ? sections : null;
|
|
341
|
+
}
|
|
342
|
+
/** Number of whitespace-only text nodes at the start of an element. */
|
|
343
|
+
function leadingWhitespace(el) {
|
|
344
|
+
const nodes = Array.from(el.childNodes);
|
|
345
|
+
const first = nodes.findIndex((n) => !isWhitespaceText(n));
|
|
346
|
+
return first < 0 ? nodes.length : first;
|
|
347
|
+
}
|
|
266
348
|
/**
|
|
267
349
|
* Compare the stored HTML with what saving the freshly loaded document would write
|
|
268
350
|
* (`toCkHtml(editor.getHTML())` before any edit). A difference means the editor could not
|
|
@@ -583,6 +665,8 @@ function toCkHtml(html, options = {}) {
|
|
|
583
665
|
var _a;
|
|
584
666
|
const body = parse(html !== null && html !== void 0 ? html : '', options.domParser);
|
|
585
667
|
const probe = body.ownerDocument.createElement('span');
|
|
668
|
+
// Marked first: the export passes below replace some blocks, and their records with them.
|
|
669
|
+
const sections = markSections(body);
|
|
586
670
|
exportDynamicFields(body);
|
|
587
671
|
exportPageBreaks(body);
|
|
588
672
|
exportEditableRegions(body);
|
|
@@ -613,13 +697,53 @@ function toCkHtml(html, options = {}) {
|
|
|
613
697
|
});
|
|
614
698
|
body.querySelectorAll(`[${CK_ORIGIN_ATTRIBUTE}]`).forEach((el) => el.removeAttribute(CK_ORIGIN_ATTRIBUTE));
|
|
615
699
|
const wrapperClass = (_a = options.wrapperClass) !== null && _a !== void 0 ? _a : CK_WRAPPER_BASE_CLASS;
|
|
616
|
-
if (wrapperClass === false)
|
|
700
|
+
if (wrapperClass === false) {
|
|
701
|
+
sections.forEach((_, marker) => marker.remove());
|
|
617
702
|
return body.innerHTML;
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
703
|
+
}
|
|
704
|
+
return wrapSections(body, wrapperClass, sections);
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Put a marker before each top-level block that opens a wrapper of its own (see {@link fromCkHtml}),
|
|
708
|
+
* mapped to that wrapper.
|
|
709
|
+
*/
|
|
710
|
+
function markSections(body) {
|
|
711
|
+
const sections = new Map();
|
|
712
|
+
Array.from(body.children).forEach((block) => {
|
|
713
|
+
var _a;
|
|
714
|
+
const origin = readOrigin(block);
|
|
715
|
+
if ((origin === null || origin === void 0 ? void 0 : origin.w) === undefined)
|
|
716
|
+
return;
|
|
717
|
+
const marker = body.ownerDocument.createComment('ck-section');
|
|
718
|
+
block.before(marker);
|
|
719
|
+
sections.set(marker, { cls: origin.w, separator: (_a = origin.s) !== null && _a !== void 0 ? _a : '' });
|
|
720
|
+
});
|
|
721
|
+
return sections;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Wrap the document, starting a new wrapper at each section marker. A wrapper left empty (its
|
|
725
|
+
* section was deleted) is dropped, with the whitespace stored before it.
|
|
726
|
+
*/
|
|
727
|
+
function wrapSections(body, firstClass, sections) {
|
|
728
|
+
const doc = body.ownerDocument;
|
|
729
|
+
// Built as elements so a class read from stored HTML is escaped like any attribute value.
|
|
730
|
+
const open = ({ cls, separator }) => {
|
|
731
|
+
const wrapper = doc.createElement('div');
|
|
732
|
+
wrapper.setAttribute('class', cls);
|
|
733
|
+
return { wrapper, separator };
|
|
734
|
+
};
|
|
735
|
+
const wrappers = [open({ cls: firstClass, separator: '' })];
|
|
736
|
+
Array.from(body.childNodes).forEach((node) => {
|
|
737
|
+
const boundary = sections.get(node);
|
|
738
|
+
if (boundary === undefined)
|
|
739
|
+
wrappers[wrappers.length - 1].wrapper.append(node);
|
|
740
|
+
else
|
|
741
|
+
wrappers.push(open(boundary));
|
|
742
|
+
});
|
|
743
|
+
const kept = wrappers.filter((w) => w.wrapper.childNodes.length);
|
|
744
|
+
if (!kept.length)
|
|
745
|
+
return wrappers[0].wrapper.outerHTML;
|
|
746
|
+
return kept.map((w, i) => (i ? w.separator : '') + w.wrapper.outerHTML).join('');
|
|
623
747
|
}
|
|
624
748
|
|
|
625
749
|
/** Node and mark types whose original CKEditor attributes are carried through editing. */
|
|
@@ -658,6 +782,36 @@ function recordPastedDynamicField(element) {
|
|
|
658
782
|
const a = Array.from(element.attributes).map((attr) => [attr.name, attr.value]);
|
|
659
783
|
return JSON.stringify({ a });
|
|
660
784
|
}
|
|
785
|
+
/**
|
|
786
|
+
* A pasted or dropped copy of the block that opens a stored section must not open another one: drop
|
|
787
|
+
* the section keys (`w`, `s`) from the records of pasted content. The block left in place keeps them.
|
|
788
|
+
*/
|
|
789
|
+
function withoutSectionRecords(fragment) {
|
|
790
|
+
const nodes = [];
|
|
791
|
+
fragment.forEach((node) => {
|
|
792
|
+
if (node.isText) {
|
|
793
|
+
nodes.push(node);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const origin = node.attrs['ckOrigin'];
|
|
797
|
+
const attrs = typeof origin === 'string' ? Object.assign(Object.assign({}, node.attrs), { ckOrigin: dropSectionKeys(origin) }) : node.attrs;
|
|
798
|
+
nodes.push(node.type.create(attrs, withoutSectionRecords(node.content), node.marks));
|
|
799
|
+
});
|
|
800
|
+
return Fragment.fromArray(nodes);
|
|
801
|
+
}
|
|
802
|
+
function dropSectionKeys(origin) {
|
|
803
|
+
try {
|
|
804
|
+
const record = JSON.parse(origin);
|
|
805
|
+
if (!record || typeof record !== 'object' || (!('w' in record) && !('s' in record)))
|
|
806
|
+
return origin;
|
|
807
|
+
delete record.w;
|
|
808
|
+
delete record.s;
|
|
809
|
+
return JSON.stringify(record);
|
|
810
|
+
}
|
|
811
|
+
catch (_a) {
|
|
812
|
+
return origin;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
661
815
|
/**
|
|
662
816
|
* Keeps the `data-ck` record written by `fromCkHtml()` on every supported node and mark, so
|
|
663
817
|
* `toCkHtml()` can restore the original CKEditor markup on save. Register it together with the
|
|
@@ -671,6 +825,16 @@ const CkCompat = Extension.create({
|
|
|
671
825
|
{ types: ['dynamicField'], attributes: { ckOrigin: ckOrigin(recordPastedDynamicField) } },
|
|
672
826
|
];
|
|
673
827
|
},
|
|
828
|
+
addProseMirrorPlugins() {
|
|
829
|
+
return [
|
|
830
|
+
new Plugin({
|
|
831
|
+
key: new PluginKey('ckCompatPaste'),
|
|
832
|
+
props: {
|
|
833
|
+
transformPasted: (slice) => new Slice(withoutSectionRecords(slice.content), slice.openStart, slice.openEnd),
|
|
834
|
+
},
|
|
835
|
+
}),
|
|
836
|
+
];
|
|
837
|
+
},
|
|
674
838
|
});
|
|
675
839
|
|
|
676
840
|
const INDENT_DEFAULT = 40; // in pixels
|
package/package.json
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* restricted-editing exceptions and the wrapper. Anything the user changed is taken from the editor; anything the editor does
|
|
14
14
|
* not model is kept verbatim.
|
|
15
15
|
*
|
|
16
|
+
* A document stored as several wrappers side by side (sections joined after CKEditor saved them) loads
|
|
17
|
+
* as one document; the first block of each later section records its wrapper, and saving splits the
|
|
18
|
+
* document there again.
|
|
19
|
+
*
|
|
16
20
|
* Both functions need a `DOMParser`. In the browser the global one is used. In Node (no global DOM)
|
|
17
21
|
* pass one in, e.g. from {@link createCkDomParser}:
|
|
18
22
|
*
|
|
@@ -43,7 +47,10 @@ export interface CkHtmlOptions extends CkDomOptions {
|
|
|
43
47
|
export interface CkLoadResult {
|
|
44
48
|
/** HTML ready to be set into the editor. */
|
|
45
49
|
html: string;
|
|
46
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Class list of the wrapper that was stripped (the first one, when the document is several wrappers
|
|
52
|
+
* side by side), or `null` when the input had none.
|
|
53
|
+
*/
|
|
47
54
|
wrapperClass: string | null;
|
|
48
55
|
/**
|
|
49
56
|
* Keys of {@link CK_UNSUPPORTED_CONTENT} found in the input. The editor has no node for these, so
|