Find extents of a mark given a selection

What’s the best way to, given a selection, a) find out if a given mark applies to all inline content in the selection, and b) if it does, expand the selection to the beginning and end of the content contiguously having that mark?

That is to say, if I have the text:

Colorless green ideas sleep furiously.

And I put my cursor between the e’s in “sleep”, how could I programmatically get the whole link?

I have a custom mark that’s semantically similar to a hyperlink, and I want to provide UI for changing the target. It doesn’t make sense to update the target for a substring in the middle of a link. I want to be able to change the target of the whole link.

1 Like

If you’re not interested in marks outside of the current textblock, you could just iterate over the child nodes of the textblock in both directions, and keep going until you find one that doesn’t have the mark.

Here’s some (untested) code to that effect:

// :: (ResolvedPos, Mark)
function markExtend($start, mark) {
  let startIndex = $start.index(), endIndex = $end.indexAfter()
  while (startIndex > 0 && mark.isInSet($start.parent.child(startIndex - 1).marks)) startIndex--
  while (startIndex < $start.parent.childCount && mark.isInSet($start.parent.child(endIndex).marks)) endIndex++
  // (This will be easier in the next release with Fragment.offsetAt)
  let startPos = $start.start(), endPos = startPos
  for (let i = 0; i < endIndex; i++) {
    let size = $start.parent.child(i).nodeSize
    if (i < startPos) startPos += size
    endPos += size
  }
  return {from: startPos, to: endPos}
}

I’m doing this same thing. Not sure why, but back when I implemented this I used DOMFromPos:

    var dom = DOMFromPos(pm, pm.selection.head);
    var start = pm.selection.head - dom.offset;
    var end = start + dom.node.length;

(@wes-r That’s a really bad approach and will break in a lot of circumstances. Don’t go to the DOM unless you really really need to.)

@marijn good premonition as my implementation just started breaking and throwing RangeError: Resolving a position in an outdated DOM structure

For my purposes, this seems to work start = pm.selection.head - pm.selection.$head.nodeBefore.nodeSize. As I’ll always be “within” the node in question.

One oddity to me is that a resolved path splits an actual node. E.g within “foo ^bar” and cursor at ^, nodeBefore = "foo " and nodeAfter = “bar”. I wish the resolved position gave me currentNode and offset within that node as appose to the grandparent as parent.

The current node is the textblock node. Text nodes don’t have content and you can’t logically have a position inside of them. That’s a bit awkward at times, but it’s preferable to the alternative, which would mean we can no longer treat textblock content as flat.

(If you really need the full text nod in this case, pos.parent.child(pos.index()) gets it.)

Just a small nitpick: actually it should be if (i < $start.start()) startPos += size instead of if (i < startPos) startPos += size. Otherwise it doesn’t work since startPos is updated at every iteration.

Actually that was the wrong fix and there were further issues with the code. Here is a version that I believe does the trick:

function markExtend ($start, mark) {
  let startIndex = $start.index()
    , endIndex = $start.indexAfter()
  ;
  while (startIndex > 0 && mark.isInSet($start.parent.child(startIndex - 1).marks)) startIndex--;
  while (
    endIndex < $start.parent.childCount &&
    mark.isInSet($start.parent.child(endIndex).marks)) endIndex++;
  let startPos = $start.start()
    , endPos = startPos
  ;
  for (let i = 0; i < endIndex; i++) {
    let size = $start.parent.child(i).nodeSize;
    if (i < startIndex) startPos += size;
    endPos += size;
  }
  return { from: startPos, to: endPos };
}
3 Likes

I had some issues with the above when you clicked at the very edge of a link.

This seems to be working for me now. Note that this version was intended for cursors.

function markExtend ($cursor: ResolvedPos, markType: MarkType) {
  let startIndex = $cursor.index()
  let endIndex = $cursor.indexAfter()

  const hasMark = (index: number) => 
    markType.isInSet($cursor.parent.child(index).marks)

 

  // Clicked outside edge of tag.
  if (startIndex === $cursor.parent.childCount) {
    startIndex--;
  }
  while (startIndex > 0 && hasMark(startIndex)) {
    startIndex--;
  }
  while ( endIndex < $cursor.parent.childCount && hasMark(endIndex)) {
    endIndex++
  }

  let startPos = $cursor.start()
  let endPos = startPos

  for (let i = 0; i < endIndex; i++) {
    let size = $cursor.parent.child(i).nodeSize;
    if (i < startIndex) startPos += size;
    endPos += size;
  }

  return { from: startPos + 1, to: endPos };
}
3 Likes