join multiple nodes of same kind

If multiple nodes of the same kind (e.g. elements with the same name)
are selected, the join method will join them to one node.

If elements are joined, the attributes of the first element are used for
the result.

Change-Id: I91199271af819d60d4930801aed38ff74fc30d43
diff --git a/org.eclipse.vex.core.tests/src/org/eclipse/vex/core/internal/widget/L2SimpleEditingTest.java b/org.eclipse.vex.core.tests/src/org/eclipse/vex/core/internal/widget/L2SimpleEditingTest.java
index da80f19..b2ddbf4 100644
--- a/org.eclipse.vex.core.tests/src/org/eclipse/vex/core/internal/widget/L2SimpleEditingTest.java
+++ b/org.eclipse.vex.core.tests/src/org/eclipse/vex/core/internal/widget/L2SimpleEditingTest.java
@@ -13,6 +13,7 @@
 

 import static org.eclipse.vex.core.internal.widget.VexWidgetTest.PARA;

 import static org.eclipse.vex.core.internal.widget.VexWidgetTest.PRE;

+import static org.eclipse.vex.core.internal.widget.VexWidgetTest.SECTION;

 import static org.eclipse.vex.core.internal.widget.VexWidgetTest.TITLE;

 import static org.eclipse.vex.core.internal.widget.VexWidgetTest.assertCanMorphOnlyTo;

 import static org.eclipse.vex.core.internal.widget.VexWidgetTest.assertXmlEquals;

@@ -33,6 +34,7 @@
 import org.eclipse.vex.core.internal.css.StyleSheetReader;

 import org.eclipse.vex.core.internal.undo.CannotRedoException;

 import org.eclipse.vex.core.provisional.dom.DocumentValidationException;

+import org.eclipse.vex.core.provisional.dom.IComment;

 import org.eclipse.vex.core.provisional.dom.IDocumentFragment;

 import org.eclipse.vex.core.provisional.dom.IElement;

 import org.eclipse.vex.core.provisional.dom.INode;

@@ -788,6 +790,302 @@
 		assertEquals("idValue", widget.getCurrentElement().getAttributeValue("id"));

 	}

 

+	@Test

+	public void givenMultipleElementsOfSameTypeSelected_canJoin() throws Exception {

+		final IElement firstPara = widget.insertElement(PARA);

+		widget.insertText("1");

+		widget.moveBy(1);

+		widget.insertElement(PARA);

+		widget.insertText("2");

+		widget.moveBy(1);

+		final IElement lastPara = widget.insertElement(PARA);

+		widget.insertText("3");

+		widget.moveBy(1);

+

+		widget.moveTo(firstPara.getStartOffset());

+		widget.moveTo(lastPara.getEndOffset(), true);

+

+		assertTrue(widget.canJoin());

+	}

+

+	@Test

+	public void givenMultipleElementsOfSameTypeSelected_shouldJoin() throws Exception {

+		final IElement firstPara = widget.insertElement(PARA);

+		widget.insertText("1");

+		widget.moveBy(1);

+		widget.insertElement(PARA);

+		widget.insertText("2");

+		widget.moveBy(1);

+		final IElement lastPara = widget.insertElement(PARA);

+		widget.insertText("3");

+		widget.moveBy(1);

+

+		widget.moveTo(firstPara.getStartOffset());

+		widget.moveTo(lastPara.getEndOffset(), true);

+

+		widget.join();

+

+		assertXmlEquals("<section><para>123</para></section>", widget);

+	}

+

+	@Test

+	public void givenMultipleElementsOfSameKindSelected_whenJoining_shouldPreserveAttributesOfFirstElement() throws Exception {

+		final IElement firstPara = widget.insertElement(PARA);

+		widget.insertText("1");

+		widget.moveBy(1);

+		widget.insertElement(PARA);

+		widget.insertText("2");

+		widget.moveBy(1);

+		final IElement lastPara = widget.insertElement(PARA);

+		widget.insertText("3");

+		firstPara.setAttribute("id", "para1");

+		lastPara.setAttribute("id", "para3");

+

+		widget.moveTo(firstPara.getStartOffset());

+		widget.moveTo(lastPara.getEndOffset(), true);

+

+		widget.join();

+

+		assertXmlEquals("<section><para id=\"para1\">123</para></section>", widget);

+	}

+

+	@Test

+	public void givenMultipleElementsOfSameKindSelected_whenJoinUndone_shouldRestoreAttributesOfAllElements() throws Exception {

+		final IElement firstPara = widget.insertElement(PARA);

+		widget.insertText("1");

+		widget.moveBy(1);

+		widget.insertElement(PARA);

+		widget.insertText("2");

+		widget.moveBy(1);

+		final IElement lastPara = widget.insertElement(PARA);

+		widget.insertText("3");

+		firstPara.setAttribute("id", "para1");

+		lastPara.setAttribute("id", "para3");

+

+		widget.moveTo(firstPara.getStartOffset());

+		widget.moveTo(lastPara.getEndOffset(), true);

+

+		widget.join();

+		widget.undo();

+

+		assertXmlEquals("<section><para id=\"para1\">1</para><para>2</para><para id=\"para3\">3</para></section>", widget);

+	}

+

+	@Test

+	public void givenMultipleElementsOfSameKindSelected_whenStructureAfterJoinWouldBeInvalid_cannotJoin() throws Exception {

+		widget.setDocument(createDocumentWithDTD(TEST_DTD, "one-kind-of-child"), readTestStyleSheet());

+		rootElement = widget.getDocument().getRootElement();

+

+		final IElement firstSection = widget.insertElement(SECTION);

+		widget.insertElement(TITLE);

+		widget.moveBy(2);

+		widget.insertElement(SECTION);

+		widget.insertElement(TITLE);

+		widget.moveBy(2);

+		final IElement lastSection = widget.insertElement(SECTION);

+		widget.insertElement(TITLE);

+

+		widget.moveTo(firstSection.getStartOffset());

+		widget.moveTo(lastSection.getEndOffset(), true);

+

+		assertFalse(widget.canJoin());

+	}

+

+	@Test(expected = CannotRedoException.class)

+	public void givenMultipleElementsOfSameKindSelected_whenStructureAfterJoinWouldBeInvalid_shouldJoin() throws Exception {

+		widget.setDocument(createDocumentWithDTD(TEST_DTD, "one-kind-of-child"), readTestStyleSheet());

+		rootElement = widget.getDocument().getRootElement();

+

+		final IElement firstSection = widget.insertElement(SECTION);

+		widget.insertElement(TITLE);

+		widget.moveBy(2);

+		widget.insertElement(SECTION);

+		widget.insertElement(TITLE);

+		widget.moveBy(2);

+		final IElement lastSection = widget.insertElement(SECTION);

+		widget.insertElement(TITLE);

+

+		widget.moveTo(firstSection.getStartOffset());

+		widget.moveTo(lastSection.getEndOffset(), true);

+

+		widget.join();

+	}

+

+	@Test

