@phuong-tran-redoc/document-engine-core 0.1.6 → 0.1.8

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
@@ -96,6 +96,34 @@ import { generateHTML, defaultExtensions } from '@phuong-tran-redoc/document-eng
96
96
  const html = await generateHTML(myProseMirrorJSON);
97
97
  ```
98
98
 
99
+ ### CKEditor 5 HTML compatibility
100
+
101
+ Load HTML stored by the legacy CKEditor 5 build and save it back byte-identical: unchanged parts keep
102
+ their exact markup, only what the user edited changes.
103
+
104
+ ```typescript
105
+ import { CkCompat, fromCkHtml, toCkHtml, verifyCkRoundTrip, defaultExtensions } from '@phuong-tran-redoc/document-engine-core';
106
+
107
+ const editor = new Editor({ extensions: [...defaultExtensions, CkCompat] });
108
+ const { html, wrapperClass, unsupported } = fromCkHtml(storedHtml); // `unsupported`: CKEditor plugin content with no editor node
109
+ editor.commands.setContent(html, { emitUpdate: false });
110
+
111
+ const saved = toCkHtml(editor.getHTML(), { wrapperClass: wrapperClass ?? false });
112
+ verifyCkRoundTrip(storedHtml, saved); // before editing: is the document reproduced exactly?
113
+ ```
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
+ On a backend (no DOM) pass a parser. `createCkDomParser()` uses happy-dom, which `@tiptap/html` already installs:
121
+
122
+ ```typescript
123
+ const domParser = await createCkDomParser();
124
+ const ckHtml = toCkHtml(await generateHTML(json, extensions), { wrapperClass, domParser });
125
+ ```
126
+
99
127
  ### Versioned documents + migrations
100
128
 
101
129
  ```typescript
