Creating an indication that the previous node is the $head of a selection [NOW AVAILABLE AS A PLUGIN!]


#1

It’s probably easiest to demonstrate with an example rather than explain, so hopefully this will go some way to explaining what I mean:

issue%20with%20selection

Edit

The solution below is now available as a plugin :slight_smile: for more details, visit:


#2


#3

Text selections are shown using the native browser selection, so we don’t have direct control over how those are drawn. But you could write a plugin that detects this case and adds some decoration that conveys this information.


#4

Thanks, yeah that’s a good suggestion… I’ll have a think. probably warrants a plugin in its own right :slight_smile:


#6

UPDATE: THE CODE BELOW HAS BEEN AMENDED

breaks will now be read as well … as best as they can be, there’s an edge case that can’t be nicely fixed because of how HTML break points actually work


Here is an exhaustive answer with sophisticated support for recognising $head, $anchor and any intermediate nodes. here’s the demo:

SelectionAdvanced

as I’ve mentioned elsewhere, I use stampit for managing my objects (it enables painless composition in JS, check it out :slight_smile: ). You will need to have stampit as a dependency if you wish to use the below. Alternatively, you can either add the helper methods directly to your main class or prototypally inherit one from the other.

without any further ado, the solution below breaks into a “main class” and a supporting “helper class”

helper class

import stampit from 'stampit'

export const PluginHelper = stampit({
  props: {
    ops: {
      eq: (a, b) => a === b,
      gt: (a, b) => a > b,
      gte: (a, b) => a >= b,
      lt: (a, b) => a < b,
      lte: (a, b) => a <= b,
      ne: (a, b) => a !== b
    }
  },
  init () {
    this.positionIsWithinSelection =
      function (position, { $anchor, $head }, useEquality = false) {
        const op = {
          gt: useEquality ? this.ops['gte'] : this.ops['gt'],
          lt: useEquality ? this.ops['lte'] : this.ops['lt']
        }
        return (op.lt($anchor.before(), $head.before()) &&
          op.gt(position, $anchor.before()) &&
          op.lt(position, $head.before())) ||
          (op.gt($anchor.before(), $head.before()) &&
            op.gt(position, $head.before()) &&
            op.lt(position, $anchor.before()))
      }.bind(this)
  }
})

Main class

import stampit from 'stampit'

import { Plugin } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'

import { PluginHelper } from './plugin-helper.js'

