Skip to content

Commit 5dc9879

Browse files
committed
Generalized bug fix for typing in empty math inline content to all inline content
1 parent a873c5d commit 5dc9879

4 files changed

Lines changed: 117 additions & 64 deletions

File tree

packages/core/src/editor/managers/ExtensionManager/extensions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
FilePanelExtension,
1616
FormattingToolbarExtension,
1717
HistoryExtension,
18+
InlineContentBoundaryEditExtension,
1819
LinkToolbarExtension,
1920
NodeSelectionKeyboardExtension,
2021
PlaceholderExtension,
@@ -176,6 +177,7 @@ export function getDefaultExtensions(
176177
SideMenuExtension(options),
177178
SuggestionMenu(options),
178179
HistoryExtension(),
180+
InlineContentBoundaryEditExtension(),
179181
PositionMappingExtension(),
180182
...(options.trailingBlock !== false ? [TrailingNodeExtension()] : []),
181183
] as ExtensionFactoryInstance[];
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { Plugin, PluginKey, Selection, TextSelection } from "prosemirror-state";
2+
import { createExtension } from "../../editor/BlockNoteExtension.js";
3+
4+
const PLUGIN_KEY = new PluginKey("inline-content-boundary-edit");
5+
6+
// Whether a Backspace/Delete at `selection` would remove the entire content
7+
// range `[content.from, content.to)` of an inline content node.
8+
function emptiesInlineContent(
9+
selection: Selection,
10+
key: string,
11+
content: { from: number; to: number },
12+
) {
13+
if (!selection.empty) {
14+
return selection.from <= content.from && selection.to >= content.to;
15+
}
16+
17+
const isSingleChar = content.to - content.from === 1;
18+
19+
return key === "Backspace"
20+
? isSingleChar && selection.from === content.to
21+
: isSingleChar && selection.from === content.from;
22+
}
23+
24+
// Fixes editing at the boundary of an empty custom inline content node (i.e. an
25+
// inline node with editable content, like a mention or inline math).
26+
//
27+
// An empty inline node can't hold a text cursor, so ProseMirror can't reconcile
28+
// edits across the empty boundary from the DOM: typing into an empty node
29+
// inserts text next to it rather than inside, and deleting the last character
30+
// leaves an un-reconcilable empty node that corrupts/freezes the editor. Both
31+
// boundary edits are handled here via transactions so the caret stays inside
32+
// the node, which is kept alive and editable in its empty state.
33+
//
34+
// The cursor is inside such a node exactly when its directly-enclosing node is
35+
// inline (`inline: true` in the spec) - regular text blocks aren't inline, and
36+
// atomic inline content can't hold a cursor - so the handling applies to any
37+
// inline content type without needing to know it by name.
38+
export const InlineContentBoundaryEditExtension = createExtension(
39+
() =>
40+
({
41+
key: "inlineContentBoundaryEdit",
42+
prosemirrorPlugins: [
43+
new Plugin({
44+
key: PLUGIN_KEY,
45+
props: {
46+
handleKeyDown: (view, event) => {
47+
if (!view.editable) {
48+
return false;
49+
}
50+
51+
const isTypedChar =
52+
event.key.length === 1 && !event.ctrlKey && !event.metaKey;
53+
54+
if (
55+
!isTypedChar &&
56+
event.key !== "Backspace" &&
57+
event.key !== "Delete"
58+
) {
59+
return false;
60+
}
61+
62+
const { selection } = view.state;
63+
const node = selection.$from.node();
64+
if (!node.type.spec.inline) {
65+
return false;
66+
}
67+
68+
const pos = selection.$from.before();
69+
const contentFrom = pos + 1;
70+
const contentTo = pos + 1 + node.content.size;
71+
72+
// Empty content: redirect the typed character into the node.
73+
if (isTypedChar && node.content.size === 0) {
74+
const tr = view.state.tr.insert(
75+
contentFrom,
76+
view.state.schema.text(event.key),
77+
);
78+
tr.setSelection(
79+
TextSelection.create(tr.doc, contentFrom + event.key.length),
80+
);
81+
view.dispatch(tr);
82+
83+
return true;
84+
}
85+
86+
// Backspace/Delete that would empty the content: delete it all in
87+
// one transaction, keeping the now-empty node (and the caret
88+
// inside it) so it stays editable.
89+
if (
90+
node.content.size > 0 &&
91+
emptiesInlineContent(selection, event.key, {
92+
from: contentFrom,
93+
to: contentTo,
94+
})
95+
) {
96+
const tr = view.state.tr.delete(contentFrom, contentTo);
97+
tr.setSelection(TextSelection.create(tr.doc, contentFrom));
98+
view.dispatch(tr);
99+
100+
return true;
101+
}
102+
103+
return false;
104+
},
105+
},
106+
}),
107+
],
108+
}) as const,
109+
);