+	public void givenMultipleElementsOfDifferentTypeSelected_cannotJoin() throws Exception {

+		final IElement title = widget.insertElement(TITLE);

+		widget.insertText("1");

+		widget.moveBy(1);

+		widget.insertElement(PARA);

+		widget.insertText("2");

+		widget.moveBy(1);

+		final IElement lastPara = widget.insertElement(PARA);

+		widget.insertText("3");

+		widget.moveBy(1);

+

+		widget.moveTo(title.getStartOffset());

+		widget.moveTo(lastPara.getEndOffset(), true);

+

+		assertFalse(widget.canJoin());

+	}

+

+	@Test(expected = DocumentValidationException.class)

+	public void givenMultipleElementsOfDifferentTypeSelected_shouldNotJoin() throws Exception {

+		final IElement title = widget.insertElement(TITLE);

+		widget.insertText("1");

+		widget.moveBy(1);

+		widget.insertElement(PARA);

+		widget.insertText("2");

+		widget.moveBy(1);

+		final IElement lastPara = widget.insertElement(PARA);

+		widget.insertText("3");

+		widget.moveBy(1);

+

+		widget.moveTo(title.getStartOffset());

+		widget.moveTo(lastPara.getEndOffset(), true);

+

+		widget.join();

+	}

+

+	@Test

+	public void givenSelectionIsEmpty_cannotJoin() throws Exception {

+		assertFalse(widget.canJoin());

+	}

+

+	@Test

+	public void givenSelectionIsEmpty_whenRequestedToJoin_shouldIgnoreGracefully() throws Exception {

+		final String expectedXml = getCurrentXML(widget);

+

+		widget.join();

+

+		assertXmlEquals(expectedXml, widget);

+	}

+

+	@Test

+	public void givenOnlyTextSelected_cannotJoin() throws Exception {

+		final IElement title = widget.insertElement(TITLE);

+		widget.insertText("title text");

+		widget.moveTo(title.getStartOffset() + 1, true);

+

+		assertFalse(widget.canJoin());

+	}

+

+	@Test

+	public void givenOnlyTextSelected_whenRequestedToJoin_shouldIgnoreGracefully() throws Exception {

+		final IElement title = widget.insertElement(TITLE);

+		widget.insertText("title text");

+		widget.selectContentOf(title);

+		final String expectedXml = getCurrentXML(widget);

+

+		widget.join();

+

+		assertXmlEquals(expectedXml, widget);

+	}

+

+	@Test

+	public void givenOnlySingleElementSelected_cannotJoin() throws Exception {

+		final IElement title = widget.insertElement(TITLE);

+		widget.moveTo(title.getStartOffset(), true);

+

+		assertFalse(widget.canJoin());

+	}

+

+	@Test

+	public void givenOnlySingleElementSelected_whenRequestedToJoin_shouldIgnoreGracefully() throws Exception {

+		final IElement title = widget.insertElement(TITLE);

+		widget.moveTo(title.getStartOffset(), true);

+		final String expectedXml = getCurrentXML(widget);

+

+		widget.join();

+

+		assertXmlEquals(expectedXml, widget);

+	}

+

+	@Test

+	public void givenMultipleCommentsSelected_canJoin() throws Exception {

+		final IComment firstComment = widget.insertComment();

+		widget.insertText("comment1");

+		widget.moveBy(1);

+		widget.insertComment();

+		widget.insertText("comment2");

+		widget.moveBy(1);

+		final IComment lastComment = widget.insertComment();

+		widget.insertText("comment3");

+

+		widget.moveTo(firstComment.getStartOffset());

+		widget.moveTo(lastComment.getEndOffset(), true);

+

+		assertTrue(widget.canJoin());

+	}

+

+	@Test

+	public void givenMultipleCommentsSelected_shouldJoin() throws Exception {

+		final IComment firstComment = widget.insertComment();

+		widget.insertText("comment1");

+		widget.moveBy(1);

+		widget.insertComment();

+		widget.insertText("comment2");

+		widget.moveBy(1);

+		final IComment lastComment = widget.insertComment();

+		widget.insertText("comment3");

+

+		widget.moveTo(firstComment.getStartOffset());

+		widget.moveTo(lastComment.getEndOffset(), true);

+

+		widget.join();

+

+		assertXmlEquals("<section><!--comment1comment2comment3--></section>", widget);

+	}

+

+	@Test

+	public void givenMultipleInlineElementsOfSameKindSelected_whenTextEndsWithSpace_shouldJoin() throws Exception {

+		widget.insertElement(PARA);

+		final IElement firstElement = widget.insertElement(PRE);

+		widget.insertText("1");

+		widget.moveBy(1);

+		final IElement lastElement = widget.insertElement(PRE);

+		widget.insertText("2 ");

+

+		widget.moveTo(firstElement.getStartOffset());

+		widget.moveTo(lastElement.getEndOffset(), true);

+

+		widget.join();

+

+		assertXmlEquals("<section><para><pre>12 </pre></para></section>", widget);

+	}

+

+	@Test

+	public void givenMultipleInlineElementsOfSameKindSelected_whenTextBetweenElements_cannotJoin() throws Exception {

+		widget.insertElement(PARA);

+		final IElement firstElement = widget.insertElement(PRE);

+		widget.insertText("1");

+		widget.moveBy(1);

+		widget.insertText("text between elements");

+		final IElement lastElement = widget.insertElement(PRE);

+		widget.insertText("2 ");

+

+		widget.moveTo(firstElement.getStartOffset());

+		widget.moveTo(lastElement.getEndOffset(), true);

+

+		assertFalse(widget.canJoin());

+	}

+

+	@Test(expected = DocumentValidationException.class)

+	public void givenMultipleInlineElementsOfSameKindSelected_whenTextBetweenElements_shouldNotJoin() throws Exception {

+		widget.insertElement(PARA);

+		final IElement firstElement = widget.insertElement(PRE);

+		widget.insertText("1");

+		widget.moveBy(1);

+		widget.insertText("text between elements");

+		final IElement lastElement = widget.insertElement(PRE);

+		widget.insertText("2 ");

+

+		widget.moveTo(firstElement.getStartOffset());

+		widget.moveTo(lastElement.getEndOffset(), true);

+

+		widget.join();

+	}

+

 	private static StyleSheet readTestStyleSheet() throws IOException {

 		return new StyleSheetReader().read(TestResources.get("test.css"));

 	}

