///
/**
* Module dependencies
*/
import Editor = require('../editor/index');
import classes = require('component-classes');
import query = require('component-query');
import computedStyle = require('computed-style');
import raf = require('raf');
import uid = require('component-uid');
import dataset = require('dataset');
import hacks = require('../hacks/index');
var TRANSFORM = ['transform', 'webkitTransform', 'MozTransform', 'msTransform', 'OTransform'].filter((prop) => document.body.style[prop] != null)[0];
/**
* Editor overlay
*
* @param {Element} el
* @api public
*/
class EditorOverlayManager {
private editor: Editor;
private ref: HTMLElement;
private el: HTMLElement;
private overlays: Object;
private interval: number;
private raf: any;
private timer: any;
constructor(editor: Editor) {
if (!(this instanceof EditorOverlayManager)) return new EditorOverlayManager(editor);
this.editor = editor;
this.ref = editor.el;
this.el = document.createElement('div');
this.el.className = 'editor-overlay';
this.overlays = {};
this.interval = 0;
this.raf = null;
this.timer = null;
// cache the bound callback, to avoid binding it every time
this['callback'] = this.callback.bind(this);
this.el.addEventListener('resize', () => {
this.update(true);
});
this.timeout();
}
/**
* Creates a reference element for an overlay element
*/
public reference(overlay: HTMLElement): HTMLElement {
var el = document.createElement('div');
el.className = 'overlay-reference';
var br = document.createElement('br');
el.appendChild(br);
var id = uid(8);
dataset(el, 'id', id);
this.add(id, overlay);
return el;
}
/**
* Adds an overlay element to the overlay manager
*/
private add(id: string, overlay: HTMLElement): void {
overlay.style.display = 'none';
this.overlays[id] = overlay;
this.el.appendChild(overlay);
}
/**
* Get the overlay for the given reference
*/
public 'for'(el: HTMLElement): HTMLElement {
return this.overlays[dataset(el, 'id')];
}
/**
* Update the position of the overlays based on the
* positions of the references
*/
public update(changed: boolean = false): void {
var i: number;
var id: string;
var ref: HTMLElement;
var overlay: HTMLElement;
var refBox: ClientRect;
// mark all overlays as not present
for (id in this.overlays) {
overlay = this.overlays[id];
overlay['present'] = false;
}
var refs = query.all('[data-id]', this.ref);
// iterate through all overlay references and
// set the overlay widths based on their widths
for (i = 0; i < refs.length; i++) {
ref = refs[i];
id = ref.getAttribute('data-id');
overlay = this.overlays[id];
if (overlay) {
refBox = ref.getBoundingClientRect();
if (overlay.style.display != 'block') {
overlay.style.display = 'block';
changed = true;
}
var width = refBox.width + 'px';
if (overlay.style.width != width) {
overlay.style.width = width;
changed = true;
}
// mark found overlay as present
overlay['present'] = true;
}
}
// iterate through all overlay references
// and set their heights based on the overlay heights
for (i = 0; i < refs.length; i++) {
ref = refs[i];
id = ref.getAttribute('data-id');
overlay = this.overlays[id];
if (overlay) {
var overlayBox = overlay.getBoundingClientRect();
if (hacks.overlayReferenceUsePadding) {
refBox = ref.getBoundingClientRect();
var paddingBottom = Math.max(0, parseInt(ref.style.paddingBottom || '0px', 10) + Math.round(overlayBox.height - refBox.height)) + 'px';
if (ref.style.paddingBottom != paddingBottom) {
this.editor.transactions.runAndSquash(() => {
ref.style.paddingBottom = paddingBottom;
});
changed = true;
}
} else {
var height = overlayBox.height + 'px';
if (ref.style.height != height) {
this.editor.transactions.runAndSquash(() => {
ref.style.height = height;
});
changed = true;
}
}
}
}
// iterate through all overlay references and
// set their heights based on the overlay heights
var externalBox = this.el.getBoundingClientRect();
for (i = 0; i < refs.length; i++) {
ref = refs[i];
id = ref.getAttribute('data-id');
overlay = this.overlays[id];
if (overlay) {
refBox = ref.getBoundingClientRect();
if (overlay.style.position != 'absolute') {
overlay.style.position = 'absolute';
changed = true;
}
var top = (refBox.top - externalBox.top) + 'px';
var left = (refBox.left - externalBox.left) + 'px';
var transform = 'translate(' + left + ', ' + top + ')';
if (overlay.style[TRANSFORM] != transform) {
overlay.style[TRANSFORM] = transform;
changed = true;
}
}
}
// hide all non-present overlays
for (id in this.overlays) {
overlay = this.overlays[id];
if (!overlay['present']) {
if (overlay.style.display != 'none') {
overlay.style.display = 'none';
changed = true;
}
}
}
this.updateSelection();
this.timeout(changed);
}
/**
* Updates the overlay classes to make them react to
* text selections
*/
public updateSelection(): void {
function rangeIntersectsNode(range: Range, node: Node): boolean {
var nodeRange = node.ownerDocument.createRange();
try {
nodeRange.selectNode(node);
} catch (e) {
nodeRange.selectNodeContents(node);
}
var rangeStartRange = range.cloneRange();
rangeStartRange.collapse(true);
var rangeEndRange = range.cloneRange();
rangeEndRange.collapse(false);
var nodeStartRange = nodeRange.cloneRange();
nodeStartRange.collapse(true);
var nodeEndRange = nodeRange.cloneRange();
nodeEndRange.collapse(false);
return rangeStartRange.compareBoundaryPoints(Range.START_TO_START, nodeEndRange) == -1 &&
rangeEndRange.compareBoundaryPoints(Range.START_TO_START, nodeStartRange) == 1;
}
var ref;
var i;
var refs = query.all('[data-id]', this.ref);
var id;
// iterate through all overlay references and
// mark all overlays as not selected or focused
for (i = 0; i < refs.length; i++) {
ref = refs[i];
id = ref.getAttribute('data-id');
overlay = this.overlays[id];
classes(overlay).remove('focused').remove('selected');
}
var selection = window.getSelection();
if (selection.rangeCount == 0) return;
var range = selection.getRangeAt(0);
if (range.collapsed) {
// check for focused overlay references
var el = range.startContainer;
do {
if (el.nodeType == Node.ELEMENT_NODE &&
(id = (el).getAttribute('data-id'))) {
var overlay = this.overlays[id];
if (overlay) {
classes(overlay).add('focused');
}
}
} while (el = el.parentNode);
} else {
// iterate through all overlay references and
// check which of them intersect the range
for (i = 0; i < refs.length; i++) {
ref = refs[i];
if (rangeIntersectsNode(range, ref)) {
id = ref.getAttribute('data-id');
overlay = this.overlays[id];
classes(overlay).add('selected');
}
}
}
}
/**
* Called when the timer or raf fires
*/
private callback() {
this.raf = null;
this.timer = null;
this.update();
}
/**
* Sets a timeout to update the overlay positions in the future
*/
private timeout(changed: boolean = false): void {
if (changed) {
this.interval = 0;
if (this.raf && window.cancelAnimationFrame) {
raf.cancel(this.raf);
}
if (this.timer) {
clearTimeout(this.timer);
}
} else if (this.timer || this.raf) return;
if (this.interval <= 200) {
// intervals lower or equal to 200ms trigger an animation frame
this.raf = raf(this.callback);
} else {
// intervals higher than that will trigger a regular timeout
this.timer = setTimeout(this.callback, this.interval);
}
// increase interval, but max at 1000ms
if (this.interval < 1000) {
this.interval += 25;
}
}
}
/**
* Expose `EditorOverlayManager`
*/
export = EditorOverlayManager;