vue3-grid-layout-next
Version:
A draggable and resizable grid layout, as a Vue component.
686 lines (621 loc) • 19.7 kB
text/typescript
export interface LayoutItemRequired {
w: number
h: number
x: number
y: number
i: string
}
export type LayoutItem = LayoutItemRequired & {
minW?: number
minH?: number
maxW?: number
maxH?: number
moved?: boolean
static?: boolean
isDraggable?: boolean
isResizable?: boolean
}
export type Layout = Array<LayoutItem>
// export type Position = {left: number, top: number, width: number, height: number};
/*
export type DragCallbackData = {
node: HTMLElement,
x: number, y: number,
deltaX: number, deltaY: number,
lastX: number, lastY: number
};
*/
// export type DragEvent = {e: Event} & DragCallbackData;
export type Size = {width: number; height: number}
// export type ResizeEvent = {e: Event, node: HTMLElement, size: Size};
// const isProduction = process.env.NODE_ENV === 'production';
/**
* Return the bottom coordinate of the layout.
*
* @param {Array} layout Layout array.
* @return {Number} Bottom coordinate.
*/
export function bottom(layout: Layout): number {
let max = 0,
bottomY
for (let i = 0, len = layout.length; i < len; i++) {
bottomY = layout[i].y + layout[i].h
if (bottomY > max) max = bottomY
}
return max
}
export function cloneLayout(layout: Layout): Layout {
const newLayout = Array(layout.length)
for (let i = 0, len = layout.length; i < len; i++) {
newLayout[i] = cloneLayoutItem(layout[i])
}
return newLayout
}
// Fast path to cloning, since this is monomorphic
export function cloneLayoutItem(layoutItem: LayoutItem): LayoutItem {
/*return {
w: layoutItem.w, h: layoutItem.h, x: layoutItem.x, y: layoutItem.y, i: layoutItem.i,
minW: layoutItem.minW, maxW: layoutItem.maxW, minH: layoutItem.minH, maxH: layoutItem.maxH,
moved: Boolean(layoutItem.moved), static: Boolean(layoutItem.static),
// These can be null
isDraggable: layoutItem.isDraggable, isResizable: layoutItem.isResizable
};*/
return JSON.parse(JSON.stringify(layoutItem))
}
/**
* Given two layoutitems, check if they collide.
*
* @return {Boolean} True if colliding.
*/
export function collides(l1: LayoutItem, l2: LayoutItem): boolean {
if (l1 === l2) return false // same element
if (l1.x + l1.w <= l2.x) return false // l1 is left of l2
if (l1.x >= l2.x + l2.w) return false // l1 is right of l2
if (l1.y + l1.h <= l2.y) return false // l1 is above l2
if (l1.y >= l2.y + l2.h) return false // l1 is below l2
return true // boxes overlap
}
/**
* Given a layout, compact it. This involves going down each y coordinate and removing gaps
* between items.
*
* @param {Array} layout Layout.
* @param {Boolean} verticalCompact Whether or not to compact the layout
* vertically.
* @param {Object} minPositions
* @return {Array} Compacted Layout.
*/
export function compact(layout: Layout, verticalCompact: boolean, minPositions?: any): Layout {
// Statics go in the compareWith array right away so items flow around them.
const compareWith = getStatics(layout)
// We go through the items by row and column.
const sorted = sortLayoutItemsByRowCol(layout)
// Holding for new items.
const out = Array(layout.length)
for (let i = 0, len = sorted.length; i < len; i++) {
let l = sorted[i]
// Don't move static elements
if (!l.static) {
l = compactItem(compareWith, l, verticalCompact, minPositions)
// Add to comparison array. We only collide with items before this one.
// Statics are already in this array.
compareWith.push(l)
}
// Add to output array to make sure they still come out in the right order.
out[layout.indexOf(l)] = l
// Clear moved flag, if it exists.
l.moved = false
}
return out
}
/**
* Compact an item in the layout.
*/
export function compactItem(
compareWith: Layout,
l: LayoutItem,
verticalCompact: boolean,
minPositions?: any
): LayoutItem {
if (verticalCompact) {
// Move the element up as far as it can go without colliding.
while (l.y > 0 && !getFirstCollision(compareWith, l)) {
l.y--
}
} else if (minPositions) {
const minY = minPositions[l.i].y
while (l.y > minY && !getFirstCollision(compareWith, l)) {
l.y--
}
}
// Move it down, and keep moving it down if it's colliding.
let collides
while ((collides = getFirstCollision(compareWith, l))) {
l.y = collides.y + collides.h
}
return l
}
/**
* Given a layout, make sure all elements fit within its bounds.
*
* @param {Array} layout Layout array.
* @param {Number} bounds Number of columns.
*/
export function correctBounds(layout: Layout, bounds: {cols: number}): Layout {
const collidesWith = getStatics(layout)
for (let i = 0, len = layout.length; i < len; i++) {
const l = layout[i]
// Overflows right
if (l.x + l.w > bounds.cols) l.x = bounds.cols - l.w
// Overflows left
if (l.x < 0) {
l.x = 0
l.w = bounds.cols
}
if (!l.static) collidesWith.push(l)
else {
// If this is static and collides with other statics, we must move it down.
// We have to do something nicer than just letting them overlap.
while (getFirstCollision(collidesWith, l)) {
l.y++
}
}
}
return layout
}
/**
* Get a layout item by ID. Used so we can override later on if necessary.
*
* @param {Array} layout Layout array.
* @param {String} id ID
* @return {LayoutItem} Item at ID.
*/
export function getLayoutItem(
layout: Layout,
id: string | number | undefined
): LayoutItem | undefined {
for (let i = 0, len = layout.length; i < len; i++) {
if (layout[i].i === id) return layout[i]
}
}
/**
* Returns the first item this layout collides with.
* It doesn't appear to matter which order we approach this from, although
* perhaps that is the wrong thing to do.
*
* @param {Object} layoutItem Layout item.
* @return {Object|undefined} A colliding layout item, or undefined.
*/
export function getFirstCollision(layout: Layout, layoutItem: LayoutItem): LayoutItem | undefined {
for (let i = 0, len = layout.length; i < len; i++) {
if (collides(layout[i], layoutItem)) return layout[i]
}
}
export function getAllCollisions(layout: Layout, layoutItem: LayoutItem): Array<LayoutItem> {
return layout.filter(l => collides(l, layoutItem))
}
/**
* Get all static elements.
* @param {Array} layout Array of layout objects.
* @return {Array} Array of static layout items..
*/
export function getStatics(layout: Layout): Array<LayoutItem> {
//return [];
return layout.filter(l => l.static)
}
/**
* Move an element. Responsible for doing cascading movements of other elements.
*
* @param {Array} layout Full layout to modify.
* @param {LayoutItem} l element to move.
* @param {Number} [x] X position in grid units.
* @param {Number} [y] Y position in grid units.
* @param {Boolean} [isUserAction] If true, designates that the item we're moving is
* being dragged/resized by th euser.
*/
export function moveElement(
layout: Layout,
l: LayoutItem,
x: number | undefined,
y: number | undefined,
isUserAction?: boolean,
preventCollision?: boolean
): Layout {
if (l.static) return layout
// Short-circuit if nothing to do.
//if (l.y === y && l.x === x) return layout;
const oldX = l.x
const oldY = l.y
const movingUp = y && l.y > y
// This is quite a bit faster than extending the object
if (typeof x === "number") l.x = x
if (typeof y === "number") l.y = y
l.moved = true
// If this collides with anything, move it.
// When doing this comparison, we have to sort the items we compare with
// to ensure, in the case of multiple collisions, that we're getting the
// nearest collision.
let sorted = sortLayoutItemsByRowCol(layout)
if (movingUp) sorted = sorted.reverse()
const collisions = getAllCollisions(sorted, l)
if (preventCollision && collisions.length) {
l.x = oldX
l.y = oldY
l.moved = false
return layout
}
// Move each item that collides away from this element.
for (let i = 0, len = collisions.length; i < len; i++) {
const collision = collisions[i]
// console.log('resolving collision between', l.i, 'at', l.y, 'and', collision.i, 'at', collision.y);
// Short circuit so we can't infinite loop
if (collision.moved) continue
// This makes it feel a bit more precise by waiting to swap for just a bit when moving up.
if (l.y > collision.y && l.y - collision.y > collision.h / 4) continue
// Don't move static items - we have to move *this* element away
if (collision.static) {
layout = moveElementAwayFromCollision(layout, collision, l, isUserAction)
} else {
layout = moveElementAwayFromCollision(layout, l, collision, isUserAction)
}
}
return layout
}
/**
* This is where the magic needs to happen - given a collision, move an element away from the collision.
* We attempt to move it up if there's room, otherwise it goes below.
*
* @param {Array} layout Full layout to modify.
* @param {LayoutItem} collidesWith Layout item we're colliding with.
* @param {LayoutItem} itemToMove Layout item we're moving.
* @param {Boolean} [isUserAction] If true, designates that the item we're moving is being dragged/resized
* by the user.
*/
export function moveElementAwayFromCollision(
layout: Layout,
collidesWith: LayoutItem,
itemToMove: LayoutItem,
isUserAction?: boolean
): Layout {
const preventCollision = false // we're already colliding
// If there is enough space above the collision to put this element, move it there.
// We only do this on the main collision as this can get funky in cascades and cause
// unwanted swapping behavior.
if (isUserAction) {
// Make a mock item so we don't modify the item here, only modify in moveElement.
const fakeItem: LayoutItem = {
x: itemToMove.x,
y: itemToMove.y,
w: itemToMove.w,
h: itemToMove.h,
i: "-1"
}
fakeItem.y = Math.max(collidesWith.y - itemToMove.h, 0)
if (!getFirstCollision(layout, fakeItem)) {
return moveElement(layout, itemToMove, undefined, fakeItem.y, preventCollision)
}
}
/*
layout: Layout,
l: LayoutItem,
x: number,
y: number,
isUserAction: boolean,
preventCollision: boolean
*/
// Previously this was optimized to move below the collision directly, but this can cause problems
// with cascading moves, as an item may actually leapflog a collision and cause a reversal in order.
return moveElement(layout, itemToMove, undefined, itemToMove.y + 1, preventCollision)
}
/**
* Helper to convert a number to a percentage string.
*
* @param {Number} num Any number
* @return {String} That number as a percentage.
*/
export function perc(num: number): string {
return num * 100 + "%"
}
export interface TransformStyle {
transform: string
WebkitTransform: string
MozTransform: string
msTransform: string
OTransform: string
width: string
height: string
position: "absolute" | "relative"
}
export function setTransform(
top: number,
left: number,
width: number,
height: number
): TransformStyle {
// Replace unitless items with px
const translate = "translate3d(" + left + "px," + top + "px, 0)"
return {
transform: translate,
WebkitTransform: translate,
MozTransform: translate,
msTransform: translate,
OTransform: translate,
width: width + "px",
height: height + "px",
position: "absolute"
}
}
/**
* Just like the setTransform method, but instead it will return a negative value of right.
*
* @param top
* @param right
* @param width
* @param height
* @returns {{transform: string, WebkitTransform: string, MozTransform: string, msTransform: string, OTransform: string, width: string, height: string, position: string}}
*/
export function setTransformRtl(
top: number,
right: number,
width: number,
height: number
): TransformStyle {
// Replace unitless items with px
const translate = "translate3d(" + right * -1 + "px," + top + "px, 0)"
return {
transform: translate,
WebkitTransform: translate,
MozTransform: translate,
msTransform: translate,
OTransform: translate,
width: width + "px",
height: height + "px",
position: "absolute"
}
}
export interface TopLeftStyle {
top: string
left: string
width: string
height: string
position: "absolute"
}
export function setTopLeft(top: number, left: number, width: number, height: number): TopLeftStyle {
return {
top: top + "px",
left: left + "px",
width: width + "px",
height: height + "px",
position: "absolute"
}
}
/**
* Just like the setTopLeft method, but instead, it will return a right property instead of left.
*
* @param top
* @param right
* @param width
* @param height
* @returns {{top: string, right: string, width: string, height: string, position: string}}
*/
export interface TopRightStyle {
top: string
right: string
width: string
height: string
position: string
}
export function setTopRight(
top: number,
right: number,
width: number,
height: number
): TopRightStyle {
return {
top: top + "px",
right: right + "px",
width: width + "px",
height: height + "px",
position: "absolute"
}
}
/**
* Get layout items sorted from top left to right and down.
*
* @return {Array} Array of layout objects.
* @return {Array} Layout, sorted static items first.
*/
export function sortLayoutItemsByRowCol(layout: Layout): Layout {
const a: Array<LayoutItem> = []
return a.concat(layout).sort(function (a, b) {
if (a.y === b.y && a.x === b.x) {
return 0
}
if (a.y > b.y || (a.y === b.y && a.x > b.x)) {
return 1
}
return -1
})
}
/**
* Generate a layout using the initialLayout and children as a template.
* Missing entries will be added, extraneous ones will be truncated.
*
* @param {Array} initialLayout Layout passed in through props.
* @param {String} breakpoint Current responsive breakpoint.
* @param {Boolean} verticalCompact Whether or not to compact the layout vertically.
* @return {Array} Working layout.
*/
/*
export function synchronizeLayoutWithChildren(initialLayout: Layout, children: Array<React.Element>|React.Element,
cols: number, verticalCompact: boolean): Layout {
// ensure 'children' is always an array
if (!Array.isArray(children)) {
children = [children];
}
initialLayout = initialLayout || [];
// Generate one layout item per child.
let layout: Layout = [];
for (let i = 0, len = children.length; i < len; i++) {
let newItem;
const child = children[i];
// Don't overwrite if it already exists.
const exists = getLayoutItem(initialLayout, child.key || "1" /!* FIXME satisfies Flow *!/);
if (exists) {
newItem = exists;
} else {
const g = child.props._grid;
// Hey, this item has a _grid property, use it.
if (g) {
if (!isProduction) {
validateLayout([g], 'ReactGridLayout.children');
}
// Validated; add it to the layout. Bottom 'y' possible is the bottom of the layout.
// This allows you to do nice stuff like specify {y: Infinity}
if (verticalCompact) {
newItem = cloneLayoutItem({...g, y: Math.min(bottom(layout), g.y), i: child.key});
} else {
newItem = cloneLayoutItem({...g, y: g.y, i: child.key});
}
}
// Nothing provided: ensure this is added to the bottom
else {
newItem = cloneLayoutItem({w: 1, h: 1, x: 0, y: bottom(layout), i: child.key || "1"});
}
}
layout[i] = newItem;
}
// Correct the layout.
layout = correctBounds(layout, {cols: cols});
layout = compact(layout, verticalCompact);
return layout;
}
*/
/**
* Validate a layout. Throws errors.
*
* @param {Array} layout Array of layout items.
* @param {String} [contextName] Context name for errors.
* @throw {Error} Validation error.
*/
export function validateLayout(layout: Layout, contextName?: string): void {
contextName = contextName || "Layout"
const subProps = ["x", "y", "w", "h"]
const keyArr = []
if (!Array.isArray(layout)) throw new Error(contextName + " must be an array!")
for (let i = 0, len = layout.length; i < len; i++) {
const item = layout[i]
for (let j = 0; j < subProps.length; j++) {
if (typeof item[subProps[j]] !== "number") {
throw new Error(
"VueGridLayout: " + contextName + "[" + i + "]." + subProps[j] + " must be a number!"
)
}
}
if (item.i === undefined || item.i === null) {
throw new Error("VueGridLayout: " + contextName + "[" + i + "].i cannot be null!")
}
if (typeof item.i !== "number" && typeof item.i !== "string") {
throw new Error("VueGridLayout: " + contextName + "[" + i + "].i must be a string or number!")
}
if (keyArr.indexOf(item.i) >= 0) {
throw new Error("VueGridLayout: " + contextName + "[" + i + "].i must be unique!")
}
keyArr.push(item.i)
if (item.static !== undefined && typeof item.static !== "boolean") {
throw new Error("VueGridLayout: " + contextName + "[" + i + "].static must be a boolean!")
}
}
}
// Flow can't really figure this out, so we just use Object
export function autoBindHandlers(el: HTMLElement, fns: Array<string>): void {
fns.forEach(key => (el[key] = el[key].bind(el)))
}
/**
* Convert a JS object to CSS string. Similar to React's output of CSS.
* @param obj
* @returns {string}
*/
interface JSStyle {
[key: string]: string
}
export function createMarkup(obj: JSStyle): string {
const keys = Object.keys(obj)
if (!keys.length) return ""
let i
const len = keys.length
let result = ""
for (i = 0; i < len; i++) {
const key = keys[i]
const val = obj[key]
result += hyphenate(key) + ":" + addPx(key, val) + ";"
}
return result
}
/* The following list is defined in React's core */
export const IS_UNITLESS = {
animationIterationCount: true,
boxFlex: true,
boxFlexGroup: true,
boxOrdinalGroup: true,
columnCount: true,
flex: true,
flexGrow: true,
flexPositive: true,
flexShrink: true,
flexNegative: true,
flexOrder: true,
gridRow: true,
gridColumn: true,
fontWeight: true,
lineClamp: true,
lineHeight: true,
opacity: true,
order: true,
orphans: true,
tabSize: true,
widows: true,
zIndex: true,
zoom: true,
// SVG-related properties
fillOpacity: true,
stopOpacity: true,
strokeDashoffset: true,
strokeOpacity: true,
strokeWidth: true
}
/**
* Will add px to the end of style values which are Numbers.
* @param name
* @param value
* @returns {*}
*/
export function addPx(name: string, value: number | string) {
if (typeof value === "number" && !IS_UNITLESS[name]) {
return value + "px"
} else {
return value
}
}
/**
* Hyphenate a camelCase string.
*
* @param {String} str
* @return {String}
*/
export const hyphenateRE = /([a-z\d])([A-Z])/g
export function hyphenate(str: string) {
return str.replace(hyphenateRE, "$1-$2").toLowerCase()
}
export function findItemInArray(array: Array<any>, property: string, value: any) {
for (let i = 0; i < array.length; i++) if (array[i][property] == value) return true
return false
}
export function findAndRemove(array: Array<any>, property: string, value: any) {
array.forEach(function (result, index) {
if (result[property] === value) {
//Remove from array
array.splice(index, 1)
}
})
}