diff --git a/org.eclipse.vex.core.tests/src/org/eclipse/vex/core/internal/widget/VexWidgetTest.java b/org.eclipse.vex.core.tests/src/org/eclipse/vex/core/internal/widget/VexWidgetTest.java
index 79729cd..3e54a96 100644
--- a/org.eclipse.vex.core.tests/src/org/eclipse/vex/core/internal/widget/VexWidgetTest.java
+++ b/org.eclipse.vex.core.tests/src/org/eclipse/vex/core/internal/widget/VexWidgetTest.java
@@ -33,6 +33,7 @@
 
 public class VexWidgetTest {
 
+	public static final QualifiedName SECTION = new QualifiedName(null, "section");
 	public static final QualifiedName TITLE = new QualifiedName(null, "title");
 	public static final QualifiedName PARA = new QualifiedName(null, "para");
 	public static final QualifiedName PRE = new QualifiedName(null, "pre");
diff --git a/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/BaseVexWidget.java b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/BaseVexWidget.java
index b2961e9..69fc7be 100644
--- a/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/BaseVexWidget.java
+++ b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/BaseVexWidget.java
@@ -69,6 +69,7 @@
 import org.eclipse.vex.core.provisional.dom.ContentRange;
 import org.eclipse.vex.core.provisional.dom.DocumentValidationException;
 import org.eclipse.vex.core.provisional.dom.Filters;
+import org.eclipse.vex.core.provisional.dom.IAxis;
 import org.eclipse.vex.core.provisional.dom.IComment;
 import org.eclipse.vex.core.provisional.dom.IDocument;
 import org.eclipse.vex.core.provisional.dom.IDocumentFragment;
@@ -146,6 +147,7 @@
 
 	private final IDocumentListener documentListener = new IDocumentListener() {
 
+		@Override
 		public void attributeChanged(final AttributeChangeEvent e) {
 			invalidateElementBox(e.getParent());
 
@@ -161,6 +163,7 @@
 			fireSelectionChanged();
 		}
 
+		@Override
 		public void beforeContentDeleted(final ContentChangeEvent e) {
 			// Clean-up stylesheet cache
 			if (e.isStructuralChange()) {
@@ -171,9 +174,11 @@
 			}
 		}
 
+		@Override
 		public void beforeContentInserted(final ContentChangeEvent e) {
 		}
 
+		@Override
 		public void contentDeleted(final ContentChangeEvent e) {
 			flushStyles(e);
 			invalidateElementBox(e.getParent());
@@ -181,6 +186,7 @@
 			BaseVexWidget.this.relayout();
 		}
 
+		@Override
 		public void contentInserted(final ContentChangeEvent e) {
 			flushStyles(e);
 			invalidateElementBox(e.getParent());
@@ -188,6 +194,7 @@
 			BaseVexWidget.this.relayout();
 		}
 
+		@Override
 		public void namespaceChanged(final NamespaceDeclarationChangeEvent e) {
 			invalidateElementBox(e.getParent());
 
@@ -234,6 +241,7 @@
 		styleSheet = null;
 	}
 
+	@Override
 	public void beginWork() {
 		if (beginWorkCount == 0) {
 			beginWorkCaretOffset = getCaretOffset();
@@ -278,6 +286,7 @@
 		return beginWorkCount > 0;
 	}
 
+	@Override
 	public boolean canInsertComment() {
 		if (readOnly) {
 			return false;
@@ -288,10 +297,12 @@
 		return document.canInsertComment(getCaretOffset());
 	}
 
+	@Override
 	public boolean canInsertFragment(final IDocumentFragment fragment) {
 		return canInsertAtCurrentSelection(fragment.getNodeNames());
 	}
 
+	@Override
 	public boolean canInsertText() {
 		return canReplaceCurrentSelectionWith(IValidator.PCDATA);
 	}
@@ -328,14 +339,17 @@
 		return validator.isValidSequence(parent.getQualifiedName(), nodesBefore, nodeNames, nodesAfter, true);
 	}
 
+	@Override
 	public boolean canPaste() {
 		throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
 	}
 
+	@Override
 	public boolean canPasteText() {
 		throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
 	}
 
+	@Override
 	public boolean canRedo() {
 		if (readOnly) {
 			return false;
@@ -343,6 +357,7 @@
 		return !redoList.isEmpty();
 	}
 
+	@Override
 	public boolean canUndo() {
 		if (readOnly) {
 			return false;
@@ -350,14 +365,17 @@
 		return !undoList.isEmpty();
 	}
 
+	@Override
 	public void copySelection() {
 		throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
 	}
 
+	@Override
 	public void cutSelection() {
 		throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
 	}
 
+	@Override
 	public void deleteNextChar() throws DocumentValidationException, ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot delete, because the editor is read-only.");
@@ -391,6 +409,7 @@
 		}
 	}
 
+	@Override
 	public void deletePreviousChar() throws DocumentValidationException, ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot delete, because the editor is read-only.");
@@ -426,6 +445,7 @@
 		}
 	}
 
+	@Override
 	public boolean canDeleteSelection() {
 		if (readOnly) {
 			return false;
@@ -436,6 +456,7 @@
 		return document.canDelete(getSelectedRange());
 	}
 
+	@Override
 	public void deleteSelection() throws ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot delete, because the editor is read-only.");
@@ -471,10 +492,12 @@
 		}
 	}
 
+	@Override
 	public void doWork(final Runnable runnable) {
 		this.doWork(runnable, false);
 	}
 
+	@Override
 	public void doWork(final Runnable runnable, final boolean savePosition) {
 		IPosition position = null;
 
@@ -551,6 +574,7 @@
 		return caret;
 	}
 
+	@Override
 	public int getCaretOffset() {
 		return caretOffset;
 	}
@@ -569,6 +593,7 @@
 		return getCaretOffset();
 	}
 
+	@Override
 	public IElement getCurrentElement() {
 		return currentNode.accept(new BaseNodeVisitorWithResult<IElement>(null) {
 			@Override
@@ -588,10 +613,12 @@
 		});
 	}
 
+	@Override
 	public INode getCurrentNode() {
 		return currentNode;
 	}
 
+	@Override
 	public IDocument getDocument() {
 		return document;
 	}
@@ -603,6 +630,7 @@
 		return rootBox.getHeight();
 	}
 
+	@Override
 	public ElementName[] getValidInsertElements() {
 		if (readOnly) {
 			return new ElementName[0];
@@ -686,6 +714,7 @@
 		return antiAliased;
 	}
 
+	@Override
 	public boolean isDebugging() {
 		return debugging;
 	}
@@ -698,6 +727,7 @@
 		return selectionStart;
 	}
 
+	@Override
 	public ContentRange getSelectedRange() {
 		if (!hasSelection()) {
 			return new ContentRange(getCaretOffset(), getCaretOffset());
@@ -705,6 +735,7 @@
 		return new ContentRange(getSelectionStart(), getSelectionEnd() - 1);
 	}
 
+	@Override
 	public IDocumentFragment getSelectedFragment() {
 		if (hasSelection()) {
 			return document.getFragment(getSelectedRange());
@@ -713,6 +744,7 @@
 		}
 	}
 
+	@Override
 	public String getSelectedText() {
 		if (hasSelection()) {
 			return document.getText(getSelectedRange());
@@ -721,10 +753,12 @@
 		}
 	}
 
+	@Override
 	public StyleSheet getStyleSheet() {
 		return styleSheet;
 	}
 
+	@Override
 	public int getLayoutWidth() {
 		return layoutWidth;
 	}
@@ -733,14 +767,17 @@
 		return rootBox;
 	}
 
+	@Override
 	public boolean hasSelection() {
 		return getSelectionStart() != getSelectionEnd();
 	}
 
+	@Override
 	public boolean canInsertElement(final QualifiedName elementName) {
 		return canReplaceCurrentSelectionWith(elementName);
 	}
 
