Java Forum / GUI / October 2004
Word-style undo / redo on a JTextPane
Timo - 11 Oct 2004 14:17 GMT Hello all,
I've written a small editor using a JTextPane without having any major problems so far. My undo/redo implementation (using the javax.swing.undo package) had one major disadvantage, though: The undo command would only undo one single character at a time. Instead, it would be nice to have an undo mechanism which, for example, records all changes until an important event occurs, like the beginning of a new paragraph. Then, it puts all these events together into one single event. If the user does an undo, the undo would occur for the whole paragraph instead of a single character - just like in Word. I know about CompoundEdit to create such single edits, I also know about using an UndoManager.
The big problem is, though, how to recognize these important events such as paragraph beginnings etc. To look out for these events, I created a class implementing UndoableEditListener which listens to the edits. Each one of these edits contains specific information about the edit event which can even be seen by printing the event to the console, but I'm not able to access this information because it is in a protected field - to be accurate, in the protected Vector edits field of javax.swing.undo.CompoundEdit. Now my question is: Has anyone done such an undo/redo implementation in Java and could point me into the right direction? Am I already on the right track or do I have to go deep down into Swing and rewrite half the internal event processing stuff :) ? Thanks a lot! Timo
Thomas Fritsch - 11 Oct 2004 14:57 GMT > Hello all, > [quoted text clipped - 24 lines] > Thanks a lot! > Timo Did you already have a look at http://www-uxsup.csx.cam.ac.uk/java/jdk-1.2.2/demo/jfc/Notepad/ or file:///yourJDKdirectory/demo/jfc/Notepad/ ? But I don't know whether the undo/redo implementation there deals with things like paragraph beginnings. May you can see whether it does, when playing with its menu item "Debug - Show elements".
 Signature Thomas<dot>Fritsch<squiggle>ops<dot>de
