Native Undo History


#1

Hi there, I noticed that the native undo / redo input events historyUndo / historyRedo events do not work as expected in ProseMirror.

Steps to reproduce in Chrome 72:

  1. Visit http://prosemirror.net/
  2. Create paragraph and insert a few characters “abcd”
  3. repeat step 2.
  4. Right-click somewhere in the editor and click undo

(The last step may differ depending on your OS. I can reproduce this in Ubuntu and Mac. In Mac you can also click Edit->Undo in the browser menu at the top)

I noticed that the undo() / redo() functions in prosemirror-history are not called. Instead the native undo/redo implementation tries to do its thing. I also noticed that you can’t undo certain parts of the editor content when using native undo. I suspect that the native undo history only keeps track of the content that was created by the user.

I tried to intercept the historyUndo and historyRedo events and call undo(); event.stopPropagation() within the event handler. This works great, with the exception that the right click menu (and the browser menu) do not offer the option to Redo anymore. The reason is probably that the native undo history is empty, because the undo event was cancelled.

Is it possible to make the native undo / redo menu work with prosemirror-history?


#2

I wasn’t aware such events existed (and can’t find anything about them online). Which browsers support them?

Unfortunately, not that I know of. The ongoing work on beforeInput events might end up helping here, but for now, hooking into the native history controls isn’t really possible.


#3

Thanks a lot for the fast answer!

Unfortunately there is very little information about this event. Even the w3c spec only mentions them briefly: https://www.w3.org/TR/input-events-2/ (search for historyUndo)

This is what works for me in Chrome / Electron:

handleDOMEvents: {
  beforeinput: (view, event) => {
    switch (event.inputType) {
      case 'historyUndo':
        this.execCommand('undo');
        event.preventDefault();
        return true;
      case 'historyRedo':
        this.execCommand('redo');
        event.preventDefault();
        return true;
      default:
        return false;
    }
  }
}

Please note that beforeinput currently is only supported in WebKit based browsers.

As I mentioned, the redo button does not work with the above approach, because it thinks that the history is empty. My hope is that I can somehow trick the native undo history to think there is something on the undo-stack.

But for now I’m wondering if we should catch historyUndo by default. The faulty behavior of the native input event is pretty confusing.

I will report in this thread when I find an approach to trick the undo history.


#4

Ah, I see, those are types of beforeinput events. Maybe @johanneswilm, who’s been involved in these specs, can comment on whether there’s any intended way for software that overrides the history to control redo availability? (A crude trick might be to allow the effect of the native undo, and then run our own undo and clean up the DOM to match our view of the document.)


#5

Hey, yes so the problem is that browser makers have a very different view of how undo/redo work than how the web works. In the browsers’ view, there should just be one undo/redo stack for everything on the same webpage. So say you have two different textareas on the same page, or some form elements and an editor, the browsers believe that they should all share the same undo stack. In the case of Safari, they go a step further in that for them it’s a “platform behavior” that has to be preserved so that web apps work the same as native apps.

It is somewhat complex to change their minds, at least in the short term, as the editors of those same browser makers (Apple iCloud Pages, Google Docs, Microsoft Word 365) all suffer under that and all try to override it as much as possible, but it seemingly has not influenced the view of the browser makers. I try to explain the situation to them whenever possible.

There is a little bit of hope right now though in that Safari has been experimenting with an API that will allow JavaScript to add items to the global undo stack programmatically. Those items will still be rather useless, but it should then be possible to add 2 items, undo one of them, and now have an enabled redo and undo button that can be used by JavaScript.


#6

Thanks for the clarification! File under ‘we can’t have nice things because browser programmers think they know what we need better than we do’.


#7

Thank you @johanneswilm for this information and for fighting for browser sanity :face_with_head_bandage:


#8

As @johanneswilm mentioned, there is only one undo/redo stack for everything on the page. My idea was to cancel all undo/redo events on the actual editor, and perform actions on an invisible element instead.

MDN mentions that execCommand('undo') undo’s the last executed command (not the last user input).

