UNPKG

medium-editor

Version:
684 lines (566 loc) 28.2 kB
(function () { 'use strict'; var Toolbar = MediumEditor.Extension.extend({ name: 'toolbar', /* Toolbar Options */ /* align: ['left'|'center'|'right'] * When the __static__ option is true, this aligns the static toolbar * relative to the medium-editor element. */ align: 'center', /* allowMultiParagraphSelection: [boolean] * enables/disables whether the toolbar should be displayed when * selecting multiple paragraphs/block elements */ allowMultiParagraphSelection: true, /* buttons: [Array] * the names of the set of buttons to display on the toolbar. */ buttons: ['bold', 'italic', 'underline', 'anchor', 'h2', 'h3', 'quote'], /* diffLeft: [Number] * value in pixels to be added to the X axis positioning of the toolbar. */ diffLeft: 0, /* diffTop: [Number] * value in pixels to be added to the Y axis positioning of the toolbar. */ diffTop: -10, /* firstButtonClass: [string] * CSS class added to the first button in the toolbar. */ firstButtonClass: 'medium-editor-button-first', /* lastButtonClass: [string] * CSS class added to the last button in the toolbar. */ lastButtonClass: 'medium-editor-button-last', /* standardizeSelectionStart: [boolean] * enables/disables standardizing how the beginning of a range is decided * between browsers whenever the selected text is analyzed for updating toolbar buttons status. */ standardizeSelectionStart: false, /* static: [boolean] * enable/disable the toolbar always displaying in the same location * relative to the medium-editor element. */ static: false, /* sticky: [boolean] * When the __static__ option is true, this enables/disables the toolbar * "sticking" to the viewport and staying visible on the screen while * the page scrolls. */ sticky: false, /* stickyTopOffset: [Number] * Value in pixel of the top offset above the toolbar */ stickyTopOffset: 0, /* updateOnEmptySelection: [boolean] * When the __static__ option is true, this enables/disables updating * the state of the toolbar buttons even when the selection is collapsed * (there is no selection, just a cursor). */ updateOnEmptySelection: false, /* relativeContainer: [node] * appending the toolbar to a given node instead of body */ relativeContainer: null, init: function () { MediumEditor.Extension.prototype.init.apply(this, arguments); this.initThrottledMethods(); if (!this.relativeContainer) { this.getEditorOption('elementsContainer').appendChild(this.getToolbarElement()); } else { this.relativeContainer.appendChild(this.getToolbarElement()); } }, // Helper method to execute method for every extension, but ignoring the toolbar extension forEachExtension: function (iterator, context) { return this.base.extensions.forEach(function (command) { if (command === this) { return; } return iterator.apply(context || this, arguments); }, this); }, // Toolbar creation/deletion createToolbar: function () { var toolbar = this.document.createElement('div'); toolbar.id = 'medium-editor-toolbar-' + this.getEditorId(); toolbar.className = 'medium-editor-toolbar'; if (this.static) { toolbar.className += ' static-toolbar'; } else if (this.relativeContainer) { toolbar.className += ' medium-editor-relative-toolbar'; } else { toolbar.className += ' medium-editor-stalker-toolbar'; } toolbar.appendChild(this.createToolbarButtons()); // Add any forms that extensions may have this.forEachExtension(function (extension) { if (extension.hasForm) { toolbar.appendChild(extension.getForm()); } }); this.attachEventHandlers(); return toolbar; }, createToolbarButtons: function () { var ul = this.document.createElement('ul'), li, btn, buttons, extension, buttonName, buttonOpts; ul.id = 'medium-editor-toolbar-actions' + this.getEditorId(); ul.className = 'medium-editor-toolbar-actions'; ul.style.display = 'block'; this.buttons.forEach(function (button) { if (typeof button === 'string') { buttonName = button; buttonOpts = null; } else { buttonName = button.name; buttonOpts = button; } // If the button already exists as an extension, it'll be returned // othwerise it'll create the default built-in button extension = this.base.addBuiltInExtension(buttonName, buttonOpts); if (extension && typeof extension.getButton === 'function') { btn = extension.getButton(this.base); li = this.document.createElement('li'); if (MediumEditor.util.isElement(btn)) { li.appendChild(btn); } else { li.innerHTML = btn; } ul.appendChild(li); } }, this); buttons = ul.querySelectorAll('button'); if (buttons.length > 0) { buttons[0].classList.add(this.firstButtonClass); buttons[buttons.length - 1].classList.add(this.lastButtonClass); } return ul; }, destroy: function () { if (this.toolbar) { if (this.toolbar.parentNode) { this.toolbar.parentNode.removeChild(this.toolbar); } delete this.toolbar; } }, // Toolbar accessors getInteractionElements: function () { return this.getToolbarElement(); }, getToolbarElement: function () { if (!this.toolbar) { this.toolbar = this.createToolbar(); } return this.toolbar; }, getToolbarActionsElement: function () { return this.getToolbarElement().querySelector('.medium-editor-toolbar-actions'); }, // Toolbar event handlers initThrottledMethods: function () { // throttledPositionToolbar is throttled because: // - It will be called when the browser is resizing, which can fire many times very quickly // - For some event (like resize) a slight lag in UI responsiveness is OK and provides performance benefits this.throttledPositionToolbar = MediumEditor.util.throttle(function () { if (this.base.isActive) { this.positionToolbarIfShown(); } }.bind(this)); }, attachEventHandlers: function () { // MediumEditor custom events for when user beings and ends interaction with a contenteditable and its elements this.subscribe('blur', this.handleBlur.bind(this)); this.subscribe('focus', this.handleFocus.bind(this)); // Updating the state of the toolbar as things change this.subscribe('editableClick', this.handleEditableClick.bind(this)); this.subscribe('editableKeyup', this.handleEditableKeyup.bind(this)); // Handle mouseup on document for updating the selection in the toolbar this.on(this.document.documentElement, 'mouseup', this.handleDocumentMouseup.bind(this)); // Add a scroll event for sticky toolbar if (this.static && this.sticky) { // On scroll (capture), re-position the toolbar this.on(this.window, 'scroll', this.handleWindowScroll.bind(this), true); } // On resize, re-position the toolbar this.on(this.window, 'resize', this.handleWindowResize.bind(this)); }, handleWindowScroll: function () { this.positionToolbarIfShown(); }, handleWindowResize: function () { this.throttledPositionToolbar(); }, handleDocumentMouseup: function (event) { // Do not trigger checkState when mouseup fires over the toolbar if (event && event.target && MediumEditor.util.isDescendant(this.getToolbarElement(), event.target)) { return false; } this.checkState(); }, handleEditableClick: function () { // Delay the call to checkState to handle bug where selection is empty // immediately after clicking inside a pre-existing selection setTimeout(function () { this.checkState(); }.bind(this), 0); }, handleEditableKeyup: function () { this.checkState(); }, handleBlur: function () { // Kill any previously delayed calls to hide the toolbar clearTimeout(this.hideTimeout); // Blur may fire even if we have a selection, so we want to prevent any delayed showToolbar // calls from happening in this specific case clearTimeout(this.delayShowTimeout); // Delay the call to hideToolbar to handle bug with multiple editors on the page at once this.hideTimeout = setTimeout(function () { this.hideToolbar(); }.bind(this), 1); }, handleFocus: function () { this.checkState(); }, // Hiding/showing toolbar isDisplayed: function () { return this.getToolbarElement().classList.contains('medium-editor-toolbar-active'); }, showToolbar: function () { clearTimeout(this.hideTimeout); if (!this.isDisplayed()) { this.getToolbarElement().classList.add('medium-editor-toolbar-active'); this.trigger('showToolbar', {}, this.base.getFocusedElement()); } }, hideToolbar: function () { if (this.isDisplayed()) { this.getToolbarElement().classList.remove('medium-editor-toolbar-active'); this.trigger('hideToolbar', {}, this.base.getFocusedElement()); } }, isToolbarDefaultActionsDisplayed: function () { return this.getToolbarActionsElement().style.display === 'block'; }, hideToolbarDefaultActions: function () { if (this.isToolbarDefaultActionsDisplayed()) { this.getToolbarActionsElement().style.display = 'none'; } }, showToolbarDefaultActions: function () { this.hideExtensionForms(); if (!this.isToolbarDefaultActionsDisplayed()) { this.getToolbarActionsElement().style.display = 'block'; } // Using setTimeout + options.delay because: // We will actually be displaying the toolbar, which should be controlled by options.delay this.delayShowTimeout = this.base.delay(function () { this.showToolbar(); }.bind(this)); }, hideExtensionForms: function () { // Hide all extension forms this.forEachExtension(function (extension) { if (extension.hasForm && extension.isDisplayed()) { extension.hideForm(); } }); }, // Responding to changes in user selection // Checks for existance of multiple block elements in the current selection multipleBlockElementsSelected: function () { var regexEmptyHTMLTags = /<[^\/>][^>]*><\/[^>]+>/gim, // http://stackoverflow.com/questions/3129738/remove-empty-tags-using-regex regexBlockElements = new RegExp('<(' + MediumEditor.util.blockContainerElementNames.join('|') + ')[^>]*>', 'g'), selectionHTML = MediumEditor.selection.getSelectionHtml(this.document).replace(regexEmptyHTMLTags, ''), // Filter out empty blocks from selection hasMultiParagraphs = selectionHTML.match(regexBlockElements); // Find how many block elements are within the html return !!hasMultiParagraphs && hasMultiParagraphs.length > 1; }, modifySelection: function () { var selection = this.window.getSelection(), selectionRange = selection.getRangeAt(0); /* * In firefox, there are cases (ie doubleclick of a word) where the selectionRange start * will be at the very end of an element. In other browsers, the selectionRange start * would instead be at the very beginning of an element that actually has content. * example: * <span>foo</span><span>bar</span> * * If the text 'bar' is selected, most browsers will have the selectionRange start at the beginning * of the 'bar' span. However, there are cases where firefox will have the selectionRange start * at the end of the 'foo' span. The contenteditable behavior will be ok, but if there are any * properties on the 'bar' span, they won't be reflected accurately in the toolbar * (ie 'Bold' button wouldn't be active) * * So, for cases where the selectionRange start is at the end of an element/node, find the next * adjacent text node that actually has content in it, and move the selectionRange start there. */ if (this.standardizeSelectionStart && selectionRange.startContainer.nodeValue && (selectionRange.startOffset === selectionRange.startContainer.nodeValue.length)) { var adjacentNode = MediumEditor.util.findAdjacentTextNodeWithContent(MediumEditor.selection.getSelectionElement(this.window), selectionRange.startContainer, this.document); if (adjacentNode) { var offset = 0; while (adjacentNode.nodeValue.substr(offset, 1).trim().length === 0) { offset = offset + 1; } selectionRange = MediumEditor.selection.select(this.document, adjacentNode, offset, selectionRange.endContainer, selectionRange.endOffset); } } }, checkState: function () { if (this.base.preventSelectionUpdates) { return; } // If no editable has focus OR selection is inside contenteditable = false // hide toolbar if (!this.base.getFocusedElement() || MediumEditor.selection.selectionInContentEditableFalse(this.window)) { return this.hideToolbar(); } // If there's no selection element, selection element doesn't belong to this editor // or toolbar is disabled for this selection element // hide toolbar var selectionElement = MediumEditor.selection.getSelectionElement(this.window); if (!selectionElement || this.getEditorElements().indexOf(selectionElement) === -1 || selectionElement.getAttribute('data-disable-toolbar')) { return this.hideToolbar(); } // Now we know there's a focused editable with a selection // If the updateOnEmptySelection option is true, show the toolbar if (this.updateOnEmptySelection && this.static) { return this.showAndUpdateToolbar(); } // If we don't have a 'valid' selection -> hide toolbar if (!MediumEditor.selection.selectionContainsContent(this.document) || (this.allowMultiParagraphSelection === false && this.multipleBlockElementsSelected())) { return this.hideToolbar(); } this.showAndUpdateToolbar(); }, // Updating the toolbar showAndUpdateToolbar: function () { this.modifySelection(); this.setToolbarButtonStates(); this.trigger('positionToolbar', {}, this.base.getFocusedElement()); this.showToolbarDefaultActions(); this.setToolbarPosition(); }, setToolbarButtonStates: function () { this.forEachExtension(function (extension) { if (typeof extension.isActive === 'function' && typeof extension.setInactive === 'function') { extension.setInactive(); } }); this.checkActiveButtons(); }, checkActiveButtons: function () { var manualStateChecks = [], queryState = null, selectionRange = MediumEditor.selection.getSelectionRange(this.document), parentNode, updateExtensionState = function (extension) { if (typeof extension.checkState === 'function') { extension.checkState(parentNode); } else if (typeof extension.isActive === 'function' && typeof extension.isAlreadyApplied === 'function' && typeof extension.setActive === 'function') { if (!extension.isActive() && extension.isAlreadyApplied(parentNode)) { extension.setActive(); } } }; if (!selectionRange) { return; } // Loop through all extensions this.forEachExtension(function (extension) { // For those extensions where we can use document.queryCommandState(), do so if (typeof extension.queryCommandState === 'function') { queryState = extension.queryCommandState(); // If queryCommandState returns a valid value, we can trust the browser // and don't need to do our manual checks if (queryState !== null) { if (queryState && typeof extension.setActive === 'function') { extension.setActive(); } return; } } // We can't use queryCommandState for this extension, so add to manualStateChecks manualStateChecks.push(extension); }); parentNode = MediumEditor.selection.getSelectedParentElement(selectionRange); // Make sure the selection parent isn't outside of the contenteditable if (!this.getEditorElements().some(function (element) { return MediumEditor.util.isDescendant(element, parentNode, true); })) { return; } // Climb up the DOM and do manual checks for whether a certain extension is currently enabled for this node while (parentNode) { manualStateChecks.forEach(updateExtensionState); // we can abort the search upwards if we leave the contentEditable element if (MediumEditor.util.isMediumEditorElement(parentNode)) { break; } parentNode = parentNode.parentNode; } }, // Positioning toolbar positionToolbarIfShown: function () { if (this.isDisplayed()) { this.setToolbarPosition(); } }, setToolbarPosition: function () { var container = this.base.getFocusedElement(), selection = this.window.getSelection(); // If there isn't a valid selection, bail if (!container) { return this; } if (this.static || !selection.isCollapsed) { this.showToolbar(); // we don't need any absolute positioning if relativeContainer is set if (!this.relativeContainer) { if (this.static) { this.positionStaticToolbar(container); } else { this.positionToolbar(selection); } } this.trigger('positionedToolbar', {}, this.base.getFocusedElement()); } }, positionStaticToolbar: function (container) { // position the toolbar at left 0, so we can get the real width of the toolbar this.getToolbarElement().style.left = '0'; // document.documentElement for IE 9 var scrollTop = (this.document.documentElement && this.document.documentElement.scrollTop) || this.document.body.scrollTop, windowWidth = this.window.innerWidth, toolbarElement = this.getToolbarElement(), containerRect = container.getBoundingClientRect(), containerTop = containerRect.top + scrollTop, containerCenter = (containerRect.left + (containerRect.width / 2)), toolbarHeight = toolbarElement.offsetHeight, toolbarWidth = toolbarElement.offsetWidth, halfOffsetWidth = toolbarWidth / 2, targetLeft; if (this.sticky) { // If it's beyond the height of the editor, position it at the bottom of the editor if (scrollTop > (containerTop + container.offsetHeight - toolbarHeight - this.stickyTopOffset)) { toolbarElement.style.top = (containerTop + container.offsetHeight - toolbarHeight) + 'px'; toolbarElement.classList.remove('medium-editor-sticky-toolbar'); // Stick the toolbar to the top of the window } else if (scrollTop > (containerTop - toolbarHeight - this.stickyTopOffset)) { toolbarElement.classList.add('medium-editor-sticky-toolbar'); toolbarElement.style.top = this.stickyTopOffset + 'px'; // Normal static toolbar position } else { toolbarElement.classList.remove('medium-editor-sticky-toolbar'); toolbarElement.style.top = containerTop - toolbarHeight + 'px'; } } else { toolbarElement.style.top = containerTop - toolbarHeight + 'px'; } switch (this.align) { case 'left': targetLeft = containerRect.left; break; case 'right': targetLeft = containerRect.right - toolbarWidth; break; case 'center': targetLeft = containerCenter - halfOffsetWidth; break; } if (targetLeft < 0) { targetLeft = 0; } else if ((targetLeft + toolbarWidth) > windowWidth) { targetLeft = (windowWidth - Math.ceil(toolbarWidth) - 1); } toolbarElement.style.left = targetLeft + 'px'; }, positionToolbar: function (selection) { // position the toolbar at left 0, so we can get the real width of the toolbar this.getToolbarElement().style.left = '0'; this.getToolbarElement().style.right = 'initial'; var range = selection.getRangeAt(0), boundary = range.getBoundingClientRect(); // Handle selections with just images if (!boundary || ((boundary.height === 0 && boundary.width === 0) && range.startContainer === range.endContainer)) { // If there's a nested image, use that for the bounding rectangle if (range.startContainer.nodeType === 1 && range.startContainer.querySelector('img')) { boundary = range.startContainer.querySelector('img').getBoundingClientRect(); } else { boundary = range.startContainer.getBoundingClientRect(); } } var containerWidth = this.window.innerWidth, toolbarElement = this.getToolbarElement(), toolbarHeight = toolbarElement.offsetHeight, toolbarWidth = toolbarElement.offsetWidth, halfOffsetWidth = toolbarWidth / 2, buttonHeight = 50, defaultLeft = this.diffLeft - halfOffsetWidth, elementsContainer = this.getEditorOption('elementsContainer'), elementsContainerAbsolute = ['absolute', 'fixed'].indexOf(window.getComputedStyle(elementsContainer).getPropertyValue('position')) > -1, positions = {}, relativeBoundary = {}, middleBoundary, elementsContainerBoundary; // If container element is absolute / fixed, recalculate boundaries to be relative to the container if (elementsContainerAbsolute) { elementsContainerBoundary = elementsContainer.getBoundingClientRect(); ['top', 'left'].forEach(function (key) { relativeBoundary[key] = boundary[key] - elementsContainerBoundary[key]; }); relativeBoundary.width = boundary.width; relativeBoundary.height = boundary.height; boundary = relativeBoundary; containerWidth = elementsContainerBoundary.width; // Adjust top position according to container scroll position positions.top = elementsContainer.scrollTop; } else { // Adjust top position according to window scroll position positions.top = this.window.pageYOffset; } middleBoundary = boundary.left + boundary.width / 2; positions.top += boundary.top - toolbarHeight; if (boundary.top < buttonHeight) { toolbarElement.classList.add('medium-toolbar-arrow-over'); toolbarElement.classList.remove('medium-toolbar-arrow-under'); positions.top += buttonHeight + boundary.height - this.diffTop; } else { toolbarElement.classList.add('medium-toolbar-arrow-under'); toolbarElement.classList.remove('medium-toolbar-arrow-over'); positions.top += this.diffTop; } if (middleBoundary < halfOffsetWidth) { positions.left = defaultLeft + halfOffsetWidth; positions.right = 'initial'; } else if ((containerWidth - middleBoundary) < halfOffsetWidth) { positions.left = 'auto'; positions.right = 0; } else { positions.left = defaultLeft + middleBoundary; positions.right = 'initial'; } ['top', 'left', 'right'].forEach(function (key) { toolbarElement.style[key] = positions[key] + (isNaN(positions[key]) ? '' : 'px'); }); } }); MediumEditor.extensions.toolbar = Toolbar; }());