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