@phuong-tran-redoc/document-engine-core 0.1.1 → 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.
- package/index.d.ts +1 -0
- package/index.esm.js +2238 -0
- package/package.json +15 -6
- package/src/constants/indent.constant.js +0 -1
- package/src/constants/index.js +0 -1
- package/src/constants/table.constant.js +0 -1
- package/src/extensions/clear-content.extension.js +0 -9
- package/src/extensions/indent.extension.js +0 -59
- package/src/extensions/index.js +0 -8
- package/src/extensions/ordered-list.extension.js +0 -47
- package/src/extensions/reset-format.extension.js +0 -9
- package/src/extensions/reset-on-enter.extension.js +0 -37
- package/src/extensions/restricted-editing.extension.js +0 -376
- package/src/extensions/table-resizing.extension.js +0 -164
- package/src/extensions/table-style.extension.js +0 -481
- package/src/extensions/text-case.extension.js +0 -39
- package/src/index.js +0 -9
- package/src/kit/default-extensions.js +0 -86
- package/src/kit/generate-html.js +0 -40
- package/src/kit/index.js +0 -2
- package/src/migrations/doc-migrations.js +0 -24
- package/src/migrations/index.js +0 -3
- package/src/migrations/migrate-doc.js +0 -32
- package/src/migrations/types.js +0 -4
- package/src/models/color.model.js +0 -30
- package/src/models/index.js +0 -1
- package/src/nodes/dynamic-field.node.js +0 -141
- package/src/nodes/heading.node.js +0 -13
- package/src/nodes/image-ref.node.js +0 -138
- package/src/nodes/index.js +0 -4
- package/src/nodes/page-break.node.js +0 -52
- package/src/types/index.js +0 -2
- package/src/types/ordered-list.type.js +0 -2
- package/src/types/text-case.type.js +0 -1
- package/src/types/tiptap-html-server.d.js +0 -10
- package/src/utils/color.util.js +0 -31
- package/src/utils/dom.util.js +0 -33
- package/src/utils/index.js +0 -4
- package/src/utils/table.util.js +0 -95
- package/src/utils/text.util.js +0 -46
- package/src/views/block-handler.js +0 -311
- 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 };
|