/*
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
* This is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package org.xwiki.gwt.wysiwyg.client.plugin.line;
import java.util.ArrayList;
import java.util.List;
import org.xwiki.gwt.dom.client.DOMUtils;
import org.xwiki.gwt.dom.client.Document;
import org.xwiki.gwt.dom.client.Element;
import org.xwiki.gwt.dom.client.Event;
import org.xwiki.gwt.dom.client.Range;
import org.xwiki.gwt.dom.client.Selection;
import org.xwiki.gwt.dom.client.Style;
import org.xwiki.gwt.user.client.Config;
import org.xwiki.gwt.user.client.KeyboardAdaptor;
import org.xwiki.gwt.user.client.StringUtils;
import org.xwiki.gwt.user.client.ui.rta.RichTextArea;
import org.xwiki.gwt.user.client.ui.rta.cmd.Command;
import org.xwiki.gwt.user.client.ui.rta.cmd.CommandListener;
import org.xwiki.gwt.user.client.ui.rta.cmd.CommandManager;
import org.xwiki.gwt.wysiwyg.client.plugin.internal.AbstractPlugin;
import com.google.gwt.dom.client.Node;
import com.google.gwt.dom.client.NodeList;
import com.google.gwt.event.dom.client.KeyCodes;
import com.google.gwt.regexp.shared.RegExp;
/**
* Overwrites the behavior of creating new lines of text and merging existing ones.
*
* @version $Id: cc307760c00232bd40a623ebd341d205a3b49ca2 $
*/
public class LinePlugin extends AbstractPlugin implements CommandListener
{
/**
* The command that stores the value of the rich text area in an HTML form field.
*/
public static final Command SUBMIT = new Command("submit");
/**
* The command that notifies us when the content of the rich text area has been reset.
*/
public static final Command RESET = new Command("reset");
/**
* The CSS class name associated with BRs added at edit time to make items like empty block-level elements editable.
*/
public static final String SPACER = "spacer";
/**
* The CSS class name associated with BRs that are present in the rich text area's HTML input and which have to be
* kept even when they are placed at the end of a block-level element. We need to mark this initial BRs so they are
* not mistaken with the {@link #SPACER} BRs we add during the editing.
*/
public static final String LINE_BREAK = "lineBreak";
/**
* The class name attribute.
*/
public static final String CLASS_NAME = "class";
/**
* The name of the <code><br/></code> tag.
*/
public static final String BR = "br";
/**
* The name of the <code><li></code> tag.
*/
public static final String LI = "li";
/**
* The name of the <code><td></code> tag.
*/
public static final String TD = "td";
/**
* The name of the <code><th></code> tag.
*/
public static final String TH = "th";
/**
* A regular expression that matches a string full of whitespace.
*/
private static final RegExp WHITESPACE = RegExp.compile("^\\s+$");
/**
* Collection of DOM utility methods.
*/
protected final DOMUtils domUtils = DOMUtils.getInstance();
/**
* The object used to handle keyboard events.
*/
private final KeyboardAdaptor keyboardAdaptor = new KeyboardAdaptor()
{
protected void handleRepeatableKey(Event event)
{
LinePlugin.this.handleRepeatableKey(event);
}
};
@Override
public void init(RichTextArea textArea, Config config)
{
super.init(textArea, config);
saveRegistration(getTextArea().addKeyDownHandler(keyboardAdaptor));
saveRegistration(getTextArea().addKeyUpHandler(keyboardAdaptor));
saveRegistration(getTextArea().addKeyPressHandler(keyboardAdaptor));
getTextArea().getCommandManager().addCommandListener(this);
// Adjust the initial content of the rich text area.
onReset();
}
@Override
public void destroy()
{
getTextArea().getCommandManager().removeCommandListener(this);
super.destroy();
}
/**
* Handles a repeatable key press.
*
* @param event the native event that was fired
*/
protected void handleRepeatableKey(Event event)
{
switch (event.getKeyCode()) {
case KeyCodes.KEY_ENTER:
onEnter(event);
break;
case KeyCodes.KEY_BACKSPACE:
onBackspace(event);
break;
default:
break;
}
}
@Override
public boolean onBeforeCommand(CommandManager sender, Command command, String param)
{
if (SUBMIT.equals(command)) {
// The edited content might be submitted so we have to mark the BRs that have been added to allow the user
// to edit the empty block elements. These BRs will be removed from rich text area's HTML output on the
// server side.
markSpacers();
}
return false;
}
@Override
public void onCommand(CommandManager sender, Command command, String param)
{
if (SUBMIT.equals(command)) {
// Revert the changes made on before submit command in order avoid conflicts with the rich text area's
// history mechanism.
unMarkSpacers();
} else if (RESET.equals(command)) {
onReset();
}
}
/**
* Marks the BRs that have been added as spacers during the editing. These BRs were added to overcome a Mozilla bug
* that prevents us from typing inside an empty block level element.
*/
protected void markSpacers()
{
Document document = getTextArea().getDocument();
NodeList<com.google.gwt.dom.client.Element> brs = document.getBody().getElementsByTagName(BR);
for (int i = 0; i < brs.getLength(); i++) {
Element br = brs.getItem(i).cast();
// Ignore the BRs that have been there from the beginning.
if (LINE_BREAK.equals(br.getClassName())) {
continue;
}
Node container = domUtils.getNearestBlockContainer(br);
Node leaf = domUtils.getNextLeaf(br);
boolean emptyLine = true;
// Look if there is any visible element on the new line, taking care to remain in the current block
// container.
while (leaf != null && container == domUtils.getNearestBlockContainer(leaf)) {
if (needsSpace(leaf)) {
emptyLine = false;
break;
}
leaf = domUtils.getNextLeaf(leaf);
}
if (emptyLine) {
br.setClassName(SPACER);
} else {
br.removeAttribute(CLASS_NAME);
}
}
}
/**
* @see #markSpacers()
*/
protected void unMarkSpacers()
{
Document document = getTextArea().getDocument();
NodeList<com.google.gwt.dom.client.Element> brs = document.getBody().getElementsByTagName(BR);
for (int i = 0; i < brs.getLength(); i++) {
Element br = (Element) brs.getItem(i);
if (SPACER.equals(br.getClassName())) {
br.removeAttribute(CLASS_NAME);
}
}
}
/**
* @param leaf a DOM node which has not children
* @return {@code true} if the given leaf needs space on the screen in order to be rendered, {@code false} otherwise
*/
protected boolean needsSpace(Node leaf)
{
if (leaf == null) {
return false;
}
switch (leaf.getNodeType()) {
case Node.TEXT_NODE:
if (WHITESPACE.test(leaf.getNodeValue())) {
// We have to check if the whitespace is rendered in the current context. Let's wrap the text node
// with a SPAN element and see if it has any width.
Element wrapper = Element.as(leaf.getOwnerDocument().createSpanElement());
leaf.getParentNode().replaceChild(wrapper, leaf);
wrapper.appendChild(leaf);
// Note: We test only the width because an empty SPAN element normally has the height of the line.
boolean needsSpace = wrapper.getOffsetWidth() > 0;
// Unwrap the whitespace text node.
wrapper.getParentNode().replaceChild(leaf, wrapper);
return needsSpace;
} else {
return leaf.getNodeValue().length() > 0;
}
case Node.ELEMENT_NODE:
Element element = Element.as(leaf);
return BR.equalsIgnoreCase(element.getTagName()) || element.getOffsetHeight() > 0
|| element.getOffsetWidth() > 0;
default:
return false;
}
}
/**
* Marks the initial line breaks so they are not mistaken as {@link #SPACER}.
*/
protected void markInitialLineBreaks()
{
Document document = getTextArea().getDocument();
NodeList<com.google.gwt.dom.client.Element> brs = document.getBody().getElementsByTagName(BR);
for (int i = 0; i < brs.getLength(); i++) {
Element br = (Element) brs.getItem(i);
// Skip the spaces and the BRs added by the browser before the document was loaded.
if (!br.hasAttribute("_moz_dirty") && !SPACER.equals(br.getClassName())) {
br.setClassName(LINE_BREAK);
}
}
}
/**
* Replaces {@code <div class="wikimodel-emptyline"/>} with {@code <p/>}. Empty lines are used by WikiModel to
* separate block level elements, but since the user should be able to write on these empty lines we convert them to
* paragraphs.
*/
protected void replaceEmptyLinesWithParagraphs()
{
Document document = getTextArea().getDocument();
NodeList<com.google.gwt.dom.client.Element> divs = document.getBody().getElementsByTagName("div");
// Since NodeList is updated when one of its nodes are detached, we store the empty lines in a separate list.
List<Node> emptyLines = new ArrayList<Node>();
for (int i = 0; i < divs.getLength(); i++) {
Element div = divs.getItem(i).cast();
if (div.hasClassName("wikimodel-emptyline")) {
emptyLines.add(div);
}
}
// Replace the empty lines with paragraphs.
for (Node emptyLine : emptyLines) {
emptyLine.getParentNode().replaceChild(document.createPElement(), emptyLine);
}
}
/**
* Overwrites the default rich text area behavior when the Enter key is being pressed.
*
* @param event the native event that was fired
*/
protected void onEnter(Event event)
{
Selection selection = getTextArea().getDocument().getSelection();
if (!selection.isCollapsed()) {
// Selection + Enter = Selection + Delete + Enter
// NOTE: We cannot use Range#deleteContents because it may lead to DTD-invalid HTML. That's because it
// operates on any DOM tree without taking care of the underlying XML syntax, (X)HTML in our case. Let's use
// the Delete command instead which is HTML-aware.
// NOTE: The Delete command can have side-effects like the insertion of a bogus BR tag. Be aware!
getTextArea().getDocument().execCommand(Command.DELETE.toString(), null);
}
// At this point the selection should be collapsed.
Range caret = selection.getRangeAt(0);
Node container = null;
// CTRL and META modifiers force the Enter key to be handled by the nearest block-level container. Otherwise we
// look for special containers like the list item.
if (!event.getCtrlKey() && !event.getMetaKey()) {
// See if the caret is inside a list item.
container = domUtils.getFirstAncestor(caret.getStartContainer(), LI);
}
if (container == null) {
// Look for the nearest block-level element that contains the caret.
container = domUtils.getNearestBlockContainer(caret.getStartContainer());
}
String containerName = container.getNodeName().toLowerCase();
if (LI.equals(containerName)) {
// Leave the default behavior for now.
return;
} else if (TD.equals(containerName) || TH.equals(containerName)) {
insertLineBreak(container, caret);
} else {
onEnterParagraph(container, caret, event);
}
// Cancel the event to prevent its default behavior.
event.xPreventDefault();
// Update the caret.
caret.collapse(true);
selection.removeAllRanges();
selection.addRange(caret);
}
/**
* Behaves as if the caret is inside a paragraph. Precisely:
* <ul>
* <li>SHIFT+Enter generates a line break</li>
* <li>Enter at the beginning of the line inserts an empty line before</li>
* <li>Enter anywhere else splits the current block and generates a new paragraph.</li>
* </ul>
*
* @param container a block-level element containing the caret
* @param caret the position of the caret inside the document
* @param event the native event that was fired
*/
protected void onEnterParagraph(Node container, Range caret, Event event)
{
if (event.getShiftKey()) {
insertLineBreak(container, caret);
} else if (isAtStart(container, caret)) {
insertEmptyLine(container, caret);
} else {
if (!isAfterLineBreak(container, caret)) {
insertLineBreak(container, caret);
}
splitLine(container, caret);
}
}
/**
* @param container a block level element containing the caret
* @param caret the position of the caret inside the document
* @return {@code true} if the caret is at the beginning of its block level container, {@code false} otherwise
*/
protected boolean isAtStart(Node container, Range caret)
{
if (!container.hasChildNodes()) {
return true;
}
if (caret.getStartOffset() > 0) {
return false;
}
return domUtils.getFirstLeaf(container) == domUtils.getFirstLeaf(caret.getStartContainer());
}
/**
* @param container a block level element containing the caret
* @param caret the position of the caret in the document
* @return {@code true} if the caret is immediately after a line break inside the given container, {@code false}
* otherwise
*/
protected boolean isAfterLineBreak(Node container, Range caret)
{
Node leaf;
if (caret.getStartOffset() > 0) {
if (caret.getStartContainer().getNodeType() == Node.ELEMENT_NODE) {
leaf = caret.getStartContainer().getChildNodes().getItem(caret.getStartOffset() - 1);
leaf = domUtils.getLastLeaf(leaf);
} else {
// We are in the middle of the text.
return false;
}
} else {
leaf = domUtils.getPreviousLeaf(caret.getStartContainer());
}
// We have to additionally test if the found line break is in the given container.
return isLineBreak(leaf) && container == domUtils.getNearestBlockContainer(leaf);
}
/**
* @param node a DOM node
* @return {@code true} if the given node marks a line break, {@code false} otherwise
*/
protected boolean isLineBreak(Node node)
{
return node != null && BR.equalsIgnoreCase(node.getNodeName());
}
/**
* Adjusts the line break position so that:
* <ul>
* <li>Anchors don't start or end with a line break.</li>
* </ul>
* See XWIKI-4193: When hitting Return at the end of the link the new line should not be a link.
*
* @param container the block-level element containing the line break
* @param br the line break
*/
protected void adjustLineBreak(Node container, Node br)
{
Node anchor = domUtils.getFirstAncestor(br, "a");
if (anchor != null) {
// NOTE: We assume the anchor is inside the container because the anchor is an in-line element while the
// container is a block-level element. We could test if the container is or has child the anchor but let's
// keep things simple for now.
// Check if the anchor starts with the given line break.
Node firstLeaf = domUtils.getFirstLeaf(anchor);
Node leaf = br;
boolean startsWithLineBreak = true;
while (leaf != firstLeaf) {
leaf = domUtils.getPreviousLeaf(leaf);
if (needsSpace(leaf)) {
startsWithLineBreak = false;
break;
}
}
if (startsWithLineBreak) {
// Move the line break before the anchor.
anchor.getParentNode().insertBefore(br, anchor);
} else {
// Check if the anchor ends with the given line break.
Node lastLeaf = domUtils.getLastLeaf(anchor);
leaf = br;
boolean endsWithLineBreak = true;
while (leaf != lastLeaf) {
leaf = domUtils.getNextLeaf(leaf);
if (needsSpace(leaf)) {
endsWithLineBreak = false;
break;
}
}
if (endsWithLineBreak) {
// Move the line break after the anchor.
domUtils.insertAfter(br, anchor);
}
}
}
}
/**
* Inserts a line break at the specified position in the document.
*
* @param container a block-level element containing the caret
* @param caret the place where to insert the line break
*/
protected void insertLineBreak(Node container, Range caret)
{
// Insert the line break.
Node lineBreak = getTextArea().getDocument().createBRElement();
switch (caret.getStartContainer().getNodeType()) {
case DOMUtils.CDATA_NODE:
case DOMUtils.COMMENT_NODE:
domUtils.insertAfter(lineBreak, caret.getStartContainer());
break;
case Node.TEXT_NODE:
Node refNode = domUtils.splitNode(caret.getStartContainer(), caret.getStartOffset());
refNode.getParentNode().insertBefore(lineBreak, refNode);
break;
case Node.ELEMENT_NODE:
domUtils.insertAt(caret.getStartContainer(), lineBreak, caret.getStartOffset());
break;
default:
break;
}
// Place the caret after the inserted line break.
caret.setStartAfter(lineBreak);
// In case the line break was inserted at the end of a line..
ensureLineBreakIsVisible(lineBreak, container);
}
/**
* Ensures that the line created by inserting a line break is visible. This is need especially when the line break
* is inserted at the end of an existing line because most browsers don't allow the caret to be placed on invisible
* lines.
*
* @param lineBreak the line break that was inserted
* @param container the container (e.g. the paragraph) where the line break was inserted
*/
protected void ensureLineBreakIsVisible(Node lineBreak, Node container)
{
Node lastLeaf = null;
Node leaf = lineBreak;
// Look if there is any visible element on the new line, taking care to remain in the current block container.
while (leaf != null && container == domUtils.getNearestBlockContainer(leaf)) {
lastLeaf = leaf;
leaf = domUtils.getNextLeaf(leaf);
if (needsSpace(leaf)) {
return;
}
}
if (lastLeaf != null) {
// It seems there's no visible element on the new line. We should add a spacer up in the tree.
Node ancestor = lastLeaf;
while (ancestor.getParentNode() != container && ancestor.getNextSibling() == null) {
ancestor = ancestor.getParentNode();
}
domUtils.insertAfter(getTextArea().getDocument().createBRElement(), ancestor);
}
}
/**
* Splits a line after a line break.
*
* @param container a block-level element containing the caret
* @param caret the position of the caret in the document
*/
protected void splitLine(Node container, Range caret)
{
Node br;
// Find the BR.
if (caret.getStartOffset() > 0) {
if (caret.getStartContainer().getNodeType() == Node.ELEMENT_NODE) {
br = caret.getStartContainer().getChildNodes().getItem(caret.getStartOffset() - 1);
br = domUtils.getLastLeaf(br);
} else {
return;
}
} else {
br = domUtils.getPreviousLeaf(caret.getStartContainer());
}
adjustLineBreak(container, br);
// Create a new paragraph.
Node paragraph = getTextArea().getDocument().createPElement();
// This is the node that will contain the caret after the split.
Node start;
// Split the container after the found BR.
if (domUtils.isFlowContainer(container)) {
start = splitContentAndWrap(container, br, paragraph);
} else {
start = splitAndReplace(container, br, paragraph);
copyLineStyle(Element.as(container), Element.as(paragraph));
}
br.getParentNode().removeChild(br);
// Make sure that both lines generated by the split can be edited.
// Note: the first call is required in case we split a line that starts with invisible garbage and the caret is
// just after this garbage. Another solution would be to enhance the isAtStart method to detect such cases but
// this is more complex.
Element.as(container).ensureEditable();
Element.as(paragraph).ensureEditable();
// Place the caret inside the new container, at the beginning.
caret.setStart(start, 0);
}
/**
* Inserts an empty line before the block containing the caret. This is useful when the caret is at the beginning of
* a block and we want to move that block down by one line; by pressing Enter we can do that.
*
* @param container a block-level element containing the caret
* @param caret the place where to insert the empty line
*/
protected void insertEmptyLine(Node container, Range caret)
{
Document document = getTextArea().getDocument();
// Create a new empty line.
Element emptyLine = document.createPElement().cast();
if (domUtils.isFlowContainer(container)) {
// We are at the beginning of a flow container. Since it can contain block elements we insert the empty line
// before its first child.
domUtils.insertAt(container, emptyLine, 0);
// We place the caret after the inserted empty line.
caret.setStartAfter(emptyLine);
} else {
// Insert the empty line before the container.
container.getParentNode().insertBefore(emptyLine, container);
}
// Ensure the newly created empty line can be edited (i.e. the user can place the caret inside it).
// Note: in order to have the desired effect in IE we need to call this method after the empty line is attached.
domUtils.ensureBlockIsEditable(emptyLine);
}
/**
* Overwrites the default rich text area behavior when the Backspace key is being pressed.
*
* @param event the native event that was fired
*/
protected void onBackspace(Event event)
{
Selection selection = getTextArea().getDocument().getSelection();
if (!selection.isCollapsed()) {
return;
}
Range caret = selection.getRangeAt(0);
// Look for the nearest block-level element that contains the caret.
Node container = domUtils.getNearestBlockContainer(caret.getStartContainer());
// See if the found container is preceded by an empty line.
if (domUtils.isBlockLevelInlineContainer(container) && isAtStart(container, caret)
&& isEmptyLine(container.getPreviousSibling())) {
// Cancel the event to prevent its default behavior.
event.xPreventDefault();
// Remove the empty line.
container.getParentNode().removeChild(container.getPreviousSibling());
}
}
/**
* @param node a DOM node
* @return {@code true} if the given node represents an empty line
*/
protected boolean isEmptyLine(Node node)
{
// Test of the given node is a paragraph.
if (node == null || !"p".equalsIgnoreCase(node.getNodeName())) {
return false;
}
// Test if the paragraph has visible child nodes.
Node child = node.getFirstChild();
while (child != null) {
if ((child.getNodeType() == Node.ELEMENT_NODE && ((Element) child).getOffsetWidth() > 0)
|| !StringUtils.isEmpty(child.getNodeValue())) {
return false;
}
child = child.getNextSibling();
}
return true;
}
/**
* Called after the content of the rich text area has been reset.
*/
protected void onReset()
{
markInitialLineBreaks();
replaceEmptyLinesWithParagraphs();
Element.as(getTextArea().getDocument().getBody()).ensureEditable();
}
/**
* Splits the given container in two parts, before the specified child and after, and replaces the container of the
* second part (to the right of the child node) with the given replacement.
*
* @param container the root of the subtree to be split
* @param child the descendant that marks the split point
* @param replacement the new container for the second part obtained from the split
* @return the node resulted from splitting the child, or the replacement if the container is split at the end
*/
private Node splitAndReplace(Node container, Node child, Node replacement)
{
Node start = domUtils.splitNode(container.getParentNode(), child.getParentNode(), domUtils.getNodeIndex(child));
if (start == container.getNextSibling()) {
start = replacement;
}
replacement.appendChild(Element.as(container.getNextSibling()).extractContents());
container.getParentNode().replaceChild(replacement, container.getNextSibling());
return start;
}
/**
* Splits the content of the given container in two parts, before the specified descendant and after, and wraps the
* in-line nodes to the right of the split with the given wrapper.
* <p>
* NOTE: The container itself is not split, only its content is.
*
* @param container the root of the subtree to be split; the root itself is not split
* @param descendant the descendant that marks the split point
* @param wrapper the new container for the in-line nodes that are positioned to the right of the split point
* @return the node resulted from splitting the descendant, or the wrapper if the descendant is a direct child of
* the container (in which case the split doesn't take place)
*/
private Node splitContentAndWrap(Node container, Node descendant, Node wrapper)
{
Node start = wrapper;
Node child = domUtils.getChild(container, descendant);
// If the descendant is a direct child of the container then we don't have to split.
if (child != descendant) {
start = domUtils.splitNode(container, descendant.getParentNode(), domUtils.getNodeIndex(descendant));
}
// Insert the wrapper after the split.
domUtils.insertAfter(wrapper, child);
// Move all the following in-line nodes inside the wrapper.
child = wrapper.getNextSibling();
while (child != null && domUtils.isInline(child)) {
wrapper.appendChild(child);
child = wrapper.getNextSibling();
}
return start;
}
/**
* Copy some of the CSS styles from the source line to the destination line. Call this method to ensure the
* important line styles are preserved on the new line after splitting a line.
*
* @param sourceLine the line from where to copy the styles
* @param destinationLine the line whose style will be changed
* @see #splitLine(Node, Range)
*/
protected void copyLineStyle(Element sourceLine, Element destinationLine)
{
destinationLine.getStyle().setProperty(Style.TEXT_ALIGN.getJSName(),
sourceLine.getStyle().getProperty(Style.TEXT_ALIGN.getJSName()));
}
}