@phuong-tran-redoc/document-engine-core 0.1.0 → 0.1.2

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.
Files changed (46) hide show
  1. package/README.md +105 -115
  2. package/THIRD-PARTY-NOTICES.txt +67 -0
  3. package/index.d.ts +1 -0
  4. package/index.esm.js +2238 -0
  5. package/package.json +21 -5
  6. package/src/__tests__/helpers/editor-factory.d.ts +0 -13
  7. package/src/__tests__/helpers/editor-factory.js +0 -25
  8. package/src/constants/indent.constant.js +0 -1
  9. package/src/constants/index.js +0 -1
  10. package/src/constants/table.constant.js +0 -1
  11. package/src/extensions/clear-content.extension.js +0 -9
  12. package/src/extensions/indent.extension.js +0 -59
  13. package/src/extensions/index.js +0 -8
  14. package/src/extensions/ordered-list.extension.js +0 -47
  15. package/src/extensions/reset-format.extension.js +0 -9
  16. package/src/extensions/reset-on-enter.extension.js +0 -37
  17. package/src/extensions/restricted-editing.extension.js +0 -376
  18. package/src/extensions/table-resizing.extension.js +0 -164
  19. package/src/extensions/table-style.extension.js +0 -481
  20. package/src/extensions/text-case.extension.js +0 -39
  21. package/src/index.js +0 -9
  22. package/src/kit/default-extensions.js +0 -86
  23. package/src/kit/generate-html.js +0 -40
  24. package/src/kit/index.js +0 -2
  25. package/src/migrations/doc-migrations.js +0 -24
  26. package/src/migrations/index.js +0 -3
  27. package/src/migrations/migrate-doc.js +0 -32
  28. package/src/migrations/types.js +0 -4
  29. package/src/models/color.model.js +0 -30
  30. package/src/models/index.js +0 -1
  31. package/src/nodes/dynamic-field.node.js +0 -141
  32. package/src/nodes/heading.node.js +0 -13
  33. package/src/nodes/image-ref.node.js +0 -138
  34. package/src/nodes/index.js +0 -4
  35. package/src/nodes/page-break.node.js +0 -52
  36. package/src/types/index.js +0 -2
  37. package/src/types/ordered-list.type.js +0 -2
  38. package/src/types/text-case.type.js +0 -1
  39. package/src/types/tiptap-html-server.d.js +0 -10
  40. package/src/utils/color.util.js +0 -31
  41. package/src/utils/dom.util.js +0 -33
  42. package/src/utils/index.js +0 -4
  43. package/src/utils/table.util.js +0 -95
  44. package/src/utils/text.util.js +0 -46
  45. package/src/views/block-handler.js +0 -311
  46. package/src/views/index.js +0 -1
