substance
Version:
Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing system. It is developed to power our online editing platform [Substance](http://substance.io).
243 lines (214 loc) • 7.7 kB
JavaScript
import { Component, $$, DefaultDOMElement, domHelpers } from '../dom'
import { isFunction, platform, uuid } from '../util'
import renderMenu from './renderMenu'
export default class Popover extends Component {
getInitialState () {
return { content: null, requester: null, desiredPos: null }
}
didMount () {
if (platform.inBrowser) {
DefaultDOMElement.getBrowserWindow().on('mousedown', this._onGlobalMousedown, this, { capture: true })
DefaultDOMElement.getBrowserWindow().on('mouseup', this._onGlobalMouseup, this, { capture: true })
}
}
dispose () {
if (platform.inBrowser) {
DefaultDOMElement.getBrowserWindow().off(this)
}
}
render () {
const { content, requester, update } = this.state
const el = $$('div', { class: 'sc-popover' })
if (!update) el.addClass('sm-hidden')
if (requester) {
// Note: content can either be given as menu spec or as a render function
if (isFunction(content)) {
const renderContent = content
el.append(
renderContent()
)
// default: content is given as menu spec
} else {
el.append(
renderMenu(requester, content)
)
}
}
return el
}
/**
* Request to render a popover, given either a menu specification or a render function,
* at the given position.
*
* To receive actions, the requesting component has to be provided.
*
* @param {object} params
* @param {Function|object} params.content - a render function or a menu specification (see renderMenu)
* @param {object} params.desiredPos
* @param {number} params.desiredPos.x - the desired x screen coordinate
* @param {number} params.desiredPos.y - the desired y screen coordinate
* @param {Component} [params.requester] - the component that is requesting the content; this is used to dispatch actions
*/
acquire (params, scrollable) {
// console.log('Popover.acquire()', params)
// Note: allowing to update the current popover using the requestId of a previous run
if (params.update) {
return this._update(params)
}
// NOTE: this implements a toggle behavior. I.e. if the same requester
// requests the popover for the same position, then we hide the popover
const state = this.state
if (params.toggle && state.requester === params.requester) {
// console.log('Popover: toggling')
this._hide()
return false
}
this._checkParams(params)
this.setState(Object.assign({}, params, { requestId: uuid(), scrollable }))
// ATTENTION: we have to postpone showing the content
// as otherwise, e.g. the DOM selection is not yet updated,
// which is needed for positioning
if (platform.inBrowser) {
window.requestAnimationFrame(() => {
this._showPopover()
})
}
return this.state.requestId
}
_update (params) {
if (params.requestId === this.state.requestId) {
this.extendState(params)
this._showPopover()
} else {
console.error('Invalid request id')
}
}
release (requester) {
if (this.state.requester === requester) {
// console.log('Popover.release()', requester)
this._hide()
}
}
close () {
// console.log('Popover.close()')
this._hide()
}
isOpen () {
return Boolean(this.state.requester)
}
_checkParams (params) {
if (!params.content) throw new Error("'params.content' is required")
if (!params.desiredPos) throw new Error("'desiredPos' is required")
}
_showPopover () {
const { desiredPos, scrollable } = this.state
const el = this.getElement()
const bounds = this._getBounds()
// console.log('bounds', bounds, 'desiredPos', desiredPos)
// TODO: we should do some positioning here to stay within the screen/container bounds
const menuWidth = el.htmlProp('offsetWidth')
// By default, context menu are aligned left bottom to the desired coordinate
let leftPos = bounds.x + desiredPos.x - menuWidth / 2
// Must not exceed left bound
leftPos = Math.max(leftPos, 0)
// Must not exceed right bound
const maxLeftPos = bounds.right - menuWidth
leftPos = Math.min(leftPos, maxLeftPos)
const topPos = desiredPos.y - bounds.y
const maxHeight = bounds.bottom - topPos
// store topPos and leftPos so that we can do repositioning on scroll
if (scrollable) {
this._topPos = topPos
this._leftPos = leftPos
this._initialScrollTop = scrollable.getProperty('scrollTop')
this._initialScrollLeft = scrollable.getProperty('scrollLeft')
}
el.css({
top: topPos,
left: leftPos,
'max-height': maxHeight
})
el.removeClass('sm-hidden')
}
_getBounds () {
if (platform.inBrowser) {
let containerEl
if (this.props.getContainer) {
containerEl = this.props.getContainer().getNativeElement()
} else {
containerEl = window.document
}
return containerEl.getBoundingClientRect()
} else {
return { left: 0, top: 0, bottom: 0, right: 0 }
}
}
// overriding the default send() mechanism to be able to dispatch actions to the current requester
_doesHandleAction () {
return Boolean(this.state.requester)
}
_handleAction (action, args) {
// console.log('FORWARDING action to requester', action, args)
this.state.requester.send(action, ...args)
// TODO: think if this is really what we want, i.e. hiding the menu whenever an action is emitted
// console.log('Popover._handleAction(): hiding popover')
this._hide()
}
_isVisible () {
return !this.getElement().hasClass('sm-hidden')
}
_onGlobalMousedown () {
// Note: we auto hide on every click
// except those that occur inside the popover
// and those that are 'white-listed' via params.allowClickInsideOf
this._lastRequestId = this.state.requestId
}
_onGlobalMouseup (e) {
const targetEl = DefaultDOMElement.wrap(e.target)
this._autoclose(targetEl)
}
_autoclose (targetEl) {
if (!this._isVisible()) return
// do not auto-close if clicked inside the popover
if (domHelpers.hasAncestor(targetEl, this.getElement())) {
return
}
// Additionally, allow to skip auto-closing if clicked insied of a given element
// e.g. QuerySelect has an input, that has a dropdown attached. Clicking into the input
// should not auto-close the dropdown.
if (this.state.allowClickInsideOf) {
if (domHelpers.hasAncestor(targetEl, this.state.allowClickInsideOf)) {
// console.log('Ignoring click because it is inside of a white-listed element')
return
}
}
const lastRequestId = this._lastRequestId
const currentRequestId = this.state.requestId
if (lastRequestId === currentRequestId) {
// console.log('Popover: auto-closing because clicked outside of popover.')
this._hide()
}
}
_hide () {
// console.log('Hiding')
const onClose = this.state.onClose
if (onClose) onClose()
this.setState(this.getInitialState())
}
/**
* ATTENTION: this method has to be called by the owner whenever the scrollable
* has been scrolled.
*/
reposition (scrollable) {
// Note: we use this for different kinds of popovers
// typically, only context menus or alike require a 'relative' positioning
if (this.state.position === 'relative') {
const dtop = scrollable.getProperty('scrollTop') - this._initialScrollTop
const dleft = scrollable.getProperty('scrollLeft') - this._initialScrollLeft
this.el.css({
top: this._topPos - dtop,
left: this._leftPos + dleft
})
}
}
}