We use cookies to make our website more effective. By using our website you agree to our privacy policy.

Source: lists.js

/**
 * lists.js is part of Aloha Editor project http://www.alohaeditor.org
 *
 * Aloha Editor ● JavaScript Content Editing Library
 * Copyright (c) 2010-2015 Gentics Software GmbH, Vienna, Austria.
 * Contributors http://www.alohaeditor.org/docs/contributing.html
 * @namespace lists
 */
define([
	'functions',
	'dom',
	'html',
	'arrays',
	'assert',
	'boromir',
	'zippers',
	'strings',
	'content',
	'mutation',
	'boundaries'
], function (
	Fn,
	Dom,
	Html,
	Arrays,
	Assert,
	Boromir,
	Zip,
	Strings,
	Content,
	Mutation,
	Boundaries
) {
	'use strict';

	/**
	 * Whether the given node is an inline node and is not right below the
	 * editing host.
	 *
	 * @private
	 * @param  {!Node} node
	 * @return {boolean}
	 */
	function hasInlineStyle(node) {
		return !Html.hasLinebreakingStyle(node)
		    && !(node.parentNode && Dom.isEditingHost(node.parentNode));
	}

	/**
	 * Reduces a list of nodes (if any are visible) into an LI element among the
	 * given list by moving nodes into LI elements.
	 *
	 * This function is to be used in a reduce() call.
	 *
	 * @private
	 * @param  {Array.<Element>} list collection of list items
	 * @param  {Array.<Node>}    children
	 * @return {Array.<Element>}
	 */
	function reduceGroup(list, children) {
		list = list.concat();
		var visible = children.filter(Html.isRendered);
		if (visible.length > 0) {
			var li = visible[0].ownerDocument.createElement('li');
			Dom.move(visible, li);
			list.push(li);
		}
		return list;
	}

	/**
	 * Recursively removes the given node and its ancestors if they are
	 * invisible.
	 *
	 * Empty list items will be removed even though it would be considered
	 * visible in general cases.
	 *
	 * @see build
	 * @private
	 * @param {!Node} node
	 */
	function removeInvisibleNodes(node) {
		var boundaries = [];
		Dom.climbUntil(
			node,
			function (node) {
				boundaries = Mutation.removeNode(node, boundaries);
			},
			function (node) {
				if (Html.isListItem(node) && 0 === Dom.children(node).length) {
					return false;
				}
				return !node.parentNode
				    || Dom.isEditingHost(node)
				    || Html.isRendered(node);
			}
		);
	}

	function isCollectLimit(node) {
		return !Html.isListItem(node) && Html.hasLinebreakingStyle(node);
	}

	/**
	 * Collects siblings between `start` and `end` and any adjacent inline nodes
	 * next to each.
	 *
	 * @see format
	 * @private
	 * @param  {!Node} start
	 * @param  {!Node} end
	 * @return {Array.<Node>}
	 */
	function collectSiblings(start, end) {
		var nodes = Dom.prevSiblings(start, isCollectLimit).concat(start);
		if (start !== end) {
			nodes = nodes.concat(Dom.nextSiblings(start, function (node) {
				return node === end;
			}), end);
		}
		return nodes.concat(Dom.nextSiblings(end, isCollectLimit));
	}

	/**
	 * Given a list of nodes, will process the list to create a groups of nodes
	 * that should be placed to gether in LI's.
	 *
	 * A `parents` arrays will also be created of nodes that may need be removed
	 * once the grouped elements have been moved into their respective
	 * destinations. This is required because we need to later remove any
	 * elements which will become empty once their children are moved into list
	 * elements.
	 *
	 * @see build
	 * @private
	 * @param  {Array.<Node>} siblings
	 * @return {Object.<string, Array.<Node>>}
	 */
	function groupNodes(siblings) {
		var groups = [];
		var parents = [];
		var nodes = siblings.concat();
		var collection;
		var split;
		var node;
		while (nodes.length > 0) {
			node = nodes.shift();
			var canUnwrap = !Html.isGroupContainer(node)
			             && !Html.isVoidType(node)
			             && !Html.isHeading(node);
			if (Html.hasLinebreakingStyle(node) && canUnwrap) {
				collection = Dom.children(node);
				parents.push(node);
			} else {
				collection = [node];
				parents.push(node.parentNode);
			}
			split = Arrays.split(nodes, Html.hasLinebreakingStyle);
			collection = collection.concat(split[0]);
			nodes = split[1];
			if (collection.length > 0) {
				groups.push(collection);
			}
		}
		return {
			groups  : groups,
			parents : parents
		};
	}

	/**
	 * Builds a list of type `type` using the given list of nodes.
	 *
	 * @private
	 * @param  {string}       type
	 * @param  {Array.<Node>} nodes
	 */
	function build(type, nodes) {
		if (0 === nodes.length) {
			return;
		}
		var node = Dom.upWhile(nodes[0], hasInlineStyle);
		if (Html.isListItem(node) && !Dom.prevSibling(node)) {
			node = node.parentNode;
		}
		Assert.assert(
			Content.allowsNesting(node.parentNode.nodeName, type),
			'Lists.format#Cannot create ' + type + ' inside of a ' + node.parentNode.nodeName
		);
		var list = node.ownerDocument.createElement(type);
		var grouping = groupNodes(nodes);
		Dom.insert(list, node);
		Dom.move(grouping.groups.reduce(reduceGroup, []), list);
		grouping.parents.forEach(removeInvisibleNodes);
	}

	/**
	 * Creates a list of the given type.
	 *
	 * @param  {string}    type Either 'ul' or 'ol'
	 * @param  {!Boundary} start
	 * @param  {!Boundary} end
	 * @return {Array.<Boundary>}
	 * @memberOf lists
	 */
	function format(type, start, end) {
		Assert.assert(
			Html.isListContainer({nodeName: type.toUpperCase()}),
			'Lists.format#' + type + ' is not a valid list container'
		);
		var node;
		if (Html.isBoundariesEqual(start, end)) {
			node = Dom.upWhile(Boundaries.nextNode(start), function (node) {
				return !Html.hasLinebreakingStyle(node)
				    && !Dom.isEditingHost(node.parentNode);
			});
			build(type, collectSiblings(node, node));
			return [start, end];
		}
		var cac = Boundaries.commonContainer(start, end);
		if (!Html.hasLinebreakingStyle(cac)) {
			node = Dom.upWhile(cac, function (node) {
				return node.parentNode
				    && !isCollectLimit(node.parentNode)
				    && !Dom.isEditingHost(node.parentNode);
			});
			build(type, collectSiblings(node, node));
			return [start, end];
		}
		var startNode = Dom.upWhile(Boundaries.nextNode(start), function (node) {
			return !isCollectLimit(node)
				&& !Dom.isEditingHost(node.parentNode);
		});
		var endNode = Dom.upWhile(Boundaries.prevNode(end), function (node) {
			return !isCollectLimit(node)
				&& !Dom.isEditingHost(node.parentNode);
		});

		// <div>
		//  <p>tw[o}</p>
		//  <p>three</p>
		// </div>
		//
		// ... or ...
		//
		// <div>
		//  <p>{t]wo</p>
		//  <p>three</p>
		// </div>
		/*
		if (startNode === cac) {
			startNode = endNode;
		} else if (endNode === cac) {
			endNode = startNode;
		}
		*/
		build(type, collectSiblings(startNode, endNode));
		return [start, end];
	}

	/**
	 * Unwraps all LI elements in the given collection of siblings.
	 *
	 * @private
	 * @param  {Array.<Node>} nodes
	 * @return {Array.<Node>} List of unwrapped nodes
	 */
	function unwrapItems(nodes) {
		return nodes.filter(Html.isListItem).reduce(function (lines, node) {
			return lines.concat(Html.unwrapListItem(node));
		}, []);
	}

	/**
	 * Removes list formatting around the given boundaries.
	 *
	 * @param  {!Boundary} start
	 * @param  {!Boundary} end
	 * @return {Array.<Boundary>}
	 * @memberOf lists
	 */
	function unformat(start, end) {
		var nearestItem = function (node) {
			return !Html.isListItem(node) && !Dom.isEditingHost(node.parentNode);
		};
		var sc = Boundaries.container(start);
		var so = Boundaries.offset(start);
		var ec = Boundaries.container(end);
		var eo = Boundaries.offset(end);
		var startLi = Dom.upWhile(sc, nearestItem);
		var endLi = Dom.upWhile(ec, nearestItem);
		var lines;
		if (Html.isListItem(startLi)) {
			lines = unwrapItems(Dom.nodeAndNextSiblings(startLi));
			if (Html.isListItem(sc)) {
				sc = lines[0];
				so = 0;
			}
		}
		if (sc === ec) {
			return [Boundaries.create(sc, so), end];
		}
		if (Html.isListItem(endLi)) {
			lines = unwrapItems(Dom.nodeAndNextSiblings(endLi));
			if (Html.isListItem(ec)) {
				ec = lines[0];
				eo = 0;
			}
		}
		return [Boundaries.create(sc, so), Boundaries.create(ec, eo)];
	}

	/**
	 * Formats the content between the given boundaries into a list.
	 * If the content is already a list, it will either unformat the content or
	 * reformat the content into the given list type.
	 *
	 * @param  {string}   type Either 'ul' or 'ol'
	 * @param  {!Boundary} start
	 * @param  {!Boundary} end
	 * @return {Array.<Boundary>}
	 * @memberOf lists
	 */
	function toggle(type, start, end) {
		var sc = Boundaries.container(start);
		var ec = Boundaries.container(end);
		var si = Dom.upWhile(sc, Html.isListItem);
		var ei = Dom.upWhile(ec, Html.isListItem);
		if (Html.isListItem(si) && Html.isListItem(ei) && si.parentNode === ei.parentNode) {
			if (si.parentNode.nodeName.toLowerCase() === type) {
				return unformat(start, end);
			}
		}
		return format(type, start, end);
	}

	/**
	 * Starting with the given, returns the first node that matches the given
	 * predicate.
	 *
	 * @private
	 * @param  {!Node}                  node
	 * @param  {function(Node):boolean} pred
	 * @return {Node}
	 */
	function nearest(node, pred) {
		return Dom.upWhile(node, function (node) {
			return !pred(node)
			    && !(node.parentNode && Dom.isEditingHost(node.parentNode));
		});
	}

	/**
	 * Checks if the given (normalized) boundary is (visually) at the start of a
	 * list item element.
	 *
	 * Strategy:
	 *
	 * 1. If a text boundary is given, there must not be any non-collapsable
	 *    characters in front of the boundary.
	 *
	 *    ... otherwise ...
	 *
	 * 2. The boundary must be container inside of a list item. This means that
	 *    it must have a list item as its container or an ancestor of its
	 *    container.
	 *
	 *    ... and ...
	 *
	 *    We must not find any visible nodes between  the start of this element
	 *    and the boundary that was given to start of with.
	 *
	 * @private
	 * @param  {!Boundary} boundary
	 * @return {boolean}
	 */
	function isAtStartOfListItem(boundary) {
		var node = Boundaries.prevNode(boundary);
		if (Dom.isTextNode(node)) {
			var text = node.data;
			var offset = Boundaries.offset(boundary);
			var prefix = text.substr(0, offset);
			var isVisible = Strings.NOT_SPACE.test(prefix)
			             || Strings.NON_BREAKING_SPACE.test(prefix);
			if (isVisible) {
				return false;
			}
			node = node.previousSibling || node.parentNode;
		}
		var stop = nearest(node, Html.isListItem);
		if (!Html.isListItem(stop)) {
			return false;
		}
		var start = Boundaries.fromFrontOfNode(stop);
		var visible = 0;
		Html.walkBetween(start, boundary, function (nodes) {
			visible += nodes.filter(Html.isRendered).length;
		});
		return 0 === visible;
	}

	function removeNext(loc, num) {
		var records = [];
		while (num--) {
			records.push(Zip.after(loc));
			loc = Zip.remove(loc);
		}
		return {
			loc     : loc,
			records : records
		};
	}

	function isRenderedRecord(record) {
		return Html.isRendered(record.domNode());
	}

	function prevVisible(loc) {
		var index = Arrays.someIndex(loc.lefts.concat().reverse(), isRenderedRecord);
		return -1 === index ? null : Zip.prev(loc, index + 1);
	}

	function nextVisible(loc) {
		var index = Arrays.someIndex(loc.rights, isRenderedRecord);
		return -1 === index ? null : Zip.next(loc, index);
	}

	function bottomJoiningLoc(loc) {
		loc = nextVisible(loc);
		if (!loc || !Html.isListItem(Zip.dom(loc))) {
			return null;
		}
		loc = nextVisible(Zip.down(loc));
		if (!loc || !Html.isListContainer(Zip.dom(loc))) {
			return null;
		}
		return loc;
	}

	function topJoiningLoc(loc) {
		loc = prevVisible(loc);
		if (!loc || !Html.isListItem(Zip.dom(loc))) {
			return null;
		}
		var atLiEnd = Zip.next(Zip.down(loc), loc.rights.length);
		loc = prevVisible(atLiEnd);
		if (!loc || !Html.isListContainer(Zip.dom(loc))) {
			return atLiEnd;
		}
		loc = Zip.down(loc);
		return Zip.next(loc, loc.rights.length);
	}

	function insertAt(loc, records) {
		if (!Html.isListContainer(Zip.dom(Zip.up(loc)))) {
			loc = Zip.down(Zip.insert(loc, Boromir(document.createElement('UL'))));
		}
		return Zip.insert(loc, records);
	}

	function indent(start, end) {
		var startLi = nearest(Boundaries.prevNode(start), Html.isListItem);
		var endLi = nearest(Boundaries.nextNode(end), Html.isListItem);
		// Because otherwise the range between `start` and `end` is not within a
		// list
		if (!Html.isListItem(startLi) || !Html.isListItem(endLi)) {
			return [start, end];
		}
		start = Boundaries.fromFrontOfNode(startLi);
		end = Boundaries.fromBehindOfNode(endLi);
		var cac = Boundaries.commonContainer(start, end);
		var zip = Zip.zipper(Dom.editingHost(startLi), {
			start : start,
			end   : end
		});
		var isBelowCac = function (loc) { return Zip.dom(Zip.up(loc)) === cac; };
		var loc = zip.loc;
		loc = Zip.splitAt(loc, zip.markers.start, isBelowCac);
		loc = Zip.splitAt(loc, zip.markers.end, isBelowCac);
		var bottom = bottomJoiningLoc(Zip.next(Zip.go(loc, zip.markers.end)));
		var records = [];
		var removed;
		if (bottom) {
			loc = Zip.down(bottom);
			removed = removeNext(loc, loc.rights.length);
			records = records.concat(removed.records);
			loc = Zip.remove(Zip.up(Zip.up(removed.loc)));
		}
		loc = Zip.go(loc, zip.markers.start);
		removed = removeNext(loc, Arrays.someIndex(loc.rights.slice(1), Zip.isMarker) + 2);
		records = removed.records.concat(records);
		loc = topJoiningLoc(removed.loc) || Zip.down(Zip.insert(
			removed.loc,
			Boromir(document.createElement('LI'))
		));
		var markers = Zip.update(Zip.root(insertAt(loc, records)));
		return [Boundaries.next(markers.start), Boundaries.prev(markers.end)];
	}

	function isIndentationRange(start, end) {
		var startLi = nearest(Boundaries.prevNode(start), Html.isListItem);
		if (!Html.isListItem(startLi)) {
			return false;
		}
		var endLi = nearest(Boundaries.nextNode(end), Html.isListItem);
		if (!Html.isListItem(endLi)) {
			return false;
		}
		// ✘ <li><b>fo[o</b><u>b]ar</u></li>
		// ✔ <li><b>{foo</b><u>b]ar</u></li>
		// ✔ <li><b>fo[o</b></li><li><u>b]ar</u></li>
		return startLi !== endLi || isAtStartOfListItem(start);
	}

	return {
		indent              : indent,
		format              : format,
		unformat            : unformat,
		toggle              : toggle,
		isIndentationRange  : isIndentationRange
	};
});
comments powered by Disqus