@phuong-tran-redoc/document-engine-core 0.1.7 → 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
@@ -112,6 +112,19 @@ const saved = toCkHtml(editor.getHTML(), { wrapperClass: wrapperClass ?? false }
112
112
  verifyCkRoundTrip(storedHtml, saved); // before editing: is the document reproduced exactly?
113
113
  ```
114
114
 
115
+ CKEditor restricted-editing exceptions (`<span class="restricted-editing-exception">`) load as editable
116
+ regions when `EditableRegion` and `RestrictedEditing` are registered, and are saved back in that markup. In
117
+ restricted mode, set content with `editor.chain().setMeta('restrictedEditing', { allow: true }).setContent(html).run()`
118
+ — otherwise the restriction blocks the change once the document is not empty.
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
+
115
128
  On a backend (no DOM) pass a parser. `createCkDomParser()` uses happy-dom, which `@tiptap/html` already installs:
116
129
 
117
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';
@@ -61,10 +61,14 @@ function absoluteLengthToPx(value) {
61
61
  * `data-ck` attribute that the {@link CkCompat} extension carries through editing.
62
62
  * - {@link toCkHtml} runs on `editor.getHTML()`. It rebuilds the CKEditor markup from those
63
63
  * records: attribute order, `prop:value;` style formatting, `<figure class="table">`,
64
- * `<colgroup>`, bare text in single-paragraph cells, `<i>`, page breaks, dynamic fields and
65
- * the wrapper. Anything the user changed is taken from the editor; anything the editor does
64
+ * `<colgroup>`, bare text in single-paragraph cells, `<i>`, page breaks, dynamic fields,
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
  *
@@ -87,11 +91,17 @@ const CK_UNSUPPORTED_CONTENT = {
87
91
  dynamicImage: '.redr-dynamic-image',
88
92
  dealTable: '.redr-deal-table',
89
93
  editorColumn: '.redr-editor-column',
94
+ /** @deprecated Supported since 0.1.8 (loads as an editable region); no longer reported. */
90
95
  restrictedEditingException: '.restricted-editing-exception',
91
96
  image: 'img, figure.image',
92
97
  };
93
98
  /** Elements whose original attributes are recorded on load. */
94
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';
95
105
  /** Style properties the editor itself writes, per element. A recorded value for one of these
96
106
  * that the editor no longer emits was removed by the user and is dropped. */
97
107
  const OWNED_STYLES = {
@@ -122,6 +132,8 @@ const DEFAULT_CELL_VERTICAL_ALIGN = 'middle';
122
132
  const NEUTRAL_DECLARATIONS = new Set(['margin-left:0px', 'margin-left:0', 'margin-left:nullpx']);
123
133
  const DYNAMIC_FIELD_BASE_CLASSES = ['red-dynamic-field', 'inline-field', 'redr-handlebar-field'];
124
134
  const DYNAMIC_FIELD_HAS_VALUE_CLASS = 'red-dynamic-field--has-value';
135
+ /** Markup of CKEditor's restricted-editing exception (an editable region). */
136
+ const EDITABLE_REGION_CK_CLASS = 'restricted-editing-exception';
125
137
  const PAGE_BREAK_HTML = '<div class="page-break" style="page-break-after:always;"><span style="display:none;">&nbsp;</span></div>';
126
138
  function parse(html, domParser) {
127
139
  if (!domParser && typeof DOMParser === 'undefined') {
@@ -214,6 +226,16 @@ function readOrigin(el) {
214
226
  return null;
215
227
  origin.c = c;
216
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
+ }
217
239
  return origin;
218
240
  }
219
241
  function writeOrigin(el, origin) {
@@ -229,11 +251,27 @@ function writeOrigin(el, origin) {
229
251
  function fromCkHtml(html, options = {}) {
230
252
  const body = parse(html !== null && html !== void 0 ? html : '', options.domParser);
231
253
  let wrapperClass = null;
254
+ // The block that opens each wrapper after the first, with that wrapper's class list.
255
+ const sectionStarts = [];
232
256
  const only = body.children.length === 1 ? body.firstElementChild : null;
233
- 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)) {
234
259
  wrapperClass = only.getAttribute('class');
235
260
  only.replaceWith(...Array.from(only.childNodes));
236
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
+ }
237
275
  body.querySelectorAll(TRACKED_SELECTOR).forEach((el) => {
238
276
  // Dynamic-field spans keep their attributes in their own record (see DynamicField export).
239
277
  writeOrigin(el, { a: attrsOf(el) });
@@ -257,9 +295,56 @@ function fromCkHtml(html, options = {}) {
257
295
  origin.c = Array.from(cols).map(attrsOf);
258
296
  writeOrigin(table, origin);
259
297
  });
260
- const unsupported = Object.keys(CK_UNSUPPORTED_CONTENT).filter((key) => body.querySelector(CK_UNSUPPORTED_CONTENT[key]));
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
+ });
306
+ const unsupported = Object.keys(CK_UNSUPPORTED_CONTENT).filter((key) => key !== 'restrictedEditingException' && body.querySelector(CK_UNSUPPORTED_CONTENT[key]));
261
307
  return { html: body.innerHTML, wrapperClass, unsupported };
262
308
  }
309
+ function isCkWrapper(el) {
310
+ return el.tagName === 'DIV' && el.classList.contains('ck-content') && el.classList.contains('ck');
311
+ }
312
+ /** Whitespace only (no `&nbsp;`): 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
+ }
263
348
  /**
264
349
  * Compare the stored HTML with what saving the freshly loaded document would write
265
350
  * (`toCkHtml(editor.getHTML())` before any edit). A difference means the editor could not
@@ -480,6 +565,33 @@ function exportDynamicFields(body) {
480
565
  el.replaceWith(span);
481
566
  });
482
567
  }
568
+ /**
569
+ * Write editable regions as CKEditor restricted-editing exceptions. The editor fills a region it
570
+ * creates empty with a zero-width space (and refills one emptied in restricted mode with a space);
571
+ * a region left without text is written as `&nbsp;` so it stays in the document (CKEditor's
572
+ * empty-content convention). The exception class is always kept, whatever the record says: it is
573
+ * what identifies the region in stored HTML.
574
+ */
575
+ function exportEditableRegions(body) {
576
+ body.querySelectorAll('span[data-editable-region]').forEach((el) => {
577
+ var _a, _b, _c;
578
+ const span = el.ownerDocument.createElement('span');
579
+ setAttributesInOrder(span, (_b = (_a = readOrigin(el)) === null || _a === void 0 ? void 0 : _a.a) !== null && _b !== void 0 ? _b : [['class', EDITABLE_REGION_CK_CLASS]]);
580
+ if (!span.classList.contains(EDITABLE_REGION_CK_CLASS))
581
+ span.classList.add(EDITABLE_REGION_CK_CLASS);
582
+ span.append(...Array.from(el.childNodes));
583
+ stripZeroWidthSpaces(span);
584
+ if (!((_c = span.textContent) === null || _c === void 0 ? void 0 : _c.trim()))
585
+ span.textContent = '\u00a0';
586
+ el.replaceWith(span);
587
+ });
588
+ }
589
+ function stripZeroWidthSpaces(node) {
590
+ var _a;
591
+ if (node.nodeType === 3)
592
+ node.nodeValue = ((_a = node.nodeValue) !== null && _a !== void 0 ? _a : '').replace(/\u200b/g, '');
593
+ node.childNodes.forEach(stripZeroWidthSpaces);
594
+ }
483
595
  /**
484
596
  * Tiptap's `TrailingNode` appends an empty paragraph whenever a document ends in a table or other
485
597
  * non-paragraph block. CKEditor allows that ending, so drop the paragraph unless the source had it.
@@ -553,14 +665,19 @@ function toCkHtml(html, options = {}) {
553
665
  var _a;
554
666
  const body = parse(html !== null && html !== void 0 ? html : '', options.domParser);
555
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);
556
670
  exportDynamicFields(body);
557
671
  exportPageBreaks(body);
672
+ exportEditableRegions(body);
558
673
  // Reconcile plain elements before tables restructure cells (so `<p>` attributes are final).
559
674
  body.querySelectorAll('*').forEach((el) => {
560
675
  var _a, _b;
561
676
  const tag = el.tagName.toLowerCase();
562
677
  if (tag === 'col' || el.closest('div.page-break') || el.matches('span[id^="red-dynamic-field"]'))
563
678
  return;
679
+ if (el.matches(`span.${EDITABLE_REGION_CK_CLASS}`))
680
+ return;
564
681
  reconcileElement(el, (_b = (_a = readOrigin(el)) === null || _a === void 0 ? void 0 : _a.a) !== null && _b !== void 0 ? _b : null, probe);
565
682
  });
566
683
  dropTrailingNode(body);
@@ -580,13 +697,53 @@ function toCkHtml(html, options = {}) {
580
697
  });
581
698
  body.querySelectorAll(`[${CK_ORIGIN_ATTRIBUTE}]`).forEach((el) => el.removeAttribute(CK_ORIGIN_ATTRIBUTE));
582
699
  const wrapperClass = (_a = options.wrapperClass) !== null && _a !== void 0 ? _a : CK_WRAPPER_BASE_CLASS;
583
- if (wrapperClass === false)
700
+ if (wrapperClass === false) {
701
+ sections.forEach((_, marker) => marker.remove());
584
702
  return body.innerHTML;
585
- // Built as an element so a class read from stored HTML is escaped like any attribute value.
586
- const wrapper = body.ownerDocument.createElement('div');
587
- wrapper.setAttribute('class', wrapperClass);
588
- wrapper.append(...Array.from(body.childNodes));
589
- 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('');
590
747
  }
591
748
 
592
749
  /** Node and mark types whose original CKEditor attributes are carried through editing. */
@@ -605,6 +762,7 @@ const CK_COMPAT_TYPES = [
605
762
  'link',
606
763
  'dynamicField',
607
764
  'pageBreak',
765
+ 'editableRegion',
608
766
  ];
609
767
  const ckOrigin = (fallback) => ({
610
768
  default: null,
@@ -624,6 +782,36 @@ function recordPastedDynamicField(element) {
624
782
  const a = Array.from(element.attributes).map((attr) => [attr.name, attr.value]);
625
783
  return JSON.stringify({ a });
626
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
+ }
627
815
  /**
628
816
  * Keeps the `data-ck` record written by `fromCkHtml()` on every supported node and mark, so
629
817
  * `toCkHtml()` can restore the original CKEditor markup on save. Register it together with the
@@ -637,6 +825,16 @@ const CkCompat = Extension.create({
637
825
  { types: ['dynamicField'], attributes: { ckOrigin: ckOrigin(recordPastedDynamicField) } },
638
826
  ];
639
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
+ },
640
838
  });
641
839
 
642
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.7",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "author": "Realestatedoc (Redoc)",
@@ -9,10 +9,14 @@
9
9
  * `data-ck` attribute that the {@link CkCompat} extension carries through editing.
10
10
  * - {@link toCkHtml} runs on `editor.getHTML()`. It rebuilds the CKEditor markup from those
11
11
  * records: attribute order, `prop:value;` style formatting, `<figure class="table">`,
12
- * `<colgroup>`, bare text in single-paragraph cells, `<i>`, page breaks, dynamic fields and
13
- * the wrapper. Anything the user changed is taken from the editor; anything the editor does
12
+ * `<colgroup>`, bare text in single-paragraph cells, `<i>`, page breaks, dynamic fields,
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
@@ -61,6 +68,7 @@ export declare const CK_UNSUPPORTED_CONTENT: {
61
68
  readonly dynamicImage: ".redr-dynamic-image";
62
69
  readonly dealTable: ".redr-deal-table";
63
70
  readonly editorColumn: ".redr-editor-column";
71
+ /** @deprecated Supported since 0.1.8 (loads as an editable region); no longer reported. */
64
72
  readonly restrictedEditingException: ".restricted-editing-exception";
65
73
  readonly image: "img, figure.image";
66
74
  };