export const SelectionLeafFactory = stampit(PluginHelper, {
  init () {
    let initialised = false

    function hilightAfterEdge (decorations, selection) {
      if (selection.parent &&
        selection.after() > 1 &&
        selection.parent.content.size === selection.parentOffset) {
        decorations.push(
          Decoration.inline(
            selection.after() - 2,
            selection.after() - 1,
            { class: 'selection-after-edge' }))
        if (selection.pos > 0) {
          decorations.push(
            Decoration.node(
              selection.pos - 1,
              selection.pos,
              { class: 'selection-before-edge' }))
        }
      }
    }

    function hilightBreakFromAnchor (decorations, selection, pos) {
      if (pos > 0 &&
        pos >= selection.$anchor.pos &&
        pos <= selection.$head.pos) {
        decorations.push(
          Decoration.inline(
            pos,
            pos + 1,
            {
              class: 'selection-before-edge',
              nodeName: 'span'
            }))
      }
    }

    function hilightBreakFromHead (decorations, selection, pos) {
      if (pos > 0 &&
        pos >= selection.$head.pos &&
        pos <= selection.$anchor.pos) {
        decorations.push(
          Decoration.inline(
            pos - 1,
            pos,
            {
              class: 'selection-after-edge',
              nodeName: 'span'
            }))
      }
    }

    const hilightEmptyIntermediateNodes =
      function (decorations, selection, node, pos) {
        if (pos > 0 &&
          pos !== selection.$anchor.before() &&
          pos !== selection.$head.before() &&
          this.positionIsWithinSelection(pos, selection)) {
          decorations.push(
            Decoration.node(
              pos,
              pos + 2,
              { class: 'selection-before-edge' }))
        }
      }.bind(this)

    function hilightBeforeEdge (decorations, selection, s, i) {
      if (!selection[s].parentOffset &&
        (selection[s].pos > selection[i ? '$anchor' : '$head'].pos ||
          (!selection[s].parent.content.size))) {
        decorations.push(
          Decoration.node(
            selection[s].before(),
            selection[s].after(),
            { class: 'selection-before-edge' }))
      }
    }
    // --------------
    // PUBLIC METHODS
    // --------------
    this.createSelectionLeafPlugin = function () {
      return new Plugin({
        props: {
          decorations: ({ doc, selection }) => {
            if (initialised) {
              const decorations = []
              if (selection.$head !== selection.$anchor) {
                doc.descendants((node, pos) => {
                  if (node.isBlock) {
                    ['$anchor', '$head'].forEach((s, i) => {
                      if (selection[s].parentOffset === 0 ||
                        pos === selection[s].before()) {
                        hilightBeforeEdge(decorations, selection, s, i)
                      }
                      hilightAfterEdge(decorations, selection[s])
                    })
                    hilightEmptyIntermediateNodes(
                      decorations, selection, node, pos)
                  }
                  if (node.type.name === 'hardBreak') {
                    hilightBreakFromAnchor(decorations, selection, pos)
                    hilightBreakFromHead(decorations, selection, pos)
                  }
                })
              }
              return DecorationSet.create(doc, decorations)
            } else initialised = true
          }
        }
      })
    }
  }
})

Here are the css classes you will have to add to your editor:

::selection {
  background: #a8d1ff;
}
::-moz-selection {
  background: #a8d1ff;
}
.selection-after-edge::after {
  content: ' ';
  background: #a8d1ff;
}
.selection-before-edge::before {
  content: ' ';
  background: #a8d1ff;
}

#7

After a night of tinkering, the one thing I can’t seem to get nice is breaks (br) this in part seems down to how Firefox reads content editable. It will read a selection differently when selecting left to right than right to left. For now, break points will not appear with this code.


#8

As I just mentioned on twitter, I’m a bit curious to work out what the methodology was behind the HTML specification on selections across breakpoints in browsers for content-editable. I’m finding curious behaviour across Chrome and Firefox already (both very very similar) … it’s eye-opening insofar as - from a user experience - the selection handling just doesn’t make much sense to me. Bear in mind, you’ll see similar behaviour in text inputs as well (I’ve been checking).

Here’s a sample from Firefox: I press shift+right twice, observe what my latest selector css picks up.

weird%20selection%201

NOTE: this isn’t my or ProseMirror’s doing, i’m literally painting on color ::before/::after to reflect the selection and ProseMirror is simply listening to the browser’s built-in selection behaviour.

But, you might say, hey Jay, that’s the correct behaviour if that’s what the specification says it should do… maybe? But watch what happens when I start playing around with P and break and selecting from a P tag into the P with breaks instead of from Break into break (note, some of the selection isn’t painted below because I’m still figuring this stuff out)… The selection actually works as I would expect!

weird%20selection%201

So is it possible to select across multiple breaks from within a single P node? Yes, BUT, only if you shift select DOWN/UP rather than LEFT/RIGHT… Maybe I sound fussy, but if people are using your product for an extended period of time - and expect something to behave in a certain way - it could get pretty frustrating. One minute shift left/right selects continously from left to right, the next, it doesn’t.

I wonder if this is one of those things that just hasn’t really had the attention it deserves.

Aaaanyway, this may be too academic for most people’s palletes but I thought it important to share my findings should someone else want to investigate the minutiae of handling selections in ProseMirror in future.


#9

Bump, the code in my solution has been updated.

