@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phuong-tran-redoc/document-engine-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Realestatedoc (Redoc)",
|
|
@@ -8,10 +8,20 @@
|
|
|
8
8
|
"Duc Phuong (Jack) <tdp99.business@gmail.com>"
|
|
9
9
|
],
|
|
10
10
|
"description": "Framework-agnostic core library for Document Engine",
|
|
11
|
-
"main": "./
|
|
12
|
-
"
|
|
11
|
+
"main": "./index.esm.js",
|
|
12
|
+
"module": "./index.esm.js",
|
|
13
|
+
"types": "./index.d.ts",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"exports": {
|
|
16
|
+
"./package.json": "./package.json",
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./index.d.ts",
|
|
19
|
+
"default": "./index.esm.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
13
22
|
"files": [
|
|
14
|
-
"
|
|
23
|
+
"index.esm.js",
|
|
24
|
+
"index.d.ts",
|
|
15
25
|
"src/**/*.d.ts",
|
|
16
26
|
"README.md",
|
|
17
27
|
"LICENSE.md",
|
|
@@ -57,6 +67,5 @@
|
|
|
57
67
|
},
|
|
58
68
|
"peerDependencies": {
|
|
59
69
|
"lodash-es": "^4.17.10"
|
|
60
|
-
}
|
|
61
|
-
"module": "./src/index.js"
|
|
70
|
+
}
|
|
62
71
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const INDENT_DEFAULT = 40; // in pixels
|
package/src/constants/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from './indent.constant';
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const MIN_NEW_COL_WIDTH = 5.0;
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { Extension } from '@tiptap/core';
|
|
2
|
-
import { INDENT_DEFAULT } from '../constants';
|
|
3
|
-
export const Indent = Extension.create({
|
|
4
|
-
name: 'indent',
|
|
5
|
-
addOptions () {
|
|
6
|
-
return {
|
|
7
|
-
types: [
|
|
8
|
-
'paragraph',
|
|
9
|
-
'heading'
|
|
10
|
-
],
|
|
11
|
-
indent: INDENT_DEFAULT
|
|
12
|
-
};
|
|
13
|
-
},
|
|
14
|
-
addGlobalAttributes () {
|
|
15
|
-
return [
|
|
16
|
-
{
|
|
17
|
-
types: this.options.types,
|
|
18
|
-
attributes: {
|
|
19
|
-
indent: {
|
|
20
|
-
parseHTML: (element)=>{
|
|
21
|
-
return Number(element.style.marginLeft.replace('px', ''));
|
|
22
|
-
},
|
|
23
|
-
renderHTML: (attributes)=>{
|
|
24
|
-
return {
|
|
25
|
-
style: `margin-left: ${attributes['indent']}px`
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
];
|
|
32
|
-
},
|
|
33
|
-
addCommands () {
|
|
34
|
-
return {
|
|
35
|
-
increaseIndent: ()=>({ state, chain })=>{
|
|
36
|
-
const currentNode = state.selection.$head.parent;
|
|
37
|
-
const nodeType = currentNode.type;
|
|
38
|
-
if (!this.options.types.includes(nodeType.name)) return false;
|
|
39
|
-
const currentIndent = currentNode.attrs['indent'] || 0;
|
|
40
|
-
const newIndent = currentIndent + this.options.indent;
|
|
41
|
-
return chain().updateAttributes(nodeType, {
|
|
42
|
-
indent: newIndent
|
|
43
|
-
}).run();
|
|
44
|
-
},
|
|
45
|
-
decreaseIndent: ()=>({ state, chain })=>{
|
|
46
|
-
const currentNode = state.selection.$head.parent;
|
|
47
|
-
const nodeType = currentNode.type;
|
|
48
|
-
if (!this.options.types.includes(nodeType.name)) return false;
|
|
49
|
-
const currentIndent = currentNode.attrs['indent'] || 0;
|
|
50
|
-
const newIndent = currentIndent - this.options.indent;
|
|
51
|
-
if (newIndent < 0) return false;
|
|
52
|
-
if (newIndent === 0) return chain().resetAttributes(nodeType, 'indent').run();
|
|
53
|
-
return chain().updateAttributes(nodeType, {
|
|
54
|
-
indent: newIndent
|
|
55
|
-
}).run();
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
});
|
package/src/extensions/index.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export * from './clear-content.extension';
|
|
2
|
-
export * from './indent.extension';
|
|
3
|
-
export * from './ordered-list.extension';
|
|
4
|
-
export * from './reset-format.extension';
|
|
5
|
-
export * from './reset-on-enter.extension';
|
|
6
|
-
export * from './restricted-editing.extension';
|
|
7
|
-
export * from './table-style.extension';
|
|
8
|
-
export * from './text-case.extension';
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import { OrderedList } from '@tiptap/extension-list';
|
|
2
|
-
export const CustomOrderedList = OrderedList.extend({
|
|
3
|
-
name: 'customOrderedList',
|
|
4
|
-
addOptions () {
|
|
5
|
-
return {
|
|
6
|
-
...this.parent?.(),
|
|
7
|
-
listStyleType: 'decimal'
|
|
8
|
-
};
|
|
9
|
-
},
|
|
10
|
-
addAttributes () {
|
|
11
|
-
return {
|
|
12
|
-
...this.parent?.(),
|
|
13
|
-
'data-list-style-type': {
|
|
14
|
-
default: this.options.listStyleType,
|
|
15
|
-
parseHTML: (element)=>{
|
|
16
|
-
// Priority 1: Parse from data-list-style-type attribute (our format)
|
|
17
|
-
const dataAttr = element.getAttribute('data-list-style-type');
|
|
18
|
-
if (dataAttr) return dataAttr;
|
|
19
|
-
// Priority 2: Parse from CSS list-style-type property (other editors' format)
|
|
20
|
-
const styleAttr = element.style.listStyleType;
|
|
21
|
-
if (styleAttr) return styleAttr;
|
|
22
|
-
// Default fallback
|
|
23
|
-
return this.options.listStyleType;
|
|
24
|
-
},
|
|
25
|
-
renderHTML: (attributes)=>{
|
|
26
|
-
const styleType = attributes['data-list-style-type'];
|
|
27
|
-
if (!styleType || styleType === 'decimal') return {
|
|
28
|
-
'data-list-style-type': 'decimal'
|
|
29
|
-
};
|
|
30
|
-
return {
|
|
31
|
-
'data-list-style-type': attributes['data-list-style-type']
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
},
|
|
37
|
-
addCommands () {
|
|
38
|
-
return {
|
|
39
|
-
...this.parent?.(),
|
|
40
|
-
setListStyle: (listStyleType)=>({ chain })=>{
|
|
41
|
-
return chain().focus().updateAttributes('customOrderedList', {
|
|
42
|
-
'data-list-style-type': listStyleType
|
|
43
|
-
}).run();
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
});
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { Extension } from '@tiptap/core';
|
|
2
|
-
/*
|
|
3
|
-
* Clear:
|
|
4
|
-
* - textStyle mark
|
|
5
|
-
* - indent attribute
|
|
6
|
-
*/ // Clears formats on Enter so the new line has default formats
|
|
7
|
-
export const ResetOnEnter = Extension.create({
|
|
8
|
-
name: 'resetOnEnter',
|
|
9
|
-
addKeyboardShortcuts () {
|
|
10
|
-
return {
|
|
11
|
-
Enter: ()=>{
|
|
12
|
-
const disabledNodes = [
|
|
13
|
-
'listItem',
|
|
14
|
-
'blockquote'
|
|
15
|
-
];
|
|
16
|
-
if (disabledNodes.some((type)=>this.editor.isActive(type))) {
|
|
17
|
-
return false; //Let the default Enter behavior proceed
|
|
18
|
-
}
|
|
19
|
-
// Handle Enter ourselves so we can both clear marks and reset indent on the new line
|
|
20
|
-
return this.editor.chain().focus().unsetAllMarks().splitBlock({
|
|
21
|
-
keepMarks: false
|
|
22
|
-
}).command(({ state, commands })=>{
|
|
23
|
-
const parent = state.selection.$head.parent;
|
|
24
|
-
const nodeType = parent.type;
|
|
25
|
-
const typesToReset = [
|
|
26
|
-
'paragraph',
|
|
27
|
-
'heading'
|
|
28
|
-
];
|
|
29
|
-
if (typesToReset.includes(nodeType.name)) {
|
|
30
|
-
return commands.resetAttributes(nodeType, 'indent');
|
|
31
|
-
}
|
|
32
|
-
return true;
|
|
33
|
-
}).run();
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
});
|
|
@@ -1,376 +0,0 @@
|
|
|
1
|
-
import { Extension, Node, mergeAttributes } from '@tiptap/core';
|
|
2
|
-
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
|
3
|
-
// Editable inline region where typing is allowed
|
|
4
|
-
export const EditableRegion = Node.create({
|
|
5
|
-
name: 'editableRegion',
|
|
6
|
-
group: 'inline',
|
|
7
|
-
inline: true,
|
|
8
|
-
content: 'text*',
|
|
9
|
-
selectable: false,
|
|
10
|
-
parseHTML () {
|
|
11
|
-
return [
|
|
12
|
-
// ----------------------------------------------------------
|
|
13
|
-
// RULE 1: Standard Tiptap Format (highest priority)
|
|
14
|
-
// ----------------------------------------------------------
|
|
15
|
-
{
|
|
16
|
-
tag: 'span[data-editable-region]'
|
|
17
|
-
},
|
|
18
|
-
// ----------------------------------------------------------
|
|
19
|
-
// RULE 2: CKEditor Format (lowest priority)
|
|
20
|
-
// ----------------------------------------------------------
|
|
21
|
-
{
|
|
22
|
-
tag: 'span.restricted-editing-exception'
|
|
23
|
-
}
|
|
24
|
-
];
|
|
25
|
-
},
|
|
26
|
-
renderHTML ({ HTMLAttributes }) {
|
|
27
|
-
return [
|
|
28
|
-
'span',
|
|
29
|
-
mergeAttributes(HTMLAttributes, {
|
|
30
|
-
'data-editable-region': 'true',
|
|
31
|
-
class: 'editable-region'
|
|
32
|
-
}),
|
|
33
|
-
0
|
|
34
|
-
];
|
|
35
|
-
}
|
|
36
|
-
});
|
|
37
|
-
export const RestrictedEditing = Extension.create({
|
|
38
|
-
name: 'restrictedEditing',
|
|
39
|
-
addOptions () {
|
|
40
|
-
return {
|
|
41
|
-
editableRegionNodeName: 'editableRegion',
|
|
42
|
-
disabledNodeTypes: [
|
|
43
|
-
'listItem',
|
|
44
|
-
'blockquote'
|
|
45
|
-
],
|
|
46
|
-
initialMode: 'restricted'
|
|
47
|
-
};
|
|
48
|
-
},
|
|
49
|
-
addStorage () {
|
|
50
|
-
return {
|
|
51
|
-
mode: this.options.initialMode
|
|
52
|
-
};
|
|
53
|
-
},
|
|
54
|
-
addCommands () {
|
|
55
|
-
return {
|
|
56
|
-
wrapSelectionInEditableRegion: ()=>({ state, dispatch })=>{
|
|
57
|
-
// 0. Always return false if the editable region node is not registered
|
|
58
|
-
const type = state.schema.nodes[this.options.editableRegionNodeName];
|
|
59
|
-
if (!type) return false;
|
|
60
|
-
const { from, to, empty } = state.selection;
|
|
61
|
-
// 1. Insert an empty editable region with a zero-width space
|
|
62
|
-
if (empty) {
|
|
63
|
-
const node = type.create(null, state.schema.text('\u200b'));
|
|
64
|
-
const tr = state.tr.replaceSelectionWith(node, false);
|
|
65
|
-
tr.setMeta('restrictedEditing', {
|
|
66
|
-
allow: true
|
|
67
|
-
});
|
|
68
|
-
dispatch?.(tr.scrollIntoView());
|
|
69
|
-
return true;
|
|
70
|
-
}
|
|
71
|
-
// Disallow wrapping when selection contains non-text inline nodes
|
|
72
|
-
let containsNonTextInline = false;
|
|
73
|
-
state.doc.nodesBetween(from, to, (node)=>{
|
|
74
|
-
if (node.isInline && !node.isText) {
|
|
75
|
-
containsNonTextInline = true;
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
return undefined;
|
|
79
|
-
});
|
|
80
|
-
if (containsNonTextInline) {
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
|
-
const multiLine = state.doc.textBetween(from, to, '\n', '\n').includes('\n');
|
|
84
|
-
let tr = state.tr;
|
|
85
|
-
if (multiLine) {
|
|
86
|
-
// Wrap each textblock segment independently
|
|
87
|
-
const ranges = [];
|
|
88
|
-
state.doc.nodesBetween(from, to, (node, pos)=>{
|
|
89
|
-
if (node.isTextblock) {
|
|
90
|
-
const s = Math.max(from, pos + 1);
|
|
91
|
-
const e = Math.min(to, pos + node.nodeSize - 1);
|
|
92
|
-
if (e > s) ranges.push({
|
|
93
|
-
s,
|
|
94
|
-
e
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
return undefined;
|
|
98
|
-
});
|
|
99
|
-
// Apply replacements from end to start to keep positions stable
|
|
100
|
-
for(let i = ranges.length - 1; i >= 0; i--){
|
|
101
|
-
const { s, e } = ranges[i];
|
|
102
|
-
const text = state.doc.textBetween(s, e, '\n', '\n');
|
|
103
|
-
if (text.length === 0) continue;
|
|
104
|
-
const node = type.create(null, state.schema.text(text));
|
|
105
|
-
tr = tr.replaceRangeWith(s, e, node);
|
|
106
|
-
}
|
|
107
|
-
} else {
|
|
108
|
-
const text = state.doc.textBetween(from, to, '\n', '\n');
|
|
109
|
-
if (text.length === 0) return false;
|
|
110
|
-
const node = type.create(null, state.schema.text(text));
|
|
111
|
-
tr = tr.replaceRangeWith(from, to, node);
|
|
112
|
-
}
|
|
113
|
-
tr.setMeta('restrictedEditing', {
|
|
114
|
-
allow: true
|
|
115
|
-
});
|
|
116
|
-
dispatch?.(tr.scrollIntoView());
|
|
117
|
-
return true;
|
|
118
|
-
},
|
|
119
|
-
removeEditableRegion: ()=>({ state, dispatch })=>{
|
|
120
|
-
const type = state.schema.nodes[this.options.editableRegionNodeName];
|
|
121
|
-
if (!type) return false;
|
|
122
|
-
const $pos = state.selection.$head;
|
|
123
|
-
for(let depth = $pos.depth; depth >= 0; depth--){
|
|
124
|
-
const node = $pos.node(depth);
|
|
125
|
-
if (node.type === type) {
|
|
126
|
-
const from = $pos.before(depth);
|
|
127
|
-
const to = $pos.after(depth);
|
|
128
|
-
const tr = state.tr.replaceWith(from, to, node.content);
|
|
129
|
-
tr.setMeta('restrictedEditing', {
|
|
130
|
-
allow: true
|
|
131
|
-
});
|
|
132
|
-
dispatch?.(tr.scrollIntoView());
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
return false;
|
|
137
|
-
},
|
|
138
|
-
toggleEditableRegion: ()=>({ state, dispatch, commands })=>{
|
|
139
|
-
const type = state.schema.nodes[this.options.editableRegionNodeName];
|
|
140
|
-
if (!type) return false;
|
|
141
|
-
const { from, to, empty } = state.selection;
|
|
142
|
-
const findRegionAtPos = (pos)=>{
|
|
143
|
-
const $pos = state.doc.resolve(pos);
|
|
144
|
-
for(let depth = $pos.depth; depth >= 0; depth--){
|
|
145
|
-
const node = $pos.node(depth);
|
|
146
|
-
if (node.type === type) {
|
|
147
|
-
const nodeFrom = $pos.before(depth);
|
|
148
|
-
const nodeTo = $pos.after(depth);
|
|
149
|
-
return {
|
|
150
|
-
from: nodeFrom,
|
|
151
|
-
to: nodeTo,
|
|
152
|
-
node
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
return null;
|
|
157
|
-
};
|
|
158
|
-
// Cursor case: inside a region -> unwrap that region; else insert empty region
|
|
159
|
-
if (empty) {
|
|
160
|
-
const region = findRegionAtPos(from);
|
|
161
|
-
if (region) {
|
|
162
|
-
const tr = state.tr.replaceWith(region.from, region.to, region.node.content);
|
|
163
|
-
tr.setMeta('restrictedEditing', {
|
|
164
|
-
allow: true
|
|
165
|
-
});
|
|
166
|
-
dispatch?.(tr.scrollIntoView());
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
const node = type.create(null, state.schema.text('\u200b'));
|
|
170
|
-
const tr = state.tr.replaceSelectionWith(node, false);
|
|
171
|
-
tr.setMeta('restrictedEditing', {
|
|
172
|
-
allow: true
|
|
173
|
-
});
|
|
174
|
-
dispatch?.(tr.scrollIntoView());
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
|
-
// Range case: unwrap only if the entire selection is inside the SAME region; otherwise
|
|
178
|
-
// if selection is completely outside all regions, wrap it; else ignore
|
|
179
|
-
const regionStart = findRegionAtPos(from);
|
|
180
|
-
const regionEnd = findRegionAtPos(Math.max(from, to - 1));
|
|
181
|
-
if (regionStart && regionEnd && regionStart.from === regionEnd.from && regionStart.to === regionEnd.to) {
|
|
182
|
-
const tr = state.tr.replaceWith(regionStart.from, regionStart.to, regionStart.node.content);
|
|
183
|
-
tr.setMeta('restrictedEditing', {
|
|
184
|
-
allow: true
|
|
185
|
-
});
|
|
186
|
-
dispatch?.(tr.scrollIntoView());
|
|
187
|
-
return true;
|
|
188
|
-
}
|
|
189
|
-
// If neither endpoint is in a region, ensure there is no region intersecting the range
|
|
190
|
-
if (!regionStart && !regionEnd) {
|
|
191
|
-
let hasRegionInside = false;
|
|
192
|
-
state.doc.nodesBetween(from, to, (node)=>{
|
|
193
|
-
if (node.type === type) {
|
|
194
|
-
hasRegionInside = true;
|
|
195
|
-
return false;
|
|
196
|
-
}
|
|
197
|
-
return undefined;
|
|
198
|
-
});
|
|
199
|
-
if (!hasRegionInside) {
|
|
200
|
-
return commands.wrapSelectionInEditableRegion();
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
return false;
|
|
204
|
-
}
|
|
205
|
-
};
|
|
206
|
-
},
|
|
207
|
-
addKeyboardShortcuts () {
|
|
208
|
-
return {
|
|
209
|
-
Enter: ()=>{
|
|
210
|
-
// Get the current selection
|
|
211
|
-
const selection = this.editor.state.selection;
|
|
212
|
-
const $from = selection.$from;
|
|
213
|
-
if (!selection.empty) return false;
|
|
214
|
-
let editableRegionInfo = null;
|
|
215
|
-
for(let d = $from.depth; d > 0; d--){
|
|
216
|
-
const node = $from.node(d);
|
|
217
|
-
if (node.type.name === 'editableRegion') {
|
|
218
|
-
editableRegionInfo = {
|
|
219
|
-
node: node,
|
|
220
|
-
depth: d,
|
|
221
|
-
// Position of the entire node (before the opening tag)
|
|
222
|
-
posBefore: $from.before(d),
|
|
223
|
-
// Position of the entire node (after the closing tag)
|
|
224
|
-
posAfter: $from.after(d),
|
|
225
|
-
// Position of the start of the content inside the node
|
|
226
|
-
contentStartPos: $from.start(d),
|
|
227
|
-
// Position of the end of the content inside the node
|
|
228
|
-
contentEndPos: $from.end(d)
|
|
229
|
-
};
|
|
230
|
-
break;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
if (!editableRegionInfo) return false;
|
|
234
|
-
const atStart = $from.pos === editableRegionInfo.contentStartPos;
|
|
235
|
-
const atEnd = $from.pos === editableRegionInfo.contentEndPos;
|
|
236
|
-
// If cursor is at the start of the editable region, add a new paragraph before the editable region
|
|
237
|
-
if (atStart) {
|
|
238
|
-
return this.editor.chain().insertContentAt(editableRegionInfo.posBefore, '<p></p>').focus(editableRegionInfo.posBefore - 2).run();
|
|
239
|
-
}
|
|
240
|
-
// If cursor is at the end of the editable region, add a new paragraph after the editable region
|
|
241
|
-
if (atEnd) {
|
|
242
|
-
return this.editor.chain().insertContentAt(editableRegionInfo.posAfter, '<p></p>').focus(editableRegionInfo.posAfter + 1).run();
|
|
243
|
-
}
|
|
244
|
-
// If cursor is in the middle of the editable region, return false
|
|
245
|
-
return false;
|
|
246
|
-
}
|
|
247
|
-
};
|
|
248
|
-
},
|
|
249
|
-
addProseMirrorPlugins () {
|
|
250
|
-
const key = new PluginKey('restricted-editing');
|
|
251
|
-
const editor = this.editor;
|
|
252
|
-
const editableRegionNodeName = this.options.editableRegionNodeName;
|
|
253
|
-
const disabledNodeTypes = this.options.disabledNodeTypes ?? [];
|
|
254
|
-
const getEditableRegionBounds = (doc, schema, pos)=>{
|
|
255
|
-
const $pos = doc.resolve(pos);
|
|
256
|
-
const type = schema.nodes[editableRegionNodeName];
|
|
257
|
-
for(let depth = $pos.depth; depth >= 0; depth--){
|
|
258
|
-
if ($pos.node(depth).type === type) {
|
|
259
|
-
return {
|
|
260
|
-
depth,
|
|
261
|
-
from: $pos.start(depth),
|
|
262
|
-
to: $pos.end(depth)
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
return null;
|
|
267
|
-
};
|
|
268
|
-
const rangesFromTransaction = (tr)=>{
|
|
269
|
-
const ranges = [];
|
|
270
|
-
for(let i = 0; i < tr.steps.length; i++){
|
|
271
|
-
const map = tr.mapping.maps[i];
|
|
272
|
-
const forward = tr.mapping.slice(i + 1);
|
|
273
|
-
map.forEach((oldStart, oldEnd, newStart, newEnd)=>{
|
|
274
|
-
const finalNewFrom = forward.map(newStart, 1);
|
|
275
|
-
const finalNewTo = forward.map(newEnd, -1);
|
|
276
|
-
ranges.push({
|
|
277
|
-
oldFrom: oldStart,
|
|
278
|
-
oldTo: oldEnd,
|
|
279
|
-
newFrom: finalNewFrom,
|
|
280
|
-
newTo: finalNewTo
|
|
281
|
-
});
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
return ranges;
|
|
285
|
-
};
|
|
286
|
-
return [
|
|
287
|
-
new Plugin({
|
|
288
|
-
key,
|
|
289
|
-
filterTransaction: (tr, state)=>{
|
|
290
|
-
// In standard mode, do not restrict edits
|
|
291
|
-
if (this.storage.mode === 'standard') return true;
|
|
292
|
-
if (!tr.docChanged) return true;
|
|
293
|
-
if (tr.getMeta('restrictedEditing')?.allow) return true;
|
|
294
|
-
// Allow while the document is still the default empty state
|
|
295
|
-
if (editor.isEmpty) return true;
|
|
296
|
-
// In restricted mode: disable mark/style/attr changes from other extensions entirely
|
|
297
|
-
const disallowedStepTypes = new Set([
|
|
298
|
-
'addMark',
|
|
299
|
-
'removeMark',
|
|
300
|
-
'setNodeMarkup'
|
|
301
|
-
]);
|
|
302
|
-
for (const step of tr.steps){
|
|
303
|
-
const t = step?.toJSON?.().stepType;
|
|
304
|
-
if (t && disallowedStepTypes.has(t)) {
|
|
305
|
-
return false;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
if (disabledNodeTypes.some((type)=>editor.isActive(type))) {
|
|
309
|
-
return true;
|
|
310
|
-
}
|
|
311
|
-
const ranges = rangesFromTransaction(tr);
|
|
312
|
-
if (ranges.length === 0) return true;
|
|
313
|
-
for (const range of ranges){
|
|
314
|
-
// Pre-change validation (against state.doc)
|
|
315
|
-
const preStart = getEditableRegionBounds(state.doc, state.schema, range.oldFrom);
|
|
316
|
-
const preEnd = getEditableRegionBounds(state.doc, state.schema, range.oldTo);
|
|
317
|
-
if (!preStart || !preEnd) return false;
|
|
318
|
-
if (preStart.from !== preEnd.from || preStart.to !== preEnd.to) return false;
|
|
319
|
-
// Post-change validation (against final tr.doc)
|
|
320
|
-
const postStart = getEditableRegionBounds(tr.doc, state.schema, range.newFrom);
|
|
321
|
-
const postEnd = getEditableRegionBounds(tr.doc, state.schema, range.newTo);
|
|
322
|
-
if (!postStart || !postEnd) return false;
|
|
323
|
-
if (postStart.from !== postEnd.from || postStart.to !== postEnd.to) return false;
|
|
324
|
-
}
|
|
325
|
-
return true;
|
|
326
|
-
},
|
|
327
|
-
appendTransaction: (_transactions, _oldState, newState)=>{
|
|
328
|
-
const type = newState.schema.nodes[editableRegionNodeName];
|
|
329
|
-
if (!type) return null;
|
|
330
|
-
const isRestricted = this.storage.mode === 'restricted';
|
|
331
|
-
const edits = [];
|
|
332
|
-
newState.doc.descendants((node, pos)=>{
|
|
333
|
-
if (node.type === type) {
|
|
334
|
-
const contentText = node.textContent ?? '';
|
|
335
|
-
if (contentText.length === 0) {
|
|
336
|
-
if (isRestricted) {
|
|
337
|
-
// Ensure a single space exists inside empty region
|
|
338
|
-
const innerFrom = pos + 1;
|
|
339
|
-
const innerTo = pos + node.nodeSize - 1;
|
|
340
|
-
edits.push({
|
|
341
|
-
kind: 'fill',
|
|
342
|
-
from: innerFrom,
|
|
343
|
-
to: innerTo
|
|
344
|
-
});
|
|
345
|
-
} else {
|
|
346
|
-
// Remove empty region
|
|
347
|
-
edits.push({
|
|
348
|
-
kind: 'delete',
|
|
349
|
-
from: pos,
|
|
350
|
-
to: pos + node.nodeSize
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
});
|
|
356
|
-
if (edits.length === 0) return null;
|
|
357
|
-
// Apply in descending order of from
|
|
358
|
-
edits.sort((a, b)=>b.from - a.from);
|
|
359
|
-
const tr = newState.tr;
|
|
360
|
-
for (const e of edits){
|
|
361
|
-
if (e.kind === 'delete') {
|
|
362
|
-
tr.delete(e.from, e.to);
|
|
363
|
-
} else {
|
|
364
|
-
const textNode = newState.schema.text(' ');
|
|
365
|
-
tr.replaceWith(e.from, e.to, textNode);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
tr.setMeta('restrictedEditing', {
|
|
369
|
-
allow: true
|
|
370
|
-
});
|
|
371
|
-
return tr;
|
|
372
|
-
}
|
|
373
|
-
})
|
|
374
|
-
];
|
|
375
|
-
}
|
|
376
|
-
});
|