Skip to content

Commit e44c1d9

Browse files
DavidMorganDavidMorgan
authored andcommitted
feat(core): align built-in extensions and delete pipeline
1 parent 84928df commit e44c1d9

16 files changed

+910
-20
lines changed

packages/core/src/Editor.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import { getCoreExtensions } from "./extensions";
1919
import { EventEmitter } from "./EventEmitter";
2020
import { ExtensionManager } from "./ExtensionManager";
2121
import { createDocumentFromContent } from "./helpers/content";
22+
import {
23+
getTextBetween,
24+
getTextSerializersFromSchema,
25+
} from "./helpers";
2226
import type {
2327
CanCommands,
2428
ChainedCommands,
@@ -109,6 +113,8 @@ export class Editor extends EventEmitter<EditorEventMap> {
109113
parseOptions: undefined,
110114
enableInputRules: true,
111115
enablePasteRules: true,
116+
coreExtensionOptions: {},
117+
enableCoreExtensions: true,
112118
enableExtensionDispatchTransaction: true,
113119
editorProps: {},
114120
onBeforeCreate: () => undefined,
@@ -121,13 +127,14 @@ export class Editor extends EventEmitter<EditorEventMap> {
121127
onBlur: () => undefined,
122128
onPaste: () => undefined,
123129
onDrop: () => undefined,
130+
onDelete: () => undefined,
124131
onDestroy: () => undefined,
125132
...options,
126133
};
127134

128135
this.extensionManager = new ExtensionManager(
129136
[
130-
...getCoreExtensions(),
137+
...getCoreExtensions(this.options),
131138
...this.options.extensions,
132139
],
133140
this,
@@ -322,14 +329,28 @@ export class Editor extends EventEmitter<EditorEventMap> {
322329
}
323330

324331
getText(options: EditorGetTextOptions = {}) {
325-
return this.state.doc.textBetween(
326-
0,
327-
this.state.doc.content.size,
328-
options.blockSeparator ?? "\n\n",
329-
(leafNode) =>
330-
options.textSerializers?.[leafNode.type.name]?.(leafNode)
331-
?? (leafNode.type.name === "hardBreak" ? "\n" : ""),
332+
const textSerializers = Object.fromEntries(
333+
Object.entries(options.textSerializers ?? {}).map(([name, serializer]) => [
334+
name,
335+
(props: Parameters<NonNullable<typeof serializer>>[0] extends never ? never : any) => (
336+
serializer.length <= 1
337+
? (serializer as (node: ProseMirrorNode) => string)(props.node)
338+
: serializer(props)
339+
),
340+
]),
332341
);
342+
343+
return getTextBetween(this.state.doc, {
344+
from: 0,
345+
to: this.state.doc.content.size,
346+
}, {
347+
blockSeparator: options.blockSeparator,
348+
textSerializers: {
349+
...getTextSerializersFromSchema(this.schema),
350+
...textSerializers,
351+
hardBreak: () => "\n",
352+
},
353+
});
333354
}
334355

335356
getHTML() {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
Plugin,
3+
PluginKey,
4+
} from "@mxm-editor/pm";
5+
import { Extension } from "../Extension";
6+
import {
7+
getTextBetween,
8+
getTextSerializersFromSchema,
9+
} from "../helpers";
10+
11+
export const ClipboardTextSerializer = Extension.create({
12+
name: "clipboardTextSerializer",
13+
14+
addProseMirrorPlugins() {
15+
return [
16+
new Plugin({
17+
key: new PluginKey("clipboardTextSerializer"),
18+
props: {
19+
clipboardTextSerializer: () => {
20+
const { editor } = this;
21+
const { state, schema } = editor;
22+
const { doc, selection } = state;
23+
const { ranges } = selection;
24+
const from = Math.min(...ranges.map((range) => range.$from.pos));
25+
const to = Math.max(...ranges.map((range) => range.$to.pos));
26+
const textSerializers = getTextSerializersFromSchema(schema);
27+
28+
return getTextBetween(doc, { from, to }, {
29+
...(this.editor.options.coreExtensionOptions?.clipboardTextSerializer?.blockSeparator
30+
!== undefined
31+
? {
32+
blockSeparator: this.editor.options.coreExtensionOptions
33+
.clipboardTextSerializer
34+
?.blockSeparator,
35+
}
36+
: {}),
37+
textSerializers,
38+
});
39+
},
40+
},
41+
}),
42+
];
43+
},
44+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { RemoveMarkStep } from "@mxm-editor/pm";
2+
import { Extension } from "../Extension";
3+
import {
4+
combineTransactionSteps,
5+
getChangedRanges,
6+
} from "../helpers";
7+
8+
export const Delete = Extension.create({
9+
name: "delete",
10+
11+
onUpdate({ transaction, appendedTransactions }) {
12+
const callback = () => {
13+
if (
14+
this.editor.options.coreExtensionOptions?.delete?.filterTransaction?.(transaction)
15+
?? transaction.getMeta("y-sync$")
16+
) {
17+
return;
18+
}
19+
20+
const combinedTransform = combineTransactionSteps(
21+
transaction.before,
22+
[transaction, ...appendedTransactions],
23+
);
24+
const changes = getChangedRanges(combinedTransform);
25+
26+
changes.forEach((change) => {
27+
if (
28+
combinedTransform.mapping.mapResult(change.oldRange.from).deletedAfter
29+
&& combinedTransform.mapping.mapResult(change.oldRange.to).deletedBefore
30+
) {
31+
combinedTransform.before.nodesBetween(change.oldRange.from, change.oldRange.to, (node, from) => {
32+
const to = from + node.nodeSize - 2;
33+
const isFullyWithinRange =
34+
change.oldRange.from <= from
35+
&& to <= change.oldRange.to;
36+
const payload = {
37+
type: "node" as const,
38+
node,
39+
from,
40+
to,
41+
newFrom: combinedTransform.mapping.map(from),
42+
newTo: combinedTransform.mapping.map(to),
43+
deletedRange: change.oldRange,
44+
newRange: change.newRange,
45+
partial: !isFullyWithinRange,
46+
editor: this.editor,
47+
transaction,
48+
combinedTransform,
49+
};
50+
51+
this.editor.emit("delete", payload);
52+
this.editor.options.onDelete(payload);
53+
});
54+
}
55+
});
56+
57+
const { mapping } = combinedTransform;
58+
59+
combinedTransform.steps.forEach((step, index) => {
60+
if (!(step instanceof RemoveMarkStep)) {
61+
return;
62+
}
63+
64+
const newStart = mapping.slice(index).map(step.from, -1);
65+
const newEnd = mapping.slice(index).map(step.to);
66+
const oldStart = mapping.invert().map(newStart, -1);
67+
const oldEnd = mapping.invert().map(newEnd);
68+
const foundBeforeMark = combinedTransform.doc
69+
.nodeAt(newStart - 1)
70+
?.marks.some((mark) => mark.eq(step.mark));
71+
const foundAfterMark = combinedTransform.doc
72+
.nodeAt(newEnd)
73+
?.marks.some((mark) => mark.eq(step.mark));
74+
const payload = {
75+
type: "mark" as const,
76+
mark: step.mark,
77+
from: step.from,
78+
to: step.to,
79+
deletedRange: {
80+
from: oldStart,
81+
to: oldEnd,
82+
},
83+
newRange: {
84+
from: newStart,
85+
to: newEnd,
86+
},
87+
partial: Boolean(foundAfterMark || foundBeforeMark),
88+
editor: this.editor,
89+
transaction,
90+
combinedTransform,
91+
};
92+
93+
this.editor.emit("delete", payload);
94+
this.editor.options.onDelete(payload);
95+
});
96+
};
97+
98+
if (this.editor.options.coreExtensionOptions?.delete?.async ?? true) {
99+
setTimeout(callback, 0);
100+
return;
101+
}
102+
103+
callback();
104+
},
105+
});
Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,68 @@
1-
import type { AnyExtension } from "../types";
1+
import type {
2+
AnyExtension,
3+
CoreExtensionName,
4+
} from "../types";
5+
import { ClipboardTextSerializer } from "./clipboardTextSerializer";
26
import { Commands } from "./commands";
7+
import { Delete } from "./delete";
38
import { Drop } from "./drop";
49
import { Editable } from "./editable";
510
import { FocusEvents } from "./focusEvents";
611
import { Keymap } from "./keymap";
712
import { Paste } from "./paste";
813
import { Tabindex } from "./tabindex";
14+
import { TextDirection } from "./textDirection";
915

10-
export function getCoreExtensions(): AnyExtension[] {
11-
return [
12-
Commands,
13-
Editable,
14-
Tabindex,
15-
FocusEvents,
16-
Drop,
17-
Keymap,
18-
Paste,
16+
type GetCoreExtensionsOptions = {
17+
enableCoreExtensions?: boolean | Partial<Record<CoreExtensionName, false>>;
18+
coreExtensionOptions?: {
19+
textDirection?: {
20+
direction?: "ltr" | "rtl" | "auto";
21+
};
22+
};
23+
};
24+
25+
function isEnabled(
26+
name: CoreExtensionName,
27+
setting: GetCoreExtensionsOptions["enableCoreExtensions"],
28+
) {
29+
if (setting === false) {
30+
return false;
31+
}
32+
33+
if (setting && typeof setting === "object") {
34+
return setting[name] !== false;
35+
}
36+
37+
return true;
38+
}
39+
40+
export function getCoreExtensions(options: GetCoreExtensionsOptions = {}): AnyExtension[] {
41+
const extensions: Array<[CoreExtensionName, AnyExtension]> = [
42+
["editable", Editable],
43+
["clipboardTextSerializer", ClipboardTextSerializer],
44+
["commands", Commands],
45+
["focusEvents", FocusEvents],
46+
["keymap", Keymap],
47+
["tabindex", Tabindex],
48+
["drop", Drop],
49+
["paste", Paste],
50+
["delete", Delete],
51+
["textDirection", TextDirection],
1952
];
53+
54+
return extensions
55+
.filter(([name]) => isEnabled(name, options.enableCoreExtensions))
56+
.map(([, extension]) => extension);
2057
}
2158

59+
export * from "./clipboardTextSerializer";
2260
export * from "./commands";
61+
export * from "./delete";
2362
export * from "./drop";
2463
export * from "./editable";
2564
export * from "./focusEvents";
2665
export * from "./keymap";
2766
export * from "./paste";
2867
export * from "./tabindex";
68+
export * from "./textDirection";
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
Plugin,
3+
PluginKey,
4+
} from "@mxm-editor/pm";
5+
import { Extension } from "../Extension";
6+
7+
export const TextDirection = Extension.create({
8+
name: "textDirection",
9+
10+
addGlobalAttributes() {
11+
const direction = this.editor.options.coreExtensionOptions?.textDirection?.direction;
12+
13+
if (!direction) {
14+
return [];
15+
}
16+
17+
const nodeExtensions = (this.extensions ?? []).filter(
18+
(extension) => extension.type === "node",
19+
);
20+
21+
return [
22+
{
23+
types: nodeExtensions
24+
.filter((extension) => extension.name !== "text")
25+
.map((extension) => extension.name),
26+
attributes: {
27+
dir: {
28+
default: direction,
29+
parseHTML: (element) => {
30+
const dir = element.getAttribute("dir");
31+
32+
if (dir === "ltr" || dir === "rtl" || dir === "auto") {
33+
return dir;
34+
}
35+
36+
return direction;
37+
},
38+
renderHTML: (attributes): Record<string, string> => (
39+
attributes.dir
40+
? { dir: String(attributes.dir) }
41+
: {}
42+
),
43+
},
44+
},
45+
},
46+
];
47+
},
48+
49+
addProseMirrorPlugins() {
50+
return [
51+
new Plugin({
52+
key: new PluginKey("textDirection"),
53+
props: {
54+
attributes: (): Record<string, string> => {
55+
const direction = this.editor.options.coreExtensionOptions?.textDirection?.direction;
56+
57+
if (!direction) {
58+
return {};
59+
}
60+
61+
return {
62+
dir: direction,
63+
};
64+
},
65+
},
66+
}),
67+
];
68+
},
69+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type {
2+
Node as ProseMirrorNode,
3+
Transaction,
4+
Transform,
5+
} from "@mxm-editor/pm";
6+
import { Transform as ProseMirrorTransform } from "@mxm-editor/pm";
7+
8+
export function combineTransactionSteps(
9+
oldDoc: ProseMirrorNode,
10+
transactions: Transaction[],
11+
): Transform {
12+
const transform = new ProseMirrorTransform(oldDoc);
13+
14+
transactions.forEach((transaction) => {
15+
transaction.steps.forEach((step) => {
16+
transform.step(step);
17+
});
18+
});
19+
20+
return transform;
21+
}

0 commit comments

Comments
 (0)