MS Word-style text alignment. A walkthrough!

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

3 Likes

PM-Example

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.

1 Like

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
    }

}

@jaysaurus Thanks for this walkthrough. We are trying to implement something similar for our ProseMirror implementation and are running into similar roadblocks. It does seem like the “recommended” approach is to rely on node attributes via a custom schema, which your example does.

I think I’m getting a little lost with step 3, where you outline the what your doing with keymap. Would it be possible for you to show how you wired up a button to the command you wrote?

Did you also notice there is weird behavior if you have text-align:justify together with white-space: pre-wrap. Is there a fix?

Yeah I noticed that issue too. I actually have a horrible feeling that it’s a browser issue rather than a prosemirror issue. The down side of working in content-editable is that it has some major limitations. You might have to use some marks and clever styling to justify stuff.

Hmmmm, step 3 is kind of how it is! I’m currently in the Amazon jungle and about to lose signal but I’ll be back in civilisation in a few days so I’ll try and get you something then :slight_smile:

Hi @jaysaurus, I saw a post by you in the tiptap (a Vue implementation of prosemirror) issues list. There is also a dedicated thread about this library in this discussion forum too.

Is the code above similar to the code you posted here? Is it aimed at accomplishing the same thing? https://github.com/scrumpy/tiptap/issues/20#issuecomment-425087834

hmmm, I think that discussion was related but was before I really understood Prose Mirror as well as I do now. The crux of why I didn’t go with TipTap at the time was down to node inheritance (the issue I address here):

If I press enter and create a new node, I want that new node to inherit all the ongoing concerns of the previous node (including alignment and so forth).

I looked at length at the TipTap core and couldn’t see an easy way to build this behaviour into the project. In fact, it even took me ages to work out in my own project after reading through how ProseMirror actually fires the enter command. At the time of writing, there is no nice-and-easy solution to the problem of node inheritance.

To my knowledge (though I must stress, it isn’t something I’m current on) TipTap would have to override the enter command to get the desired behaviour and insist on the user having some kind of classical/style conventions in order to actually maintain the inheritance across nodes (which is basically how I do it; and is the reason - unfortunately - it would be potentially difficult to build an uncompromising client-friendly framework on top of the library). Since its MO is to make things as painless as possible for client developers, I’m not sure how/if TipTap would really be able to do classical/style node inheritance OOB. It’s the blessing and curse of using a library as powerful and complete as prosemirror unfortunately.

Sorry to necropost, but I just finished implementing something like this and found a way to preserve alignment when splitting blocks that’s a little cleaner than re-implementing splitBlock. Assuming that you have a textAlign attribute on alignable node types, this command should preserve it across block splits:

function splitBlockPreservingTextAlign(state, dispatch) {
	const { $from: previousSelectionFrom } = state.selection;
	return splitBlock(state, (tr) => {
		if (dispatch) {
			const targetNodePosition = tr.selection.$from.before();
			const sourceNode = previousSelectionFrom.node();
			const { textAlign } = sourceNode.attrs;
			tr.setNodeMarkup(targetNodePosition, undefined, { textAlign });
			dispatch(tr);
		}
	});
};

as described above, using the command involves overriding the Enter key in the keymap:

keymap({
    ...baseKeymap,
    Enter: chainCommands(
        newlineInCode,
        createParagraphNear,
        liftEmptyBlock,
        splitBlockPreservingTextAlign,
    ),
})
2 Likes

Hi. I am new to this. Can anyone please provide the full code for this? Thank you very much.

working example code, please