1

I want to get all of the elements contained in a user selection (as in DOM 2 ranges / MS TextRanges).

/** @return {Array.<Element>} */
function getSelectedElements() {

  var elements = [];

  // get elements in the user selection somehow

  return elements;

}

I've tried to do this by following Tim Down's excellent solution to a similar question, and some Moz and MS docs, and some PPK stuff.

The approach is basically:


  • Define SelectionLikeObject as a DOM Selection or an IE Selection.

  • Define RangeLikeObject as a DOM Range or an IE TextRange.

  • Let containerNode be a Node.

  • Let containerElement be an Element.

  • Let containedElements be a NodeList.

  • Let elementRange be a RangeLikeObject.

  • Let selectedRange be a RangeLikeObject.

  • Let selectedElements be an Array of Elements.

  • Let element be an Element.

  • Let selection be a SelectionLikeObject.

  • Set selection from the user's selection.

  • Set selectedElements to a new Array.

  • For each selectedRange in selection:

    • Set containerNode to the common ancestor container of selectedRange.

    • Set containerElement to the closest Element ancestor to containerNode.

    • Set containedElements to a list of descendants of containerElement.

    • For each element in containedElements:

      • Set elementRange from element.

      • If the boundaries of elementRange fall within the boundaries of selectedRange:

        • Push element onto selectedElements.

The DOM branch looks like this:

/** 
    @param {Document} doc
    @return {Array.<Element>} 
*/
getSelectedElements.fromDOM = function (doc) {

  /** @type {Range} */
  var selectedRange;

  /** @type {Array.<Element>} */
  var selectedElements = [];

  /** @type {Node} */
  var containerNode;

  /** @type {Element} */
  var containerElement;

  /** @type {NodeList} */
  var containedElements;

  /** @type {Range} */
  var elementRange;

  /** @type {Element} */
  var element;

  /** @type {Selection} */
  var selection = doc.defaultView.getSelection();

  /** @type {number} */
  var rangeCount = selection.rangeCount;

  /** @type {number} */
  var elementCount;

  /** @type {number} */
  var i;

  // hack for browsers without getRangeAt
  // see http://www.quirksmode.org/dom/range_intro.html

  if (!selection.getRangeAt) {

    selection.getRangeAt = function (i) {
      /** @type {Range} */
      var range = doc.createRange();
      if (i || !selection.anchorNode) {
        return range;
      }
      range.setStart(selection.anchorNode, selection.anchorOffset);
      range.setEnd(selection.focusNode, selection.focusOffset);
      return range;

    };

    selection.rangeCount = 1;

  }

  elementRange = doc.createRange();

  for (i = 0; i < rangeCount; ++i) {

    selectedRange = selection.getRangeAt(i);

    containerNode = selectedRange.commonAncestorContainer;

    while (containerNode && containerNode.nodeType != 1) {

      containerNode = containerNode.parentNode;

    }

    if (!containerNode) {

      return selectedElements; // something went wrong...

    }

    containerElement = /** @type {Element} */ containerNode;

    containedElements = containerElement.getElementsByTagName('*');

    elementCount = containedElements.length;

    for (var i = 0; i < elementCount; ++i) {

      element = containedElements[i];

      elementRange.selectNodeContents(element);

      if (elementRange.compareBoundaryPoints(selectedRange.END_TO_START, selectedRange) < 1 &&
          elementRange.compareBoundaryPoints(selectedRange.START_TO_END, selectedRange) > -1) {

        selectedElements.push(element);

      }
    }
  }

  elementRange.detach();

  return selectedElements;

};

The IE branch looks like this:

/** 
    @param {Document} doc
    @return {Array.<Element>} 
*/
getSelectedElements.fromIE = function (doc) {

  // Selection - http://msdn.microsoft.com/en-us/library/ie/dd347133(v=vs.85).aspx
  // TextRange - http://msdn.microsoft.com/en-us/library/dd347140(v=vs.85).aspx
  // ControlRange - http://msdn.microsoft.com/en-us/library/ie/ms537447(v=vs.85).aspx

  /** @type {TextRange|ControlRange} */
  var ieRange = doc.selection && doc.selection.createRange();

  /** @type {Array.<Element>} */
  var selectedElements = [];

  /** @type {TextRange} */
  var selectedRange;

  /** @type {Element} */
  var containerElement;

  /** @type {NodeList} */
  var containedElements;

  /** @type {TextRange} */
  var elementRange;

  /** @type {Element} */
  var element;

  /** @type {Selection} */
  var selection;

  /** @type {number} */
  var i = -1;


  if (ieRange.text === void 0) {

    return []; // FIXME: It's a ControlRange, give up.

  }

  selectedRange = /** @type {TextRange} */ ieRange;

  containerElement = selectedRange.parentElement();

  containedElements = containerElement.getElementsByTagName('*');

  elementRange = doc.body.createTextRange();

  while ((element = containedElements[++i])) {

      elementRange.moveToElementText(element);

      if (elementRange.compareEndPoints("StartToEnd", selectedRange) > -1 && 
          elementRange.compareEndPoints("EndToStart", selectedRange) < 1) {

        selectedElements.push(element);

      } 
  }

  return /** @type {Array.<Element>} */ selectedElements;

};

Now, the issue I want to solve is this: if only part of the text in an element is selected, it appears in the returned array, even though it is only partly selected.

I'd like to add a parameter that changes the behavior to only include fully-selected elements. I have a feeling the answer lies with compareBoundaryPoints, I just don't understand it well enough to figure it out yet.

Also, the IE code is untested so far, but please let me know if anything looks wrong with it (or the DOM branch).

Community
  • 1
  • 1
Dagg Nabbit
  • 75,346
  • 19
  • 113
  • 141
  • You may have difficulty defining "contained". One approach may be to remove all whitespace from the selected text, then get the text content for the elements at the extremes and get their white-space reduced text content and see if it's wholy contained in the selection (either test, match or indexof should do the trick). If it is, the whole element is selected. If not, it isn't. – RobG Apr 18 '12 at 03:43
  • @RobG maybe it could be done by checking `anchorOffset` and `focusOffset`? If the entire content of an element is selected, I want to consider it "fully selected," otherwise only "partly selected." In most cases the start and end element will be partly selected. In fact, maybe I could always just treat them as partly selected... – Dagg Nabbit Apr 18 '12 at 03:54
  • @RobG: There's no need for that: you can get reasonably precise boundaries for the selection in all major browsers. – Tim Down Apr 18 '12 at 08:28
  • @TimDown I was sort of hoping you would chime in on this. If you have a few minutes later, could you explain how to use compareBoundaryPoints/compareEndPoints to only get elements that fall completely within the selection? – Dagg Nabbit Apr 18 '12 at 19:25
  • @TimDown I think I figured it out actually. Thanks anyway, your work on that other question helped me get here. – Dagg Nabbit Apr 18 '12 at 19:38
  • I was intending to have a proper look at this but I'm glad to see you've sorted it out for yourself. – Tim Down Apr 18 '12 at 23:12

1 Answers1

1

After getting some sleep and reading up on compareBoundaryPoints again, I think I have the answer.

if (elementRange.compareBoundaryPoints(Range.START_TO_START, selectedRange) > -1 &&
    elementRange.compareBoundaryPoints(Range.END_TO_END, selectedRange) < 1) {

This seems to only evaluate to true for elements that fall completely within the user selection.

Dagg Nabbit
  • 75,346
  • 19
  • 113
  • 141