UNPKG

@hugogo/angular-grid-layout-hugo

Version:

Grid Layout with draggable and resizable items for Angular

1,171 lines (1,157 loc) 77.2 kB
import { iif, fromEvent, merge, Subject, Observable, BehaviorSubject, NEVER, interval, animationFrameScheduler, combineLatest, of } from 'rxjs'; import { filter, switchMap, startWith, exhaustMap, takeUntil, take, map, tap, distinctUntilChanged } from 'rxjs/operators'; import { InjectionToken, Directive, ElementRef, ɵɵdefineInjectable, ɵɵinject, NgZone, Injectable, Component, ChangeDetectionStrategy, Renderer2, Inject, ContentChildren, ViewChild, Input, EventEmitter, ViewEncapsulation, Output, NgModule } from '@angular/core'; import { CommonModule } 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) { l.y = Math.min(bottom(compareWith), l.y); // 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++; } } 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 }; } /** * 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 ktdMouseOrTouchEnd(element, touchNumber = 1) { return iif(() => ktdIsMobileOrTablet(), ktdTouchEnd(element, touchNumber), fromEvent(element, 'mouseup')); } /** 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; } /** * 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 })); } function screenXPosToGridValue(screenXPos, cols, width) { return Math.round((screenXPos * cols) / width); } function screenYPosToGridValue(screenYPos, rowHeight, height) { return Math.round(screenYPos / rowHeight); } /** 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 gridItemId id of the 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(gridItemId, config, compactionType, draggingData) { const { pointerDownEvent, pointerDragEvent, gridElemClientRect, dragElemClientRect, scrollDifference } = draggingData; 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; // Get layout item position const layoutItem = Object.assign(Object.assign({}, draggingElemPrevItem), { x: screenXPosToGridValue(gridRelXPos, config.cols, gridElemClientRect.width), y: screenYPosToGridValue(gridRelYPos, config.rowHeight, gridElemClientRect.height) }); // 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 gridItemId id of the 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(gridItemId, config, compactionType, draggingData) { const { pointerDownEvent, pointerDragEvent, gridElemClientRect, dragElemClientRect, scrollDifference } = draggingData; 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); // Get layout item grid position const layoutItem = Object.assign(Object.assign({}, draggingElemPrevItem), { w: screenXPosToGridValue(width, config.cols, gridElemClientRect.width), h: screenYPosToGridValue(height, config.rowHeight, gridElemClientRect.height) }); layoutItem.w = Math.max(1, layoutItem.w); layoutItem.h = Math.max(1, layoutItem.h); 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'; } /** * 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. */ // tslint:disable-next-line:directive-class-suffix class KtdGridDragHandle { constructor(element) { this.element = element; } } KtdGridDragHandle.decorators = [ { type: Directive, args: [{ selector: '[ktdGridDragHandle]', host: { class: 'ktd-grid-drag-handle' }, providers: [{ provide: KTD_GRID_DRAG_HANDLE, useExisting: KtdGridDragHandle }], },] } ]; KtdGridDragHandle.ctorParameters = () => [ { type: 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. */ class KtdGridResizeHandle { constructor(element) { this.element = element; } } KtdGridResizeHandle.decorators = [ { type: Directive, args: [{ selector: '[ktdGridResizeHandle]', host: { class: 'ktd-grid-resize-handle' }, providers: [{ provide: KTD_GRID_RESIZE_HANDLE, useExisting: KtdGridResizeHandle }], },] } ]; KtdGridResizeHandle.ctorParameters = () => [ { type: ElementRef } ]; const GRID_ITEM_GET_RENDER_DATA_TOKEN = new InjectionToken('GRID_ITEM_GET_RENDER_DATA_TOKEN'); /** Event options that can be used to bind an active, capturing event. */ const activeCapturingEventOptions = ktdNormalizePassiveListenerOptions({ passive: false, capture: true }); class KtdGridService { constructor(ngZone) { this.ngZone = ngZone; this.touchMoveSubject = new Subject(); this.touchMove$ = this.touchMoveSubject.asObservable(); this.registerTouchMoveSubscription(); } ngOnDestroy() { this.touchMoveSubscription.unsubscribe(); } mouseOrTouchMove$(element) { return iif(() => ktdIsMobileOrTablet(), this.touchMove$, fromEvent(element, 'mousemove', activeCapturingEventOptions) // TODO: Fix rxjs typings, boolean should be a good param too. ); } 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(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))); } } KtdGridService.ɵprov = ɵɵdefineInjectable({ factory: function KtdGridService_Factory() { return new KtdGridService(ɵɵinject(NgZone)); }, token: KtdGridService, providedIn: "root" }); KtdGridService.decorators = [ { type: Injectable, args: [{ providedIn: 'root' },] } ]; KtdGridService.ctorParameters = () => [ { type: NgZone } ]; /** 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)); }; } /** 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)); } class KtdGridItemComponent { constructor(elementRef, gridService, renderer, ngZone, getItemRenderData) { this.elementRef = elementRef; this.gridService = gridService; this.renderer = renderer; this.ngZone = ngZone; 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._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(); } /** 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. */ 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); } 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()); } 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 this._draggable$.pipe(switchMap((draggable) => { if (!draggable) { return NEVER; } else { return this._dragHandles.changes.pipe(startWith(this._dragHandles), switchMap((dragHandles) => { return iif(() => dragHandles.length > 0, merge(...dragHandles.toArray().map(dragHandle => ktdMouseOrTouchDown(dragHandle.element.nativeElement, 1))), ktdMouseOrTouchDown(this.elementRef.nativeElement, 1)).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` since doing the same for `touchstart` will stop any `click` // events from firing on touch devices. if (startEvent.target && startEvent.target.draggable && startEvent.type === 'mousedown') { startEvent.preventDefault(); } const startPointer = ktdPointerClient(startEvent); return this.gridService.mouseOrTouchMove$(document).pipe(takeUntil(ktdMouseOrTouchEnd(document, 1)), ktdOutsideZone(this.ngZone), filter((moveEvent) => { moveEvent.preventDefault(); const movePointer = ktdPointerClient(moveEvent); const distanceX = Math.abs(startPointer.clientX - movePointer.clientX); const distanceY = Math.abs(startPointer.clientY - movePointer.clientY); // When this conditions returns true mean that we are over threshold. return distanceX + distanceY >= this.dragStartThreshold; }), take(1), // Return the original start event map(() => startEvent)); })); })); } })); } _resizeStart$() { return this._resizable$.pipe(switchMap((resizable) => { if (!resizable) { // Side effect to hide the resizeElem if resize is disabled. this.renderer.setStyle(this.resizeElem.nativeElement, 'display', 'none'); return NEVER; } else { return this._resizeHandles.changes.pipe(startWith(this._resizeHandles), switchMap((resizeHandles) => { if (resizeHandles.length > 0) { // Side effect to hide the resizeElem if there are resize handles. this.renderer.setStyle(this.resizeElem.nativeElement, 'display', 'none'); return merge(...resizeHandles.toArray().map(resizeHandle => ktdMouseOrTouchDown(resizeHandle.element.nativeElement, 1))); } else { this.renderer.setStyle(this.resizeElem.nativeElement, 'display', 'block'); return ktdMouseOrTouchDown(this.resizeElem.nativeElement, 1); } })); } })); } } KtdGridItemComponent.decorators = [ { type: Component, args: [{ selector: 'ktd-grid-item', template: "<ng-content></ng-content>\n<div #resizeElem class=\"grid-item-resize-icon\"></div>\n", changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host{display:none;overflow:hidden;z-index:1}:host,:host div{position:absolute}:host div{-moz-user-select:none;-ms-user-select:none;-webkit-user-select:none;user-select:none;z-index:10}:host div.grid-item-resize-icon{bottom:0;color:inherit;cursor:se-resize;height:20px;right:0;width:20px}:host div.grid-item-resize-icon:after{border-bottom:2px solid;border-right:2px solid;bottom:3px;content:\"\";height:5px;position:absolute;right:3px;width:5px}.display-none{display:none!important}"] },] } ]; KtdGridItemComponent.ctorParameters = () => [ { type: ElementRef }, { type: KtdGridService }, { type: Renderer2 }, { type: NgZone }, { type: undefined, decorators: [{ type: Inject, args: [GRID_ITEM_GET_RENDER_DATA_TOKEN,] }] } ]; KtdGridItemComponent.propDecorators = { _dragHandles: [{ type: ContentChildren, args: [KTD_GRID_DRAG_HANDLE, { descendants: true },] }], _resizeHandles: [{ type: ContentChildren, args: [KTD_GRID_RESIZE_HANDLE, { descendants: true },] }], resizeElem: [{ type: ViewChild, args: ['resizeElem', { static: true, read: ElementRef },] }], transition: [{ type: Input }], id: [{ type: Input }], dragStartThreshold: [{ type: Input }], draggable: [{ type: Input }], resizable: [{ type: Input }] }; // tslint:disable /** * Client rect utilities. * This file is taken from Angular Material repository. This is the reason why the tslint is disabled on this case. * Don't enable it until some custom change is done on this file. */ /** Gets a mutable version of an element's bounding `ClientRect`. */ function getMutableClientRect(element) { const clientRect = element.getBoundingClientRect(); // We need to clone the `clientRect` here, because all the values on it are readonly // and we need to be able to update them. Also we can't use a spread here, because // the values on a `ClientRect` aren't own properties. See: // https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect#Notes return { top: clientRect.top, right: clientRect.right, bottom: clientRect.bottom, left: clientRect.left, width: clientRect.width, height: clientRect.height }; } /** * Checks whether some coordinates are within a `ClientRect`. * @param clientRect ClientRect that is being checked. * @param x Coordinates along the X axis. * @param y Coordinates along the Y axis. */ function isInsideClientRect(clientRect, x, y) { const { top, bottom, left, right } = clientRect; return y >= top && y <= bottom && x >= left && x <= right; } /** * Updates the top/left positions of a `ClientRect`, as well as their bottom/right counterparts. * @param clientRect `ClientRect` that should be updated. * @param top Amount to add to the `top` position. * @param left Amount to add to the `left` position. */ function adjustClientRect(clientRect, top, left) { clientRect.top += top; clientRect.bottom = clientRect.top + clientRect.height; clientRect.left += left; clientRect.right = clientRect.left + clientRect.width; } /** * Checks whether the pointer coordinates are close to a ClientRect. * @param rect ClientRect to check against. * @param threshold Threshold around the ClientRect. * @param pointerX Coordinates along the X axis. * @param pointerY Coordinates along the Y axis. */ function isPointerNearClientRect(rect, threshold, pointerX, pointerY) { const { top, right, bottom, left, width, height } = rect; const xThreshold = width * threshold; const yThreshold = height * threshold; return pointerY > top - yThreshold && pointerY < bottom + yThreshold && pointerX > left - xThreshold && pointerX < right + xThreshold; } /** * Proximity, as a ratio to width/height at which to start auto-scrolling. * The value comes from trying it out manually until it feels right. */ const SCROLL_PROXIMITY_THRESHOLD = 0.05; /** * Increments the vertical scroll position of a node. * @param node Node whose scroll position should change. * @param amount Amount of pixels that the `node` should be scrolled. */ function incrementVerticalScroll(node, amount) { if (node === window) { node.scrollBy(0, amount); } else { // Ideally we could use `Element.scrollBy` here as well, but IE and Edge don't support it. node.scrollTop += amount; } } /** * Increments the horizontal scroll position of a node. * @param node Node whose scroll position should change. * @param amount Amount of pixels that the `node` should be scrolled. */ function incrementHorizontalScroll(node, amount) { if (node === window) { node.scrollBy(amount, 0); } else { // Ideally we could use `Element.scrollBy` here as well, but IE and Edge don't support it. node.scrollLeft += amount; } } /** * Gets whether the vertical auto-scroll direction of a node. * @param clientRect Dimensions of the node. * @param pointerY Position of the user's pointer along the y axis. */ function getVerticalScrollDirection(clientRect, pointerY) { const { top, bottom, height } = clientRect; const yThreshold = height * SCROLL_PROXIMITY_THRESHOLD; if (pointerY >= top - yThreshold && pointerY <= top + yThreshold) { return 1 /* UP */; } else if (pointerY >= bottom - yThreshold && pointerY <= bottom + yThreshold) { return 2 /* DOWN */; } return 0 /* NONE */; } /** * Gets whether the horizontal auto-scroll direction of a node. * @param clientRect Dimensions of the node. * @param pointerX Position of the user's pointer along the x axis. */ function getHorizontalScrollDirection(clientRect, pointerX) { const { left, right, width } = clientRect; const xThreshold = width * SCROLL_PROXIMITY_THRESHOLD; if (pointerX >= left - xThreshold && pointerX <= left + xThreshold) { return 1 /* LEFT */; } else if (pointerX >= right - xThreshold && pointerX <= right + xThreshold) { return 2 /* RIGHT */; } return 0 /* NONE */; } /** * Returns an observable that schedules a loop and apply scroll on the scrollNode into the specified direction/s. * This observable doesn't emit, it just performs the 'scroll' side effect. * @param scrollNode, node where the scroll would be applied. * @param verticalScrollDirection, vertical direction of the scroll. * @param horizontalScrollDirection, horizontal direction of the scroll. * @param scrollStep, scroll step in CSS pixels that would be applied in every loop. */ function scrollToDirectionInterval$(scrollNode, verticalScrollDirection, horizontalScrollDirection, scrollStep = 2) { return interval(0, animationFrameScheduler) .pipe(tap(() => { if (verticalScrollDirection === 1 /* UP */) { incrementVerticalScroll(scrollNode, -scrollStep); } else if (verticalScrollDirection === 2 /* DOWN */) { incrementVerticalScroll(scrollNode, scrollStep); } if (hor