packages/core/src/extensions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from "./DropCursor/DropCursor.js";
33
export * from "./FilePanel/FilePanel.js";
44
export * from "./FormattingToolbar/FormattingToolbar.js";
55
export * from "./History/History.js";
6+
export * from "./InlineContentBoundaryEdit/InlineContentBoundaryEdit.js";
67
export * from "./LinkToolbar/LinkToolbar.js";
78
export * from "./LinkToolbar/protocols.js";
89
export * from "./NodeSelectionKeyboard/NodeSelectionKeyboard.js";

packages/math-block/src/inlineContent/SourceInlineContentWithPreviewExtension.ts

Lines changed: 5 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,6 @@
11
import { BlockNoteEditor, createExtension, createStore } from "@blocknote/core";
22
import { Selection, TextSelection } from "prosemirror-state";
33

4-
// Whether a Backspace/Delete at `selection` would remove the entire source
5-
// content range `[content.from, content.to)`.
6-
const emptiesSource = (
7-
selection: Selection,
8-
key: string,
9-
content: { from: number; to: number },
10-
) => {
11-
if (!selection.empty) {
12-
return selection.from <= content.from && selection.to >= content.to;
13-
}
14-
15-
const isSingleChar = content.to - content.from === 1;
16-
17-
return key === "Backspace"
18-
? isSingleChar && selection.from === content.to
19-
: isSingleChar && selection.from === content.from;
20-
};
21-
224
/**
235
* Inline-content counterpart of {@link SourceBlockWithPreviewExtension}. Drives
246
* the source popup for inline content with a preview: toggling it with the
@@ -153,53 +135,12 @@ export const SourceInlineContentWithPreviewExtension = createExtension(
153135
const isTypedChar =
154136
event.key.length === 1 && !event.ctrlKey && !event.metaKey;
155137

156-
// An empty inline node can't hold a text cursor, so ProseMirror can't
157-
// edit across the empty boundary from the DOM: typing into an empty
158-
// source inserts next to the node, and deleting the last character
159-
// leaves an un-reconcilable empty node that freezes the editor. While
160-
// the popup is open, handle both boundary edits via transactions so
161-
// the caret stays inside the source.
138+
// While the popup is open, the source is shown and editable, so edits
139+
// are let through. Boundary edits on the (possibly empty) source - an
140+
// empty inline node can't hold a text cursor, so typing/deleting at
141+
// the boundary needs special handling - are handled generically by the
142+
// editor's `InlineContentBoundaryEdit` extension.
162143
if (store.state.popupOpen !== undefined) {
163-
const pos = store.state.popupOpen;
164-
const node = view.state.doc.nodeAt(pos);
165-
166-
if (node?.type.name === inlineContentType) {
167-
const contentFrom = pos + 1;
168-
const contentTo = pos + 1 + node.content.size;
169-
170-
// Empty source: redirect the typed character into the node.
171-
if (isTypedChar && node.content.size === 0) {
172-
const tr = view.state.tr.insert(
173-
contentFrom,
174-
view.state.schema.text(event.key),
175-
);
176-
tr.setSelection(
177-
TextSelection.create(tr.doc, contentFrom + event.key.length),
178-
);
179-
view.dispatch(tr);
180-
181-
event.preventDefault();
182-
event.stopImmediatePropagation();
183-
} else if (
184-
(event.key === "Backspace" || event.key === "Delete") &&
185-
node.content.size > 0 &&
186-
emptiesSource(view.state.selection, event.key, {
187-
from: contentFrom,
188-
to: contentTo,
189-
})
190-
) {
191-
// Delete the whole source in one transaction, keeping the now-
192-
// empty node (and the caret inside it) so it shows the "add
193-
// source" state and stays editable.
194-
const tr = view.state.tr.delete(contentFrom, contentTo);
195-
tr.setSelection(TextSelection.create(tr.doc, contentFrom));
196-
view.dispatch(tr);
197-
198-
event.preventDefault();
199-
event.stopImmediatePropagation();
200-
}
201-
}
202-
203144
return;
204145
}
205146

0 commit comments

Comments
 (0)