tinymce

Getting selected inline node in TinyMCE


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.


Solution

  • 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 + "'");
        }
      });
    };