mirror of
https://gitlab.com/bramw/baserow.git
synced 2024-11-21 23:37:55 +00:00
534 lines
15 KiB
Vue
534 lines
15 KiB
Vue
<template>
|
|
<div
|
|
v-auto-overflow-scroll="open && overflowScroll"
|
|
class="context"
|
|
:class="{
|
|
'visibility-hidden': !open || !updatedOnce,
|
|
'context--overflow-scroll': overflowScroll,
|
|
}"
|
|
>
|
|
<slot v-if="openedOnce"></slot>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import {
|
|
isElement,
|
|
isDomElement,
|
|
onClickOutside,
|
|
} from '@baserow/modules/core/utils/dom'
|
|
|
|
import MoveToBody from '@baserow/modules/core/mixins/moveToBody'
|
|
|
|
export default {
|
|
name: 'Context',
|
|
mixins: [MoveToBody],
|
|
props: {
|
|
hideOnClickOutside: {
|
|
type: Boolean,
|
|
default: true,
|
|
required: false,
|
|
},
|
|
hideOnScroll: {
|
|
type: Boolean,
|
|
default: false,
|
|
required: false,
|
|
},
|
|
hideOnResize: {
|
|
type: Boolean,
|
|
default: false,
|
|
required: false,
|
|
},
|
|
overflowScroll: {
|
|
type: Boolean,
|
|
default: false,
|
|
required: false,
|
|
},
|
|
maxHeightIfOutsideViewport: {
|
|
type: Boolean,
|
|
default: () => false,
|
|
required: false,
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
open: false,
|
|
opener: null,
|
|
updatedOnce: false,
|
|
// If opened once, should stay in DOM to keep nested content
|
|
openedOnce: false,
|
|
maxHeightOffset: 10,
|
|
}
|
|
},
|
|
methods: {
|
|
/**
|
|
* Toggles the open state of the context menu.
|
|
*
|
|
* @param target The original element that changed the state of the
|
|
* context, this will be used to calculate the correct position.
|
|
* @param vertical `bottom` positions the context under the target.
|
|
* `top` positions the context above the target.
|
|
* `over-bottom` positions the context over and under the target.
|
|
* `over-top` positions the context over and above the target.
|
|
* `over` positions the context between top and bottom of the
|
|
* target.
|
|
* @param horizontal `left` aligns the context with the left side of the target.
|
|
* `right` aligns the context with the right side of the target.
|
|
* @param verticalOffset
|
|
* The offset indicates how many pixels the context is moved
|
|
* top from the original calculated position.
|
|
* @param horizontalOffset
|
|
* The offset indicates how many pixels the context is moved
|
|
* left from the original calculated position.
|
|
* @param value True if context must be shown, false if not and undefine
|
|
* will invert the current state.
|
|
*/
|
|
toggle(
|
|
target,
|
|
vertical = 'bottom',
|
|
horizontal = 'left',
|
|
verticalOffset = 10,
|
|
horizontalOffset = 0,
|
|
value
|
|
) {
|
|
if (value === undefined) {
|
|
value = !this.open
|
|
}
|
|
|
|
if (value) {
|
|
return this.show(
|
|
target,
|
|
vertical,
|
|
horizontal,
|
|
verticalOffset,
|
|
horizontalOffset
|
|
)
|
|
} else {
|
|
this.hide()
|
|
}
|
|
},
|
|
/**
|
|
* Calculate the position, show the context menu and register a click event on the
|
|
* body to check if the user has clicked outside the context.
|
|
*/
|
|
async show(
|
|
target,
|
|
vertical,
|
|
horizontal,
|
|
verticalOffset = 10,
|
|
horizontalOffset = 0
|
|
) {
|
|
const isElementOrigin = isDomElement(target)
|
|
const updatePosition = () => {
|
|
const css = isElementOrigin
|
|
? this.calculatePositionElement(
|
|
target,
|
|
vertical,
|
|
horizontal,
|
|
verticalOffset,
|
|
horizontalOffset
|
|
)
|
|
: this.calculatePositionFixed(
|
|
target,
|
|
vertical,
|
|
horizontal,
|
|
verticalOffset,
|
|
horizontalOffset
|
|
)
|
|
|
|
// If the context menu doesn't fit inside the viewport from the opposite.
|
|
// direction, then it will break out of it. We will therefore close it. This can
|
|
// happen the height or width of the viewport decreases.
|
|
if (
|
|
(css.bottom && css.bottom < this.getWindowScrollHeight()) ||
|
|
(css.right && css.right < 0) ||
|
|
(css.top &&
|
|
css.top > window.innerHeight + this.getWindowScrollHeight())
|
|
) {
|
|
this.hide()
|
|
return
|
|
}
|
|
|
|
// Set the calculated positions of the context.
|
|
for (const key in css) {
|
|
const cssValue =
|
|
css[key] !== null ? Math.ceil(css[key]) + 'px' : 'auto'
|
|
this.$el.style[key] = cssValue
|
|
}
|
|
|
|
// The max height can optionally be automatically to prevent the context from
|
|
// breaking out of the viewport.
|
|
if (this.maxHeightIfOutsideViewport) {
|
|
const maxHeight =
|
|
css.top || css.bottom
|
|
? `calc(100vh - ${
|
|
(css.top || css.bottom) +
|
|
this.maxHeightOffset -
|
|
this.getWindowScrollHeight()
|
|
}px)`
|
|
: 'none'
|
|
this.$el.style['max-height'] = maxHeight
|
|
}
|
|
|
|
this.updatedOnce = true
|
|
}
|
|
|
|
// If we store the element who opened the context menu we can exclude the element
|
|
// when clicked outside of this element.
|
|
this.opener = isElementOrigin ? target : null
|
|
|
|
this.open = true
|
|
this.openedOnce = true
|
|
|
|
// Delay the position update to the next tick to let the Context content
|
|
// be available in DOM for accurate positioning.
|
|
await this.$nextTick()
|
|
updatePosition()
|
|
|
|
this.$el.cancelOnClickOutside = onClickOutside(this.$el, (target) => {
|
|
if (
|
|
this.open &&
|
|
// If the prop allows it to be closed by clicking outside.
|
|
this.hideOnClickOutside &&
|
|
// If the click was not on the opener because they can trigger the toggle
|
|
// method.
|
|
!isElement(this.opener, target) &&
|
|
// If the click was not inside one of the context children of this context
|
|
// menu.
|
|
!this.moveToBody.children.some((child) => {
|
|
return isElement(child.$el, target)
|
|
})
|
|
) {
|
|
this.hide()
|
|
}
|
|
})
|
|
|
|
this.$el.updatePositionViaScrollEvent = (event) => {
|
|
if (this.hideOnScroll) {
|
|
this.hide()
|
|
} else if (
|
|
// The context menu itself can have a scrollbar, and resizing everytime you
|
|
// scroll internally doesn't make sense because it can't influence the position.
|
|
!isElement(this.$el, event.target) &&
|
|
// If the scroll was not inside one of the context children of this context
|
|
// menu.
|
|
!this.moveToBody.children.some((child) => {
|
|
return isElement(child.$el, target)
|
|
})
|
|
) {
|
|
updatePosition()
|
|
}
|
|
}
|
|
window.addEventListener(
|
|
'scroll',
|
|
this.$el.updatePositionViaScrollEvent,
|
|
true
|
|
)
|
|
|
|
this.$el.updatePositionViaResizeEvent = () => {
|
|
if (this.hideOnResize) {
|
|
this.hide()
|
|
} else {
|
|
updatePosition()
|
|
}
|
|
}
|
|
window.addEventListener('resize', this.$el.updatePositionViaResizeEvent)
|
|
|
|
this.$emit('shown')
|
|
},
|
|
/**
|
|
* Toggles context menu next to mouse when click event has happened
|
|
*/
|
|
toggleNextToMouse(
|
|
clickEvent,
|
|
vertical = 'bottom',
|
|
horizontal = 'left',
|
|
verticalOffset = 10,
|
|
horizontalOffset = 0,
|
|
value = true
|
|
) {
|
|
this.toggle(
|
|
{
|
|
top: clickEvent.pageY,
|
|
left: clickEvent.pageX,
|
|
},
|
|
vertical,
|
|
horizontal,
|
|
verticalOffset,
|
|
horizontalOffset,
|
|
value
|
|
)
|
|
},
|
|
/**
|
|
* Shows context menu next to mouse when click event has happened
|
|
*/
|
|
showNextToMouse(
|
|
clickEvent,
|
|
vertical = 'bottom',
|
|
horizontal = 'left',
|
|
verticalOffset = 10,
|
|
horizontalOffset = 0
|
|
) {
|
|
this.show(
|
|
{
|
|
top: clickEvent.pageY,
|
|
left: clickEvent.pageX,
|
|
},
|
|
vertical,
|
|
horizontal,
|
|
verticalOffset,
|
|
horizontalOffset
|
|
)
|
|
},
|
|
/**
|
|
* Forces the child elements to render by setting `openedOnce` to `true`. This
|
|
* could be useful when children of the context must be accessed before the context
|
|
* has been opened.
|
|
*/
|
|
forceRender() {
|
|
this.openedOnce = true
|
|
},
|
|
/**
|
|
* Hide the context menu and make sure the body event is removed.
|
|
*/
|
|
hide(emit = true) {
|
|
this.opener = null
|
|
this.open = false
|
|
|
|
if (emit) {
|
|
this.$emit('hidden')
|
|
}
|
|
|
|
// If the context menu was never opened, it doesn't have the
|
|
// `cancelOnClickOutside`, so we can't call it.
|
|
if (
|
|
Object.prototype.hasOwnProperty.call(this.$el, 'cancelOnClickOutside')
|
|
) {
|
|
this.$el.cancelOnClickOutside()
|
|
}
|
|
window.removeEventListener(
|
|
'scroll',
|
|
this.$el.updatePositionViaScrollEvent,
|
|
true
|
|
)
|
|
window.removeEventListener(
|
|
'resize',
|
|
this.$el.updatePositionViaResizeEvent
|
|
)
|
|
},
|
|
/**
|
|
* Calculates the absolute position of the context based on the original clicked
|
|
* element. If the target element is not visible, it might mean that we can't
|
|
* figure out the correct position, so in that case we force the element to be
|
|
* visible.
|
|
*/
|
|
calculatePositionElement(
|
|
target,
|
|
vertical,
|
|
horizontal,
|
|
verticalOffset,
|
|
horizontalOffset
|
|
) {
|
|
const visible =
|
|
window.getComputedStyle(target).getPropertyValue('display') !== 'none'
|
|
|
|
// If the target is not visible then we can't calculate the position, so we
|
|
// temporarily need to show the element forcefully.
|
|
if (!visible) {
|
|
target.classList.add('forced-block')
|
|
}
|
|
|
|
const targetRect = target.getBoundingClientRect()
|
|
const positions = this.calculatePositions(
|
|
horizontal,
|
|
vertical,
|
|
targetRect.top,
|
|
targetRect.right,
|
|
targetRect.bottom,
|
|
targetRect.left,
|
|
verticalOffset,
|
|
horizontalOffset
|
|
)
|
|
|
|
if (!visible) {
|
|
target.classList.remove('forced-block')
|
|
}
|
|
|
|
return positions
|
|
},
|
|
/**
|
|
* Calculates the desired position based on the provided coordinates. For now this
|
|
* is only used by the row context menu, but because of the reserved space of the
|
|
* grid on the right and bottom there is always room for the context. Therefore we
|
|
* do not need to check if the context fits.
|
|
*/
|
|
calculatePositionFixed(
|
|
coordinates,
|
|
vertical,
|
|
horizontal,
|
|
verticalOffset,
|
|
horizontalOffset
|
|
) {
|
|
const targetTop = coordinates.top
|
|
const targetLeft = coordinates.left
|
|
// The bottom and right must be equal to the top and left because when calculating
|
|
// the position fixed, it's a mouseclick which just has an x and y coordinate and
|
|
// is not an element with a width and height.
|
|
const targetBottom = coordinates.top
|
|
const targetRight = coordinates.left
|
|
|
|
const positions = this.calculatePositions(
|
|
horizontal,
|
|
vertical,
|
|
targetTop,
|
|
targetRight,
|
|
targetBottom,
|
|
targetLeft,
|
|
verticalOffset,
|
|
horizontalOffset
|
|
)
|
|
|
|
return positions
|
|
},
|
|
/**
|
|
* Calculates the optimal positions based on the chosen position, target and offset.
|
|
*/
|
|
calculatePositions(
|
|
horizontal,
|
|
vertical,
|
|
targetTop,
|
|
targetRight,
|
|
targetBottom,
|
|
targetLeft,
|
|
verticalOffset,
|
|
horizontalOffset
|
|
) {
|
|
const { vertical: verticalAdjusted, horizontal: horizontalAdjusted } =
|
|
this.checkForEdges(
|
|
{
|
|
top: targetTop,
|
|
left: targetLeft,
|
|
bottom: targetBottom,
|
|
right: targetRight,
|
|
},
|
|
vertical,
|
|
horizontal,
|
|
verticalOffset,
|
|
horizontalOffset
|
|
)
|
|
|
|
const positions = { top: null, right: null, bottom: null, left: null }
|
|
|
|
// Calculate the correct positions for horizontal and vertical values.
|
|
if (horizontalAdjusted === 'left') {
|
|
positions.left = targetLeft + horizontalOffset
|
|
}
|
|
|
|
if (horizontalAdjusted === 'right') {
|
|
positions.right = window.innerWidth - targetRight - horizontalOffset
|
|
}
|
|
|
|
if (verticalAdjusted === 'bottom') {
|
|
positions.top =
|
|
targetBottom + verticalOffset + this.getWindowScrollHeight()
|
|
}
|
|
|
|
if (verticalAdjusted === 'over-bottom' || verticalAdjusted === 'over') {
|
|
positions.top =
|
|
targetTop + verticalOffset + this.getWindowScrollHeight()
|
|
}
|
|
|
|
if (verticalAdjusted === 'top') {
|
|
positions.bottom =
|
|
window.innerHeight -
|
|
targetTop +
|
|
verticalOffset +
|
|
this.getWindowScrollHeight()
|
|
}
|
|
|
|
if (verticalAdjusted === 'over-top' || verticalAdjusted === 'over') {
|
|
positions.bottom = window.innerHeight - targetBottom + verticalOffset
|
|
}
|
|
|
|
// Round position otherwise sometimes it breaks, especially when using
|
|
// Browser zoom
|
|
return Object.fromEntries(
|
|
Object.entries(positions).map(([key, value]) => [
|
|
key,
|
|
Number.isFinite(value) ? Math.round(value) : value,
|
|
])
|
|
)
|
|
},
|
|
/**
|
|
* Checks if we need to adjust the horizontal/vertical value of where the context
|
|
* menu will be placed. This might happen if the screen size would cause the context
|
|
* to clip out of the screen if positioned in a certain position.
|
|
*
|
|
* @returns {{horizontal: string, vertical: string}}
|
|
*/
|
|
checkForEdges(
|
|
targetRect,
|
|
vertical,
|
|
horizontal,
|
|
verticalOffset,
|
|
horizontalOffset
|
|
) {
|
|
const contextRect = this.$el.getBoundingClientRect()
|
|
// We need to use the scrollHeight in the calculations because we need to work
|
|
// with the full height of the element without scrollbar to calculate the optimal
|
|
// position.
|
|
const scrollHeight = this.$el.scrollHeight
|
|
const canTop =
|
|
targetRect.top -
|
|
scrollHeight -
|
|
verticalOffset +
|
|
this.getWindowScrollHeight() >
|
|
0
|
|
const canBottom =
|
|
window.innerHeight +
|
|
this.getWindowScrollHeight() -
|
|
targetRect.bottom -
|
|
scrollHeight -
|
|
this.maxHeightOffset -
|
|
1 -
|
|
verticalOffset >
|
|
0
|
|
const canRight =
|
|
targetRect.right - contextRect.width - horizontalOffset > 0
|
|
const canLeft =
|
|
window.innerWidth -
|
|
targetRect.left -
|
|
contextRect.width -
|
|
horizontalOffset >
|
|
0
|
|
|
|
// If bottom, top, left or right doesn't fit, but their opposite does we switch to
|
|
// that.
|
|
if (vertical === 'bottom' && !canBottom && canTop) {
|
|
vertical = 'top'
|
|
}
|
|
|
|
if (vertical === 'top' && !canTop && canBottom) {
|
|
vertical = 'bottom'
|
|
}
|
|
|
|
if (horizontal === 'left' && !canLeft && canRight) {
|
|
horizontal = 'right'
|
|
}
|
|
|
|
if (horizontal === 'right' && !canRight && canLeft) {
|
|
horizontal = 'left'
|
|
}
|
|
|
|
return { vertical, horizontal }
|
|
},
|
|
getWindowScrollHeight() {
|
|
return window?.scrollY || 0
|
|
},
|
|
isOpen() {
|
|
return this.open
|
|
},
|
|
},
|
|
}
|
|
</script>
|