@phuong-tran-redoc/document-engine-core 0.1.5 → 0.1.7

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,29 @@ 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
+ On a backend (no DOM) pass a parser. `createCkDomParser()` uses happy-dom, which `@tiptap/html` already installs:
116
+
117
+ ```typescript
118
+ const domParser = await createCkDomParser();
119
+ const ckHtml = toCkHtml(await generateHTML(json, extensions), { wrapperClass, domParser });
120
+ ```
121
+
99
122
  ### Versioned documents + migrations
100
123
 
101
124
  ```typescript
@@ -138,6 +161,12 @@ Everything below is re-exported from the package entry (`@phuong-tran-redoc/docu
138
161
  - `defaultExtensions: Extensions` — the canonical schema (nodes, marks, structures).
139
162
  - `generateHTML(doc, extensions?): Promise<string>` — async, environment-aware headless serializer.
140
163
 
164
+ ### CKEditor compatibility (`compat`)
165
+
166
+ - `CkCompat` — extension that carries the original CKEditor attributes through editing.
167
+ - `fromCkHtml(html, { domParser? })` → `{ html, wrapperClass, unsupported }`; `toCkHtml(html, { wrapperClass?, domParser? })`.
168
+ - `verifyCkRoundTrip(original, saved)`, `CK_UNSUPPORTED_CONTENT`, `createCkDomParser()`.
169
+
141
170
  ### Migrations (`migrations`)
142
171
 
143
172
  - `EditorDocument` — `{ schemaVersion: number; content: JSONContent }`.
@@ -201,13 +230,9 @@ nx lint @phuong-tran-redoc/document-engine-core # lint
201
230
 
202
231
  ---
203
232
 
204
- ## 👤 Author
205
-
206
- Developed by **Duc Phuong (Jack)**
233
+ ## 👤 Authors
207
234
 
