Preventing image placeholder replacement from being undone

I’m implementing images with upload placeholders, similar to the image upload example. However, I’m using block nodes (instead of inline) for images, and node decorations (instead of widgets) to track placeholders. The intent is to allow editing (e.g. caption) of an image node while the file is uploading. Thus, the node is added to the document immediately, rather than after upload.

Once the upload is complete, I replace the image node’s src attribute with the URL of the uploaded file. However, I’d like to prevent this transaction from being added to the history; src replacement should be transparent to the user and should not be undoable. I’m using tr.setMeta("addToHistory", false) to accomplish this.

I’m wondering, is this a valid approach to prevent attribute replacement from being undone?

I ask because I’ve noticed that undo works as expected in certain circumstances, but not in others. The expected behaviour is that once an image is uploaded and src replaced, undoing will remove the image node. However, the image node is not removed when undoing in certain circumstances.


I’ve created a basic example that exhibits the behaviour here:

(Note: In reality, I’m using a plugin with node decorations to track placeholders. However, that isn’t pertinent to reproducing the behaviour).

To reproduce:

  • [Working] Place cursor in MIDDLE of any paragraph. Click “Insert image” button. Once image is “uploaded”, perform undo. See that image node is removed as expected.

  • [Not working] Place cursor at END of any paragraph. Click “Insert image” button. Once image is “uploaded”, perform undo. See that image node is NOT removed.

1 Like

A transaction with addToHistory set to false will, in principle, create content that can’t be removed by undoing. Since setting an attribute on a node replaces that node, it counts as adding content. If this happens to be in the middle of some previous insertion, and that previous insertion is undone, it’ll be dropped along with that content, but as you found out, this isn’t something you can count on happening.

I can’t think of any easy way to work around this. A non-easy way might be to define a custom step type that sets attributes without replacing content. Something like this (only superficially tested):

const {Fragment, Slice} = require("prosemirror-model")
const {Step, StepResult} = require("prosemirror-transform")

export class SetAttrsStep extends Step {
  // :: (number, Object | null)
  constructor(pos, attrs) {
    super()
    this.pos = pos
    this.attrs = attrs
  }

  apply(doc) {
    let target = doc.nodeAt(this.pos)
    if (!target) return StepResult.fail("No node at given position")
    let newNode = target.type.create(this.attrs, Fragment.emtpy, target.marks)
    let slice = new Slice(Fragment.from(newNode), 0, target.isLeaf ? 0 : 1)
    return StepResult.fromReplace(doc, this.pos, this.pos + 1, slice)
  }

  invert(doc) {
    let target = doc.nodeAt(this.pos)
    return new SetAttrsStep(this.pos, target ? target.attrs : null)
  }

  map(mapping) {
    let pos = mapping.mapResult(this.pos, 1)
    return pos.deleted ? null : new SetAttrsStep(pos.pos, this.attrs)
  }

  toJSON() {
    return {stepType: "setAttrs", pos: this.pos, attrs: this.attrs}
  }

  static fromJSON(schema, json) {
    if (typeof json.pos != "number" || (json.attrs != null && typeof json.attrs != "object"))
      throw new RangeError("Invalid input for SetAttrsStep.fromJSON")
    return new SetAttrsStep(json.pos, json.attrs)
  }
}

Step.jsonID("setAttrs", SetAttrsStep)

Because that step type makes its changes without changing the shape of the document, and thus doesn’t have to export a mapping, it won’t count as an insertion and thus, when the original insertion that created the image is undone, it won’t get in the way.

2 Likes

Since setting an attribute on a node replaces that node, it counts as adding content. If this happens to be in the middle of some previous insertion, and that previous insertion is undone, it’ll be dropped along with that content, but as you found out, this isn’t something you can count on happening.

Well worded, thanks. I had a vague notion this might be the case.

A custom step is not something I’d have considered; happy to add that knowledge to the arsenal. Much thanks for pondering on this and providing an example. I’ve quickly tested it out, and so far it works like a charm :bowing_man::pray:


For those interested, I’ve reworked my original example based on Marijn’s suggestion:

2 Likes