Skip to content

Commit d441d8f

Browse files
authored
Merge pull request #432 from Harbour-Enterprises/har-8792_list-input-rules
Lists input rules
2 parents 9228edd + cbe41af commit d441d8f

14 files changed

Lines changed: 443 additions & 5 deletions

File tree

packages/super-editor/src/core/Editor.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export class Editor extends EventEmitter {
6464
editorProps: {},
6565
parseOptions: {},
6666
coreExtensionOptions: {},
67+
enableInputRules: true,
6768
isCommentsEnabled: false,
6869
isNewFile: false,
6970
scale: 1,
@@ -1111,4 +1112,4 @@ export class Editor extends EventEmitter {
11111112
};
11121113

11131114
}
1114-
}
1115+
}

packages/super-editor/src/core/ExtensionService.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { getNodeType } from './helpers/getNodeType.js';
55
import { getExtensionConfigField } from './helpers/getExtensionConfigField.js';
66
import { getSchemaTypeByName } from './helpers/getSchemaTypeByName.js';
77
import { callOrGet } from './utilities/callOrGet.js';
8+
import { isExtensionRulesEnabled } from './helpers/isExtentionRulesEnabled.js';
9+
import { inputRulesPlugin } from './InputRule.js';
810

911
/**
1012
* ExtensionService is the main class to work with extensions.
@@ -108,6 +110,8 @@ export class ExtensionService {
108110
const editor = this.editor;
109111
const extensions = ExtensionService.sortByPriority([...this.extensions].reverse());
110112

113+
const inputRules = [];
114+
111115
const allPlugins = extensions
112116
.map((extension) => {
113117
const context = {
@@ -133,6 +137,12 @@ export class ExtensionService {
133137

134138
plugins.push(keymap(bindingsObject));
135139

140+
const addInputRules = getExtensionConfigField(extension, 'addInputRules', context);
141+
142+
if (isExtensionRulesEnabled(extension, editor.options.enableInputRules) && addInputRules) {
143+
inputRules.push(...addInputRules());
144+
}
145+
136146
const addPmPlugins = getExtensionConfigField(extension, 'addPmPlugins', context);
137147

138148
if (addPmPlugins) {
@@ -144,7 +154,13 @@ export class ExtensionService {
144154
})
145155
.flat();
146156

147-
return [...allPlugins];
157+
return [
158+
inputRulesPlugin({
159+
editor,
160+
rules: inputRules,
161+
}),
162+
...allPlugins
163+
];
148164
}
149165

150166
/**
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { Plugin, PluginKey } from 'prosemirror-state';
2+
import { Fragment } from 'prosemirror-model';
3+
import { CommandService } from './CommandService.js';
4+
import { chainableEditorState } from './helpers/chainableEditorState.js';
5+
import { getHTMLFromFragment } from './helpers/getHTMLFromFragment.js';
6+
import { getTextContentFromNodes } from './helpers/getTextContentFromNodes.js';
7+
import { isRegExp } from './utilities/isRegExp.js';
8+
9+
export class InputRule {
10+
match;
11+
handler;
12+
13+
constructor(config) {
14+
this.match = config.match;
15+
this.handler = config.handler;
16+
}
17+
}
18+
19+
const inputRuleMatcherHandler = (text, match) => {
20+
if (isRegExp(match)) {
21+
return match.exec(text);
22+
}
23+
24+
const inputRuleMatch = match(text);
25+
26+
if (!inputRuleMatch) {
27+
return null;
28+
}
29+
30+
const result = [ inputRuleMatch.text ];
31+
32+
result.index = inputRuleMatch.index;
33+
result.input = text;
34+
result.data = inputRuleMatch.data;
35+
36+
if (inputRuleMatch.replaceWith) {
37+
if (!inputRuleMatch.text.includes(inputRuleMatch.replaceWith)) {
38+
console.warn(
39+
'[super-editor warn]: "inputRuleMatch.replaceWith" must be part of "inputRuleMatch.text".',
40+
);
41+
}
42+
43+
result.push(inputRuleMatch.replaceWith);
44+
}
45+
46+
return result;
47+
}
48+
49+
const run = (config) => {
50+
const {
51+
editor, from, to, text, rules, plugin,
52+
} = config;
53+
const { view } = editor;
54+
55+
if (view.composing) {
56+
return false;
57+
}
58+
59+
const $from = view.state.doc.resolve(from);
60+
61+
if (
62+
$from.parent.type.spec.code
63+
|| !!($from.nodeBefore || $from.nodeAfter)?.marks.find(mark => mark.type.spec.code)
64+
) {
65+
return false;
66+
}
67+
68+
let matched = false;
69+
const textBefore = getTextContentFromNodes($from) + text;
70+
71+
rules.forEach(rule => {
72+
if (matched) {
73+
return;
74+
}
75+
76+
const match = inputRuleMatcherHandler(textBefore, rule.match)
77+
78+
if (!match) {
79+
return
80+
}
81+
82+
const tr = view.state.tr;
83+
const state = chainableEditorState(tr, view.state);
84+
const range = {
85+
from: from - (match[0].length - text.length),
86+
to,
87+
};
88+
89+
const { commands, chain, can } = new CommandService({
90+
editor,
91+
state,
92+
});
93+
94+
const handler = rule.handler({
95+
state,
96+
range,
97+
match,
98+
commands,
99+
chain,
100+
can,
101+
});
102+
103+
// stop if there are no changes
104+
if (handler === null || !tr.steps.length) {
105+
return;
106+
}
107+
108+
// store transform as metadata
109+
// so we can undo input rules within the `undoInputRules` command
110+
tr.setMeta(plugin, {
111+
transform: tr,
112+
from,
113+
to,
114+
text,
115+
});
116+
117+
view.dispatch(tr);
118+
matched = true;
119+
})
120+
121+
return matched;
122+
}
123+
124+
/**
125+
* Create an input rules plugin. When enabled, it will cause text
126+
* input that matches any of the given rules to trigger the rule’s
127+
* action.
128+
*/
129+
export const inputRulesPlugin = ({ editor, rules }) => {
130+
const plugin = new Plugin({
131+
key: new PluginKey('inputRulesPlugin'),
132+
133+
state: {
134+
init() {
135+
return null;
136+
},
137+
138+
apply(tr, prev, state) {
139+
const stored = tr.getMeta(plugin);
140+
141+
if (stored) {
142+
return stored;
143+
}
144+
145+
// if InputRule is triggered by insertContent()
146+
const simulatedInputMeta = tr.getMeta('applyInputRules');
147+
const isSimulatedInput = !!simulatedInputMeta;
148+
149+
if (isSimulatedInput) {
150+
setTimeout(() => {
151+
let { text } = simulatedInputMeta;
152+
153+
if (typeof text !== 'string') {
154+
text = getHTMLFromFragment(Fragment.from(text), state.schema);
155+
}
156+
157+
const { from } = simulatedInputMeta;
158+
const to = from + text.length;
159+
160+
run({
161+
editor,
162+
from,
163+
to,
164+
text,
165+
rules,
166+
plugin,
167+
});
168+
})
169+
}
170+
171+
return tr.selectionSet || tr.docChanged ? null : prev;
172+
},
173+
},
174+
175+
props: {
176+
handleTextInput(view, from, to, text) {
177+
return run({
178+
editor,
179+
from,
180+
to,
181+
text,
182+
rules,
183+
plugin,
184+
})
185+
},
186+
187+
// add support for input rules to trigger on enter
188+
// this is useful for example for code blocks
189+
handleKeyDown(view, event) {
190+
if (event.key !== 'Enter') {
191+
return false;
192+
}
193+
194+
const { $cursor } = view.state.selection;
195+
196+
if ($cursor) {
197+
return run({
198+
editor,
199+
from: $cursor.pos,
200+
to: $cursor.pos,
201+
text: '\n',
202+
rules,
203+
plugin,
204+
})
205+
}
206+
207+
return false;
208+
},
209+
},
210+
211+
isInputRules: true,
212+
});
213+
return plugin;
214+
}

