| /******************************************** |
| * 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">​</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](); |
| }); |
| }; |
| }); |