@@ -138,6 +166,12 @@ Everything below is re-exported from the package entry (`@phuong-tran-redoc/docu
138
166
  - `defaultExtensions: Extensions` — the canonical schema (nodes, marks, structures).
139
167
  - `generateHTML(doc, extensions?): Promise<string>` — async, environment-aware headless serializer.
140
168
 
169
+ ### CKEditor compatibility (`compat`)
170
+
171
+ - `CkCompat` — extension that carries the original CKEditor attributes through editing.
172
+ - `fromCkHtml(html, { domParser? })` → `{ html, wrapperClass, unsupported }`; `toCkHtml(html, { wrapperClass?, domParser? })`.
173
+ - `verifyCkRoundTrip(original, saved)`, `CK_UNSUPPORTED_CONTENT`, `createCkDomParser()`.
174
+
141
175
  ### Migrations (`migrations`)
142
176
 
143
177
  - `EditorDocument` — `{ schemaVersion: number; content: JSONContent }`.
@@ -201,13 +235,9 @@ nx lint @phuong-tran-redoc/document-engine-core # lint
201
235
 
202
236
  ---
203
237
 
204
- ## 👤 Author
205
-
206
- Developed by **Duc Phuong (Jack)**
238
+ ## 👤 Authors
207
239
 
208
- - 💼 [LinkedIn](https://www.linkedin.com/in/tdp1999/)
209
- - 🐙 [GitHub](https://github.com/tdp1999)
210
- - 📧 [Email](mailto:tdp99.business@gmail.com)
240
+ See [AUTHORS.md](https://github.com/phuong-tran-redoc/document-engine/blob/main/AUTHORS.md).
211
241
 
212
242
  ---
213
243
 
package/index.esm.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Extension, Node, mergeAttributes } from '@tiptap/core';
2
+ import { __awaiter } from 'tslib';
2
3
  import { OrderedList, BulletList, ListItem } from '@tiptap/extension-list';
3
4
  import { PluginKey, Plugin, TextSelection, NodeSelection } from '@tiptap/pm/state';
4
5
  import { TableCell, TableHeader, Table, TableRow } from '@tiptap/extension-table';
@@ -6,26 +7,671 @@ import { selectionCell, findTable, TableMap, CellSelection, tableNodeTypes, dele
6
7
  import { isEqual } from 'lodash-es';
7
8
  import { DOMSerializer } from '@tiptap/pm/model';
8
9
  import { Decoration, DecorationSet } from '@tiptap/pm/view';
9
- import Blockquote from '@tiptap/extension-blockquote';
10
- import Bold from '@tiptap/extension-bold';
11
- import Code from '@tiptap/extension-code';
12
- import CodeBlock from '@tiptap/extension-code-block';
10
+ import { Blockquote } from '@tiptap/extension-blockquote';
11
+ import { Bold } from '@tiptap/extension-bold';
12
+ import { Code } from '@tiptap/extension-code';
13
+ import { CodeBlock } from '@tiptap/extension-code-block';
13
14
  import { Document } from '@tiptap/extension-document';
14
15
  import { HardBreak } from '@tiptap/extension-hard-break';
15
16
  import { HorizontalRule } from '@tiptap/extension-horizontal-rule';
16
17
  import { Image } from '@tiptap/extension-image';
17
- import Italic from '@tiptap/extension-italic';
18
- import Link from '@tiptap/extension-link';
18
+ import { Italic } from '@tiptap/extension-italic';
19
+ import { Link } from '@tiptap/extension-link';
19
20
  import { Paragraph } from '@tiptap/extension-paragraph';
20
- import Strike from '@tiptap/extension-strike';
21
+ import { Strike } from '@tiptap/extension-strike';
21
22
  import { Subscript } from '@tiptap/extension-subscript';
22
23
  import { Superscript } from '@tiptap/extension-superscript';
23
24
  import { Text } from '@tiptap/extension-text';
24
25
  import { TextAlign } from '@tiptap/extension-text-align';
25
26
  import { TextStyleKit } from '@tiptap/extension-text-style';
26
- import Underline from '@tiptap/extension-underline';
27
+ import { Underline } from '@tiptap/extension-underline';
27
28
  import { Heading } from '@tiptap/extension-heading';
28
- import { __awaiter } from 'tslib';
29
+
30
+ /** Pixels per unit for the absolute CSS length units (CSS Values 4: 1in = 96px). */
31
+ const PX_PER_UNIT = {
32
+ px: 1,
33
+ pt: 96 / 72,
34
+ pc: 16,
35
+ in: 96,
36
+ cm: 96 / 2.54,
37
+ mm: 96 / 25.4,
38
+ q: 96 / 101.6,
39
+ };
40
+ /**
41
+ * Convert a single absolute CSS length (`36pt`, `1cm`, `40px`) to pixels.
42
+ * Returns `null` for anything else: relative units (`em`, `%`), keywords, or several values.
43
+ */
44
+ function absoluteLengthToPx(value) {
45
+ const match = /^(-?\d*\.?\d+)([a-z]*)$/i.exec((value !== null && value !== void 0 ? value : '').trim());
46
+ if (!match)
47
+ return null;
48
+ const unit = match[2].toLowerCase();
49
+ const factor = unit === '' ? (Number(match[1]) === 0 ? 1 : undefined) : PX_PER_UNIT[unit];
50
+ return factor === undefined ? null : Number(match[1]) * factor;
51
+ }
52
+
53
+ /**
54
+ * CKEditor 5 HTML compatibility layer.
55
+ *
56
+ * Lets the editor load HTML that was produced by the legacy CKEditor 5 build and save it back
57
+ * in the same shape, so stored documents and the code that reads them keep working.
58
+ *
59
+ * - {@link fromCkHtml} runs before content is set into the editor. It strips the CKEditor
60
+ * data-processor wrapper and records each element's original attributes, in order, in a
61
+ * `data-ck` attribute that the {@link CkCompat} extension carries through editing.
62
+ * - {@link toCkHtml} runs on `editor.getHTML()`. It rebuilds the CKEditor markup from those
63
+ * records: attribute order, `prop:value;` style formatting, `<figure class="table">`,
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
+ * not model is kept verbatim.
67
+ *
68
+ * Both functions need a `DOMParser`. In the browser the global one is used. In Node (no global DOM)
69
+ * pass one in, e.g. from {@link createCkDomParser}:
70
+ *
71
+ * ```ts
72
+ * const domParser = await createCkDomParser();
73
+ * const ckHtml = toCkHtml(await generateHTML(json, extensions), { wrapperClass, domParser });
74
+ * ```
75
+ */
76
+ /** Attribute that carries the original CKEditor attributes of an element through the editor. */
77
+ const CK_ORIGIN_ATTRIBUTE = 'data-ck';
78
+ /** Class list of the CKEditor data-processor wrapper (`CustomHtmlDataProcessor`). */
79
+ const CK_WRAPPER_BASE_CLASS = 'ck ck-content ck-print';
80
+ /**
81
+ * Content produced by CKEditor plugins of the legacy build that this editor cannot represent yet,
82
+ * keyed by name, with the selector that identifies it in stored HTML.
83
+ */
84
+ const CK_UNSUPPORTED_CONTENT = {
85
+ signatureField: '.redr-signature-field',
86
+ inlineField: '.redr-inline-field',
87
+ dynamicImage: '.redr-dynamic-image',
88
+ dealTable: '.redr-deal-table',
89
+ editorColumn: '.redr-editor-column',
90
+ /** @deprecated Supported since 0.1.8 (loads as an editable region); no longer reported. */
91
+ restrictedEditingException: '.restricted-editing-exception',
92
+ image: 'img, figure.image',
93
+ };
94
+ /** Elements whose original attributes are recorded on load. */
95
+ const TRACKED_SELECTOR = 'p,h1,h2,h3,h4,h5,h6,span,table,tr,td,th,blockquote,li,ol,ul,a,div.page-break';
96
+ /** Style properties the editor itself writes, per element. A recorded value for one of these
97
+ * that the editor no longer emits was removed by the user and is dropped. */
98
+ const OWNED_STYLES = {
99
+ p: ['text-align', 'margin-left'],
100
+ h: ['text-align', 'margin-left'],
101
+ span: ['font-size', 'font-family', 'color', 'background-color', 'line-height'],
102
+ td: ['text-align', 'vertical-align', 'background-color', 'border-color', 'border-style', 'border-width'],
103
+ table: ['background-color', 'border-color', 'border-style', 'border-width'],
104
+ col: ['width'],
105
+ };
106
+ /** Attributes the editor itself writes. A recorded one the editor no longer emits is dropped. */
107
+ const OWNED_ATTRIBUTES = new Set(['style', 'colspan', 'rowspan', 'href', 'target', 'rel', 'start', 'type']);
108
+ /** Attributes the editor emits that never belong in CKEditor output. */
109
+ const ENGINE_ONLY_ATTRIBUTES = ['data-colwidths', 'colwidth', 'data-indent'];
110
+ /**
111
+ * Default cell styles the table extension writes on every cell. On an element that came from
112
+ * CKEditor they are dropped unless the source had them; a border group is only dropped when all
113
+ * three parts are still the default, so a border the user set is kept.
114
+ */
115
+ const DEFAULT_CELL_BORDER = [
116
+ ['border-style', 'solid'],
117
+ ['border-color', '#e5e7eb'],
118
+ ['border-width', '1px'],
119
+ ];
120
+ const DEFAULT_TABLE_BORDER = DEFAULT_CELL_BORDER;
121
+ const DEFAULT_CELL_VERTICAL_ALIGN = 'middle';
122
+ /** Declarations the editor emits as a neutral default; dropped unless they were in the source. */
123
+ const NEUTRAL_DECLARATIONS = new Set(['margin-left:0px', 'margin-left:0', 'margin-left:nullpx']);
124
+ const DYNAMIC_FIELD_BASE_CLASSES = ['red-dynamic-field', 'inline-field', 'redr-handlebar-field'];
125
+ const DYNAMIC_FIELD_HAS_VALUE_CLASS = 'red-dynamic-field--has-value';
126
+ /** Markup of CKEditor's restricted-editing exception (an editable region). */
127
+ const EDITABLE_REGION_CK_CLASS = 'restricted-editing-exception';
128
+ const PAGE_BREAK_HTML = '<div class="page-break" style="page-break-after:always;"><span style="display:none;">&nbsp;</span></div>';
129
+ function parse(html, domParser) {
130
+ if (!domParser && typeof DOMParser === 'undefined') {
131
+ throw new Error('CKEditor compatibility needs a DOM: pass `domParser` (see createCkDomParser) when there is no global DOMParser.');
132
+ }
133
+ const doc = (domParser !== null && domParser !== void 0 ? domParser : new DOMParser()).parseFromString(`<body>${html}</body>`, 'text/html');
134
+ return doc.body;
135
+ }
136
+ /**
137
+ * A `DOMParser` for {@link fromCkHtml} / {@link toCkHtml} that works in any environment: the global one
138
+ * in the browser, otherwise one from happy-dom (already installed with `@tiptap/html`, which
139
+ * {@link generateHTML} uses for the same reason). Create it once and reuse it.
140
+ */
141
+ function createCkDomParser() {
142
+ return __awaiter(this, void 0, void 0, function* () {
143
+ if (typeof DOMParser !== 'undefined')
144
+ return new DOMParser();
145
+ // Built at runtime so browser bundlers never resolve happy-dom (see generateHTML).
146
+ const specifier = ['happy', 'dom'].join('-');
147
+ let happyDom;
148
+ try {
149
+ happyDom = yield import(/* webpackIgnore: true */ /* @vite-ignore */ specifier);
150
+ }
151
+ catch (_a) {
152
+ throw new Error('No DOM available: install happy-dom, or pass your own `domParser` (e.g. from jsdom).');
153
+ }
154
+ return new new happyDom.Window().DOMParser();
155
+ });
156
+ }
157
+ function attrsOf(el) {
158
+ return Array.from(el.attributes)
159
+ .filter((a) => a.name !== CK_ORIGIN_ATTRIBUTE)
160
+ .map((a) => [a.name, a.value]);
161
+ }
162
+ /** Attribute names a record may restore: no event handlers, nothing a browser would not parse. */
163
+ const SAFE_ATTRIBUTE_NAME = /^(?!on)[a-z_:][a-z0-9_:.-]*$/i;
164
+ const UNSAFE_URL = /^\s*(javascript|vbscript|data:text\/html)/i;
165
+ /**
166
+ * A record is restored verbatim on save, and it can arrive from pasted HTML, not only from
167
+ * `fromCkHtml`. Accept only a well-formed one, without script-bearing attributes.
168
+ */
169
+ /** Browsers ignore control characters inside a URL scheme (`java\tscript:`). */
170
+ function withoutControlChars(value) {
171
+ return Array.from(value)
172
+ .filter((c) => c.charCodeAt(0) > 0x1f)
173
+ .join('');
174
+ }
175
+ function cleanAttrList(value) {
176
+ if (!Array.isArray(value))
177
+ return null;
178
+ const list = [];
179
+ for (const pair of value) {
180
+ if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== 'string' || typeof pair[1] !== 'string')
181
+ return null;
182
+ const [name, val] = pair;
183
+ if (SAFE_ATTRIBUTE_NAME.test(name) && !UNSAFE_URL.test(withoutControlChars(val)))
184
+ list.push([name, val]);
185
+ }
186
+ return list;
187
+ }
188
+ function readOrigin(el) {
189
+ const raw = el.getAttribute(CK_ORIGIN_ATTRIBUTE);
190
+ if (!raw)
191
+ return null;
192
+ let parsed;
193
+ try {
194
+ parsed = JSON.parse(raw);
195
+ }
196
+ catch (_a) {
197
+ return null;
198
+ }
199
+ if (!parsed || typeof parsed !== 'object')
200
+ return null;
201
+ const record = parsed;
202
+ const a = cleanAttrList(record['a']);
203
+ if (!a)
204
+ return null;
205
+ const origin = { a };
206
+ if (record['f'] !== undefined) {
207
+ const f = cleanAttrList(record['f']);
208
+ if (!f)
209
+ return null;
210
+ origin.f = f;
211
+ }
212
+ if (record['c'] !== undefined) {
213
+ if (!Array.isArray(record['c']))
214
+ return null;
215
+ const c = record['c'].map(cleanAttrList);
216
+ if (c.some((x) => !x))
217
+ return null;
218
+ origin.c = c;
219
+ }
220
+ return origin;
221
+ }
222
+ function writeOrigin(el, origin) {
223
+ el.setAttribute(CK_ORIGIN_ATTRIBUTE, JSON.stringify(origin));
224
+ }
225
+ // ---------------------------------------------------------------------------
226
+ // Load
227
+ // ---------------------------------------------------------------------------
228
+ /**
229
+ * Prepare CKEditor HTML for the editor. Safe to call on HTML that did not come from
230
+ * CKEditor: it then only records attributes.
231
+ */
232
+ function fromCkHtml(html, options = {}) {
233
+ const body = parse(html !== null && html !== void 0 ? html : '', options.domParser);
234
+ let wrapperClass = null;
235
+ const only = body.children.length === 1 ? body.firstElementChild : null;
236
+ if (only && only.tagName === 'DIV' && only.classList.contains('ck-content') && only.classList.contains('ck')) {
237
+ wrapperClass = only.getAttribute('class');
238
+ only.replaceWith(...Array.from(only.childNodes));
239
+ }
240
+ body.querySelectorAll(TRACKED_SELECTOR).forEach((el) => {
241
+ // Dynamic-field spans keep their attributes in their own record (see DynamicField export).
242
+ writeOrigin(el, { a: attrsOf(el) });
243
+ });
244
+ body.querySelectorAll('figure.table').forEach((figure) => {
245
+ var _a;
246
+ const table = figure.querySelector(':scope > table');
247
+ if (!table)
248
+ return;
249
+ const origin = (_a = readOrigin(table)) !== null && _a !== void 0 ? _a : { a: attrsOf(table) };
250
+ origin.f = attrsOf(figure);
251
+ writeOrigin(table, origin);
252
+ figure.replaceWith(...Array.from(figure.childNodes));
253
+ });
254
+ body.querySelectorAll('table').forEach((table) => {
255
+ var _a;
256
+ const cols = table.querySelectorAll(':scope > colgroup > col');
257
+ if (!cols.length)
258
+ return;
259
+ const origin = (_a = readOrigin(table)) !== null && _a !== void 0 ? _a : { a: attrsOf(table) };
260
+ origin.c = Array.from(cols).map(attrsOf);
261
+ writeOrigin(table, origin);
262
+ });
263
+ const unsupported = Object.keys(CK_UNSUPPORTED_CONTENT).filter((key) => key !== 'restrictedEditingException' && body.querySelector(CK_UNSUPPORTED_CONTENT[key]));
264
+ return { html: body.innerHTML, wrapperClass, unsupported };
265
+ }
266
+ /**
267
+ * Compare the stored HTML with what saving the freshly loaded document would write
268
+ * (`toCkHtml(editor.getHTML())` before any edit). A difference means the editor could not
269
+ * represent part of the document and saving would change it — open it read-only instead.
270
+ */
271
+ function verifyCkRoundTrip(original, saved) {
272
+ if (original === saved)
273
+ return { identical: true, difference: null };
274
+ let index = 0;
275
+ while (index < original.length && index < saved.length && original[index] === saved[index])
276
+ index++;
277
+ const excerpt = (text) => text.slice(Math.max(0, index - 40), index + 80);
278
+ return { identical: false, difference: { index, expected: excerpt(original), actual: excerpt(saved) } };
279
+ }
280
+ // ---------------------------------------------------------------------------
281
+ // Save
282
+ // ---------------------------------------------------------------------------
283
+ function parseStyle(style) {
284
+ if (!style)
285
+ return [];
286
+ return style
287
+ .split(';')
288
+ .map((d) => d.trim())
289
+ .filter(Boolean)
290
+ .map((d) => {
291
+ const i = d.indexOf(':');
292
+ return [d.slice(0, i).trim().toLowerCase(), d.slice(i + 1).trim()];
293
+ })
294
+ .filter(([k]) => !!k);
295
+ }
296
+ function formatStyle(decls) {
297
+ return decls.map(([k, v]) => `${k}:${v};`).join('');
298
+ }
299
+ /** Compare two CSS values the way the browser would compute them. */
300
+ function sameCssValue(prop, a, b, probe) {
301
+ if (a === b)
302
+ return true;
303
+ if (a.replace(/\s+/g, '') === b.replace(/\s+/g, ''))
304
+ return true;
305
+ probe.style.setProperty(prop, a);
306
+ const ca = probe.style.getPropertyValue(prop);
307
+ probe.style.setProperty(prop, b);
308
+ const cb = probe.style.getPropertyValue(prop);
309
+ probe.removeAttribute('style');
310
+ if (!!ca && ca === cb)
311
+ return true;
312
+ // The editor writes lengths in px; `1cm` in the source and `37.8px` from the editor are the same value.
313
+ const pa = absoluteLengthToPx(a);
314
+ const pb = absoluteLengthToPx(b);
315
+ return pa !== null && pb !== null && Math.abs(pa - pb) < 0.01;
316
+ }
317
+ function styleGroup(tag) {
318
+ if (/^h[1-6]$/.test(tag))
319
+ return 'h';
320
+ if (tag === 'th')
321
+ return 'td';
322
+ return tag;
323
+ }
324
+ function dropEditorDefaults(group, orig, cur, probe) {
325
+ const had = (k) => orig.some(([ok]) => ok === k || (k.startsWith('border-') && ok === 'border'));
326
+ const border = group === 'td' ? DEFAULT_CELL_BORDER : group === 'table' ? DEFAULT_TABLE_BORDER : null;
327
+ if (border &&
328
+ border.every(([k]) => !had(k)) &&
329
+ border.every(([k, v]) => cur.has(k) && sameCssValue(k, v, cur.get(k), probe))) {
330
+ border.forEach(([k]) => cur.delete(k));
331
+ }
332
+ if (group === 'td' && !had('vertical-align') && cur.get('vertical-align') === DEFAULT_CELL_VERTICAL_ALIGN) {
333
+ cur.delete('vertical-align');
334
+ }
335
+ }
336
+ /** Keep a source `border` shorthand when the editor's longhands still say the same thing. */
337
+ function shorthandStillHolds(value, cur, probe) {
338
+ probe.style.setProperty('border', value);
339
+ const parts = ['border-style', 'border-color', 'border-width'].map((k) => [k, probe.style.getPropertyValue(k)]);
340
+ probe.removeAttribute('style');
341
+ return parts.every(([k, v]) => !v || (cur.has(k) && sameCssValue(k, v, cur.get(k), probe)));
342
+ }
343
+ function reconcileStyle(tag, original, current, probe, fromSource = original !== null) {
344
+ var _a;
345
+ const group = styleGroup(tag);
346
+ const owned = (_a = OWNED_STYLES[group]) !== null && _a !== void 0 ? _a : [];
347
+ const orig = parseStyle(original);
348
+ // A value the editor could not parse (e.g. `margin-left: NaNpx`) means "unset".
349
+ const cur = new Map(parseStyle(current).filter(([, v]) => !/NaN|null|undefined/.test(v)));
350
+ const out = [];
351
+ if (fromSource)
352
+ dropEditorDefaults(group, orig, cur, probe);
353
+ for (const [k, v] of orig) {
354
+ if (k === 'border' && shorthandStillHolds(v, cur, probe)) {
355
+ out.push([k, v]);
356
+ ['border-style', 'border-color', 'border-width'].forEach((l) => cur.delete(l));
357
+ }
358
+ else if (cur.has(k)) {
359
+ const nv = cur.get(k);
360
+ out.push([k, sameCssValue(k, v, nv, probe) ? v : nv]);
361
+ cur.delete(k);
362
+ }
363
+ else if ((!owned.includes(k) || isZeroLength(v) || notReadable(k, v)) && !isCoveredBy(k, cur)) {
364
+ // Unmodelled properties are kept verbatim. So is an owned one the editor reads as "no value":
365
+ // zero in any unit (`margin-left:0in`) or a length it cannot express in px (`margin-left:2em`).
366
+ out.push([k, v]);
367
+ }
368
+ }
369
+ for (const [k, v] of cur) {
370
+ if (NEUTRAL_DECLARATIONS.has(`${k}:${v.replace(/\s+/g, '')}`))
371
+ continue;
372
+ if (impliedByShorthand(k, v, orig, probe))
373
+ continue;
374
+ out.push([k, v]);
375
+ }
376
+ return formatStyle(out);
377
+ }
378
+ /** Owned length properties the editor only models in absolute units. */
379
+ const PX_ONLY_STYLES = new Set(['margin-left']);
380
+ function notReadable(prop, value) {
381
+ return PX_ONLY_STYLES.has(prop) && absoluteLengthToPx(value) === null;
382
+ }
383
+ /**
384
+ * The editor reads a longhand out of a source shorthand (`margin:0 0 0 36pt` gives an indent of
385
+ * 48px) and writes it back. While the value is unchanged, the shorthand already says it.
386
+ */
387
+ function impliedByShorthand(prop, value, orig, probe) {
388
+ var _a;
389
+ const shorthand = (_a = /^(margin|padding)-/.exec(prop)) === null || _a === void 0 ? void 0 : _a[1];
390
+ const source = shorthand && orig.find(([k]) => k === shorthand);
391
+ if (!source)
392
+ return false;
393
+ probe.style.setProperty(shorthand, source[1]);
394
+ const implied = probe.style.getPropertyValue(prop);
395
+ probe.removeAttribute('style');
396
+ return !!implied && sameCssValue(prop, implied, value, probe);
397
+ }
398
+ function isZeroLength(value) {
399
+ return /^0(\.0+)?([a-z]+|%)?$/i.test(value.trim());
400
+ }
401
+ /** A shorthand such as `border` is superseded when the editor wrote its longhands. */
402
+ function isCoveredBy(prop, current) {
403
+ if (prop === 'border')
404
+ return ['border-style', 'border-color', 'border-width'].some((k) => current.has(k));
405
+ return false;
406
+ }
407
+ function setAttributesInOrder(el, attrs) {
408
+ Array.from(el.attributes).forEach((a) => el.removeAttribute(a.name));
409
+ attrs.forEach(([k, v]) => el.setAttribute(k, v));
410
+ }
411
+ /** Rebuild one element's attributes from its recorded origin and what the editor emitted. */
412
+ function reconcileElement(el, origin, probe) {
413
+ var _a, _b;
414
+ const tag = el.tagName.toLowerCase();
415
+ const record = el.getAttribute(CK_ORIGIN_ATTRIBUTE);
416
+ const current = new Map(attrsOf(el).filter(([k]) => !ENGINE_ONLY_ATTRIBUTES.includes(k)));
417
+ const result = [];
418
+ for (const [k, v] of origin !== null && origin !== void 0 ? origin : []) {
419
+ if (k === 'style') {
420
+ const style = reconcileStyle(tag, v, (_a = current.get('style')) !== null && _a !== void 0 ? _a : null, probe);
421
+ if (style)
422
+ result.push(['style', style]);
423
+ current.delete('style');
424
+ }
425
+ else if (k === 'class') {
426
+ const classes = ((_b = current.get('class')) !== null && _b !== void 0 ? _b : '').split(/\s+/).filter(Boolean);
427
+ const merged = [...v.split(/\s+/).filter(Boolean)];
428
+ classes.forEach((c) => !merged.includes(c) && merged.push(c));
429
+ result.push(['class', merged.join(' ')]);
430
+ current.delete('class');
431
+ }
432
+ else if (current.has(k)) {
433
+ result.push([k, current.get(k)]);
434
+ current.delete(k);
435
+ }
436
+ else if (!OWNED_ATTRIBUTES.has(k)) {
437
+ result.push([k, v]);
438
+ }
439
+ }
440
+ for (const [k, v] of current) {
441
+ if (k === 'style') {
442
+ const style = reconcileStyle(tag, null, v, probe, origin !== null);
443
+ if (style)
444
+ result.push(['style', style]);
445
+ }
446
+ else if ((k === 'colspan' || k === 'rowspan') && v === '1') {
447
+ continue;
448
+ }
449
+ else {
450
+ result.push([k, v]);
451
+ }
452
+ }
453
+ setAttributesInOrder(el, result);
454
+ // Keep the record for later passes (tables read it); it is stripped at the very end.
455
+ if (record)
456
+ el.setAttribute(CK_ORIGIN_ATTRIBUTE, record);
457
+ }
458
+ function exportDynamicFields(body) {
459
+ body.querySelectorAll('span[data-field-id]').forEach((el) => {
460
+ var _a, _b, _c, _d;
461
+ const key = (_a = el.getAttribute('data-field-id')) !== null && _a !== void 0 ? _a : '';
462
+ const label = el.getAttribute('data-label') || key;
463
+ const origin = (_c = (_b = readOrigin(el)) === null || _b === void 0 ? void 0 : _b.a) !== null && _c !== void 0 ? _c : null;
464
+ const doc = el.ownerDocument;
465
+ const span = doc.createElement('span');
466
+ const originKey = origin ? ((_d = el.textContent) !== null && _d !== void 0 ? _d : '').replace(/{{|}}/g, '').trim() : null;
467
+ if (origin && originKey === key) {
468
+ setAttributesInOrder(span, origin);
469
+ }
470
+ else {
471
+ // A field inserted (or re-keyed) in this editor: emit the shape the CKEditor plugin writes.
472
+ setAttributesInOrder(span, [
473
+ ['class', [...DYNAMIC_FIELD_BASE_CLASSES, DYNAMIC_FIELD_HAS_VALUE_CLASS].join(' ')],
474
+ ['id', `red-dynamic-field__${key}`],
475
+ ['name', label],
476
+ ['background', 'false'],
477
+ ['dynamicfieldname', label],
478
+ ['value', label],
479
+ ['type', 'textbox:text'],
480
+ ]);
481
+ }
482
+ span.textContent = `{{${key}}}`;
483
+ el.replaceWith(span);
484
+ });
485
+ }
486
+ /**
487
+ * Write editable regions as CKEditor restricted-editing exceptions. The editor fills a region it
488
+ * creates empty with a zero-width space (and refills one emptied in restricted mode with a space);
489
+ * a region left without text is written as `&nbsp;` so it stays in the document (CKEditor's
490
+ * empty-content convention). The exception class is always kept, whatever the record says: it is
491
+ * what identifies the region in stored HTML.
492
+ */
493
+ function exportEditableRegions(body) {
494
+ body.querySelectorAll('span[data-editable-region]').forEach((el) => {
495
+ var _a, _b, _c;
496
+ const span = el.ownerDocument.createElement('span');
497
+ setAttributesInOrder(span, (_b = (_a = readOrigin(el)) === null || _a === void 0 ? void 0 : _a.a) !== null && _b !== void 0 ? _b : [['class', EDITABLE_REGION_CK_CLASS]]);
498
+ if (!span.classList.contains(EDITABLE_REGION_CK_CLASS))
499
+ span.classList.add(EDITABLE_REGION_CK_CLASS);
500
+ span.append(...Array.from(el.childNodes));
501
+ stripZeroWidthSpaces(span);
502
+ if (!((_c = span.textContent) === null || _c === void 0 ? void 0 : _c.trim()))
503
+ span.textContent = '\u00a0';
504
+ el.replaceWith(span);
505
+ });
506
+ }
507
+ function stripZeroWidthSpaces(node) {
508
+ var _a;
509
+ if (node.nodeType === 3)
510
+ node.nodeValue = ((_a = node.nodeValue) !== null && _a !== void 0 ? _a : '').replace(/\u200b/g, '');
511
+ node.childNodes.forEach(stripZeroWidthSpaces);
512
+ }
513
+ /**
514
+ * Tiptap's `TrailingNode` appends an empty paragraph whenever a document ends in a table or other
515
+ * non-paragraph block. CKEditor allows that ending, so drop the paragraph unless the source had it.
516
+ */
517
+ function dropTrailingNode(body) {
518
+ const last = body.lastElementChild;
519
+ if (!last || last.tagName !== 'P' || last.attributes.length || last.childNodes.length)
520
+ return;
521
+ const previous = last.previousElementSibling;
522
+ if (previous && previous.tagName !== 'P')
523
+ last.remove();
524
+ }
525
+ function exportPageBreaks(body) {
526
+ body.querySelectorAll('div[data-page-break]').forEach((el) => {
527
+ var _a;
528
+ const origin = (_a = readOrigin(el)) === null || _a === void 0 ? void 0 : _a.a;
529
+ const holder = el.ownerDocument.createElement('div');
530
+ holder.innerHTML = PAGE_BREAK_HTML;
531
+ const div = holder.firstElementChild;
532
+ if (origin)
533
+ setAttributesInOrder(div, origin);
534
+ el.replaceWith(div);
535
+ });
536
+ }
537
+ function exportTables(body, probe) {
538
+ body.querySelectorAll('table').forEach((table) => {
539
+ var _a, _b;
540
+ const origin = readOrigin(table);
541
+ const doc = table.ownerDocument;
542
+ // <colgroup>: keep the recorded <col> attributes when the column count is unchanged.
543
+ const cols = Array.from(table.querySelectorAll(':scope > colgroup > col'));
544
+ cols.forEach((col, i) => {
545
+ const recorded = (origin === null || origin === void 0 ? void 0 : origin.c) && origin.c.length === cols.length ? origin.c[i] : null;
546
+ reconcileElement(col, recorded, probe);
547
+ });
548
+ table.querySelectorAll(':scope > tbody > tr > td, :scope > tbody > tr > th').forEach(unwrapLoneParagraph);
549
+ // <figure class="table">: restore the recorded wrapper, or add CKEditor's default.
550
+ if (((_a = table.parentElement) === null || _a === void 0 ? void 0 : _a.tagName) !== 'FIGURE') {
551
+ const figure = doc.createElement('figure');
552
+ setAttributesInOrder(figure, (_b = origin === null || origin === void 0 ? void 0 : origin.f) !== null && _b !== void 0 ? _b : [['class', 'table']]);
553
+ table.replaceWith(figure);
554
+ figure.appendChild(table);
555
+ }
556
+ });
557
+ }
558
+ /** CKEditor writes a lone, attribute-less paragraph in a table cell or list item as bare content. */
559
+ function unwrapLoneParagraph(container) {
560
+ const only = container.childNodes.length === 1 ? container.firstElementChild : null;
561
+ if (only && only === container.firstChild && only.tagName === 'P' && !hasMeaningfulAttributes(only)) {
562
+ only.replaceWith(...Array.from(only.childNodes));
563
+ }
564
+ }
565
+ /** True when an element carries attributes CKEditor would keep (ignores the origin record). */
566
+ function hasMeaningfulAttributes(el) {
567
+ var _a, _b;
568
+ const origin = (_b = (_a = readOrigin(el)) === null || _a === void 0 ? void 0 : _a.a) !== null && _b !== void 0 ? _b : [];
569
+ if (origin.length)
570
+ return true;
571
+ return attrsOf(el).some(([k, v]) => {
572
+ if (ENGINE_ONLY_ATTRIBUTES.includes(k))
573
+ return false;
574
+ if (k === 'style')
575
+ return parseStyle(v).some(([p, val]) => !NEUTRAL_DECLARATIONS.has(`${p}:${val.replace(/\s+/g, '')}`));
576
+ return true;
577
+ });
578
+ }
579
+ /**
580
+ * Turn `editor.getHTML()` output back into CKEditor-shaped HTML.
581
+ */
582
+ function toCkHtml(html, options = {}) {
583
+ var _a;
584
+ const body = parse(html !== null && html !== void 0 ? html : '', options.domParser);
585
+ const probe = body.ownerDocument.createElement('span');
586
+ exportDynamicFields(body);
587
+ exportPageBreaks(body);
588
+ exportEditableRegions(body);
589
+ // Reconcile plain elements before tables restructure cells (so `<p>` attributes are final).
590
+ body.querySelectorAll('*').forEach((el) => {
591
+ var _a, _b;
592
+ const tag = el.tagName.toLowerCase();
593
+ if (tag === 'col' || el.closest('div.page-break') || el.matches('span[id^="red-dynamic-field"]'))
594
+ return;
595
+ if (el.matches(`span.${EDITABLE_REGION_CK_CLASS}`))
596
+ return;
597
+ reconcileElement(el, (_b = (_a = readOrigin(el)) === null || _a === void 0 ? void 0 : _a.a) !== null && _b !== void 0 ? _b : null, probe);
598
+ });
599
+ dropTrailingNode(body);
600
+ // CKEditor writes an empty block as `&nbsp;`.
601
+ body.querySelectorAll('p,h1,h2,h3,h4,h5,h6').forEach((el) => {
602
+ if (!el.childNodes.length)
603
+ el.textContent = '\u00a0';
604
+ });
605
+ exportTables(body, probe);
606
+ body.querySelectorAll('li').forEach(unwrapLoneParagraph);
607
+ // CKEditor's Italic writes <i>, not <em>.
608
+ body.querySelectorAll('em').forEach((em) => {
609
+ const i = em.ownerDocument.createElement('i');
610
+ Array.from(em.attributes).forEach((a) => i.setAttribute(a.name, a.value));
611
+ i.append(...Array.from(em.childNodes));
612
+ em.replaceWith(i);
613
+ });
614
+ body.querySelectorAll(`[${CK_ORIGIN_ATTRIBUTE}]`).forEach((el) => el.removeAttribute(CK_ORIGIN_ATTRIBUTE));
615
+ const wrapperClass = (_a = options.wrapperClass) !== null && _a !== void 0 ? _a : CK_WRAPPER_BASE_CLASS;
616
+ if (wrapperClass === false)
617
+ 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;
623
+ }
624
+
625
+ /** Node and mark types whose original CKEditor attributes are carried through editing. */
626
+ const CK_COMPAT_TYPES = [
627
+ 'paragraph',
628
+ 'heading',
629
+ 'blockquote',
630
+ 'bulletList',
631
+ 'orderedList',
632
+ 'listItem',
633
+ 'table',
634
+ 'tableRow',
635
+ 'tableCell',
636
+ 'tableHeader',
637
+ 'textStyle',
638
+ 'link',
639
+ 'dynamicField',
640
+ 'pageBreak',
641
+ 'editableRegion',
642
+ ];
643
+ const ckOrigin = (fallback) => ({
644
+ default: null,
645
+ // Splitting a block must not copy the record: the new block is new content.
646
+ keepOnSplit: false,
647
+ parseHTML: (element) => { var _a, _b; return (_b = (_a = element.getAttribute(CK_ORIGIN_ATTRIBUTE)) !== null && _a !== void 0 ? _a : fallback === null || fallback === void 0 ? void 0 : fallback(element)) !== null && _b !== void 0 ? _b : null; },
648
+ renderHTML: (attributes) => attributes['ckOrigin'] ? { [CK_ORIGIN_ATTRIBUTE]: attributes['ckOrigin'] } : {},
649
+ });
650
+ /**
651
+ * A CKEditor dynamic field pasted into the editor never went through `fromCkHtml()`, so record
652
+ * its attributes here — otherwise its original id/name/value would be replaced on save.
653
+ */
654
+ function recordPastedDynamicField(element) {
655
+ // The editor renders its own fields with this class too; only CKEditor markup lacks `data-field-id`.
656
+ if (!element.classList.contains('red-dynamic-field') || element.hasAttribute('data-field-id'))
657
+ return null;
658
+ const a = Array.from(element.attributes).map((attr) => [attr.name, attr.value]);
659
+ return JSON.stringify({ a });
660
+ }
661
+ /**
662
+ * Keeps the `data-ck` record written by `fromCkHtml()` on every supported node and mark, so
663
+ * `toCkHtml()` can restore the original CKEditor markup on save. Register it together with the
664
+ * two functions; on its own it only round-trips the attribute.
665
+ */
666
+ const CkCompat = Extension.create({
667
+ name: 'ckCompat',
668
+ addGlobalAttributes() {
669
+ return [
670
+ { types: CK_COMPAT_TYPES.filter((t) => t !== 'dynamicField'), attributes: { ckOrigin: ckOrigin() } },
671
+ { types: ['dynamicField'], attributes: { ckOrigin: ckOrigin(recordPastedDynamicField) } },
672
+ ];
673
+ },
674
+ });
29
675
 
30
676
  const INDENT_DEFAULT = 40; // in pixels
31
677
 
@@ -53,7 +699,13 @@ const Indent = Extension.create({
53
699
  attributes: {
54
700
  indent: {
55
701
  parseHTML: (element) => {
56
- return Number(element.style.marginLeft.replace('px', ''));
702
+ const marginLeft = element.style.marginLeft;
703
+ if (!marginLeft)
704
+ return 0;
705
+ // Content pasted from Word / CKEditor often indents in pt or cm. Relative values (`em`, `%`)
706
+ // cannot be expressed in px and are left unset.
707
+ const px = absoluteLengthToPx(marginLeft);
708
+ return px === null ? null : Math.round(px * 100) / 100;
57
709
  },
58
710
  renderHTML: (attributes) => {
59
711
  return { style: `margin-left: ${attributes['indent']}px` };
@@ -2259,4 +2911,4 @@ class Color {
2259
2911
  }
2260
2912
  }
2261
2913
 
2262
- export { ClearContent, Color, CustomOrderedList, DynamicField, EditableRegion, HandleNodeView, INDENT_DEFAULT, ImageRef, Indent, LATEST_SCHEMA_VERSION, NotumHeading, PageBreak, PageBreakNodeView, ResetFormat, ResetOnEnter, RestrictedEditing, StyledTable, StyledTableCell, StyledTableHeader, StyledTableKit, TableDefaultAttributes, TableNodeView, TextCase, createPageBreakNodeView, createTableNodeView, defaultExtensions, docMigrations, generateHTML, getActiveMarkRange, getClosestDomElement, getCombinedCellAttributeValue, getCursorCellInfo, getSelectedCells, getSelectedText, getTableDOMFromView, migrateDoc, normalizeColor };
2914
+ export { CK_COMPAT_TYPES, CK_ORIGIN_ATTRIBUTE, CK_UNSUPPORTED_CONTENT, CK_WRAPPER_BASE_CLASS, CkCompat, ClearContent, Color, CustomOrderedList, DynamicField, EditableRegion, HandleNodeView, INDENT_DEFAULT, ImageRef, Indent, LATEST_SCHEMA_VERSION, NotumHeading, PageBreak, PageBreakNodeView, ResetFormat, ResetOnEnter, RestrictedEditing, StyledTable, StyledTableCell, StyledTableHeader, StyledTableKit, TableDefaultAttributes, TableNodeView, TextCase, createCkDomParser, createPageBreakNodeView, createTableNodeView, defaultExtensions, docMigrations, fromCkHtml, generateHTML, getActiveMarkRange, getClosestDomElement, getCombinedCellAttributeValue, getCursorCellInfo, getSelectedCells, getSelectedText, getTableDOMFromView, migrateDoc, normalizeColor, toCkHtml, verifyCkRoundTrip };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phuong-tran-redoc/document-engine-core",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "author": "Realestatedoc (Redoc)",
@@ -66,6 +66,12 @@
66
66
  "tslib": "^2.3.0"
67
67
  },
68
68
  "peerDependencies": {
69
- "lodash-es": "^4.17.10"
69
+ "lodash-es": "^4.17.10",
70
+ "happy-dom": "^20.8.9"
71
+ },
72
+ "peerDependenciesMeta": {
73
+ "happy-dom": {
74
+ "optional": true
75
+ }
70
76
  }
71
77
  }
@@ -0,0 +1,9 @@
1
+ import { Extension } from '@tiptap/core';
2
+ /** Node and mark types whose original CKEditor attributes are carried through editing. */
3
+ export declare const CK_COMPAT_TYPES: readonly string[];
4
+ /**
5
+ * Keeps the `data-ck` record written by `fromCkHtml()` on every supported node and mark, so
6
+ * `toCkHtml()` can restore the original CKEditor markup on save. Register it together with the
7
+ * two functions; on its own it only round-trips the attribute.
8
+ */
9
+ export declare const CkCompat: Extension<any, any>;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * CKEditor 5 HTML compatibility layer.
3
+ *
4
+ * Lets the editor load HTML that was produced by the legacy CKEditor 5 build and save it back
5
+ * in the same shape, so stored documents and the code that reads them keep working.
6
+ *
7
+ * - {@link fromCkHtml} runs before content is set into the editor. It strips the CKEditor
8
+ * data-processor wrapper and records each element's original attributes, in order, in a
9
+ * `data-ck` attribute that the {@link CkCompat} extension carries through editing.
10
+ * - {@link toCkHtml} runs on `editor.getHTML()`. It rebuilds the CKEditor markup from those
11
+ * records: attribute order, `prop:value;` style formatting, `<figure class="table">`,
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
+ * not model is kept verbatim.
15
+ *
16
+ * Both functions need a `DOMParser`. In the browser the global one is used. In Node (no global DOM)
17
+ * pass one in, e.g. from {@link createCkDomParser}:
18
+ *
19
+ * ```ts
20
+ * const domParser = await createCkDomParser();
21
+ * const ckHtml = toCkHtml(await generateHTML(json, extensions), { wrapperClass, domParser });
22
+ * ```
23
+ */
24
+ /** Attribute that carries the original CKEditor attributes of an element through the editor. */
25
+ export declare const CK_ORIGIN_ATTRIBUTE = "data-ck";
26
+ /** Class list of the CKEditor data-processor wrapper (`CustomHtmlDataProcessor`). */
27
+ export declare const CK_WRAPPER_BASE_CLASS = "ck ck-content ck-print";
28
+ /** The part of the DOM `DOMParser` API the compatibility layer needs. */
29
+ export interface CkDomParser {
30
+ parseFromString(source: string, type: 'text/html'): Document;
31
+ }
32
+ export interface CkDomOptions {
33
+ /** Parser to use instead of the global `DOMParser`; required where there is no DOM (Node). */
34
+ domParser?: CkDomParser;
35
+ }
36
+ export interface CkHtmlOptions extends CkDomOptions {
37
+ /**
38
+ * Class list for the outer wrapper `<div>` — pass the `wrapperClass` that {@link fromCkHtml}
39
+ * returned for this document. Defaults to {@link CK_WRAPPER_BASE_CLASS}; `false` emits no wrapper.
40
+ */
41
+ wrapperClass?: string | false;
42
+ }
43
+ export interface CkLoadResult {
44
+ /** HTML ready to be set into the editor. */
45
+ html: string;
46
+ /** Class list of the wrapper that was stripped, or `null` when the input had none. */
47
+ wrapperClass: string | null;
48
+ /**
49
+ * Keys of {@link CK_UNSUPPORTED_CONTENT} found in the input. The editor has no node for these, so
50
+ * loading the document would drop them: open it read-only (or keep the legacy editor) when non-empty.
51
+ */
52
+ unsupported: CkUnsupportedContent[];
53
+ }
54
+ /**
55
+ * Content produced by CKEditor plugins of the legacy build that this editor cannot represent yet,
56
+ * keyed by name, with the selector that identifies it in stored HTML.
57
+ */
58
+ export declare const CK_UNSUPPORTED_CONTENT: {
59
+ readonly signatureField: ".redr-signature-field";
60
+ readonly inlineField: ".redr-inline-field";
61
+ readonly dynamicImage: ".redr-dynamic-image";
62
+ readonly dealTable: ".redr-deal-table";
63
+ readonly editorColumn: ".redr-editor-column";
64
+ /** @deprecated Supported since 0.1.8 (loads as an editable region); no longer reported. */
65
+ readonly restrictedEditingException: ".restricted-editing-exception";
66
+ readonly image: "img, figure.image";
67
+ };
68
+ export type CkUnsupportedContent = keyof typeof CK_UNSUPPORTED_CONTENT;
69
+ export interface CkRoundTripReport {
70
+ /** `true` when saving the loaded document unchanged reproduces the input byte-for-byte. */
71
+ identical: boolean;
72
+ /** Where the two first differ (a short excerpt of each side), or `null` when identical. */
73
+ difference: {
74
+ index: number;
75
+ expected: string;
76
+ actual: string;
77
+ } | null;
78
+ }
79
+ /**
80
+ * A `DOMParser` for {@link fromCkHtml} / {@link toCkHtml} that works in any environment: the global one
81
+ * in the browser, otherwise one from happy-dom (already installed with `@tiptap/html`, which
82
+ * {@link generateHTML} uses for the same reason). Create it once and reuse it.
83
+ */
84
+ export declare function createCkDomParser(): Promise<CkDomParser>;
85
+ /**
86
+ * Prepare CKEditor HTML for the editor. Safe to call on HTML that did not come from
87
+ * CKEditor: it then only records attributes.
88
+ */
89
+ export declare function fromCkHtml(html: string, options?: CkDomOptions): CkLoadResult;
90
+ /**
91
+ * Compare the stored HTML with what saving the freshly loaded document would write
92
+ * (`toCkHtml(editor.getHTML())` before any edit). A difference means the editor could not
93
+ * represent part of the document and saving would change it — open it read-only instead.
94
+ */
95
+ export declare function verifyCkRoundTrip(original: string, saved: string): CkRoundTripReport;
96
+ /**
97
+ * Turn `editor.getHTML()` output back into CKEditor-shaped HTML.
98
+ */
99
+ export declare function toCkHtml(html: string, options?: CkHtmlOptions): string;
@@ -0,0 +1,2 @@
1
+ export * from './ck-compat.extension';
2
+ export * from './ck-html';
@@ -0,0 +1 @@
1
+ export * from './ckeditor';
package/src/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './compat';
1
2
  export * from './constants';
2
3
  export * from './extensions';
3
4
  export * from './kit';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Convert a single absolute CSS length (`36pt`, `1cm`, `40px`) to pixels.
3
+ * Returns `null` for anything else: relative units (`em`, `%`), keywords, or several values.
4
+ */
5
+ export declare function absoluteLengthToPx(value: string | null | undefined): number | null;