@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,258 @@
|
|
|
1
|
+
import { Plugin, PluginKey } from 'prosemirror-state';
|
|
2
|
+
import { EditorView } from 'prosemirror-view';
|
|
3
|
+
|
|
4
|
+
import { Extension } from '@kerebron/editor';
|
|
5
|
+
|
|
6
|
+
export interface MediaUploadOptions {
|
|
7
|
+
/** Maximum file size in bytes (default: 10MB for images) */
|
|
8
|
+
maxFileSize?: number;
|
|
9
|
+
|
|
10
|
+
/** Maximum file size for videos (default: 50MB) */
|
|
11
|
+
maxVideoFileSize?: number;
|
|
12
|
+
|
|
13
|
+
/** Allowed image MIME types */
|
|
14
|
+
allowedImageTypes?: string[];
|
|
15
|
+
|
|
16
|
+
/** Allowed video MIME types */
|
|
17
|
+
allowedVideoTypes?: string[];
|
|
18
|
+
|
|
19
|
+
/** Use object URLs for videos instead of base64 (default: true) */
|
|
20
|
+
useObjectURLForVideos?: boolean;
|
|
21
|
+
|
|
22
|
+
/** Custom upload handler. Returns the URL of uploaded media. */
|
|
23
|
+
uploadHandler?: (file: File) => Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const mediaUploadKey = new PluginKey('mediaUpload');
|
|
27
|
+
|
|
28
|
+
/** Convert a File to a base64 data URL */
|
|
29
|
+
function fileToDataURL(file: File): Promise<string> {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const reader = new FileReader();
|
|
32
|
+
reader.onload = () => resolve(reader.result as string);
|
|
33
|
+
reader.onerror = reject;
|
|
34
|
+
reader.readAsDataURL(file);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Convert a File to an object URL (better for videos) */
|
|
39
|
+
function fileToObjectURL(file: File): string {
|
|
40
|
+
return URL.createObjectURL(file);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Check if a file is an image */
|
|
44
|
+
function isImage(file: File, allowedTypes: string[]): boolean {
|
|
45
|
+
return allowedTypes.some((type) => file.type.match(type));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Check if a file is a video */
|
|
49
|
+
function isVideo(file: File, allowedTypes: string[]): boolean {
|
|
50
|
+
return allowedTypes.some((type) => file.type.match(type));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Insert an image into the editor at the given position */
|
|
54
|
+
function insertImage(
|
|
55
|
+
view: EditorView,
|
|
56
|
+
pos: number,
|
|
57
|
+
src: string,
|
|
58
|
+
alt?: string,
|
|
59
|
+
title?: string,
|
|
60
|
+
) {
|
|
61
|
+
const { schema } = view.state;
|
|
62
|
+
const imageType = schema.nodes.image;
|
|
63
|
+
|
|
64
|
+
if (!imageType) {
|
|
65
|
+
console.warn('Image node type not found in schema');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const node = imageType.create({ src, alt, title });
|
|
70
|
+
const transaction = view.state.tr.insert(pos, node);
|
|
71
|
+
view.dispatch(transaction);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Insert a video into the editor at the given position */
|
|
75
|
+
function insertVideo(
|
|
76
|
+
view: EditorView,
|
|
77
|
+
pos: number,
|
|
78
|
+
src: string,
|
|
79
|
+
title?: string,
|
|
80
|
+
width?: string,
|
|
81
|
+
height?: string,
|
|
82
|
+
) {
|
|
83
|
+
const { schema } = view.state;
|
|
84
|
+
const videoType = schema.nodes.video;
|
|
85
|
+
|
|
86
|
+
if (!videoType) {
|
|
87
|
+
console.warn('Video node type not found in schema');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const node = videoType.create({ src, title, width, height, controls: true });
|
|
92
|
+
const transaction = view.state.tr.insert(pos, node);
|
|
93
|
+
view.dispatch(transaction);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Handle media files (images and videos) from drop or paste events */
|
|
97
|
+
async function handleMediaFiles(
|
|
98
|
+
view: EditorView,
|
|
99
|
+
files: File[],
|
|
100
|
+
pos: number,
|
|
101
|
+
options: MediaUploadOptions,
|
|
102
|
+
): Promise<void> {
|
|
103
|
+
const {
|
|
104
|
+
maxFileSize = 10 * 1024 * 1024, // 10MB for images
|
|
105
|
+
maxVideoFileSize = 50 * 1024 * 1024, // 50MB for videos
|
|
106
|
+
allowedImageTypes = ['^image/'],
|
|
107
|
+
allowedVideoTypes = ['^video/'],
|
|
108
|
+
uploadHandler,
|
|
109
|
+
} = options;
|
|
110
|
+
|
|
111
|
+
for (const file of files) {
|
|
112
|
+
const isImageFile = isImage(file, allowedImageTypes);
|
|
113
|
+
const isVideoFile = isVideo(file, allowedVideoTypes);
|
|
114
|
+
|
|
115
|
+
// Skip if not an image or video
|
|
116
|
+
if (!isImageFile && !isVideoFile) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check file size
|
|
121
|
+
const sizeLimit = isVideoFile ? maxVideoFileSize : maxFileSize;
|
|
122
|
+
if (file.size > sizeLimit) {
|
|
123
|
+
console.warn(
|
|
124
|
+
`${isVideoFile ? 'Video' : 'Image'} file "${file.name}" is too large (${
|
|
125
|
+
(file.size / 1024 / 1024).toFixed(2)
|
|
126
|
+
}MB). Maximum size is ${(sizeLimit / 1024 / 1024).toFixed(2)}MB.`,
|
|
127
|
+
);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
// Upload or convert to data URL
|
|
133
|
+
console.log(
|
|
134
|
+
`Processing ${isVideoFile ? 'video' : 'image'}: ${file.name} (${
|
|
135
|
+
(file.size / 1024 / 1024).toFixed(2)
|
|
136
|
+
}MB, ${file.type})`,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
let src: string;
|
|
140
|
+
if (uploadHandler) {
|
|
141
|
+
src = await uploadHandler(file);
|
|
142
|
+
} else if (isVideoFile && options.useObjectURLForVideos !== false) {
|
|
143
|
+
// Use object URL for videos (better performance, doesn't bloat the document)
|
|
144
|
+
src = fileToObjectURL(file);
|
|
145
|
+
console.log('Using object URL for video:', src);
|
|
146
|
+
} else {
|
|
147
|
+
// Use base64 data URL
|
|
148
|
+
src = await fileToDataURL(file);
|
|
149
|
+
console.log(
|
|
150
|
+
`${
|
|
151
|
+
isVideoFile ? 'Video' : 'Image'
|
|
152
|
+
} converted to data URL, length: ${src.length} characters`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Insert the media
|
|
157
|
+
if (isVideoFile) {
|
|
158
|
+
insertVideo(view, pos, src, file.name);
|
|
159
|
+
console.log('Video inserted into editor');
|
|
160
|
+
} else {
|
|
161
|
+
insertImage(view, pos, src, file.name);
|
|
162
|
+
console.log('Image inserted into editor');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Increment position for next media
|
|
166
|
+
pos += 1;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error(
|
|
169
|
+
`Failed to process ${isVideoFile ? 'video' : 'image'} "${file.name}":`,
|
|
170
|
+
error,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Create the media upload plugin */
|
|
177
|
+
function createMediaUploadPlugin(options: MediaUploadOptions = {}): Plugin {
|
|
178
|
+
return new Plugin({
|
|
179
|
+
key: mediaUploadKey,
|
|
180
|
+
|
|
181
|
+
props: {
|
|
182
|
+
/** Handle file drops */
|
|
183
|
+
handleDrop(view, event, slice, moved) {
|
|
184
|
+
// If content was moved from within the editor, let the default handler deal with it
|
|
185
|
+
if (moved) return false;
|
|
186
|
+
|
|
187
|
+
const files = Array.from(event.dataTransfer?.files || []);
|
|
188
|
+
if (files.length === 0) return false;
|
|
189
|
+
|
|
190
|
+
// Check if any files are images or videos
|
|
191
|
+
const {
|
|
192
|
+
allowedImageTypes = ['^image/'],
|
|
193
|
+
allowedVideoTypes = ['^video/'],
|
|
194
|
+
} = options;
|
|
195
|
+
const hasMedia = files.some((file) =>
|
|
196
|
+
isImage(file, allowedImageTypes) || isVideo(file, allowedVideoTypes)
|
|
197
|
+
);
|
|
198
|
+
if (!hasMedia) return false;
|
|
199
|
+
|
|
200
|
+
// Prevent default drop behavior
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
|
|
203
|
+
// Get drop position
|
|
204
|
+
const coords = { left: event.clientX, top: event.clientY };
|
|
205
|
+
const pos = view.posAtCoords(coords);
|
|
206
|
+
if (!pos) return false;
|
|
207
|
+
|
|
208
|
+
// Handle the media files
|
|
209
|
+
handleMediaFiles(view, files, pos.pos, options);
|
|
210
|
+
|
|
211
|
+
return true;
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
/** Handle paste events with images (videos typically don't paste) */
|
|
215
|
+
handlePaste(view, event, slice) {
|
|
216
|
+
const items = Array.from(event.clipboardData?.items || []);
|
|
217
|
+
const imageItems = items.filter((item) =>
|
|
218
|
+
item.type.startsWith('image/')
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
if (imageItems.length === 0) return false;
|
|
222
|
+
|
|
223
|
+
// Prevent default paste behavior
|
|
224
|
+
event.preventDefault();
|
|
225
|
+
|
|
226
|
+
// Convert clipboard items to files
|
|
227
|
+
const files: File[] = [];
|
|
228
|
+
for (const item of imageItems) {
|
|
229
|
+
const file = item.getAsFile();
|
|
230
|
+
if (file) files.push(file);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (files.length === 0) return false;
|
|
234
|
+
|
|
235
|
+
// Get current cursor position
|
|
236
|
+
const { from } = view.state.selection;
|
|
237
|
+
|
|
238
|
+
// Handle the image files
|
|
239
|
+
handleMediaFiles(view, files, from, options);
|
|
240
|
+
|
|
241
|
+
return true;
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Extension that adds media upload support via drag & drop and paste */
|
|
248
|
+
export class ExtensionMediaUpload extends Extension {
|
|
249
|
+
name = 'mediaUpload';
|
|
250
|
+
|
|
251
|
+
constructor(protected override config: Partial<MediaUploadOptions> = {}) {
|
|
252
|
+
super(config);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
override getProseMirrorPlugins(): Plugin[] {
|
|
256
|
+
return [createMediaUploadPlugin(this.config)];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Fragment,
|
|
3
|
+
Node,
|
|
4
|
+
NodeType,
|
|
5
|
+
ResolvedPos,
|
|
6
|
+
Slice,
|
|
7
|
+
} from 'prosemirror-model';
|
|
8
|
+
import {
|
|
9
|
+
AllSelection,
|
|
10
|
+
EditorState,
|
|
11
|
+
TextSelection,
|
|
12
|
+
Transaction,
|
|
13
|
+
} from 'prosemirror-state';
|
|
14
|
+
|
|
15
|
+
import { type CoreEditor, Extension } from '@kerebron/editor';
|
|
16
|
+
import type {
|
|
17
|
+
CommandFactories,
|
|
18
|
+
CommandFactory,
|
|
19
|
+
} from '@kerebron/editor/commands';
|
|
20
|
+
import { createNodeFromObject } from '@kerebron/editor/utilities';
|
|
21
|
+
import { EditorView } from 'prosemirror-view';
|
|
22
|
+
|
|
23
|
+
function normalizeSiblings(fragment: Fragment, $context: ResolvedPos) {
|
|
24
|
+
if (fragment.childCount < 2) return fragment;
|
|
25
|
+
for (let d = $context.depth; d >= 0; d--) {
|
|
26
|
+
let parent = $context.node(d);
|
|
27
|
+
let match = parent.contentMatchAt($context.index(d));
|
|
28
|
+
let lastWrap: readonly NodeType[] | undefined;
|
|
29
|
+
let result: Node[] | null = [];
|
|
30
|
+
fragment.forEach((node) => {
|
|
31
|
+
if (!result) return;
|
|
32
|
+
let wrap = match.findWrapping(node.type);
|
|
33
|
+
let inLast;
|
|
34
|
+
if (!wrap) return result = null;
|
|
35
|
+
if (
|
|
36
|
+
inLast = result.length && lastWrap!.length &&
|
|
37
|
+
addToSibling(wrap, lastWrap!, node, result[result.length - 1], 0)
|
|
38
|
+
) {
|
|
39
|
+
result[result.length - 1] = inLast;
|
|
40
|
+
} else {
|
|
41
|
+
if (result.length) {
|
|
42
|
+
result[result.length - 1] = closeRight(
|
|
43
|
+
result[result.length - 1],
|
|
44
|
+
lastWrap!.length,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
let wrapped = withWrappers(node, wrap);
|
|
48
|
+
result.push(wrapped);
|
|
49
|
+
match = match.matchType(wrapped.type)!;
|
|
50
|
+
lastWrap = wrap;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
if (result) return Fragment.from(result);
|
|
54
|
+
}
|
|
55
|
+
return fragment;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function withWrappers(node: Node, wrap: readonly NodeType[], from = 0) {
|
|
59
|
+
for (let i = wrap.length - 1; i >= from; i--) {
|
|
60
|
+
node = wrap[i].create(null, Fragment.from(node));
|
|
61
|
+
}
|
|
62
|
+
return node;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function addToSibling(
|
|
66
|
+
wrap: readonly NodeType[],
|
|
67
|
+
lastWrap: readonly NodeType[],
|
|
68
|
+
node: Node,
|
|
69
|
+
sibling: Node,
|
|
70
|
+
depth: number,
|
|
71
|
+
): Node | undefined {
|
|
72
|
+
if (
|
|
73
|
+
depth < wrap.length && depth < lastWrap.length &&
|
|
74
|
+
wrap[depth] == lastWrap[depth]
|
|
75
|
+
) {
|
|
76
|
+
let inner = addToSibling(
|
|
77
|
+
wrap,
|
|
78
|
+
lastWrap,
|
|
79
|
+
node,
|
|
80
|
+
sibling.lastChild!,
|
|
81
|
+
depth + 1,
|
|
82
|
+
);
|
|
83
|
+
if (inner) {
|
|
84
|
+
return sibling.copy(
|
|
85
|
+
sibling.content.replaceChild(sibling.childCount - 1, inner),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
let match = sibling.contentMatchAt(sibling.childCount);
|
|
89
|
+
if (
|
|
90
|
+
match.matchType(depth == wrap.length - 1 ? node.type : wrap[depth + 1])
|
|
91
|
+
) {
|
|
92
|
+
return sibling.copy(
|
|
93
|
+
sibling.content.append(
|
|
94
|
+
Fragment.from(withWrappers(node, wrap, depth + 1)),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function closeRight(node: Node, depth: number) {
|
|
102
|
+
if (depth == 0) return node;
|
|
103
|
+
let fragment = node.content.replaceChild(
|
|
104
|
+
node.childCount - 1,
|
|
105
|
+
closeRight(node.lastChild!, depth - 1),
|
|
106
|
+
);
|
|
107
|
+
let fill = node.contentMatchAt(node.childCount).fillBefore(
|
|
108
|
+
Fragment.empty,
|
|
109
|
+
true,
|
|
110
|
+
)!;
|
|
111
|
+
return node.copy(fragment.append(fill));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function closeRange(
|
|
115
|
+
fragment: Fragment,
|
|
116
|
+
side: number,
|
|
117
|
+
from: number,
|
|
118
|
+
to: number,
|
|
119
|
+
depth: number,
|
|
120
|
+
openEnd: number,
|
|
121
|
+
) {
|
|
122
|
+
let node = side < 0 ? fragment.firstChild! : fragment.lastChild!,
|
|
123
|
+
inner = node.content;
|
|
124
|
+
if (fragment.childCount > 1) openEnd = 0;
|
|
125
|
+
if (depth < to - 1) {
|
|
126
|
+
inner = closeRange(inner, side, from, to, depth + 1, openEnd);
|
|
127
|
+
}
|
|
128
|
+
if (depth >= from) {
|
|
129
|
+
inner = side < 0
|
|
130
|
+
? node.contentMatchAt(0)!.fillBefore(inner, openEnd <= depth)!.append(
|
|
131
|
+
inner,
|
|
132
|
+
)
|
|
133
|
+
: inner.append(
|
|
134
|
+
node.contentMatchAt(node.childCount)!.fillBefore(Fragment.empty, true)!,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return fragment.replaceChild(
|
|
138
|
+
side < 0 ? 0 : fragment.childCount - 1,
|
|
139
|
+
node.copy(inner),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function closeSlice(slice: Slice, openStart: number, openEnd: number) {
|
|
144
|
+
if (openStart < slice.openStart) {
|
|
145
|
+
slice = new Slice(
|
|
146
|
+
closeRange(
|
|
147
|
+
slice.content,
|
|
148
|
+
-1,
|
|
149
|
+
openStart,
|
|
150
|
+
slice.openStart,
|
|
151
|
+
0,
|
|
152
|
+
slice.openEnd,
|
|
153
|
+
),
|
|
154
|
+
openStart,
|
|
155
|
+
slice.openEnd,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
if (openEnd < slice.openEnd) {
|
|
159
|
+
slice = new Slice(
|
|
160
|
+
closeRange(slice.content, 1, openEnd, slice.openEnd, 0, 0),
|
|
161
|
+
slice.openStart,
|
|
162
|
+
openEnd,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return slice;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function sliceSingleNode(slice: Slice) {
|
|
169
|
+
return slice.openStart == 0 && slice.openEnd == 0 &&
|
|
170
|
+
slice.content.childCount == 1
|
|
171
|
+
? slice.content.firstChild
|
|
172
|
+
: null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function fixSlice(slice: Slice, $context: ResolvedPos): Slice {
|
|
176
|
+
slice = Slice.maxOpen(normalizeSiblings(slice.content, $context), true);
|
|
177
|
+
if (slice.openStart || slice.openEnd) {
|
|
178
|
+
let openStart = 0, openEnd = 0;
|
|
179
|
+
for (
|
|
180
|
+
let node = slice.content.firstChild;
|
|
181
|
+
openStart < slice.openStart && !node!.type.spec.isolating;
|
|
182
|
+
openStart++, node = node!.firstChild
|
|
183
|
+
// deno-lint-ignore no-empty
|
|
184
|
+
) {}
|
|
185
|
+
for (
|
|
186
|
+
let node = slice.content.lastChild;
|
|
187
|
+
openEnd < slice.openEnd && !node!.type.spec.isolating;
|
|
188
|
+
openEnd++, node = node!.lastChild
|
|
189
|
+
// deno-lint-ignore no-empty
|
|
190
|
+
) {}
|
|
191
|
+
slice = closeSlice(slice, openStart, openEnd);
|
|
192
|
+
}
|
|
193
|
+
return slice;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function sliceHasOnlyText(slice: Slice) {
|
|
197
|
+
return slice.content.content.every((node) => node.isInline);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const selectAll: CommandFactory = () => {
|
|
201
|
+
return function (
|
|
202
|
+
state: EditorState,
|
|
203
|
+
dispatch?: (tr: Transaction) => void,
|
|
204
|
+
view?: EditorView,
|
|
205
|
+
) {
|
|
206
|
+
const tr = state.tr.setSelection(new AllSelection(state.doc));
|
|
207
|
+
if (view) {
|
|
208
|
+
view.dispatch(tr);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return true;
|
|
212
|
+
};
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
function textPositionsToResolvedPos(
|
|
216
|
+
textPosVec: number[],
|
|
217
|
+
doc: Node,
|
|
218
|
+
paraNum: number,
|
|
219
|
+
): ResolvedPos[] {
|
|
220
|
+
const retVal = textPosVec.map((x) => -1);
|
|
221
|
+
|
|
222
|
+
let currentTextPos = 0;
|
|
223
|
+
let inParaRange = false;
|
|
224
|
+
|
|
225
|
+
function callback(
|
|
226
|
+
currentPos: number,
|
|
227
|
+
level: number,
|
|
228
|
+
idx: number,
|
|
229
|
+
textLen: number,
|
|
230
|
+
) {
|
|
231
|
+
if (!inParaRange) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (let i = 0; i < textPosVec.length; i++) {
|
|
236
|
+
const val = textPosVec[i];
|
|
237
|
+
if (val >= currentTextPos && val < currentTextPos + textLen) {
|
|
238
|
+
retVal[i] = currentPos + (val - currentTextPos);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
currentTextPos += textLen;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function treeTraverse(
|
|
246
|
+
node: Node,
|
|
247
|
+
level = 0,
|
|
248
|
+
idx = 0,
|
|
249
|
+
currentPos = 0,
|
|
250
|
+
) {
|
|
251
|
+
if (level === 1 && idx === paraNum) {
|
|
252
|
+
inParaRange = true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let textLen = 0;
|
|
256
|
+
if (node.isText && node.text) {
|
|
257
|
+
textLen = node.text?.length;
|
|
258
|
+
} else if (node.isLeaf) {
|
|
259
|
+
textLen = 1;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (textLen > 0) {
|
|
263
|
+
callback(currentPos, level, idx, textLen);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
node.forEach((child, offset, childIndex) => {
|
|
267
|
+
treeTraverse(child, level + 1, childIndex, currentPos + offset + 1);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
treeTraverse(doc);
|
|
272
|
+
|
|
273
|
+
if (inParaRange) {
|
|
274
|
+
for (let i = 0; i < textPosVec.length; i++) {
|
|
275
|
+
const val = textPosVec[i];
|
|
276
|
+
if (retVal[i] === -1) {
|
|
277
|
+
if (val < currentTextPos) {
|
|
278
|
+
retVal[i] = 1;
|
|
279
|
+
} else {
|
|
280
|
+
retVal[i] = doc.nodeSize - 1;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return retVal.map((x) => doc.resolve(x - 1));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const selectText: CommandFactory = (
|
|
290
|
+
textStart: number,
|
|
291
|
+
length: number,
|
|
292
|
+
paraNum = 0,
|
|
293
|
+
) => {
|
|
294
|
+
return function (
|
|
295
|
+
state: EditorState,
|
|
296
|
+
dispatch?: (tr: Transaction) => void,
|
|
297
|
+
view?: EditorView,
|
|
298
|
+
) {
|
|
299
|
+
const [$head, $anchor] = textPositionsToResolvedPos(
|
|
300
|
+
[textStart + length, textStart],
|
|
301
|
+
state.doc,
|
|
302
|
+
paraNum,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const tr = state.tr.setSelection(new TextSelection($anchor, $head));
|
|
306
|
+
if (view) {
|
|
307
|
+
view.dispatch(tr);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return true;
|
|
311
|
+
};
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
export class ExtensionSelection extends Extension {
|
|
315
|
+
name = 'selection';
|
|
316
|
+
|
|
317
|
+
extractSelection(): Node {
|
|
318
|
+
const state = this.editor.state;
|
|
319
|
+
const { from, to } = state.selection;
|
|
320
|
+
const slice = state.doc.slice(from, to);
|
|
321
|
+
|
|
322
|
+
if (sliceHasOnlyText(slice)) {
|
|
323
|
+
const para = state.schema.nodes.paragraph.create(null, slice.content);
|
|
324
|
+
return state.schema.topNodeType.createAndFill(null, [para])!;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return state.schema.topNodeType.createAndFill(null, slice.content)!;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
replaceSelection(otherDoc: Node) {
|
|
331
|
+
const preferPlain = false;
|
|
332
|
+
const view = this.editor.view;
|
|
333
|
+
const state = this.editor.state;
|
|
334
|
+
|
|
335
|
+
let slice: Slice;
|
|
336
|
+
|
|
337
|
+
if (otherDoc.type?.name === 'doc') {
|
|
338
|
+
otherDoc = createNodeFromObject(otherDoc.toJSON(), this.editor.schema);
|
|
339
|
+
}
|
|
340
|
+
slice = new Slice(otherDoc.content, 1, 1);
|
|
341
|
+
|
|
342
|
+
const $context = state.selection.$from;
|
|
343
|
+
|
|
344
|
+
slice = fixSlice(slice, $context);
|
|
345
|
+
|
|
346
|
+
let singleNode = sliceSingleNode(slice);
|
|
347
|
+
let tr = singleNode
|
|
348
|
+
? state.tr.replaceSelectionWith(singleNode, preferPlain)
|
|
349
|
+
: state.tr.replaceSelection(slice);
|
|
350
|
+
view.dispatch(tr.scrollIntoView());
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
appendSelection(otherDoc: Node) {
|
|
354
|
+
const view = this.editor.view;
|
|
355
|
+
const { state } = view;
|
|
356
|
+
|
|
357
|
+
let slice: Slice;
|
|
358
|
+
|
|
359
|
+
if (otherDoc.type?.name === 'doc') {
|
|
360
|
+
otherDoc = createNodeFromObject(otherDoc.toJSON(), this.editor.schema);
|
|
361
|
+
}
|
|
362
|
+
slice = new Slice(otherDoc.content, 1, 1);
|
|
363
|
+
|
|
364
|
+
const $context = view.state.selection.$from;
|
|
365
|
+
|
|
366
|
+
slice = fixSlice(slice, $context);
|
|
367
|
+
|
|
368
|
+
const tr = state.tr.insert(view.state.selection.to, slice.content);
|
|
369
|
+
view.dispatch(tr.scrollIntoView());
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
override getCommandFactories(editor: CoreEditor): Partial<CommandFactories> {
|
|
373
|
+
this.editor = editor;
|
|
374
|
+
return {
|
|
375
|
+
'selectAll': () => selectAll(),
|
|
376
|
+
'selectText': (...args) => selectText(...args),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type CoreEditor, Extension } from '@kerebron/editor';
|
|
2
|
+
import { type EditorState, type Transaction } from 'prosemirror-state';
|
|
3
|
+
import { type CommandFactories } from '@kerebron/editor/commands';
|
|
4
|
+
|
|
5
|
+
type TextAlign = 'left' | 'center' | 'right' | 'justify';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extension that adds text alignment commands.
|
|
9
|
+
* Works by setting a 'textAlign' attribute on block nodes that support it.
|
|
10
|
+
*/
|
|
11
|
+
export class ExtensionTextAlign extends Extension {
|
|
12
|
+
name = 'textAlign';
|
|
13
|
+
|
|
14
|
+
override getCommandFactories(editor: CoreEditor): Partial<CommandFactories> {
|
|
15
|
+
const setAlignment = (align: TextAlign) => {
|
|
16
|
+
return (state: EditorState, dispatch?: (tr: Transaction) => void) => {
|
|
17
|
+
const { selection, tr } = state;
|
|
18
|
+
const { from, to } = selection;
|
|
19
|
+
|
|
20
|
+
let changed = false;
|
|
21
|
+
state.doc.nodesBetween(from, to, (node, pos) => {
|
|
22
|
+
// Check if this node type has a textAlign attribute
|
|
23
|
+
if (
|
|
24
|
+
node.isBlock &&
|
|
25
|
+
node.type.spec.attrs &&
|
|
26
|
+
'textAlign' in node.type.spec.attrs
|
|
27
|
+
) {
|
|
28
|
+
tr.setNodeMarkup(pos, undefined, {
|
|
29
|
+
...node.attrs,
|
|
30
|
+
textAlign: align,
|
|
31
|
+
});
|
|
32
|
+
changed = true;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (changed && dispatch) {
|
|
37
|
+
dispatch(tr);
|
|
38
|
+
}
|
|
39
|
+
return changed;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
setTextAlignLeft: () => setAlignment('left'),
|
|
45
|
+
setTextAlignCenter: () => setAlignment('center'),
|
|
46
|
+
setTextAlignRight: () => setAlignment('right'),
|
|
47
|
+
setTextAlignJustify: () => setAlignment('justify'),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { MarkSpec } from 'prosemirror-model';
|
|
2
|
+
|
|
3
|
+
import { Mark } from '@kerebron/editor';
|
|
4
|
+
|
|
5
|
+
export class MarkBookmark extends Mark {
|
|
6
|
+
override name = 'bookmark';
|
|
7
|
+
requires = ['doc'];
|
|
8
|
+
|
|
9
|
+
override getMarkSpec(): MarkSpec {
|
|
10
|
+
return {
|
|
11
|
+
attrs: {
|
|
12
|
+
id: {},
|
|
13
|
+
},
|
|
14
|
+
parseDOM: [],
|
|
15
|
+
toDOM(mark) {
|
|
16
|
+
return ['a', { id: mark.attrs.id }, 0];
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|