package/index.esm.js ADDED
@@ -0,0 +1,2238 @@
1
+ import { Extension, Node, mergeAttributes } from '@tiptap/core';
2
+ import { OrderedList, BulletList, ListItem } from '@tiptap/extension-list';
3
+ import { PluginKey, Plugin, TextSelection, NodeSelection } from '@tiptap/pm/state';
4
+ import { TableCell, TableHeader, Table, TableRow } from '@tiptap/extension-table';
5
+ import { selectionCell, findTable, TableMap, CellSelection, tableNodeTypes, deleteColumn, addColumnBefore, addColumnAfter } from '@tiptap/pm/tables';
6
+ import { isEqual } from 'lodash-es';
7
+ import { DOMSerializer } from '@tiptap/pm/model';
8
+ 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';
13
+ import { Document } from '@tiptap/extension-document';
14
+ import { HardBreak } from '@tiptap/extension-hard-break';
15
+ import { HorizontalRule } from '@tiptap/extension-horizontal-rule';
16
+ import { Image } from '@tiptap/extension-image';
17
+ import Italic from '@tiptap/extension-italic';
18
+ import Link from '@tiptap/extension-link';
19
+ import { Paragraph } from '@tiptap/extension-paragraph';
20
+ import Strike from '@tiptap/extension-strike';
21
+ import { Subscript } from '@tiptap/extension-subscript';
22
+ import { Superscript } from '@tiptap/extension-superscript';
23
+ import { Text } from '@tiptap/extension-text';
24
+ import { TextAlign } from '@tiptap/extension-text-align';
25
+ import { TextStyleKit } from '@tiptap/extension-text-style';
26
+ import Underline from '@tiptap/extension-underline';
27
+ import { Heading } from '@tiptap/extension-heading';
28
+ import { __awaiter } from 'tslib';
29
+
30
+ const INDENT_DEFAULT = 40; // in pixels
31
+
32
+ const ClearContent = Extension.create({
33
+ name: 'clearContent',
34
+ addCommands() {
35
+ return {
36
+ clear: () => ({ chain }) => chain().focus().clearContent().run(),
37
+ };
38
+ },
39
+ });
40
+
41
+ const Indent = Extension.create({
42
+ name: 'indent',
43
+ addOptions() {
44
+ return {
45
+ types: ['paragraph', 'heading'],
46
+ indent: INDENT_DEFAULT,
47
+ };
48
+ },
49
+ addGlobalAttributes() {
50
+ return [
51
+ {
52
+ types: this.options.types,
53
+ attributes: {
54
+ indent: {
55
+ parseHTML: (element) => {
56
+ return Number(element.style.marginLeft.replace('px', ''));
57
+ },
58
+ renderHTML: (attributes) => {
59
+ return { style: `margin-left: ${attributes['indent']}px` };
60
+ },
61
+ },
62
+ },
63
+ },
64
+ ];
65
+ },
66
+ addCommands() {
67
+ return {
68
+ increaseIndent: () => ({ state, chain }) => {
69
+ const currentNode = state.selection.$head.parent;
70
+ const nodeType = currentNode.type;
71
+ if (!this.options.types.includes(nodeType.name))
72
+ return false;
73
+ const currentIndent = currentNode.attrs['indent'] || 0;
74
+ const newIndent = currentIndent + this.options.indent;
75
+ return chain().updateAttributes(nodeType, { indent: newIndent }).run();
76
+ },
77
+ decreaseIndent: () => ({ state, chain }) => {
78
+ const currentNode = state.selection.$head.parent;
79
+ const nodeType = currentNode.type;
80
+ if (!this.options.types.includes(nodeType.name))
81
+ return false;
82
+ const currentIndent = currentNode.attrs['indent'] || 0;
83
+ const newIndent = currentIndent - this.options.indent;
84
+ if (newIndent < 0)
85
+ return false;
86
+ if (newIndent === 0)
87
+ return chain().resetAttributes(nodeType, 'indent').run();
88
+ return chain().updateAttributes(nodeType, { indent: newIndent }).run();
89
+ },
90
+ };
91
+ },
92
+ });
93
+
94
+ const CustomOrderedList = OrderedList.extend({
95
+ name: 'customOrderedList',
96
+ addOptions() {
97
+ var _a;
98
+ return Object.assign(Object.assign({}, (_a = this.parent) === null || _a === void 0 ? void 0 : _a.call(this)), { listStyleType: 'decimal' });
99
+ },
100
+ addAttributes() {
101
+ var _a;
102
+ return Object.assign(Object.assign({}, (_a = this.parent) === null || _a === void 0 ? void 0 : _a.call(this)), { 'data-list-style-type': {
103
+ default: this.options.listStyleType,
104
+ parseHTML: (element) => {
105
+ // Priority 1: Parse from data-list-style-type attribute (our format)
106
+ const dataAttr = element.getAttribute('data-list-style-type');
107
+ if (dataAttr)
108
+ return dataAttr;
109
+ // Priority 2: Parse from CSS list-style-type property (other editors' format)
110
+ const styleAttr = element.style.listStyleType;
111
+ if (styleAttr)
112
+ return styleAttr;
113
+ // Default fallback
114
+ return this.options.listStyleType;
115
+ },
116
+ renderHTML: (attributes) => {
117
+ const styleType = attributes['data-list-style-type'];
118
+ if (!styleType || styleType === 'decimal')
119
+ return { 'data-list-style-type': 'decimal' };
120
+ return { 'data-list-style-type': attributes['data-list-style-type'] };
121
+ },
122
+ } });
123
+ },
124
+ addCommands() {
125
+ var _a;
126
+ return Object.assign(Object.assign({}, (_a = this.parent) === null || _a === void 0 ? void 0 : _a.call(this)), { setListStyle: (listStyleType) => ({ chain }) => {
127
+ return chain().focus().updateAttributes('customOrderedList', { 'data-list-style-type': listStyleType }).run();
128
+ } });
129
+ },
130
+ });
131
+
132
+ const ResetFormat = Extension.create({
133
+ name: 'resetFormat',
134
+ addCommands() {
135
+ return {
136
+ resetFormat: () => ({ chain }) => chain().focus().unsetAllMarks().clearNodes().run(),
137
+ };
138
+ },
139
+ });
140
+
141
+ /*
142
+ * Clear:
143
+ * - textStyle mark
144
+ * - indent attribute
145
+ */
146
+ // Clears formats on Enter so the new line has default formats
147
+ const ResetOnEnter = Extension.create({
148
+ name: 'resetOnEnter',
149
+ addKeyboardShortcuts() {
150
+ return {
151
+ Enter: () => {
152
+ const disabledNodes = ['listItem', 'blockquote'];
153
+ if (disabledNodes.some((type) => this.editor.isActive(type))) {
154
+ return false; //Let the default Enter behavior proceed
155
+ }
156
+ // Handle Enter ourselves so we can both clear marks and reset indent on the new line
157
+ return this.editor
158
+ .chain()
159
+ .focus()
160
+ .unsetAllMarks()
161
+ .splitBlock({ keepMarks: false })
162
+ .command(({ state, commands }) => {
163
+ const parent = state.selection.$head.parent;
164
+ const nodeType = parent.type;
165
+ const typesToReset = ['paragraph', 'heading'];
166
+ if (typesToReset.includes(nodeType.name)) {
167
+ return commands.resetAttributes(nodeType, 'indent');
168
+ }
169
+ return true;
170
+ })
171
+ .run();
172
+ },
173
+ };
174
+ },
175
+ });
176
+
177
+ // Editable inline region where typing is allowed
178
+ const EditableRegion = Node.create({
179
+ name: 'editableRegion',
180
+ group: 'inline',
181
+ inline: true,
182
+ content: 'text*',
183
+ selectable: false,
184
+ parseHTML() {
185
+ return [
186
+ // ----------------------------------------------------------
187
+ // RULE 1: Standard Tiptap Format (highest priority)
188
+ // ----------------------------------------------------------
189
+ { tag: 'span[data-editable-region]' },
190
+ // ----------------------------------------------------------
191
+ // RULE 2: CKEditor Format (lowest priority)
192
+ // ----------------------------------------------------------
193
+ { tag: 'span.restricted-editing-exception' },
194
+ ];
195
+ },
196
+ renderHTML({ HTMLAttributes }) {
197
+ return [
198
+ 'span',
199
+ mergeAttributes(HTMLAttributes, {
200
+ 'data-editable-region': 'true',
201
+ class: 'editable-region',
202
+ }),
203
+ 0,
204
+ ];
205
+ },
206
+ });
207
+ const RestrictedEditing = Extension.create({
208
+ name: 'restrictedEditing',
209
+ addOptions() {
210
+ return {
211
+ editableRegionNodeName: 'editableRegion',
212
+ disabledNodeTypes: ['listItem', 'blockquote'],
213
+ initialMode: 'restricted',
214
+ };
215
+ },
216
+ addStorage() {
217
+ return {
218
+ mode: this.options.initialMode,
219
+ };
220
+ },
221
+ addCommands() {
222
+ return {
223
+ wrapSelectionInEditableRegion: () => ({ state, dispatch }) => {
224
+ // 0. Always return false if the editable region node is not registered
225
+ const type = state.schema.nodes[this.options.editableRegionNodeName];
226
+ if (!type)
227
+ return false;
228
+ const { from, to, empty } = state.selection;
229
+ // 1. Insert an empty editable region with a zero-width space
230
+ if (empty) {
231
+ const node = type.create(null, state.schema.text('\u200b'));
232
+ const tr = state.tr.replaceSelectionWith(node, false);
233
+ tr.setMeta('restrictedEditing', { allow: true });
234
+ dispatch === null || dispatch === void 0 ? void 0 : dispatch(tr.scrollIntoView());
235
+ return true;
236
+ }
237
+ // Disallow wrapping when selection contains non-text inline nodes
238
+ let containsNonTextInline = false;
239
+ state.doc.nodesBetween(from, to, (node) => {
240
+ if (node.isInline && !node.isText) {
241
+ containsNonTextInline = true;
242
+ return false;
243
+ }
244
+ return undefined;
245
+ });
246
+ if (containsNonTextInline) {
247
+ return false;
248
+ }
249
+ const multiLine = state.doc.textBetween(from, to, '\n', '\n').includes('\n');
250
+ let tr = state.tr;
251
+ if (multiLine) {
252
+ // Wrap each textblock segment independently
253
+ const ranges = [];
254
+ state.doc.nodesBetween(from, to, (node, pos) => {
255
+ if (node.isTextblock) {
256
+ const s = Math.max(from, pos + 1);
257
+ const e = Math.min(to, pos + node.nodeSize - 1);
258
+ if (e > s)
259
+ ranges.push({ s, e });
260
+ }
261
+ return undefined;
262
+ });
263
+ // Apply replacements from end to start to keep positions stable
264
+ for (let i = ranges.length - 1; i >= 0; i--) {
265
+ const { s, e } = ranges[i];
266
+ const text = state.doc.textBetween(s, e, '\n', '\n');
267
+ if (text.length === 0)
268
+ continue;
269
+ const node = type.create(null, state.schema.text(text));
270
+ tr = tr.replaceRangeWith(s, e, node);
271
+ }
272
+ }
273
+ else {
274
+ const text = state.doc.textBetween(from, to, '\n', '\n');
275
+ if (text.length === 0)
276
+ return false;
277
+ const node = type.create(null, state.schema.text(text));
278
+ tr = tr.replaceRangeWith(from, to, node);
279
+ }
280
+ tr.setMeta('restrictedEditing', { allow: true });
281
+ dispatch === null || dispatch === void 0 ? void 0 : dispatch(tr.scrollIntoView());
282
+ return true;
283
+ },
284
+ removeEditableRegion: () => ({ state, dispatch }) => {
285
+ const type = state.schema.nodes[this.options.editableRegionNodeName];
286
+ if (!type)
287
+ return false;
288
+ const $pos = state.selection.$head;
289
+ for (let depth = $pos.depth; depth >= 0; depth--) {
290
+ const node = $pos.node(depth);
291
+ if (node.type === type) {
292
+ const from = $pos.before(depth);
293
+ const to = $pos.after(depth);
294
+ const tr = state.tr.replaceWith(from, to, node.content);
295
+ tr.setMeta('restrictedEditing', { allow: true });
296
+ dispatch === null || dispatch === void 0 ? void 0 : dispatch(tr.scrollIntoView());
297
+ return true;
298
+ }
299
+ }
300
+ return false;
301
+ },
302
+ toggleEditableRegion: () => ({ state, dispatch, commands }) => {
303
+ const type = state.schema.nodes[this.options.editableRegionNodeName];
304
+ if (!type)
305
+ return false;
306
+ const { from, to, empty } = state.selection;
307
+ const findRegionAtPos = (pos) => {
308
+ const $pos = state.doc.resolve(pos);
309
+ for (let depth = $pos.depth; depth >= 0; depth--) {
310
+ const node = $pos.node(depth);
311
+ if (node.type === type) {
312
+ const nodeFrom = $pos.before(depth);
313
+ const nodeTo = $pos.after(depth);
314
+ return { from: nodeFrom, to: nodeTo, node };
315
+ }
316
+ }
317
+ return null;
318
+ };
319
+ // Cursor case: inside a region -> unwrap that region; else insert empty region
320
+ if (empty) {
321
+ const region = findRegionAtPos(from);
322
+ if (region) {
323
+ const tr = state.tr.replaceWith(region.from, region.to, region.node.content);
324
+ tr.setMeta('restrictedEditing', { allow: true });
325
+ dispatch === null || dispatch === void 0 ? void 0 : dispatch(tr.scrollIntoView());
326
+ return true;
327
+ }
328
+ const node = type.create(null, state.schema.text('\u200b'));
329
+ const tr = state.tr.replaceSelectionWith(node, false);
330
+ tr.setMeta('restrictedEditing', { allow: true });
331
+ dispatch === null || dispatch === void 0 ? void 0 : dispatch(tr.scrollIntoView());
332
+ return true;
333
+ }
334
+ // Range case: unwrap only if the entire selection is inside the SAME region; otherwise
335
+ // if selection is completely outside all regions, wrap it; else ignore
336
+ const regionStart = findRegionAtPos(from);
337
+ const regionEnd = findRegionAtPos(Math.max(from, to - 1));
338
+ if (regionStart && regionEnd && regionStart.from === regionEnd.from && regionStart.to === regionEnd.to) {
339
+ const tr = state.tr.replaceWith(regionStart.from, regionStart.to, regionStart.node.content);
340
+ tr.setMeta('restrictedEditing', { allow: true });
341
+ dispatch === null || dispatch === void 0 ? void 0 : dispatch(tr.scrollIntoView());
342
+ return true;
343
+ }
344
+ // If neither endpoint is in a region, ensure there is no region intersecting the range
345
+ if (!regionStart && !regionEnd) {
346
+ let hasRegionInside = false;
347
+ state.doc.nodesBetween(from, to, (node) => {
348
+ if (node.type === type) {
349
+ hasRegionInside = true;
350
+ return false;
351
+ }
352
+ return undefined;
353
+ });
354
+ if (!hasRegionInside) {
355
+ return commands.wrapSelectionInEditableRegion();
356
+ }
357
+ }
358
+ return false;
359
+ },
360
+ };
361
+ },
362
+ addKeyboardShortcuts() {
363
+ return {
364
+ Enter: () => {
365
+ // Get the current selection
366
+ const selection = this.editor.state.selection;
367
+ const $from = selection.$from;
368
+ if (!selection.empty)
369
+ return false;
370
+ let editableRegionInfo = null;
371
+ for (let d = $from.depth; d > 0; d--) {
372
+ const node = $from.node(d);
373
+ if (node.type.name === 'editableRegion') {
374
+ editableRegionInfo = {
375
+ node: node,
376
+ depth: d,
377
+ // Position of the entire node (before the opening tag)
378
+ posBefore: $from.before(d),
379
+ // Position of the entire node (after the closing tag)
380
+ posAfter: $from.after(d),
381
+ // Position of the start of the content inside the node
382
+ contentStartPos: $from.start(d),
383
+ // Position of the end of the content inside the node
384
+ contentEndPos: $from.end(d),
385
+ };
386
+ break;
387
+ }
388
+ }
389
+ if (!editableRegionInfo)
390
+ return false;
391
+ const atStart = $from.pos === editableRegionInfo.contentStartPos;
392
+ const atEnd = $from.pos === editableRegionInfo.contentEndPos;
393
+ // If cursor is at the start of the editable region, add a new paragraph before the editable region
394
+ if (atStart) {
395
+ return this.editor
396
+ .chain()
397
+ .insertContentAt(editableRegionInfo.posBefore, '<p></p>')
398
+ .focus(editableRegionInfo.posBefore - 2)
399
+ .run();
400
+ }
401
+ // If cursor is at the end of the editable region, add a new paragraph after the editable region
402
+ if (atEnd) {
403
+ return this.editor
404
+ .chain()
405
+ .insertContentAt(editableRegionInfo.posAfter, '<p></p>')
406
+ .focus(editableRegionInfo.posAfter + 1)
407
+ .run();
408
+ }
409
+ // If cursor is in the middle of the editable region, return false
410
+ return false;
411
+ },
412
+ };
413
+ },
414
+ addProseMirrorPlugins() {
415
+ var _a;
416
+ const key = new PluginKey('restricted-editing');
417
+ const editor = this.editor;
418
+ const editableRegionNodeName = this.options.editableRegionNodeName;
419
+ const disabledNodeTypes = (_a = this.options.disabledNodeTypes) !== null && _a !== void 0 ? _a : [];
420
+ const getEditableRegionBounds = (doc, schema, pos) => {
421
+ const $pos = doc.resolve(pos);
422
+ const type = schema.nodes[editableRegionNodeName];
423
+ for (let depth = $pos.depth; depth >= 0; depth--) {
424
+ if ($pos.node(depth).type === type) {
425
+ return { depth, from: $pos.start(depth), to: $pos.end(depth) };
426
+ }
427
+ }
428
+ return null;
429
+ };
430
+ const rangesFromTransaction = (tr) => {
431
+ const ranges = [];
432
+ for (let i = 0; i < tr.steps.length; i++) {
433
+ const map = tr.mapping.maps[i];
434
+ const forward = tr.mapping.slice(i + 1);
435
+ map.forEach((oldStart, oldEnd, newStart, newEnd) => {
436
+ const finalNewFrom = forward.map(newStart, 1);
437
+ const finalNewTo = forward.map(newEnd, -1);
438
+ ranges.push({ oldFrom: oldStart, oldTo: oldEnd, newFrom: finalNewFrom, newTo: finalNewTo });
439
+ });
440
+ }
441
+ return ranges;
442
+ };
443
+ return [
444
+ new Plugin({
445
+ key,
446
+ filterTransaction: (tr, state) => {
447
+ var _a, _b;
448
+ // In standard mode, do not restrict edits
449
+ if (this.storage.mode === 'standard')
450
+ return true;
451
+ if (!tr.docChanged)
452
+ return true;
453
+ if ((_a = tr.getMeta('restrictedEditing')) === null || _a === void 0 ? void 0 : _a.allow)
454
+ return true;
455
+ // Allow while the document is still the default empty state
456
+ if (editor.isEmpty)
457
+ return true;
458
+ // In restricted mode: disable mark/style/attr changes from other extensions entirely
459
+ const disallowedStepTypes = new Set(['addMark', 'removeMark', 'setNodeMarkup']);
460
+ for (const step of tr.steps) {
461
+ const t = (_b = step === null || step === void 0 ? void 0 : step.toJSON) === null || _b === void 0 ? void 0 : _b.call(step).stepType;
462
+ if (t && disallowedStepTypes.has(t)) {
463
+ return false;
464
+ }
465
+ }
466
+ if (disabledNodeTypes.some((type) => editor.isActive(type))) {
467
+ return true;
468
+ }
469
+ const ranges = rangesFromTransaction(tr);
470
+ if (ranges.length === 0)
471
+ return true;
472
+ for (const range of ranges) {
473
+ // Pre-change validation (against state.doc)
474
+ const preStart = getEditableRegionBounds(state.doc, state.schema, range.oldFrom);
475
+ const preEnd = getEditableRegionBounds(state.doc, state.schema, range.oldTo);
476
+ if (!preStart || !preEnd)
477
+ return false;
478
+ if (preStart.from !== preEnd.from || preStart.to !== preEnd.to)
479
+ return false;
480
+ // Post-change validation (against final tr.doc)
481
+ const postStart = getEditableRegionBounds(tr.doc, state.schema, range.newFrom);
482
+ const postEnd = getEditableRegionBounds(tr.doc, state.schema, range.newTo);
483
+ if (!postStart || !postEnd)
484
+ return false;
485
+ if (postStart.from !== postEnd.from || postStart.to !== postEnd.to)
486
+ return false;
487
+ }
488
+ return true;
489
+ },
490
+ appendTransaction: (_transactions, _oldState, newState) => {
491
+ const type = newState.schema.nodes[editableRegionNodeName];
492
+ if (!type)
493
+ return null;
494
+ const isRestricted = this.storage.mode === 'restricted';
495
+ const edits = [];
496
+ newState.doc.descendants((node, pos) => {
497
+ var _a;
498
+ if (node.type === type) {
499
+ const contentText = (_a = node.textContent) !== null && _a !== void 0 ? _a : '';
500
+ if (contentText.length === 0) {
501
+ if (isRestricted) {
502
+ // Ensure a single space exists inside empty region
503
+ const innerFrom = pos + 1;
504
+ const innerTo = pos + node.nodeSize - 1;
505
+ edits.push({ kind: 'fill', from: innerFrom, to: innerTo });
506
+ }
507
+ else {
508
+ // Remove empty region
509
+ edits.push({ kind: 'delete', from: pos, to: pos + node.nodeSize });
510
+ }
511
+ }
512
+ }
513
+ });
514
+ if (edits.length === 0)
515
+ return null;
516
+ // Apply in descending order of from
517
+ edits.sort((a, b) => b.from - a.from);
518
+ const tr = newState.tr;
519
+ for (const e of edits) {
520
+ if (e.kind === 'delete') {
521
+ tr.delete(e.from, e.to);
522
+ }
523
+ else {
524
+ const textNode = newState.schema.text(' ');
525
+ tr.replaceWith(e.from, e.to, textNode);
526
+ }
527
+ }
528
+ tr.setMeta('restrictedEditing', { allow: true });
529
+ return tr;
530
+ },
531
+ }),
532
+ ];
533
+ },
534
+ });
535
+
536
+ const MIN_NEW_COL_WIDTH = 5.0;
537
+
538
+ /**
539
+ * Normalizes a color value to lowercase hex format
540
+ * Supports hex (#RGB, #RRGGBB) and rgb(r, g, b) formats
541
+ *
542
+ * @param color - Color value to normalize
543
+ * @returns Normalized hex color or null if invalid
544
+ *
545
+ * @example
546
+ * normalizeColor('#FF0000') // '#ff0000'
547
+ * normalizeColor('rgb(255, 0, 0)') // '#ff0000'
548
+ * normalizeColor('#F00') // '#f00'
549
+ * normalizeColor('invalid') // null
550
+ */
551
+ function normalizeColor(color) {
552
+ if (!color)
553
+ return null;
554
+ const trimmed = color.trim().toLowerCase();
555
+ if (trimmed === 'transparent')
556
+ return trimmed;
557
+ // Check if it's a valid hex color
558
+ if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed)) {
559
+ return trimmed;
560
+ }
561
+ // Try to parse RGB
562
+ const rgbMatch = trimmed.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
563
+ if (rgbMatch) {
564
+ const r = Math.max(0, Math.min(255, parseInt(rgbMatch[1], 10)));
565
+ const g = Math.max(0, Math.min(255, parseInt(rgbMatch[2], 10)));
566
+ const b = Math.max(0, Math.min(255, parseInt(rgbMatch[3], 10)));
567
+ const toHex = (n) => n.toString(16).padStart(2, '0');
568
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
569
+ }
570
+ return null;
571
+ }
572
+
573
+ function getClosestDomElement(editor, selector) {
574
+ const { view, state } = editor;
575
+ const { selection } = state;
576
+ const { from } = selection;
577
+ if (!from)
578
+ return null;
579
+ let domNodeAtPos = null;
580
+ // Ưu tiên tìm DOM node của 'nearest parent element' của vị trí from
581
+ // Nếu from là trong text, $from.parent.nodeDOM có thể chính xác hơn
582
+ try {
583
+ const resolvedPos = state.doc.resolve(from);
584
+ domNodeAtPos = view.domAtPos(resolvedPos.pos).node; // Bắt đầu từ node ở vị trí from
585
+ // Nếu domNodeAtPos không phải là HTMLElement (ví dụ, là một Text node),
586
+ // thì cố gắng lấy element cha của nó trong DOM
587
+ if (domNodeAtPos && !(domNodeAtPos instanceof HTMLElement)) {
588
+ domNodeAtPos = domNodeAtPos.parentElement;
589
+ }
590
+ }
591
+ catch (e) {
592
+ // Xử lý trường hợp hiếm gặp khi nodeDOM(from) không trả về gì hợp lệ
593
+ console.warn('Could not find DOM node at cursor position:', e);
594
+ return null;
595
+ }
596
+ if (!domNodeAtPos)
597
+ return null;
598
+ const boundary = editor.view.dom;
599
+ // Tìm kiếm từ domNodeAtPos lên trên cho đến khi gặp editor container
600
+ let currentElement = domNodeAtPos;
601
+ while (currentElement && currentElement !== boundary) {
602
+ if (currentElement.matches(selector)) {
603
+ return currentElement;
604
+ }
605
+ currentElement = currentElement.parentElement;
606
+ }
607
+ return null;
608
+ }
609
+
610
+ function getCursorCellInfo(state) {
611
+ // 1. Dùng selectionCell để tìm vị trí bắt đầu của ô chứa con trỏ
612
+ try {
613
+ const cellInfo = selectionCell(state);
614
+ // Nếu không tìm thấy (con trỏ không nằm trong ô nào), trả về null
615
+ if (!cellInfo)
616
+ return null;
617
+ // cellInfo.$pos là một ResolvedPos trỏ tới vị trí bắt đầu của ô
618
+ const table = findTable(cellInfo);
619
+ if (!table)
620
+ return null;
621
+ const map = TableMap.get(table.node);
622
+ // 2. Lấy vị trí tương đối của ô so với bảng
623
+ const cellOffset = cellInfo.pos - table.start;
624
+ // 3. Tìm chỉ số (index) của ô này trong "bản đồ" của bảng
625
+ const cellIndex = map.map.indexOf(cellOffset);
626
+ if (cellIndex === -1) {
627
+ return null;
628
+ }
629
+ // 4. Từ chỉ số, tính ra rowIndex và colIndex
630
+ const rowIndex = Math.floor(cellIndex / map.width);
631
+ const colIndex = cellIndex % map.width;
632
+ return {
633
+ rowIndex,
634
+ colIndex,
635
+ cellIndex,
636
+ tableStart: table.start,
637
+ tableNode: table.node,
638
+ map,
639
+ };
640
+ }
641
+ catch (_a) {
642
+ // selectionCell throws RangeError when cursor is not in a table
643
+ return null;
644
+ }
645
+ }
646
+ function getSelectedCells(state) {
647
+ const { selection } = state;
648
+ const cells = [];
649
+ // Trường hợp chọn nhiều ô
650
+ if (selection instanceof CellSelection) {
651
+ selection.forEachCell((node, pos) => cells.push({ pos, node }));
652
+ return cells;
653
+ }
654
+ // Trường hợp con trỏ nằm trong một ô
655
+ if (selection.empty) {
656
+ // Tìm node cell cha gần nhất
657
+ const cell = selection.$head.node(-1);
658
+ if (!cell || !(cell.type.name === 'tableCell' || cell.type.name === 'tableHeader'))
659
+ return cells;
660
+ // Lấy vị trí bắt đầu của node cell
661
+ const cellPos = selection.$head.start(-1);
662
+ cells.push({ pos: cellPos, node: cell });
663
+ }
664
+ return cells;
665
+ }
666
+ /**
667
+ * Quyết định giá trị thuộc tính cuối cùng từ một danh sách các cell đã chọn.
668
+ * @param cells Danh sách các cell được chọn.
669
+ * @param attributeName Tên của thuộc tính cần lấy (ví dụ: 'backgroundColor').
670
+ * @returns Giá trị chung, hoặc MIXED_VALUE nếu các giá trị không đồng nhất.
671
+ */
672
+ function getCombinedCellAttributeValue(cells, attributeName, fallbackValue) {
673
+ // Không có cell nào được chọn
674
+ if (cells.length === 0)
675
+ return fallbackValue;
676
+ const firstValue = cells[0].node.attrs[attributeName];
677
+ for (let i = 1; i < cells.length; i++) {
678
+ const currentValue = cells[i].node.attrs[attributeName];
679
+ // Dùng isEqual để so sánh object (quan trọng cho thuộc tính 'border')
680
+ if (!isEqual(currentValue, firstValue))
681
+ return undefined;
682
+ }
683
+ return firstValue;
684
+ }
685
+ /**
686
+ * Get the actual table DOM element from a node position
687
+ * Handles both direct table elements and NodeView wrappers
688
+ * Uses :scope selector to avoid selecting nested tables
689
+ *
690
+ * @export For use in other table-related extensions
691
+ */
692
+ function getTableDOMFromView(view, tablePos) {
693
+ const nodeDOMEl = view.nodeDOM(tablePos);
694
+ if (!nodeDOMEl)
695
+ return null;
696
+ // Case 1: nodeDOMEl is the table itself
697
+ if (nodeDOMEl.tagName === 'TABLE') {
698
+ return nodeDOMEl;
699
+ }
700
+ // Case 2: nodeDOMEl is a NodeView wrapper (figure.widget)
701
+ // Structure: figure.widget > table.widget-content
702
+ // Use :scope to only get direct child, avoiding nested tables in cells
703
+ const tableDOM = nodeDOMEl.querySelector(':scope > table.widget-content');
704
+ return tableDOM;
705
+ }
706
+
707
+ function getSelectedText(editor) {
708
+ const { state } = editor;
709
+ const { selection } = state;
710
+ // 1. Kiểm tra nếu selection rỗng (chỉ là con trỏ) hoặc không phải là TextSelection
711
+ if (selection.empty || !(selection instanceof TextSelection)) {
712
+ return null;
713
+ }
714
+ // 2. Lấy text giữa điểm đầu và điểm cuối của selection
715
+ const { from, to } = selection;
716
+ return state.doc.textBetween(from, to, ' '); // Trả về text
717
+ }
718
+ /**
719
+ * Tìm instance và range (from, to) của một mark cụ thể tại vị trí con trỏ.
720
+ * @param editor The Tiptap editor instance.
721
+ * @param markName Tên của mark cần tìm (vd: 'link').
722
+ * @returns Object chứa from, to, và mark instance nếu tìm thấy, ngược lại trả về null.
723
+ */
724
+ function getActiveMarkRange(editor, markName) {
725
+ const { state } = editor;
726
+ const { $from } = state.selection;
727
+ const { doc } = state;
728
+ // 2. Lấy instance của mark tại vị trí con trỏ
729
+ const activeMarks = $from.marks();
730
+ const targetMark = activeMarks.find((mark) => mark.type.name === markName);
731
+ // Nếu không có mark active, thoát ngay
732
+ if (!targetMark)
733
+ return null;
734
+ const markType = targetMark.type;
735
+ // 3. Quét ngược về đầu để tìm vị trí `from`
736
+ let from = $from.pos;
737
+ // Giới hạn việc quét trong node cha hiện tại ($from.start())
738
+ while (from > $from.start() && doc.rangeHasMark(from - 1, from, markType)) {
739
+ from--;
740
+ }
741
+ // 4. Quét xuôi về cuối để tìm vị trí `to`
742
+ let to = $from.pos;
743
+ // Giới hạn việc quét trong node cha hiện tại ($from.end())
744
+ while (to < $from.end() && doc.rangeHasMark(to, to + 1, markType)) {
745
+ to++;
746
+ }
747
+ // 5. Trả về kết quả
748
+ return { from, to, mark: targetMark };
749
+ }
750
+
751
+ class HandleNodeView {
752
+ constructor(node, view, getPos) {
753
+ // Store event listeners for cleanup
754
+ this.eventCleanups = [];
755
+ this.node = node;
756
+ this.view = view;
757
+ this.getPos = getPos;
758
+ // 1. Dùng <figure> làm wrapper, KHÔNG set contentEditable vì sẽ chặn tất cả
759
+ this.dom = document.createElement('figure');
760
+ this.dom.classList.add('widget');
761
+ // Make the widget draggable (via the handle with data-drag-handle attribute)
762
+ // this.dom.draggable = true;
763
+ // 2. Tạo Handle kéo thả
764
+ this.createBlockHandle(view, getPos);
765
+ // 3. Tạo Nội dung (do lớp con định nghĩa)
766
+ const contentElement = this.createContentElement(node);
767
+ contentElement.classList.add('widget-content');
768
+ // 4. Tạo các nút "Type Around"
769
+ const typeAround = this.createTypeAround(view, getPos, node);
770
+ // 5. Lắp ráp cấu trúc: Handle -> Nội dung -> TypeAround
771
+ // this.dom.append(handle, contentElement, typeAround);
772
+ this.dom.append(contentElement, typeAround);
773
+ // 6. Setup drag handling for ProseMirror
774
+ // this.setupDragHandling(view, getPos, node);
775
+ }
776
+ setupDragHandling(view, getPos, node) {
777
+ const handleDragStart = (event) => {
778
+ const pos = getPos();
779
+ if (pos === undefined || !event.dataTransfer)
780
+ return;
781
+ // Let ProseMirror know which node is being dragged
782
+ // Store node position and allow move effect
783
+ event.dataTransfer.effectAllowed = 'move';
784
+ // ProseMirror expects this format
785
+ const slice = view.state.doc.slice(pos, pos + node.nodeSize);
786
+ const serializer = DOMSerializer.fromSchema(view.state.schema);
787
+ const dom = document.createElement('div');
788
+ dom.appendChild(serializer.serializeFragment(slice.content));
789
+ event.dataTransfer.setDragImage(this.dom, 0, 0);
790
+ event.dataTransfer.setData('text/html', dom.innerHTML);
791
+ // Add dragging state
792
+ this.dom.classList.add('widget-dragging');
793
+ };
794
+ const handleDragEnd = () => {
795
+ this.dom.classList.remove('widget-dragging');
796
+ };
797
+ this.dom.addEventListener('dragstart', handleDragStart);
798
+ this.dom.addEventListener('dragend', handleDragEnd);
799
+ this.eventCleanups.push(() => {
800
+ this.dom.removeEventListener('dragstart', handleDragStart);
801
+ this.dom.removeEventListener('dragend', handleDragEnd);
802
+ });
803
+ }
804
+ createBlockHandle(view, getPos) {
805
+ const handle = document.createElement('div');
806
+ handle.className = 'widget__selection-handle';
807
+ handle.innerHTML =
808
+ '<svg xmlns="http://www.w3.org/2000/svg" height="24px" fill="currentColor" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M360-160q-33 0-56.5-23.5T280-240q0-33 23.5-56.5T360-320q33 0 56.5 23.5T440-240q0 33-23.5 56.5T360-160Zm240 0q-33 0-56.5-23.5T520-240q0-33 23.5-56.5T600-320q33 0 56.5 23.5T680-240q0 33-23.5 56.5T600-160ZM360-400q-33 0-56.5-23.5T280-480q0-33 23.5-56.5T360-560q33 0 56.5 23.5T440-480q0 33-23.5 56.5T360-400Zm240 0q-33 0-56.5-23.5T520-480q0-33 23.5-56.5T600-560q33 0 56.5 23.5T680-480q0 33-23.5 56.5T600-400ZM360-640q-33 0-56.5-23.5T280-720q0-33 23.5-56.5T360-800q33 0 56.5 23.5T440-720q0 33-23.5 56.5T360-640Zm240 0q-33 0-56.5-23.5T520-720q0-33 23.5-56.5T600-800q33 0 56.5 23.5T680-720q0 33-23.5 56.5T600-640Z"/></svg>';
809
+ handle.contentEditable = 'false';
810
+ // handle.draggable = true;
811
+ handle.setAttribute('data-drag-handle', '');
812
+ handle.setAttribute('data-block-handle', ''); // Mark để dễ check trong stopEvent
813
+ // Logic click-to-select (only on click, not drag)
814
+ let isDragging = false;
815
+ const handleMouseDown = () => {
816
+ isDragging = false;
817
+ // Let drag happen naturally, don't prevent
818
+ };
819
+ const handleClick = (event) => {
820
+ var _a;
821
+ // Only select if it was a click, not a drag
822
+ if (!isDragging && event.button === 0) {
823
+ event.preventDefault();
824
+ event.stopPropagation();
825
+ const pos = (_a = getPos()) !== null && _a !== void 0 ? _a : 0;
826
+ const tr = view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos));
827
+ view.dispatch(tr);
828
+ }
829
+ isDragging = false;
830
+ };
831
+ handle.addEventListener('mousedown', handleMouseDown);
832
+ // handle.addEventListener('dragstart', handleDragStart);
833
+ handle.addEventListener('click', handleClick);
834
+ // Store cleanup
835
+ this.eventCleanups.push(() => {
836
+ handle.removeEventListener('mousedown', handleMouseDown);
837
+ // handle.removeEventListener('dragstart', handleDragStart);
838
+ handle.removeEventListener('click', handleClick);
839
+ });
840
+ return handle;
841
+ }
842
+ createTypeAround(view, getPos, node) {
843
+ const wrapper = document.createElement('div');
844
+ wrapper.className = 'type-around';
845
+ const icon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/></svg>';
846
+ const buttonBefore = document.createElement('div');
847
+ buttonBefore.className = 'widget__type-around__button widget__type-around__button_before';
848
+ buttonBefore.title = 'Insert paragraph before';
849
+ buttonBefore.innerHTML = icon;
850
+ const buttonAfter = document.createElement('div');
851
+ buttonAfter.className = 'widget__type-around__button widget__type-around__button_after';
852
+ buttonAfter.title = 'Insert paragraph after';
853
+ buttonAfter.innerHTML = icon;
854
+ // Logic chèn node 'paragraph' mới
855
+ const handleInsert = (position) => {
856
+ const currentPos = getPos();
857
+ if (currentPos === undefined)
858
+ return;
859
+ const newNode = view.state.schema.nodes['paragraph'].create();
860
+ let insertPos;
861
+ if (position === 'before') {
862
+ insertPos = currentPos;
863
+ }
864
+ else {
865
+ const tableStartPos = view.state.doc.resolve(currentPos);
866
+ const tableIndex = tableStartPos.index();
867
+ insertPos = tableStartPos.posAtIndex(tableIndex + 1);
868
+ }
869
+ const tr = view.state.tr.insert(insertPos, newNode);
870
+ const mappedInsertPos = tr.mapping.map(insertPos, -1);
871
+ const selectionPos = mappedInsertPos + 1;
872
+ // Di chuyển con trỏ vào node mới
873
+ view.dispatch(tr.setSelection(TextSelection.create(tr.doc, selectionPos)));
874
+ view.focus();
875
+ };
876
+ // Button Before event handlers
877
+ const beforeMouseDown = (e) => {
878
+ e.preventDefault();
879
+ e.stopPropagation();
880
+ };
881
+ const beforeClick = (e) => {
882
+ e.preventDefault();
883
+ e.stopPropagation();
884
+ handleInsert('before');
885
+ };
886
+ buttonBefore.addEventListener('mousedown', beforeMouseDown);
887
+ buttonBefore.addEventListener('click', beforeClick);
888
+ // Button After event handlers
889
+ const afterMouseDown = (e) => {
890
+ e.preventDefault();
891
+ e.stopPropagation();
892
+ };
893
+ const afterClick = (e) => {
894
+ e.preventDefault();
895
+ e.stopPropagation();
896
+ handleInsert('after');
897
+ };
898
+ buttonAfter.addEventListener('mousedown', afterMouseDown);
899
+ buttonAfter.addEventListener('click', afterClick);
900
+ // Store cleanup functions
901
+ this.eventCleanups.push(() => {
902
+ buttonBefore.removeEventListener('mousedown', beforeMouseDown);
903
+ buttonBefore.removeEventListener('click', beforeClick);
904
+ buttonAfter.removeEventListener('mousedown', afterMouseDown);
905
+ buttonAfter.removeEventListener('click', afterClick);
906
+ });
907
+ wrapper.append(buttonBefore, buttonAfter);
908
+ return wrapper;
909
+ }
910
+ /**
911
+ * Ngăn các sự kiện từ handle và type-around buttons lan truyền vào editor
912
+ * CHỈ chặn events từ các controls, KHÔNG chặn events từ content
913
+ *
914
+ * QUAN TRỌNG:
915
+ * - return true = ProseMirror BỎ QUA event này
916
+ * - return false = ProseMirror XỬ LÝ event này (table editing, selection, drag, etc.)
917
+ */
918
+ stopEvent(event) {
919
+ const target = event.target;
920
+ // Chỉ chặn events từ control elements (handle, type-around buttons)
921
+ const isControl = target.closest('[data-block-handle]') ||
922
+ target.closest('.widget__type-around__button') ||
923
+ target.closest('.type-around');
924
+ if (!isControl) {
925
+ // Event từ table content → cho ProseMirror xử lý
926
+ return false;
927
+ }
928
+ // Event từ control → chặn hầu hết, NHƯNG cho phép một số events quan trọng
929
+ const eventType = event.type;
930
+ // Cho phép drag events để ProseMirror handle drag & drop
931
+ if (eventType === 'dragstart' || eventType === 'drag' || eventType === 'dragend') {
932
+ return false; // Let ProseMirror handle drag
933
+ }
934
+ // Cho phép focus events để không phá navigation
935
+ if (eventType === 'focus' || eventType === 'blur') {
936
+ return false;
937
+ }
938
+ // Chặn các events khác từ controls
939
+ return true;
940
+ }
941
+ selectNode() {
942
+ this.dom.classList.add('ProseMirror-selectednode');
943
+ }
944
+ deselectNode() {
945
+ this.dom.classList.remove('ProseMirror-selectednode');
946
+ }
947
+ destroy() {
948
+ // Cleanup all event listeners
949
+ this.eventCleanups.forEach((cleanup) => cleanup());
950
+ this.eventCleanups = [];
951
+ // Remove DOM
952
+ this.dom.remove();
953
+ }
954
+ }
955
+ class TableNodeView extends HandleNodeView {
956
+ createContentElement() {
957
+ // Create table element
958
+ const table = document.createElement('table');
959
+ // Apply table styles (border and backgroundColor)
960
+ this.updateTableStyles(table);
961
+ // Create tbody - this is the contentDOM where ProseMirror renders <tr>s
962
+ const tbody = document.createElement('tbody');
963
+ this.contentDOM = tbody;
964
+ table.appendChild(tbody);
965
+ // Render colgroup
966
+ this.updateColgroup(table);
967
+ return table;
968
+ }
969
+ /**
970
+ * Get table element from DOM
971
+ * Query from this.dom instead of storing reference to avoid stale references
972
+ */
973
+ getTableElement() {
974
+ return this.dom.querySelector('table');
975
+ }
976
+ update(node) {
977
+ // Check if node actually changed
978
+ if (node.type !== this.node.type) {
979
+ return false;
980
+ }
981
+ // Update node reference
982
+ this.node = node;
983
+ // Get table element
984
+ const table = this.getTableElement();
985
+ if (!table) {
986
+ return true;
987
+ }
988
+ // Update table styles if border or backgroundColor changed
989
+ this.updateTableStyles(table);
990
+ // Update colgroup if colwidths changed
991
+ this.updateColgroup(table);
992
+ return true;
993
+ }
994
+ updateTableStyles(table) {
995
+ const border = this.node.attrs['border'];
996
+ const backgroundColor = this.node.attrs['backgroundColor'];
997
+ // Reset styles
998
+ // (Chúng ta reset để xử lý trường hợp attribute bị xóa)
999
+ table.style.borderStyle = '';
1000
+ table.style.borderColor = '';
1001
+ table.style.borderWidth = '';
1002
+ table.style.backgroundColor = '';
1003
+ // Apply backgroundColor
1004
+ if (backgroundColor) {
1005
+ table.style.backgroundColor = backgroundColor;
1006
+ }
1007
+ // Apply border
1008
+ if (border) {
1009
+ if (border.style)
1010
+ table.style.borderStyle = border.style;
1011
+ if (border.color)
1012
+ table.style.borderColor = border.color;
1013
+ if (border.width)
1014
+ table.style.borderWidth = border.width;
1015
+ }
1016
+ }
1017
+ updateColgroup(table) {
1018
+ // Remove existing colgroup if exists
1019
+ const existingColgroup = table.querySelector('colgroup');
1020
+ if (existingColgroup) {
1021
+ existingColgroup.remove();
1022
+ }
1023
+ // Render new colgroup if colwidths exist
1024
+ const colwidths = this.node.attrs['colwidths'];
1025
+ if (colwidths && colwidths.length > 0) {
1026
+ const colgroup = document.createElement('colgroup');
1027
+ colwidths.forEach((width) => {
1028
+ const col = document.createElement('col');
1029
+ if (width) {
1030
+ col.style.width = `${width}%`;
1031
+ }
1032
+ colgroup.appendChild(col);
1033
+ });
1034
+ // Insert before tbody
1035
+ const tbody = table.querySelector('tbody');
1036
+ if (tbody) {
1037
+ table.insertBefore(colgroup, tbody);
1038
+ }
1039
+ else {
1040
+ table.appendChild(colgroup);
1041
+ }
1042
+ }
1043
+ }
1044
+ }
1045
+ // Factory function for Tiptap NodeView
1046
+ const createTableNodeView = (props) => {
1047
+ return new TableNodeView(props.node, props.view, props.getPos);
1048
+ };
1049
+ class PageBreakNodeView extends HandleNodeView {
1050
+ createContentElement() {
1051
+ // Create page break container
1052
+ const pageBreakDiv = document.createElement('div');
1053
+ pageBreakDiv.className = 'document-engine-page-break';
1054
+ pageBreakDiv.setAttribute('data-page-break', 'true');
1055
+ pageBreakDiv.style.cssText = 'break-before: page;';
1056
+ pageBreakDiv.contentEditable = 'false';
1057
+ // Create label
1058
+ const label = document.createElement('span');
1059
+ label.className = 'document-engine-page-break-label';
1060
+ label.textContent = 'Page break';
1061
+ label.contentEditable = 'false';
1062
+ pageBreakDiv.appendChild(label);
1063
+ return pageBreakDiv;
1064
+ }
1065
+ }
1066
+ // Factory function for PageBreak NodeView
1067
+ const createPageBreakNodeView = (props) => {
1068
+ return new PageBreakNodeView(props.node, props.view, props.getPos);
1069
+ };
1070
+
1071
+ /**
1072
+ * Handler for mouse move (dragging)
1073
+ */
1074
+ function handleMouseMoveEvent(event, dragInfo) {
1075
+ var _a;
1076
+ if (!dragInfo.isDragging)
1077
+ return;
1078
+ event.preventDefault();
1079
+ const { startX, tableWidthPx, initialLeftWidth, initialRightWidth, leftColIndex, tablePos, tableNode, view } = dragInfo;
1080
+ // 1. Compute the delta (pixels)
1081
+ const deltaX = event.clientX - startX;
1082
+ // 2. Convert the pixel delta into a percentage delta
1083
+ // This is the most important piece of logic
1084
+ const deltaPercent = (deltaX / tableWidthPx) * 100;
1085
+ // 3. Compute the new widths
1086
+ let newLeftWidth = initialLeftWidth + deltaPercent;
1087
+ let newRightWidth = initialRightWidth - deltaPercent;
1088
+ // 4. Apply constraints (e.g. min-width 5%)
1089
+ const minWidth = 5; // min-width configuration
1090
+ if (newLeftWidth < minWidth) {
1091
+ const diff = minWidth - newLeftWidth;
1092
+ newLeftWidth = minWidth;
1093
+ newRightWidth = newRightWidth - diff; // Right column absorbs the shortfall
1094
+ }
1095
+ else if (newRightWidth < minWidth) {
1096
+ const diff = minWidth - newRightWidth;
1097
+ newRightWidth = minWidth;
1098
+ newLeftWidth = newLeftWidth - diff; // Left column absorbs the shortfall
1099
+ }
1100
+ // Make sure no column goes negative (in case of a very fast drag)
1101
+ if (newLeftWidth < 0)
1102
+ newLeftWidth = 0;
1103
+ if (newRightWidth < 0)
1104
+ newRightWidth = 0;
1105
+ // 5. Build the new colwidths array
1106
+ const newColWidths = [...(_a = tableNode === null || tableNode === void 0 ? void 0 : tableNode.attrs) === null || _a === void 0 ? void 0 : _a['colwidths']];
1107
+ newColWidths[leftColIndex] = newLeftWidth;
1108
+ newColWidths[leftColIndex + 1] = newRightWidth;
1109
+ // 6. Dispatch a transaction to update the state
1110
+ if (tableNode && view) {
1111
+ const tr = view.state.tr.setNodeMarkup(tablePos, null, Object.assign(Object.assign({}, tableNode.attrs), { colwidths: newColWidths }));
1112
+ // Use dispatchTransaction so Tiptap/ProseMirror updates the UI itself
1113
+ view.dispatch(tr);
1114
+ }
1115
+ }
1116
+ /**
1117
+ * Handler for mouse up (end of drag)
1118
+ */
1119
+ function handleMouseUpEvent(event, dragInfo) {
1120
+ if (!dragInfo.isDragging)
1121
+ return;
1122
+ event.preventDefault();
1123
+ // Reset the state
1124
+ dragInfo = {
1125
+ isDragging: false,
1126
+ startX: 0,
1127
+ tablePos: -1,
1128
+ tableNode: null,
1129
+ tableWidthPx: 0,
1130
+ leftColIndex: -1,
1131
+ initialLeftWidth: 0,
1132
+ initialRightWidth: 0,
1133
+ view: null,
1134
+ };
1135
+ }
1136
+ /**
1137
+ * Extension PercentageColumnResizing
1138
+ * * Replaces the default column-resize logic with percentage-based logic.
1139
+ */
1140
+ const PercentageColumnResizing = Extension.create({
1141
+ name: 'percentageColumnResizing',
1142
+ addProseMirrorPlugins() {
1143
+ // A temporary object to hold the drag-and-drop state
1144
+ // We don't use Plugin State because this state never needs to be "undone"
1145
+ let dragInfo = {
1146
+ isDragging: false,
1147
+ startX: 0,
1148
+ tablePos: -1,
1149
+ tableNode: null,
1150
+ tableWidthPx: 0,
1151
+ leftColIndex: -1,
1152
+ initialLeftWidth: 0,
1153
+ initialRightWidth: 0,
1154
+ view: null,
1155
+ // finalColWidths: null,
1156
+ };
1157
+ const editorInstance = this.editor;
1158
+ return [
1159
+ new Plugin({
1160
+ key: new PluginKey('percentageColumnResizing'),
1161
+ props: {
1162
+ /**
1163
+ * Draw the resize handles into the DOM
1164
+ */
1165
+ decorations(state) {
1166
+ if (!(editorInstance === null || editorInstance === void 0 ? void 0 : editorInstance.isEditable))
1167
+ return;
1168
+ const decorations = [];
1169
+ const { doc } = state;
1170
+ const tableTypes = tableNodeTypes(state.schema);
1171
+ // Walk every node in the document
1172
+ doc.descendants((node, pos) => {
1173
+ var _a;
1174
+ if (node.type.name !== tableTypes.table.name)
1175
+ return;
1176
+ const colwidths = node.attrs['colwidths'];
1177
+ if (!colwidths || colwidths.length === 0)
1178
+ return;
1179
+ let accumulatedWidth = 0;
1180
+ // Create a handle for N-1 columns (the last column doesn't need one)
1181
+ for (let i = 0; i < colwidths.length - 1; i++) {
1182
+ accumulatedWidth += (_a = colwidths[i]) !== null && _a !== void 0 ? _a : 0;
1183
+ // Create a 'div' widget to act as the handle
1184
+ const handle = document.createElement('div');
1185
+ handle.className = 'pm-col-resizer';
1186
+ handle.style.left = `calc(${accumulatedWidth}% - 2px)`;
1187
+ // Store the column position so we know which column is being resized
1188
+ handle.setAttribute('data-col-index', i.toString());
1189
+ handle.setAttribute('data-table-pos', pos.toString());
1190
+ // Place the widget right after the <table> tag (pos + 1)
1191
+ decorations.push(Decoration.widget(pos + 1, handle));
1192
+ }
1193
+ });
1194
+ return DecorationSet.create(doc, decorations);
1195
+ },
1196
+ /**
1197
+ * Handle the mousedown event on a handle
1198
+ */
1199
+ handleDOMEvents: {
1200
+ mousedown(view, event) {
1201
+ var _a, _b;
1202
+ const target = event.target;
1203
+ // Only trigger when clicking on a handle
1204
+ if (!target.classList.contains('pm-col-resizer'))
1205
+ return false;
1206
+ event.preventDefault();
1207
+ const colIndex = parseInt((_a = target.getAttribute('data-col-index')) !== null && _a !== void 0 ? _a : '0', 10);
1208
+ const tablePos = parseInt((_b = target.getAttribute('data-table-pos')) !== null && _b !== void 0 ? _b : '0', 10);
1209
+ const tableNode = view.state.doc.nodeAt(tablePos);
1210
+ const colwidths = tableNode === null || tableNode === void 0 ? void 0 : tableNode.attrs['colwidths'];
1211
+ if (!tableNode || !colwidths)
1212
+ return false;
1213
+ // Get table DOM element (handles NodeView wrappers and nested tables)
1214
+ const tableDOM = getTableDOMFromView(view, tablePos);
1215
+ if (!tableDOM)
1216
+ return false;
1217
+ // Store the info needed for dragging
1218
+ dragInfo = {
1219
+ isDragging: true,
1220
+ startX: event.clientX,
1221
+ tablePos: tablePos,
1222
+ tableNode: tableNode,
1223
+ tableWidthPx: tableDOM.offsetWidth, // <-- Get the table width (px)
1224
+ leftColIndex: colIndex,
1225
+ initialLeftWidth: colwidths[colIndex],
1226
+ initialRightWidth: colwidths[colIndex + 1],
1227
+ view: view,
1228
+ // finalColWidths: null,
1229
+ };
1230
+ const handleMouseMove = (event) => handleMouseMoveEvent(event, dragInfo);
1231
+ const handleMouseUp = (event) => {
1232
+ handleMouseUpEvent(event, dragInfo);
1233
+ window.removeEventListener('mousemove', handleMouseMove, true);
1234
+ window.removeEventListener('mouseup', handleMouseUp, true);
1235
+ };
1236
+ window.addEventListener('mousemove', handleMouseMove, true);
1237
+ window.addEventListener('mouseup', handleMouseUp, true);
1238
+ return true; // Event handled
1239
+ },
1240
+ },
1241
+ },
1242
+ }),
1243
+ ];
1244
+ },
1245
+ });
1246
+
1247
+ /**
1248
+ * Calculate new column widths when adding a column, ensuring no column goes below MIN_NEW_COL_WIDTH
1249
+ * @param oldColWidths - Array of current column widths
1250
+ * @returns Array of adjusted column widths (without the new column inserted yet)
1251
+ */
1252
+ function calculateColumnWidthsForNewColumn(oldColWidths) {
1253
+ // Find columns that can contribute (above minimum)
1254
+ const columnsAboveMin = oldColWidths.filter((w) => w > MIN_NEW_COL_WIDTH);
1255
+ // If not enough space, proportionally reduce all columns
1256
+ if (columnsAboveMin.length === 0) {
1257
+ const reduction = MIN_NEW_COL_WIDTH / oldColWidths.length;
1258
+ return oldColWidths.map((w) => w - reduction);
1259
+ }
1260
+ // Calculate available space from columns above minimum
1261
+ const availableSpace = columnsAboveMin.reduce((sum, w) => sum + (w - MIN_NEW_COL_WIDTH), 0);
1262
+ // If we have enough space, only tax columns above minimum
1263
+ if (availableSpace >= MIN_NEW_COL_WIDTH) {
1264
+ return oldColWidths.map((w) => {
1265
+ if (w > MIN_NEW_COL_WIDTH) {
1266
+ const contribution = ((w - MIN_NEW_COL_WIDTH) / availableSpace) * MIN_NEW_COL_WIDTH;
1267
+ return Math.max(MIN_NEW_COL_WIDTH, w - contribution);
1268
+ }
1269
+ return w;
1270
+ });
1271
+ }
1272
+ // Not enough space even from columns above min, reduce proportionally
1273
+ const reduction = MIN_NEW_COL_WIDTH / oldColWidths.length;
1274
+ return oldColWidths.map((w) => w - reduction);
1275
+ }
1276
+ const TableDefaultAttributes = {
1277
+ border: {
1278
+ style: 'double',
1279
+ color: '#e5e7eb',
1280
+ width: '1px',
1281
+ },
1282
+ backgroundColor: null,
1283
+ colwidths: null, // Array of column widths as percentages
1284
+ cellBorder: {
1285
+ style: 'solid',
1286
+ color: '#e5e7eb',
1287
+ width: '1px',
1288
+ },
1289
+ cellBackgroundColor: null,
1290
+ cellVerticalAlign: 'middle',
1291
+ cellTextAlign: null,
1292
+ };
1293
+ // Extend TableCell and TableHeader to support styling attributes
1294
+ const commonCellAttributes = {
1295
+ backgroundColor: {
1296
+ default: null,
1297
+ renderHTML: (attrs) => attrs.backgroundColor ? { style: `background-color: ${attrs.backgroundColor};` } : {},
1298
+ parseHTML: (element) => element.style.getPropertyValue('background-color') || null,
1299
+ },
1300
+ border: {
1301
+ default: TableDefaultAttributes.cellBorder,
1302
+ renderHTML: (attrs) => {
1303
+ const border = attrs['border'];
1304
+ if (!border)
1305
+ return {};
1306
+ const styles = [];
1307
+ if (border.style)
1308
+ styles.push(`border-style: ${border.style}`);
1309
+ if (border.color)
1310
+ styles.push(`border-color: ${border.color}`);
1311
+ if (border.width)
1312
+ styles.push(`border-width: ${border.width}`);
1313
+ return styles.length ? { style: styles.join('; ') + ';' } : {};
1314
+ },
1315
+ parseHTML: (element) => {
1316
+ const style = element.style.getPropertyValue('border-style');
1317
+ const color = element.style.getPropertyValue('border-color');
1318
+ const width = element.style.getPropertyValue('border-width');
1319
+ if (!style && !color && !width)
1320
+ return null;
1321
+ return {
1322
+ style: style || null,
1323
+ color: color || null,
1324
+ width: width || null,
1325
+ };
1326
+ },
1327
+ },
1328
+ verticalAlign: {
1329
+ default: TableDefaultAttributes.cellVerticalAlign,
1330
+ renderHTML: (attrs) => attrs.verticalAlign ? { style: `vertical-align: ${attrs.verticalAlign};` } : {},
1331
+ parseHTML: (element) => element.style.getPropertyValue('vertical-align') || null,
1332
+ },
1333
+ textAlign: {
1334
+ default: TableDefaultAttributes.cellTextAlign,
1335
+ renderHTML: (attrs) => attrs.textAlign ? { style: `text-align: ${attrs.textAlign};` } : {},
1336
+ parseHTML: (element) => element.style.getPropertyValue('text-align') || null,
1337
+ },
1338
+ };
1339
+ const StyledTableCell = TableCell.extend({
1340
+ addAttributes() {
1341
+ var _a, _b;
1342
+ const parent = (_b = (_a = this.parent) === null || _a === void 0 ? void 0 : _a.call(this)) !== null && _b !== void 0 ? _b : {};
1343
+ return Object.assign(Object.assign({}, parent), commonCellAttributes);
1344
+ },
1345
+ });
1346
+ const StyledTableHeader = TableHeader.extend({
1347
+ addAttributes() {
1348
+ var _a, _b;
1349
+ const parent = (_b = (_a = this.parent) === null || _a === void 0 ? void 0 : _a.call(this)) !== null && _b !== void 0 ? _b : {};
1350
+ return Object.assign(Object.assign({}, parent), commonCellAttributes);
1351
+ },
1352
+ });
1353
+ // Extend Table node to support width (%) and alignment (left|center|right)
1354
+ const StyledTable = Table.extend({
1355
+ // name: 'styledTable',
1356
+ // draggable: true,
1357
+ addOptions() {
1358
+ var _a;
1359
+ return Object.assign(Object.assign({}, (_a = this.parent) === null || _a === void 0 ? void 0 : _a.call(this)), { enableNodeView: true });
1360
+ },
1361
+ addAttributes() {
1362
+ var _a, _b;
1363
+ const parent = (_b = (_a = this.parent) === null || _a === void 0 ? void 0 : _a.call(this)) !== null && _b !== void 0 ? _b : {};
1364
+ return Object.assign(Object.assign({}, parent), { border: {
1365
+ default: null,
1366
+ renderHTML: (attrs) => {
1367
+ const border = attrs.border;
1368
+ if (!border)
1369
+ return {};
1370
+ const styles = [];
1371
+ if (border.style)
1372
+ styles.push(`border-style: ${border.style}`);
1373
+ if (border.color)
1374
+ styles.push(`border-color: ${border.color}`);
1375
+ if (border.width)
1376
+ styles.push(`border-width: ${border.width}`);
1377
+ return styles.length ? { style: styles.join('; ') + ';' } : {};
1378
+ },
1379
+ parseHTML: (element) => {
1380
+ const style = element.style.getPropertyValue('border-style');
1381
+ const color = element.style.getPropertyValue('border-color');
1382
+ const width = element.style.getPropertyValue('border-width');
1383
+ if (!style && !color && !width)
1384
+ return null;
1385
+ return { style: style || null, color: color || null, width: width || null };
1386
+ },
1387
+ }, backgroundColor: {
1388
+ default: null,
1389
+ renderHTML: (attrs) => attrs.backgroundColor ? { style: `background-color: ${attrs.backgroundColor};` } : {},
1390
+ parseHTML: (element) => element.style.getPropertyValue('background-color') || null,
1391
+ }, colwidths: {
1392
+ default: null,
1393
+ renderHTML: (attributes) => {
1394
+ if (!attributes['colwidths']) {
1395
+ return {}; // Nothing to render
1396
+ }
1397
+ return {
1398
+ 'data-colwidths': JSON.stringify(attributes['colwidths']),
1399
+ };
1400
+ },
1401
+ parseHTML: (element) => {
1402
+ // Only get cols from direct child colgroup (not from nested tables)
1403
+ const colgroup = element.querySelector(':scope > colgroup');
1404
+ if (!colgroup)
1405
+ return null;
1406
+ const cols = colgroup.querySelectorAll('col');
1407
+ if (cols.length === 0)
1408
+ return null;
1409
+ const colwidths = [];
1410
+ cols.forEach((col) => {
1411
+ const width = col.getAttribute('style');
1412
+ if (width) {
1413
+ const match = width.match(/width:\s*(\d+(?:\.\d+)?)%/);
1414
+ colwidths.push(match ? parseFloat(match[1]) : null);
1415
+ }
1416
+ else {
1417
+ colwidths.push(null);
1418
+ }
1419
+ });
1420
+ return colwidths.length > 0 ? colwidths : null;
1421
+ },
1422
+ } });
1423
+ },
1424
+ renderHTML({ node, HTMLAttributes }) {
1425
+ var _a;
1426
+ const colwidths = (_a = node.attrs) === null || _a === void 0 ? void 0 : _a['colwidths'];
1427
+ const domNodes = [];
1428
+ // Render <colgroup> when colwidths are present
1429
+ if (colwidths) {
1430
+ const colgroup = ['colgroup', {}];
1431
+ colwidths.forEach((width) => {
1432
+ colgroup.push(['col', { style: width ? `width: ${width}%` : null }]);
1433
+ });
1434
+ domNodes.push(colgroup);
1435
+ }
1436
+ // Add <tbody> (0 is the 'content hole')
1437
+ domNodes.push(['tbody', 0]);
1438
+ // Merge the HTML attributes (including global attributes)
1439
+ // HTMLAttributes has already been merged by Tiptap via getRenderedAttributes
1440
+ return ['table', HTMLAttributes, ...domNodes];
1441
+ },
1442
+ addCommands() {
1443
+ var _a, _b;
1444
+ const parent = (_b = (_a = this.parent) === null || _a === void 0 ? void 0 : _a.call(this)) !== null && _b !== void 0 ? _b : {};
1445
+ return Object.assign(Object.assign({}, parent), {
1446
+ // Table
1447
+ setTableBorder: (border) => ({ chain, state }) => {
1448
+ var _a, _b;
1449
+ if (!border)
1450
+ return chain().focus().updateAttributes('table', { border: null }).run();
1451
+ // Get current table border and merge with new values
1452
+ // Find the table node
1453
+ const cellInfo = getCursorCellInfo(state);
1454
+ if (!cellInfo)
1455
+ return false;
1456
+ const currentBorder = ((_b = (_a = cellInfo === null || cellInfo === void 0 ? void 0 : cellInfo.tableNode) === null || _a === void 0 ? void 0 : _a.attrs) === null || _b === void 0 ? void 0 : _b['border']) || {};
1457
+ const mergedBorder = {
1458
+ style: border.style !== undefined ? border.style : currentBorder.style || null,
1459
+ color: border.color !== undefined ? border.color : currentBorder.color || null,
1460
+ width: border.width !== undefined ? border.width : currentBorder.width || null,
1461
+ };
1462
+ return chain().focus().updateAttributes('table', { border: mergedBorder }).run();
1463
+ }, setTableBackgroundColor: (color) => ({ chain }) => {
1464
+ return chain()
1465
+ .focus()
1466
+ .updateAttributes('table', { backgroundColor: color !== null && color !== void 0 ? color : null })
1467
+ .run();
1468
+ }, insertTable: ({ rows, cols, withHeaderRow }) => ({ tr, dispatch, editor }) => {
1469
+ const { schema } = editor.state;
1470
+ // --- STEP 1: Compute colWidths ---
1471
+ const calculatedColWidths = [];
1472
+ const defaultWidth = 100 / cols;
1473
+ for (let i = 0; i < cols; i += 1) {
1474
+ calculatedColWidths.push(defaultWidth);
1475
+ }
1476
+ // Example: cols = 3 -> calculatedColWidths = [33.33, 33.33, 33.33]
1477
+ // --- STEP 2: Build the table content (rows, cells) ---
1478
+ // We have to build the child nodes ourselves
1479
+ const cellType = schema.nodes['tableCell'];
1480
+ const headerType = schema.nodes['tableHeader'];
1481
+ const rowType = schema.nodes['tableRow'];
1482
+ const tableRows = [];
1483
+ // Helper to create a single empty cell
1484
+ const createEmptyCell = (type) => {
1485
+ const emptyParagraph = schema.nodes['paragraph'].create();
1486
+ return type.create(null, emptyParagraph);
1487
+ };
1488
+ // Create the rows
1489
+ for (let r = 0; r < rows; r += 1) {
1490
+ const tableCells = [];
1491
+ for (let c = 0; c < cols; c += 1) {
1492
+ let cellNode;
1493
+ // If this is the first row and 'withHeaderRow' is set
1494
+ if (r === 0 && withHeaderRow) {
1495
+ cellNode = createEmptyCell(headerType);
1496
+ }
1497
+ else {
1498
+ cellNode = createEmptyCell(cellType);
1499
+ }
1500
+ tableCells.push(cellNode);
1501
+ }
1502
+ // Add the new row to the rows array
1503
+ tableRows.push(rowType.create(null, tableCells));
1504
+ }
1505
+ // --- STEP 3: Create the main Table node WITH the 'colwidths' ATTRIBUTE ---
1506
+ const tableNode = schema.nodes['table'].create({
1507
+ // This is the key part: assign our percentage array here
1508
+ colwidths: calculatedColWidths,
1509
+ }, tableRows // Content (the 'tableRow' nodes)
1510
+ );
1511
+ // --- STEP 4: Dispatch transaction (Original code)---
1512
+ if (dispatch) {
1513
+ const offset = tr.selection.from + 1;
1514
+ tr.replaceSelectionWith(tableNode)
1515
+ .scrollIntoView()
1516
+ .setSelection(TextSelection.near(tr.doc.resolve(offset)));
1517
+ }
1518
+ return true;
1519
+ },
1520
+ // 2. ADDCOLUMNAFTER COMMAND
1521
+ addColumnAfter: () => ({ state, dispatch }) => {
1522
+ const $cell = selectionCell(state);
1523
+ if (!$cell)
1524
+ return false;
1525
+ const table = $cell.node(-1);
1526
+ const tablePos = $cell.start(-1) - 1;
1527
+ const oldColWidths = table.attrs['colwidths'];
1528
+ // Calculate column index
1529
+ const map = TableMap.get(table);
1530
+ const cellPosInTable = $cell.pos - $cell.start(-1);
1531
+ const colIndex = map.colCount(cellPosInTable);
1532
+ // Calculate new column widths - ensure no column goes below MIN_NEW_COL_WIDTH
1533
+ const newColWidths = calculateColumnWidthsForNewColumn(oldColWidths);
1534
+ // Insert new column width
1535
+ newColWidths.splice(colIndex + 1, 0, MIN_NEW_COL_WIDTH);
1536
+ if (dispatch) {
1537
+ // Use the prosemirror-tables function directly to add column
1538
+ // This modifies the transaction but doesn't dispatch it
1539
+ addColumnAfter(state, (modifiedTr) => {
1540
+ // Add our colwidth changes to the same transaction
1541
+ modifiedTr.setNodeMarkup(tablePos, null, Object.assign(Object.assign({}, table.attrs), { colwidths: newColWidths }));
1542
+ // Now dispatch the complete transaction
1543
+ dispatch(modifiedTr.scrollIntoView());
1544
+ });
1545
+ }
1546
+ return true;
1547
+ },
1548
+ // 3. ADDCOLUMNBEFORE COMMAND
1549
+ addColumnBefore: () => ({ state, dispatch }) => {
1550
+ const $cell = selectionCell(state);
1551
+ if (!$cell)
1552
+ return false;
1553
+ const table = $cell.node(-1);
1554
+ const tablePos = $cell.start(-1) - 1;
1555
+ const oldColWidths = table.attrs['colwidths'];
1556
+ // Calculate column index
1557
+ const map = TableMap.get(table);
1558
+ const cellPosInTable = $cell.pos - $cell.start(-1);
1559
+ const colIndex = map.colCount(cellPosInTable);
1560
+ // Calculate new column widths - ensure no column goes below MIN_NEW_COL_WIDTH
1561
+ const newColWidths = calculateColumnWidthsForNewColumn(oldColWidths);
1562
+ // Insert new column width at current column index (before)
1563
+ newColWidths.splice(colIndex, 0, MIN_NEW_COL_WIDTH);
1564
+ if (dispatch) {
1565
+ addColumnBefore(state, (modifiedTr) => {
1566
+ modifiedTr.setNodeMarkup(tablePos, null, Object.assign(Object.assign({}, table.attrs), { colwidths: newColWidths }));
1567
+ dispatch(modifiedTr.scrollIntoView());
1568
+ });
1569
+ }
1570
+ return true;
1571
+ },
1572
+ // 4. DELETECOLUMN COMMAND
1573
+ deleteColumn: () => ({ state, dispatch }) => {
1574
+ const $cell = selectionCell(state);
1575
+ if (!$cell)
1576
+ return false;
1577
+ const table = $cell.node(-1);
1578
+ const tablePos = $cell.start(-1) - 1;
1579
+ const oldColWidths = table.attrs['colwidths'];
1580
+ // Calculate column index
1581
+ const map = TableMap.get(table);
1582
+ const cellPosInTable = $cell.pos - $cell.start(-1);
1583
+ const colIndex = map.colCount(cellPosInTable);
1584
+ // Get width of column being removed
1585
+ const removedWidth = oldColWidths[colIndex];
1586
+ // Create array of remaining columns
1587
+ const remainingWidths = oldColWidths.filter((_, i) => i !== colIndex);
1588
+ // Distribute removed width among remaining columns
1589
+ const gainPerCol = removedWidth / remainingWidths.length;
1590
+ const newColWidths = remainingWidths.map((w) => w + gainPerCol);
1591
+ if (dispatch) {
1592
+ deleteColumn(state, (modifiedTr) => {
1593
+ modifiedTr.setNodeMarkup(tablePos, null, Object.assign(Object.assign({}, table.attrs), { colwidths: newColWidths }));
1594
+ dispatch(modifiedTr.scrollIntoView());
1595
+ });
1596
+ }
1597
+ return true;
1598
+ },
1599
+ // Cell
1600
+ setCellBorder: (border) => ({ chain, state }) => {
1601
+ var _a;
1602
+ if (!border)
1603
+ return chain()
1604
+ .focus()
1605
+ .updateAttributes('tableCell', { border: null })
1606
+ .updateAttributes('tableHeader', { border: null })
1607
+ .run();
1608
+ // 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']) || {};
1613
+ 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,
1617
+ };
1618
+ return chain()
1619
+ .focus()
1620
+ .updateAttributes('tableCell', { border: mergedBorder })
1621
+ .updateAttributes('tableHeader', { border: mergedBorder })
1622
+ .run();
1623
+ }, setCellVerticalAlign: (align) => ({ chain }) => {
1624
+ return chain()
1625
+ .focus()
1626
+ .updateAttributes('tableCell', { verticalAlign: align !== null && align !== void 0 ? align : null })
1627
+ .updateAttributes('tableHeader', { verticalAlign: align !== null && align !== void 0 ? align : null })
1628
+ .run();
1629
+ }, setCellTextAlign: (align) => ({ chain }) => {
1630
+ return chain()
1631
+ .focus()
1632
+ .updateAttributes('tableCell', { textAlign: align !== null && align !== void 0 ? align : null })
1633
+ .updateAttributes('tableHeader', { textAlign: align !== null && align !== void 0 ? align : null })
1634
+ .run();
1635
+ }, setCellBackgroundColor: (color) => ({ chain }) => {
1636
+ // Set all cells to the same background color
1637
+ return chain()
1638
+ .focus()
1639
+ .updateAttributes('tableCell', { backgroundColor: color !== null && color !== void 0 ? color : null })
1640
+ .updateAttributes('tableHeader', { backgroundColor: color !== null && color !== void 0 ? color : null })
1641
+ .run();
1642
+ } });
1643
+ },
1644
+ addNodeView() {
1645
+ const options = this.options;
1646
+ if (options.enableNodeView === false) {
1647
+ return null;
1648
+ }
1649
+ // Only use custom NodeView (with handle) when editor is editable
1650
+ return ((props) => {
1651
+ // Check if editor is editable
1652
+ if (!props.editor.isEditable) {
1653
+ // Return undefined to use default table rendering (no widget wrapper)
1654
+ return undefined;
1655
+ }
1656
+ // Editor is editable → use custom NodeView with handle
1657
+ return createTableNodeView(props);
1658
+ });
1659
+ },
1660
+ });
1661
+ // Convenience export for consumers who want the full set
1662
+ const StyledTableKit = Extension.create({
1663
+ name: 'styledTableKit',
1664
+ addExtensions() {
1665
+ var _a;
1666
+ const extensions = [];
1667
+ if (this.options.table !== false) {
1668
+ extensions.push(StyledTable.configure(Object.assign(Object.assign({}, this.options.table), { resizable: false })));
1669
+ }
1670
+ if (this.options.tableRow !== false) {
1671
+ extensions.push(TableRow.configure(this.options.tableRow));
1672
+ }
1673
+ if (this.options.tableHeader !== false) {
1674
+ extensions.push(StyledTableHeader.configure(this.options.tableHeader));
1675
+ }
1676
+ if (this.options.tableCell !== false) {
1677
+ extensions.push(StyledTableCell.configure(this.options.tableCell));
1678
+ }
1679
+ if (((_a = this.options.table) === null || _a === void 0 ? void 0 : _a.resizable) !== false) {
1680
+ extensions.push(PercentageColumnResizing);
1681
+ }
1682
+ return extensions;
1683
+ },
1684
+ });
1685
+
1686
+ const TextCase = Extension.create({
1687
+ name: 'textCase',
1688
+ addOptions() {
1689
+ return {
1690
+ type: 'uppercase',
1691
+ };
1692
+ },
1693
+ addCommands() {
1694
+ return {
1695
+ textCase: (type) => ({ tr, state }) => {
1696
+ const { $from, $to, empty } = tr.selection;
1697
+ // If selection is empty, return false
1698
+ if (empty)
1699
+ return false;
1700
+ // If selection is not empty, get the text of the selection
1701
+ const text = state.doc.textBetween($from.pos, $to.pos, '\n', '\n');
1702
+ // If text is empty, return false
1703
+ if (text.length === 0)
1704
+ return false;
1705
+ let newText = text;
1706
+ // Change the text case
1707
+ switch (type) {
1708
+ case 'uppercase':
1709
+ newText = text.toUpperCase();
1710
+ break;
1711
+ case 'lowercase':
1712
+ newText = text.toLowerCase();
1713
+ break;
1714
+ case 'capitalize':
1715
+ newText = text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
1716
+ break;
1717
+ }
1718
+ tr.replaceWith($from.pos, $to.pos, state.schema.text(newText));
1719
+ tr.setSelection(TextSelection.create(tr.doc, $from.pos, $from.pos + newText.length));
1720
+ return true;
1721
+ },
1722
+ };
1723
+ },
1724
+ });
1725
+
1726
+ /**
1727
+ * Dynamic Field Node Extension
1728
+ *
1729
+ * This extension creates atomic, non-editable inline nodes that represent
1730
+ * dynamic fields (e.g., {{customer_name}}) in document templates.
1731
+ * These fields can be programmatically replaced with actual data later.
1732
+ */
1733
+ const DynamicField = Node.create({
1734
+ name: 'dynamicField',
1735
+ /**
1736
+ * Node group configuration
1737
+ * - 'inline': Makes the node behave as inline content
1738
+ * - 'atom': true is critical for non-editable, atomic behavior
1739
+ * - 'selectable': true allows users to select the entire node
1740
+ * - 'draggable': false prevents users from dragging the node
1741
+ */
1742
+ group: 'inline',
1743
+ inline: true,
1744
+ atom: true,
1745
+ selectable: true,
1746
+ draggable: false,
1747
+ /**
1748
+ * Define the attributes that this node can store
1749
+ */
1750
+ addAttributes() {
1751
+ return {
1752
+ fieldId: {
1753
+ default: null,
1754
+ parseHTML: (element) => element.getAttribute('data-field-id'),
1755
+ renderHTML: (attributes) => {
1756
+ if (!attributes['fieldId'])
1757
+ return {};
1758
+ return {
1759
+ 'data-field-id': attributes['fieldId'],
1760
+ };
1761
+ },
1762
+ },
1763
+ label: {
1764
+ default: null,
1765
+ parseHTML: (element) => element.getAttribute('data-label'),
1766
+ renderHTML: (attributes) => {
1767
+ if (!attributes['label'])
1768
+ return {};
1769
+ return {
1770
+ 'data-label': attributes['label'],
1771
+ };
1772
+ },
1773
+ },
1774
+ };
1775
+ },
1776
+ /**
1777
+ * Parse HTML to recognize dynamic field spans when loading/pasting content
1778
+ */
1779
+ parseHTML() {
1780
+ return [
1781
+ // ----------------------------------------------------------
1782
+ // RULE 1: Standard Tiptap Format (Ưu tiên cao nhất)
1783
+ // ----------------------------------------------------------
1784
+ { tag: 'span[data-field-id]' },
1785
+ // ----------------------------------------------------------
1786
+ // RULE 2: Legacy CKEditor Format (Migration Layer)
1787
+ // ----------------------------------------------------------
1788
+ {
1789
+ // Selector CSS để nhận diện node của CKEditor
1790
+ tag: 'span.red-dynamic-field',
1791
+ // Hàm này chạy khi Tiptap tìm thấy tag trên
1792
+ getAttrs: (node) => {
1793
+ if (typeof node === 'string')
1794
+ return false;
1795
+ const element = node;
1796
+ // 1. Trích xuất ID từ nội dung text: "{{ref}}" -> "ref"
1797
+ const rawText = element.innerText || element.textContent || '';
1798
+ // Regex để xóa dấu {{ và }} và khoảng trắng thừa
1799
+ const id = rawText.replace(/{{|}}/g, '').trim();
1800
+ // 2. Trích xuất Label từ attribute cũ
1801
+ // CKEditor dùng 'value', 'dynamicfieldname' hoặc 'name'
1802
+ const label = element.getAttribute('value') ||
1803
+ element.getAttribute('dynamicfieldname') ||
1804
+ element.getAttribute('name') ||
1805
+ id; // Fallback nếu không tìm thấy label
1806
+ // Trả về object khớp với cấu trúc 'addAttributes' ở trên
1807
+ return {
1808
+ fieldId: id,
1809
+ label: label,
1810
+ };
1811
+ },
1812
+ },
1813
+ ];
1814
+ },
1815
+ /**
1816
+ * Render the node as HTML
1817
+ * Creates a span element with Tailwind classes for styling
1818
+ * and stores attributes in data-* attributes for robustness
1819
+ */
1820
+ renderHTML({ HTMLAttributes, node }) {
1821
+ const { fieldId, label } = node.attrs;
1822
+ return [
1823
+ 'span',
1824
+ mergeAttributes(HTMLAttributes, {
1825
+ class: 'dynamic-field red-dynamic-field',
1826
+ 'data-field-id': fieldId,
1827
+ 'data-label': label,
1828
+ }),
1829
+ // label || '',
1830
+ fieldId ? `{{${fieldId}}}` : '',
1831
+ ];
1832
+ },
1833
+ /**
1834
+ * NODE VIEW: Đây là phần giúp hiển thị giao diện giống CKEditor Widget
1835
+ * Thay vì render ra text thuần, ta render ra một DOM element được style sẵn
1836
+ */
1837
+ addNodeView() {
1838
+ return ({ node }) => {
1839
+ // 1. Tạo thẻ bao bọc (Wrapper)
1840
+ const dom = document.createElement('span');
1841
+ // 2. Thêm class để style (Dùng Tailwind như techstack của bạn )
1842
+ // Bạn có thể thêm class 'ck-widget' nếu muốn dùng lại CSS cũ
1843
+ dom.classList.add('dynamic-field', 'red-dynamic-field');
1844
+ // 3. Gán dữ liệu label vào nội dung hiển thị
1845
+ const { label, fieldId } = node.attrs;
1846
+ dom.textContent = label || fieldId || 'Unknown Field';
1847
+ return { dom };
1848
+ };
1849
+ },
1850
+ /**
1851
+ * Add custom commands for inserting dynamic fields
1852
+ */
1853
+ addCommands() {
1854
+ return {
1855
+ insertDynamicField: (attributes) => ({ chain }) => {
1856
+ if (!attributes.fieldId || !attributes.label) {
1857
+ console.error('[DynamicField] Both fieldId and label are required.');
1858
+ return false;
1859
+ }
1860
+ return chain().insertContent({ type: this.name, attrs: attributes }).run();
1861
+ },
1862
+ };
1863
+ },
1864
+ /**
1865
+ * Default options
1866
+ */
1867
+ addOptions() {
1868
+ return {
1869
+ HTMLAttributes: {},
1870
+ };
1871
+ },
1872
+ });
1873
+
1874
+ const NotumHeading = Heading.extend({
1875
+ addCommands() {
1876
+ var _a;
1877
+ return Object.assign(Object.assign({}, (_a = this.parent) === null || _a === void 0 ? void 0 : _a.call(this)), { removeHeading: () => ({ editor, chain }) => {
1878
+ if (!editor.isActive('heading'))
1879
+ return false;
1880
+ return chain().setParagraph().run();
1881
+ } });
1882
+ },
1883
+ });
1884
+
1885
+ /**
1886
+ * `image-ref` — a semantic, URL-free reference to a media asset.
1887
+ *
1888
+ * Renders a `<figure data-block="image-ref" data-image-id="…">` (with an optional
1889
+ * `<figcaption>`), decoupling document content from the resolved image URL. The
1890
+ * consumer hydrates the figure by attribute (`data-image-id`) at render time —
1891
+ * the document itself never embeds a URL or any presentation. In the editor the
1892
+ * node shows a placeholder (it has no URL to display); resolving and showing the
1893
+ * real image is the consumer's / media-picker's job.
1894
+ */
1895
+ const ImageRef = Node.create({
1896
+ name: 'imageRef',
1897
+ group: 'block',
1898
+ atom: true,
1899
+ selectable: true,
1900
+ draggable: true,
1901
+ addOptions() {
1902
+ return {
1903
+ HTMLAttributes: {},
1904
+ };
1905
+ },
1906
+ addAttributes() {
1907
+ return {
1908
+ imageId: {
1909
+ default: null,
1910
+ parseHTML: (element) => element.getAttribute('data-image-id'),
1911
+ renderHTML: (attributes) => {
1912
+ if (!attributes['imageId'])
1913
+ return {};
1914
+ return { 'data-image-id': attributes['imageId'] };
1915
+ },
1916
+ },
1917
+ caption: {
1918
+ default: null,
1919
+ // Caption is rendered as <figcaption> text content (below), not a data
1920
+ // attribute — so parse it back from the figcaption child for round-trip.
1921
+ parseHTML: (element) => {
1922
+ var _a;
1923
+ const figcaption = element.querySelector(':scope > figcaption');
1924
+ return ((_a = figcaption === null || figcaption === void 0 ? void 0 : figcaption.textContent) === null || _a === void 0 ? void 0 : _a.trim()) || null;
1925
+ },
1926
+ renderHTML: () => ({}),
1927
+ },
1928
+ captionPosition: {
1929
+ default: null,
1930
+ // Normalize to the typed union: absent attribute stays `null` (preserves
1931
+ // the byte-identical round-trip), any present-but-unknown value coerces to
1932
+ // `'bottom'` so a non-`'top' | 'bottom'` string can never leak through.
1933
+ parseHTML: (element) => {
1934
+ const raw = element.getAttribute('data-caption-position');
1935
+ if (raw === null)
1936
+ return null;
1937
+ return raw === 'top' ? 'top' : 'bottom';
1938
+ },
1939
+ renderHTML: (attributes) => {
1940
+ if (!attributes['captionPosition'])
1941
+ return {};
1942
+ return { 'data-caption-position': attributes['captionPosition'] };
1943
+ },
1944
+ },
1945
+ };
1946
+ },
1947
+ parseHTML() {
1948
+ return [{ tag: 'figure[data-block="image-ref"]' }];
1949
+ },
1950
+ renderHTML({ HTMLAttributes, node }) {
1951
+ const { caption } = node.attrs;
1952
+ const figure = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
1953
+ 'data-block': 'image-ref',
1954
+ });
1955
+ // No <img> is emitted — the asset is URL-free. The caption order relative to
1956
+ // the (not-yet-resolved) image is carried by `data-caption-position` for the
1957
+ // consumer to honor at hydration time; the figure itself just holds the text.
1958
+ if (!caption) {
1959
+ return ['figure', figure];
1960
+ }
1961
+ const figcaption = ['figcaption', {}, caption];
1962
+ return ['figure', figure, figcaption];
1963
+ },
1964
+ /**
1965
+ * Editor-only placeholder. The DOM here is chrome (never serialized — that is
1966
+ * `renderHTML`'s job); it just shows which media the block references.
1967
+ */
1968
+ addNodeView() {
1969
+ return ({ node }) => {
1970
+ const { imageId, caption, captionPosition } = node.attrs;
1971
+ const dom = document.createElement('figure');
1972
+ dom.classList.add('image-ref-placeholder');
1973
+ dom.setAttribute('data-block', 'image-ref');
1974
+ if (imageId)
1975
+ dom.setAttribute('data-image-id', imageId);
1976
+ if (captionPosition)
1977
+ dom.setAttribute('data-caption-position', captionPosition);
1978
+ dom.style.cssText = 'margin:1rem 0;display:flex;flex-direction:column;gap:.5rem;';
1979
+ const box = document.createElement('div');
1980
+ box.style.cssText =
1981
+ 'display:flex;align-items:center;gap:.5rem;padding:1rem;border:1px dashed currentColor;' +
1982
+ 'border-radius:.5rem;opacity:.65;font-size:.875rem;justify-content:center;';
1983
+ box.textContent = `🖼 image-ref · id: ${imageId || '—'}`;
1984
+ const figcaption = document.createElement('figcaption');
1985
+ figcaption.style.cssText = 'font-size:.8125rem;opacity:.7;text-align:center;';
1986
+ if (caption)
1987
+ figcaption.textContent = caption;
1988
+ if (caption && captionPosition === 'top') {
1989
+ dom.append(figcaption, box);
1990
+ }
1991
+ else {
1992
+ dom.append(box);
1993
+ if (caption)
1994
+ dom.append(figcaption);
1995
+ }
1996
+ return { dom };
1997
+ };
1998
+ },
1999
+ addCommands() {
2000
+ return {
2001
+ insertImageRef: (attributes) => ({ chain }) => {
2002
+ if (!attributes.imageId) {
2003
+ console.error('[ImageRef] imageId is required.');
2004
+ return false;
2005
+ }
2006
+ return chain().insertContent({ type: this.name, attrs: attributes }).run();
2007
+ },
2008
+ };
2009
+ },
2010
+ });
2011
+
2012
+ const PageBreak = Node.create({
2013
+ name: 'pageBreak',
2014
+ group: 'block',
2015
+ atom: true,
2016
+ selectable: true,
2017
+ // addNodeView() {
2018
+ // return createPageBreakNodeView;
2019
+ // },
2020
+ parseHTML() {
2021
+ return [
2022
+ // ----------------------------------------------------------
2023
+ // RULE 1: Standard Tiptap Format (Ưu tiên cao nhất)
2024
+ // ----------------------------------------------------------
2025
+ { tag: 'div[data-page-break]' },
2026
+ // ----------------------------------------------------------
2027
+ // RULE 2: CKEditor Format (Ưu tiên thấp nhất)
2028
+ // ----------------------------------------------------------
2029
+ { tag: 'div.page-break' },
2030
+ ];
2031
+ },
2032
+ renderHTML({ HTMLAttributes }) {
2033
+ return [
2034
+ 'div',
2035
+ mergeAttributes(HTMLAttributes, {
2036
+ 'data-page-break': 'true',
2037
+ class: 'document-engine-page-break',
2038
+ style: 'break-before: page; page-break-before: always;',
2039
+ }),
2040
+ ['span', { class: 'document-engine-page-break-label' }, 'Page break'],
2041
+ ];
2042
+ },
2043
+ addCommands() {
2044
+ return {
2045
+ insertPageBreak: () => ({ chain }) => {
2046
+ return chain().insertContent({ type: this.name }).run();
2047
+ },
2048
+ };
2049
+ },
2050
+ });
2051
+
2052
+ /**
2053
+ * Canonical default extension list for headless (Node-safe) serialization.
2054
+ *
2055
+ * This is the schema half of the editor's runtime kit (`DocumentEngineKit` in
2056
+ * the Angular wrapper): every node, mark, and HTML-affecting attribute the
2057
+ * editor can persist, so {@link generateHTML} can turn stored document JSON back
2058
+ * into HTML without a browser.
2059
+ *
2060
+ * It deliberately omits editing-only plugins (history/undo-redo, drop/gap
2061
+ * cursors, placeholder, character count, list keymaps, trailing node). Those
2062
+ * shape the live editing experience but contribute nothing to serialized
2063
+ * output, and some attach view-level behavior that has no meaning headless.
2064
+ *
2065
+ * Only `renderHTML` is exercised during serialization — node views (which touch
2066
+ * the DOM, e.g. `DynamicField.addNodeView`) are never invoked here, so this list
2067
+ * is safe to import and run in Node.
2068
+ *
2069
+ * Callers needing a different schema (e.g. restricted-editing regions, or a
2070
+ * trimmed set) should pass their own array to {@link generateHTML} instead.
2071
+ */
2072
+ const defaultExtensions = [
2073
+ // Structure
2074
+ Document,
2075
+ Paragraph,
2076
+ Text,
2077
+ NotumHeading,
2078
+ HardBreak,
2079
+ HorizontalRule,
2080
+ Blockquote,
2081
+ CodeBlock,
2082
+ // Lists
2083
+ BulletList,
2084
+ ListItem,
2085
+ CustomOrderedList,
2086
+ // Marks
2087
+ Bold,
2088
+ Italic,
2089
+ Underline,
2090
+ Strike,
2091
+ Code,
2092
+ Link,
2093
+ Subscript,
2094
+ Superscript,
2095
+ TextStyleKit,
2096
+ // Block-level alignment (emits `text-align` only when explicitly set).
2097
+ // NOTE: `Indent` is intentionally excluded — its `renderHTML` emits an empty
2098
+ // `style=""` on every block even when unset, which pollutes otherwise-semantic
2099
+ // output. Consumers that persist indentation can pass a custom extension array.
2100
+ TextAlign.configure({ types: ['paragraph', 'heading'] }),
2101
+ // Content nodes
2102
+ Image,
2103
+ ImageRef,
2104
+ PageBreak,
2105
+ DynamicField,
2106
+ // Tables (table / row / header / cell nodes)
2107
+ StyledTableKit,
2108
+ ];
2109
+
2110
+ /**
2111
+ * Serialize a Tiptap/ProseMirror document (JSON) to an HTML string.
2112
+ *
2113
+ * Environment-aware and Node-safe. Tiptap ships two serializers and the wrong
2114
+ * one throws:
2115
+ * - `@tiptap/html` needs a real `document` (browser / jsdom).
2116
+ * - `@tiptap/html/server` is backed by happy-dom and runs headlessly in Node.
2117
+ *
2118
+ * The serializer is chosen at call time from whether a DOM is present, and
2119
+ * imported lazily — so the heavy happy-dom build is never pulled into a browser
2120
+ * editor bundle that only ever edits (and never serializes server-side), and
2121
+ * Node backends never touch the browser-only build. This is why the function is
2122
+ * async.
2123
+ *
2124
+ * @param doc The document as ProseMirror JSON (a `doc` node).
2125
+ * @param extensions Schema to serialize against. Defaults to
2126
+ * {@link defaultExtensions} (the editor's canonical kit). Pass
2127
+ * a custom array when your documents use a different schema.
2128
+ * @returns Semantic HTML. Presentation styling and sanitization (e.g. DOMPurify)
2129
+ * are the consumer's responsibility — this helper does neither.
2130
+ */
2131
+ function generateHTML(doc_1) {
2132
+ return __awaiter(this, arguments, void 0, function* (doc, extensions = defaultExtensions) {
2133
+ if (typeof document !== 'undefined') {
2134
+ // browser / jsdom: uses the ambient DOM
2135
+ const serializer = yield import('@tiptap/html');
2136
+ return serializer.generateHTML(doc, extensions);
2137
+ }
2138
+ // Node: happy-dom backed, no real DOM needed. The specifier is built at runtime
2139
+ // (not a static string literal) and flagged for webpack/vite so browser
2140
+ // bundlers (esbuild/webpack/vite) can't statically resolve it and therefore
2141
+ // never pull happy-dom — and its Node built-ins — into a client bundle. The
2142
+ // `typeof document` guard means this branch never executes in a browser anyway.
2143
+ const serverEntry = ['@tiptap', 'html', 'server'].join('/');
2144
+ const serializer = yield import(/* webpackIgnore: true */ /* @vite-ignore */ serverEntry);
2145
+ return serializer.generateHTML(doc, extensions);
2146
+ });
2147
+ }
2148
+
2149
+ /**
2150
+ * The current schema version. `EditorDocument`s authored by this build of the
2151
+ * editor carry this value, and {@link migrateDoc} upgrades older documents up to
2152
+ * it.
2153
+ *
2154
+ * Bump this by exactly one whenever a schema change requires transforming stored
2155
+ * content, and add the matching entry (keyed by the previous version) to
2156
+ * {@link docMigrations}.
2157
+ */
2158
+ const LATEST_SCHEMA_VERSION = 1;
2159
+ /**
2160
+ * Ordered migration steps, keyed by the source version they upgrade from.
2161
+ *
2162
+ * v0.1.0 ships an identity (empty) set: there is nothing to migrate yet, but the
2163
+ * registry, walker ({@link migrateDoc}), and version field exist so future
2164
+ * schema bumps are non-breaking. To add one, e.g. for the 1 -> 2 bump:
2165
+ *
2166
+ * ```ts
2167
+ * export const docMigrations: DocMigrationRegistry = {
2168
+ * 1: (doc) => ({ schemaVersion: 2, content: transformV1ToV2(doc.content) }),
2169
+ * };
2170
+ * ```
2171
+ *
2172
+ * Each entry must be pure (no mutation, no browser APIs).
2173
+ */
2174
+ const docMigrations = {};
2175
+
2176
+ /**
2177
+ * Upgrade a stored document to {@link LATEST_SCHEMA_VERSION} by applying each
2178
+ * registered migration in order.
2179
+ *
2180
+ * Pure and idempotent: a document already at the latest version is returned
2181
+ * unchanged (a re-stamped copy), so batch runs are safe to re-run. A document
2182
+ * with a missing/invalid `schemaVersion` is treated as version 1 (the earliest).
2183
+ *
2184
+ * @param doc The document to migrate.
2185
+ * @param migrations Registry to apply. Defaults to {@link docMigrations}; the
2186
+ * override exists for testing the walker in isolation.
2187
+ * @param latest Target version. Defaults to {@link LATEST_SCHEMA_VERSION}.
2188
+ * @returns A new `EditorDocument` at `latest`.
2189
+ */
2190
+ function migrateDoc(doc, migrations = docMigrations, latest = LATEST_SCHEMA_VERSION) {
2191
+ let current = Object.assign(Object.assign({}, doc), { schemaVersion: Number.isInteger(doc.schemaVersion) && doc.schemaVersion >= 1 ? doc.schemaVersion : 1 });
2192
+ while (current.schemaVersion < latest) {
2193
+ const migrate = migrations[current.schemaVersion];
2194
+ if (!migrate) {
2195
+ throw new Error(`[migrateDoc] No migration registered for schema version ${current.schemaVersion} ` +
2196
+ `(target ${latest}).`);
2197
+ }
2198
+ const next = migrate(current);
2199
+ if (next.schemaVersion <= current.schemaVersion) {
2200
+ throw new Error(`[migrateDoc] Migration from version ${current.schemaVersion} did not advance the ` +
2201
+ `version (got ${next.schemaVersion}); migrations must be strictly increasing.`);
2202
+ }
2203
+ current = next;
2204
+ }
2205
+ return current;
2206
+ }
2207
+
2208
+ class Color {
2209
+ constructor(value, name, type) {
2210
+ const normalizedColor = normalizeColor(value);
2211
+ if (!normalizedColor) {
2212
+ throw new Error('Invalid or unsupported color.');
2213
+ }
2214
+ this.value = normalizedColor;
2215
+ this.name = name !== null && name !== void 0 ? name : normalizedColor;
2216
+ this.type = type;
2217
+ Object.freeze(this);
2218
+ }
2219
+ static from(value, name, type) {
2220
+ return new Color(value, name, type);
2221
+ }
2222
+ static fromArray(colors) {
2223
+ return colors.map((color) => {
2224
+ if (typeof color === 'string') {
2225
+ return new Color(color);
2226
+ }
2227
+ return new Color(color.value, color.name, color.type);
2228
+ });
2229
+ }
2230
+ equals(color) {
2231
+ return this.value === color.value;
2232
+ }
2233
+ is(color) {
2234
+ return this.value === normalizeColor(color);
2235
+ }
2236
+ }
2237
+
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 };