MS Word-style text alignment. A walkthrough!


#1

Since I’d seen a number of text-alignment posts - and that a number of people had been wrestling to get what they wanted out of it (me included, this took me hours to figure out) - I thought I’d share my solution.

specification

Effectively, I wanted the option to align text left/right/centre/justify using attribute classes and to have the enter key assign the class on to the next node in turn.

The work that needs to take place has 3 parts:

  1. setting up the schema attributes
  2. setting up the button command
  3. setting up the keymap behaviour for when the user presses Enter (I replicate prose-mirror’s baseKeymap, but override its ‘Enter’ key splitBlock command).

I’m going to demonstrate with the paragraph node; you may also need to set up schema attributes on other nodes depending on how you want your editor to split stuff like headers etc.

(note Because I use classes to specify alignment, you will need CSS in your implementation that sets text-alignment: left/right/etc. for .pm-align--left/.pm-align--right/etc. without said CSS you’ll just get a bunch of tags with classes.)

1. schema attributes

amend your schema’s paragraph definition to contain attrs.class.default and toDOM as below.

paragraph: {
            group: 'block',
            attrs: {
              class: {
                default: 'pm-align--left'
              }
            },
            content: 'inline*',
            toDOM (node) {
              return ['p', { class: node.attrs.class }, 0]
            },
            parseDOM: [{
              tag: 'p',
              getAttrs: node => {
                return {
                  textAlign: node.attributes
                    ? node.attributes.class
                    : node.attrs.class
                }
              }
            }]
          }

2. commands

I’m writing a Vue implementation so I wont show how to bind the command to a button since my methods are probably different to yours. What I will show you is the command. I actually handle the command in 2 parts (I have a helper class). I iterate through the selected nodes because I want the user to be able to select multiple nodes and align them at once (I may refactor this logic as I wrote it a while back and I think I can probably use something better); anyhow, here is the method that does the heavy lifting

import { setBlockType } from 'prosemirror-commands'
import { Helper } from '../Helper.js'

export function setSelectedNodeClasses (className, state, view) {
  const helper = Helper({ state })
  const commands = []
  helper.findNodeBySelection(
    (node) => {
      if (!commands.filter(({type}) => type === node.type.name).length) {
        commands.push({
          command:
            setBlockType(
              node.type,
              { class: className, isBeingAligned: true })
        })
      }
    })
  commands.forEach(({command}) => command(view.state, view.dispatch))
}

… and here is a stub of the Helper class that it calls (again, probably could be replaced with an existing prosemirror method, but - brother - there’s a lot to this tool chain!)

I actually use Stampit to manage my objects, but here I’m using a plain old JS function factory:

export const Helper = function ({state}) {
  return {
    findNodeBySelection (callback, _state) {
      if (_state || this.state) {
        const state = _state || this.state
        let { from, to, fromFound, toFound } = this.getSelectionObject(state)
        const priorNodes = []
        state.doc.descendants((node) => {
          if (node.isBlock && (node === from || (fromFound && !toFound))) {
            callback(node, priorNodes, { from, to })
            fromFound = true
          } else if (node.isBlock && !fromFound) { priorNodes.push(node) }
          toFound = toFound || (from === to || node === to)
        })
      }
    }
  }
}

In synopsis, the helper just fires a callback on all nodes within the bounds of the user’s current selection.

3. keymap

I’m operating on the assumption that you use baseKeymap. Obviously, you can configure the chainCommand in the example below to your needs.


// imports required to perform the "Enter command"
import {
  chainCommands,
  baseKeymap,
  newlineInCode,
  createParagraphNear,
  liftEmptyBlock
}
import ...
import ... etc.

