/*
* 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.macro;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import org.xwiki.gwt.dom.client.DOMUtils;
import org.xwiki.gwt.dom.client.Document;
import org.xwiki.gwt.dom.client.DocumentFragment;
import org.xwiki.gwt.dom.client.Element;
import org.xwiki.gwt.dom.client.InnerHTMLListener;
import org.xwiki.gwt.user.client.ui.rta.RichTextArea;
import com.google.gwt.dom.client.Node;
/**
* Hides macro meta data and displays macro output in a read only text box.
*
* @version $Id: afbf7c487bc04f899253a3af0c353aed5669c182 $
*/
public class MacroDisplayer implements InnerHTMLListener
{
/**
* The CSS class name used on the text box containing the output of a macro.
*/
public static final String MACRO_STYLE_NAME = "macro";
/**
* The CSS class name used on the text box containing the output of a selected macro.
*/
public static final String SELECTED_MACRO_STYLE_NAME = MACRO_STYLE_NAME + "-selected";
/**
* The CSS class name used on the text box containing the output of a block macro.
*/
public static final String BLOCK_MACRO_STYLE_NAME = MACRO_STYLE_NAME + "-block";
/**
* The CSS class name used on the text box containing the output of an in-line macro.
*/
public static final String INLINE_MACRO_STYLE_NAME = MACRO_STYLE_NAME + "-inline";
/**
* The CSS class name used on the macro container when a place-holder is displayed instead of the macro output.
*/
public static final String COLLAPSED_MACRO_STYLE_NAME = MACRO_STYLE_NAME + "-collapsed";
/**
* The CSS class name used on the macro content place-holder.
*/
public static final String MACRO_PLACEHOLDER_STYLE_NAME = "macro-placeholder";
/**
* The prefix of the start macro comment node.
*/
public static final String START_MACRO_COMMENT_PREFIX = "startmacro:";
/**
* The value of the stop macro comment node.
*/
public static final String STOP_MACRO_COMMENT_VALUE = "stopmacro";
/**
* Collection of DOM utility methods.
*/
protected final DOMUtils domUtils = DOMUtils.getInstance();
/**
* The underlying rich text area where the macros are displayed.
*/
private final RichTextArea textArea;
/**
* Creates a new macro displayer for the given rich text area.
*
* @param textArea the rich text area whose macros will be displayed using this object
*/
public MacroDisplayer(RichTextArea textArea)
{
this.textArea = textArea;
// Listen to rich text area's inner HTML changes to detect new macros.
textArea.getDocument().addInnerHTMLListener(this);
// Display the current macros.
display(getStartMacroCommentNodes(textArea.getDocument().getBody()));
}
/**
* Destroys this displayer.
*/
public void destroy()
{
textArea.getDocument().removeInnerHTMLListener(this);
}
/**
* @return {@link #textArea}
*/
public RichTextArea getTextArea()
{
return textArea;
}
/**
* Displays the macros from the given list. Each macro is identified by its start comment node which contains meta
* data regarding the macro call.
*
* @param startMacroComments the list of macros to be displayed
*/
private void display(List<Node> startMacroComments)
{
for (Node start : startMacroComments) {
display(start);
}
}
/**
* Displays the macro identified by the given start comment node which contains meta data about the macro call.
*
* @param start the start comment node identifying the macro to be displayed
*/
private void display(Node start)
{
// Look for the stop macro comment.
Node stop = start.getNextSibling();
int siblingCount = 0;
int openedMacrosCount = 0;
while (stop != null) {
if (stop.getNodeType() == DOMUtils.COMMENT_NODE) {
if (stop.getNodeValue().startsWith(START_MACRO_COMMENT_PREFIX)) {
// Nested macro. Ignore the next stop macro comment.
openedMacrosCount++;
} else if (STOP_MACRO_COMMENT_VALUE.equals(stop.getNodeValue())) {
// Check if there are nested macros opened.
if (openedMacrosCount == 0) {
break;
}
openedMacrosCount--;
}
}
stop = stop.getNextSibling();
siblingCount++;
}
if (stop == null) {
return;
}
Element container;
if (siblingCount == 1 && isMacroContainer(start.getNextSibling())) {
// Macro container is already there.
container = (Element) start.getNextSibling();
} else {
// Put macro output inside a read only text box.
container = createMacroContainer(start, stop, siblingCount);
// Expand the macro by default.
setCollapsed(container, false);
}
// Hide macro meta data.
container.setMetaData(extractMetaData(start, stop));
// We have to display the macro as unselected to ensure the selected state is changed only from the
// MacroSelector.
setSelected(container, false);
}
/**
* Puts macro output inside a read only text box that can be collapsed.
*
* @param start start macro comment node
* @param stop stop macro comment node
* @param siblingCount the number of siblings between start and stop nodes
* @return the created container that holds the macro output
*/
protected Element createMacroContainer(Node start, Node stop, int siblingCount)
{
boolean inLine = isInLine(start, stop, siblingCount);
// Create the read only text box.
Element container = createReadOnlyBox(inLine);
container.addClassName(MACRO_STYLE_NAME);
container.addClassName(inLine ? INLINE_MACRO_STYLE_NAME : BLOCK_MACRO_STYLE_NAME);
MacroCall call = new MacroCall(start.getNodeValue());
container.setTitle(call.getName() + " macro");
// Use a place holder when the macro is collapsed or when it is empty.
// The place holder is hidden when the macro is expanded.
container.appendChild(createPlaceHolder(call));
// Extract the macro output, if there is any.
if (siblingCount > 0) {
int startIndex = domUtils.getNodeIndex(start);
int endIndex = startIndex + siblingCount + 1;
Document doc = textArea.getDocument();
// We need to put macro output inside a container to be able to hide it when the macro is collapsed.
Element output = Element.as(inLine ? doc.createSpanElement() : doc.createDivElement());
output.appendChild(domUtils.extractNodeContents(start.getParentNode(), startIndex + 1, endIndex));
output.setClassName("macro-output");
container.appendChild(output);
}
// Insert the macro container before the start macro comment node, which will be removed.
start.getParentNode().insertBefore(container, start);
if (inLine) {
// We need to make sure that a double click inside the macro output or its placeholder doesn't select the
// nearby text (to the left or to the right of the macro) because this may prevent the user from editing the
// macro. See XWIKI-11057: Unable to double click to edit an inline macro in WYSIWYG
limitTextSelectionOnDoubleClick(container);
}
return container;
}
/**
* @param start start macro comment node
* @param stop stop macro comment node
* @param siblingCount the number of siblings between start and stop nodes
* @return {@code false} if the output of the macro contains block-level elements and thus the macro needs to be
* displayed as a block, {@code true} otherwise
*/
private boolean isInLine(Node start, Node stop, int siblingCount)
{
if (siblingCount > 0) {
Node sibling = start.getNextSibling();
while (sibling != stop) {
if (domUtils.isBlock(sibling)) {
return false;
}
sibling = sibling.getNextSibling();
}
return true;
} else {
return !domUtils.isFlowContainer(start.getParentNode());
}
}
/**
* @param inLine {@code true} if the read-only box is going to displayed in-line, {@code false} otherwise
* @return an element whose contents cannot be edited inside the rich text area
*/
protected Element createReadOnlyBox(boolean inLine)
{
Document doc = textArea.getDocument();
Element container = Element.as(inLine ? doc.createSpanElement() : doc.createDivElement());
container.setClassName("readOnly");
return container;
}
/**
* @param root the root of a DOM subtree
* @return the list of start macro comment nodes for the top level macros under the given subtree (nested macros are
* ignored)
*/
private List<Node> getStartMacroCommentNodes(Node root)
{
Document document = (Document) root.getOwnerDocument();
Iterator<Node> iterator = document.getIterator(root);
List<Node> startMacroComments = new ArrayList<Node>();
int openedMacrosCount = 0;
while (iterator.hasNext()) {
Node node = iterator.next();
if (node.getNodeType() == DOMUtils.COMMENT_NODE) {
if (node.getNodeValue().startsWith(START_MACRO_COMMENT_PREFIX)) {
// Include only the top level macros.
if (openedMacrosCount == 0) {
startMacroComments.add(node);
}
openedMacrosCount++;
} else if (STOP_MACRO_COMMENT_VALUE.equals(node.getNodeValue())) {
openedMacrosCount--;
}
}
}
return startMacroComments;
}
/**
* Creates a place holder for an empty macro.
*
* @param call the macro call
* @return a document fragment to be used as a place holder for an empty macro
*/
private DocumentFragment createPlaceHolder(MacroCall call)
{
Document document = textArea.getDocument();
// We use a separate element to display the macro icon to be sure that the line height is not less than the
// image height (we cannot set the height for in-line macros). We use a span instead of an image element because
// we couldn't find a way to hide the image selection when a collapsed macro is selected.
Element macroIcon = Element.as(document.createSpanElement());
macroIcon.setClassName("macro-icon");
Element placeHolder = document.createSpanElement().cast();
placeHolder.appendChild(macroIcon);
placeHolder.appendChild(document.createTextNode(call.getName()));
placeHolder.setClassName(MACRO_PLACEHOLDER_STYLE_NAME);
DocumentFragment output = document.createDocumentFragment();
output.appendChild(placeHolder);
return output;
}
/**
* Make sure that a double click inside the given in-line element selects only text that is inside the element.
*
* @param container the element for which to limit the text selection on double click
*/
protected void limitTextSelectionOnDoubleClick(Element container)
{
container.insertFirst(createSelectionBoundary());
container.appendChild(createSelectionBoundary());
}
/**
* @return an in-line element that limits the text selection on double click
*/
private Element createSelectionBoundary()
{
Document document = textArea.getDocument();
Element boundary = Element.as(document.createSpanElement());
// The element must contain a space, otherwise the selection goes over it. We tried to add the space from CSS
// but it doesn't work. The space must be present in the DOM (as a text node).
boundary.appendChild(document.createTextNode("\u00A0"));
boundary.setClassName("selectionBoundary");
return boundary;
}
/**
* Extracts the meta data nodes from the DOM tree and places them in a document fragment.
*
* @param start start macro comment node
* @param stop stop macro comment node
* @return the meta data document fragment
*/
private DocumentFragment extractMetaData(Node start, Node stop)
{
DocumentFragment metaData = textArea.getDocument().createDocumentFragment();
metaData.appendChild(start);
metaData.appendChild(stop);
return metaData;
}
/**
* Changes the appearance of the specified macro based on its selected state.
*
* @param container a macro container
* @param selected {@code true} to select the specified macro, {@code false} otherwise
*/
public void setSelected(Element container, boolean selected)
{
if (selected) {
container.addClassName(SELECTED_MACRO_STYLE_NAME);
} else {
container.removeClassName(SELECTED_MACRO_STYLE_NAME);
}
}
/**
* @param container a macro container
* @return {@code true} if the specified macro is selected, {@code false} otherwise
*/
public boolean isSelected(Element container)
{
return container.hasClassName(SELECTED_MACRO_STYLE_NAME);
}
/**
* @param node a DOM node
* @return {@code true} if the given node is a macro container, {@code false} otherwise
*/
public boolean isMacroContainer(Node node)
{
if (!Element.is(node)) {
return false;
}
Element element = Element.as(node);
return element.hasClassName(MACRO_STYLE_NAME) && element.hasAttribute(Element.META_DATA_ATTR)
&& element.getAttribute(Element.META_DATA_ATTR).startsWith(START_MACRO_COMMENT_PREFIX, 4);
}
/**
* @param root a DOM element
* @return the list of macro containers in the specified subtree
*/
public List<Element> getMacroContainers(Element root)
{
Node node = root;
List<Element> containers = new ArrayList<Element>();
while (true) {
boolean isMacroContainer = isMacroContainer(node);
if (!node.hasChildNodes() || isMacroContainer) {
// Add the node to the list of containers and skip its descendants.
if (isMacroContainer) {
containers.add(Element.as(node));
}
// Look for the next node.
while (node != root && node.getNextSibling() == null) {
node = node.getParentNode();
}
if (node == root) {
break;
} else {
node = node.getNextSibling();
}
} else {
node = node.getFirstChild();
}
}
return containers;
}
/**
* Collapses or expands the specified macro.
*
* @param container a macro container
* @param collapsed {@code true} to collapse the specified macro, {@code false} to expand it
*/
public void setCollapsed(Element container, boolean collapsed)
{
Element output = getOutput(container);
boolean collapse = collapsed || output == null;
if (collapse) {
container.addClassName(COLLAPSED_MACRO_STYLE_NAME);
} else {
container.removeClassName(COLLAPSED_MACRO_STYLE_NAME);
}
}
/**
* @param container a macro container
* @return {@code true} if the specified macro is collapsed, {@code false} otherwise
*/
public boolean isCollapsed(Element container)
{
return container.hasClassName(COLLAPSED_MACRO_STYLE_NAME);
}
/**
* @param container a macro container
* @return the macro output wrapper from the given macro container
*/
protected Element getOutput(Element container)
{
return (Element) getPlaceHolder(container).getNextSibling();
}
/**
* This method is useful to determine if a macro can be expanded or not. Macros that don't generate any output are
* always displayed as collapsed because otherwise they would be invisible to the user.
*
* @param container a macro container
* @return {@code true} if the specified macro has any output, {@code false} otherwise
*/
public boolean hasOutput(Element container)
{
return getOutput(container) != null;
}
/**
* @param container a macro container
* @return the macro place holder from the given macro container
*/
protected Element getPlaceHolder(Element container)
{
Element placeHolder = Element.as(container.getFirstChildElement());
while (placeHolder != null && !placeHolder.hasClassName(MACRO_PLACEHOLDER_STYLE_NAME)) {
placeHolder = Element.as(placeHolder.getNextSiblingElement());
}
return placeHolder;
}
@Override
public void onInnerHTMLChange(Element element)
{
if (element.getOwnerDocument() == textArea.getDocument()) {
display(getStartMacroCommentNodes(element));
}
}
/**
* @param container a macro container
* @return the serialized macro call (e.g. the value of the start macro comment node) associated with the given
* macro container
*/
public String getSerializedMacroCall(Element container)
{
return container.getMetaData().getFirstChild().getNodeValue();
}
}