Chapter 5. More commands

Table of Contents

1. Highlighting text
1.1. Marks other than dot, mark, selected and selected2
1.2. Description of prepare
1.3. Step by step description of execute
2. A validating command
2.1. ElementEditor
2.2. Step by step description of prepare
2.3. Step by step description of execute
3. How to avoid writing a validating command
3.1. Delegating node insertion to the ubiquitous Paste command
3.2. Step by step description of prepare
3.3. Step by step description of execute
4. Recordable commands

In the previous chapter we have learned:

But reading the code of a single command is not sufficient to have a clear idea of what is a command and how to write a custom one.

In this chapter, we will study three very different commands:

ShowMatchingCharCmd

When a character such as ')' is typed, inserts this character at caret position and then highlights matching '(' for half a second. (Identical to the standard showMatchingChar command. See Section 97, “showMatchingChar” in XMLmind XML Editor - Commands.)

WrapElementCmd

Wraps selected element in a new parent element which is chosen interactively using the element chooser dialog box. (Same behavior than the standard wrap command in the case of single element selection. See Section 110, “wrap” in XMLmind XML Editor - Commands.)

MakeParagraphsCmd

Converts text lines contained in the clipboard to a sequence of paragraphs. (A simplified version of the formatTextAs command. See Section 38, “formatTextAs” in XMLmind XML Editor - Commands.)

1. Highlighting text

1.1. Marks other than dot, mark, selected and selected2

When a character such as ')' is typed, ShowMatchingCharCmd inserts this character at caret position and then highlights matching '(' for half a second.

When writing such command, a developer may be tempted to find the bounding box of the matching '(' using methods such as DocumentView.modelToView and to directly draw the highlight on the DocumentPane containing the DocumentView.

Commands should never do this. Commands should only deal with nodes and selection marks.

Nodes which are contained in a Document are rendered graphically using NodeViews created by a ViewFactory. Similarly, Marks which are attached to Nodes and which managed by a MarkManager are rendered graphically using Highlights created by a HighlightFactory.

Dot, mark, selected, selected2 are special marks only because standard commands operate on them. An application can create its own marks for any purpose.

A custom mark has an ID (any Object which implements hashCode() and equals() will do) and is attached to a Node. It is created using MarkManager.set(Object id, Node node) for NodeMarks and MarkManager.set(Object id, TextNode text, int offset) for TextLocations.

Standard commands completely ignore these custom marks. For example, standard command CancelSelection will leave these marks intact. The only way to get rid of them is either to remove them using MarkManager.remove or to delete the Nodes they are attached to.

Custom marks are rendered graphically using Highlights created by the HighlightFactory of the DocumentView. By default, the HighlightFactory of a DocumentView is a BasicHighlightFactory.

A BasicHighlightFactory creates Highlights which are:

  • Similar to the caret for a TextLocation with a String ID which starts with "bookmark.".

    For example, it will create a colored vertical bar after a text location marked "bookmark.foo".

  • Similar to the text selection for two TextLocations with String IDs which start with "highlight." and "highlight2.".

    For example, it will create a colored background behind the area between a text location marked "highlight.bar" and another text location marked "highlight2.bar".

  • Similar to the node selection for two NodeMarks with String IDs which start with "highlight." and "highlight2.".

    For example, it will create a colored box around the area between a node marked "highlight.wiz" and its sibling node marked "highlight2.wiz".

Therefore, in order to highlight the matching '(', ShowMatchingCharCmd creates a TextLocation named "highlight.matchingCharN" before the matching '(' and a TextLocation named "highlight2.matchingCharN" after the matching '('. After half a second, ShowMatchingCharCmd automatically removes these marks.

1.2. Description of prepare

