Adding style on the fly

Whenever the user types (or pastes) a certain text at a certain place (for example inside a <h> block), I would like to add a <span class="myclass">...</span> around it (for styling purpose). Would someone please point me to the appropriate concepts for doing this?

Do you want to add something to the document, or do you just want to just display the text differently? In the first case, you can define a mark type in your schema, in the second case, a decoration is what you want. To figure out when the text is added, you could use a plugin with an appendTransaction option, but this is not currently very easy to do.

Thanks @marijn. I only need to display some text differently, so I will have a look at decorations.

I successfully created decorations, but finally it seems they doesn’t suit my need (it seems they are attached to absolute positions, not nodes). So I went for marks instead.

I added marks to my model and I now want to apply them on the fly.

I successfully identified the transaction I want to intercept in appendTransaction. Also, I seem to understand how to create the new transaction I want to apply.

But I cannot figure out how to apply the new transaction (to generate a new state). I tried newState.apply, but it doesn’t work (which sounds logical). view.dispatch looks more promising, but I can’t see a clean way to access the view from inside appendTransaction. What’s the usual method?

(it seems they are attached to absolute positions, not nodes)

You have to map them on each transaction.

Return it from your appendTransaction function.

Yes, this is what I did first.

So it seems I don’t prepare well my new transaction. This what I do:

appendTransaction(tr, oldState, newState) {
  if (condition)
    return newState.tr.insertText('Hello World', 0, 10)
})

What am I missing?

I don’t think you’re missing anything – that code looks correct.

It was a silly mistake (shame on me), it works as expected.

So I’m using appendTransaction to apply marks on the fly. This way, I force some specific styling while the user modifies the document.

But I’m now facing an issue: the new transaction I generate can be undone by the user (with ctrl+Z or undo button). How can I prevent that?

You can do .setMeta("addToHistory", false) on your transaction.

1 Like

On closer look, getting separate undo history events for appended transactions was a bug, which will be fixed in the next release.

Thanks @marijn.

For those landing here, you’ll find my full code below (comments welcome). It dynamically adds the <small> tag to the end of any text following the last ‘(’ in a heading. For example:

<h2>Hello World! (I mean it!)</h2>

becomes:

<h2>Hello World! <small>(I mean it!)</small></h2>

This is the code to add the <small> tag to the schema:

const { nodes, marks } = require('prosemirror-schema-basic')
marks.small = {
  excludes: '_',		// Prevent any other mark from being applied to this mark
  parseDOM: [{ tag: 'small' }],
  toDOM: function toDOM(node) { 
    return ['small', { style: 'font-size:10px;color:dodgerblue' }] 
  }
}
mySchema = new Schema({ nodes, marks })

The plugin:

const { Plugin } = require("prosemirror-state")

const addSmallMark = new Plugin({
  appendTransaction(tr, oldState, newState) {
    // Initialize an empty transaction. We will enrich it so that, when applied, 
    // it adds the <small> tag to the end of any text following the last '(' 
    // in a heading.
    let newTr = newState.tr

    // Loop through the transactions
    _.each(tr, transaction => {
      // Loop through the steps
      _.each(transaction.steps, step => {
        // Loop through positions affected by the step
        step.getMap().forEach((oldStart, oldEnd, newStart, newEnd) => {
          // Loop through nodes affected by the transaction in the new state
          newState.doc.nodesBetween(newStart, newEnd, (parentNode, parentPos) => {
            // We consider headings only
            if (parentNode.type.name !== 'heading')
              return
            
            // Loop through children nodes of the heading
            parentNode.forEach((childNode, childOffset) => {
              // We consider text nodes only
              if (!childNode.isText)
                return
              
              // See if the child node has a '(' near the end. If it doesn't, quit
              const relativePos = childNode.text.lastIndexOf('(')
              if (relativePos === -1)
                return

              // If it does, remove any <small> style up to the '(' and add
              // a <small> style from the '(' to the end of the heading
              const absolutePos = parentPos + childOffset + relativePos + 1 // Why +1 ?
              const mark = mySchema.mark('small')							
              newTr = newTr.removeMark(parentPos, absolutePos, mark)
              newTr = newTr.addMark(absolutePos, parentPos + parentNode.nodeSize, mark)
            })
          })
        })
      })
    })

    // Apply the transaction we have jsut built
    return newTr
  }
}) 	
5 Likes

It works well when typing new text in the editor, but I would like to apply the transaction on the initial content as well. It seems the editor creation is not considered as a transaction. Is there a way to call appendTransaction at init time?

No, but it shouldn’t be hard to just call whatever your appendTransaction is doing at init time, and roll the state forward if it does something.

Thanks a lot, it works great.

I see that this post origins already some time back, but anyway I want to thank you sacha to present the solution, that worked for you.

When I looked at it and thought about it, it makes sense to me. In short: In the appendTransaction you iterate over all transactions with their steps taking the new or lets say final range of each step and checking the nodesBetween, if a treatment is necessary.

However, when I think of longer transactions consisting of many steps or even several of such transactions in a row, when I look at the positions, I wonder if that solution also holds for such a case. - May be I did not get the thing with the position mapping right, but is it not necessary to map the position of each node found that way with every StepMap that comes after in the same transaction, in order to apply the additional change removeMark & addMark at the right place?

An example: Let’s say you have a very powerful command, that takes a bullet list and converts it to a series of headings - someone might want such a feature - then I presume all of that conversion happens in one transaction with quite many steps. So by taking the positions of each step at that moment and by ignoring the steps after I somehow got the impression it could lead to wrong (shifted) positions in all the removeMark & addMark steps that are created by the loop. If just one of the steps of that command cause a position shift - for example the removal of one level in the dom - to remove the ol (ordered list) tag for example - that shift would make all positions of preceding steps inaccurate when appending the new transaction with all the removeMark & addMark steps calculated to those positions. - In this case it would be just +/- 1 but still - not a 100% accurate positioning. There might be other cases where the shift is more and as a result the removeMark & addMark applied at completely wrong positions.

Is that an issue, or am I wrong?

1 Like

Yes, that’s an issue—when interpreting ranges from intermediate steps in the context of the post-transaction document, you’ll want to map them forward through all steps coming after it.

Thank you marijn for your really quick and precise reply. It helps me a lot to consolidate my understanding of the concepts of ProseMirror. - Hope that helps others too.