What I’m Trying to Accomplish: I’m trying to create a popup that lets users select which node type should be inserted next when they press Enter.
What I’ve Done:
- I created a plugin that displays a popup on Enter key press.
- The plugin checks the current position, retrieves the next possible sibling nodes, and renders an HTML list of these options.
- The list items have two event listeners:
- Click: Selecting an item with the mouse correctly inserts the node and moves the cursor to the expected position.
- Keyboard (Enter key): Pressing Enter inserts the node, but there is a problem (see below).
My problem is: Whilst mouse selection works perfectly, keyboard selection does not. When selecting with the keyboard, the cursor does not move to the expected position even though the selection is correct. I expected the cursor to be positioned inside the new node I have inserted, this does happen with mouse selection, but not with keyboard selection.
I noticed that if I insert a zero-width space character (\u200B
) at the new selection position, the cursor moves correctly and the selection is as expected. However, this feels like a workaround rather than a proper solution; as we would need to later strip these zero-width space characters back out.
My Question is: How can we have insertion of a new node via keyboard selection place the cursor in the correct position?
Code Snippets: Here’s our plugin code that shows the popup on Enter key press:
export const suggestionPlugin = new Plugin({
props: {
handleKeyDown(view, event) {
if (event.key === 'Enter') {
if (isEOL(view.state.tr, 0) || isEmpty(view.state.tr, 0)) {
const suggestions = getSuggestions(view);
const suggestionPopup = new SuggestionPopup(view, suggestions);
view.dom.parentNode?.appendChild(suggestionPopup.dom);
const firstListItem = suggestionPopup.dom.querySelector(".suggestionsList li") as HTMLLIElement;
firstListItem?.focus();
return true;
}
}
return false;
}
}
});
The popup creates list items and attaches click and keyboard event listeners:
li.addEventListener("click", (e) => {
e.preventDefault();
insertNode(editorView, followingSibling.type);
this.destroy();
});
keyboardNavHandler(e: KeyboardEvent) {
e.preventDefault();
const focusedElement = document.activeElement as HTMLLIElement;
if (e.key === "Enter") {
focusedElement.click();
}
}
The insertNode
function handles inserting the node and updating the selection:
function insertNode(view: EditorView, _nodeType: NodeType) {
const { dispatch, state } = view;
const { tr, selection, schema } = state;
const { $from } = selection;
const type = schema.nodes['p'];
const node = type.createAndFill(null, schema.text('\u200B')); // Zero-width space workaround
if (!node) return;
tr.insert($from.pos, node);
const mapped = tr.mapping.map($from.pos);
const resolvedPos = tr.doc.resolve(mapped + node.nodeSize);
tr.setSelection(TextSelection.create(tr.doc, resolvedPos.pos));
dispatch(tr.scrollIntoView());
}
Live Demo: https://dev.petal.evolvedbinary.com/ This is related to: Resolved Pos node is not the same as doc.nodeAt()