Search code examples
javascriptcontenteditablequasar-frameworkquasar

How do I control range selection in a Quasar Editor?


I have a Quasar editor where I want to exercise more finegrained control over caret/selection behavior. Specifically, when there are <a> tags with a data-item-type attribute, I want them to behave like one character in terms of selection, etc.

For example, if it contains this HTML: x<a href="" data-item-type>yyy</a>z

The in the display will be: xyyyz (bold added to show the link)

Then when I move the cursor left to right, as soon as it passes the first y, the entire yyy should be selected so that I can't edit the text within it and if you want to delete it, you delete the entire thing. Similarly, when moving right to left. Clicking anywhere in the link area should also highlight the whole thing (and mouse drags).

I thought this would be as simple as adding a selectionchange listener:

const onSelectionChange = function() {
    const selection = document.getSelection();
    const range = selection?.getRangeAt(0);

    const editorNode = editorRef.value?.getContentEl();
    if (!editorNode || !range) {
      return;
    }

    // see if we're inside the editor
    if (range?.commonAncestorContainer === editorNode || range?.commonAncestorContainer.parentElement?.closest('.q-editor__content') === editorNode) {
      // we're inside the editor
      // make sure this function doesn't trigger a change
      document.removeEventListener('selectionchange', onSelectionChange);

      const rangeEnds = [range?.startContainer?.parentElement, range?.endContainer?.parentElement] as HTMLElement[];
      const endsInLink = rangeEnds.map((el) => el?.nodeName === 'A' && el.getAttribute('data-item-type'));

      // need to make sure the end isn't in the text that's in the link
      if (endsInLink[0] && !endsInLink[1]) {
        // start is inside and end is outside - adjust the start
        range.setStartBefore(rangeEnds[0]);
        selection?.removeAllRanges();
        selection?.addRange(range);
      } else if (endsInLink[1] && !endsInLink[0]) {
        // end is inside but start is outside
        range.setEndAfter(rangeEnds[1]);
        selection?.removeAllRanges();
        selection?.addRange(range);
      } else if (endsInLink[0] && endsInLink[1]) {
        // both are inside
        range.setStartBefore(rangeEnds[0]);
        range.setEndAfter(rangeEnds[1]);
        selection?.removeAllRanges();
        selection?.addRange(range);
      } else {
        //both are outside - nothing to do
      }
      document.addEventListener('selectionchange', onSelectionChange);
    }
}

But it exhibits some strange behavior. Moving the cursor right to left through the works - it highlights the whole thing when I enter it and turns off when I leave. But moving left to right, it highlights the whole thing, but then additional presses of the right arrow don't move the selection at all. And going left to right holding shift with a selection that starts outside the link works, but using shift to create a selection right to left works to highlink the link, but further presses of left arrow start to shrink the range on the right side.

I believe what's going on may be that the editor itself is handling the selectionchange event. I can see an installed event handler and despite turning mine off and on, it is still being called multiple times on certain key presses.

So, I guess it's a 3-part question:

  1. Am I just doing something wrong that's easily fixed?
  2. If it's tied to the Quasar Editor handling selectionchange, does anyone know if I can alter that behavior or hook into my code from it?
  3. If not, is there an alternative option for fixing this? Willing to look at other editors, if that's what it takes.

Thanks!


Solution

  • Got it to work. First of all removing the eventlistener didn't seem to prevent subsequent calls - maybe because the selection isn't actually updated until after the function ends? In any case, I removed that and replaced it with a check so we only do the update if it actually changes.

    Then, I needed to extend the right side of the selection to one character past the node (and if there isn't a character there, we need to insert one). Otherwise additional right arrow presses were just returning the same range for some reason, so it could never leave the <a>

    Finally, we can't just set the range on the selection, because we need to handle cases where we are extending the selection - and specifically going right to left (otherwise, further left movement after we reset the selection will shorten the selection on the right side rather than extend it on the left).

    There's one outstanding issue, which is if extending selection to the left, once an <a> gets selected, you can't back up the selection to remove it. I think this will require a keypress handler to deal with.

    Otherwise, this seems to work:

     const onSelectionChange = function() {
        const selection = document.getSelection();
        const range = selection?.getRangeAt(0);
    
        const editorNode = editorRef.value?.getContentEl();
        if (!editorNode || !range) {
          return;
        }
    
        // see if we're inside the editor
        if (range?.commonAncestorContainer === editorNode || range?.commonAncestorContainer.parentElement?.closest('.q-editor__content') === editorNode) {
          // we're inside the editor
          const rangeEnds = [range?.startContainer?.parentElement, range?.endContainer?.parentElement] as HTMLElement[];
          const endsInLink = rangeEnds.map((el) => el?.nodeName === 'A' && el.getAttribute('data-item-type'));
    
          const newRange = range.cloneRange();
          if (endsInLink[0]) { 
            // if there's more text after it, set the end into that text; we actually have to go to index 1 or it doesn't behave properly on future right arrow keypresses
            if (rangeEnds[0].previousSibling) {
              // this ensures that if we click the node and then press a letter, it deletes the whole node and not just the text inside
              newRange.setStart(rangeEnds[0].previousSibling, rangeEnds[0].previousSibling.textContent.length);
            } else {
              newRange.setStartBefore(rangeEnds[0]);
            }
          }
    
          if (endsInLink[1]) {
            // if there's more text after it, set the end into that text; we actually have to go to index 1 or it doesn't behave properly on future right arrow keypresses
            if (rangeEnds[1].nextSibling) {
              newRange.setEnd(rangeEnds[1].nextSibling, 1);
            } else {
              // add a sibling to the end of the link so we can move the cursor past it
              rangeEnds[1].insertAdjacentText('afterend', ' ');
              newRange.setEnd(rangeEnds[1].nextSibling as Node, 1);
            }
          }
    
          if (newRange.endContainer !== range.endContainer || newRange.startContainer !== range.startContainer || newRange.endOffset !== range.endOffset || newRange.startOffset !== range.startOffset) {
            // we can't just use the range, because we need to preserve the direction of selection (for when holding shift)
            // focus (end) is either the start of the range or the end, depending on which way we're going
            // also need to see if we're selecting or not
            if (selection?.isCollapsed) {
              // just set the beginning an end - direction doesn't matter
              selection.setBaseAndExtent(newRange.startContainer, newRange.startOffset, newRange.endContainer, newRange.endOffset);
            } else {
              // we're selecting, so extend the selection
              if (selection?.anchorNode?.compareDocumentPosition(selection?.focusNode) === Node.DOCUMENT_POSITION_PRECEDING) {
                selection?.extend(newRange.startContainer, newRange.startOffset);
              } else {
                selection?.extend(newRange.endContainer, newRange.endOffset);
              }
            }
          }
        }
    }