as I meantion, there’s one exception. If you finish a node with a <br> and no text, it wont be found. this is because the br tag can’t have meta content appended to it; the browser just wont render it. The only option would be to make a special inline class that hid the final tag and apended the metacontent to the P tag itself, but this leads to all manner of weird behaviour so has been abandoned. Ooooph, this was haaard to do.

with%20break%20points


#10

another bump because I forgot to include the additional helper class. This solution is quite complex now. I may dedicate some time tomorrow to rolling it into its own NPM module actually. I’ll keep you posted!


#11

as promised, my plugin is now available to download as a module :slight_smile:


#12

I’ve added support for you to choose the name of your <br/> tags. Also, I tested and the behaviour is pretty good in lists too!


#13

The UX looks really good! Thanks for open sourcing this :smiley:


#14

Right, I’m adding the following for posterity because I myself will likely need to remember this should I ever have to return to the code in question. The below is a solution to an extremely complex problem I had with custom lists. By virtue of my requirements, I’m not going to update the NPM plugin unless there’s demand because it could have breaking changes for some people’s prose-mirror editors (simply put, I just don’t know).

problem

If - like me - you would like to create alignable ul/ol tags, be warned that the the selections will look a little odd without some major investigation. I have found a fix but it required days of experimentation and observation to get right.

Simply put, traditionally, you can solve aligning ul/ol nicely with either flexbox or by hiding the ul/ol decoration and injecting it via css pseudo content. Unfortunately:

flexbox + content-editable = a massive mess when shift-selecting in content-editable blocks

so that leaves you with using pseudo content, e.g:

li p:before {
      content: '• ';
    }

which is functionally acceptable but has implications for the selection plugin… when my plugin paints your selection it will paint the bullet point backgrounds. You can modify the pseudo content to have background: none !important but, you’ll also notice that - by design - the paragraph will not be able to actually paint the line because it normally adds a :before pseudo class to your paragraph node! So instead, I have to do a bunch of careful checks and tweaks when a paragraph is specifically the child of a list tag. I’ll walk you through the logic (again, I must emphasise that this is as much for posterity because it’s a very complex piece of code that only specific clients will want in their builds)

  1. If it’s an “empty” <p> tag (something I ascertain using an is-empty class that is injected by a whole other plugin), I hide its placeholder <br /> tag but only if it is being selected. Here is the piece of css gymnastics needed to get that working:
li p.selection-before-edge.empty-list br {
      display:none
    }
  1. If the selection is at the edge of a list’s paragraph and that paragraph contains text, I want the selection to indicate to the user that pressing backspace will probably push the current line up to the previous line. To make that possible, I have to ascertain that the paragraph has contents and - instead of decorating the node, apply the decoration inline to the first char of the text.

the .gif below demonstrates the finished product. The fix is obviously very subtle but I really really want perfection from my build because the text editor is pretty much the heart of what I’m working on. Ultimately, some of the things that the selection painting points out are pretty weird; but as I’ve said before, this is a reflection of how content-editable is managed by the browser rather than any eccentric programming from me or @marijn .

My opinion:

a tenet of the WYSIWYM philosophy should be to indicate to the user in as clear a manner as possible exactly what every single key stroke is going to do on the screen (and, indeed, how their data will look when it’s saved/reloaded, etc.) Without that attention to detail, we’re in danger of defaulting to the kind of dream-weaver crap we used in the late 90s.

discussion points:

couldn’t I do something easier by wrapping the P tag in a span/div?

short answer: no. Why? Because - once again - it will make your selections behave strangely. Also, it will cause strange behaviour with prose-mirror’s list implementation. Believe me when I say, you do not want to start playing around with writing a customised variation of the splitListItem method unless you have to (I shelved 2 git branches investigating this avenue).

can’t I just use vanilla ul/ol tags?

you can, but the bullet points themselves wont align with the text. if you want something like the below, the only viable solution is to use pseudo content:

                                                        centredBullets