+	@Override
 	public IElement insertElement(final QualifiedName elementName) throws DocumentValidationException, ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException(MessageFormat.format("Cannot insert element {0}, because the editor is read-only.", elementName));
@@ -771,6 +808,7 @@
 		}
 	}
 
+	@Override
 	public void insertFragment(final IDocumentFragment fragment) throws DocumentValidationException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot insert fragment, because the editor is read-only");
@@ -798,6 +836,7 @@
 		}
 	}
 
+	@Override
 	public void insertText(final String text) throws DocumentValidationException, ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot insert text, because the editor is read-only.");
@@ -849,6 +888,7 @@
 		}
 	}
 
+	@Override
 	public void insertXML(final String xml) throws DocumentValidationException, ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot insert text, because the editor is read-only.");
@@ -932,6 +972,7 @@
 		return null;
 	}
 
+	@Override
 	public void insertChar(final char c) throws DocumentValidationException, ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot insert a character, because the editor is read-only.");
@@ -944,6 +985,7 @@
 		this.moveBy(+1);
 	}
 
+	@Override
 	public IComment insertComment() throws DocumentValidationException, ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot insert comment, because the editor is read-only.");
@@ -969,6 +1011,7 @@
 		}
 	}
 
+	@Override
 	public boolean canUnwrap() {
 		if (readOnly) {
 			return false;
@@ -997,6 +1040,7 @@
 		return validator.isValidSequence(parent.getQualifiedName(), nodesBefore, newNodes, nodesAfter, true);
 	}
 
+	@Override
 	public void unwrap() throws DocumentValidationException, ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot unwrap the element, because the editor is read-only.");
@@ -1029,6 +1073,7 @@
 		}
 	}
 
+	@Override
 	public ElementName[] getValidMorphElements() {
 		final IElement currentElement = document.getElementForInsertionAt(getCaretOffset());
 		if (!canMorphElement(currentElement)) {
@@ -1101,6 +1146,7 @@
 		return validator.isValidSequence(parentName, nodesBefore, Arrays.asList(elementName), nodesAfter, true);
 	}
 
+	@Override
 	public boolean canMorph(final QualifiedName elementName) {
 		final IElement currentElement = document.getElementForInsertionAt(getCaretOffset());
 		if (!canMorphElement(currentElement)) {
@@ -1120,6 +1166,7 @@
 		return isValidChild(validator, parent.getQualifiedName(), elementName, nodesBefore, nodesAfter);
 	}
 
+	@Override
 	public void morph(final QualifiedName elementName) throws DocumentValidationException, ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException(MessageFormat.format("Cannot morph to element {0}, because the editor is read-only.", elementName));
@@ -1154,18 +1201,22 @@
 
 	}
 
+	@Override
 	public void moveBy(final int distance) {
 		this.moveTo(getCaretOffset() + distance, false);
 	}
 
+	@Override
 	public void moveBy(final int distance, final boolean select) {
 		this.moveTo(getCaretOffset() + distance, select);
 	}
 
+	@Override
 	public void moveTo(final int offset) {
 		this.moveTo(offset, false);
 	}
 
+	@Override
 	public void moveTo(final int offset, final boolean select) {
 		if (!Document.isInsertionPointIn(document, offset)) {
 			return;
@@ -1271,14 +1322,17 @@
 		mark = offset;
 	}
 
+	@Override
 	public void moveToLineEnd(final boolean select) {
 		this.moveTo(rootBox.getLineEndOffset(getCaretOffset()), select);
 	}
 
+	@Override
 	public void moveToLineStart(final boolean select) {
 		this.moveTo(rootBox.getLineStartOffset(getCaretOffset()), select);
 	}
 
+	@Override
 	public void moveToNextLine(final boolean select) {
 		final int x = magicX == -1 ? caret.getBounds().getX() : magicX;
 
@@ -1290,6 +1344,7 @@
 		magicX = x;
 	}
 
+	@Override
 	public void moveToNextPage(final boolean select) {
 		final int x = magicX == -1 ? caret.getBounds().getX() : magicX;
 		final int y = caret.getY() + Math.round(hostComponent.getViewport().getHeight() * 0.9f);
@@ -1297,6 +1352,7 @@
 		magicX = x;
 	}
 
+	@Override
 	public void moveToNextWord(final boolean select) {
 		final int n = document.getLength() - 1;
 		int offset = getCaretOffset();
@@ -1311,6 +1367,7 @@
 		this.moveTo(offset, select);
 	}
 
+	@Override
 	public void moveToPreviousLine(final boolean select) {
 		final int x = magicX == -1 ? caret.getBounds().getX() : magicX;
 
@@ -1322,6 +1379,7 @@
 		magicX = x;
 	}
 
+	@Override
 	public void moveToPreviousPage(final boolean select) {
 		final int x = magicX == -1 ? caret.getBounds().getX() : magicX;
 		final int y = caret.getY() - Math.round(hostComponent.getViewport().getHeight() * 0.9f);
@@ -1329,6 +1387,7 @@
 		magicX = x;
 	}
 
+	@Override
 	public void moveToPreviousWord(final boolean select) {
 		int offset = getCaretOffset();
 		while (offset > 1 && !Character.isLetterOrDigit(document.getCharacterAt(offset - 1))) {
@@ -1440,14 +1499,17 @@
 		 */
 	}
 
+	@Override
 	public void paste() throws DocumentValidationException {
 		throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
 	}
 
+	@Override
 	public void pasteText() throws DocumentValidationException {
 		throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
 	}
 
+	@Override
 	public void redo() throws CannotRedoException, ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot redo, because the editor is read-only.");
@@ -1461,6 +1523,7 @@
 		undoList.add(event);
 	}
 
+	@Override
 	public void savePosition(final Runnable runnable) {
 		final IPosition pos = document.createPosition(getCaretOffset());
 		try {
@@ -1480,6 +1543,7 @@
 		this.antiAliased = antiAliased;
 	}
 
+	@Override
 	public boolean canSetAttribute(final String attributeName, final String value) {
 		if (readOnly) {
 			return false;
@@ -1492,6 +1556,7 @@
 		return element.canSetAttribute(qualifiedAttributeName, value);
 	}
 
+	@Override
 	public void setAttribute(final String attributeName, final String value) throws ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException(MessageFormat.format("Cannot set attribute {0}, because the editor is read-only.", attributeName));
@@ -1511,6 +1576,7 @@
 		}
 	}
 
+	@Override
 	public boolean canRemoveAttribute(final String attributeName) {
 		if (readOnly) {
 			return false;
@@ -1525,6 +1591,7 @@
 		return element.canRemoveAttribute(qualifiedAttributeName);
 	}
 
+	@Override
 	public void removeAttribute(final String attributeName) throws ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException(MessageFormat.format("Cannot remove attribute {0}, because the editor is read-only.", attributeName));
@@ -1542,18 +1609,22 @@
 		}
 	}
 
+	@Override
 	public void setDebugging(final boolean debugging) {
 		this.debugging = debugging;
 	}
 
+	@Override
 	public boolean isReadOnly() {
 		return readOnly;
 	}
 
+	@Override
 	public void setReadOnly(final boolean readOnly) {
 		this.readOnly = readOnly;
 	}
 
+	@Override
 	public void setDocument(final IDocument document, final StyleSheet styleSheet) {
 		if (this.document != null) {
 			final IDocument doc = document;
@@ -1585,6 +1656,7 @@
 		repaintCaret();
 	}
 
+	@Override
 	public void setLayoutWidth(int width) {
 		width = Math.max(width, MIN_LAYOUT_WIDTH);
 		if (document != null && width != getLayoutWidth()) {
@@ -1597,6 +1669,7 @@
 		}
 	}
 
+	@Override
 	public void setStyleSheet(final StyleSheet styleSheet) {
 		if (document != null) {
 			relayoutAll(layoutWidth, styleSheet);
@@ -1609,6 +1682,7 @@
 		this.setStyleSheet(ss);
 	}
 
+	@Override
 	public void setWhitespacePolicy(final IWhitespacePolicy whitespacePolicy) {
 		if (whitespacePolicy == null) {
 			this.whitespacePolicy = IWhitespacePolicy.NULL;
@@ -1617,10 +1691,12 @@
 		}
 	}
 
+	@Override
 	public IWhitespacePolicy getWhitespacePolicy() {
 		return whitespacePolicy;
 	}
 
+	@Override
 	public boolean canSplit() {
 		if (readOnly) {
 			return false;
@@ -1655,6 +1731,7 @@
 		return validator.isValidSequence(parent.getQualifiedName(), nodesBefore, newNodes, nodesAfter, true);
 	}
 
+	@Override
 	public void split() throws DocumentValidationException, ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot split, because the editor is read-only.");
@@ -1715,6 +1792,7 @@
 		repaintCaret();
 	}
 
+	@Override
 	public void undo() throws CannotUndoException, ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot undo, because the editor is read-only.");
@@ -1729,6 +1807,7 @@
 		redoList.add(event);
 	}
 
+	@Override
 	public int viewToModel(final int x, final int y) {
 		final Graphics g = hostComponent.createDefaultGraphics();
 		final LayoutContext context = createLayoutContext(g);
@@ -1737,6 +1816,7 @@
 		return offset;
 	}
 
+	@Override
 	public void declareNamespace(final String namespacePrefix, final String namespaceURI) throws ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException(MessageFormat.format("Cannot declare namespace {0}, because the editor is read-only.", namespacePrefix));
@@ -1751,6 +1831,7 @@
 		applyEdit(new ChangeNamespaceEdit(document, getCaretOffset(), namespacePrefix, currentNamespaceURI, namespaceURI), getCaretOffset());
 	}
 
+	@Override
 	public void removeNamespace(final String namespacePrefix) throws ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException(MessageFormat.format("Cannot remove namespace {0}, because the editor is read-only.", namespacePrefix));
@@ -1765,6 +1846,7 @@
 		applyEdit(new ChangeNamespaceEdit(document, getCaretOffset(), namespacePrefix, currentNamespaceURI, null), getCaretOffset());
 	}
 