packages/super-editor/src/core/commands/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export * from './selectTextblockStart.js';
3232
export * from './selectTextblockEnd.js';
3333
export * from './insertContent.js';
3434
export * from './insertContentAt.js';
35+
export * from './undoInputRule.js';
3536

3637
// Lists
3738
export * from './wrapInList.js';
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export const undoInputRule = () => ({ state, dispatch }) => {
2+
const plugins = state.plugins;
3+
4+
for (let i = 0; i < plugins.length; i += 1) {
5+
const plugin = plugins[i];
6+
let undoable;
7+
8+
// eslint-disable-next-line
9+
if (plugin.spec.isInputRules && (undoable = plugin.getState(state))) {
10+
if (dispatch) {
11+
const tr = state.tr;
12+
const toUndo = undoable.transform;
13+
14+
for (let j = toUndo.steps.length - 1; j >= 0; j -= 1) {
15+
tr.step(toUndo.steps[j].invert(toUndo.docs[j]));
16+
}
17+
18+
if (undoable.text) {
19+
const marks = tr.doc.resolve(undoable.from).marks();
20+
21+
tr.replaceWith(undoable.from, undoable.to, state.schema.text(undoable.text, marks));
22+
} else {
23+
tr.delete(undoable.from, undoable.to);
24+
}
25+
}
26+
27+
return true;
28+
}
29+
}
30+
31+
return false;
32+
}