With this information I came up with a method to manipulate the browser undo/redo stack. I only want the browser to show the undo/redo buttons from the right-click menu. You can clone my example from github. But I will attach the code below too.

You can see that the undo/redo buttons work as expected. Notice that the redo button is disabled when prosemirror’s redo-stack is empty. Unfortunately this currently only works in Chrome/Chromium. But it has no downsides to other Browsers.

I dislike to keep visible elements floating around in the document. But I found out that I can remove the invisible element from the document after each action.

The complete source code:

import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { Schema, DOMParser } from 'prosemirror-model'
import { schema } from 'prosemirror-schema-basic'
import { addListNodes } from 'prosemirror-schema-list'
import { exampleSetup } from 'prosemirror-example-setup'
import { undo, redo } from 'prosemirror-history'

// Mix the nodes from prosemirror-schema-list into the basic schema to
// create a schema with list support.
const mySchema = new Schema({
  nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'),
  marks: schema.spec.marks
})

/**
 * Create a hidden contenteditable element
 * We perform fake actions on this element to manipulate the browser undo stack
 * We can add/remove this element from the document as we see fit,
 * but it needs to be in the document when we manipulate it.
 */
const undoMock = document.createElement('div')
undoMock.setAttribute('contenteditable', 'true')
undoMock.setAttribute('style', 'position:fixed; bottom:-5em;')

const setSelection = range => {
  const sel = window.getSelection()
  const previousRange = sel.rangeCount > 0 ? sel.getRangeAt(0) : null
  sel.removeAllRanges()
  sel.addRange(range)
  return previousRange
}

/**
 * By performing a fake action on `undoMock` we force the browser to put something on its undo-stack.
 * This also forces the browser to delete its redo stack.
 */
const simulateAddToUndoStack = () => {
  document.body.insertBefore(undoMock, null)
  const range = document.createRange()
  range.selectNodeContents(undoMock)
  const restoreRange = setSelection(range)
  document.execCommand('insertText', false, 'x')
  setSelection(restoreRange)
  undoMock.remove()
  return restoreRange
}

/**
 * By performing a fake undo on `undoMock`, we force the browser to put something on its redo-stack
 */
const simulateAddToRedoStack = () => {
  document.body.insertBefore(undoMock, null)
  // Perform a fake action on undoMock. The browser will think that it can undo this action.
  const restoreRange = simulateAddToUndoStack()
  // wait for the next tick, and tell the browser to undo the fake action on undoMock
  setTimeout(() => {
    document.execCommand('undo')
    // restore previous selection
    setSelection(restoreRange)
    undoMock.remove()
  }, 0)
}

window.view = new EditorView(document.querySelector('#editor'), {
  state: EditorState.create({
    doc: DOMParser.fromSchema(mySchema).parse(document.querySelector('#content')),
    plugins: exampleSetup({ schema: mySchema })
  }),
  handleDOMEvents: {
    beforeinput: (view, event) => {
      switch (event.inputType) {
        case 'historyUndo':
          undo(view.state, view.dispatch)
          event.preventDefault()
          simulateAddToRedoStack()
          return true
        case 'historyRedo':
          redo(view.state, view.dispatch)
          if (!redo(view.state)) {
            // By triggering another action, we force the browser to empty the redo stack
            // Then the redo button is disabled
            simulateAddToUndoStack()
          }
          event.preventDefault()
          return true
        default:
          return false
      }
    }
  }
})

#9

@dmonad I created something similar a few years ago to figure out whether it was possible to get around the limitations the browser makers put on us. And, as you, I came to the conclusion that it wasn’t, at least for the redo button. But if this works for you for now, I guess it is better than having nothing at all.


#10

I think you got me wrong @johanneswilm. PM and native Undo/Redo now interact nicely in my example. Today I even made it work in Safari.

In my conclusion, I think that you can trick WebKit browsers well enough to make native input events work with editors like PM. Am I missing anything here @johanneswilm?

