medium-editor
Version:
Medium.com WYSIWYG editor clone.
307 lines (247 loc) • 12.5 kB
JavaScript
(function () {
'use strict';
var AnchorPreview = MediumEditor.Extension.extend({
name: 'anchor-preview',
// Anchor Preview Options
/* hideDelay: [number] (previously options.anchorPreviewHideDelay)
* time in milliseconds to show the anchor tag preview after the mouse has left the anchor tag.
*/
hideDelay: 500,
/* previewValueSelector: [string]
* the default selector to locate where to put the activeAnchor value in the preview
*/
previewValueSelector: 'a',
/* showWhenToolbarIsVisible: [boolean]
* determines whether the anchor tag preview shows up when the toolbar is visible
*/
showWhenToolbarIsVisible: false,
/* showOnEmptyLinks: [boolean]
* determines whether the anchor tag preview shows up on links with href="" or href="#something"
*/
showOnEmptyLinks: true,
init: function () {
this.anchorPreview = this.createPreview();
this.getEditorOption('elementsContainer').appendChild(this.anchorPreview);
this.attachToEditables();
},
getInteractionElements: function () {
return this.getPreviewElement();
},
// TODO: Remove this function in 6.0.0
getPreviewElement: function () {
return this.anchorPreview;
},
createPreview: function () {
var el = this.document.createElement('div');
el.id = 'medium-editor-anchor-preview-' + this.getEditorId();
el.className = 'medium-editor-anchor-preview';
el.innerHTML = this.getTemplate();
this.on(el, 'click', this.handleClick.bind(this));
return el;
},
getTemplate: function () {
return '<div class="medium-editor-toolbar-anchor-preview" id="medium-editor-toolbar-anchor-preview">' +
' <a class="medium-editor-toolbar-anchor-preview-inner"></a>' +
'</div>';
},
destroy: function () {
if (this.anchorPreview) {
if (this.anchorPreview.parentNode) {
this.anchorPreview.parentNode.removeChild(this.anchorPreview);
}
delete this.anchorPreview;
}
},
hidePreview: function () {
if (this.anchorPreview) {
this.anchorPreview.classList.remove('medium-editor-anchor-preview-active');
}
this.activeAnchor = null;
},
showPreview: function (anchorEl) {
if (this.anchorPreview.classList.contains('medium-editor-anchor-preview-active') ||
anchorEl.getAttribute('data-disable-preview')) {
return true;
}
if (this.previewValueSelector) {
this.anchorPreview.querySelector(this.previewValueSelector).textContent = anchorEl.attributes.href.value;
this.anchorPreview.querySelector(this.previewValueSelector).href = anchorEl.attributes.href.value;
}
this.anchorPreview.classList.add('medium-toolbar-arrow-over');
this.anchorPreview.classList.remove('medium-toolbar-arrow-under');
if (!this.anchorPreview.classList.contains('medium-editor-anchor-preview-active')) {
this.anchorPreview.classList.add('medium-editor-anchor-preview-active');
}
this.activeAnchor = anchorEl;
this.positionPreview();
this.attachPreviewHandlers();
return this;
},
positionPreview: function (activeAnchor) {
activeAnchor = activeAnchor || this.activeAnchor;
var containerWidth = this.window.innerWidth,
buttonHeight = this.anchorPreview.offsetHeight,
boundary = activeAnchor.getBoundingClientRect(),
diffLeft = this.diffLeft,
diffTop = this.diffTop,
elementsContainer = this.getEditorOption('elementsContainer'),
elementsContainerAbsolute = ['absolute', 'fixed'].indexOf(window.getComputedStyle(elementsContainer).getPropertyValue('position')) > -1,
relativeBoundary = {},
halfOffsetWidth, defaultLeft, middleBoundary, elementsContainerBoundary, top;
halfOffsetWidth = this.anchorPreview.offsetWidth / 2;
var toolbarExtension = this.base.getExtensionByName('toolbar');
if (toolbarExtension) {
diffLeft = toolbarExtension.diffLeft;
diffTop = toolbarExtension.diffTop;
}
defaultLeft = diffLeft - halfOffsetWidth;
// 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
top = elementsContainer.scrollTop;
} else {
// Adjust top position according to window scroll position
top = this.window.pageYOffset;
}
middleBoundary = boundary.left + boundary.width / 2;
top += buttonHeight + boundary.top + boundary.height - diffTop - this.anchorPreview.offsetHeight;
this.anchorPreview.style.top = Math.round(top) + 'px';
this.anchorPreview.style.right = 'initial';
if (middleBoundary < halfOffsetWidth) {
this.anchorPreview.style.left = defaultLeft + halfOffsetWidth + 'px';
this.anchorPreview.style.right = 'initial';
} else if ((containerWidth - middleBoundary) < halfOffsetWidth) {
this.anchorPreview.style.left = 'auto';
this.anchorPreview.style.right = 0;
} else {
this.anchorPreview.style.left = defaultLeft + middleBoundary + 'px';
this.anchorPreview.style.right = 'initial';
}
},
attachToEditables: function () {
this.subscribe('editableMouseover', this.handleEditableMouseover.bind(this));
this.subscribe('positionedToolbar', this.handlePositionedToolbar.bind(this));
},
handlePositionedToolbar: function () {
// If the toolbar is visible and positioned, we don't need to hide the preview
// when showWhenToolbarIsVisible is true
if (!this.showWhenToolbarIsVisible) {
this.hidePreview();
}
},
handleClick: function (event) {
var anchorExtension = this.base.getExtensionByName('anchor'),
activeAnchor = this.activeAnchor;
if (anchorExtension && activeAnchor) {
event.preventDefault();
this.base.selectElement(this.activeAnchor);
// Using setTimeout + delay because:
// We may actually be displaying the anchor form, which should be controlled by delay
this.base.delay(function () {
if (activeAnchor) {
var opts = {
value: activeAnchor.attributes.href.value,
target: activeAnchor.getAttribute('target'),
buttonClass: activeAnchor.getAttribute('class')
};
anchorExtension.showForm(opts);
activeAnchor = null;
}
}.bind(this));
}
this.hidePreview();
},
handleAnchorMouseout: function () {
this.anchorToPreview = null;
this.off(this.activeAnchor, 'mouseout', this.instanceHandleAnchorMouseout);
this.instanceHandleAnchorMouseout = null;
},
handleEditableMouseover: function (event) {
var target = MediumEditor.util.getClosestTag(event.target, 'a');
if (false === target) {
return;
}
// Detect empty href attributes
// The browser will make href="" or href="#top"
// into absolute urls when accessed as event.target.href, so check the html
if (!this.showOnEmptyLinks &&
(!/href=["']\S+["']/.test(target.outerHTML) || /href=["']#\S+["']/.test(target.outerHTML))) {
return true;
}
// only show when toolbar is not present
var toolbar = this.base.getExtensionByName('toolbar');
if (!this.showWhenToolbarIsVisible && toolbar && toolbar.isDisplayed && toolbar.isDisplayed()) {
return true;
}
// detach handler for other anchor in case we hovered multiple anchors quickly
if (this.activeAnchor && this.activeAnchor !== target) {
this.detachPreviewHandlers();
}
this.anchorToPreview = target;
this.instanceHandleAnchorMouseout = this.handleAnchorMouseout.bind(this);
this.on(this.anchorToPreview, 'mouseout', this.instanceHandleAnchorMouseout);
// Using setTimeout + delay because:
// - We're going to show the anchor preview according to the configured delay
// if the mouse has not left the anchor tag in that time
this.base.delay(function () {
if (this.anchorToPreview) {
this.showPreview(this.anchorToPreview);
}
}.bind(this));
},
handlePreviewMouseover: function () {
this.lastOver = (new Date()).getTime();
this.hovering = true;
},
handlePreviewMouseout: function (event) {
if (!event.relatedTarget || !/anchor-preview/.test(event.relatedTarget.className)) {
this.hovering = false;
}
},
updatePreview: function () {
if (this.hovering) {
return true;
}
var durr = (new Date()).getTime() - this.lastOver;
if (durr > this.hideDelay) {
// hide the preview 1/2 second after mouse leaves the link
this.detachPreviewHandlers();
}
},
detachPreviewHandlers: function () {
// cleanup
clearInterval(this.intervalTimer);
if (this.instanceHandlePreviewMouseover) {
this.off(this.anchorPreview, 'mouseover', this.instanceHandlePreviewMouseover);
this.off(this.anchorPreview, 'mouseout', this.instanceHandlePreviewMouseout);
if (this.activeAnchor) {
this.off(this.activeAnchor, 'mouseover', this.instanceHandlePreviewMouseover);
this.off(this.activeAnchor, 'mouseout', this.instanceHandlePreviewMouseout);
}
}
this.hidePreview();
this.hovering = this.instanceHandlePreviewMouseover = this.instanceHandlePreviewMouseout = null;
},
// TODO: break up method and extract out handlers
attachPreviewHandlers: function () {
this.lastOver = (new Date()).getTime();
this.hovering = true;
this.instanceHandlePreviewMouseover = this.handlePreviewMouseover.bind(this);
this.instanceHandlePreviewMouseout = this.handlePreviewMouseout.bind(this);
this.intervalTimer = setInterval(this.updatePreview.bind(this), 200);
this.on(this.anchorPreview, 'mouseover', this.instanceHandlePreviewMouseover);
this.on(this.anchorPreview, 'mouseout', this.instanceHandlePreviewMouseout);
this.on(this.activeAnchor, 'mouseover', this.instanceHandlePreviewMouseover);
this.on(this.activeAnchor, 'mouseout', this.instanceHandlePreviewMouseout);
}
});
MediumEditor.extensions.anchorPreview = AnchorPreview;
}());