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 = `

Popup content not found. Missing ${selector}

` 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 } } }