+	@Override
 	public void declareDefaultNamespace(final String namespaceURI) throws ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot declare default namespace, because the editor is read-only.");
@@ -1779,6 +1861,7 @@
 		applyEdit(new ChangeNamespaceEdit(document, getCaretOffset(), null, currentNamespaceURI, namespaceURI), getCaretOffset());
 	}
 
+	@Override
 	public void removeDefaultNamespace() throws ReadOnlyException {
 		if (readOnly) {
 			throw new ReadOnlyException("Cannot remove default namespace, because the editor is read-only.");
@@ -1884,6 +1967,7 @@
 	private void invalidateElementBox(final INode node) {
 
 		final BlockBox elementBox = (BlockBox) this.findInnermostBox(new IBoxFilter() {
+			@Override
 			public boolean matches(final Box box) {
 				return box instanceof BlockBox && box.getNode() != null && box.getStartOffset() <= node.getStartOffset() + 1 && box.getEndOffset() >= node.getEndOffset();
 			}
@@ -1953,6 +2037,107 @@
 		}
 	}
 
+	@Override
+	public boolean canJoin() {
+		if (!hasSelection()) {
+			return false;
+		}
+
+		final IElement parent = document.getElementForInsertionAt(getCaretOffset());
+		final IAxis<? extends INode> selectedNodes = parent.children().in(getSelectedRange());
+		if (selectedNodes.isEmpty()) {
+			return false;
+		}
+
+		final IValidator validator = document.getValidator();
+		final INode firstNode = selectedNodes.first();
+		final List<QualifiedName> childNodeNames = new ArrayList<QualifiedName>();
+		int count = 0;
+		for (final INode selectedNode : selectedNodes) {
+			if (!selectedNode.isKindOf(firstNode)) {
+				return false;
+			}
+			childNodeNames.addAll(selectedNode.accept(new BaseNodeVisitorWithResult<List<QualifiedName>>(Collections.<QualifiedName> emptyList()) {
+				@Override
+				public List<QualifiedName> visit(final IElement element) {
+					return Node.getNodeNames(element.children());
+				}
+			}));
+			count++;
+		}
+
+		if (count <= 1) {
+			return false;
+		}
+
+		final boolean joinedChildrenValid = firstNode.accept(new BaseNodeVisitorWithResult<Boolean>(true) {
+			@Override
+			public Boolean visit(final IElement element) {
+				return validator.isValidSequence(element.getQualifiedName(), childNodeNames, true);
+			}
+		});
+		if (!joinedChildrenValid) {
+			return false;
+		}
+
+		return true;
+	}
+
+	@Override
+	public void join() throws DocumentValidationException {
+		if (!hasSelection()) {
+			return;
+		}
+
+		final IElement parent = document.getElementForInsertionAt(getCaretOffset());
+		final IAxis<? extends INode> selectedNodes = parent.children().in(getSelectedRange());
+		if (selectedNodes.isEmpty()) {
+			return;
+		}
+
+		final INode firstNode = selectedNodes.first();
+		final int selectionEnd = getSelectionEnd();
+
+		boolean success = false;
+		try {
+			beginWork();
+
+			final ArrayList<IDocumentFragment> contentToJoin = new ArrayList<IDocumentFragment>();
+			int count = 0;
+			for (final INode selectedNode : selectedNodes) {
+				if (!selectedNode.isKindOf(firstNode) && count > 0) {
+					throw new DocumentValidationException("Cannot join nodes of different kind.");
+				}
+				if (!selectedNode.isEmpty()) {
+					contentToJoin.add(document.getFragment(selectedNode.getRange().resizeBy(1, -1)));
+				}
+				count++;
+			}
+
+			if (count <= 1) {
+				return;
+			}
+
+			moveTo(firstNode.getEndOffset() + 1);
+			moveTo(selectionEnd, true);
+			deleteSelection();
+
+			moveTo(firstNode.getStartOffset() + 1);
+			moveTo(firstNode.getEndOffset(), true);
+			deleteSelection();
+
+			for (final IDocumentFragment preservedContent : contentToJoin) {
+				moveTo(firstNode.getEndOffset());
+				insertFragment(preservedContent);
+			}
+
+			success = true;
+		} finally {
+			endWork(success);
+		}
+
+	}
+
 	private void joinElementsAt(final int offset) throws DocumentValidationException {
 		boolean success = false;
 		try {
diff --git a/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/IVexWidget.java b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/IVexWidget.java
index 374d5f1..891bde3 100644
--- a/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/IVexWidget.java
+++ b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/IVexWidget.java
@@ -626,6 +626,10 @@
 	 */
 	void morph(QualifiedName elementName) throws DocumentValidationException;
 
+	boolean canJoin();
+
+	void join() throws DocumentValidationException;
+
 	public void setWhitespacePolicy(IWhitespacePolicy whitespacePolicy);
 
 	public IWhitespacePolicy getWhitespacePolicy();
diff --git a/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/swt/VexWidget.java b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/swt/VexWidget.java
index f23346b..e9d0584 100644
--- a/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/swt/VexWidget.java
+++ b/org.eclipse.vex.core/src/org/eclipse/vex/core/internal/widget/swt/VexWidget.java
@@ -111,30 +111,37 @@
 		return impl.getDocument();
 	}
 
+	@Override
 	public void addSelectionChangedListener(final ISelectionChangedListener listener) {
 		selectionListeners.add(listener);
 	}
 
+	@Override
 	public ISelection getSelection() {
 		return selection;
 	}
 
+	@Override
 	public void removeSelectionChangedListener(final ISelectionChangedListener listener) {
 		selectionListeners.remove(listener);
 	}
 
+	@Override
 	public void setSelection(final ISelection selection) {
 		throw new RuntimeException("Unexpected call to setSelection");
 	}
 
+	@Override
 	public void beginWork() {
 		impl.beginWork();
 	}
 
+	@Override
 	public boolean canInsertComment() {
 		return impl.canInsertComment();
 	}
 
+	@Override
 	public boolean canPaste() {
 		// TODO Auto-generated method stub
 		return false;
@@ -143,15 +150,18 @@
 	/**
 	 * @see org.eclipse.vex.core.internal.widget.IVexWidget#canPasteText()
 	 */
+	@Override
 	public boolean canPasteText() {
 		// TODO Auto-generated method stub
 		return false;
 	}
 
+	@Override
 	public boolean canRedo() {
 		return impl.canRedo();
 	}
 
+	@Override
 	public boolean canUndo() {
 		return impl.canUndo();
 	}
@@ -168,6 +178,7 @@
 		return new Point(r.width, height);
 	}
 
+	@Override
 	public void copySelection() {
 		final Clipboard clipboard = new Clipboard(getDisplay());
 		final Object[] data = { getSelectedFragment(), getSelectedText() };
@@ -175,6 +186,7 @@
 		clipboard.setContents(data, transfers);
 	}
 
+	@Override
 	public void cutSelection() throws ReadOnlyException {
 		if (isReadOnly()) {
 			throw new ReadOnlyException("Cannot cut selection, because the editor is read-only.");
@@ -184,186 +196,242 @@
 		deleteSelection();
 	}
 
+	@Override
 	public void deleteNextChar() throws DocumentValidationException {
 		impl.deleteNextChar();
 	}
 
+	@Override
 	public void deletePreviousChar() throws DocumentValidationException {
 		impl.deletePreviousChar();
 	}
 
+	@Override
 	public boolean canDeleteSelection() {
 		return impl.canDeleteSelection();
 	}
 
+	@Override
 	public void deleteSelection() {
 		impl.deleteSelection();
 	}
 
+	@Override
 	public void doWork(final Runnable runnable) {
 		impl.doWork(runnable);
 	}
 
+	@Override
 	public void doWork(final Runnable runnable, final boolean savePosition) {
 		impl.doWork(runnable, savePosition);
 	}
 
+	@Override
 	public void endWork(final boolean success) {
 		impl.endWork(success);
 	}
 
+	@Override
 	public int getCaretOffset() {
 		return impl.getCaretOffset();
 	}
 
+	@Override
 	public IElement getCurrentElement() {
 		return impl.getCurrentElement();
 	}
 
+	@Override
 	public INode getCurrentNode() {
 		return impl.getCurrentNode();
 	}
 
+	@Override
 	public IDocument getDocument() {
 		return impl.getDocument();
 	}
 
+	@Override
 	public int getLayoutWidth() {
 		return impl.getLayoutWidth();
 	}
 
+	@Override
 	public ContentRange getSelectedRange() {
 		return impl.getSelectedRange();
 	}
 
+	@Override
 	public IDocumentFragment getSelectedFragment() {
 		return impl.getSelectedFragment();
 	}
 
+	@Override
 	public String getSelectedText() {
 		return impl.getSelectedText();
 	}
 
+	@Override
 	public StyleSheet getStyleSheet() {
 		return impl.getStyleSheet();
 	}
 
+	@Override
 	public ElementName[] getValidInsertElements() {
 		return impl.getValidInsertElements();
 	}
 
+	@Override
 	public ElementName[] getValidMorphElements() {
 		return impl.getValidMorphElements();
 	}
 
+	@Override
 	public boolean hasSelection() {
 		return impl.hasSelection();
 	}
 
+	@Override
 	public void insertChar(final char c) throws DocumentValidationException {
 		impl.insertChar(c);
 	}
 
+	@Override
 	public boolean canInsertFragment(final IDocumentFragment fragment) {
 		return impl.canInsertFragment(fragment);
 	}
 
+	@Override
 	public void insertFragment(final IDocumentFragment fragment) throws DocumentValidationException {
 		impl.insertFragment(fragment);
 	}
 
+	@Override
 	public boolean canInsertElement(final QualifiedName elementName) {
 		return impl.canInsertElement(elementName);
 	}
 
+	@Override
 	public IElement insertElement(final QualifiedName elementName) throws DocumentValidationException {
 		return impl.insertElement(elementName);
 	}
 
+	@Override
 	public boolean canInsertText() {
 		return impl.canInsertText();
 	}
 
+	@Override
 	public void insertText(final String text) throws DocumentValidationException {
 		impl.insertText(text);
 	}
 
+	@Override
 	public void insertXML(final String xml) throws DocumentValidationException {
 		impl.insertXML(xml);
 	}
 
+	@Override
 	public IComment insertComment() throws DocumentValidationException {
 		return impl.insertComment();
 	}
 
+	@Override
 	public boolean isDebugging() {
 		return impl.isDebugging();
 	}
 
+	@Override
 	public boolean canUnwrap() {
 		return impl.canUnwrap();
 	}
 
+	@Override
 	public void unwrap() throws DocumentValidationException {
 		impl.unwrap();
 	}
 
+	@Override
 	public boolean canMorph(final QualifiedName elementName) {
 		return impl.canMorph(elementName);
 	}
 
+	@Override
 	public void morph(final QualifiedName elementName) throws DocumentValidationException {
 		impl.morph(elementName);
 	}
 
+	@Override
+	public boolean canJoin() {
+		return impl.canJoin();
+	}
+
+	@Override
+	public void join() throws DocumentValidationException {
+		impl.join();
+	}
+
+	@Override
 	public void moveBy(final int distance) {
 		impl.moveBy(distance);
 	}
 
+	@Override
 	public void moveBy(final int distance, final boolean select) {
 		impl.moveBy(distance, select);
 	}
 
+	@Override
 	public void moveTo(final int offset) {
 		impl.moveTo(offset);
 	}
 
+	@Override
 	public void moveTo(final int offset, final boolean select) {
 		impl.moveTo(offset, select);
 	}
 
+	@Override
 	public void moveToLineEnd(final boolean select) {
 		impl.moveToLineEnd(select);
 	}
 
+	@Override
 	public void moveToLineStart(final boolean select) {
 		impl.moveToLineStart(select);
 	}
 
+	@Override
 	public void moveToNextLine(final boolean select) {
 		impl.moveToNextLine(select);
 	}
 
+	@Override
 	public void moveToNextPage(final boolean select) {
 		impl.moveToNextPage(select);
 	}
 
+	@Override
 	public void moveToNextWord(final boolean select) {
 		impl.moveToNextWord(select);
 	}
 
+	@Override
 	public void moveToPreviousLine(final boolean select) {
 		impl.moveToPreviousLine(select);
 	}
 
+	@Override
 	public void moveToPreviousPage(final boolean select) {
 		impl.moveToPreviousPage(select);
 	}
 
+	@Override
 	public void moveToPreviousWord(final boolean select) {
 		impl.moveToPreviousWord(select);
 	}
 
+	@Override
 	public void paste() throws DocumentValidationException, ReadOnlyException {
 		if (isReadOnly()) {
 			throw new ReadOnlyException("Cannot paste, because the editor is read-only.");
@@ -378,6 +446,7 @@
 		}
 	}
 
+	@Override
 	public void pasteText() throws DocumentValidationException, ReadOnlyException {
 		if (isReadOnly()) {
 			throw new ReadOnlyException("Cannot paste text, because the editor is read-only.");
@@ -390,26 +459,32 @@
 		}
 	}
 
+	@Override
 	public void redo() throws CannotRedoException {
 		impl.redo();
 	}
 
+	@Override
 	public boolean canRemoveAttribute(final String attributeName) {
 		return impl.canRemoveAttribute(attributeName);
 	}
 
+	@Override
 	public void removeAttribute(final String attributeName) {
 		impl.removeAttribute(attributeName);
 	}
 
+	@Override
 	public void savePosition(final Runnable runnable) {
 		impl.savePosition(runnable);
 	}
 
+	@Override
 	public void selectAll() {
 		impl.selectAll();
 	}
 
+	@Override
 	public void selectWord() {
 		impl.selectWord();
 	}
@@ -427,30 +502,37 @@
 		return impl.canSetAttribute(attributeName, value);
 	}
 
+	@Override
 	public void setAttribute(final String attributeName, final String value) {
 		impl.setAttribute(attributeName, value);
 	}
 
+	@Override
 	public void setDebugging(final boolean debugging) {
 		impl.setDebugging(debugging);
 	}
 
+	@Override
 	public boolean isReadOnly() {
 		return impl.isReadOnly();
 	}
 
+	@Override
 	public void setReadOnly(final boolean readOnly) {
 		impl.setReadOnly(readOnly);
 	}
 
+	@Override
 	public void setDocument(final IDocument doc, final StyleSheet styleSheet) {
 		impl.setDocument(doc, styleSheet);
 	}
 
+	@Override
 	public void setLayoutWidth(final int width) {
 		impl.setLayoutWidth(width);
 	}
 
+	@Override
 	public void setStyleSheet(final StyleSheet styleSheet) {
 		impl.setStyleSheet(styleSheet);
 	}
@@ -459,42 +541,52 @@
 		impl.setStyleSheet(ssUrl);
 	}
 
+	@Override
 	public void setWhitespacePolicy(final IWhitespacePolicy whitespacePolicy) {
 		impl.setWhitespacePolicy(whitespacePolicy);
 	}
 
+	@Override
 	public IWhitespacePolicy getWhitespacePolicy() {
 		return impl.getWhitespacePolicy();
 	}
 
+	@Override
 	public boolean canSplit() {
 		return impl.canSplit();
 	}
 
+	@Override
 	public void split() throws DocumentValidationException {
 		impl.split();
 	}
 
+	@Override
 	public void undo() throws CannotUndoException {
 		impl.undo();
 	}
 
+	@Override
 	public int viewToModel(final int x, final int y) {
 		return impl.viewToModel(x, y);
 	}
 
+	@Override
 	public void declareNamespace(final String namespacePrefix, final String namespaceURI) {
 		impl.declareNamespace(namespacePrefix, namespaceURI);
 	}
 
+	@Override
 	public void removeNamespace(final String namespacePrefix) {
 		impl.removeNamespace(namespacePrefix);
 	}
 
+	@Override
 	public void declareDefaultNamespace(final String namespaceURI) {
 		impl.declareDefaultNamespace(namespaceURI);
 	}
 
+	@Override
 	public void removeDefaultNamespace() {
 		impl.removeDefaultNamespace();
 	}
@@ -529,6 +621,7 @@
 	private ISelection selection;
 
 	private final Runnable caretTimerRunnable = new Runnable() {
+		@Override
 		public void run() {
 			impl.toggleCaret();
 		}
@@ -536,9 +629,11 @@
 	private final Timer caretTimer = new Timer(500, caretTimerRunnable);
 
 	private final ControlListener controlListener = new ControlListener() {
+		@Override
 		public void controlMoved(final ControlEvent e) {
 		}
 
+		@Override
 		public void controlResized(final ControlEvent e) {
 			final org.eclipse.swt.graphics.Rectangle r = getClientArea();
 			// There seems to be a bug in SWT (at least on Linux/GTK+)
@@ -558,11 +653,13 @@
 	};
 
 	private final FocusListener focusListener = new FocusListener() {
+		@Override
 		public void focusGained(final FocusEvent e) {
 			impl.setFocus(true);
 			caretTimer.start();
 		}
 
+		@Override
 		public void focusLost(final FocusEvent e) {
 			impl.setFocus(false);
 			caretTimer.stop();
@@ -571,6 +668,7 @@
 
 	private final IHostComponent hostComponent = new IHostComponent() {
 
+		@Override
 		public Graphics createDefaultGraphics() {
 			if (VexWidget.this.isDisposed()) {
 				System.out.println("*** Woot! VexWidget is disposed!");
@@ -578,6 +676,7 @@
 			return new SwtGraphics(new GC(VexWidget.this));
 		}
 
+		@Override
 		public void fireSelectionChanged() {
 
 			if (hasSelection()) {
@@ -594,14 +693,17 @@
 			caretTimer.reset();
 		}
 
+		@Override
 		public Rectangle getViewport() {
 			return new Rectangle(getClientArea().x - originX, getClientArea().y - originY, getClientArea().width, getClientArea().height);
 		}
 
+		@Override
 		public void invokeLater(final Runnable runnable) {
 			VexWidget.this.getDisplay().asyncExec(runnable);
 		}
 
+		@Override
 		public void repaint() {
 			if (!VexWidget.this.isDisposed()) {
 				// We can sometimes get a repaint from the VexWidgetImpl's
@@ -610,10 +712,12 @@
 			}
 		}
 
+		@Override
 		public void repaint(final int x, final int y, final int width, final int height) {
 			VexWidget.this.redraw(x + originX, y + originY, width, height, true);
 		}
 
+		@Override
 		public void scrollTo(final int left, final int top) {
 			final ScrollBar vbar = getVerticalBar();
 			if (vbar != null) {
@@ -622,6 +726,7 @@
 			setOrigin(-left, -top);
 		}
 
+		@Override
 		public void setPreferredSize(final int width, final int height) {
 			final ScrollBar vbar = getVerticalBar();
 			if (vbar != null) {
@@ -633,6 +738,7 @@
 
 	private static abstract class Action implements IVexWidgetHandler {
 
+		@Override
 		public void execute(final VexWidget widget) throws ExecutionException {
 			runEx(widget);
 		}
@@ -669,12 +775,14 @@
 	};
 
 	private final MouseListener mouseListener = new MouseListener() {
+		@Override
 		public void mouseDoubleClick(final MouseEvent e) {
 			if (e.button == 1) {
 				selectWord();
 			}
 		}
 
+		@Override
 		public void mouseDown(final MouseEvent e) {
 			if (e.button == 1) {
 				final int offset = viewToModel(e.x - originX, e.y - originY);
@@ -683,11 +791,13 @@
 			}
 		}
 
+		@Override
 		public void mouseUp(final MouseEvent e) {
 		}
 	};
 
 	private final MouseMoveListener mouseMoveListener = new MouseMoveListener() {
+		@Override
 		public void mouseMove(final MouseEvent e) {
 			if ((e.stateMask & SWT.BUTTON1) > 0) {
 				final int offset = viewToModel(e.x - originX, e.y - originY);
@@ -697,6 +807,7 @@
 	};
 
 	private final PaintListener painter = new PaintListener() {
+		@Override
 		public void paintControl(final PaintEvent e) {
 
 			final SwtGraphics g = new SwtGraphics(e.gc);
@@ -719,6 +830,7 @@
 	};
 
 	private final SelectionListener selectionListener = new SelectionListener() {
+		@Override
 		public void widgetSelected(final SelectionEvent e) {
 			final ScrollBar vbar = getVerticalBar();
 			if (vbar != null) {
@@ -727,6 +839,7 @@
 			}
 		}
 
+		@Override
 		public void widgetDefaultSelected(final SelectionEvent e) {
 		}
 	};
@@ -738,6 +851,7 @@
 	 * @see org.eclipse.swt.widgets.Widget#dispose
 	 */
 	private final DisposeListener disposeListener = new DisposeListener() {
+		@Override
 		public void widgetDisposed(final DisposeEvent e) {
 			impl.dispose();
 			caretTimer.stop();
diff --git a/org.eclipse.vex.ui/plugin.properties b/org.eclipse.vex.ui/plugin.properties
index aa2898a..1d77aa0 100644
--- a/org.eclipse.vex.ui/plugin.properties
+++ b/org.eclipse.vex.ui/plugin.properties
@@ -39,6 +39,7 @@
 command.category.name= Vex - Visual Editor for XML
 command.addElement.name= Add Element...
 command.duplicateSelection.name= Duplicate Selection
+command.join.name= Join
 command.convertElement.name= Convert Element To...
 # dynamic names for 'Remove Tag' see org.eclipse.vex.ui.internal.editor.messages.properties
 command.removeTag.name= Remove Tag
@@ -69,6 +70,7 @@
 menu.Add.ColumnLeft.name= Column to the &Left
 menu.Add.ColumnRight.name= Column to the &Right
 menu.DuplicateSelection.name= &Duplicate Selection
+menu.Join.name= Join
 menu.EditNamespaces.name= &Namespaces...
 menu.Move.name= &Move
 menu.Move.RowUp= Row &Up
diff --git a/org.eclipse.vex.ui/plugin.xml b/org.eclipse.vex.ui/plugin.xml
index c05ce40..b991cfc 100644
--- a/org.eclipse.vex.ui/plugin.xml
+++ b/org.eclipse.vex.ui/plugin.xml
@@ -89,6 +89,11 @@
       </command>
       <command
             categoryId="org.eclipse.vex.ui.commands.category"
+            id="org.eclipse.vex.ui.JoinCommand"
+            name="%command.join.name">
+      </command>
+      <command
+            categoryId="org.eclipse.vex.ui.commands.category"
             id="org.eclipse.vex.ui.ConvertElementCommand"
             name="%command.convertElement.name">
       </command>
@@ -272,6 +277,15 @@
          </activeWhen>
       </handler>
       <handler
+            class="org.eclipse.vex.ui.internal.handlers.JoinHandler"
+            commandId="org.eclipse.vex.ui.JoinCommand">
+         <activeWhen>
+            <reference
+                  definitionId="org.eclipse.vex.ui.activeVexEditor">
+            </reference>
+         </activeWhen>
+      </handler>
+      <handler
             class="org.eclipse.vex.ui.internal.handlers.ConvertElementHandler"
             commandId="org.eclipse.vex.ui.ConvertElementCommand">
          <activeWhen>
@@ -1003,6 +1017,10 @@
                </command>
             </menu>
             <command
+                  commandId="org.eclipse.vex.ui.JoinCommand"
+                  label="%menu.Join.name">
+            </command>
+            <command
                   commandId="org.eclipse.vex.ui.EditNamespacesCommand"
                   label="%menu.EditNamespaces.name">
             </command>
@@ -1053,6 +1071,10 @@
                commandId="org.eclipse.vex.ui.RemoveTagCommand">
          </command>
          <command
+               commandId="org.eclipse.vex.ui.JoinCommand"
+               label="%menu.Join.name">
+         </command>
+         <command
                commandId="org.eclipse.vex.ui.EditNamespacesCommand"
                label="%menu.EditNamespaces.name">
          </command>
diff --git a/org.eclipse.vex.ui/src/org/eclipse/vex/ui/internal/handlers/JoinHandler.java b/org.eclipse.vex.ui/src/org/eclipse/vex/ui/internal/handlers/JoinHandler.java
new file mode 100644
index 0000000..cf2c25f
--- /dev/null
+++ b/org.eclipse.vex.ui/src/org/eclipse/vex/ui/internal/handlers/JoinHandler.java
@@ -0,0 +1,28 @@
+/*******************************************************************************
+ * Copyright (c) 2013 Florian Thienel and others.
+ * All rights reserved. This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License v1.0
+ * which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v10.html
+ *
+ * Contributors:
+ * 		Florian Thienel - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.vex.ui.internal.handlers;
+
+import org.eclipse.core.commands.ExecutionException;
+import org.eclipse.vex.core.internal.widget.swt.VexWidget;
+
+/**
+ * @author Florian Thienel
+ */
+public class JoinHandler extends AbstractVexWidgetHandler {
+
+	@Override
+	public void execute(final VexWidget widget) throws ExecutionException {
+		if (widget.canJoin()) {
+			widget.join();
+		}
+	}
+
+}