public class ShowMatchingCharCmd extends CommandBase
                                 implements Traversal.Handler {
    private int highlightId = 0;

    private char matchingChar;
    private char insertedChar;
    private int charCount;
    
    public boolean prepare(DocumentView docView, 
                           String parameter, int x, int y) {
        if (parameter == null || parameter.length() != 1)
            return false;

        return docView.canInsertString();
    }
    ...

ShowMatchingCharCmd can be executed if:

  • The document view is not empty.

  • The document loaded in the document view contains some text. In such case, the caret shows where to insert ')', ']' or '}'.

  • The text node containing the caret has not been marked as being read-only.

All the above conditions are tested by DocumentView.canInsertString.

1.3. Step by step description of execute

    ...
    public Object execute(DocumentView docView, 
                          String parameter, int x, int y) {
        String s = parameter.substring(0, 1);
        docView.insertString(s);1

        insertedChar = s.charAt(0);
        switch (insertedChar) {2
        case '}':
            matchingChar = '{';
            break;
        case ')':
            matchingChar = '(';
            break;
        case ']':
            matchingChar = '[';
            break;
        default:
            matchingChar = '\0';
        }
        if (matchingChar == '\0') {
            docView.getToolkit().beep();
            return null;
        }

        final MarkManager markManager = docView.getMarkManager();
        TextLocation dot = markManager.getDot();

        charCount = 0;
        TextOffset match = null;
        if (dot.getOffset() > 0)3
            match = processTextNode(dot.getTextNode(), dot.getOffset() - 1);
        if (match == null)4
            match = (TextOffset) Traversal.traverseBefore(dot.getTextNode(), 
                                                          this);
        if (match == null) {
            docView.getToolkit().beep();
            return null;
        }

        Rectangle r1 = docView.modelToView(match.text, match.offset);5
        Rectangle r2 = docView.getVisibleRectangle();
        // r1 is null if the matching char is contained in a collapsed section.
        if (r1 == null || r1.height <= 0 || 
            r2.height <= 0 || !r1.intersects(r2)) {6
            char[] chars = match.text.getTextChars();

            int count = 1;
            int boundary = match.offset - 1;
            loop: while (boundary >= 0) {
                switch (chars[boundary]) {
                case '\n': case '\r':
                    break loop;
                }

                ++count;
                if (count == 80)
                    break;

                --boundary;
            }
            
            String line = new String(chars, boundary+1, match.offset-boundary);
            line = XMLText.collapseWhiteSpace(line);
            if (boundary >= 0 && count == 80)
                line = "..." + line;

            ShowStatus.showStatus(docView.getPanel(), "Matches '" + line + "'");
            return null;
        }

        final String id1 = "highlight.matchingChar" + highlightId;
        final String id2 = "highlight2.matchingChar" + highlightId;
        ++highlightId;

        markManager.add(id2, match.text, match.offset+1);7
        markManager.add(id1, match.text, match.offset);

        Timer timer = 
            new Timer(500 /*ms*/, new ActionListener() {
                public void actionPerformed(ActionEvent e) {
                    markManager.remove(id1);
                    markManager.remove(id2);
                }
            });
        timer.setRepeats(false);
        timer.start();8

        return null;
    }
    ...

1

DocumentView.insertString inserts specified string before caret. If there is a text selection, insertString deletes the nodes specified by the selection and then inserts its argument.

2

Find character corresponding to inserted character.

3

If the caret is not located a the very beginning of a text node, search matching character before the caret.

4

If matching character has not been found, search it in nodes which precede the node containing the caret.

5

Matching character is found. ShowMatchingCharCmd doesn't need to highlight it if it is out of sight. DocumentView.modelToView and getVisibleRectangle are used to determine whether the matching character is out of sight.

6

When matching character is out of sight, ShowMatchingCharCmd simply prints a message containing the text line which precedes this character.

7

TextLocations with IDs "highlight.matchingCharN" and "highlight2.matchingCharN" are added around the matching character.

8

After 500ms, a Timer removes TextLocations with IDs "highlight.matchingCharN" and "highlight2.matchingCharN".

The following Traversal.Handler is used to find the matching '(' in nodes which precede the node where ')':

    ...
    private TextOffset processTextNode(TextNode textNode, int from) {
        char[] chars = textNode.getTextChars();

        if (from < 0)
            from = chars.length - 1;

        for (int i = from; i >= 0; --i) {
            char c = chars[i];

            if (c == insertedChar) {
                ++charCount;
            } else if (c == matchingChar) {
                --charCount;
                if (charCount == 0)
                    return new TextOffset(textNode, i);
            }
        }

        return null;
    }

    // ---------------------------------------
    // Traversal.Handler
    // ---------------------------------------

    public Object processText(Text text) {
        return processTextNode(text, -1);
    }

    public Object processPI(ProcessingInstruction pi) {
        return processTextNode(pi, -1);
    }

    public Object processComment(Comment comment) {
        return processTextNode(comment, -1);
    }

    public Object enterElement(Element element) {
        return null;
    }

    public Object leaveElement(Element element) {
        return null;
    }
}

2. A validating command

WrapElementCmd wraps explicitly or implicitly selected element in a new parent element.

Example:

"<blockquote><para>the <emphasis>little</emphasis> girl.</para></blockquote>"

In the above blockquote, para is implicitly selected because the caret is before word "girl". User chooses to wrap it in a note element, which gives:

"<blockquote><note><para>the <emphasis>little</emphasis> girl.</para></note><blockquote>".

2.1. ElementEditor

WrapElementCmd is an example of a validating command, that is, a command which cannot make the document invalid by inserting, deleting or replacing nodes.

This kind of command often requires the user to specify which element is to be inserted in the document. In such case, the chooser dialog box should only display elements which, when inserted in the document, cannot make it invalid.

This kind of command always uses an ElementEditor.

An ElementEditor is a validating editor which operates on the child nodes of a given element.

In order to use an ElementEditor, you need to create it and then to specify which element is being edited:

ElementEditor myElementEditor = new ElementEditor(myDocumentType);
myElementEditor.setEditedElement(myElement);

It implements a number of editing operations: delete, insert, replace, convert, wrap, cut, paste, etc.

For each operation, there are 3 methods:

Validating commands operating on the text selection rather than on the node selection use a TextEditor rather than an ElementEditor.

2.2. Step by step description of prepare

public class WrapElementCmd extends CommandBase {
    private Element element;

    public boolean prepare(DocumentView docView, 
                           String parameter, int x, int y) {
        element = docView.getSelectedElement(/*implicit*/ true);1
        if (element == null)
            return false;

        Element editedElement = element.getParentElement();2
        if (editedElement == null)
            return false;
        ElementEditor elementEditor = docView.getElementEditor();3
        elementEditor.editElement(editedElement);4

        return elementEditor.canWrap(element, element);5
    }
    ...

1

WrapElementCmd can be executed only if a single element is implicitly or explicitly selected. getSelectedElement(true) returns this implicitly or explicitly selected element. It returns null if text or multiple nodes are selected.

2

The element being edited is the parent of the selected element.

3

Commands do not need to create ElementEditors or TextEditors. The DocumentView has ready-to-use validating editors. Such editors can be accessed using getElementEditor and getTextEditor.

4

editElement is more efficient than setEditedElement but can only be used with the ElementEditor owned by the DocumentView.

5

Tests if there is at least one parent element which can be used to wrap selected element.

2.3. Step by step description of execute

    ...
    public Object execute(DocumentView docView,
                          String parameter, int x, int y) {
        ElementEditor elementEditor = docView.getElementEditor();1
        Element editedElement = element.getParentElement();

        ArrayList<Field> fieldList = new ArrayList<Field>();
        elementEditor.canWrap(element, element, fieldList);2

        Field[] fields = new Field[fieldList.size()];
        fieldList.toArray(fields);

        String title = "Wrap Element";
        FieldChooserDialog dialog = 
            new FieldChooserDialog(docView.getPanel(), title);

        FieldChoice[] choices = 
            FieldChoice.list(fields, /*includingText*/ false, 
                             /*includingWildcards*/ true,
                             /*includingTemplates*/ false, editedElement);3
        FieldChoice choice = dialog.chooseField(choices, editedElement);4
        if (choice == null)
            return null;
        
        MarkManager markManager = docView.getMarkManager();
        markManager.beginMark();

        Element wrapper = elementEditor.wrap(element, element,
                                             choice.field, choice.name);5

        docView.describeUndo(title);

        markManager.remove(Mark.MARK);
        markManager.remove(Mark.SELECTED2);
        markManager.set(Mark.SELECTED, wrapper);6
        docView.moveDotInto(wrapper);

        markManager.endMark();

        return null;
    }
}

1

There is no need to call editElement(editedElement). Remember that execute is never invoked without being preceded by prepare. Therefore the ElementEditor is necessarily properly configured.

2

canWrap fills ArrayList fieldList with all possible Fields.

3

FieldChoice.list is used to create a properly labeled list of choices out of a list of Fields and a context (editedElement).

4

FieldChooserDialog is displayed to let the user specify which element he wants to use to wrap selected element.

This dialog box returns a FieldChoice describing user's choice or null if user has canceled the command.

5

Wraps selected element in a new parent element.

6

Selects the newly created parent element.

3. How to avoid writing a validating command

3.1. Delegating node insertion to the ubiquitous Paste command

Authors often need to paste in their XML documents text blocks copied from a word processor.

Without a special command, doing so is tedious because:

  1. The author must create a blank paragraph in the XML document.

  2. The author must paste text copied from the clipboard inside this paragraph.

  3. If multiple text blocks have been copied, the author must remove open lines and split the paragraph in multiple pieces.

A special command can do all this automatically. Such a command is a validating one because the author must not be allowed to insert captured paragraphs in places which would make the document invalid.

