iwmlib/lib/popup.js

963 lines
33 KiB
JavaScript

import { Elements } from './utils.js'
import Poppable from './poppable.js'
/** A Popup that shows text labels, images, or html
*/
export default class Popup extends Poppable {
/**
* Creates an instance of Popup.
* @param {any} [{
* parent = null, - The DOM parent element.
* content = null, - A dict object with type strings (text, img, html) as keys
* and corresponding values.
* context = window, - A context object for poppable elements
* fontSize = "1em", - Describes the font size as CSS value
* fontFamily = "Arial", - Describes the font family as CSS value
* padding = 16, - {number || string} padding - Describes the padding as CSS value
* notchSize = 10, - {number || string} notchSize - Describes the size of the notch (callout) as CSS value
* switchPos = false,
* minWidth = null,
* maxWidth = 800,
* backgroundColor = "#EEE", - The color of the background as CSS value
* normalColor = "#444", - normalColor - The color of textitems as CSS value
* notchPosition = "bottomLeft",
* zIndex = 0,
* keepWithin = null, - Ensure that popup is visible within the bounds of the given container
* autoClose = true, - Autoclose the Popup on tap
* closeIcon = null,
* resizeIcon = null,
* closeCommand = null,
* draggable = false
* noStyle = false - When true, prevents the popup from doing any aesthetic manipulations to the DOM leaving the styling completely to the style sheets.
* }={}]
* @memberof Popup
*/
constructor({
parent = null,
content = null,
context = window,
fontSize = '1em',
fontFamily = 'Arial',
padding = 16,
notchSize = 10,
switchPos = false,
minWidth = null,
maxWidth = 800,
backgroundColor = '#EEE',
normalColor = '#444',
notchPosition = 'bottomCenter',
zIndex = 0,
keepWithin = null,
autoClose = true,
closeIcon = null,
resizeIcon = null,
closeCommand = null,
draggable = false,
posOffset = 0,
targetBoundingBox = null,
useEventPosWithBoundingBox = false,
interactive = false,
onResize = null,
onMove = null,
noStyle = false,
hideOnUp = true
} = {}) {
super()
this.context = context
this.noStyle = noStyle
this.hideOnUp = hideOnUp
this.padding = padding
this.notchPosition = notchPosition
this.notchSize = notchSize
this.switchPos = switchPos
this.fontSize = fontSize
this.fontFamily = fontFamily
this.minWidth = minWidth
this.maxWidth = maxWidth
this.normalColor = normalColor
this.backgroundColor = backgroundColor
this.keepWithin = keepWithin
this.autoClose = autoClose
this.resizeIcon = resizeIcon
this.closeIcon = closeIcon
this.closeCommand = closeCommand
this.zIndex = zIndex
this.parent = parent || document.body
this.draggable = draggable
this.posOffset = posOffset
this.targetBoundingBox = targetBoundingBox
this.useEventPosWithBoundingBox = useEventPosWithBoundingBox
this.currentPos = null
this.insertedNode = null
this.loaded = false
this.interactive = interactive
this.onload = null
this.onResize = onResize
this.onMove = onMove
if (content) {
this.show(content)
}
}
/** Setup popup with a dictionary of content types and contents.
* @param {Object} content - A dict object with type strings (text, img, html) as keys
* and corresponding values.
* @return {Popup} this
*/
setup(content) {
//console.log("Popup.setup", this.draggable)
this.content = content
this.items = {}
this.element = document.createElement('div')
this.element.classList.add('popup')
this.setAlpha(this.element, 0)
// this.element.style.opacity = 0
Elements.addClass(this.element, 'unselectable')
this.notch = document.createElement('div')
Elements.setStyle(this.notch, this.notchStyle())
this.notch.className = 'notch'
this.setupDraggable()
if (this.closeIcon) {
let img = document.createElement('img')
img.setAttribute('draggable', false)
img.src = this.closeIcon
img.style.position = 'absolute'
img.style.right = '0px'
img.style.top = '0px'
img.style.width = '16px'
img.style.height = '16px'
img.onclick = e => {
this.close()
}
this.element.appendChild(img)
}
if (this.resizeIcon) {
let img = document.createElement('img')
img.style.position = 'absolute'
img.style.right = '0px'
img.style.bottom = '0px'
img.style.width = '16px'
img.style.height = '16px'
img.src = this.resizeIcon
img.setAttribute('draggable', true)
img.ondragstart = e => {
this.currentPos = { x: e.clientX, y: e.clientY }
return true
}
img.ondrag = e => {
e.preventDefault()
let target = this.element.querySelector('iframe') || this.element
let delta = {
x: e.clientX - this.currentPos.x,
y: e.clientY - this.currentPos.y
}
this.currentPos = { x: e.clientX, y: e.clientY }
if (delta.x == 0 && delta.y == 0) return
let rect = target.getBoundingClientRect()
let width = rect.width + delta.x
let height = rect.height + delta.y
target.style.width = width + 'px'
target.style.height = height + 'px'
switch (this.notchPosition) {
case 'bottomLeft':
case 'bottomCenter':
let bottom = parseFloat(this.element.style.bottom)
this.element.style.bottom = bottom - delta.y + 'px'
break
default:
break
}
//console.log("onResize", this.onResize)
if (this.onResize) {
this.onResize({ target, delta, width, height })
}
}
img.ondragend = e => {}
this.element.appendChild(img)
}
for (let key in content) {
switch (key) {
case 'selector':
break
case 'text':
let text = document.createElement('span')
this.element.appendChild(text)
text.innerHTML = content[key]
Elements.setStyle(text, { color: this.normalColor })
Elements.addClass(text, 'unselectable')
Elements.addClass(text, 'PopupContent')
this.insertedNode = text
this.loaded = true
break
case 'img':
alert('img to be implemented')
break
case 'iframe':
let iframe = document.createElement('iframe')
iframe.setAttribute('frameBorder', 0)
iframe.src = content[key]
iframe.onload = e => {
let body = iframe.contentWindow.document.body
let observer = new MutationObserver(() => {
this.iframeChanged(iframe)
})
observer.observe(iframe.contentWindow.document, {
attributes: true,
subtree: true,
childList: true,
characterData: true
})
let w = Math.max(body.scrollWidth, body.offsetWidth)
let h = Math.max(body.scrollHeight, body.offsetHeight)
iframe.style.width = w + 'px'
iframe.style.height = h + 'px'
this.layoutAfterInsert()
if (this.onload != null) {
this.onload()
}
this.loaded = true
}
this.element.appendChild(iframe)
Elements.addClass(iframe, 'PopupContent')
this.insertIntoDOM()
return
case 'html':
this.loaded = false
let div = document.createElement('div')
Elements.addClass(div, 'PopupContent')
this.element.appendChild(div)
div.innerHTML = content.html
//console.log("insert", content)
let selector = content.selector
if (selector) {
this.insertedNode = div.querySelector(selector)
if (this.insertedNode == null) {
div.innerHTML = `<p style="color:red;">Popup content not found. Missing ${selector}</p>`
this.insertedNode = div.firstElementChild
}
} else {
this.insertedNode = div.firstElementChild || div
}
this.setAlpha(this.insertedNode, 0)
let images = this.element.querySelectorAll('img')
let total = 0
if (images.length > 0) {
let count = 0
for (let image of images) {
if (!image.complete && !image.src.startsWith('data:')) {
total += 1
console.log('image not complete', image.src)
image.onload = e => {
count += 1
if (count == total) {
this.loaded = true
if (this.onload != null) {
this.onload()
}
}
}
}
}
}
if (total == 0) {
this.loaded = true
}
break
case 'node':
this.loaded = true
Elements.addClass(content.node, 'PopupContent')
this.element.appendChild(content.node)
this.insertedNode = content.node
this.setAlpha(this.insertedNode, 0)
break
default:
alert('Unexpected content type: ' + key)
break
}
}
this.insertIntoDOM()
this.layoutAfterInsert()
this.setupEventHandler()
return this
}
handleClose(e) {
let closing = this.closingEvent(e)
if (closing) {
this.close()
} else {
this.setupCloseHandler()
}
}
setupCloseHandler() {
let close = this.handleClose
if (this.hideOnUp) {
if (window.PointerEvent)
this.parent.addEventListener('pointerup', close.bind(this), {
capture: true,
once: true
})
else if (window.TouchEvent)
this.parent.addEventListener('touchend', close.bind(this), {
capture: true,
once: true
})
else
this.parent.addEventListener('mouseup', close.bind(this), {
capture: true,
once: true
})
} else {
if (window.PointerEvent)
this.parent.addEventListener('pointerdown', close.bind(this), {
capture: true,
once: true
})
else if (window.TouchEvent)
this.parent.addEventListener('touchstart', close.bind(this), {
capture: true,
once: true
})
else
this.parent.addEventListener('mousedown', close.bind(this), {
capture: true,
once: true
})
}
}
setupEventHandler() {
if (this.autoClose) {
this.setupCloseHandler()
}
}
closingEvent(e) {
if (this.interactive) {
let node = e.target.closest('.PopupContent')
return node == null
}
return true
}
iframeChanged(iframe) {
let body = iframe.contentWindow.document.body
let w = Math.max(body.scrollWidth, body.offsetWidth)
let h = Math.max(body.scrollHeight, body.offsetHeight)
iframe.style.width = w + 'px'
iframe.style.height = h + 'px'
}
setupDraggable() {
if (this.draggable) {
let target = this.element
target.setAttribute('draggable', true)
target.ondragstart = e => {
this.currentPos = { x: e.clientX, y: e.clientY }
var img = document.createElement('img')
img.src = 'data:image/gifbase64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
e.dataTransfer.setDragImage(img, 0, 0)
}
target.ondrag = e => {
e.preventDefault()
let delta = {
x: e.clientX - this.currentPos.x,
y: e.clientY - this.currentPos.y
}
this.currentPos = { x: e.clientX, y: e.clientY }
let left = parseFloat(target.style.left)
let top = parseFloat(target.style.top)
target.style.left = left + delta.x + 'px'
target.style.top = top + delta.y + 'px'
//console.log("Popup.ondrag", target, event.target)
if (this.onMove) {
this.onMove({ target, delta })
}
this.lastDrag = { left, top }
}
target.ondragend = e => {
target.style.left = this.lastDrag.left + 'px'
target.style.top = this.lastDrag.top + 'px'
this.currentPos = null
}
}
}
moveDragged(target) {}
insertIntoDOM(layout = true) {
this.setAlpha(this.insertedNode, 0)
this.element.appendChild(this.notch)
this.parent.appendChild(this.element)
}
layoutAfterInsert() {
Elements.setStyle(this.element, this.defaultStyle())
this.layout()
//this.element.style.opacity = 1
}
/** Layout the menu items. Needed only in the subclass.
*/
layout() {}
remove() {
if (this.parent.contains(this.element)) this.parent.removeChild(this.element)
this.unregister(this.context)
}
/** Close and remove the Popup from the DOM tree.
*/
close() {
//console.log("Popup.close", this.closeCommand)
this.unregister(this.context)
if (this.closeCommand) {
this.closeCommand(this, () => this.remove())
} else {
this.remove()
}
}
/**
* Set the alpha value to show or hide the popup. Uses CSS transitions.
* (A former dependency on TweenLite has beeen removed.)
*
* @param {*} targets
* @param {*} value
* @memberof Popup
*/
setAlpha(targets, value) {
let objs = targets instanceof Array ? targets : [targets]
for (let obj of objs) {
if (value) {
obj.style.transition = 'opacity 0.2s ease-in'
}
obj.style.opacity = value
}
// if (value) {
// TweenLite.to(targets, 0.2, { autoAlpha: value })
// }
// else {
// TweenLite.set(targets, { autoAlpha: 0 })
// }
}
/**
* Starts a fade in animation.
*
* @memberof Popup
*/
fadeIn() {
this.setAlpha([this.element, this.insertedNode], 1)
}
/** Shows the Popup with the given commands at the specified point.
* @param {Object} content - A dict object with type strings (text, img, html) as keys
* and corresponding values.
* @param {Point} point - The position as x, y coordinates {px}.
* @return {Popup} this
*/
showAt(content, point) {
this.setup(content)
console.log('showAt', this.loaded)
if (this.loaded) {
this.placeAt(point)
this.fadeIn()
} else {
this.setAlpha([this.element, this.insertedNode], 0)
this.onload = () => {
this.layoutAfterInsert()
this.placeAt(point)
this.fadeIn()
}
}
return this
}
/**
* Place the origin, i.e. the upper left corner at the given position using CSS styles.
*
* @param {any} x
* @param {any} y
* @memberof Popup
*/
placeOrigin(x, y) {
Elements.setStyle(this.element, { left: x + 'px', top: y + 'px' })
}
/**
* Calculate the local coordinates within the keepWithin container.
*
* @param {any} x
* @param {any} y
* @returns
* @memberof Popup
*/
localPointWithin(x, y, width, height) {
let pt = { x, y }
return pt
}
withinDimensions() {
return {
width: this.keepWithin.offsetWidth,
height: this.keepWithin.offsetHeight
}
}
localDimensions() {
return {
width: this.element.offsetWidth,
height: this.element.offsetHeight
}
}
/**
* Returns the notch position depending on the local coordinates within the keepWithin container
* Divides the space vertically into top, center, bottom and horizontally into left, center, right
*
* @param {any} x
* @param {any} y
* @returns
* @memberof Popup
*/
notchPositionWithin(x, y) {
let horizontal = 'Center'
let vertical = 'center'
let { width, height } = this.withinDimensions()
let local = this.localPointWithin(x, y, width, height)
if (local.y < height * 0.33) {
vertical = 'top'
}
if (local.y > height * 0.66) {
vertical = 'bottom'
}
if (local.x < width * 0.33) {
horizontal = 'Left'
}
if (local.x > width * 0.66) {
horizontal = 'Right'
}
let result = vertical + horizontal
if (result == 'centerCenter') return this.notchPosition
return result
}
placeAt(point) {
let x = point.x
let y = point.y
let notchPosition = this.notchPosition
if (this.keepWithin != null) {
notchPosition = this.notchPositionWithin(x, y)
}
Elements.setStyle(this.notch, this.notchStyle(notchPosition))
this.notch.className = 'notch ' + notchPosition
let { width, height } = this.localDimensions()
//if targetBoundingBox is set, popup is placed next to the rectangle
if (this.targetBoundingBox) {
let bbTop = this.targetBoundingBox.y
let bbBottom = this.targetBoundingBox.y + this.targetBoundingBox.height
let bbLeft = this.targetBoundingBox.x
let bbRight = this.targetBoundingBox.x + this.targetBoundingBox.width
//console.log("place popup with bb set:", x, y, bbTop, bbBottom, bbLeft, bbRight)
switch (notchPosition) {
case 'bottomLeft':
case 'bottomRight':
case 'bottomCenter':
y = bbTop
if (!this.useEventPosWithBoundingBox) x = (bbLeft + bbRight) / 2
break
case 'topLeft':
case 'topRight':
case 'topCenter':
y = bbBottom
if (!this.useEventPosWithBoundingBox) x = (bbLeft + bbRight) / 2
break
case 'centerRight':
x = bbLeft
if (!this.useEventPosWithBoundingBox) y = (bbTop + bbBottom) / 2
break
case 'centerLeft':
x = bbRight
if (!this.useEventPosWithBoundingBox) y = (bbTop + bbBottom) / 2
break
default:
break
}
}
//calculate position depending on several (optional) parameters
switch (notchPosition) {
case 'bottomLeft':
x -= this.padding
x -= this.notchSize
y -= height
y -= this.notchSize * 2
y -= this.posOffset
break
case 'bottomRight':
x -= width
x += this.padding
x += this.notchSize
y -= height
y -= this.notchSize * 2
y -= this.posOffset
break
case 'bottomCenter':
x -= width / 2
y -= height
y -= this.notchSize * 2
y -= this.posOffset
break
case 'topLeft':
x -= this.padding
x -= this.notchSize
y += this.notchSize * 2
y += this.posOffset
break
case 'topRight':
x -= width
x += this.padding
x += this.notchSize
y += this.notchSize * 2
y += this.posOffset
break
case 'topCenter':
x -= width / 2
y += this.notchSize * 2
y += this.posOffset
break
case 'centerRight':
x -= width + this.notchSize * 2
x -= this.posOffset
y -= height / 2
break
case 'centerLeft':
//console.log("height", height)
y -= height / 2
x += this.notchSize * 2
x += this.posOffset
break
default:
break
}
this.placeOrigin(x, y)
}
/** Shows the Popup with the given commands at the current position.
* @param {Object} content - A dict object with type strings (text, img, html) as keys
* and corresponding values.
* @return {Popup} this
*/
show(content) {
this.setup(content)
this.fadeIn()
return this
}
/** Configuration object. Return default styles as CSS values.
*/
defaultStyle() {
let padding = this.padding
let style = {
maxWidth: this.maxWidth + 'px',
zIndex: this.zIndex,
position: 'absolute'
}
if (this.minWidth) {
style.minWidth = this.minWidth + 'px'
}
if (!this.noStyle) {
Object.assign(style, {
borderRadius: Math.round(this.padding / 2) + 'px',
backgroundColor: this.backgroundColor,
padding: this.padding + 'px',
boxShadow: '0 10px 15px rgba(0, 0, 0, 0.3)',
fontFamily: this.fontFamily,
fontSize: this.fontSize,
stroke: 'black',
fill: 'white'
})
}
return style
}
/** Configuration object. Return notch styles as CSS values.
*/
notchStyle(notchPosition = null) {
if (notchPosition == null) {
notchPosition = this.notchPosition
}
let width = 0
let height = 0
let left = this.padding
let size = this.localDimensions()
if (notchPosition.endsWith('Right')) {
left = size.width - this.padding - this.notchSize * 2
}
if (notchPosition.endsWith('Center')) {
left = size.width / 2 - this.notchSize
}
left = Math.round(left) + 'px'
let borderBottom = 0
let borderTop = 0
if (notchPosition.startsWith('bottom')) {
if (this.noStyle) {
return {
width,
height,
left,
bottom: -this.notchSize + 'px',
position: 'absolute',
borderStyle: 'solid',
borderTopWidth: this.notchSize + 'px',
borderRight: this.notchSize + 'px solid transparent',
borderLeft: this.notchSize + 'px solid transparent',
borderBottom: 0
}
} else {
return {
width,
height,
left,
boxShadow: '0 12px 15px rgba(0, 0, 0, 0.1)',
bottom: -this.notchSize + 'px',
position: 'absolute',
borderTop: this.notchSize + 'px solid ' + this.backgroundColor,
borderRight: this.notchSize + 'px solid transparent',
borderLeft: this.notchSize + 'px solid transparent',
borderBottom: 0
}
}
}
if (notchPosition.startsWith('top')) {
if (this.noStyle) {
return {
width,
height,
left,
top: -this.notchSize + 'px',
position: 'absolute',
borderStyle: 'solid',
borderBottomWidth: this.notchSize + 'px',
borderRight: this.notchSize + 'px solid transparent',
borderLeft: this.notchSize + 'px solid transparent',
borderTop: 0
}
} else {
return {
width,
height,
left,
top: -this.notchSize + 'px',
position: 'absolute',
borderBottom: this.notchSize + 'px solid ' + this.backgroundColor,
borderRight: this.notchSize + 'px solid transparent',
borderLeft: this.notchSize + 'px solid transparent',
borderTop: 0
}
}
}
if (this.noStyle) {
if (notchPosition.endsWith('Left')) {
left = -this.notchSize * 2 + 'px'
}
if (notchPosition.endsWith('Right')) {
left = size.width + 'px'
}
let top = size.height / 2 - this.notchSize
top = Math.round(top) + 'px'
return {
width,
height,
left,
top,
borderRightWidth: this.notchSize,
borderLeftWidth: this.notchSize,
position: 'absolute',
borderTop: this.notchSize + 'px solid transparent',
borderBottom: this.notchSize + 'px solid transparent'
}
} else {
let borderRight = this.notchSize + 'px solid transparent'
let borderLeft = this.notchSize + 'px solid transparent'
let top = size.height / 2 - this.notchSize
if (notchPosition.endsWith('Left')) {
left = -this.notchSize * 2 + 'px'
borderRight = this.notchSize + 'px solid ' + this.backgroundColor
this.element.style.boxShadow = '15px 10px 15px rgba(0, 0, 0, 0.3)'
}
if (notchPosition.endsWith('Right')) {
left = size.width + 'px'
borderLeft = this.notchSize + 'px solid ' + this.backgroundColor
this.element.style.boxShadow = '15px 5px 15px rgba(0, 0, 0, 0.3)'
}
top = Math.round(top) + 'px'
return {
width,
height,
left,
top,
borderRight,
borderLeft,
// boxShadow,
position: 'absolute',
borderTop: this.notchSize + 'px solid transparent',
borderBottom: this.notchSize + 'px solid transparent'
}
}
}
/** Convenient static methods to show and reuse a Popup implemented
* as a class variable.
* @param {Object} content - A dict object with type strings (text, img, html) as keys
* and corresponding values.
* @param {Point} point - The position as x, y coordinates {px}.
* @param {boolean} autoClose - Autoclose the menu after selecting an item.
*/
static open(
content,
point,
{
parent = null,
context = window,
fontSize = '1em',
fontFamily = 'Arial',
padding = 16,
notchSize = 10,
switchPos = false,
minWidth = null,
maxWidth = 800,
backgroundColor = '#EEE',
zIndex = 0,
normalColor = '#444',
closeIcon = null,
resizeIcon = null,
closeCommand = null,
autoClose = true,
keepWithin = null,
draggable = false,
posOffset = 0,
targetBoundingBox = null,
useEventPosWithBoundingBox = false,
interactive = false,
onResize = null,
onMove = null
} = {}
) {
let notchPosition = switchPos && point.y < 50 ? 'topCenter' : 'bottomCenter'
let popup = new Popup({
parent,
context,
fontFamily,
fontSize,
padding,
notchSize,
switchPos,
minWidth,
maxWidth,
backgroundColor,
normalColor,
notchPosition,
zIndex,
autoClose,
keepWithin,
closeCommand,
closeIcon,
resizeIcon,
draggable,
posOffset,
targetBoundingBox,
useEventPosWithBoundingBox,
interactive,
onResize,
onMove
})
popup.register(context)
popup.showAt(content, point)
return popup
}
/** Convenient static method to close the Popup implemented
* as a class variable. Calls the close command.
*/
static closePopup(context = window) {
let popup = Poppable.registrations.get(context)
if (popup != null) {
popup.close()
}
}
/** Convenient static methods to remove the Popup implemented
* as a class variable. Removes the popup without performing the close command.
*/
static remove(context = window) {
let popup = Poppable.registrations.get(context)
if (popup != null) {
popup.remove()
}
}
/**
* Convenient static method to compute the clicked rect of objects that have multiple clients rects.
* Needed to position popups correctly above objects with line breaks, e.g. spans
*
* @static
* @param {*} event
* @returns {*} DOMRect
* @memberof Popup
*/
static targetRect(event) {
let target = event.target
let x = event.pageX
let y = event.pageY
for (let rect of target.getClientRects()) {
let withinX = x >= rect.left && x <= rect.left + rect.width
let withinY = y >= rect.top && y <= rect.top + rect.height
if (withinX && withinY) {
return rect
}
}
return null
}
/**
* Convenient static method to compute the center of objects that have multiple clients rects.
* Needed to position popups correctly above objects with line breaks, e.g. spans
*
* @static
* @param {*} event
* @returns {*} Point
* @memberof Popup
*/
static targetCenter(event) {
let target = event.target
let x = event.pageX
let y = event.pageY
let rect = Popup.targetRect(event)
if (rect != null) {
x = rect.left + rect.width / 2
y = rect.top + rect.height / 2
}
return { x, y }
}
}