I'm working with a custom button in TinyMCE that opens a custom dialog to create an inline element. If the user selects something in the editor I want to access the element. However, by default TinyMCE seems to select the surrounding block level element.
As a simplified example, if the editor contains this HTML:
<p>Some <em>emphasised</em> and <em>more emphasised</em> text.</p>
And in the editor I select the word "emphasised", then tinymce.activeEditor.selection.getNode();
returns the surrounding <p>
element, not the <em>
.
I've tried this, to look for the <em>
within that surrounding element:
const selectionNode = tinymce.activeEditor.selection.getNode();
let currentNode = null;
if (selectionNode.nodeName.toLowerCase() === "em") {
currentNode = selectionNode;
} else {
child = selectionNode.firstChild;
while (child) {
if (child.nodeName.toLowerCase() === "em") {
currentNode = child;
break;
}
child = child.nextSibling;
}
}
This works if the user selects exactly "emphasised" or "more emphasised". But if they select " more emphasised " (including the spaces around the words) then this code uses the first found <em>
: "emphasised".
I can't work out how to select the node actually within the selected text.
I've made this CodePen that – when you click the pencil icon – opens an alert showing what it thinks the selected node's text is, based on the above code.
I can't believe this is the only or best way to solve this, but it's the complicated solution I've ended up with.
I created a new function within the callback that looks for nodes within the selection. You can look for a specific named node (e.g. "em"
) or also pass in a filter function to restrict. e.g. to find all <em>
nodes that have an id
attribute you'd also pass in the one-line function (node) => node.hasAttribute("id")
, which would return true
for matching nodes.
I've forked the previous CodePen to add this function, and am including all the commented JS below:
tinymce.init({
selector: 'textarea#editor',
plugins: "code",
menubar: false,
toolbar: 'code blocks | bold italic | customButton',
height: 400,
setup: customSetupCallback,
});
function customSetupCallback(editor) {
/**
* Helper function for finding a node within a selection in the editor.
*
* e.g. if the editor contains:
* <p>The quick <b>brown</b> fox <b>jumped</b> over the lazy dog.</p>
*
* And the call is to getSelectedNode("b"), then:
* - If "brown" is selected, then the first <b> element is returned.
* - If "quick brown fox" is selected then that same.
* - If "quick brown fox jumped over" is selected then the same.
* - If the cursor is within "brown" then the same.
*
* It can additionally restrict further.
*
* e.g. if the editor contains:
* <p>The quick <b class="foo">brown</b> fox <b class="bar">jumped</b> over the lazy dog.</p>
*
* And the call is to getSelectedNode("b", (node) => node.classList.contains("bar")), then:
* - If "quick brown fox jumped over", then the second <b> will be returned,
* because it's the only one with a class of "bar".
*
* @param {string} nodeName The name of the node to look for, e.g. "span"
* @param {function} nodeFilter An optional function that will be used to
* check the node. Should return boolean. e.g.
* (node) => node.hasAttribute("itemscope")
* would only select a node if it has an "itemscope" attribute.
* @returns {(Object|null)} The selected Node, or null if none found.
*/
const getSelectedNode = function (nodeName, nodeFilter = null) {
const tinymceNode = tinymce.activeEditor.selection.getNode();
let selectedNode = null;
if (
tinymceNode.nodeName.toLowerCase() === nodeName &&
(nodeFilter === null || (nodeFilter && nodeFilter(tinymceNode)))
) {
// Whatever TinyMCE thinks of as the selected node is correct.
// It was probably selected exactly.
selectedNode = tinymceNode;
} else {
// The selected text might contain desired node somewhere.
// So we need to walk all the elements to find it within the selection.
const rng = editor.selection.getRng();
if (rng && !rng.collapsed) {
// Walker function for traversing the elements
// https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
const walker = editor
.getDoc()
.createTreeWalker(
rng.commonAncestorContainer,
NodeFilter.SHOW_ELEMENT,
{
acceptNode: function (node) {
// Only include nodes that intersect the range
// and are of the kind we're looking for.
if (
rng.intersectsNode(node) &&
node.nodeName.toLowerCase() === nodeName &&
(nodeFilter === null || (nodeFilter && nodeFilter(node)))
) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_SKIP;
},
}
);
const elements = [];
let walkedNode = walker.currentNode;
while (walkedNode) {
// Avoid including the common ancestor itself
if (walkedNode !== rng.commonAncestorContainer) {
elements.push(walkedNode);
}
walkedNode = walker.nextNode();
}
if (elements.length > 0) {
// We found 1 or more, so we'll return the first one.
console.log(`${elements.length} matching element(s) found`);
selectedNode = elements[0];
} else {
console.log("No matching elements found");
}
} else {
console.log("No selection or selection is collapsed.");
}
}
return selectedNode;
};
editor.ui.registry.addButton("customButton", {
icon: "edit-block",
onAction: (_) => {
const selectionNode = getSelectedNode("em");
alert("Selected node text: '" + selectionNode.textContent + "'");
}
});
};