The Paste command can be passed plain text or an XML string (a string which starts with "<?xml ") as its parameter (see Section 59, “paste” in XMLmind XML Editor - Commands). So why not use this standard validating command to do the paragraph insertion?

The idea here is to write a simple command, MakeParagraphsCmd, which returns an XML string containing one or several paragraphs built using clipboard content. This string is then passed to the Paste command which inserts the paragraphs (if the XML Schema or the DTD constraining the document allows to do so).

Note that MakeParagraphsCmd cannot be used alone. It must be part of a macro-command such as the following one (DocBook example):

  <command name="insertAfterAsSimpara">
    <macro>
      <sequence>
        <command name="MakeParagraphsCmd" parameter="simpara" />
        <command name="paste" parameter="after[implicitElement] %_" />
      </sequence>
    </macro>
  </command>

3.2. Step by step description of prepare

public class MakeParagraphsCmd extends CommandBase {
    private Name elementName;
    private boolean systemSelection;
    private boolean blocks;
    private String pastable;

    public boolean prepare(DocumentView docView, 
                           String parameter, int x, int y) {
        elementName = null;
        pastable = null;

        if (docView.getDocument() == null)
            return false;
        
        if (parameter == null || parameter.length() == 0)1
            return false;

        String name = null;
        int pos = parameter.lastIndexOf(']');
        if (pos < 0)
            name = parameter;
        else if (pos+1 < parameter.length())
            name = parameter.substring(pos+1);

        if (name != null)
            elementName = Name.fromString(name.trim());2
        if (elementName == null)
            return false;

        systemSelection = (parameter.indexOf("[systemSelection]") >= 0);3
        blocks = (parameter.indexOf("[blocks]") >= 0);4

        if (systemSelection)
            pastable = ClipboardUtil.getSelection();
        else
            pastable = ClipboardUtil.getString();
        if (pastable == null || pastable.length() == 0 || 
            pastable.startsWith("<?xml "))5
            return false;

        return true;
    }
    ...

1

This command must be passed a parameter.

2

After options [systemSelection] and [blocks], the parameter must contain a fully qualified element name. This name specifies the kind of paragraph element that MakeParagraphsCmd will create.

DocBook 4 examples: para or simpara.

DocBook 5 examples: {http://docbook.org/ns/docbook}para or {http://docbook.org/ns/docbook}simpara[5].

XHTML example: {http://www.w3.org/1999/xhtml}p.

3

If the [systemSelection] option has been specified, MakeParagraphsCmd reads text lines from the system selection (Unix/X-Window only) rather than from the clipboard.

4

If the [blocks] option has been specified, MakeParagraphsCmd converts multiple text lines separated by open lines to a single paragraph. Without this option, each non-empty line is converted to a paragraph.

5

MakeParagraphsCmd cannot be executed if the clipboard is empty or if it contains XML.

3.3. Step by step description of execute

    ...
    public Object execute(DocumentView docView, 
                          String parameter, int x, int y) {
        ArrayList<String> textList = new ArrayList<String>();

        StringBuilder block = new StringBuilder();

        LineNumberReader lines = 
            new LineNumberReader(new StringReader(pastable));
        String line;
        while ((line = readLine(lines)) != null) {1
            line = XMLText.collapseWhiteSpace(line);2
            if (line.length() == 0) { 
                // Open line.
                if (blocks && block.length() > 0) {
                    textList.add(block.toString());
                    block = new StringBuilder();
                }

                continue;
            }

            line = XMLText.filterText(line);3

            if (blocks) {
                if (block.length() > 0)
                    block.append(' ');
                block.append(line);
            } else {
                textList.add(line);
            }
        }

        if (blocks && block.length() > 0)
            textList.add(block.toString());

        int count = textList.size();
        if (count == 0)
            return EXECUTION_FAILED;

        // The content of the clipboard is fetched as a *Java String*. 
        // Therefore no need to specify encoding in the XML declaration.

        StringBuilder result = new StringBuilder();
        result.append("<?xml version='1.0'?>");4

        openTag(ClipboardFormat.ENVELOPE_NAME, result);5
        for (int i = 0; i < count; ++i) {
            String text = (String) textList.get(i);

            openTag(elementName, result);
            XMLText.escapeXML(text, result);
            closeTag(elementName, result);
        }
        closeTag(ClipboardFormat.ENVELOPE_NAME, result);
        
        return result.toString();
    }
    ...

1

This loop reads the content of the clipboard line by line and, for each paragraph that needs to be created, adds its textual content to ArrayList textList.

2

Whitespaces contained in the textual content of a future paragraph are collapsed using utility XMLText.collapseWhiteSpace.

3

Non-XML characters contained in the textual content of a future paragraph are discarded using utility XMLText.filterText.

4

The content of the clipboard is fetched as a Java™ String. Therefore there is no need to specify an encoding in the XML declaration.

5

XXE only supports text inside the clipboard. This text is considered to be XML (that is, not plain text) if it starts with "<?xml " and if it can be parsed as well-formed XML.

As expected, the following text is parsed as a single p element:

<?xml version="1.0"?><p>Paragraph #1.</p>

Using special wrapper element {http://www.xmlmind.com/xmleditor/namespace/clipboard}clipboard, the following text is parsed as multiple p elements and empty text nodes (see ClipboardFormat):

<?xml version="1.0"?>
<ns:clipboard
xmlns:ns="http://www.xmlmind.com/xmleditor/namespace/clipboard">
  <p>Paragraph #1.</p>
  <p>Paragraph #2.</p>
</ns:clipboard>

Execute uses the following helper methods:

    ...
    private static final String readLine(LineNumberReader lines) {
        try {
            return lines.readLine();
        } catch (IOException cannotHappen) {
            cannotHappen.printStackTrace();
            return null;
        }
    }

    private static final void openTag(Name name, StringBuilder buffer) {
        buffer.append('<');
        buffer.append(name.localPart);
        buffer.append(" xmlns='");
        buffer.append(name.namespace.uri);
        buffer.append("'>");
    }

    private static final void closeTag(Name name, StringBuilder buffer) {
        buffer.append("</");
        buffer.append(name.localPart);
        buffer.append('>');
    }
}

4. Recordable commands

All the built-in commands of XMLmind XML Editor are recordable. That is, the user can automatically create a macro-command by recording the execution of a sequence of commands (ToolsRecord MacroStart, Stop) and then replay the recorded macro-command (ToolsRecord MacroReplay).

For simplicity, none of the commands described in the previous chapters have been made recordable.

The easiest way to make a command recordable is to extend the RecordableCommand class rather than implement the Command interface or extend the CommandBase base class.

Example:

public class ConvertCaseCmd extends CommandBase {
        .
        .
        .
    public Object execute(DocumentView docView,
                          String parameter, int x, int y) {
        Some code
            .
            .
            .
    }
}

becomes:

public class ConvertCaseCmd extends RecordableCommand {
        .
        .
        .
    protected Object doExecute(DocumentView docView, 
                               String parameter, int x, int y) {
        Exactly the same code as above
            .
            .
            .
    }
}

Extending the RecordableCommand is not the only way to make a command recordable. In order to explain the other, lower-level, method, we'll look at the way the RecordableCommand abstract class is implemented:

public abstract class RecordableCommand extends CommandBase {
    public Object execute(DocumentView docView, 
                          String parameter, int x, int y) {
            .
            .
            .
        if (!docView.notifyCommandListeners(this, parameter, x, y, 
                                            CommandEvent.Status.UNKNOWN, null))1
            // Vetoed by a listener: give up.
            return EXECUTION_FAILED;2

        CommandEvent.Status status;
        Object result;
        try {
            result = doExecute(docView, parameter, x, y);
            status = CommandEvent.Status.EXECUTED;
        } catch (Exception ex) {
            Report the error on the console

            result = EXECUTION_FAILED;
            status = CommandEvent.Status.CRASHED;
        }

        docView.notifyCommandListeners(this, parameter, x, y, status, result);3

        return result;
    }

    protected abstract Object doExecute(DocumentView docView, 
                                        String parameter, int x, int y);
}

1

Notify CommandListeners that we are about the execute a command. This is done using DocumentView.notifyCommandListeners. CommandEvent.Status.UNKNOWN implies that the command has not yet been executed.

CommandListeners such as the facility used to record macros (command RecordMacro) register themselves with a DocumentView using DocumentView.addCommandListener.

Such listeners will receive

  • a CommandEvent containing information about the command to be executed before it is executed

  • and another CommandEvent containing complementary information after the command is executed.

2

CommandListeners may veto the execution of a command[6]. In which case the vetoed command must immediately return value EXECUTION_FAILED.

3

After the command has been executed, successfully or not, notify the listeners one more time. This time, a status different from CommandEvent.Status.UNKNOWN (Status.EXECUTED or Status.CRASHED) means that the command has been executed.



[5] Namespace prefixes are not supported inside command parameters.

[6] This is, of course, not the case with command RecordMacro.