// Copyright 2005 The Closure Library Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS-IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. /** * @fileoverview Bubble component - handles display, hiding, etc. of the * actual bubble UI. * * This is used exclusively by code within the editor package, and should not * be used directly. * * @author robbyw@google.com (Robby Walker) */ goog.provide('goog.ui.editor.Bubble'); goog.require('goog.asserts'); goog.require('goog.dom'); goog.require('goog.dom.TagName'); goog.require('goog.dom.ViewportSizeMonitor'); goog.require('goog.dom.classlist'); goog.require('goog.editor.style'); goog.require('goog.events.EventHandler'); goog.require('goog.events.EventTarget'); goog.require('goog.events.EventType'); goog.require('goog.functions'); goog.require('goog.log'); goog.require('goog.math.Box'); goog.require('goog.object'); goog.require('goog.positioning'); goog.require('goog.positioning.Corner'); goog.require('goog.positioning.Overflow'); goog.require('goog.positioning.OverflowStatus'); goog.require('goog.string'); goog.require('goog.style'); goog.require('goog.ui.Component'); goog.require('goog.ui.PopupBase'); goog.require('goog.userAgent'); /** * Property bubble UI element. * @param {Element} parent The parent element for this bubble. * @param {number} zIndex The z index to draw the bubble at. * @constructor * @extends {goog.events.EventTarget} */ goog.ui.editor.Bubble = function(parent, zIndex) { goog.ui.editor.Bubble.base(this, 'constructor'); /** * Dom helper for the document the bubble should be shown in. * @type {!goog.dom.DomHelper} * @private */ this.dom_ = goog.dom.getDomHelper(parent); /** * Event handler for this bubble. * @type {goog.events.EventHandler} * @private */ this.eventHandler_ = new goog.events.EventHandler(this); /** * Object that monitors the application window for size changes. * @type {goog.dom.ViewportSizeMonitor} * @private */ this.viewPortSizeMonitor_ = new goog.dom.ViewportSizeMonitor(this.dom_.getWindow()); /** * Maps panel ids to panels. * @type {Object} * @private */ this.panels_ = {}; /** * Container element for the entire bubble. This may contain elements related * to look and feel or styling of the bubble. * @type {Element} * @private */ this.bubbleContainer_ = this.dom_.createDom( goog.dom.TagName.DIV, {'className': goog.ui.editor.Bubble.BUBBLE_CLASSNAME}); goog.style.setElementShown(this.bubbleContainer_, false); goog.dom.appendChild(parent, this.bubbleContainer_); goog.style.setStyle(this.bubbleContainer_, 'zIndex', zIndex); /** * Container element for the bubble panels - this should be some inner element * within (or equal to) bubbleContainer. * @type {Element} * @private */ this.bubbleContents_ = this.createBubbleDom(this.dom_, this.bubbleContainer_); /** * Element showing the close box. * @type {!Element} * @private */ this.closeBox_ = this.dom_.createDom(goog.dom.TagName.DIV, { 'className': goog.getCssName('tr_bubble_closebox'), 'innerHTML': ' ' }); this.bubbleContents_.appendChild(this.closeBox_); // We make bubbles unselectable so that clicking on them does not steal focus // or move the cursor away from the element the bubble is attached to. goog.editor.style.makeUnselectable(this.bubbleContainer_, this.eventHandler_); /** * Popup that controls showing and hiding the bubble at the appropriate * position. * @type {goog.ui.PopupBase} * @private */ this.popup_ = new goog.ui.PopupBase(this.bubbleContainer_); }; goog.inherits(goog.ui.editor.Bubble, goog.events.EventTarget); /** * The css class name of the bubble container element. * @type {string} */ goog.ui.editor.Bubble.BUBBLE_CLASSNAME = goog.getCssName('tr_bubble'); /** * Creates and adds DOM for the bubble UI to the given container. This default * implementation just returns the container itself. * @param {!goog.dom.DomHelper} dom DOM helper to use. * @param {!Element} container Element to add the new elements to. * @return {!Element} The element where bubble content should be added. * @protected */ goog.ui.editor.Bubble.prototype.createBubbleDom = function(dom, container) { return container; }; /** * A logger for goog.ui.editor.Bubble. * @type {goog.log.Logger} * @protected */ goog.ui.editor.Bubble.prototype.logger = goog.log.getLogger('goog.ui.editor.Bubble'); /** @override */ goog.ui.editor.Bubble.prototype.disposeInternal = function() { goog.ui.editor.Bubble.base(this, 'disposeInternal'); goog.dom.removeNode(this.bubbleContainer_); this.bubbleContainer_ = null; this.eventHandler_.dispose(); this.eventHandler_ = null; this.viewPortSizeMonitor_.dispose(); this.viewPortSizeMonitor_ = null; }; /** * @return {Element} The element that where the bubble's contents go. */ goog.ui.editor.Bubble.prototype.getContentElement = function() { return this.bubbleContents_; }; /** * @return {Element} The element that contains the bubble. * @protected */ goog.ui.editor.Bubble.prototype.getContainerElement = function() { return this.bubbleContainer_; }; /** * @return {goog.events.EventHandler} The event handler. * @protected * @this {T} * @template T */ goog.ui.editor.Bubble.prototype.getEventHandler = function() { return this.eventHandler_; }; /** * Handles user resizing of window. * @private */ goog.ui.editor.Bubble.prototype.handleWindowResize_ = function() { if (this.isVisible()) { this.reposition(); } }; /** * Sets whether the bubble dismisses itself when the user clicks outside of it. * @param {boolean} autoHide Whether to autohide on an external click. */ goog.ui.editor.Bubble.prototype.setAutoHide = function(autoHide) { this.popup_.setAutoHide(autoHide); }; /** * Returns whether there is already a panel of the given type. * @param {string} type Type of panel to check. * @return {boolean} Whether there is already a panel of the given type. */ goog.ui.editor.Bubble.prototype.hasPanelOfType = function(type) { return goog.object.some( this.panels_, function(panel) { return panel.type == type; }); }; /** * Adds a panel to the bubble. * @param {string} type The type of bubble panel this is. Should usually be * the same as the tagName of the targetElement. This ensures multiple * bubble panels don't appear for the same element. * @param {string} title The title of the panel. * @param {Element} targetElement The target element of the bubble. * @param {function(Element): void} contentFn Function that when called with * a container element, will add relevant panel content to it. * @param {boolean=} opt_preferTopPosition Whether to prefer placing the bubble * above the element instead of below it. Defaults to preferring below. * If any panel prefers the top position, the top position is used. * @return {string} The id of the panel. */ goog.ui.editor.Bubble.prototype.addPanel = function( type, title, targetElement, contentFn, opt_preferTopPosition) { var id = goog.string.createUniqueString(); var panel = new goog.ui.editor.Bubble.Panel_( this.dom_, id, type, title, targetElement, !opt_preferTopPosition); this.panels_[id] = panel; // Insert the panel in string order of type. Technically we could use binary // search here but n is really small (probably 0 - 2) so it's not worth it. // The last child of bubbleContents_ is the close box so we take care not // to treat it as a panel element, and we also ensure it stays as the last // element. The intention here is not to create any artificial order, but // just to ensure that it is always consistent. var nextElement; for (var i = 0, len = this.bubbleContents_.childNodes.length - 1; i < len; i++) { var otherChild = this.bubbleContents_.childNodes[i]; var otherPanel = this.panels_[otherChild.id]; if (otherPanel.type > type) { nextElement = otherChild; break; } } goog.dom.insertSiblingBefore( panel.element, nextElement || this.bubbleContents_.lastChild); contentFn(panel.getContentElement()); goog.editor.style.makeUnselectable(panel.element, this.eventHandler_); var numPanels = goog.object.getCount(this.panels_); if (numPanels == 1) { this.openBubble_(); } else if (numPanels == 2) { goog.dom.classlist.add( goog.asserts.assert(this.bubbleContainer_), goog.getCssName('tr_multi_bubble')); } this.reposition(); return id; }; /** * Removes the panel with the given id. * @param {string} id The id of the panel. */ goog.ui.editor.Bubble.prototype.removePanel = function(id) { var panel = this.panels_[id]; goog.dom.removeNode(panel.element); delete this.panels_[id]; var numPanels = goog.object.getCount(this.panels_); if (numPanels <= 1) { goog.dom.classlist.remove( goog.asserts.assert(this.bubbleContainer_), goog.getCssName('tr_multi_bubble')); } if (numPanels == 0) { this.closeBubble_(); } else { this.reposition(); } }; /** * Opens the bubble. * @private */ goog.ui.editor.Bubble.prototype.openBubble_ = function() { this.eventHandler_ .listen(this.closeBox_, goog.events.EventType.CLICK, this.closeBubble_) .listen( this.viewPortSizeMonitor_, goog.events.EventType.RESIZE, this.handleWindowResize_) .listen( this.popup_, goog.ui.PopupBase.EventType.HIDE, this.handlePopupHide); this.popup_.setVisible(true); this.reposition(); }; /** * Closes the bubble. * @private */ goog.ui.editor.Bubble.prototype.closeBubble_ = function() { this.popup_.setVisible(false); }; /** * Handles the popup's hide event by removing all panels and dispatching a * HIDE event. * @protected */ goog.ui.editor.Bubble.prototype.handlePopupHide = function() { // Remove the panel elements. for (var panelId in this.panels_) { goog.dom.removeNode(this.panels_[panelId].element); } // Update the state to reflect no panels. this.panels_ = {}; goog.dom.classlist.remove( goog.asserts.assert(this.bubbleContainer_), goog.getCssName('tr_multi_bubble')); this.eventHandler_.removeAll(); this.dispatchEvent(goog.ui.Component.EventType.HIDE); }; /** * Returns the visibility of the bubble. * @return {boolean} True if visible false if not. */ goog.ui.editor.Bubble.prototype.isVisible = function() { return this.popup_.isVisible(); }; /** * The vertical clearance in pixels between the bottom of the targetElement * and the edge of the bubble. * @type {number} * @private */ goog.ui.editor.Bubble.VERTICAL_CLEARANCE_ = goog.userAgent.IE ? 4 : 2; /** * Bubble's margin box to be passed to goog.positioning. * @type {goog.math.Box} * @private */ goog.ui.editor.Bubble.MARGIN_BOX_ = new goog.math.Box( goog.ui.editor.Bubble.VERTICAL_CLEARANCE_, 0, goog.ui.editor.Bubble.VERTICAL_CLEARANCE_, 0); /** * Returns the margin box. * @return {goog.math.Box} * @protected */ goog.ui.editor.Bubble.prototype.getMarginBox = function() { return goog.ui.editor.Bubble.MARGIN_BOX_; }; /** * Positions and displays this bubble below its targetElement. Assumes that * the bubbleContainer is already contained in the document object it applies * to. */ goog.ui.editor.Bubble.prototype.reposition = function() { var targetElement = null; var preferBottomPosition = true; for (var panelId in this.panels_) { var panel = this.panels_[panelId]; // We don't care which targetElement we get, so we just take the last one. targetElement = panel.targetElement; preferBottomPosition = preferBottomPosition && panel.preferBottomPosition; } var status = goog.positioning.OverflowStatus.FAILED; // Fix for bug when bubbleContainer and targetElement have // opposite directionality, the bubble should anchor to the END of // the targetElement instead of START. var reverseLayout = (goog.style.isRightToLeft(this.bubbleContainer_) != goog.style.isRightToLeft(targetElement)); // Try to put the bubble at the bottom of the target unless the plugin has // requested otherwise. if (preferBottomPosition) { status = this.positionAtAnchor_( reverseLayout ? goog.positioning.Corner.BOTTOM_END : goog.positioning.Corner.BOTTOM_START, goog.positioning.Corner.TOP_START, goog.positioning.Overflow.ADJUST_X | goog.positioning.Overflow.FAIL_Y); } if (status & goog.positioning.OverflowStatus.FAILED) { // Try to put it at the top of the target if there is not enough // space at the bottom. status = this.positionAtAnchor_( reverseLayout ? goog.positioning.Corner.TOP_END : goog.positioning.Corner.TOP_START, goog.positioning.Corner.BOTTOM_START, goog.positioning.Overflow.ADJUST_X | goog.positioning.Overflow.FAIL_Y); } if (status & goog.positioning.OverflowStatus.FAILED) { // Put it at the bottom again with adjustment if there is no // enough space at the top. status = this.positionAtAnchor_( reverseLayout ? goog.positioning.Corner.BOTTOM_END : goog.positioning.Corner.BOTTOM_START, goog.positioning.Corner.TOP_START, goog.positioning.Overflow.ADJUST_X | goog.positioning.Overflow.ADJUST_Y); if (status & goog.positioning.OverflowStatus.FAILED) { goog.log.warning( this.logger, 'reposition(): positionAtAnchor() failed with ' + status); } } }; /** * A helper for reposition() - positions the bubble in regards to the position * of the elements the bubble is attached to. * @param {goog.positioning.Corner} targetCorner The corner of * the target element. * @param {goog.positioning.Corner} bubbleCorner The corner of the bubble. * @param {number} overflow Overflow handling mode bitmap, * {@see goog.positioning.Overflow}. * @return {number} Status bitmap, {@see goog.positioning.OverflowStatus}. * @private */ goog.ui.editor.Bubble.prototype.positionAtAnchor_ = function( targetCorner, bubbleCorner, overflow) { var targetElement = null; for (var panelId in this.panels_) { // For now, we use the outermost element. This assumes the multiple // elements this panel is showing for contain each other - in the event // that is not generally the case this may need to be updated to pick // the lowest or highest element depending on targetCorner. var candidate = this.panels_[panelId].targetElement; if (!targetElement || goog.dom.contains(candidate, targetElement)) { targetElement = this.panels_[panelId].targetElement; } } return goog.positioning.positionAtAnchor( targetElement, targetCorner, this.bubbleContainer_, bubbleCorner, null, this.getMarginBox(), overflow, null, this.getViewportBox()); }; /** * Returns the viewport box to use when positioning the bubble. * @return {goog.math.Box} * @protected */ goog.ui.editor.Bubble.prototype.getViewportBox = goog.functions.NULL; /** * Private class used to describe a bubble panel. * @param {goog.dom.DomHelper} dom DOM helper used to create the panel. * @param {string} id ID of the panel. * @param {string} type Type of the panel. * @param {string} title Title of the panel. * @param {Element} targetElement Element the panel is showing for. * @param {boolean} preferBottomPosition Whether this panel prefers to show * below the target element. * @constructor * @private */ goog.ui.editor.Bubble.Panel_ = function( dom, id, type, title, targetElement, preferBottomPosition) { /** * The type of bubble panel. * @type {string} */ this.type = type; /** * The target element of this bubble panel. * @type {Element} */ this.targetElement = targetElement; /** * Whether the panel prefers to be placed below the target element. * @type {boolean} */ this.preferBottomPosition = preferBottomPosition; /** * The element containing this panel. */ this.element = dom.createDom( goog.dom.TagName.DIV, {className: goog.getCssName('tr_bubble_panel'), id: id}, dom.createDom( goog.dom.TagName.DIV, {className: goog.getCssName('tr_bubble_panel_title')}, title ? title + ':' : ''), // TODO(robbyw): Does this work in bidi? dom.createDom( goog.dom.TagName.DIV, {className: goog.getCssName('tr_bubble_panel_content')})); }; /** * @return {Element} The element in the panel where content should go. */ goog.ui.editor.Bubble.Panel_.prototype.getContentElement = function() { return /** @type {Element} */ (this.element.lastChild); };