blob: 622a0238f256a9276001b0c025343c0451a448b8 [file] [log] [blame]
/********************************************
* Cursor and Selection "singleton" classes
*******************************************/
/* The main thing that manipulates the Math DOM. Makes sure to manipulate the
HTML DOM to match. */
/* Sort of singletons, since there should only be one per editable math
textbox, but any one HTML document can contain many such textboxes, so any one
JS environment could actually contain many instances. */
//A fake cursor in the fake textbox that the math is rendered in.
var Cursor = P(Point, function(_) {
_.init = function(initParent, options) {
this.parent = initParent;
this.options = options;
var jQ = this.jQ = this._jQ = $('<span class="mq-cursor">&#8203;</span>');
//closured for setInterval
this.blink = function(){ jQ.toggleClass('mq-blink'); };
this.upDownCache = {};
};
_.show = function() {
this.jQ = this._jQ.removeClass('mq-blink');
if ('intervalId' in this) //already was shown, just restart interval
clearInterval(this.intervalId);
else { //was hidden and detached, insert this.jQ back into HTML DOM
if (this[R]) {
if (this.selection && this.selection.ends[L][L] === this[L])
this.jQ.insertBefore(this.selection.jQ);
else
this.jQ.insertBefore(this[R].jQ.first());
}
else
this.jQ.appendTo(this.parent.jQ);
this.parent.focus();
}
this.intervalId = setInterval(this.blink, 500);
return this;
};
_.hide = function() {
if ('intervalId' in this)
clearInterval(this.intervalId);
delete this.intervalId;
this.jQ.detach();
this.jQ = $();
return this;
};
_.withDirInsertAt = function(dir, parent, withDir, oppDir) {
var oldParent = this.parent;
this.parent = parent;
this[dir] = withDir;
this[-dir] = oppDir;
// by contract, .blur() is called after all has been said and done
// and the cursor has actually been moved
// FIXME pass cursor to .blur() so text can fix cursor pointers when removing itself
if (oldParent !== parent && oldParent.blur) oldParent.blur(this);
};
_.insDirOf = function(dir, el) {
prayDirection(dir);
this.jQ.insDirOf(dir, el.jQ);
this.withDirInsertAt(dir, el.parent, el[dir], el);
this.parent.jQ.addClass('mq-hasCursor');
return this;
};
_.insLeftOf = function(el) { return this.insDirOf(L, el); };
_.insRightOf = function(el) { return this.insDirOf(R, el); };
_.insAtDirEnd = function(dir, el) {
prayDirection(dir);
this.jQ.insAtDirEnd(dir, el.jQ);
this.withDirInsertAt(dir, el, 0, el.ends[dir]);
el.focus();
return this;
};
_.insAtLeftEnd = function(el) { return this.insAtDirEnd(L, el); };
_.insAtRightEnd = function(el) { return this.insAtDirEnd(R, el); };
/**
* jump up or down from one block Node to another:
* - cache the current Point in the node we're jumping from
* - check if there's a Point in it cached for the node we're jumping to
* + if so put the cursor there,
* + if not seek a position in the node that is horizontally closest to
* the cursor's current position
*/
_.jumpUpDown = function(from, to) {
var self = this;
self.upDownCache[from.id] = Point.copy(self);
var cached = self.upDownCache[to.id];
if (cached) {
cached[R] ? self.insLeftOf(cached[R]) : self.insAtRightEnd(cached.parent);
}
else {
var pageX = self.offset().left;
to.seek(pageX, self);
}
};
_.offset = function() {
//in Opera 11.62, .getBoundingClientRect() and hence jQuery::offset()
//returns all 0's on inline elements with negative margin-right (like
//the cursor) at the end of their parent, so temporarily remove the
//negative margin-right when calling jQuery::offset()
//Opera bug DSK-360043
//http://bugs.jquery.com/ticket/11523
//https://github.com/jquery/jquery/pull/717
var self = this, offset = self.jQ.removeClass('mq-cursor').offset();
self.jQ.addClass('mq-cursor');
return offset;
}
_.unwrapGramp = function() {
var gramp = this.parent.parent;
var greatgramp = gramp.parent;
var rightward = gramp[R];
var cursor = this;
var leftward = gramp[L];
gramp.disown().eachChild(function(uncle) {
if (uncle.isEmpty()) return;
uncle.children()
.adopt(greatgramp, leftward, rightward)
.each(function(cousin) {
cousin.jQ.insertBefore(gramp.jQ.first());
})
;
leftward = uncle.ends[R];
});
if (!this[R]) { //then find something to be rightward to insLeftOf
if (this[L])
this[R] = this[L][R];
else {
while (!this[R]) {
this.parent = this.parent[R];
if (this.parent)
this[R] = this.parent.ends[L];
else {
this[R] = gramp[R];
this.parent = greatgramp;
break;
}
}
}
}
if (this[R])
this.insLeftOf(this[R]);
else
this.insAtRightEnd(greatgramp);
gramp.jQ.remove();
if (gramp[L].siblingDeleted) gramp[L].siblingDeleted(cursor.options, R);
if (gramp[R].siblingDeleted) gramp[R].siblingDeleted(cursor.options, L);
};
_.startSelection = function() {
var anticursor = this.anticursor = Point.copy(this);
var ancestors = anticursor.ancestors = {}; // a map from each ancestor of
// the anticursor, to its child that is also an ancestor; in other words,
// the anticursor's ancestor chain in reverse order
for (var ancestor = anticursor; ancestor.parent; ancestor = ancestor.parent) {
ancestors[ancestor.parent.id] = ancestor;
}
};
_.endSelection = function() {
delete this.anticursor;
};
_.select = function() {
var anticursor = this.anticursor;
if (this[L] === anticursor[L] && this.parent === anticursor.parent) return false;
// Find the lowest common ancestor (`lca`), and the ancestor of the cursor
// whose parent is the LCA (which'll be an end of the selection fragment).
for (var ancestor = this; ancestor.parent; ancestor = ancestor.parent) {
if (ancestor.parent.id in anticursor.ancestors) {
var lca = ancestor.parent;
break;
}
}
pray('cursor and anticursor in the same tree', lca);
// The cursor and the anticursor should be in the same tree, because the
// mousemove handler attached to the document, unlike the one attached to
// the root HTML DOM element, doesn't try to get the math tree node of the
// mousemove target, and Cursor::seek() based solely on coordinates stays
// within the tree of `this` cursor's root.
// The other end of the selection fragment, the ancestor of the anticursor
// whose parent is the LCA.
var antiAncestor = anticursor.ancestors[lca.id];
// Now we have two either Nodes or Points, guaranteed to have a common
// parent and guaranteed that if both are Points, they are not the same,
// and we have to figure out which is the left end and which the right end
// of the selection.
var leftEnd, rightEnd, dir = R;
// This is an extremely subtle algorithm.
// As a special case, `ancestor` could be a Point and `antiAncestor` a Node
// immediately to `ancestor`'s left.
// In all other cases,
// - both Nodes
// - `ancestor` a Point and `antiAncestor` a Node
// - `ancestor` a Node and `antiAncestor` a Point
// `antiAncestor[R] === rightward[R]` for some `rightward` that is
// `ancestor` or to its right, if and only if `antiAncestor` is to
// the right of `ancestor`.
if (ancestor[L] !== antiAncestor) {
for (var rightward = ancestor; rightward; rightward = rightward[R]) {
if (rightward[R] === antiAncestor[R]) {
dir = L;
leftEnd = ancestor;
rightEnd = antiAncestor;
break;
}
}
}
if (dir === R) {
leftEnd = antiAncestor;
rightEnd = ancestor;
}
// only want to select Nodes up to Points, can't select Points themselves
if (leftEnd instanceof Point) leftEnd = leftEnd[R];
if (rightEnd instanceof Point) rightEnd = rightEnd[L];
this.hide().selection = lca.selectChildren(leftEnd, rightEnd);
this.insDirOf(dir, this.selection.ends[dir]);
this.selectionChanged();
return true;
};
_.clearSelection = function() {
if (this.selection) {
this.selection.clear();
delete this.selection;
this.selectionChanged();
}
return this;
};
_.deleteSelection = function() {
if (!this.selection) return;
this[L] = this.selection.ends[L][L];
this[R] = this.selection.ends[R][R];
this.selection.remove();
this.selectionChanged();
delete this.selection;
};
_.replaceSelection = function() {
var seln = this.selection;
if (seln) {
this[L] = seln.ends[L][L];
this[R] = seln.ends[R][R];
delete this.selection;
}
return seln;
};
});
var Selection = P(Fragment, function(_, super_) {
_.init = function() {
super_.init.apply(this, arguments);
this.jQ = this.jQ.wrapAll('<span class="mq-selection"></span>').parent();
//can't do wrapAll(this.jQ = $(...)) because wrapAll will clone it
};
_.adopt = function() {
this.jQ.replaceWith(this.jQ = this.jQ.children());
return super_.adopt.apply(this, arguments);
};
_.clear = function() {
// using the browser's native .childNodes property so that we
// don't discard text nodes.
this.jQ.replaceWith(this.jQ[0].childNodes);
return this;
};
_.join = function(methodName) {
return this.fold('', function(fold, child) {
return fold + child[methodName]();
});
};
});