packages/super-editor/src/core/extensions/keymap.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const Keymap = Extension.create({
2020

2121
const handleBackspace = () =>
2222
this.editor.commands.first(({ commands, tr }) => [
23+
() => commands.undoInputRule(),
2324
() => {
2425
tr.setMeta('inputType', 'deleteContentBackward');
2526
return false;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { DOMSerializer } from 'prosemirror-model';
2+
3+
export function getHTMLFromFragment(fragment, schema) {
4+
const documentFragment = DOMSerializer.fromSchema(schema).serializeFragment(fragment);
5+
6+
const temporaryDocument = document.implementation.createHTMLDocument();
7+
const container = temporaryDocument.createElement('div');
8+
9+
container.appendChild(documentFragment);
10+
11+
return container.innerHTML;
12+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Returns the text content of a resolved prosemirror position
3+
* @param $from The resolved position to get the text content from
4+
* @param maxMatch The maximum number of characters to match
5+
* @returns The text content
6+
*/
7+
export const getTextContentFromNodes = ($from, maxMatch = 500) => {
8+
let textBefore = '';
9+
10+
const sliceEndPos = $from.parentOffset;
11+
12+
$from.parent.nodesBetween(
13+
Math.max(0, sliceEndPos - maxMatch),
14+
sliceEndPos,
15+
(node, pos, parent, index) => {
16+
const chunk = node.type.spec.toText?.({
17+
node,
18+
pos,
19+
parent,
20+
index,
21+
})
22+
|| node.textContent
23+
|| '%leaf%'
24+
25+
textBefore += node.isAtom && !node.isText ? chunk : chunk.slice(0, Math.max(0, sliceEndPos - pos));
26+
},
27+
)
28+
29+
return textBefore;
30+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export function isExtensionRulesEnabled(extension, enabled) {
2+
if (Array.isArray(enabled)) {
3+
return enabled.some(enabledExtension => {
4+
const name = typeof enabledExtension === 'string'
5+
? enabledExtension
6+
: enabledExtension.name;
7+
8+
return name === extension.name;
9+
})
10+
}
11+
12+
return enabled;
13+
}

0 commit comments

Comments
 (0)