I haven’t found a way to detect historyUndo / historyRedo events in Firefox. The input event that is triggered by an undo command does not hold any information about the action that triggered it. So if I understand correctly there is currently no way to detect undo’s. Nor is there a way to interact with Firefox’s UndoManager. Does anyone know a way around the input event limitation?

Updated example:

import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { Schema, DOMParser } from 'prosemirror-model'
import { schema } from 'prosemirror-schema-basic'
import { addListNodes } from 'prosemirror-schema-list'
import { exampleSetup } from 'prosemirror-example-setup'
import { undo, redo } from 'prosemirror-history'

// Mix the nodes from prosemirror-schema-list into the basic schema to
// create a schema with list support.
const mySchema = new Schema({
  nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'),
  marks: schema.spec.marks
})

/**
 * Create a hidden contenteditable element
 * We perform fake actions on this element to manipulate the browser undo stack
 */
const undoMock = document.createElement('div')
undoMock.setAttribute('contenteditable', 'true')
undoMock.setAttribute('style', 'position:fixed; bottom:-5em;')
document.body.insertBefore(undoMock, null)

const setSelection = range => {
  const sel = window.getSelection()
  const previousRange = sel.rangeCount > 0 ? sel.getRangeAt(0) : null
  sel.removeAllRanges()
  sel.addRange(range)
  return previousRange
}

/**
 * By performing a fake action on `undoMock` we force the browser to put something on its undo-stack.
 * This also forces the browser to delete its redo stack.
 */
const simulateAddToUndoStack = () => {
  const range = document.createRange()
  range.selectNodeContents(undoMock)
  const restoreRange = setSelection(range)
  document.execCommand('insertText', false, 'x')
  setSelection(restoreRange)
  return restoreRange
}

let simulatedUndoActive = false

/**
 * By performing a fake undo on `undoMock`, we force the browser to put something on its redo-stack
 */
const simulateAddToRedoStack = () => {
  // Perform a fake action on undoMock. The browser will think that it can undo this action.
  const restoreRange = simulateAddToUndoStack()
  // wait for the next tick, and tell the browser to undo the fake action on undoMock
  simulatedUndoActive = true
  try {
    document.execCommand('undo')
  } finally {
    simulatedUndoActive = false
  }
  // restore previous selection
  setSelection(restoreRange)
}

const view = new EditorView(document.querySelector('#editor'), {
  state: EditorState.create({
    doc: DOMParser.fromSchema(mySchema).parse(document.querySelector('#content')),
    plugins: exampleSetup({ schema: mySchema })
  }),
  handleDOMEvents: {
    beforeinput: (view, event) => beforeinputHandler(event)
  }
})

const beforeinputHandler = event => {
  // we only handle user interactions
  if (simulatedUndoActive) {
    return
  }
  switch (event.inputType) {
    case 'historyUndo':
      event.preventDefault()
      undo(view.state, view.dispatch)
      if (undo(view.state)) {
        // we can perform another undo
        simulateAddToUndoStack()
      }
      simulateAddToRedoStack()
      return true
    case 'historyRedo':
      event.preventDefault()
      redo(view.state, view.dispatch)
      if (!redo(view.state)) {
        // by triggering another action, we force the browser to empty the undo stack
        simulateAddToUndoStack()
      } else {
        simulateAddToRedoStack()
      }
      return true
  }
  return false
}

// In safari the historyUndo/Redo event is triggered on the undoMock
// In Chrome these events are triggered on the editor
undoMock.addEventListener('beforeinput', beforeinputHandler)

#11

I meant that there was no way to make it work reliably across the four browser engines that existed back then. Now that you have it working in Safari you have gotten further than what I did.if you get it working everywhere, I’ll take that back to the browser makers as evidence that they cannot really stop us from doing this, so there is no reason why they shouldn’t make it accessible through a nicer API.


#12

Firefox is currently implementing the beforeinput event. If it hasn’t shipped yet, it should be shipped in one of the next versions.


#13