Timo - 12 Oct 2004 12:41 GMT > Did you already have a look at > http://www-uxsup.csx.cam.ac.uk/java/jdk-1.2.2/demo/jfc/Notepad/ or > file:///yourJDKdirectory/demo/jfc/Notepad/ ? > But I don't know whether the undo/redo implementation there deals with > things like paragraph beginnings. May you can see whether it does, when > playing with its menu item "Debug - Show elements". Unfortunately, the Notepad example app has the same one-character-at-a-time undo implementation. But the hint with the Debug - Show elements may be very helpful: I could check the number of Elements in the Document each time an edit occurs because inserting a new paragraph creates a new Element in the Document. I'm certainly gonna try this out. Thanks a lot,
Timo
Thomas Weidenfeller - 11 Oct 2004 17:09 GMT > (using the javax.swing.undo package) Oh, that almost undocumented atrocity ...
> The big problem is, though, how to recognize these important events > such as paragraph beginnings etc. To look out for these events, I [quoted text clipped - 3 lines] > console, but I'm not able to access this information because it is in > a protected field Something like this in the event handler:
public void undoableEditHappened(UndoableEditEvent ue) { if(ue instanceof AbstractDocument.DefaultDocumentEvent) { AbstractDocument.DefaultDocumentEvent de = (AbstractDocument.DefaultDocumentEvent)ue;
// // Apply whatever heuristics you need, to figure out to continue // UndoableEdit consolidation in a separate // CompoundEdit, to add the separate CompoundEdit, or to add a new // edit. // DocumentEvent.EventType type = de.getType(); int len = de.getLenght(); : .
} else { // // Didn't get specific Document Event, continue to add the // otherwise unknown UndoableEdit // undo.addEdit(ue.getEdit()); } }
/Thomas
Timo - 12 Oct 2004 12:59 GMT > > (using the javax.swing.undo package) > > Oh, that almost undocumented atrocity ... Indeed :)
> DocumentEvent.EventType type = de.getType(); > int len = de.getLenght(); I thought about that and played a bit with the AbstractDocument.DefaultdocumentEvent.getType() method, but this only informs me about changes, inserts and removes on the document. It propably won't handle caret moves and it looks pretty difficult to get info about which key the user pressed and if he did some cut/copy/paste stuff. Thanks,
Timo
Alan Moore - 11 Oct 2004 22:33 GMT > Hello all, > [quoted text clipped - 24 lines] > Thanks a lot! > Timo Instead of having the listener evaluate the edits and decide what to do with them, I find it's better to have the edits themselves make the call. The key is that, when the UndoManager receives an UndoableEdit, it presents the new edit to the last edit it received and gives it a chance to absorb the new one. CompoundEdit only absorbs new edits if isInProgress() returns true, but you can subclass it and override addEdit() to change its criteria. By wrapping all incoming edits in this subclass, you can have certain edits chain themselves together automatically, *after* they've been added to the UndoManager.
It still isn't simple, though. While subclassing CompoundEdit gives you access to the "edits" field, that doesn't do you much good. The actual edit information is still very difficult to get at, and there isn't really enough of it. It won't tell you, for instance, whether a single-character insertion was the result of a keypress or a paste operation, and a replacement will be seen by the listener as a deletion followed by an (apparently unrelated) insertion.
What I do is wrap every user-initiated edit in a CompoundEdit subclass that allows me to assign a unique presentation name to each kind of operation. I do this in the Action objects that actually perform the operations, so there's never any doubt about which "atomic" edits are part of what operation. For example, in DefaultKeyTypedAction, instead of:
target.replaceSelection(content);
...I would have:
target.beginCompoundEdit("typing"); target.replaceSelection(content); target.endCompoundEdit();
...where "target" is now an instance of my custom JTextComponent. Here are the actual methods:
public void beginCompoundEdit(String name) { _compoundEditCount++; if (_compoundEdit == null) { _compoundEditNonEmpty = false; _compoundEdit = new NamedCompoundEdit(name); _compoundEdit.addEdit( new CaretEdit(getCaret().getMark(), getCaret().getDot())); } }
public void endCompoundEdit() { if (_compoundEditCount > 0) { if (--_compoundEditCount == 0) { _compoundEdit.end(); if (_compoundEditNonEmpty && _compoundEdit.canUndo()) { _undoer.addEdit(_compoundEdit); } _compoundEdit = null; } } }
(As you can see, a compound edit may contain other compound edits, or it may be completely empty--but I don't think either case ever really happens. I'll explain about the CaretEdit in a bit.) While a compound edit is in progress, it absorbs any atomic edits received by the listener:
public void undoableEditHappened(UndoableEditEvent evt) { if (_undoer != null && !_undoInProgress) { UndoableEdit edit = evt.getEdit(); if (_compoundEdit != null) { _compoundEditNonEmpty = true; _compoundEdit.addEdit(edit); } else { // never happens _undoer.addEdit(edit); } } }
And at last, here's the NamedCompoundEdit class itself:
/** * <p>NamedCompoundEdit allows us to assign a meaningful * presentation name to the edit by passing the name to the * constructor. Consecutive edits with the same name will * automatically be chained together so that they will all * be undone at once.</p> * * <p>Note that, for this to work properly, CaretEdits should be * added to the UndoManager whenever the user moves the caret (as * opposed to moves that are part of a compound edit or an * undo/redo). These insignificant edits will serve as separators * between significant edits where appropriate (e.g., if the user * types for a while, then moves the caret to another part of the * document and types some more, two separate UndoableEdits will be * created).</p> */ class NamedCompoundEdit extends CompoundEdit { private String name;
public NamedCompoundEdit(String name) { if (name == null || name.length() == 0) { throw new IllegalArgumentException("Invalid name: " + name); } this.name = name; }
public String getPresentationName() { return name; }
public String getUndoPresentationName() { return "Undo " + name; }
public String getRedoPresentationName() { return "Redo " + name; }
public boolean addEdit(UndoableEdit anEdit) { if (!isInProgress() && !canAbsorb(anEdit)) { return false; }
edits.addElement(anEdit); return true; }
private boolean canAbsorb(UndoableEdit anEdit) { String editName = anEdit.getPresentationName(); if (editName.equals(name)) { return true; } else if ("typing".equals(name) && ("Backspace".equals(editName) || "Delete".equals(editName))) { return true; } else if ("typing".equals(editName) && ("Backspace".equals(name) || "Delete".equals(name))) { name = "typing"; return true; } return false; } }
So, if two consecutive edits are received with the same name, the first one absorbs the second. I also treat "Delete" and "Backspace" as equivalent to "typing" because, well, I do a lot of that, and it's much less annoying this way :/
When an insignificant edit is added directly to the UndoManager, it's just a placeholder, so CaretEdits serve to separate unrelated edits without polluting the undo stack. When they're part of a CompoundEdit, though, UndoManager actually pays attention to them; adding one to the beginning of every compound edit causes the caret position and selection to be restored as part or the undo. I override the fireCaretUpdate() method to generate the 'dummy' CaretEdits:
protected void fireCaretUpdate(CaretEvent evt) { super.fireCaretUpdate(evt); _undoer.addEdit( new CaretEdit(getCaret().getMark(), getCaret().getDot())); }
And here's the CaretEdit class:
class CaretEdit extends AbstractUndoableEdit { private int mark; private int dot;
CaretEdit(int mark, int dot) { this.mark = mark; this.dot = dot; }
public boolean isSignificant() { return false; }
public String getPresentationName() { return "caret move"; }
public void undo() throws CannotUndoException { super.undo(); try { setCaretPosition(mark); moveCaretPosition(dot); } catch (Exception ex) { throw new CannotUndoException(ex); } }
public boolean addEdit(UndoableEdit edit) { if (edit instanceof CaretEdit) { edit.die(); return true; } else { return false; } } }
There's a problem with the dummy edits, though. If I undo an edit, then move the caret, I can't redo the edit that I just undo'd (dude!). UndoManager doesn't care that CaretEdits are not significant; it just throws away the whole redo stack. I'm going to have to come up with another way to handle that.
Timo - 12 Oct 2004 17:01 GMT <snip>
> What I do is wrap every user-initiated edit in a CompoundEdit subclass > that allows me to assign a unique presentation name to each kind of > operation. I do this in the Action objects that actually perform the > operations, so there's never any doubt about which "atomic" edits are > part of what operation. Wow. Seems that you already spent a lot of time on this problem :) It looks a bit complicated, but very very promising. Fortunately, the weather is bad and I got nothing else to do for the next few days so there'll be plenty of time to try it out :) Thank you very much for this!
Timo
Alan Moore - 12 Oct 2004 19:37 GMT > Wow. Seems that you already spent a lot of time on this problem :) It > looks a bit complicated, but very very promising. Fortunately, the [quoted text clipped - 3 lines] > > Timo Yeah, I've spent a lot of time on this problem, but this is one feature that's just not negotiable--if you're going to have Undo at all, it has to work at least this well. And if you're not going to have Undo, why are you bothering to write an editor? ;)
There may be a simpler way to get undo to work sensibly WRT typing, but my approach has another big advantage: the presentation name always relfects what the user actually did. In the default implementation, the UndoableEdits have totally generic names like "addition", "removal", "change" (or something like that). But in my editor, when I hover the mouse pointer over the Undo button, the tooltip says, e.g., "Undo typing" or "Undo Block Comment".
I should mention that the approach I use was originally copied from jEdit. My version has changed considerably since then, and I believe jEdit's has, too. So that's another resource you could could check out.
Alan
Timo - 25 Oct 2004 21:47 GMT Time flies! After wrecking my car (and someone else's - thank heavens no one was hurt) and beginning a new year at the university I finally found a little time to look into this problem again. And after quite a few failed attempts of doing this on my own, I have another question. Alan wrote:
> For example, in DefaultKeyTypedAction, > instead of: [quoted text clipped - 6 lines] > target.replaceSelection(content); > target.endCompoundEdit(); I guess by DefaultKeyTypedAction, you mean DefaultEditorKit.DefaultKeyTypedAction. So I tried to replace this action with one of my own, but with no success. I tried a lot of subclassing DefaultEditorKit, but none of it worked and there seems no method such as DefaultEditorKit.setAction() to replace an action. Am I on the right track here but missing a few points or am I entirely on the wrong way? Thank you for any help!
Timo
Alan Moore - 26 Oct 2004 08:47 GMT > I guess by DefaultKeyTypedAction, you mean > DefaultEditorKit.DefaultKeyTypedAction. So I tried to replace this [quoted text clipped - 3 lines] > on the right track here but missing a few points or am I entirely on > the wrong way? I didn't go into how to replace a text component's actions with your own because there was already so much information in my previous post, but it isn't particularly obvious. With most actions, you can get the text component's ActionMap and put your own action in it under the same key to replace the original one. But the default ("typing") action is a special case; the only way to get at it is through the Keymap. Here's some sample code that shows how to replace both kinds of action, along with a way to tack on the undo-related stuff.
===================================================================
import java.awt.event.ActionEvent; import javax.swing.*; import javax.swing.text.*;
public class Test { public static void main(String[] args) { JTextField tf = new JTextField();
Action bksp = tf.getActionMap().get( DefaultEditorKit.deletePrevCharAction); tf.getActionMap().put( DefaultEditorKit.deletePrevCharAction, new InstrumentedAction(bksp, "Backspace"));
Action def = tf.getKeymap().getDefaultAction(); tf.getKeymap().setDefaultAction( new InstrumentedAction(def, "typing"));
JOptionPane.showConfirmDialog(null, tf, "Test", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE); }
static class InstrumentedAction extends AbstractAction { private Action delegate; private String label;
public InstrumentedAction(Action delegate, String label) { this.delegate = delegate; this.label = label; }
public void actionPerformed(ActionEvent evt) { System.out.println("Performing action: " + label); // beginCompoundEdit(label); delegate.actionPerformed(evt); // endCompoundEdit(); } } }
=============================================================
I just noticed that the default action gets executed whenever, e.g., backspace or Ctrl-V is pressed, in addition to the correct actions for those keys. Obviously it doesn't hurt anything, though, or I would have noticed it long before this!
There's a boatload of actions in DefaultEditorKit, but you only have to replace the ones that mutate the document:
DefaultEditorKit.insertBreakAction DefaultEditorKit.insertTabAction DefaultEditorKit.deletePrevCharAction DefaultEditorKit.deleteNextCharAction DefaultEditorKit.cutAction DefaultEditorKit.pasteAction DefaultEditorKit.insertContentAction
(Actually, that last one doesn't seem to be used for anything.)
Free MagazinesGet these publications absolutely FREE for up to 12 months. There are no hidden fees and no obligation. Simply choose a title, complete the application form and submit it. Read more ...
|
|
|