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

Source: selections.js

/**
 * selections.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 selections
 */
define([
	'functions',
	'dom',
	'keys',
	'maps',
	'html',
	'mouse',
	'events',
	'arrays',
	'ranges',
	'carets',
	'browsers',
	'overrides',
	'animation',
	'boundaries',
	'traversing',
	'editables'
], function (
	Fn,
	Dom,
	Keys,
	Maps,
	Html,
	Mouse,
	Events,
	Arrays,
	Ranges,
	Carets,
	Browsers,
	Overrides,
	Animation,
	Boundaries,
	Traversing,
	Editables
) {
	'use strict';

	/**
	 * Hides all visible caret elements and returns all those that were hidden
	 * in this operation.
	 *
	 * @param  {Document} doc
	 * @return {Array.<Element>}
	 */
	function hideCarets(doc) {
		var carets = doc.querySelectorAll('div.aloha-caret');
		var visible = [];
		[].forEach.call(carets, function (caret) {
			if ('block' === Dom.getStyle(caret, 'display')) {
				visible.push(caret);
				Dom.setStyle(caret, 'display', 'none');
			}
		});
		return visible;
	}

	/**
	 * Unhides the given list of caret elements.
	 *
	 * @param {Array.<Element>} carets
	 */
	function unhideCarets(carets) {
		carets.forEach(function (caret) {
			Dom.setStyle(caret, 'display', 'block');
		});
	}

	/**
	 * Renders the given element at the specified boundary to represent the
	 * caret position.
	 *
	 * @param {Element}  caret
	 * @param {Boundary} boundary
	 * @memberOf selections
	 */
	function show(caret, boundary) {
		var box = Carets.box(boundary);
		Maps.extend(caret.style, {
			'top'     : box.top + 'px',
			'left'    : box.left + 'px',
			'height'  : box.height + 'px',
			'width'   : '2px',
			'display' : 'block'
		});
	}

	/**
	 * Determines how to style a caret element based on the given overrides.
	 *
	 * @private
	 * @param  {Object} overrides
	 * @return {Object} A map of style properties and their values
	 */
	function stylesFromOverrides(overrides) {
		var style = {};
		style['padding'] = overrides['bold'] ? '1.5px' : '0px';
		style[Browsers.VENDOR_PREFIX + 'transform']
				= overrides['italic'] ? 'rotate(16deg)' : '';
		style['background'] = overrides['color'] || 'black';
		return style;
	}

	/**
	 * Given the boundaries checks whether the end boundary preceeds the start
	 * boundary in document order.
	 *
	 * @private
	 * @param  {Boundary} start
	 * @param  {Boundary} end
	 * @return {boolean}
	 */
	function isReversed(start, end) {
		var sc = Boundaries.container(start);
		var ec = Boundaries.container(end);
		var so = Boundaries.offset(start);
		var eo = Boundaries.offset(end);
		return (sc === ec && so > eo) || Dom.followedBy(ec, sc);
	}

	/**
	 * Creates a range that is `stride` pixels above the given offset bounds.
	 *
	 * @private
	 * @param  {Object.<string, number>} box
	 * @param  {number}                  stride
	 * @param  {Document}                doc
	 * @return {Range}
	 */
	function up(box, stride, doc) {
		var boundary = Boundaries.fromPosition(
			box.left,
			box.top - stride,
			doc
		);
		return boundary && Boundaries.range(boundary, boundary);
	}

	/**
	 * Creates a range that is `stride` pixels below the given offset bounds.
	 *
	 * @private
	 * @param  {Object.<string, number>} box
	 * @param  {number}                  stride
	 * @return {Range}
	 */
	function down(box, stride, doc) {
		var boundary = Boundaries.fromPosition(
			box.left,
			box.top + box.height + stride,
			doc
		);
		return boundary && Boundaries.range(boundary, boundary);
	}

	/**
	 * Given two ranges (represented in boundary tuples), creates a range that
	 * is between the two.
	 *
	 * @private
	 * @param  {Array.<Boundary>} a
	 * @param  {Array.<Boundary>} b
	 * @param  {string} focus Either "start" or "end"
	 * @return {Object}
	 */
	function mergeRanges(a, b, focus) {
		var start, end;
		if ('start' === focus) {
			start = a[0];
			end = b[1];
		} else {
			start = b[0];
			end = a[1];
		}
		if (isReversed(start, end)) {
			return {
				boundaries : [end, start],
				focus      : ('start' === focus) ? 'end' : 'start'
			};
		}
		return {
			boundaries : [start, end],
			focus      : focus
		};
	}

	/**
	 * Jumps the front or end position of the given editable.
	 *
	 * @private
	 * @param  {string}           direction "up" or "down"
	 * @param  {Event}            event
	 * @param  {Array.<Boundary>} boundaries
	 * @param  {string}           focus
	 * @return {Object}
	 */
	function jump(direction, event, boundaries, focus) {
		var boundary;
		if ('up' === direction) {
			boundary = Boundaries.create(event.editable.elem, 0);
			boundary = Html.expandForward(boundary);
		} else {
			boundary = Boundaries.fromEndOfNode(event.editable.elem);
			boundary = Html.expandBackward(boundary);
		}
		var next = [boundary, boundary];
		if (!Events.hasKeyModifier(event, 'shift')) {
			return {
				boundaries : next,
				focus      : focus
			};
		}
		return mergeRanges(next, boundaries, focus);
	}

	/**
	 * Finds the closest linebreaking element from the given node.
	 *
	 * @private
	 * @param  {!Node} node
	 * @return {?Element};
	 */
	function closestLine(node) {
		return Dom.upWhile(node, Fn.complement(Html.hasLinebreakingStyle));
	}

	/**
	 * Computes the visual boundary positoin above/below the given.
	 *
	 * @private
	 * @param  {!Boundary} boundary
	 * @param  {!function} step
	 * @return {?Boundary}
	 */
	function climbStep(boundary, step) {
		var range = Boundaries.range(boundary, boundary);
		var doc = Boundaries.document(boundary);
		var box = Carets.box(boundary);
		var half = box.height / 4;
		var stride = 0;
		var next;
		do {
			stride += half;
			next = step(box, stride, doc);
		} while (next && Ranges.equals(next, range));
		return next && Boundaries.fromRangeStart(next);
	}

	/**
	 * Computes a lists of box dimensions for the a given range boundaries.
	 *
	 * @private
	 * @param  {!Boundary} start
	 * @param  {!Boundary} end
	 * @return {Array.<Object>}
	 */
	function selectionBoxes(start, end) {
		var doc = Boundaries.document(start);
		var endBox = Carets.box(end);
		var endTop = endBox.top;
		var endLeft = endBox.left;
		var box, top, left, right, width, line, atEnd;
		var leftBoundary, rightBoundary;
		var boxes = [];
		var boundary = start;
		while (boundary) {
			box = Carets.box(boundary);
			top = box.top;
			line = closestLine(Boundaries.container(boundary));
			if (!line) {
				break;
			}
			atEnd = endTop < top + box.height;
			if (atEnd) {
				if (0 === boxes.length) {
					left = box.left;
				} else {
					left = Dom.offset(line).left;
				}
				width = endLeft - left;
			} else {
				if (0 === boxes.length) {
					left = box.left;
					width = elementWidth(line) - (left - Dom.offset(line).left);
				} else {
					left = Dom.offset(line).left;
					width = elementWidth(line);
				}
			}
			leftBoundary = Boundaries.fromPosition(left, top, doc);
			rightBoundary = Boundaries.fromPosition(left + width, top, doc);
			if (!leftBoundary || !rightBoundary) {
				break;
			}
			left = Carets.box(leftBoundary).left;
			right = Carets.box(rightBoundary).left;
			boxes.push({
				top    : top,
				left   : left,
				width  : right - left,
				height : box.height
			});
			if (atEnd) {
				break;
			}
			boundary = moveDown(boundary);
		}
		return boxes;
	}

	/**
	 * Renders divs to represent the given range
	 *
	 * @private
	 * @param  {!Boundary} start
	 * @param  {!Boundary} end
	 * @return {Array.<Element>}
	 */
	function highlight(start, end) {
		var doc = Boundaries.document(start);
		Dom.query('.aloha-selection-box', doc).forEach(Dom.remove);
		return selectionBoxes(start, end).map(function (box) {
			return drawBox(box, doc);
		});
	}

	/**
	 * Calculates the width of the given element as best as possible.
	 *
	 * We need to do this because clientWidth/clientHeight sometimes return 0
	 * erroneously.
	 *
	 * The difference between clientWidth and offsetWidth is that offsetWidth
	 * includes scrollbar size, but since we will almost certainly not have
	 * scrollbar within editing elements, this should not be a problem:
	 * http://stackoverflow.com/questions/4106538/difference-between-offsetheight-and-clientheight
	 *
	 * @private
	 * @param  {!Element} elem
	 * @return {number}
	 */
	function elementWidth(elem) {
		return elem.clientWidth || elem.offsetWidth;
	}

	/**
	 * Calculates the height of the given element as best as possible.
	 *
	 * We need to do this because clientWidth/clientHeight sometimes return 0
	 * erroneously.
	 *
	 * @private
	 * @see elementWidth
	 * @param  {!Element} elem
	 * @return {number}
	 */
	function elementHeight(elem) {
		return elem.clientHeight || elem.offsetHeight;
	}

	/**
	 * Checks whether the given node is a visible linebreaking non-void element.
	 *
	 * @private
	 * @param  {!Node} node
	 * @return {boolean}
	 */
	function isVisibleBreakingContainer(node) {
		return !Html.isVoidType(node)
		    && Html.isRendered(node)
		    && Html.hasLinebreakingStyle(node);
	}

	/**
	 * Checks whether the given node is a visible non-void element.
	 *
	 * @private
	 * @param  {!Node} node
	 * @return {boolean}
	 */
	function isVisibleContainer(node) {
		return !Html.isVoidType(node) && Html.isRendered(node);
	}

	/**
	 * Finds the breaking element above/below the given boundary.
	 *
	 * @private
	 * @param  {!Boundary} boundary
	 * @param  {!function} next
	 * @param  {!function} forwards
	 * @return {?Object}
	 */
	function findBreakpoint(boundary, next, forwards) {
		var node = next(boundary);
		var breaker = isVisibleBreakingContainer(node)
		            ? node
		            : forwards(node, isVisibleBreakingContainer);
		if (!breaker) {
			return null;
		}
		var isInsideBreaker = !!Dom.upWhile(node, function (node) {
			return node !== breaker;
		});
		return {
			breaker: breaker,

			// Because if the breaking node is an ancestor of the boundary
			// container, then the breakpoint ought to be calculated from the
			// top of the breaker, otherwise we ought to calculate it from the
			// bottom
			isInsideBreaker: isInsideBreaker
		};
	}

	/**
	 * Finds the visual boundary position above the given.
	 *
	 * Cases:
	 * foo<br>
	 * ba░r
	 *
	 * <p>foo</p>ba░r
	 *
	 * <p>foo</p><p>ba░r</p>
	 *
	 * foo<ul><li>ba░r</li></ul>
	 *
	 * @private
	 * @param  {!Boundary} boundary
	 * @return {Boundary}
	 */
	function moveUp(boundary) {
		var next;
		var box = Carets.box(boundary);
		var above = paragraphAbove(boundary);
		if (above) {
			var aboveBoundary = Html.expandBackward(
				Boundaries.fromEndOfNode(above)
			);
			above = Boundaries.prevNode(aboveBoundary);
			var aboveBox = Carets.box(aboveBoundary);
			var top;
			if (Dom.isTextNode(above)) {
				top = aboveBox.top + (aboveBox.height / 2);
			} else {
				top = Dom.absoluteTop(above)
				    + elementHeight(above)
				    - (aboveBox.height / 2);
			}
			next = Boundaries.fromPosition(
				box.left,
				top,
				above.ownerDocument
			);
		}
		next = next || climbStep(boundary, up);
		return (!next || box.top === Carets.box(next).top) ? boundary : next;
	}

	/**
	 * Finds the visual boundary position below the given.
	 *
	 * @private
	 * @param  {!Boundary} boundary
	 * @return {Boundary}
	 */
	function moveDown(boundary) {
		var next;
		var box = Carets.box(boundary);
		var below = paragraphBelow(boundary);
		if (below) {
			var belowBoundary = Html.expandForward(
				Boundaries.fromStartOfNode(below)
			);
			below = Boundaries.nodeAfter(belowBoundary);
			var belowBox = Carets.box(belowBoundary);
			var top = Dom.isTextNode(below)
					? belowBox.top
					: Dom.absoluteTop(below);
			top += belowBox.height / 2;
			next = Boundaries.fromPosition(
				box.left,
				top,
				below.ownerDocument
			);
		}
		next = next || climbStep(boundary, down);
		return (!next || box.top === Carets.box(next).top) ? boundary : next;
	}

	/**
	 * Locates the next paragraphing element that is visually above the given
	 * aboundary.
	 *
	 * If a soft break is causing there to be a line of text inside before a
	 * visual paragraph-dictated linebreak, then `null` is returned.
	 *
	 * @private
	 * @param  {!Boundary} boundary
	 * @return {?Boundary}
	 */
	function paragraphAbove(boundary) {
		var breakpoint = findBreakpoint(
			boundary,
			Boundaries.prevNode,
			Dom.backwardPreorderBacktraceUntil
		);
		if (!breakpoint) {
			return null;
		}
		var box = Carets.box(boundary);
		var breaker = breakpoint.breaker;
		var offset = box.top - box.height;
		var breakOffset = Dom.absoluteTop(breaker);
		if (!breakpoint.isInsideBreaker) {
			breakOffset += elementHeight(breaker);
		}
		if (offset >= breakOffset) {
			return null;
		}
		var above;
		if (breakpoint.isInsideBreaker) {
			above = Dom.nextNonAncestor(
				breaker,
				true,
				isVisibleContainer,
				Dom.isEditingHost
			);
		} else {
			above = breaker;
		}
		if (!above) {
			return null;
		}
		if (Html.isGroupContainer(above)) {
			return Dom.backwardPreorderBacktraceUntil(
				above.nextSibling,
				Html.isGroupedElement
			);
		}
		return above;
	}

	/**
	 * Locates the next paragraphing element that is visually below the given
	 * aboundary.
	 *
	 * If a soft break is causing there to be a line of text inside before a
	 * visual paragraph-dictated linebreak, then `null` is returned.
	 *
	 * @private
	 * @param  {!Boundary} boundary
	 * @return {?Boundary}
	 */
	function paragraphBelow(boundary) {
		var breakpoint = findBreakpoint(
			boundary,
			Boundaries.nextNode,
			Dom.forwardPreorderBacktraceUntil
		);
		if (!breakpoint) {
			return null;
		}
		var breaker = breakpoint.breaker;
		var box = Carets.box(boundary);
		var offset = box.top + box.height + box.height;
		var breakOffset = Dom.absoluteTop(breaker);
		if (breakpoint.isInsideBreaker) {
			breakOffset += elementHeight(breaker);
		}
		if (offset <= breakOffset) {
			return null;
		}
		var below;
		if (breakpoint.isInsideBreaker) {
			below = Dom.nextNonAncestor(
				breaker,
				false,
				isVisibleContainer,
				Dom.isEditingHost
			);
		} else {
			below = breaker;
		}
		if (!below) {
			return null;
		}
		if (Html.isGroupContainer(below)) {
			return Dom.forwardPreorderBacktraceUntil(
				below.previousSibling,
				Html.isGroupedElement
			);
		}
		return below;
	}

	/**
	 * Ensures that there is enough space above/below the given boundary from
	 * which to calculate the next boundary position for moveUp/moveDown.
	 *
	 * @private
	 * @param  {!Boundary} boundary
	 * @param  {string}    direction
	 */
	function ensureVerticalMovingRoom(boundary, direction) {
		var doc = Boundaries.document(boundary);
		var win = Dom.documentWindow(doc);
		var top = Dom.scrollTop(doc);
		var height = win.innerHeight;
		var box = Carets.box(boundary);

		var above = paragraphAbove(boundary);
		var below = paragraphBelow(boundary);

		var belowCaret = (below && !Dom.isTextNode(below))
		               ? Dom.absoluteTop(below) + box.height
		               : box.top + box.height + box.height;

		var aboveCaret = (above && !Dom.isTextNode(above))
		               ? Dom.absoluteTop(above) + elementHeight(above) - box.height
		               : box.top - box.height;

		var buffer = box.height;
		var correctTop = 0;
		if (aboveCaret <= top) {
			if ('up' === direction) {
				correctTop = aboveCaret - buffer;
			}
		} else if (belowCaret >= top + height) {
			if ('down' === direction) {
				correctTop = belowCaret - height + buffer + buffer;
			}
		}
		if (correctTop) {
			win.scrollTo(Dom.scrollLeft(doc), correctTop);
		}
	}

	/**
	 * Determines the closest visual caret position above or below the given
	 * range.
	 *
	 * @private
	 * @param  {string}           direction "up" or "down"
	 * @param  {Event}            event
	 * @param  {Array.<Boundary>} boundary
	 * @param  {string}           focus
	 * @return {Object}
	 */
	function climb(direction, event, boundaries, focus) {
		var boundary = boundaries['start' === focus ? 0 : 1];
		ensureVerticalMovingRoom(boundary, direction);
		var next = 'up' === direction ? moveUp(boundary) : moveDown(boundary);
		if (!next) {
			return {
				boundaries : boundaries,
				focus      : focus
			};
		}
		if (Events.hasKeyModifier(event, 'shift')) {
			return mergeRanges([next, next], boundaries, focus);
		}
		return {
			boundaries : [next, next],
			focus      : focus
		};
	}

	/**
	 * Determines the next visual caret position before or after the given
	 * boundaries.
	 *
	 * @private
	 * @param  {string}           direction "left" or "right"
	 * @param  {Event}            event
	 * @param  {Array.<Boundary>} boundaries
	 * @param  {string}           focus
	 * @return {Object}
	 */
	function step(direction, event, boundaries, focus) {
		var shift = Events.hasKeyModifier(event, 'shift');
		var start = boundaries[0];
		var end = boundaries[1];
		var collapsed = Boundaries.equals(start, end);
		if (collapsed || !shift) {
			focus = ('left' === direction) ? 'start' : 'end';
		}
		var boundary = ('start' === focus)
		             ? start
		             : Traversing.envelopeInvisibleCharacters(end);
		if (collapsed || shift) {
			var stride = (Events.hasKeyModifier(event, 'ctrl')
			          || Events.hasKeyModifier(event, 'alt'))
			           ? 'word'
			           : 'visual';
			var next = ('left' === direction)
			         ? Traversing.prev(boundary, stride)
			         : Traversing.next(boundary, stride);
			if (Dom.isEditingHost(Boundaries.container(next))) {
				if (Boundaries.isAtStart(boundary)) {
					next = Html.expandForward(boundary);
				} else if (Boundaries.isAtEnd(boundary)) {
					next = Html.expandBackward(boundary);
				}
			}
			if (next) {
				boundary = next;
			}
		}
		if (shift) {
			return {
				boundaries : ('start' === focus) ? [boundary, end] : [start, boundary],
				focus      : focus
			};
		}
		return {
			boundaries : [boundary, boundary],
			focus      : focus
		};
	}

	/**
	 * Determines the dimensions of the vertical line in the editable at the
	 * given boundary position.
	 *
	 * @private
	 * @param  {Boundary} boundary
	 * @param  {Element}  editable
	 * @return {Object.<string, number>}
	 */
	function lineBox(boundary, editable) {
		var box = Carets.box(boundary);
		var node = Boundaries.container(boundary);
		if (Dom.isTextNode(node)) {
			node = node.parentNode;
		}
		var fontSize = parseInt(Dom.getComputedStyle(node, 'font-size'));
		var top = box ? box.top : Dom.absoluteTop(node);
		top += (fontSize ? fontSize / 2 : 0);
		var left = Dom.offset(editable).left;
		return {
			top   : top,
			left  : left,
			right : left + elementWidth(editable)
		};
	}

	function end(event, boundaries, focus) {
		var box = lineBox(boundaries[1], event.editable.elem);
		var boundary = Boundaries.fromPosition(
			// Because -1 ensures that the position is within the viewport
			box.right - 1,
			box.top,
			Boundaries.document(boundaries[0])
		);
		if (boundary) {
			var start = boundaries['start' === focus ? 1 : 0];
			boundaries = Events.hasKeyModifier(event, 'shift')
			           ? [start, boundary]
			           : [boundary, boundary];
			focus = 'end';
		}
		return {
			boundaries : boundaries,
			focus      : focus
		};
	}

	function home(event, boundaries, focus) {
		var box = lineBox(boundaries[0], event.editable.elem);
		var boundary = Boundaries.fromPosition(
			box.left,
			box.top,
			Boundaries.document(boundaries[0])
		);
		if (boundary) {
			var end = boundaries['end' === focus ? 0 : 1];
			boundaries = Events.hasKeyModifier(event, 'shift')
			           ? [boundary, end]
			           : [boundary, boundary];
			focus = 'start';
		}
		return {
			boundaries : boundaries,
			focus      : focus
		};
	}

	/**
	 * Caret movement operations mapped against cursor key keycodes.
	 *
	 * @private
	 * @type {Object.<string, function(Event, Array.<Boundary>, string):Object>}
	 */
	var movements = {};

	movements['left'] =
	movements['*+left'] = Fn.partial(step, 'left');

	movements['right'] =
	movements['*+right'] = Fn.partial(step, 'right');

	movements['up'] =
	movements['*+up'] = Fn.partial(climb, 'up');

	movements['down'] =
	movements['*+down'] = Fn.partial(climb, 'down');

	movements['pageUp'] =
	movements['meta+up'] = Fn.partial(jump, 'up');

	movements['pageDown'] =
	movements['meta+down'] = Fn.partial(jump, 'down');

	movements['home'] =
	movements['meta+left'] =
	movements['meta+shift+left'] = home;

	movements['end'] =
	movements['meta+right'] =
	movements['meta+shift+right'] = end;

	/**
	 * Processes a keydown event.
	 *
	 * @private
	 * @param  {AlohaEvent}       event
	 * @param  {Array.<Boundary>} boundaries
	 * @param  {string}           focus
	 * @return {Object}
	 */
	function keydown(event, boundaries, focus) {
		var handler = Keys.shortcutHandler(event.meta, event.keycode, movements);
		if (handler) {
			Events.preventDefault(event.nativeEvent);
			return handler(event, boundaries, focus);
		}
		return {
			boundaries : boundaries,
			focus      : focus
		};
	}

	/**
	 * Processes a double-click event.
	 *
	 * @private
	 * @param  {Event}            event
	 * @param  {Array.<Boundary>} boundaries
	 * @return {Object}
	 */
	function dblclick(event, boundaries) {
		return {
			boundaries : Traversing.expand(boundaries[0], boundaries[1], 'word'),
			focus      : 'end'
		};
	}

	/**
	 * Processes a triple-click event.
	 *
	 * @private
	 * @param  {Event}            event
	 * @param  {Array.<Boundary>} boundaries
	 * @return {Object}
	 */
	function tplclick(event, boundaries) {
		return {
			boundaries : Traversing.expand(boundaries[0], boundaries[1], 'block'),
			focus      : 'end'
		};
	}

	/**
	 * Processes a mouseup event.
	 *
	 * @private
	 * @param  {Event}            event
	 * @param  {Array.<Boundary>} boundaries
	 * @param  {string}           focus
	 * @return {Object}
	 */
	function mouseup(event, boundaries, focus, previous, expanding) {
		return mergeRanges(boundaries, previous, focus);
	}

	/**
	 * Processes a mousedown event.
	 *
	 * @private
	 * @param  {Event}            event
	 * @param  {Array.<Boundary>} boundaries
	 * @param  {string}           focus
	 * @param  {Array.<Boundary>} previous
	 * @param  {boolean}          expanding
	 * @return {Object}
	 */
	function mousedown(event, boundaries, focus, previous, expanding) {
		if (!expanding) {
			return {
				boundaries : boundaries,
				focus      : focus
			};
		}
		var start = boundaries[0];
		var end = previous['start' === focus ? 1 : 0];
		if (isReversed(start, end)) {
			return {
				boundaries : [end, start],
				focus      : 'end'
			};
		}
		return {
			boundaries : [start, end],
			focus      : 'start'
		};
	}

	function dragndrop(event, boundaries) {
		return {
			boundaries : boundaries,
			focus      : 'end'
		};
	}

	function resize(event, boundaries, focus) {
		return {
			boundaries : boundaries,
			focus      : focus
		};
	}

	function paste(event, boundaries) {
		return {
			boundaries : boundaries,
			focus      : 'end'
		};
	}

	/**
	 * Event handlers.
	 *
	 * @private
	 * @type {Object.<string, function>}
	 */
	var handlers = {
		'keydown'        : keydown,
		'aloha.dblclick' : dblclick,
		'aloha.tplclick' : tplclick,
		'aloha.mouseup'  : mouseup,
		'mouseup'        : mouseup,
		'mousedown'      : mousedown,
		'dragover'       : dragndrop,
		'drop'           : dragndrop,
		'resize'         : resize,
		'paste'          : paste
	};

	/**
	 * Initialize blinking using the given element.
	 *
	 * @private
	 * @param  {Element} caret
	 * @return {Object}
	 */
	function blinking(caret) {
		var timers = [];
		var isBlinking = true;
		function fade(start, end, duration) {
			Animation.animate(
				start,
				end,
				Animation.easeLinear,
				duration,
				function (value, percent, state) {
					if (!isBlinking) {
						return true;
					}
					Dom.setStyle(caret, 'opacity', value);
					if (percent < 1) {
						return;
					}
					if (0 === value) {
						timers.push(setTimeout(function () {
							fade(0, 1, 100);
						}, 300));
					} else if (1 === value){
						timers.push(setTimeout(function () {
							fade(1, 0, 100);
						}, 500));
					}
				}
			);
		}
		function stop() {
			isBlinking = false;
			Dom.setStyle(caret, 'opacity', 1);
			timers.forEach(clearTimeout);
			timers = [];
		}
		function blink() {
			stop();
			isBlinking = true;
			timers.push(setTimeout(function () {
				fade(1, 0, 100);
			}, 500));
		}
		function start() {
			stop();
			timers.push(setTimeout(blink, 50));
		}
		return {
			start : start,
			stop  : stop
		};
	}

	/**
	 * Creates a new selection context.
	 *
	 * Will create a DOM element at the end of the document body to be used to
	 * represent the caret position.
	 *
	 * @param  {Document} doc
	 * @return {Object}
	 * @memberOf selections
	 */
	function Context(doc) {
		var hidden = doc.createElement('textarea');
		Maps.extend(hidden.style, {
			'overflow'  : 'hidden',
			'width'     : '1px',
			'height'    : '1px',
			'outline'   : '0',
			'opacity'   : '0.01'
		});
		var caret = doc.createElement('div');
		Maps.extend(caret.style, {
			'overflow' : 'hidden',
			'cursor'   : 'text',
			'color'    : '#000',
			'zIndex'   : '9999',
			'display'  : 'none',
			'position' : 'absolute'
		});
		Dom.addClass(caret, 'aloha-caret', 'aloha-ephemera');
		Dom.append(hidden, caret);
		Dom.append(caret, doc.body);
		return {
			blinking       : blinking(caret),
			focus          : 'end',
			boundaries     : null,
			event          : null,
			dragging       : null,
			multiclick     : null,
			clickTimer     : 0,
			lastMouseEvent : '',
			caret          : caret,
			formatting     : [],
			overrides      : []
		};
	}

	/**
	 * Ensures that the given boundary is visible inside of the viewport by
	 * scolling the view port if necessary.
	 *
	 * @param {!Boundary} boundary
	 * @memberOf selections
	 */
	function focus(boundary) {
		var box = Carets.box(boundary);
		var doc = Boundaries.document(boundary);
		var win = Dom.documentWindow(doc);
		var top = Dom.scrollTop(doc);
		var left = Dom.scrollLeft(doc);
		var height = win.innerHeight;
		var width = win.innerWidth;
		var buffer = box.height;
		var caretTop = box.top;
		var caretLeft = box.left;
		var correctTop = 0;
		var correctLeft = 0;
		if (caretTop < top) {
			// Because we want to caret to be near the top
			correctTop = caretTop - buffer;
		} else if (caretTop > top + height) {
			// Because we want to caret to be near the bottom
			correctTop = caretTop - height + buffer + buffer;
		}
		if (caretLeft < left) {
			// Because we want to caret to be near the left
			correctLeft = caretLeft - buffer;
		} else if (caretLeft > left + width) {
			// Because we want to caret to be near the right
			correctLeft = caretLeft - width + buffer + buffer;
		}
		if (correctTop || correctLeft) {
			win.scrollTo(correctLeft || left, correctTop || top);
		}
	}

	/**
	 * Computes a table of the given override and those collected at the given
	 * node.
	 *
	 * An object with overrides mapped against their names.
	 *
	 * @private
	 * @param  {!Node}      node
	 * @param  {!Selection} selection
	 * @return {Object}
	 */
	function mapOverrides(node, selection) {
		var overrides = Overrides.joinToSet(
			selection.formatting,
			Overrides.harvest(node),
			selection.overrides
		);
		var map = Maps.merge(Maps.mapTuples(overrides));
		if (!map['color']) {
			map['color'] = Dom.getComputedStyle(
				Dom.isTextNode(node) ? node.parentNode : node,
				'color'
			);
		}
		return map;
	}

	function drawBox(box, doc) {
		var elem = doc.createElement('div');
		Maps.extend(elem.style, {
			'top'        : box.top + 'px',
			'left'       : box.left + 'px',
			'height'     : box.height + 'px',
			'width'      : box.width + 'px',
			'position'   : 'absolute',
			'background' : 'red',
			'opacity'    : 0.4
		});
		Dom.addClass(elem, 'aloha-selection-box', 'aloha-ephemera');
		Dom.append(elem, doc.body);
		return elem;
	}

	/**
	 * Updates selection
	 *
	 * @param  {AlohaEvent} event
	 * @return {AlohaEvent}
	 * @memberOf selections
	 */
	function middleware(event) {
		if (!handlers[event.type]) {
			return event;
		}
		var selection = event.selection;
		var change = handlers[event.type](
			event,
			selection.boundaries,
			selection.focus,
			selection.previousBoundaries,
			Events.hasKeyModifier(event, 'shift')
		);
		selection.focus = change.focus;
		selection.boundaries = change.boundaries;
		/*
		// This check should not be necessary
		if (selection.boundaries[0]) {
			highlight(selection.boundaries[0], selection.boundaries[1]).forEach(function (box) {
				Dom.setStyle(box, 'background', '#fce05e'); // or blue #a6c7f7
			});
		}
		*/
		return event;
	}

	/**
	 * Whether the given event will cause the position of the selection to move.
	 *
	 * @private
	 * @param  {Event} event
	 * @return {boolean}
	 */
	function isCaretMovingEvent(event) {
		if ('keypress' === event.type) {
			return true;
		}
		if ('paste' === event.type) {
			return true;
		}
		if (Keys.ARROWS[event.keycode]) {
			return true;
		}
		if (Keys.CODES['pageDown'] === event.keycode || Keys.CODES['pageUp'] === event.keycode) {
			return true;
		}
		if (Keys.CODES['undo'] === event.keycode) {
			if ('meta' === event.meta || 'ctrl' === event.meta || 'shift' === event.meta) {
				return true;
			}
		}
		if (Keys.CODES['enter'] === event.keycode) {
			return true;
		}
		return false;
	}

	var MOBILE_EVENT_TYPE = /^mobile\./;

	/**
	 * Causes the selection for the given event to be set to the browser and the
	 * caret position to be visualized.
	 *
	 * @param  {!Event} event
	 * @return {?Selection}
	 */
	function update(event) {
		var selection = event.selection;
		if (MOBILE_EVENT_TYPE.test(event.type)) {
			return;
		}
		if (event.preventSelection || (selection.dragging && 'dragover' !== event.type)) {
			return;
		}
		if ('leave' === event.type) {
			Dom.setStyle(selection.caret, 'display', 'none');
			return;
		}
		var type = event.type;
		if ('mouseup' === type || 'click' === type || 'dblclick' === type) {
			Dom.setStyle(selection.caret, 'display', 'block');
			return;
		}
		selection = select(
			selection,
			selection.boundaries[0],
			selection.boundaries[1],
			selection.focus
		);
		var boundary = selection.focus === 'start' ? selection.boundaries[0] : selection.boundaries[1];
		// Because we don't want the screen to jump when the editor hits "shift"
		if (isCaretMovingEvent(event)) {
			focus(boundary);
		}
		return selection;
	}

	/**
	 * Selects the given boundaries and visualizes the caret position.
	 *
	 * Returns the updated Selection object, that can be reassigned to
	 * aloha.editor.selection
	 *
	 * @param  {Selection} selection
	 * @param  {Boundary}  start
	 * @param  {Boundary}  end
	 * @param  {string=}   focus optional. "start" or "end". Defaults to "end"
	 * @return {Selection}
	 * @memberOf selections
	 */
	function select(selection, start, end, focus) {
		var boundary = 'start' === focus ? start : end;
		var node = Boundaries.container(boundary);
		if (!Dom.isEditableNode(node)) {
			Dom.setStyle(selection.caret, 'display', 'none');
			return selection;
		}
		show(selection.caret, boundary);
		Maps.extend(
			selection.caret.style,
			stylesFromOverrides(mapOverrides(node, selection))
		);
		Boundaries.select(start, end);
		selection.blinking.start();
		return Maps.merge(selection, { 
			boundaries : [start, end],
			focus      : focus
		});
	}

	/**
	 * Returns true if obj is a selection as returned by a selection context
	 * object.
	 *
	 * @param  {*} obj
	 * @return {boolean}
	 * @memberOf selections
	 */
	function is(obj) {
		if (obj &&
			obj.hasOwnPropery &&
			obj.hasOwnProperty('focus') &&
			obj.hasOwnProperty('caret') &&
			obj.hasOwnProperty('boundaries')) {
			return true;
		}
		return false;
	}

	var MOUSE_EVENT = {
		'mousemove'      : true,
		'mousedown'      : true,
		'mouseup'        : true,
		'click'          : true,
		'dblclick'       : true,
		'aloha.dblclick' : true,
		'aloha.tplclick' : true
	};

	var CLICKING_EVENT = {
		'mousedown'      : true,
		'mouseup'        : true,
		'click'          : true,
		'dblclick'       : true,
		'aloha.dblclick' : true,
		'aloha.tplclick' : true
	};

	var MUTLICLICK_EVENT = {
		'dblclick'       : true,
		'aloha.dblclick' : true,
		'aloha.tplclick' : true
	};

	/**
	 * Returns the appropriate event type in the click cycle.
	 *
	 * <pre>
	 * Event cycle:
	 *     mousedown
	 *     mouseup
	 *     click
	 *     mousedown -> aloha.dblclick
	 *     mouseup
	 *     click
	 *     dblclick
	 *     mousedown -> aloha.tplclick
	 *     mouseup
	 *     click
	 * </pre>
	 *
	 * @private
	 * @param  {!Event}     event
	 * @param  {!Selection} selection
	 * @return {?string}
	 */
	function processClicking(event, selection) {
		if ('mousedown'      !== event.type &&
		    'dbclick'        !== event.type &&
		    'aloha.dblclick' !== event.type) {
			return null;
		}
		var time = new Date();
		var elapsed = time - selection.clickTimer;
		var multiclick = selection.multiclick;
		selection.multiclick = null;
		selection.clickTimer = time;
		if (elapsed > 500) {
			return null;
		}
		if (!selection.event) {
			return null;
		}
		if (selection.event.clientX !== event.clientX) {
			return null;
		}
		if (selection.event.clientY !== event.clientY) {
			return null;
		}
		return MUTLICLICK_EVENT[multiclick] ? 'aloha.tplclick' : 'aloha.dblclick';
	}

	/**
	 * Gets the dragging state.
	 *
	 * @private
	 * @param  {!Event}     event
	 * @param  {!Selection} selection
	 * @return {?string}
	 */
	function getDragging(event, selection) {
		if (selection.dragging) {
			return selection.dragging;
		}
		if ('mousemove' !== event.type) {
			return null;
		}
		var last = selection.lastMouseEvent;
		if ('mousedown'       === last ||
		    'aloha.dblclick'  === last ||
		    'aloha.tplclick'  === last) {
			return last;
		}
		return null;
	}

	function isMobileField(node, selection) {
		if (node === selection.caret) {
			return true;
		}
		var parent = node.parentNode;
		return (parent.parentNode === selection.caret)
		    && ('true' === Dom.getAttr(parent, 'contentEditable'));
	}

	/**
	 * Creates an event object that will contain the following properties:
	 *
	 * <pre>
	 *     type
	 *     nativeEvent
	 *     editable
	 *     selection
	 *     dnd
	 *     preventSelection
	 * </pre>
	 *
	 * @param  {!Editor} editor
	 * @param  {!Event}  event
	 * @return {?AlohaEvent}
	 * @memberOf selections
	 */
	function selectionEvent(editor, event) {
		var type = event.type;
		var doc = event.target.document || event.target.ownerDocument;
		var selection = editor.selection || Context(doc);
		var isClicking = CLICKING_EVENT[type] || false;
		var dragging = getDragging(event, selection);
		var isDragStart = dragging && dragging !== selection.dragging;
		var caretDisplay = Dom.getStyle(selection.caret, 'display');
		if (isClicking || isDragStart) {
			// Because otherwise if the mouse position is over the caret element
			// Boundaries.fromPosition() will compute the boundaries to be
			// inside the absolutely positioned caret element, which is not what
			// we want
			Dom.setStyle(selection.caret, 'display', 'none');
		}
		if (isDragStart) {
			selection.dragging = dragging;
		}
		if ('mousemove' === type) {
			return null;
		}
		if ('mouseup' === type && selection.dragging) {
			type = 'aloha.mouseup';
			selection.dragging = null;
		}
		if (isClicking) {
			type = processClicking(event, selection) || type;
		}
		if (MUTLICLICK_EVENT[type]) {
			selection.multiclick = type;
			Events.preventDefault(event);
		}
		if (MOUSE_EVENT[type]) {
			selection.lastMouseEvent = type;
		}
		var boundaries;
		if (isClicking) {
			var boundary = Boundaries.fromPosition(
				event.clientX + Dom.scrollLeft(doc),
				event.clientY + Dom.scrollTop(doc),
				doc
			);
			boundaries = boundary && [boundary, boundary];
		} else {
			boundaries = Boundaries.get(doc);
		}
		Dom.setStyle(selection.caret, 'display', caretDisplay);
		var editable;
		if (!boundaries) {
			if ('click' !== type && selection.boundaries) {
				editable = Editables.fromBoundary(editor, selection.boundaries[0]);
				return {
					preventSelection : false,
					type             : 'leave',
					nativeEvent      : event,
					editable         : editable,
					selection        : selection,
					dnd              : editor.dnd
				};
			}
			return null;
		}
		var cac = Boundaries.commonContainer(boundaries[0], boundaries[1]);
		var start = Boundaries.container(boundaries[0]);
		var end = Boundaries.container(boundaries[1]);
		var isPartial = !Dom.isEditableNode(cac)
		             && (Dom.isEditableNode(start) || Dom.isEditableNode(end));
		if ('keydown' === type && isPartial) {
			Events.preventDefault(event);
			return null;
		}
		editable = Editables.fromBoundary(editor, boundaries[0]);
		if (!editable) {
			if (isMobileField(Boundaries.container(boundaries[0]), selection)) {
				type = 'mobile.' + type;
			} else {
				return null;
			}
		}
		selection.overrides = editor.selection ? editor.selection.overrides : [];
		selection.previousBoundaries = selection.boundaries || boundaries;
		selection.boundaries = boundaries;
		selection.event = event;
		return {
			// TODO: Reconsider this
			// Because sometimes an interaction going through the editor pipe
			// should not result in an updated selection. eg: When inserting a
			// link you want to focus on an input field in the ui.
			preventSelection : false,
			type             : type,
			nativeEvent      : event,
			editable         : editable,
			selection        : selection,
			dnd              : editor.dnd
		};
	}

	/**
	 * Returns true if the given value is a Selection Event object as created by
	 * aloha.selections.selectionEvent.
	 *
	 * @param  {*} obj
	 * @return {boolean}
	 * @memberOf events
	 */
	function isSelectionEvent(obj) {
		return obj
		    && obj.hasOwnProperty
		    && obj.hasOwnProperty('dnd')
		    && obj.hasOwnProperty('editable')
		    && obj.hasOwnProperty('selection')
		    && obj.hasOwnProperty('nativeEvent');
	}

	return {
		is               : is,
		isSelectionEvent : isSelectionEvent,
		show             : show,
		select           : select,
		focus            : focus,
		update           : update,
		middleware       : middleware,
		Context          : Context,
		hideCarets       : hideCarets,
		unhideCarets     : unhideCarets,
		highlight        : highlight,
		selectionBoxes   : selectionBoxes,
		selectionEvent   : selectionEvent
	};
});
comments powered by Disqus