208
- - 💼 [LinkedIn](https://www.linkedin.com/in/tdp1999/)
209
- - 🐙 [GitHub](https://github.com/tdp1999)
210
- - 📧 [Email](mailto:tdp99.business@gmail.com)
235
+ See [AUTHORS.md](https://github.com/phuong-tran-redoc/document-engine/blob/main/AUTHORS.md).
211
236
 
212
237
  ---
213
238
 
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,637 @@ 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 and
65
+ * 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
+ restrictedEditingException: '.restricted-editing-exception',
91
+ image: 'img, figure.image',
92
+ };
93
+ /** Elements whose original attributes are recorded on load. */
94
+ const TRACKED_SELECTOR = 'p,h1,h2,h3,h4,h5,h6,span,table,tr,td,th,blockquote,li,ol,ul,a,div.page-break';
95
+ /** Style properties the editor itself writes, per element. A recorded value for one of these
96
+ * that the editor no longer emits was removed by the user and is dropped. */
97
+ const OWNED_STYLES = {
98
+ p: ['text-align', 'margin-left'],
99
+ h: ['text-align', 'margin-left'],
100
+ span: ['font-size', 'font-family', 'color', 'background-color', 'line-height'],
101
+ td: ['text-align', 'vertical-align', 'background-color', 'border-color', 'border-style', 'border-width'],
102
+ table: ['background-color', 'border-color', 'border-style', 'border-width'],
103
+ col: ['width'],
104
+ };
105
+ /** Attributes the editor itself writes. A recorded one the editor no longer emits is dropped. */
106
+ const OWNED_ATTRIBUTES = new Set(['style', 'colspan', 'rowspan', 'href', 'target', 'rel', 'start', 'type']);
107
+ /** Attributes the editor emits that never belong in CKEditor output. */
108
+ const ENGINE_ONLY_ATTRIBUTES = ['data-colwidths', 'colwidth', 'data-indent'];
109
+ /**
110
+ * Default cell styles the table extension writes on every cell. On an element that came from
111
+ * CKEditor they are dropped unless the source had them; a border group is only dropped when all
112
+ * three parts are still the default, so a border the user set is kept.
113
+ */
114
+ const DEFAULT_CELL_BORDER = [
115
+ ['border-style', 'solid'],
116
+ ['border-color', '#e5e7eb'],
117
+ ['border-width', '1px'],
118
+ ];
119
+ const DEFAULT_TABLE_BORDER = DEFAULT_CELL_BORDER;
120
+ const DEFAULT_CELL_VERTICAL_ALIGN = 'middle';
121
+ /** Declarations the editor emits as a neutral default; dropped unless they were in the source. */
122
+ const NEUTRAL_DECLARATIONS = new Set(['margin-left:0px', 'margin-left:0', 'margin-left:nullpx']);
123
+ const DYNAMIC_FIELD_BASE_CLASSES = ['red-dynamic-field', 'inline-field', 'redr-handlebar-field'];
124
+ const DYNAMIC_FIELD_HAS_VALUE_CLASS = 'red-dynamic-field--has-value';
125
+ const PAGE_BREAK_HTML = '<div class="page-break" style="page-break-after:always;"><span style="display:none;">&nbsp;</span></div>';
126
+ function parse(html, domParser) {
127
+ if (!domParser && typeof DOMParser === 'undefined') {
128
+ throw new Error('CKEditor compatibility needs a DOM: pass `domParser` (see createCkDomParser) when there is no global DOMParser.');
129
+ }
130
+ const doc = (domParser !== null && domParser !== void 0 ? domParser : new DOMParser()).parseFromString(`<body>${html}</body>`, 'text/html');
131
+ return doc.body;
132
+ }
133
+ /**
134
+ * A `DOMParser` for {@link fromCkHtml} / {@link toCkHtml} that works in any environment: the global one
135
+ * in the browser, otherwise one from happy-dom (already installed with `@tiptap/html`, which
136
+ * {@link generateHTML} uses for the same reason). Create it once and reuse it.
137
+ */
138
+ function createCkDomParser() {
139
+ return __awaiter(this, void 0, void 0, function* () {
140
+ if (typeof DOMParser !== 'undefined')
141
+ return new DOMParser();
142
+ // Built at runtime so browser bundlers never resolve happy-dom (see generateHTML).
143
+ const specifier = ['happy', 'dom'].join('-');
144
+ let happyDom;
145
+ try {
146
+ happyDom = yield import(/* webpackIgnore: true */ /* @vite-ignore */ specifier);
147
+ }
148
+ catch (_a) {
149
+ throw new Error('No DOM available: install happy-dom, or pass your own `domParser` (e.g. from jsdom).');
150
+ }
151
+ return new new happyDom.Window().DOMParser();
152
+ });
153
+ }
154
+ function attrsOf(el) {
155
+ return Array.from(el.attributes)
156
+ .filter((a) => a.name !== CK_ORIGIN_ATTRIBUTE)
157
+ .map((a) => [a.name, a.value]);
158
+ }
159
+ /** Attribute names a record may restore: no event handlers, nothing a browser would not parse. */
160
+ const SAFE_ATTRIBUTE_NAME = /^(?!on)[a-z_:][a-z0-9_:.-]*$/i;
161
+ const UNSAFE_URL = /^\s*(javascript|vbscript|data:text\/html)/i;
162
+ /**
163
+ * A record is restored verbatim on save, and it can arrive from pasted HTML, not only from
164
+ * `fromCkHtml`. Accept only a well-formed one, without script-bearing attributes.
165
+ */
166
+ /** Browsers ignore control characters inside a URL scheme (`java\tscript:`). */
167
+ function withoutControlChars(value) {
168
+ return Array.from(value)
169
+ .filter((c) => c.charCodeAt(0) > 0x1f)
170
+ .join('');
171
+ }
172
+ function cleanAttrList(value) {
173
+ if (!Array.isArray(value))
174
+ return null;
175
+ const list = [];
176
+ for (const pair of value) {
177
+ if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== 'string' || typeof pair[1] !== 'string')
178
+ return null;
179
+ const [name, val] = pair;
180
+ if (SAFE_ATTRIBUTE_NAME.test(name) && !UNSAFE_URL.test(withoutControlChars(val)))
181
+ list.push([name, val]);
182
+ }
183
+ return list;
184
+ }
185
+ function readOrigin(el) {
186
+ const raw = el.getAttribute(CK_ORIGIN_ATTRIBUTE);
187
+ if (!raw)
188
+ return null;
189
+ let parsed;
190
+ try {
191
+ parsed = JSON.parse(raw);
192
+ }
193
+ catch (_a) {
194
+ return null;
195
+ }
196
+ if (!parsed || typeof parsed !== 'object')
197
+ return null;
198
+ const record = parsed;
199
+ const a = cleanAttrList(record['a']);
200
+ if (!a)
201
+ return null;
202
+ const origin = { a };
203
+ if (record['f'] !== undefined) {
204
+ const f = cleanAttrList(record['f']);
205
+ if (!f)
206
+ return null;
207
+ origin.f = f;
208
+ }
209
+ if (record['c'] !== undefined) {
210
+ if (!Array.isArray(record['c']))
211
+ return null;
212
+ const c = record['c'].map(cleanAttrList);
213
+ if (c.some((x) => !x))
214
+ return null;
215
+ origin.c = c;
216
+ }
217
+ return origin;
218
+ }
219
+ function writeOrigin(el, origin) {
220
+ el.setAttribute(CK_ORIGIN_ATTRIBUTE, JSON.stringify(origin));
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // Load
224
+ // ---------------------------------------------------------------------------
225
+ /**
226
+ * Prepare CKEditor HTML for the editor. Safe to call on HTML that did not come from
227
+ * CKEditor: it then only records attributes.
228
+ */
229
+ function fromCkHtml(html, options = {}) {
230
+ const body = parse(html !== null && html !== void 0 ? html : '', options.domParser);
231
+ let wrapperClass = null;
232
+ const only = body.children.length === 1 ? body.firstElementChild : null;
233
+ if (only && only.tagName === 'DIV' && only.classList.contains('ck-content') && only.classList.contains('ck')) {
234
+ wrapperClass = only.getAttribute('class');
235
+ only.replaceWith(...Array.from(only.childNodes));
236
+ }
237
+ body.querySelectorAll(TRACKED_SELECTOR).forEach((el) => {
238
+ // Dynamic-field spans keep their attributes in their own record (see DynamicField export).
239
+ writeOrigin(el, { a: attrsOf(el) });
240
+ });
241
+ body.querySelectorAll('figure.table').forEach((figure) => {
242
+ var _a;
243
+ const table = figure.querySelector(':scope > table');
244
+ if (!table)
245
+ return;
246
+ const origin = (_a = readOrigin(table)) !== null && _a !== void 0 ? _a : { a: attrsOf(table) };
247
+ origin.f = attrsOf(figure);
248
+ writeOrigin(table, origin);
249
+ figure.replaceWith(...Array.from(figure.childNodes));
250
+ });
251
+ body.querySelectorAll('table').forEach((table) => {
252
+ var _a;
253
+ const cols = table.querySelectorAll(':scope > colgroup > col');
254
+ if (!cols.length)
255
+ return;
256
+ const origin = (_a = readOrigin(table)) !== null && _a !== void 0 ? _a : { a: attrsOf(table) };
257
+ origin.c = Array.from(cols).map(attrsOf);
258
+ writeOrigin(table, origin);
259
+ });
260
+ const unsupported = Object.keys(CK_UNSUPPORTED_CONTENT).filter((key) => body.querySelector(CK_UNSUPPORTED_CONTENT[key]));
261
+ return { html: body.innerHTML, wrapperClass, unsupported };
262
+ }
263
+ /**
264
+ * Compare the stored HTML with what saving the freshly loaded document would write
265
+ * (`toCkHtml(editor.getHTML())` before any edit). A difference means the editor could not
266
+ * represent part of the document and saving would change it — open it read-only instead.
267
+ */
268
+ function verifyCkRoundTrip(original, saved) {
269
+ if (original === saved)
270
+ return { identical: true, difference: null };
271
+ let index = 0;
272
+ while (index < original.length && index < saved.length && original[index] === saved[index])
273
+ index++;
274
+ const excerpt = (text) => text.slice(Math.max(0, index - 40), index + 80);
275
+ return { identical: false, difference: { index, expected: excerpt(original), actual: excerpt(saved) } };
276
+ }
277
+ // ---------------------------------------------------------------------------
278
+ // Save
279
+ // ---------------------------------------------------------------------------
280
+ function parseStyle(style) {
281
+ if (!style)
282
+ return [];
283
+ return style
284
+ .split(';')
285
+ .map((d) => d.trim())
286
+ .filter(Boolean)
287
+ .map((d) => {
288
+ const i = d.indexOf(':');
289
+ return [d.slice(0, i).trim().toLowerCase(), d.slice(i + 1).trim()];
290
+ })
291
+ .filter(([k]) => !!k);
292
+ }
293
+ function formatStyle(decls) {
294
+ return decls.map(([k, v]) => `${k}:${v};`).join('');
295
+ }
296
+ /** Compare two CSS values the way the browser would compute them. */
297
+ function sameCssValue(prop, a, b, probe) {
298
+ if (a === b)
299
+ return true;
300
+ if (a.replace(/\s+/g, '') === b.replace(/\s+/g, ''))
301
+ return true;
302
+ probe.style.setProperty(prop, a);
303
+ const ca = probe.style.getPropertyValue(prop);
304
+ probe.style.setProperty(prop, b);
305
+ const cb = probe.style.getPropertyValue(prop);
306
+ probe.removeAttribute('style');
307
+ if (!!ca && ca === cb)
308
+ return true;
309
+ // The editor writes lengths in px; `1cm` in the source and `37.8px` from the editor are the same value.
310
+ const pa = absoluteLengthToPx(a);
311
+ const pb = absoluteLengthToPx(b);
312
+ return pa !== null && pb !== null && Math.abs(pa - pb) < 0.01;
313
+ }
314
+ function styleGroup(tag) {
315
+ if (/^h[1-6]$/.test(tag))
316
+ return 'h';
317
+ if (tag === 'th')
318
+ return 'td';
319
+ return tag;
320
+ }
321
+ function dropEditorDefaults(group, orig, cur, probe) {
322
+ const had = (k) => orig.some(([ok]) => ok === k || (k.startsWith('border-') && ok === 'border'));
323
+ const border = group === 'td' ? DEFAULT_CELL_BORDER : group === 'table' ? DEFAULT_TABLE_BORDER : null;
324
+ if (border &&
325
+ border.every(([k]) => !had(k)) &&
326
+ border.every(([k, v]) => cur.has(k) && sameCssValue(k, v, cur.get(k), probe))) {
327
+ border.forEach(([k]) => cur.delete(k));
328
+ }
329
+ if (group === 'td' && !had('vertical-align') && cur.get('vertical-align') === DEFAULT_CELL_VERTICAL_ALIGN) {
330
+ cur.delete('vertical-align');
331
+ }
332
+ }
333
+ /** Keep a source `border` shorthand when the editor's longhands still say the same thing. */
334
+ function shorthandStillHolds(value, cur, probe) {
335
+ probe.style.setProperty('border', value);
336
+ const parts = ['border-style', 'border-color', 'border-width'].map((k) => [k, probe.style.getPropertyValue(k)]);
337
+ probe.removeAttribute('style');
338
+ return parts.every(([k, v]) => !v || (cur.has(k) && sameCssValue(k, v, cur.get(k), probe)));
339
+ }
340
+ function reconcileStyle(tag, original, current, probe, fromSource = original !== null) {
341
+ var _a;
342
+ const group = styleGroup(tag);
343
+ const owned = (_a = OWNED_STYLES[group]) !== null && _a !== void 0 ? _a : [];
344
+ const orig = parseStyle(original);
345
+ // A value the editor could not parse (e.g. `margin-left: NaNpx`) means "unset".
346
+ const cur = new Map(parseStyle(current).filter(([, v]) => !/NaN|null|undefined/.test(v)));
347
+ const out = [];
348
+ if (fromSource)
349
+ dropEditorDefaults(group, orig, cur, probe);
350
+ for (const [k, v] of orig) {
351
+ if (k === 'border' && shorthandStillHolds(v, cur, probe)) {
352
+ out.push([k, v]);
353
+ ['border-style', 'border-color', 'border-width'].forEach((l) => cur.delete(l));
354
+ }
355
+ else if (cur.has(k)) {
356
+ const nv = cur.get(k);
357
+ out.push([k, sameCssValue(k, v, nv, probe) ? v : nv]);
358
+ cur.delete(k);
359
+ }
360
+ else if ((!owned.includes(k) || isZeroLength(v) || notReadable(k, v)) && !isCoveredBy(k, cur)) {
361
+ // Unmodelled properties are kept verbatim. So is an owned one the editor reads as "no value":
362
+ // zero in any unit (`margin-left:0in`) or a length it cannot express in px (`margin-left:2em`).
363
+ out.push([k, v]);
364
+ }
365
+ }
366
+ for (const [k, v] of cur) {
367
+ if (NEUTRAL_DECLARATIONS.has(`${k}:${v.replace(/\s+/g, '')}`))
368
+ continue;
369
+ if (impliedByShorthand(k, v, orig, probe))
370
+ continue;
371
+ out.push([k, v]);
372
+ }
373
+ return formatStyle(out);
374
+ }
375
+ /** Owned length properties the editor only models in absolute units. */
376
+ const PX_ONLY_STYLES = new Set(['margin-left']);
377
+ function notReadable(prop, value) {
378
+ return PX_ONLY_STYLES.has(prop) && absoluteLengthToPx(value) === null;
379
+ }
380
+ /**
381
+ * The editor reads a longhand out of a source shorthand (`margin:0 0 0 36pt` gives an indent of
382
+ * 48px) and writes it back. While the value is unchanged, the shorthand already says it.
383
+ */
384
+ function impliedByShorthand(prop, value, orig, probe) {
385
+ var _a;
386
+ const shorthand = (_a = /^(margin|padding)-/.exec(prop)) === null || _a === void 0 ? void 0 : _a[1];
387
+ const source = shorthand && orig.find(([k]) => k === shorthand);
388
+ if (!source)
389
+ return false;
390
+ probe.style.setProperty(shorthand, source[1]);
391
+ const implied = probe.style.getPropertyValue(prop);
392
+ probe.removeAttribute('style');
393
+ return !!implied && sameCssValue(prop, implied, value, probe);
394
+ }
395
+ function isZeroLength(value) {
396
+ return /^0(\.0+)?([a-z]+|%)?$/i.test(value.trim());
397
+ }
398
+ /** A shorthand such as `border` is superseded when the editor wrote its longhands. */
399
+ function isCoveredBy(prop, current) {
400
+ if (prop === 'border')
401
+ return ['border-style', 'border-color', 'border-width'].some((k) => current.has(k));
402
+ return false;
403
+ }
404
+ function setAttributesInOrder(el, attrs) {
405
+ Array.from(el.attributes).forEach((a) => el.removeAttribute(a.name));
406
+ attrs.forEach(([k, v]) => el.setAttribute(k, v));
407
+ }
408
+ /** Rebuild one element's attributes from its recorded origin and what the editor emitted. */
409
+ function reconcileElement(el, origin, probe) {
410
+ var _a, _b;
411
+ const tag = el.tagName.toLowerCase();
412
+ const record = el.getAttribute(CK_ORIGIN_ATTRIBUTE);
413
+ const current = new Map(attrsOf(el).filter(([k]) => !ENGINE_ONLY_ATTRIBUTES.includes(k)));
414
+ const result = [];
415
+ for (const [k, v] of origin !== null && origin !== void 0 ? origin : []) {
416
+ if (k === 'style') {
417
+ const style = reconcileStyle(tag, v, (_a = current.get('style')) !== null && _a !== void 0 ? _a : null, probe);
418
+ if (style)
419
+ result.push(['style', style]);
420
+ current.delete('style');
421
+ }
422
+ else if (k === 'class') {
423
+ const classes = ((_b = current.get('class')) !== null && _b !== void 0 ? _b : '').split(/\s+/).filter(Boolean);
424
+ const merged = [...v.split(/\s+/).filter(Boolean)];
425
+ classes.forEach((c) => !merged.includes(c) && merged.push(c));
426
+ result.push(['class', merged.join(' ')]);
427
+ current.delete('class');
428
+ }
429
+ else if (current.has(k)) {
430
+ result.push([k, current.get(k)]);
431
+ current.delete(k);
432
+ }
433
+ else if (!OWNED_ATTRIBUTES.has(k)) {
434
+ result.push([k, v]);
435
+ }
436
+ }
437
+ for (const [k, v] of current) {
438
+ if (k === 'style') {
439
+ const style = reconcileStyle(tag, null, v, probe, origin !== null);
440
+ if (style)
441
+ result.push(['style', style]);
442
+ }
443
+ else if ((k === 'colspan' || k === 'rowspan') && v === '1') {
444
+ continue;
445
+ }
446
+ else {
447
+ result.push([k, v]);
448
+ }
449
+ }
450
+ setAttributesInOrder(el, result);
451
+ // Keep the record for later passes (tables read it); it is stripped at the very end.
452
+ if (record)
453
+ el.setAttribute(CK_ORIGIN_ATTRIBUTE, record);
454
+ }
455
+ function exportDynamicFields(body) {
456
+ body.querySelectorAll('span[data-field-id]').forEach((el) => {
457
+ var _a, _b, _c, _d;
458
+ const key = (_a = el.getAttribute('data-field-id')) !== null && _a !== void 0 ? _a : '';
459
+ const label = el.getAttribute('data-label') || key;
460
+ const origin = (_c = (_b = readOrigin(el)) === null || _b === void 0 ? void 0 : _b.a) !== null && _c !== void 0 ? _c : null;
461
+ const doc = el.ownerDocument;
462
+ const span = doc.createElement('span');
463
+ const originKey = origin ? ((_d = el.textContent) !== null && _d !== void 0 ? _d : '').replace(/{{|}}/g, '').trim() : null;
464
+ if (origin && originKey === key) {
465
+ setAttributesInOrder(span, origin);
466
+ }
467
+ else {
468
+ // A field inserted (or re-keyed) in this editor: emit the shape the CKEditor plugin writes.
469
+ setAttributesInOrder(span, [
470
+ ['class', [...DYNAMIC_FIELD_BASE_CLASSES, DYNAMIC_FIELD_HAS_VALUE_CLASS].join(' ')],
471
+ ['id', `red-dynamic-field__${key}`],
472
+ ['name', label],
473
+ ['background', 'false'],
474
+ ['dynamicfieldname', label],
475
+ ['value', label],
476
+ ['type', 'textbox:text'],
477
+ ]);
478
+ }
479
+ span.textContent = `{{${key}}}`;
480
+ el.replaceWith(span);
481
+ });
482
+ }
483
+ /**
484
+ * Tiptap's `TrailingNode` appends an empty paragraph whenever a document ends in a table or other
485
+ * non-paragraph block. CKEditor allows that ending, so drop the paragraph unless the source had it.
486
+ */
487
+ function dropTrailingNode(body) {
488
+ const last = body.lastElementChild;
489
+ if (!last || last.tagName !== 'P' || last.attributes.length || last.childNodes.length)
490
+ return;
491
+ const previous = last.previousElementSibling;
492
+ if (previous && previous.tagName !== 'P')
493
+ last.remove();
494
+ }
495
+ function exportPageBreaks(body) {
496
+ body.querySelectorAll('div[data-page-break]').forEach((el) => {
497
+ var _a;
498
+ const origin = (_a = readOrigin(el)) === null || _a === void 0 ? void 0 : _a.a;
499
+ const holder = el.ownerDocument.createElement('div');
500
+ holder.innerHTML = PAGE_BREAK_HTML;
501
+ const div = holder.firstElementChild;
502
+ if (origin)
503
+ setAttributesInOrder(div, origin);
504
+ el.replaceWith(div);
505
+ });
506
+ }
507
+ function exportTables(body, probe) {
508
+ body.querySelectorAll('table').forEach((table) => {
509
+ var _a, _b;
510
+ const origin = readOrigin(table);
511
+ const doc = table.ownerDocument;
512
+ // <colgroup>: keep the recorded <col> attributes when the column count is unchanged.
513
+ const cols = Array.from(table.querySelectorAll(':scope > colgroup > col'));
514
+ cols.forEach((col, i) => {
515
+ const recorded = (origin === null || origin === void 0 ? void 0 : origin.c) && origin.c.length === cols.length ? origin.c[i] : null;
516
+ reconcileElement(col, recorded, probe);
517
+ });
518
+ table.querySelectorAll(':scope > tbody > tr > td, :scope > tbody > tr > th').forEach(unwrapLoneParagraph);
519
+ // <figure class="table">: restore the recorded wrapper, or add CKEditor's default.
520
+ if (((_a = table.parentElement) === null || _a === void 0 ? void 0 : _a.tagName) !== 'FIGURE') {
521
+ const figure = doc.createElement('figure');
522
+ setAttributesInOrder(figure, (_b = origin === null || origin === void 0 ? void 0 : origin.f) !== null && _b !== void 0 ? _b : [['class', 'table']]);
523
+ table.replaceWith(figure);
524
+ figure.appendChild(table);
525
+ }
526
+ });
527
+ }
528
+ /** CKEditor writes a lone, attribute-less paragraph in a table cell or list item as bare content. */
529
+ function unwrapLoneParagraph(container) {
530
+ const only = container.childNodes.length === 1 ? container.firstElementChild : null;
531
+ if (only && only === container.firstChild && only.tagName === 'P' && !hasMeaningfulAttributes(only)) {
532
+ only.replaceWith(...Array.from(only.childNodes));
533
+ }
534
+ }
535
+ /** True when an element carries attributes CKEditor would keep (ignores the origin record). */
536
+ function hasMeaningfulAttributes(el) {
537
+ var _a, _b;
538
+ const origin = (_b = (_a = readOrigin(el)) === null || _a === void 0 ? void 0 : _a.a) !== null && _b !== void 0 ? _b : [];
539
+ if (origin.length)
540
+ return true;
541
+ return attrsOf(el).some(([k, v]) => {
542
+ if (ENGINE_ONLY_ATTRIBUTES.includes(k))
543
+ return false;
544
+ if (k === 'style')
545
+ return parseStyle(v).some(([p, val]) => !NEUTRAL_DECLARATIONS.has(`${p}:${val.replace(/\s+/g, '')}`));
546
+ return true;
547
+ });
548
+ }
549
+ /**
550
+ * Turn `editor.getHTML()` output back into CKEditor-shaped HTML.
551
+ */
552
+ function toCkHtml(html, options = {}) {
553
+ var _a;
554
+ const body = parse(html !== null && html !== void 0 ? html : '', options.domParser);
555
+ const probe = body.ownerDocument.createElement('span');
556
+ exportDynamicFields(body);
557
+ exportPageBreaks(body);
558
+ // Reconcile plain elements before tables restructure cells (so `<p>` attributes are final).
559
+ body.querySelectorAll('*').forEach((el) => {
560
+ var _a, _b;
561
+ const tag = el.tagName.toLowerCase();
562
+ if (tag === 'col' || el.closest('div.page-break') || el.matches('span[id^="red-dynamic-field"]'))
563
+ return;
564
+ reconcileElement(el, (_b = (_a = readOrigin(el)) === null || _a === void 0 ? void 0 : _a.a) !== null && _b !== void 0 ? _b : null, probe);
565
+ });
566
+ dropTrailingNode(body);
567
+ // CKEditor writes an empty block as `&nbsp;`.
568
+ body.querySelectorAll('p,h1,h2,h3,h4,h5,h6').forEach((el) => {
569
+ if (!el.childNodes.length)
570
+ el.textContent = '\u00a0';
571
+ });
572
+ exportTables(body, probe);
573
+ body.querySelectorAll('li').forEach(unwrapLoneParagraph);
574
+ // CKEditor's Italic writes <i>, not <em>.
575
+ body.querySelectorAll('em').forEach((em) => {
576
+ const i = em.ownerDocument.createElement('i');
577
+ Array.from(em.attributes).forEach((a) => i.setAttribute(a.name, a.value));
578
+ i.append(...Array.from(em.childNodes));
579
+ em.replaceWith(i);
580
+ });
581
+ body.querySelectorAll(`[${CK_ORIGIN_ATTRIBUTE}]`).forEach((el) => el.removeAttribute(CK_ORIGIN_ATTRIBUTE));
582
+ const wrapperClass = (_a = options.wrapperClass) !== null && _a !== void 0 ? _a : CK_WRAPPER_BASE_CLASS;
583
+ if (wrapperClass === false)
584
+ 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;
590
+ }
591
+
592
+ /** Node and mark types whose original CKEditor attributes are carried through editing. */
593
+ const CK_COMPAT_TYPES = [
594
+ 'paragraph',
595
+ 'heading',
596
+ 'blockquote',
597
+ 'bulletList',
598
+ 'orderedList',
599
+ 'listItem',
600
+ 'table',
601
+ 'tableRow',
602
+ 'tableCell',
603
+ 'tableHeader',
604
+ 'textStyle',
605
+ 'link',
606
+ 'dynamicField',
607
+ 'pageBreak',
608
+ ];
609
+ const ckOrigin = (fallback) => ({
610
+ default: null,
611
+ // Splitting a block must not copy the record: the new block is new content.
612
+ keepOnSplit: false,
613
+ 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; },
614
+ renderHTML: (attributes) => attributes['ckOrigin'] ? { [CK_ORIGIN_ATTRIBUTE]: attributes['ckOrigin'] } : {},
615
+ });
616
+ /**
617
+ * A CKEditor dynamic field pasted into the editor never went through `fromCkHtml()`, so record
618
+ * its attributes here — otherwise its original id/name/value would be replaced on save.
619
+ */
620
+ function recordPastedDynamicField(element) {
621
+ // The editor renders its own fields with this class too; only CKEditor markup lacks `data-field-id`.
622
+ if (!element.classList.contains('red-dynamic-field') || element.hasAttribute('data-field-id'))
623
+ return null;
624
+ const a = Array.from(element.attributes).map((attr) => [attr.name, attr.value]);
625
+ return JSON.stringify({ a });
626
+ }
627
+ /**
628
+ * Keeps the `data-ck` record written by `fromCkHtml()` on every supported node and mark, so
629
+ * `toCkHtml()` can restore the original CKEditor markup on save. Register it together with the
630
+ * two functions; on its own it only round-trips the attribute.
631
+ */
632
+ const CkCompat = Extension.create({
633
+ name: 'ckCompat',
634
+ addGlobalAttributes() {
635
+ return [
636
+ { types: CK_COMPAT_TYPES.filter((t) => t !== 'dynamicField'), attributes: { ckOrigin: ckOrigin() } },
637
+ { types: ['dynamicField'], attributes: { ckOrigin: ckOrigin(recordPastedDynamicField) } },
638
+ ];
639
+ },
640
+ });
29
641
 
30
642
  const INDENT_DEFAULT = 40; // in pixels
31
643
 
@@ -53,7 +665,13 @@ const Indent = Extension.create({
53
665
  attributes: {
54
666
  indent: {
55
667
  parseHTML: (element) => {
56
- return Number(element.style.marginLeft.replace('px', ''));
668
+ const marginLeft = element.style.marginLeft;
669
+ if (!marginLeft)
670
+ return 0;
671
+ // Content pasted from Word / CKEditor often indents in pt or cm. Relative values (`em`, `%`)
672
+ // cannot be expressed in px and are left unset.
673
+ const px = absoluteLengthToPx(marginLeft);
674
+ return px === null ? null : Math.round(px * 100) / 100;
57
675
  },
58
676
  renderHTML: (attributes) => {
59
677
  return { style: `margin-left: ${attributes['indent']}px` };
@@ -1273,6 +1891,30 @@ function calculateColumnWidthsForNewColumn(oldColWidths) {
1273
1891
  const reduction = MIN_NEW_COL_WIDTH / oldColWidths.length;
1274
1892
  return oldColWidths.map((w) => w - reduction);
1275
1893
  }
1894
+ /**
1895
+ * Read the `border` attrs of the cell the command should merge against.
1896
+ *
1897
+ * `$from.node(-1)` alone is only the cell for a collapsed cursor: with a `CellSelection`
1898
+ * prosemirror-tables resolves `$from` *before* the head cell, so that lookup lands on the
1899
+ * row and reports an empty border — which, now that an omitted field means "leave this
1900
+ * one alone", would clear every field the caller did not pass. `getSelectedCells` covers
1901
+ * the cell-selection and cursor cases; the depth walk covers a text selection inside one
1902
+ * cell, which `getSelectedCells` returns nothing for.
1903
+ */
1904
+ function getCurrentCellBorder(state) {
1905
+ var _a, _b, _c, _d;
1906
+ const [firstSelected] = getSelectedCells(state);
1907
+ if (firstSelected)
1908
+ return (_b = (_a = firstSelected.node.attrs) === null || _a === void 0 ? void 0 : _a['border']) !== null && _b !== void 0 ? _b : {};
1909
+ const { $from } = state.selection;
1910
+ for (let depth = $from.depth; depth > 0; depth--) {
1911
+ const node = $from.node(depth);
1912
+ if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
1913
+ return (_d = (_c = node.attrs) === null || _c === void 0 ? void 0 : _c['border']) !== null && _d !== void 0 ? _d : {};
1914
+ }
1915
+ }
1916
+ return {};
1917
+ }
1276
1918
  const TableDefaultAttributes = {
1277
1919
  border: {
1278
1920
  style: 'double',
@@ -1598,7 +2240,6 @@ const StyledTable = Table.extend({
1598
2240
  },
1599
2241
  // Cell
1600
2242
  setCellBorder: (border) => ({ chain, state }) => {
1601
- var _a;
1602
2243
  if (!border)
1603
2244
  return chain()
1604
2245
  .focus()
@@ -1606,14 +2247,15 @@ const StyledTable = Table.extend({
1606
2247
  .updateAttributes('tableHeader', { border: null })
1607
2248
  .run();
1608
2249
  // Get current border and merge with new values
1609
- const { selection } = state;
1610
- const { $from } = selection;
1611
- const cellPos = $from.node(-1);
1612
- const currentBorder = ((_a = cellPos === null || cellPos === void 0 ? void 0 : cellPos.attrs) === null || _a === void 0 ? void 0 : _a['border']) || {};
2250
+ const currentBorder = getCurrentCellBorder(state);
2251
+ // `undefined` means "leave this field alone"; an explicit `null` clears it.
2252
+ // A truthiness check conflates the two, so clearing a cell's border colour
2253
+ // fell straight back to the colour being cleared and the UI could never
2254
+ // remove one. Matches `setTableBorder` above.
1613
2255
  const mergedBorder = {
1614
- style: border.style ? border.style : currentBorder.style || null,
1615
- color: border.color ? border.color : currentBorder.color || null,
1616
- width: border.width ? border.width : currentBorder.width || null,
2256
+ style: border.style !== undefined ? border.style : currentBorder.style || null,
2257
+ color: border.color !== undefined ? border.color : currentBorder.color || null,
2258
+ width: border.width !== undefined ? border.width : currentBorder.width || null,
1617
2259
  };
1618
2260
  return chain()
1619
2261
  .focus()
@@ -2235,4 +2877,4 @@ class Color {
2235
2877
  }
2236
2878
  }
2237
2879
 
2238
- 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 };
2880
+ 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.5",
3
+ "version": "0.1.7",
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,98 @@
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 and
13
+ * 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
+ readonly restrictedEditingException: ".restricted-editing-exception";
65
+ readonly image: "img, figure.image";
66
+ };
67
+ export type CkUnsupportedContent = keyof typeof CK_UNSUPPORTED_CONTENT;
68
+ export interface CkRoundTripReport {
69
+ /** `true` when saving the loaded document unchanged reproduces the input byte-for-byte. */
70
+ identical: boolean;
71
+ /** Where the two first differ (a short excerpt of each side), or `null` when identical. */
72
+ difference: {
73
+ index: number;
74
+ expected: string;
75
+ actual: string;
76
+ } | null;
77
+ }
78
+ /**
79
+ * A `DOMParser` for {@link fromCkHtml} / {@link toCkHtml} that works in any environment: the global one
80
+ * in the browser, otherwise one from happy-dom (already installed with `@tiptap/html`, which
81
+ * {@link generateHTML} uses for the same reason). Create it once and reuse it.
82
+ */
83
+ export declare function createCkDomParser(): Promise<CkDomParser>;
84
+ /**
85
+ * Prepare CKEditor HTML for the editor. Safe to call on HTML that did not come from
86
+ * CKEditor: it then only records attributes.
87
+ */
88
+ export declare function fromCkHtml(html: string, options?: CkDomOptions): CkLoadResult;
89
+ /**
90
+ * Compare the stored HTML with what saving the freshly loaded document would write
91
+ * (`toCkHtml(editor.getHTML())` before any edit). A difference means the editor could not
92
+ * represent part of the document and saving would change it — open it read-only instead.
93
+ */
94
+ export declare function verifyCkRoundTrip(original: string, saved: string): CkRoundTripReport;
95
+ /**
96
+ * Turn `editor.getHTML()` output back into CKEditor-shaped HTML.
97
+ */
98
+ 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;