@kerebron/extension-basic-editor 0.4.28 → 0.4.29
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/esm/ExtensionBaseKeymap.js +1 -0
- package/esm/ExtensionBaseKeymap.js.map +1 -0
- package/esm/ExtensionBasicCodeEditor.js +1 -0
- package/esm/ExtensionBasicCodeEditor.js.map +1 -0
- package/esm/ExtensionBasicEditor.js +1 -0
- package/esm/ExtensionBasicEditor.js.map +1 -0
- package/esm/ExtensionDropcursor.js +1 -0
- package/esm/ExtensionDropcursor.js.map +1 -0
- package/esm/ExtensionGapcursor.js +1 -0
- package/esm/ExtensionGapcursor.js.map +1 -0
- package/esm/ExtensionHistory.js +1 -0
- package/esm/ExtensionHistory.js.map +1 -0
- package/esm/ExtensionHtml.js +1 -0
- package/esm/ExtensionHtml.js.map +1 -0
- package/esm/ExtensionMediaUpload.js +1 -0
- package/esm/ExtensionMediaUpload.js.map +1 -0
- package/esm/ExtensionSelection.js +1 -0
- package/esm/ExtensionSelection.js.map +1 -0
- package/esm/ExtensionTextAlign.js +1 -0
- package/esm/ExtensionTextAlign.js.map +1 -0
- package/esm/MarkBookmark.js +1 -0
- package/esm/MarkBookmark.js.map +1 -0
- package/esm/MarkChange.js +1 -0
- package/esm/MarkChange.js.map +1 -0
- package/esm/MarkCode.js +1 -0
- package/esm/MarkCode.js.map +1 -0
- package/esm/MarkHighlight.js +1 -0
- package/esm/MarkHighlight.js.map +1 -0
- package/esm/MarkItalic.js +1 -0
- package/esm/MarkItalic.js.map +1 -0
- package/esm/MarkLink.js +1 -0
- package/esm/MarkLink.js.map +1 -0
- package/esm/MarkStrike.js +1 -0
- package/esm/MarkStrike.js.map +1 -0
- package/esm/MarkStrong.js +1 -0
- package/esm/MarkStrong.js.map +1 -0
- package/esm/MarkSubscript.js +1 -0
- package/esm/MarkSubscript.js.map +1 -0
- package/esm/MarkSuperscript.js +1 -0
- package/esm/MarkSuperscript.js.map +1 -0
- package/esm/MarkTextColor.js +1 -0
- package/esm/MarkTextColor.js.map +1 -0
- package/esm/MarkUnderline.js +1 -0
- package/esm/MarkUnderline.js.map +1 -0
- package/esm/NodeAside.js +1 -0
- package/esm/NodeAside.js.map +1 -0
- package/esm/NodeBlockquote.js +1 -0
- package/esm/NodeBlockquote.js.map +1 -0
- package/esm/NodeBookmark.js +1 -0
- package/esm/NodeBookmark.js.map +1 -0
- package/esm/NodeBulletList.js +1 -0
- package/esm/NodeBulletList.js.map +1 -0
- package/esm/NodeCodeBlock.js +1 -0
- package/esm/NodeCodeBlock.js.map +1 -0
- package/esm/NodeDefinitionDesc.js +1 -0
- package/esm/NodeDefinitionDesc.js.map +1 -0
- package/esm/NodeDefinitionList.js +1 -0
- package/esm/NodeDefinitionList.js.map +1 -0
- package/esm/NodeDefinitionTerm.js +1 -0
- package/esm/NodeDefinitionTerm.js.map +1 -0
- package/esm/NodeDocument.js +1 -0
- package/esm/NodeDocument.js.map +1 -0
- package/esm/NodeDocumentCode.js +1 -0
- package/esm/NodeDocumentCode.js.map +1 -0
- package/esm/NodeFrontmatter.js +1 -0
- package/esm/NodeFrontmatter.js.map +1 -0
- package/esm/NodeHardBreak.js +1 -0
- package/esm/NodeHardBreak.js.map +1 -0
- package/esm/NodeHeading.js +1 -0
- package/esm/NodeHeading.js.map +1 -0
- package/esm/NodeHorizontalRule.js +1 -0
- package/esm/NodeHorizontalRule.js.map +1 -0
- package/esm/NodeImage.js +1 -0
- package/esm/NodeImage.js.map +1 -0
- package/esm/NodeInlineShortCode.js +1 -0
- package/esm/NodeInlineShortCode.js.map +1 -0
- package/esm/NodeListItem.js +1 -0
- package/esm/NodeListItem.js.map +1 -0
- package/esm/NodeMath.js +1 -0
- package/esm/NodeMath.js.map +1 -0
- package/esm/NodeOrderedList.js +1 -0
- package/esm/NodeOrderedList.js.map +1 -0
- package/esm/NodeParagraph.js +1 -0
- package/esm/NodeParagraph.js.map +1 -0
- package/esm/NodeTaskItem.js +1 -0
- package/esm/NodeTaskItem.js.map +1 -0
- package/esm/NodeTaskList.js +1 -0
- package/esm/NodeTaskList.js.map +1 -0
- package/esm/NodeText.js +1 -0
- package/esm/NodeText.js.map +1 -0
- package/esm/NodeVideo.js +1 -0
- package/esm/NodeVideo.js.map +1 -0
- package/esm/remote-selection/ExtensionRemoteSelection.js +1 -0
- package/esm/remote-selection/ExtensionRemoteSelection.js.map +1 -0
- package/esm/remote-selection/remoteSelectionPlugin.js +1 -0
- package/esm/remote-selection/remoteSelectionPlugin.js.map +1 -0
- package/package.json +6 -2
- package/src/ExtensionBaseKeymap.ts +64 -0
- package/src/ExtensionBasicCodeEditor.ts +82 -0
- package/src/ExtensionBasicEditor.ts +97 -0
- package/src/ExtensionDropcursor.ts +221 -0
- package/src/ExtensionGapcursor.ts +278 -0
- package/src/ExtensionHistory.ts +48 -0
- package/src/ExtensionHtml.ts +158 -0
- package/src/ExtensionMediaUpload.ts +258 -0
- package/src/ExtensionSelection.ts +379 -0
- package/src/ExtensionTextAlign.ts +50 -0
- package/src/MarkBookmark.ts +20 -0
- package/src/MarkChange.ts +17 -0
- package/src/MarkCode.ts +35 -0
- package/src/MarkHighlight.ts +38 -0
- package/src/MarkItalic.ts +41 -0
- package/src/MarkLink.ts +32 -0
- package/src/MarkStrike.ts +38 -0
- package/src/MarkStrong.ts +52 -0
- package/src/MarkSubscript.ts +42 -0
- package/src/MarkSuperscript.ts +42 -0
- package/src/MarkTextColor.ts +29 -0
- package/src/MarkUnderline.ts +47 -0
- package/src/NodeAside.ts +19 -0
- package/src/NodeBlockquote.ts +51 -0
- package/src/NodeBookmark.ts +23 -0
- package/src/NodeBulletList.ts +51 -0
- package/src/NodeCodeBlock.ts +60 -0
- package/src/NodeDefinitionDesc.ts +19 -0
- package/src/NodeDefinitionList.ts +46 -0
- package/src/NodeDefinitionTerm.ts +19 -0
- package/src/NodeDocument.ts +22 -0
- package/src/NodeDocumentCode.ts +33 -0
- package/src/NodeFrontmatter.ts +19 -0
- package/src/NodeHardBreak.ts +92 -0
- package/src/NodeHeading.ts +76 -0
- package/src/NodeHorizontalRule.ts +43 -0
- package/src/NodeImage.ts +36 -0
- package/src/NodeInlineShortCode.ts +55 -0
- package/src/NodeListItem.ts +320 -0
- package/src/NodeMath.ts +109 -0
- package/src/NodeOrderedList.ts +79 -0
- package/src/NodeParagraph.ts +60 -0
- package/src/NodeTaskItem.ts +190 -0
- package/src/NodeTaskList.ts +38 -0
- package/src/NodeText.ts +12 -0
- package/src/NodeVideo.ts +44 -0
- package/src/remote-selection/ExtensionRemoteSelection.ts +45 -0
- package/src/remote-selection/remoteSelectionPlugin.ts +157 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EditorState,
|
|
3
|
+
NodeSelection,
|
|
4
|
+
Plugin,
|
|
5
|
+
Selection,
|
|
6
|
+
TextSelection,
|
|
7
|
+
} from 'prosemirror-state';
|
|
8
|
+
import { Fragment, Node, ResolvedPos, Slice } from 'prosemirror-model';
|
|
9
|
+
import { Mappable } from 'prosemirror-transform';
|
|
10
|
+
import { Decoration, DecorationSet, EditorView } from 'prosemirror-view';
|
|
11
|
+
|
|
12
|
+
import type { Command } from '@kerebron/editor/commands';
|
|
13
|
+
import { Extension } from '@kerebron/editor';
|
|
14
|
+
import { keydownHandler } from '@kerebron/editor/plugins/keymap';
|
|
15
|
+
|
|
16
|
+
/// Gap cursor selections are represented using this class. Its
|
|
17
|
+
/// `$anchor` and `$head` properties both point at the cursor position.
|
|
18
|
+
export class GapCursor extends Selection {
|
|
19
|
+
/// Create a gap cursor.
|
|
20
|
+
constructor($pos: ResolvedPos) {
|
|
21
|
+
super($pos, $pos);
|
|
22
|
+
this.visible = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
map(doc: Node, mapping: Mappable): Selection {
|
|
26
|
+
let $pos = doc.resolve(mapping.map(this.head));
|
|
27
|
+
return GapCursor.valid($pos) ? new GapCursor($pos) : Selection.near($pos);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override content() {
|
|
31
|
+
return Slice.empty;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
eq(other: Selection): boolean {
|
|
35
|
+
return other instanceof GapCursor && other.head == this.head;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
toJSON(): any {
|
|
39
|
+
return { type: 'gapcursor', pos: this.head };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// @internal
|
|
43
|
+
static fromJSONToGapCursor(doc: Node, json: any): GapCursor {
|
|
44
|
+
if (typeof json.pos != 'number') {
|
|
45
|
+
throw new RangeError('Invalid input for GapCursor.fromJSON');
|
|
46
|
+
}
|
|
47
|
+
return new GapCursor(doc.resolve(json.pos));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// @internal
|
|
51
|
+
override getBookmark(): GapBookmark {
|
|
52
|
+
return new GapBookmark(this.anchor);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// @internal
|
|
56
|
+
static valid($pos: ResolvedPos) {
|
|
57
|
+
let parent = $pos.parent;
|
|
58
|
+
if (parent.isTextblock || !closedBefore($pos) || !closedAfter($pos)) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
let override = parent.type.spec.allowGapCursor;
|
|
62
|
+
if (override != null) return override;
|
|
63
|
+
let deflt = parent.contentMatchAt($pos.index()).defaultType;
|
|
64
|
+
return deflt && deflt.isTextblock;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// @internal
|
|
68
|
+
static findGapCursorFrom($pos: ResolvedPos, dir: number, mustMove = false) {
|
|
69
|
+
search: for (;;) {
|
|
70
|
+
if (!mustMove && GapCursor.valid($pos)) return $pos;
|
|
71
|
+
let pos = $pos.pos, next = null;
|
|
72
|
+
// Scan up from this position
|
|
73
|
+
for (let d = $pos.depth;; d--) {
|
|
74
|
+
let parent = $pos.node(d);
|
|
75
|
+
if (
|
|
76
|
+
dir > 0 ? $pos.indexAfter(d) < parent.childCount : $pos.index(d) > 0
|
|
77
|
+
) {
|
|
78
|
+
next = parent.child(dir > 0 ? $pos.indexAfter(d) : $pos.index(d) - 1);
|
|
79
|
+
break;
|
|
80
|
+
} else if (d == 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
pos += dir;
|
|
84
|
+
let $cur = $pos.doc.resolve(pos);
|
|
85
|
+
if (GapCursor.valid($cur)) return $cur;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// And then down into the next node
|
|
89
|
+
for (;;) {
|
|
90
|
+
let inside: Node | null = dir > 0 ? next.firstChild : next.lastChild;
|
|
91
|
+
if (!inside) {
|
|
92
|
+
if (
|
|
93
|
+
next.isAtom && !next.isText && !NodeSelection.isSelectable(next)
|
|
94
|
+
) {
|
|
95
|
+
$pos = $pos.doc.resolve(pos + next.nodeSize * dir);
|
|
96
|
+
mustMove = false;
|
|
97
|
+
continue search;
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
next = inside;
|
|
102
|
+
pos += dir;
|
|
103
|
+
let $cur = $pos.doc.resolve(pos);
|
|
104
|
+
if (GapCursor.valid($cur)) return $cur;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
Selection.jsonID('gapcursor', GapCursor);
|
|
113
|
+
|
|
114
|
+
class GapBookmark {
|
|
115
|
+
constructor(readonly pos: number) {}
|
|
116
|
+
|
|
117
|
+
map(mapping: Mappable) {
|
|
118
|
+
return new GapBookmark(mapping.map(this.pos));
|
|
119
|
+
}
|
|
120
|
+
resolve(doc: Node): Selection {
|
|
121
|
+
let $pos = doc.resolve(this.pos);
|
|
122
|
+
return GapCursor.valid($pos) ? new GapCursor($pos) : Selection.near($pos);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function closedBefore($pos: ResolvedPos) {
|
|
127
|
+
for (let d = $pos.depth; d >= 0; d--) {
|
|
128
|
+
let index = $pos.index(d), parent = $pos.node(d);
|
|
129
|
+
// At the start of this parent, look at next one
|
|
130
|
+
if (index == 0) {
|
|
131
|
+
if (parent.type.spec.isolating) return true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// See if the node before (or its first ancestor) is closed
|
|
135
|
+
for (let before = parent.child(index - 1);; before = before.lastChild!) {
|
|
136
|
+
if (
|
|
137
|
+
(before.childCount == 0 && !before.inlineContent) || before.isAtom ||
|
|
138
|
+
before.type.spec.isolating
|
|
139
|
+
) return true;
|
|
140
|
+
if (before.inlineContent) return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Hit start of document
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function closedAfter($pos: ResolvedPos) {
|
|
148
|
+
for (let d = $pos.depth; d >= 0; d--) {
|
|
149
|
+
let index = $pos.indexAfter(d), parent = $pos.node(d);
|
|
150
|
+
if (index == parent.childCount) {
|
|
151
|
+
if (parent.type.spec.isolating) return true;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
for (let after = parent.child(index);; after = after.firstChild!) {
|
|
155
|
+
if (
|
|
156
|
+
(after.childCount == 0 && !after.inlineContent) || after.isAtom ||
|
|
157
|
+
after.type.spec.isolating
|
|
158
|
+
) return true;
|
|
159
|
+
if (after.inlineContent) return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Create a gap cursor plugin. When enabled, this will capture clicks
|
|
166
|
+
/// near and arrow-key-motion past places that don't have a normally
|
|
167
|
+
/// selectable position nearby, and create a gap cursor selection for
|
|
168
|
+
/// them. The cursor is drawn as an element with class
|
|
169
|
+
/// `kb-gapcursor`. You can either include
|
|
170
|
+
/// `style/gapcursor.css` from the package's directory or add your own
|
|
171
|
+
/// styles to make it visible.
|
|
172
|
+
function gapCursor(): Plugin {
|
|
173
|
+
return new Plugin({
|
|
174
|
+
props: {
|
|
175
|
+
decorations: drawGapCursor,
|
|
176
|
+
|
|
177
|
+
createSelectionBetween(_view, $anchor, $head) {
|
|
178
|
+
return $anchor.pos == $head.pos && GapCursor.valid($head)
|
|
179
|
+
? new GapCursor($head)
|
|
180
|
+
: null;
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
handleClick,
|
|
184
|
+
handleKeyDown,
|
|
185
|
+
handleDOMEvents: { beforeinput: beforeinput as any },
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const handleKeyDown = keydownHandler({
|
|
191
|
+
'ArrowLeft': arrow('horiz', -1),
|
|
192
|
+
'ArrowRight': arrow('horiz', 1),
|
|
193
|
+
'ArrowUp': arrow('vert', -1),
|
|
194
|
+
'ArrowDown': arrow('vert', 1),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
function arrow(axis: 'vert' | 'horiz', dir: number): Command {
|
|
198
|
+
const dirStr = axis == 'vert'
|
|
199
|
+
? (dir > 0 ? 'down' : 'up')
|
|
200
|
+
: (dir > 0 ? 'right' : 'left');
|
|
201
|
+
return function (state, dispatch, view) {
|
|
202
|
+
let sel = state.selection;
|
|
203
|
+
let $start = dir > 0 ? sel.$to : sel.$from, mustMove = sel.empty;
|
|
204
|
+
if (sel instanceof TextSelection) {
|
|
205
|
+
if (!view!.endOfTextblock(dirStr) || $start.depth == 0) return false;
|
|
206
|
+
mustMove = false;
|
|
207
|
+
$start = state.doc.resolve(dir > 0 ? $start.after() : $start.before());
|
|
208
|
+
}
|
|
209
|
+
let $found = GapCursor.findGapCursorFrom($start, dir, mustMove);
|
|
210
|
+
if (!$found) return false;
|
|
211
|
+
if (dispatch) dispatch(state.tr.setSelection(new GapCursor($found)));
|
|
212
|
+
return true;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function handleClick(view: EditorView, pos: number, event: MouseEvent) {
|
|
217
|
+
if (!view || !view.editable) return false;
|
|
218
|
+
let $pos = view.state.doc.resolve(pos);
|
|
219
|
+
if (!GapCursor.valid($pos)) return false;
|
|
220
|
+
let clickPos = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
|
221
|
+
if (
|
|
222
|
+
clickPos && clickPos.inside > -1 &&
|
|
223
|
+
NodeSelection.isSelectable(view.state.doc.nodeAt(clickPos.inside)!)
|
|
224
|
+
) return false;
|
|
225
|
+
view.dispatch(view.state.tr.setSelection(new GapCursor($pos)));
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// This is a hack that, when a composition starts while a gap cursor
|
|
230
|
+
// is active, quickly creates an inline context for the composition to
|
|
231
|
+
// happen in, to avoid it being aborted by the DOM selection being
|
|
232
|
+
// moved into a valid position.
|
|
233
|
+
function beforeinput(view: EditorView, event: InputEvent) {
|
|
234
|
+
if (
|
|
235
|
+
event.inputType != 'insertCompositionText' ||
|
|
236
|
+
!(view.state.selection instanceof GapCursor)
|
|
237
|
+
) return false;
|
|
238
|
+
|
|
239
|
+
let { $from } = view.state.selection;
|
|
240
|
+
let insert = $from.parent.contentMatchAt($from.index()).findWrapping(
|
|
241
|
+
view.state.schema.nodes.text,
|
|
242
|
+
);
|
|
243
|
+
if (!insert) return false;
|
|
244
|
+
|
|
245
|
+
let frag = Fragment.empty;
|
|
246
|
+
for (let i = insert.length - 1; i >= 0; i--) {
|
|
247
|
+
frag = Fragment.from(insert[i].createAndFill(null, frag));
|
|
248
|
+
}
|
|
249
|
+
let tr = view.state.tr.replace($from.pos, $from.pos, new Slice(frag, 0, 0));
|
|
250
|
+
tr.setSelection(TextSelection.near(tr.doc.resolve($from.pos + 1)));
|
|
251
|
+
view.dispatch(tr);
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function drawGapCursor(state: EditorState) {
|
|
256
|
+
if (!(state.selection instanceof GapCursor)) return null;
|
|
257
|
+
let node = document.createElement('div');
|
|
258
|
+
node.className = 'kb-gapcursor';
|
|
259
|
+
return DecorationSet.create(state.doc, [
|
|
260
|
+
Decoration.widget(state.selection.head, node, { key: 'gapcursor' }),
|
|
261
|
+
]);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export class ExtensionGapcursor extends Extension {
|
|
265
|
+
name = 'gapcursor';
|
|
266
|
+
|
|
267
|
+
options = {
|
|
268
|
+
color: 'currentColor',
|
|
269
|
+
width: 1,
|
|
270
|
+
class: undefined,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
override getProseMirrorPlugins(): Plugin[] {
|
|
274
|
+
return [
|
|
275
|
+
gapCursor(),
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { history, redo, undo } from 'prosemirror-history';
|
|
2
|
+
import { Plugin } from 'prosemirror-state';
|
|
3
|
+
|
|
4
|
+
import { type CoreEditor, Extension } from '@kerebron/editor';
|
|
5
|
+
import {
|
|
6
|
+
type Command,
|
|
7
|
+
type CommandFactories,
|
|
8
|
+
type CommandShortcuts,
|
|
9
|
+
} from '@kerebron/editor/commands';
|
|
10
|
+
|
|
11
|
+
export class ExtensionHistory extends Extension {
|
|
12
|
+
name = 'history';
|
|
13
|
+
|
|
14
|
+
options = {
|
|
15
|
+
depth: 100,
|
|
16
|
+
newGroupDelay: 500,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
override getCommandFactories(editor: CoreEditor): Partial<CommandFactories> {
|
|
20
|
+
return {
|
|
21
|
+
'undo': () => undo,
|
|
22
|
+
'redo': () => redo,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
override getKeyboardShortcuts(): Partial<CommandShortcuts> {
|
|
27
|
+
// https://stackoverflow.com/a/73619128
|
|
28
|
+
const mac = typeof navigator != 'undefined'
|
|
29
|
+
? /Mac|iP(hone|[oa]d)/.test(navigator?.platform)
|
|
30
|
+
: false;
|
|
31
|
+
|
|
32
|
+
const shortcuts = {
|
|
33
|
+
'Mod-z': 'undo',
|
|
34
|
+
'Mod-y': 'redo',
|
|
35
|
+
};
|
|
36
|
+
if (!mac) {
|
|
37
|
+
shortcuts['Mod-y'] = 'redo';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return shortcuts;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
override getProseMirrorPlugins(): Plugin[] {
|
|
44
|
+
return [
|
|
45
|
+
history(this.options),
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DOMParser,
|
|
3
|
+
DOMSerializer,
|
|
4
|
+
Fragment,
|
|
5
|
+
Node,
|
|
6
|
+
type ParseOptions,
|
|
7
|
+
Schema,
|
|
8
|
+
} from 'prosemirror-model';
|
|
9
|
+
|
|
10
|
+
import { type Converter, type CoreEditor, Extension } from '@kerebron/editor';
|
|
11
|
+
|
|
12
|
+
export type CreateNodeFromContentOptions = {
|
|
13
|
+
parseOptions?: ParseOptions;
|
|
14
|
+
errorOnInvalidContent?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function getHTMLFromFragment(
|
|
18
|
+
fragment: Fragment,
|
|
19
|
+
schema: Schema,
|
|
20
|
+
): string {
|
|
21
|
+
const document = globalThis.document;
|
|
22
|
+
const documentFragment = DOMSerializer.fromSchema(schema).serializeFragment(
|
|
23
|
+
fragment,
|
|
24
|
+
{ document },
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const temporaryDocument = document.implementation.createHTMLDocument();
|
|
28
|
+
const container = temporaryDocument.createElement('div');
|
|
29
|
+
|
|
30
|
+
container.appendChild(documentFragment);
|
|
31
|
+
|
|
32
|
+
return container.innerHTML;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const removeWhitespaces = (node: HTMLElement) => {
|
|
36
|
+
const children = node.childNodes;
|
|
37
|
+
|
|
38
|
+
for (let i = children.length - 1; i >= 0; i -= 1) {
|
|
39
|
+
const child = children[i];
|
|
40
|
+
|
|
41
|
+
if (
|
|
42
|
+
child.nodeType === 3 && child.nodeValue &&
|
|
43
|
+
/^(\n\s\s|\n)$/.test(child.nodeValue)
|
|
44
|
+
) {
|
|
45
|
+
node.removeChild(child);
|
|
46
|
+
} else if (child.nodeType === 1) {
|
|
47
|
+
removeWhitespaces(child as HTMLElement);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return node;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function elementFromString(value: string): HTMLElement {
|
|
55
|
+
// add a wrapper to preserve leading and trailing whitespace
|
|
56
|
+
const wrappedValue = `<html lang="en"><body>${value}</body></html>`;
|
|
57
|
+
|
|
58
|
+
const body =
|
|
59
|
+
new globalThis.DOMParser().parseFromString(wrappedValue, 'text/html').body;
|
|
60
|
+
|
|
61
|
+
return removeWhitespaces(body);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function prepareContentCheckSchema(schema: Schema): Schema {
|
|
65
|
+
const contentCheckSchema = new Schema({
|
|
66
|
+
topNode: schema.spec.topNode,
|
|
67
|
+
marks: schema.spec.marks,
|
|
68
|
+
// Prosemirror's schemas are executed such that: the last to execute, matches last
|
|
69
|
+
// This means that we can add a catch-all node at the end of the schema to catch any content that we don't know how to handle
|
|
70
|
+
nodes: schema.spec.nodes.append({
|
|
71
|
+
__unknown__catch__all__node: {
|
|
72
|
+
content: 'inline*',
|
|
73
|
+
group: 'block',
|
|
74
|
+
parseDOM: [
|
|
75
|
+
{
|
|
76
|
+
tag: '*',
|
|
77
|
+
getAttrs: (e) => {
|
|
78
|
+
// Try to stringify the element for a more helpful error message
|
|
79
|
+
const invalidContent = typeof e === 'string' ? e : e.outerHTML;
|
|
80
|
+
throw new Error('Invalid HTML content', {
|
|
81
|
+
cause: new Error(`Invalid element found: ${invalidContent}`),
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return contentCheckSchema;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createNodeFromHTML(
|
|
94
|
+
content: string,
|
|
95
|
+
schema: Schema,
|
|
96
|
+
options?: CreateNodeFromContentOptions,
|
|
97
|
+
): Node {
|
|
98
|
+
options = {
|
|
99
|
+
parseOptions: {},
|
|
100
|
+
...options,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if (options.errorOnInvalidContent) {
|
|
104
|
+
const contentCheckSchema = prepareContentCheckSchema(schema);
|
|
105
|
+
DOMParser.fromSchema(contentCheckSchema).parse(
|
|
106
|
+
elementFromString(content),
|
|
107
|
+
options.parseOptions,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const parser = DOMParser.fromSchema(schema);
|
|
112
|
+
return parser.parse(elementFromString(content), options.parseOptions);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function createFragmentFromHTML(
|
|
116
|
+
content: string,
|
|
117
|
+
schema: Schema,
|
|
118
|
+
options?: CreateNodeFromContentOptions,
|
|
119
|
+
): Fragment {
|
|
120
|
+
options = {
|
|
121
|
+
parseOptions: {},
|
|
122
|
+
...options,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (options.errorOnInvalidContent) {
|
|
126
|
+
const contentCheckSchema = prepareContentCheckSchema(schema);
|
|
127
|
+
DOMParser.fromSchema(contentCheckSchema).parseSlice(
|
|
128
|
+
elementFromString(content),
|
|
129
|
+
options.parseOptions,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const parser = DOMParser.fromSchema(schema);
|
|
134
|
+
return parser.parseSlice(elementFromString(content), options.parseOptions)
|
|
135
|
+
.content;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class ExtensionHtml extends Extension {
|
|
139
|
+
name = 'html';
|
|
140
|
+
|
|
141
|
+
override getConverters(
|
|
142
|
+
editor: CoreEditor,
|
|
143
|
+
schema: Schema,
|
|
144
|
+
): Record<string, Converter> {
|
|
145
|
+
return {
|
|
146
|
+
'text/html': {
|
|
147
|
+
fromDoc: async (document: Node): Promise<Uint8Array> => {
|
|
148
|
+
const html = getHTMLFromFragment(document.content, editor.schema);
|
|
149
|
+
return new TextEncoder().encode(html);
|
|
150
|
+
},
|
|
151
|
+
toDoc: async (buffer: Uint8Array): Promise<Node> => {
|
|
152
|
+
const html = new TextDecoder().decode(buffer);
|
|
153
|
+
return createNodeFromHTML(html, editor.schema);
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|