@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 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
- if (only && only.tagName === 'DIV' && only.classList.contains('ck-content') && only.classList.contains('ck')) {
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
- // Built as an element so a class read from stored HTML is escaped like any attribute value.
619
- const wrapper = body.ownerDocument.createElement('div');
620
- wrapper.setAttribute('class', wrapperClass);
621
- wrapper.append(...Array.from(body.childNodes));
622
- return wrapper.outerHTML;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phuong-tran-redoc/document-engine-core",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "author": "Realestatedoc (Redoc)",
@@ -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
- /** Class list of the wrapper that was stripped, or `null` when the input had none. */
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