That’s fantastic news! I will get an experimental version of Firefox make it work there too. This is really good timing.

All we really need is an API to tell the Browser that we performed an undo / redo action on element x. This doesn’t even affect the Browser vendors decisions to have different scopes for the UndoManager.

There are lots of good arguments in the above channel. If my example helps to make the web a bit more accessible I’d be really happy.


#14

That could work if you are ok with the “global” undo manager. Most editing apps seem to not be ok with it, but I also think that will likely be the compromise we end up with. First preference, for me, would be to get a way to simply enable or disable the undo/redo buttons at will and then listen to the beforeinput event.


#15

I see that a global undo manager is gets in the way of any document editing app. Imagine a note-editing app with a custom search tool (e.g. Google Docs).

  • Type a string “abc”
  • Ctrl+f and search string “bc”
  • Resume editing the document “def”

With a global undo manager it would also undo the content of the search string (which is confusing to say the least). Annotations and discussions in Google Docs would also share the same undo manager. This is probably why they disabled the native undo redo button completely. I guess accessibility gets in their way.

But on the other hand an undo manager that works for only one element does not work either. Image a note editing app with a Title field that is not part of the editor. You would expect that hitting undo reverts the changes in the title and in the editor.

Ideally we could tell the browser to create a separate undo manager for a set of elements. The browser should also recognize which element is clicked when the context menu is shown. Currently, you can undo on textarea A, which might trigger an undo in textarea B. This is extremely confusing if you can’t see textarea B.

But I think that the concern of the scope of the undo manager can be separated from the issue of telling the browser that an undo / redo happened on an element. As the state of the web is now, the native undo redo buttons do not work well on contenteditable at all:

  • Chrome/Safari disable undo / redo when you replace the element that is referenced by the undo / redo stack. Chrome thinks there is nothing to undo / redo anymore.
  • Firefox disables redo for seemingly random reasons in contenteditable elements.

About the state of mocking undo manager in Firefox: Firefox will support eventType on Input Events in version 66 - which will ship next week. However, it does not support beforeinput.

I added support for native undo / redo by listening to input events and reverting the changes. The redo-button works unreliably in Firefox. But this is due to Firefox’s unreliable implementation of redo on contenteditable elements - not because of my hack.

The next step will be to implement support on Edge. But it may take a while until I get my hands on a Windows machine.


#16

I think whether that feels good or bad to the user depends very much on how you have layouted the page and whether different colors are used, etc. . So yes, I very much agree with you that this should be up to the website developer/JavaScript to define. Notice though that the browser makers at best have one developer working on contenteditable. So if we get a feature as you describe it, there is a chance that it will be full of bugs that won’t be fixed for another decade. That’s why I’m saying it may be better to simply get the option to enable/disable the buttons and figure our about the rest in JavaScript.

Ok, thanks for the update. The Firefox developer has been quite active in the github discussion on Input Events lately as he was implementing it. Before input events should come next, but I’ll need to write tests for them to make sure their implementations are working according to spec. I think I’ll have time to get started on that in the second half of this month.


#17

Being able to manipulate the state of the buttons (preferably per element) would work for me. Though at this time browsers don’t fire historyUndo when the undo stack is empty. So with this approach, we would also need to find a method to tell the browser to actually fire undo/redo events if the stack is empty. I proposed to work with the undo stack, because I thought it would work better with the existing implementation. But in the end I wouldn’t mind either of the approaches as long as I can manipulate the scope and state of undo / redo buttons.


#18

Absolutely. The two would go hand in hand: enable the button and fire the event. events should usually also fire even if the browser itself would not make any change to the DOM. Its meant as a “The user has expressed the wish to do X” even if the browser thinks that wish is impossible to fulfill at times.

Same here. I think the best is simply to present a number of different ideas to the browser makers which require different levels of commitment on their part.


#19

This makes a lot of sense. This is how it should work.

I’d greatly appreciate if you could keep me updated if you go into a conversation with them. Also please let me know if I can help out in any way.