UNPKG

@katoid/angular-grid-layout

Version:

Grid Layout with draggable and resizable items for Angular

1,123 lines (1,109 loc) 107 kB
import { iif, fromEvent, merge, Observable, Subject, BehaviorSubject, NEVER, interval, animationFrameScheduler, combineLatest, of } from 'rxjs'; import { filter, switchMap, startWith, exhaustMap, takeUntil, take, map, tap, distinctUntilChanged } from 'rxjs/operators'; import * as i0 from '@angular/core'; import { InjectionToken, Directive, Input, Injectable, Inject, ElementRef, Component, ChangeDetectionStrategy, ContentChildren, ViewChild, ContentChild, HostBinding, EventEmitter, ViewEncapsulation, Output, NgModule } from '@angular/core'; import { DOCUMENT } from '@angular/common'; /** * IMPORTANT: * This utils are taken from the project: https://github.com/STRML/react-grid-layout. * The code should be as less modified as possible for easy maintenance. */ const DEBUG = false; /** * Return the bottom coordinate of the layout. * * @param {Array} layout Layout array. * @return {Number} Bottom coordinate. */ function bottom(layout) { 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; } function cloneLayout(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 /** NOTE: This code has been modified from the original source */ function cloneLayoutItem(layoutItem) { const clonedLayoutItem = { w: layoutItem.w, h: layoutItem.h, x: layoutItem.x, y: layoutItem.y, id: layoutItem.id, moved: !!layoutItem.moved, static: !!layoutItem.static, }; if (layoutItem.minW !== undefined) { clonedLayoutItem.minW = layoutItem.minW; } if (layoutItem.maxW !== undefined) { clonedLayoutItem.maxW = layoutItem.maxW; } if (layoutItem.minH !== undefined) { clonedLayoutItem.minH = layoutItem.minH; } if (layoutItem.maxH !== undefined) { clonedLayoutItem.maxH = layoutItem.maxH; } // These can be null if (layoutItem.isDraggable !== undefined) { clonedLayoutItem.isDraggable = layoutItem.isDraggable; } if (layoutItem.isResizable !== undefined) { clonedLayoutItem.isResizable = layoutItem.isResizable; } return clonedLayoutItem; } /** * Given two layoutitems, check if they collide. */ function collides(l1, l2) { if (l1.id === l2.id) { 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. * @return {Array} Compacted Layout. */ function compact(layout, compactType, cols) { // 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 = sortLayoutItems(layout, compactType); // Holding for new items. const out = Array(layout.length); for (let i = 0, len = sorted.length; i < len; i++) { let l = cloneLayoutItem(sorted[i]); // Don't move static elements if (!l.static) { l = compactItem(compareWith, l, compactType, cols, sorted); // 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(sorted[i])] = l; // Clear moved flag, if it exists. l.moved = false; } return out; } const heightWidth = { x: 'w', y: 'h' }; /** * Before moving item down, it will check if the movement will cause collisions and move those items down before. */ function resolveCompactionCollision(layout, item, moveToCoord, axis) { const sizeProp = heightWidth[axis]; item[axis] += 1; const itemIndex = layout .map(layoutItem => { return layoutItem.id; }) .indexOf(item.id); // Go through each item we collide with. for (let i = itemIndex + 1; i < layout.length; i++) { const otherItem = layout[i]; // Ignore static items if (otherItem.static) { continue; } // Optimization: we can break early if we know we're past this el // We can do this b/c it's a sorted layout if (otherItem.y > item.y + item.h) { break; } if (collides(item, otherItem)) { resolveCompactionCollision(layout, otherItem, moveToCoord + item[sizeProp], axis); } } item[axis] = moveToCoord; } /** * Compact an item in the layout. */ function compactItem(compareWith, l, compactType, cols, fullLayout) { const compactV = compactType === 'vertical'; const compactH = compactType === 'horizontal'; if (compactV) { // Bottom 'y' possible is the bottom of the layout. // This allows you to do nice stuff like specify {y: Infinity} // This is here because the layout must be sorted in order to get the correct bottom `y`. l.y = Math.min(bottom(compareWith), l.y); // Move the element up as far as it can go without colliding. while (l.y > 0 && !getFirstCollision(compareWith, l)) { l.y--; } } else if (compactH) { // Move the element left as far as it can go without colliding. while (l.x > 0 && !getFirstCollision(compareWith, l)) { l.x--; } } // Move it down, and keep moving it down if it's colliding. let collides; while ((collides = getFirstCollision(compareWith, l))) { if (compactH) { resolveCompactionCollision(fullLayout, l, collides.x + collides.w, 'x'); } else { resolveCompactionCollision(fullLayout, l, collides.y + collides.h, 'y'); } // Since we can't grow without bounds horizontally, if we've overflown, let's move it down and try again. if (compactH && l.x + l.w > cols) { l.x = cols - l.w; l.y++; // ALso move element as left as much as we can (ktd-custom-change) while (l.x > 0 && !getFirstCollision(compareWith, l)) { l.x--; } } } // Ensure that there are no negative positions l.y = Math.max(l.y, 0); l.x = Math.max(l.x, 0); return l; } /** * Given a layout, make sure all elements fit within its bounds. * * @param {Array} layout Layout array. * @param {Number} bounds Number of columns. */ function correctBounds(layout, bounds) { 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. */ function getLayoutItem(layout, id) { for (let i = 0, len = layout.length; i < len; i++) { if (layout[i].id === id) { return layout[i]; } } return null; } /** * 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. */ function getFirstCollision(layout, layoutItem) { for (let i = 0, len = layout.length; i < len; i++) { if (collides(layout[i], layoutItem)) { return layout[i]; } } return null; } function getAllCollisions(layout, 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.. */ function getStatics(layout) { 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. */ function moveElement(layout, l, x, y, isUserAction, preventCollision, compactType, cols) { // If this is static and not explicitly enabled as draggable, // no move is possible, so we can short-circuit this immediately. if (l.static && l.isDraggable !== true) { return layout; } // Short-circuit if nothing to do. if (l.y === y && l.x === x) { return layout; } log(`Moving element ${l.id} to [${String(x)},${String(y)}] from [${l.x},${l.y}]`); const oldX = l.x; const oldY = l.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 = sortLayoutItems(layout, compactType); const movingUp = compactType === 'vertical' && typeof y === 'number' ? oldY >= y : compactType === 'horizontal' && typeof x === 'number' ? oldX >= x : false; if (movingUp) { sorted = sorted.reverse(); } const collisions = getAllCollisions(sorted, l); // There was a collision; abort if (preventCollision && collisions.length) { log(`Collision prevented on ${l.id}, reverting.`); 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]; log(`Resolving collision between ${l.id} at [${l.x},${l.y}] and ${collision.id} at [${collision.x},${collision.y}]`); // Short circuit so we can't infinite loop if (collision.moved) { continue; } // Don't move static items - we have to move *this* element away if (collision.static) { layout = moveElementAwayFromCollision(layout, collision, l, isUserAction, compactType, cols); } else { layout = moveElementAwayFromCollision(layout, l, collision, isUserAction, compactType, cols); } } 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. */ function moveElementAwayFromCollision(layout, collidesWith, itemToMove, isUserAction, compactType, cols) { const compactH = compactType === 'horizontal'; // Compact vertically if not set to horizontal const compactV = compactType !== 'horizontal'; const preventCollision = collidesWith.static; // we're already colliding (not for static items) // 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) { // Reset isUserAction flag because we're not in the main collision anymore. isUserAction = false; // Make a mock item so we don't modify the item here, only modify in moveElement. const fakeItem = { x: compactH ? Math.max(collidesWith.x - itemToMove.w, 0) : itemToMove.x, y: compactV ? Math.max(collidesWith.y - itemToMove.h, 0) : itemToMove.y, w: itemToMove.w, h: itemToMove.h, id: '-1', }; // No collision? If so, we can go up there; otherwise, we'll end up moving down as normal if (!getFirstCollision(layout, fakeItem)) { log(`Doing reverse collision on ${itemToMove.id} up to [${fakeItem.x},${fakeItem.y}].`); return moveElement(layout, itemToMove, compactH ? fakeItem.x : undefined, compactV ? fakeItem.y : undefined, isUserAction, preventCollision, compactType, cols); } } return moveElement(layout, itemToMove, compactH ? itemToMove.x + 1 : undefined, compactV ? itemToMove.y + 1 : undefined, isUserAction, preventCollision, compactType, cols); } /** * Helper to convert a number to a percentage string. * * @param {Number} num Any number * @return {String} That number as a percentage. */ function perc(num) { return num * 100 + '%'; } function setTransform({ top, left, width, height }) { // Replace unitless items with px const translate = `translate(${left}px,${top}px)`; return { transform: translate, WebkitTransform: translate, MozTransform: translate, msTransform: translate, OTransform: translate, width: `${width}px`, height: `${height}px`, position: 'absolute', }; } function setTopLeft({ top, left, width, height }) { return { top: `${top}px`, left: `${left}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. */ function sortLayoutItems(layout, compactType) { if (compactType === 'horizontal') { return sortLayoutItemsByColRow(layout); } else { return sortLayoutItemsByRowCol(layout); } } function sortLayoutItemsByRowCol(layout) { return [].concat(layout).sort(function (a, b) { if (a.y > b.y || (a.y === b.y && a.x > b.x)) { return 1; } else if (a.y === b.y && a.x === b.x) { // Without this, we can get different sort results in IE vs. Chrome/FF return 0; } return -1; }); } function sortLayoutItemsByColRow(layout) { return [].concat(layout).sort(function (a, b) { if (a.x > b.x || (a.x === b.x && a.y > b.y)) { return 1; } return -1; }); } /** * Validate a layout. Throws errors. * * @param {Array} layout Array of layout items. * @param {String} [contextName] Context name for errors. * @throw {Error} Validation error. */ function validateLayout(layout, contextName = 'Layout') { const subProps = ['x', 'y', 'w', 'h']; 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('ReactGridLayout: ' + contextName + '[' + i + '].' + subProps[j] + ' must be a number!'); } } if (item.id && typeof item.id !== 'string') { throw new Error('ReactGridLayout: ' + contextName + '[' + i + '].i must be a string!'); } if (item.static !== undefined && typeof item.static !== 'boolean') { throw new Error('ReactGridLayout: ' + contextName + '[' + i + '].static must be a boolean!'); } } } // Flow can't really figure this out, so we just use Object function autoBindHandlers(el, fns) { fns.forEach(key => (el[key] = el[key].bind(el))); } function log(...args) { if (!DEBUG) { return; } // eslint-disable-next-line no-console console.log(...args); } const noop = () => { }; /** Cached result of whether the user's browser supports passive event listeners. */ let supportsPassiveEvents; /** * Checks whether the user's browser supports passive event listeners. * See: https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md */ function ktdSupportsPassiveEventListeners() { if (supportsPassiveEvents == null && typeof window !== 'undefined') { try { window.addEventListener('test', null, Object.defineProperty({}, 'passive', { get: () => supportsPassiveEvents = true })); } finally { supportsPassiveEvents = supportsPassiveEvents || false; } } return supportsPassiveEvents; } /** * Normalizes an `AddEventListener` object to something that can be passed * to `addEventListener` on any browser, no matter whether it supports the * `options` parameter. * @param options Object to be normalized. */ function ktdNormalizePassiveListenerOptions(options) { return ktdSupportsPassiveEventListeners() ? options : !!options.capture; } /** Options that can be used to bind a passive event listener. */ const passiveEventListenerOptions = ktdNormalizePassiveListenerOptions({ passive: true }); /** Options that can be used to bind an active event listener. */ const activeEventListenerOptions = ktdNormalizePassiveListenerOptions({ passive: false }); let isMobile = null; function ktdIsMobileOrTablet() { if (isMobile != null) { return isMobile; } // Generic match pattern to identify mobile or tablet devices const isMobileDevice = /Android|webOS|BlackBerry|Windows Phone|iPad|iPhone|iPod/i.test(navigator.userAgent); // Since IOS 13 is not safe to just check for the generic solution. See: https://stackoverflow.com/questions/58019463/how-to-detect-device-name-in-safari-on-ios-13-while-it-doesnt-show-the-correct const isIOSMobileDevice = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); isMobile = isMobileDevice || isIOSMobileDevice; return isMobile; } function ktdIsMouseEvent(event) { return event.clientX != null; } function ktdIsTouchEvent(event) { return event.touches != null && event.touches.length != null; } function ktdPointerClientX(event) { return ktdIsMouseEvent(event) ? event.clientX : event.touches[0].clientX; } function ktdPointerClientY(event) { return ktdIsMouseEvent(event) ? event.clientY : event.touches[0].clientY; } function ktdPointerClient(event) { return { clientX: ktdIsMouseEvent(event) ? event.clientX : event.touches[0].clientX, clientY: ktdIsMouseEvent(event) ? event.clientY : event.touches[0].clientY }; } function ktdIsMouseEventOrMousePointerEvent(event) { return event.type === 'mousedown' || (event.type === 'pointerdown' && event.pointerType === 'mouse'); } /** Returns true if browser supports pointer events */ function ktdSupportsPointerEvents() { return !!window.PointerEvent; } /** * Emits when a mousedown or touchstart emits. Avoids conflicts between both events. * @param element, html element where to listen the events. * @param touchNumber number of the touch to track the event, default to the first one. */ function ktdMouseOrTouchDown(element, touchNumber = 1) { return iif(() => ktdIsMobileOrTablet(), fromEvent(element, 'touchstart', passiveEventListenerOptions).pipe(filter((touchEvent) => touchEvent.touches.length === touchNumber)), fromEvent(element, 'mousedown', activeEventListenerOptions).pipe(filter((mouseEvent) => { /** * 0 : Left mouse button * 1 : Wheel button or middle button (if present) * 2 : Right mouse button */ return mouseEvent.button === 0; // Mouse down to be only fired if is left click }))); } /** * Emits when a 'mousemove' or a 'touchmove' event gets fired. * @param element, html element where to listen the events. * @param touchNumber number of the touch to track the event, default to the first one. */ function ktdMouseOrTouchMove(element, touchNumber = 1) { return iif(() => ktdIsMobileOrTablet(), fromEvent(element, 'touchmove', activeEventListenerOptions).pipe(filter((touchEvent) => touchEvent.touches.length === touchNumber)), fromEvent(element, 'mousemove', activeEventListenerOptions)); } function ktdTouchEnd(element, touchNumber = 1) { return merge(fromEvent(element, 'touchend').pipe(filter((touchEvent) => touchEvent.touches.length === touchNumber - 1)), fromEvent(element, 'touchcancel').pipe(filter((touchEvent) => touchEvent.touches.length === touchNumber - 1))); } /** * Emits when a there is a 'mouseup' or the touch ends. * @param element, html element where to listen the events. * @param touchNumber number of the touch to track the event, default to the first one. */ function ktdMouserOrTouchEnd(element, touchNumber = 1) { return iif(() => ktdIsMobileOrTablet(), ktdTouchEnd(element, touchNumber), fromEvent(element, 'mouseup')); } /** * Emits when a 'pointerdown' event occurs (only for the primary pointer). Fallbacks to 'mousemove' or a 'touchmove' if pointer events are not supported. * @param element, html element where to listen the events. */ function ktdPointerDown(element) { if (!ktdSupportsPointerEvents()) { return ktdMouseOrTouchDown(element); } return fromEvent(element, 'pointerdown', activeEventListenerOptions).pipe(filter((pointerEvent) => pointerEvent.isPrimary)); } /** * Emits when a 'pointermove' event occurs (only for the primary pointer). Fallbacks to 'mousemove' or a 'touchmove' if pointer events are not supported. * @param element, html element where to listen the events. */ function ktdPointerMove(element) { if (!ktdSupportsPointerEvents()) { return ktdMouseOrTouchMove(element); } return fromEvent(element, 'pointermove', activeEventListenerOptions).pipe(filter((pointerEvent) => pointerEvent.isPrimary)); } /** * Emits when a 'pointerup' event occurs (only for the primary pointer). Fallbacks to 'mousemove' or a 'touchmove' if pointer events are not supported. * @param element, html element where to listen the events. */ function ktdPointerUp(element) { if (!ktdSupportsPointerEvents()) { return ktdMouserOrTouchEnd(element); } return fromEvent(element, 'pointerup').pipe(filter(pointerEvent => pointerEvent.isPrimary)); } /** Tracks items by id. This function is mean to be used in conjunction with the ngFor that renders the 'ktd-grid-items' */ function ktdTrackById(index, item) { return item.id; } /** Given a layout, the gridHeight and the gap return the resulting rowHeight */ function ktdGetGridItemRowHeight(layout, gridHeight, gap) { const numberOfRows = layout.reduce((acc, cur) => Math.max(acc, Math.max(cur.y + cur.h, 0)), 0); const gapTotalHeight = (numberOfRows - 1) * gap; const gridHeightMinusGap = gridHeight - gapTotalHeight; return gridHeightMinusGap / numberOfRows; } /** * Call react-grid-layout utils 'compact()' function and return the compacted layout. * @param layout to be compacted. * @param compactType, type of compaction. * @param cols, number of columns of the grid. */ function ktdGridCompact(layout, compactType, cols) { return compact(layout, compactType, cols) // Prune react-grid-layout compact extra properties. .map(item => ({ id: item.id, x: item.x, y: item.y, w: item.w, h: item.h, minW: item.minW, minH: item.minH, maxW: item.maxW, maxH: item.maxH })); } function screenXToGridX(screenXPos, cols, width, gap) { if (cols <= 1) { return 0; } const totalGapsWidth = gap * (cols - 1); const totalItemsWidth = width - totalGapsWidth; const itemPlusGapWidth = totalItemsWidth / cols + gap; return Math.round(screenXPos / itemPlusGapWidth); } function screenYToGridY(screenYPos, rowHeight, height, gap) { return Math.round(screenYPos / (rowHeight + gap)); } function screenWidthToGridWidth(gridScreenWidth, cols, width, gap) { const widthMinusGaps = width - (gap * (cols - 1)); const itemWidth = widthMinusGaps / cols; const gridScreenWidthMinusFirst = gridScreenWidth - itemWidth; return Math.round(gridScreenWidthMinusFirst / (itemWidth + gap)) + 1; } function screenHeightToGridHeight(gridScreenHeight, rowHeight, height, gap) { const gridScreenHeightMinusFirst = gridScreenHeight - rowHeight; return Math.round(gridScreenHeightMinusFirst / (rowHeight + gap)) + 1; } /** Returns a Dictionary where the key is the id and the value is the change applied to that item. If no changes Dictionary is empty. */ function ktdGetGridLayoutDiff(gridLayoutA, gridLayoutB) { const diff = {}; gridLayoutA.forEach(itemA => { const itemB = gridLayoutB.find(_itemB => _itemB.id === itemA.id); if (itemB != null) { const posChanged = itemA.x !== itemB.x || itemA.y !== itemB.y; const sizeChanged = itemA.w !== itemB.w || itemA.h !== itemB.h; const change = posChanged && sizeChanged ? 'moveresize' : posChanged ? 'move' : sizeChanged ? 'resize' : null; if (change) { diff[itemB.id] = { change }; } } }); return diff; } /** * Given the grid config & layout data and the current drag position & information, returns the corresponding layout and drag item position * @param gridItem grid item that is been dragged * @param config current grid configuration * @param compactionType type of compaction that will be performed * @param draggingData contains all the information about the drag */ function ktdGridItemDragging(gridItem, config, compactionType, draggingData) { const { pointerDownEvent, pointerDragEvent, gridElemClientRect, dragElemClientRect, scrollDifference } = draggingData; const gridItemId = gridItem.id; const draggingElemPrevItem = config.layout.find(item => item.id === gridItemId); const clientStartX = ktdPointerClientX(pointerDownEvent); const clientStartY = ktdPointerClientY(pointerDownEvent); const clientX = ktdPointerClientX(pointerDragEvent); const clientY = ktdPointerClientY(pointerDragEvent); const offsetX = clientStartX - dragElemClientRect.left; const offsetY = clientStartY - dragElemClientRect.top; // Grid element positions taking into account the possible scroll total difference from the beginning. const gridElementLeftPosition = gridElemClientRect.left + scrollDifference.left; const gridElementTopPosition = gridElemClientRect.top + scrollDifference.top; // Calculate position relative to the grid element. const gridRelXPos = clientX - gridElementLeftPosition - offsetX; const gridRelYPos = clientY - gridElementTopPosition - offsetY; const rowHeightInPixels = config.rowHeight === 'fit' ? ktdGetGridItemRowHeight(config.layout, config.height ?? gridElemClientRect.height, config.gap) : config.rowHeight; // Get layout item position const layoutItem = { ...draggingElemPrevItem, x: screenXToGridX(gridRelXPos, config.cols, gridElemClientRect.width, config.gap), y: screenYToGridY(gridRelYPos, rowHeightInPixels, gridElemClientRect.height, config.gap) }; // Correct the values if they overflow, since 'moveElement' function doesn't do it layoutItem.x = Math.max(0, layoutItem.x); layoutItem.y = Math.max(0, layoutItem.y); if (layoutItem.x + layoutItem.w > config.cols) { layoutItem.x = Math.max(0, config.cols - layoutItem.w); } // Parse to LayoutItem array data in order to use 'react.grid-layout' utils const layoutItems = config.layout; const draggedLayoutItem = layoutItems.find(item => item.id === gridItemId); let newLayoutItems = moveElement(layoutItems, draggedLayoutItem, layoutItem.x, layoutItem.y, true, config.preventCollision, compactionType, config.cols); newLayoutItems = compact(newLayoutItems, compactionType, config.cols); return { layout: newLayoutItems, draggedItemPos: { top: gridRelYPos, left: gridRelXPos, width: dragElemClientRect.width, height: dragElemClientRect.height, } }; } /** * Given the grid config & layout data and the current drag position & information, returns the corresponding layout and drag item position * @param gridItem grid item that is been dragged * @param config current grid configuration * @param compactionType type of compaction that will be performed * @param draggingData contains all the information about the drag */ function ktdGridItemResizing(gridItem, config, compactionType, draggingData) { const { pointerDownEvent, pointerDragEvent, gridElemClientRect, dragElemClientRect, scrollDifference } = draggingData; const gridItemId = gridItem.id; const clientStartX = ktdPointerClientX(pointerDownEvent); const clientStartY = ktdPointerClientY(pointerDownEvent); const clientX = ktdPointerClientX(pointerDragEvent); const clientY = ktdPointerClientY(pointerDragEvent); // Get the difference between the mouseDown and the position 'right' of the resize element. const resizeElemOffsetX = dragElemClientRect.width - (clientStartX - dragElemClientRect.left); const resizeElemOffsetY = dragElemClientRect.height - (clientStartY - dragElemClientRect.top); const draggingElemPrevItem = config.layout.find(item => item.id === gridItemId); const width = clientX + resizeElemOffsetX - (dragElemClientRect.left + scrollDifference.left); const height = clientY + resizeElemOffsetY - (dragElemClientRect.top + scrollDifference.top); const rowHeightInPixels = config.rowHeight === 'fit' ? ktdGetGridItemRowHeight(config.layout, config.height ?? gridElemClientRect.height, config.gap) : config.rowHeight; // Get layout item grid position const layoutItem = { ...draggingElemPrevItem, w: screenWidthToGridWidth(width, config.cols, gridElemClientRect.width, config.gap), h: screenHeightToGridHeight(height, rowHeightInPixels, gridElemClientRect.height, config.gap) }; layoutItem.w = limitNumberWithinRange(layoutItem.w, gridItem.minW ?? layoutItem.minW, gridItem.maxW ?? layoutItem.maxW); layoutItem.h = limitNumberWithinRange(layoutItem.h, gridItem.minH ?? layoutItem.minH, gridItem.maxH ?? layoutItem.maxH); if (layoutItem.x + layoutItem.w > config.cols) { layoutItem.w = Math.max(1, config.cols - layoutItem.x); } if (config.preventCollision) { const maxW = layoutItem.w; const maxH = layoutItem.h; let colliding = hasCollision(config.layout, layoutItem); let shrunkDimension; while (colliding) { shrunkDimension = getDimensionToShrink(layoutItem, shrunkDimension); layoutItem[shrunkDimension]--; colliding = hasCollision(config.layout, layoutItem); } if (shrunkDimension === 'w') { layoutItem.h = maxH; colliding = hasCollision(config.layout, layoutItem); while (colliding) { layoutItem.h--; colliding = hasCollision(config.layout, layoutItem); } } if (shrunkDimension === 'h') { layoutItem.w = maxW; colliding = hasCollision(config.layout, layoutItem); while (colliding) { layoutItem.w--; colliding = hasCollision(config.layout, layoutItem); } } } const newLayoutItems = config.layout.map((item) => { return item.id === gridItemId ? layoutItem : item; }); return { layout: compact(newLayoutItems, compactionType, config.cols), draggedItemPos: { top: dragElemClientRect.top - gridElemClientRect.top, left: dragElemClientRect.left - gridElemClientRect.left, width, height, } }; } function hasCollision(layout, layoutItem) { return !!getFirstCollision(layout, layoutItem); } function getDimensionToShrink(layoutItem, lastShrunk) { if (layoutItem.h <= 1) { return 'w'; } if (layoutItem.w <= 1) { return 'h'; } return lastShrunk === 'w' ? 'h' : 'w'; } /** * Given the current number and min/max values, returns the number within the range * @param number can be any numeric value * @param min minimum value of range * @param max maximum value of range */ function limitNumberWithinRange(num, min = 1, max = Infinity) { return Math.min(Math.max(num, min < 1 ? 1 : min), max); } /** Returns true if both item1 and item2 KtdGridLayoutItems are equivalent. */ function ktdGridItemLayoutItemAreEqual(item1, item2) { return item1.id === item2.id && item1.x === item2.x && item1.y === item2.y && item1.w === item2.w && item1.h === item2.h; } /** * Injection token that can be used to reference instances of `KtdGridDragHandle`. It serves as * alternative token to the actual `KtdGridDragHandle` class which could cause unnecessary * retention of the class and its directive metadata. */ const KTD_GRID_DRAG_HANDLE = new InjectionToken('KtdGridDragHandle'); /** Handle that can be used to drag a KtdGridItem instance. */ // eslint-disable-next-line @angular-eslint/directive-class-suffix class KtdGridDragHandle { constructor(element) { this.element = element; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: KtdGridDragHandle, deps: [{ token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "16.2.12", type: KtdGridDragHandle, isStandalone: true, selector: "[ktdGridDragHandle]", host: { classAttribute: "ktd-grid-drag-handle" }, providers: [{ provide: KTD_GRID_DRAG_HANDLE, useExisting: KtdGridDragHandle }], ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: KtdGridDragHandle, decorators: [{ type: Directive, args: [{ standalone: true, selector: '[ktdGridDragHandle]', // eslint-disable-next-line @angular-eslint/no-host-metadata-property host: { class: 'ktd-grid-drag-handle' }, providers: [{ provide: KTD_GRID_DRAG_HANDLE, useExisting: KtdGridDragHandle }], }] }], ctorParameters: function () { return [{ type: i0.ElementRef }]; } }); /** * Injection token that can be used to reference instances of `KtdGridResizeHandle`. It serves as * alternative token to the actual `KtdGridResizeHandle` class which could cause unnecessary * retention of the class and its directive metadata. */ const KTD_GRID_RESIZE_HANDLE = new InjectionToken('KtdGridResizeHandle'); /** Handle that can be used to drag a KtdGridItem instance. */ // eslint-disable-next-line @angular-eslint/directive-class-suffix class KtdGridResizeHandle { constructor(element) { this.element = element; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: KtdGridResizeHandle, deps: [{ token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "16.2.12", type: KtdGridResizeHandle, isStandalone: true, selector: "[ktdGridResizeHandle]", host: { classAttribute: "ktd-grid-resize-handle" }, providers: [{ provide: KTD_GRID_RESIZE_HANDLE, useExisting: KtdGridResizeHandle }], ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: KtdGridResizeHandle, decorators: [{ type: Directive, args: [{ standalone: true, selector: '[ktdGridResizeHandle]', // eslint-disable-next-line @angular-eslint/no-host-metadata-property host: { class: 'ktd-grid-resize-handle' }, providers: [{ provide: KTD_GRID_RESIZE_HANDLE, useExisting: KtdGridResizeHandle }], }] }], ctorParameters: function () { return [{ type: i0.ElementRef }]; } }); /** * Injection token that can be used to reference instances of `KtdGridItemPlaceholder`. It serves as * alternative token to the actual `KtdGridItemPlaceholder` class which could cause unnecessary * retention of the class and its directive metadata. */ const KTD_GRID_ITEM_PLACEHOLDER = new InjectionToken('KtdGridItemPlaceholder'); /** Directive that can be used to create a custom placeholder for a KtdGridItem instance. */ // eslint-disable-next-line @angular-eslint/directive-class-suffix class KtdGridItemPlaceholder { constructor(templateRef) { this.templateRef = templateRef; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: KtdGridItemPlaceholder, deps: [{ token: i0.TemplateRef }], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "16.2.12", type: KtdGridItemPlaceholder, isStandalone: true, selector: "ng-template[ktdGridItemPlaceholder]", inputs: { data: "data" }, host: { classAttribute: "ktd-grid-item-placeholder-content" }, providers: [{ provide: KTD_GRID_ITEM_PLACEHOLDER, useExisting: KtdGridItemPlaceholder }], ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: KtdGridItemPlaceholder, decorators: [{ type: Directive, args: [{ standalone: true, selector: 'ng-template[ktdGridItemPlaceholder]', // eslint-disable-next-line @angular-eslint/no-host-metadata-property host: { class: 'ktd-grid-item-placeholder-content' }, providers: [{ provide: KTD_GRID_ITEM_PLACEHOLDER, useExisting: KtdGridItemPlaceholder }], }] }], ctorParameters: function () { return [{ type: i0.TemplateRef }]; }, propDecorators: { data: [{ type: Input }] } }); /** Coerces a data-bound value (typically a string) to a boolean. */ function coerceBooleanProperty(value) { return value != null && `${value}` !== 'false'; } function coerceNumberProperty(value, fallbackValue = 0) { return _isNumberValue(value) ? Number(value) : fallbackValue; } /** * Whether the provided value is considered a number. * @docs-private */ function _isNumberValue(value) { // parseFloat(value) handles most of the cases we're interested in (it treats null, empty string, // and other non-number values as NaN, where Number just uses 0) but it considers the string // '123hello' to be a valid number. Therefore we also check if Number(value) is NaN. return !isNaN(parseFloat(value)) && !isNaN(Number(value)); } const GRID_ITEM_GET_RENDER_DATA_TOKEN = new InjectionToken('GRID_ITEM_GET_RENDER_DATA_TOKEN'); /** Runs source observable outside the zone */ function ktdOutsideZone(zone) { return (source) => { return new Observable(observer => { return zone.runOutsideAngular(() => source.subscribe(observer)); }); }; } /** Rxjs operator that makes source observable to no emit any data */ function ktdNoEmit() { return (source$) => { return source$.pipe(filter(() => false)); }; } /** Event options that can be used to bind an active, capturing event. */ const activeCapturingEventOptions = ktdNormalizePassiveListenerOptions({ passive: false, capture: true }); class KtdGridService { constructor(ngZone, document) { this.ngZone = ngZone; this.document = document; this.touchMoveSubject = new Subject(); this.touchMove$ = this.touchMoveSubject.asObservable(); this.registerTouchMoveSubscription(); } ngOnDestroy() { this.touchMoveSubscription.unsubscribe(); } mouseOrTouchMove$(element) { if (!ktdSupportsPointerEvents()) { return iif(() => ktdIsMobileOrTablet(), this.touchMove$, fromEvent(element, 'mousemove', activeCapturingEventOptions) // TODO: Fix rxjs typings, boolean should be a good param too. ); } return fromEvent(element, 'pointermove', activeCapturingEventOptions); } registerTouchMoveSubscription() { // The `touchmove` event gets bound once, ahead of time, because WebKit // won't preventDefault on a dynamically-added `touchmove` listener. // See https://bugs.webkit.org/show_bug.cgi?id=184250. this.touchMoveSubscription = this.ngZone.runOutsideAngular(() => // The event handler has to be explicitly active, // because newer browsers make it passive by default. fromEvent(this.document, 'touchmove', activeCapturingEventOptions) // TODO: Fix rxjs typings, boolean should be a good param too. .pipe(filter((touchEvent) => touchEvent.touches.length === 1)) .subscribe((touchEvent) => this.touchMoveSubject.next(touchEvent))); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: KtdGridService, deps: [{ token: i0.NgZone }, { token: DOCUMENT }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: KtdGridService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: KtdGridService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: function () { return [{ type: i0.NgZone }, { type: Document, decorators: [{ type: Inject, args: [DOCUMENT] }] }]; } }); class KtdGridItemComponent { /** Dynamically apply `touch-action` to the host element based on draggable */ get touchAction() { return this._draggable ? 'none' : 'auto'; } /** Id of the grid item. This property is strictly compulsory. */ get id() { return this._id; } set id(val) { this._id = val; } /** Minimum amount of pixels that the user should move before it starts the drag sequence. */ get dragStartThreshold() { return this._dragStartThreshold; } set dragStartThreshold(val) { this._dragStartThreshold = coerceNumberProperty(val); } /** Whether the item is draggable or not. Defaults to true. Does not affect manual dragging using the startDragManually method. */ get draggable() { return this._draggable; } set draggable(val) { this._draggable = coerceBooleanProperty(val); this._draggable$.next(this._draggable); } /** Whether the item is resizable or not. Defaults to true. */ get resizable() { return this._resizable; } set resizable(val) { this._resizable = coerceBooleanProperty(val); this._resizable$.next(this._resizable); } constructor(elementRef, gridService, renderer, ngZone, document, getItemRenderData) { this.elementRef = elementRef; this.gridService = gridService; this.renderer = renderer; this.ngZone = ngZone; this.document = document; this.getItemRenderData = getItemRenderData; /** CSS transition style. Note that for more performance is preferable only make transition on transform property. */ this.transition = 'transform 500ms ease, width 500ms ease, height 500ms ease'; this._dragStartThreshold = 0; this._draggable = true; this._draggable$ = new BehaviorSubject(this._draggable); this._manualDragEvents$ = new Subject(); this._resizable = true; this._resizable$ = new BehaviorSubject(this._resizable); this.dragStartSubject = new Subject(); this.resizeStartSubject = new Subject(); this.subscriptions = []; this.dragStart$ = this.dragStartSubject.asObservable(); this.resizeStart$ = this.resizeStartSubject.asObservable(); } ngOnInit() { const gridItemRenderData = this.getItemRenderData(this.id); this.setStyles(gridItemRenderData); } ngAfterContentInit() { this.subscriptions.push(this._dragStart$().subscribe(this.dragStartSubject), this._resizeStart$().subscribe(this.resizeStartSubject)); } ngOnDestroy() { this.subscriptions.forEach(sub => sub.unsubscribe()); } /** * To manually start dragging, route the desired pointer events to this method. * Dragging initiated by this method will work regardless of the value of the draggable Input. * It is the caller's responsibility to call this method with only the events that are desired to cause a drag. * For example, if you only want left clicks to cause a drag, it is your responsibility to filter out other mouse button events. * @param startEvent The pointer event that should initiate the drag. */ startDragManually(startEvent) { this._manualDragEvents$.next(startEvent); } setStyles({ top, left, width, height }) { // transform is 6x times faster than top/left this.renderer.setStyle(this.elementRef.nativeElement, 'transform', `translateX(${left}) translateY(${top})`); this.renderer.setStyle(this.elementRef.nativeElement, 'display', `block`); this.renderer.setStyle(this.elementRef.nativeElement, 'transition', this.transition); if (width != null) { this.renderer.setStyle(this.elementRef.nativeElement, 'width', width); } if (height != null) { this.renderer.setStyle(this.elementRef.nativeElement, 'height', height); } } _dragStart$() { return merge(this._manualDragEvents$, this._draggable$.pipe(switchMap((draggable) => { if (!draggable) { return NEVER; } return this._dragHandles.changes.pipe(startWith(this._dragHandles), switchMap((dragHandles) => { return iif(() => dragHandles.length > 0, merge(...dragHandles.toArray().map(dragHandle => ktdPointerDown(dragHandle.element.nativeElement))), ktdPointerDown(this.elementRef.nativeElement)); })); }))).pipe(exhaustMap(startEvent => { // If the event started from an element with the native HTML drag&drop, it'll interfere // with our own dragging (e.g. `img` tags do it by default). Prevent the default action // to stop it from happening. Note that preventing on `dragstart` also seems to work, but // it's flaky and it fails if the user drags it away quickly. Also note that we only want // to do this for `mousedown` and `pointerdown` since doing the same for `touchstart` will // stop any `click` events from firing on touch devices. if (ktdIsMouseEventOrMousePointerEvent(startEvent)) { startEvent.preventDefault();