///
/**
* TypeScript dependencies
*/
import Editor = require('../editor/index');
import events = require('events');
import classes = require('component-classes');
import query = require('component-query');
import domify = require('domify');
import dataset = require('dataset');
import DEBUG = require('debug');
import domSerializeInterfaces = require('dom-serialize/interfaces');
import SerializeEvent = domSerializeInterfaces.SerializeEvent;
import corejs = require('babel-runtime/core-js');
import currentSelection = require('current-selection');
import collapse = require('collapse');
import is = require('../is/index');
var debug = DEBUG('editor:editor-block');
class Block extends events.EventEmitter {
/**
* Private Fields
*/
private _editor: Editor;
private _overlay: HTMLElement;
private _el: HTMLElement;
private _id: string;
private _hold: boolean = false;
private _holdX: number = 0;
private _holdY: number = 0;
private static _boundEditors: WeakSet = new corejs.default.WeakSet();
public constructor(overlay: HTMLElement) {
super();
this._overlay = overlay;
// store a reference to the block in the overlay DOM node
this._overlay['block'] = this;
}
/**
* Returns the current overlay reference for the block, searching
* through the DOM of the editor to do so.
*/
public get el(): HTMLElement {
var qry = '.overlay-reference[data-id=\'' + this._id + '\']';
try {
var els: Array = query.all(qry, this._editor.el);
} catch (e) {
if (!this._editor) {
throw new Error('Block not bound to an editor instance. You must call .bind() before accessing the \'el\' property.');
}
throw e;
}
if (els.length > 0) {
if (els.length > 1) {
debug('duplicate elements for block with id ' + this._id);
}
if (this._el != els[0]) {
debug('updating element reference for block with id ' + this._id);
this._el = els[0];
}
}
return this._el;
}
/**
* Returns the current overlay for the block
*/
public get overlay(): HTMLElement {
return this._overlay;
}
/**
* Returns the bound editor for the block
*/
public get editor(): Editor {
return this._editor;
}
/**
* Binds the block to the editor instance
*/
public bind(editor: Editor) {
if (this._editor) {
if (this._editor == editor) return; // noop
throw new Error('Already bound to another editor instance');
}
// also bind the Block class to the editor
Block._bind(editor);
this._editor = editor;
this._el = editor.overlay.reference(this._overlay);
this._id = dataset(this._el, 'id');
this._overlay.addEventListener('dragenter', this.onDragEnter.bind(this), false);
this._overlay.addEventListener('dragover', this.onDragOver.bind(this), false);
this._overlay.addEventListener('dragleave', this.onDragLeave.bind(this), false);
this._overlay.addEventListener('drop', this.onDrop.bind(this), false);
this._overlay.addEventListener('mousedown', (e) => this.onmousedown(e), false);
window.addEventListener('mousemove', (e) => this.onmousemove(e), false);
window.addEventListener('mouseup', (e) => this.onmouseup(e), false);
var deleteElement: HTMLElement = query('.delete-button', this._overlay);
if (deleteElement) {
deleteElement.addEventListener('click', (e) => this.onremove(e), false);
}
var afterElement: HTMLElement = query('.caret-after-button', this._overlay);
if (afterElement) {
afterElement.addEventListener('mousedown', (e) => this.onmovecaret(e), false);
}
var beforeElement: HTMLElement = query('.caret-before-button', this._overlay);
if (beforeElement) {
beforeElement.addEventListener('mousedown', (e) => this.onmovecaret(e), false);
}
}
private static _bind(editor: Editor) {
if (Block._boundEditors.has(editor)) {
// editor is already bound to the Block class, make this a noop
return;
}
Block._boundEditors.add(editor);
editor.el.addEventListener('serialize', (e: SerializeEvent) => {
if (is.overlayReference(e.serializeTarget)) {
var overlay = editor.overlay.for(e.serializeTarget);
if (overlay && overlay['block']) {
var block: Block = (overlay['block']);
e.detail.serialize = block.serialize(e.detail.context);
e.preventDefault();
}
}
});
}
/**
* Fired when the mouse button is pressed on the block
*/
private onmousedown(e: MouseEvent) {
if (e.button != 0) return;
var c = classes(e.target);
if (e.target == this.overlay || c.has('body') || c.has('grabber')) {
this._editor.focus();
this._hold = true;
this._holdX = e.clientX;
this._holdY = e.clientY;
var range = document.createRange();
range.setStart(this.el, 0);
range.setEnd(this.el, 0);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
e.preventDefault();
return this;
}
}
/**
* Fired when the mouse moves
*/
private onmousemove(e: MouseEvent){
if (this._hold && (Math.abs(this._holdX - e.clientX) > 5 || Math.abs(this._holdY - e.clientY) > 5)) {
this._editor.drag.start(this.el);
this._hold = false;
} else {
var bounding = this.el.getBoundingClientRect();
if (e.clientX >= bounding.left &&
e.clientX < bounding.right &&
e.clientY >= bounding.top &&
e.clientY < bounding.bottom) {
this._editor.drag.update(this.el, e.clientX, e.clientY);
this._editor.autoscroll.target(e.clientX, e.clientY);
}
}
}
/**
* Fired when the mouse button is lifted
*/
private onmouseup(e: MouseEvent): void{
if (e.button != 0) return;
this._hold = false;
}
/**
* Delete block through of `delete` button
*/
private onremove(e: MouseEvent){
this.el.parentNode.removeChild(this.el);
this.emit('remove');
}
/**
* Move cursor either before or after the block
*/
private onmovecaret(e: MouseEvent){
// Figure out direction of motion
var direction;
if ((e.target).className == 'caret-before-button') {
direction = -1;
} else {
direction = 1;
}
// Figure out whether we should create a new paragraph or move to an existing one
var shouldCreate = false;
if (direction == -1) {
if (!this.el.previousSibling || is.overlayReference(this.el.previousSibling)) {
shouldCreate = true;
}
} else {
if (!this.el.nextSibling || is.overlayReference(this.el.nextSibling)) {
shouldCreate = true;
}
}
// To better match native behavior, we must change the selection asynchronously.
//
// If we change it synchronously inside the mousedown handler, and don't call
// `e.preventDefault()` it will be overwritten by the default browser selection
// behavior.
//
// If we do call `e.preventDefault()`, the default browser selection
// behavior is also suppressed, so the user is not able to start a selection by
// draggin before/after a block.
//
// Since there's no way to suppress just part of the default behavior (setting
// caret position, but not starting a selection), what we do is that we set the
// caret position asynchronously, so it overwrites the caret set by the default
// browser behavior. This is not really pretty, but does the trick.
//
// Another way of dealing with this would be to manually implement selection
// behavior. However that would be really complex and error prone, so this
// approach is cleaner overall.
//
// TODO: figure out a way of getting the selection portion to work on Firefox
// TODO: figure out why selection behavior gets a bit weird on Safari
setTimeout(() => {
var s = currentSelection(document);
var r = document.createRange();
if (shouldCreate) {
var p = document.createElement('p');
var n = document.createElement('br');
p.appendChild(n);
if (direction == -1) {
this.el.parentNode.insertBefore(p, this.el);
} else {
this.el.parentNode.insertBefore(p, this.el.nextSibling);
}
r.selectNode(n);
collapse.toStart(r);
} else {
if (direction == -1) {
if (is.emptyParagraph(this.el.previousSibling)) {
// Make sure cursor is before `
` tag.
r.selectNode(this.el.previousSibling.firstChild);
collapse.toStart(r);
} else {
r.selectNodeContents(this.el.previousSibling);
collapse.toEnd(r);
}
} else {
r.selectNodeContents(this.el.nextSibling);
collapse.toStart(r);
}
}
this._editor.el.focus();
s.removeAllRanges();
s.addRange(r);
}, 0);
}
/**
* Destroy the block
*/
public destroy(): void {
debug('destroying `%s` ...', this._id);
this.emit('destroy');
}
/**
* Sets the float direction of the block
* @param {String} dir
* @api public
*/
public float(dir: string): void {
var elClasses = classes(this.el);
elClasses.remove('left');
elClasses.remove('right');
if (dir == 'left') {
elClasses.add('left');
} else if (dir == 'right') {
elClasses.add('right');
}
this._editor.overlay.update();
}
/**
* Serializes the block
*/
protected serialize(context: String): Node | String {
return document.createDocumentFragment();
}
protected onDragEnter(e: DragEvent) {
this.editor.media.onDragEnter(e);
}
protected onDragOver(e: DragEvent) {
this.editor.media.onDragOver(e);
}
protected onDragLeave(e: DragEvent) {
this.editor.media.onDragLeave(e);
}
protected onDrop(e: DragEvent) {
// There's no need to call onDrop here, because it's
// caught on a higher level on the DOM hierarchy by
// the media controller.
// If you do call it here you get a double drop.
// this.editor.media.onDrop(e);
}
}
export = Block;