// here is the customSplitBlock method.  This is effectively a copy of prose-mirror's own default splitBlock; I flag the changes with the comments marked "HERE" below.  You may wish to decouple this logic as it's a pretty hefty function!
function customSplitBlock (state, dispatch) {
      const { $from, $to } = state.selection
      if (state.selection instanceof NodeSelection && state.selection.node.isBlock) {
        if (!$from.parentOffset || !canSplit(state.doc, $from.pos)) return false
        if (dispatch) dispatch(state.tr.split($from.pos).scrollIntoView())
        return true
      }

      if (!$from.parent.isBlock) return false

      if (dispatch) {
        let atEnd = $to.parentOffset === $to.parent.content.size
        let tr = state.tr
        if (state.selection instanceof TextSelection) tr.deleteSelection()
        let deflt = $from.depth === 0 ? null : $from.node(-1).contentMatchAt($from.indexAfter(-1)).defaultType
        // HERE: I assign the class from the $from.node's class
        let types = atEnd && deflt ? [{ type: deflt, attrs: { class: $from.node().attrs.class } }] : null
        let can = canSplit(tr.doc, $from.pos, 1, types)
        if (!types && !can && canSplit(tr.doc, tr.mapping.map($from.pos), 1, deflt && [{ type: deflt }])) {
        // HERE: I assign the class from the $from.node's class again (either way, I want the inheritance)
          types = [{ type: deflt, attrs: { class: $from.node().attrs.class } }]
          can = true
        }
        if (can) {
          tr.split(tr.mapping.map($from.pos), 1, types)
          if (
            !$from.parentOffset &&
            $from.parent.type !== deflt &&
            $from.node(-1).canReplace(
              $from.index(-1),
              $from.indexAfter(-1),
              Fragment.from(deflt.create(), $from.parent))) {
            tr.setNodeMarkup(tr.mapping.map($from.before()), deflt)
          }
        }
        dispatch(tr.scrollIntoView())
      }
      return true
    }

// add the customSplitBlock to a custom keymap call in your EditorState.create()
EditorState.create({
      doc: DOMParser.fromSchema(schema).parse(ed.doc),
      plugins:       plugins: [ 
        ...
        keymap({
          'Enter': chainCommands(
            newlineInCode,
            createParagraphNear,
            liftEmptyBlock,
            customSplitBlock // our new implementation goes here.
          ),
         ... // your other custom keymap properties
        }),
        keymap(baseKeymap) // baseKeymap must go AFTER the 'Enter' definition above       
        ...
      ]
    })

I hope the above is useful to someone :slight_smile: happy coding


#2

PM-Example


#3

Thanks for sharing that! It’s a shame that customizing Enter is so awkward — I’m not sure what would be a good way to pass the command such information.


#4

Maybe you could have a variation of splitBlock that wrapped the command in a function factory that could be supplied with event-hook callbacks that let you override different points in the default behaviour? :slight_smile:

Here’s a very crude example:

function CustomSplitBlockFactory (callbacks = {}) {
  return (state, dispatch) => {
      const { $from, $to } = state.selection
      if (state.selection instanceof NodeSelection && state.selection.node.isBlock) {
        if (!$from.parentOffset || !canSplit(state.doc, $from.pos)) return false
        // CALLBACK HOOK 1
        if (dispatch) callbacks.hasOwnProperty('nodeSelectionCallback') 
          ? callbacks.nodeSelectionCallback(state, dispatch) 
          : dispatch(state.tr.split($from.pos).scrollIntoView())
        return true
      }

      if (!$from.parent.isBlock) return false

      if (dispatch) {
        let atEnd = $to.parentOffset === $to.parent.content.size
        let tr = state.tr
        if (state.selection instanceof TextSelection) tr.deleteSelection()
        let deflt = $from.depth === 0 ? null : $from.node(-1).contentMatchAt($from.indexAfter(-1)).defaultType
        let types = atEnd && deflt ? [{ type: deflt, attrs: { class: $from.node().attrs.class } }] : null
        let can = canSplit(tr.doc, $from.pos, 1, types)
        if (!types && !can && canSplit(tr.doc, tr.mapping.map($from.pos), 1, deflt && [{ type: deflt }])) {
          types = [{ type: deflt, attrs: { class: $from.node().attrs.class } }]
          can = true
        }
        if (can) {
          tr.split(tr.mapping.map($from.pos), 1, types)
          if (
            !$from.parentOffset &&
            $from.parent.type !== deflt &&
            $from.node(-1).canReplace(
              $from.index(-1),
              $from.indexAfter(-1),
              Fragment.from(deflt.create(), $from.parent))) {
                // CALLBACK HOOK 2
                callbacks.hasOwnProperty('canSplitCallback') 
                  ? callbacks.canSplitCallback(tr, default, from) 
                  : tr.setNodeMarkup(tr.mapping.map($from.before()), deflt)
          }
        }
        // MAYBE ANOTHER CALLBACK HOOK HERE? ETC....
        dispatch(tr.scrollIntoView())
      }
      return true
    }

}