/* * Group drag for GridStack (legacy jQuery UI versions and newer releases). * * Usage: * Ctrl+click / Cmd+click - add or remove a card from the selection * Cmd/Ctrl+drag - select cards with a marquee rectangle * Drag a selected card - move the whole selection * Click a card / Escape - clear the selection * * The script is inert until a .grid-stack element with a GridStack instance * appears on the page. */ (function (window, document) { 'use strict'; if (window.GridStackMultiDrag && window.GridStackMultiDrag.version) return; var VERSION = '1.1.0'; var GRID_SELECTOR = '.grid-stack'; var ITEM_SELECTOR = '.grid-stack-item'; var SELECTED_CLASS = 'gs-multidrag-selected'; var FOLLOWER_CLASS = 'gs-multidrag-follower'; var initialized = []; var observer = null; var scanTimer = null; var pollTimer = null; var styleElement = null; function toNumber(value, fallback) { var number = parseFloat(value); return isFinite(number) ? number : fallback; } function directItem(node, gridElement) { while (node && node !== gridElement) { if (node.nodeType === 1 && hasClass(node, 'grid-stack-item') && node.parentNode === gridElement) { return node; } node = node.parentNode; } return null; } function hasClass(element, name) { return !!(element && (' ' + element.className + ' ').indexOf(' ' + name + ' ') !== -1); } function isInteractive(target, item) { while (target && target !== item) { if (target.nodeType === 1) { var tag = target.tagName ? target.tagName.toLowerCase() : ''; if (/^(input|button|select|textarea|a|label)$/.test(tag)) return true; if (hasClass(target, 'ui-resizable-handle') || hasClass(target, 'config-button') || hasClass(target, 'resize-button')) return true; } target = target.parentNode; } return false; } function getGridInstance(gridElement) { if (gridElement.gridstack) return gridElement.gridstack; if (window.jQuery) { return window.jQuery(gridElement).data('gridstack') || null; } return null; } function getNode(element) { if (element.gridstackNode) return element.gridstackNode; if (window.jQuery) { return window.jQuery(element).data('_gridstack_node') || window.jQuery(element).data('gridstackNode') || null; } return null; } function readAttr(element, modernName, legacyName, fallback) { var value = element.getAttribute(modernName); if (value === null) value = element.getAttribute(legacyName); return toNumber(value, fallback); } function readPosition(element) { var node = getNode(element) || {}; return { element: element, x: toNumber(node.x, readAttr(element, 'gs-x', 'data-gs-x', 0)), y: toNumber(node.y, readAttr(element, 'gs-y', 'data-gs-y', 0)), w: toNumber(node.w, toNumber(node.width, readAttr(element, 'gs-w', 'data-gs-width', 1))), h: toNumber(node.h, toNumber(node.height, readAttr(element, 'gs-h', 'data-gs-height', 1))) }; } function directItems(gridElement) { var children = gridElement.children; var result = []; var i; for (i = 0; i < children.length; i += 1) { if (hasClass(children[i], 'grid-stack-item')) result.push(children[i]); } return result; } function setSelected(state, item, selected) { var index = state.selected.indexOf(item); if (selected && index === -1) { state.selected.push(item); item.classList.add(SELECTED_CLASS); item.setAttribute('aria-selected', 'true'); } else if (!selected && index !== -1) { state.selected.splice(index, 1); item.classList.remove(SELECTED_CLASS); item.removeAttribute('aria-selected'); } } function pruneSelection(state) { var copy = state.selected.slice(); var i; for (i = 0; i < copy.length; i += 1) { if (!document.documentElement.contains(copy[i]) || copy[i].parentNode !== state.element) { setSelected(state, copy[i], false); } } } function clearSelection(state) { var copy = state.selected.slice(); var i; for (i = 0; i < copy.length; i += 1) setSelected(state, copy[i], false); } function selectOnly(state, item) { clearSelection(state); setSelected(state, item, true); } function mouseDistance(startX, startY, event) { var dx = event.clientX - startX; var dy = event.clientY - startY; return Math.sqrt(dx * dx + dy * dy); } function createMarqueeBox() { var box = document.createElement('div'); box.className = 'gs-multidrag-marquee'; document.body.appendChild(box); return box; } function marqueeRect(marquee, event) { return { left: Math.min(marquee.startX, event.clientX), top: Math.min(marquee.startY, event.clientY), right: Math.max(marquee.startX, event.clientX), bottom: Math.max(marquee.startY, event.clientY) }; } function rectanglesIntersect(a, b) { return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; } function updateMarquee(state, event) { var marquee = state.marquee; if (!marquee || !marquee.moved) return; var rect = marqueeRect(marquee, event); var box = marquee.box; var items = directItems(state.element); var i; box.style.left = rect.left + 'px'; box.style.top = rect.top + 'px'; box.style.width = (rect.right - rect.left) + 'px'; box.style.height = (rect.bottom - rect.top) + 'px'; for (i = 0; i < items.length; i += 1) { var itemRect = items[i].getBoundingClientRect(); var initiallySelected = marquee.initialSelection.indexOf(items[i]) !== -1; var visible = itemRect.width > 0 && itemRect.height > 0; setSelected(state, items[i], initiallySelected || (visible && rectanglesIntersect(rect, itemRect))); } } function finishMarquee(state, event, cancel) { var marquee = state.marquee; if (!marquee) return; if (!cancel && marquee.moved) updateMarquee(state, event); if (!cancel && !marquee.moved && marquee.startItem) { setSelected(state, marquee.startItem, marquee.initialSelection.indexOf(marquee.startItem) === -1); } if (cancel) { clearSelection(state); for (var i = 0; i < marquee.initialSelection.length; i += 1) { if (marquee.initialSelection[i].parentNode === state.element) { setSelected(state, marquee.initialSelection[i], true); } } } if (marquee.box && marquee.box.parentNode) marquee.box.parentNode.removeChild(marquee.box); document.documentElement.classList.remove('gs-multidrag-selecting'); state.marquee = null; } function startMarquee(state, event, item) { pruneSelection(state); state.pendingPlainClick = null; state.marquee = { startX: event.clientX, startY: event.clientY, startItem: item, initialSelection: state.selected.slice(), moved: false, box: null }; } function onDocumentMouseMove(state, event) { if (state.pendingPlainClick && mouseDistance( state.pendingPlainClick.startX, state.pendingPlainClick.startY, event ) >= 4) { state.pendingPlainClick.moved = true; } if (!state.marquee) return; if (!state.marquee.moved && mouseDistance(state.marquee.startX, state.marquee.startY, event) >= 4) { state.marquee.moved = true; state.marquee.box = createMarqueeBox(); document.documentElement.classList.add('gs-multidrag-selecting'); } if (state.marquee.moved) { event.preventDefault(); updateMarquee(state, event); } } function onDocumentMouseUp(state, event) { if (state.marquee) { finishMarquee(state, event, false); event.preventDefault(); return; } if (state.pendingPlainClick) { var pending = state.pendingPlainClick; state.pendingPlainClick = null; if (!pending.moved && !pending.dragged) clearSelection(state); } } function getColumns(state) { var grid = state.grid; var options = (grid && (grid.opts || grid.options)) || {}; var columns = toNumber(options.column, toNumber(options.width, 0)); var match; if (!columns) { columns = readAttr(state.element, 'gs-column', 'data-gs-width', 0); } if (!columns) { match = state.element.className.match(/(?:^|\s)grid-stack-(\d+)(?:\s|$)/); if (match) columns = toNumber(match[1], 0); } if (!columns && grid) { if (typeof grid.getColumn === 'function') columns = toNumber(grid.getColumn(), 0); else if (typeof grid.column === 'function') columns = toNumber(grid.column(), 0); } if (!columns) { var positions = directItems(state.element).map(readPosition); var max = 0; var i; for (i = 0; i < positions.length; i += 1) { max = Math.max(max, positions[i].x + positions[i].w); } columns = max || 12; } return Math.round(columns); } function getCellWidth(state) { var grid = state.grid; var value = 0; if (grid) { if (typeof grid.cellWidth === 'function') value = toNumber(grid.cellWidth(), 0); else if (typeof grid.cell_width === 'function') value = toNumber(grid.cell_width(), 0); else if (typeof grid.getCellWidth === 'function') value = toNumber(grid.getCellWidth(), 0); } return value || (state.element.getBoundingClientRect().width / getColumns(state)) || 1; } function median(values) { if (!values.length) return 0; values.sort(function (a, b) { return a - b; }); return values[Math.floor(values.length / 2)]; } function getRowPitch(state) { var grid = state.grid; var options = (grid && (grid.opts || grid.options)) || {}; var value = 0; var margin = toNumber(options.verticalMargin, toNumber(options.vertical_margin, 0)); var samples = []; var items = directItems(state.element); var i; /* The rendered top/y ratio includes GridStack's vertical margin and is the most reliable value for heavily customized old grids. */ for (i = 0; i < items.length && samples.length < 20; i += 1) { var position = readPosition(items[i]); var top = toNumber(window.getComputedStyle(items[i]).top, 0); if (position.y > 0 && top > 0) samples.push(top / position.y); } value = median(samples); if (value > 0) return value; if (grid) { if (typeof grid.getCellHeight === 'function') value = toNumber(grid.getCellHeight(true), 0); else if (typeof grid.cellHeight === 'function') value = toNumber(grid.cellHeight(), 0); else if (typeof grid.cell_height === 'function') value = toNumber(grid.cell_height(), 0); } return (value + margin) || 1; } function clampDelta(state, snapshot, dx, dy) { var columns = getColumns(state); var minX = Infinity; var maxX = -Infinity; var minY = Infinity; var maxY = -Infinity; var maxRows = 0; var options = (state.grid && (state.grid.opts || state.grid.options)) || {}; var i; for (i = 0; i < snapshot.length; i += 1) { minX = Math.min(minX, snapshot[i].x); maxX = Math.max(maxX, snapshot[i].x + snapshot[i].w); minY = Math.min(minY, snapshot[i].y); maxY = Math.max(maxY, snapshot[i].y + snapshot[i].h); } dx = Math.max(-minX, Math.min(dx, columns - maxX)); dy = Math.max(-minY, dy); maxRows = toNumber(options.maxRow, toNumber(options.max_row, toNumber(options.height, 0))); if (maxRows > 0) dy = Math.min(dy, maxRows - maxY); return { dx: Math.round(dx), dy: Math.round(dy) }; } function savePreviewStyle(item) { if (item.__gsMultiDragPreview) return; item.__gsMultiDragPreview = { transform: item.style.transform, transition: item.style.transition, zIndex: item.style.zIndex }; } function previewFollowers(active, left, top) { var i; for (i = 0; i < active.snapshot.length; i += 1) { var item = active.snapshot[i].element; if (item === active.leader) continue; savePreviewStyle(item); item.classList.add(FOLLOWER_CLASS); item.style.transition = 'none'; item.style.zIndex = '999'; item.style.transform = (item.__gsMultiDragPreview.transform || '') + ' translate(' + left + 'px,' + top + 'px)'; } } function clearPreview(active) { var i; if (!active) return; for (i = 0; i < active.snapshot.length; i += 1) { var item = active.snapshot[i].element; var saved = item.__gsMultiDragPreview; if (!saved) continue; item.style.transform = saved.transform; item.style.transition = saved.transition; item.style.zIndex = saved.zIndex; item.classList.remove(FOLLOWER_CLASS); try { delete item.__gsMultiDragPreview; } catch (ignore) { item.__gsMultiDragPreview = null; } } } function beginBatch(grid) { if (!grid) return; if (typeof grid.batchUpdate === 'function') grid.batchUpdate(true); else if (typeof grid.batch_update === 'function') grid.batch_update(); } function endBatch(grid) { if (!grid) return; if (typeof grid.commit === 'function') grid.commit(); else if (typeof grid.batchUpdate === 'function') grid.batchUpdate(false); } function moveOne(grid, position, x, y) { var element = position.element; if (typeof grid.move === 'function') { grid.move(window.jQuery ? window.jQuery(element) : element, x, y); } else if (typeof grid.update === 'function') { if (grid.engine || element.gridstackNode || grid.el) { grid.update(element, { x: x, y: y }); } else { grid.update(window.jQuery ? window.jQuery(element) : element, x, y, position.w, position.h); } } else { throw new Error('GridStack move/update API was not found'); } } function movementOrder(snapshot, dx, dy, reverse) { var multiplier = reverse ? -1 : 1; return snapshot.slice().sort(function (a, b) { var acx = a.x + a.w / 2; var acy = a.y + a.h / 2; var bcx = b.x + b.w / 2; var bcy = b.y + b.h / 2; var projectionA = acx * dx + acy * dy; var projectionB = bcx * dx + bcy * dy; return multiplier * (projectionB - projectionA); }); } function positionsMatch(snapshot, dx, dy) { var i; for (i = 0; i < snapshot.length; i += 1) { var actual = readPosition(snapshot[i].element); if (actual.x !== snapshot[i].x + dx || actual.y !== snapshot[i].y + dy) return false; } return true; } function applyMove(state, active, dx, dy) { var grid = state.grid; var ordered = movementOrder(active.snapshot, dx, dy, false); var i; var succeeded = false; try { beginBatch(grid); for (i = 0; i < ordered.length; i += 1) { moveOne(grid, ordered[i], ordered[i].x + dx, ordered[i].y + dy); } endBatch(grid); succeeded = positionsMatch(active.snapshot, dx, dy); } catch (error) { try { endBatch(grid); } catch (ignore) {} if (window.console && console.error) console.error('[GridStackMultiDrag]', error); } if (!succeeded) { /* Atomic rollback: either the complete group moves, or all selected items return to the coordinates they had at drag start. */ ordered = movementOrder(active.snapshot, dx, dy, true); try { beginBatch(grid); for (i = 0; i < ordered.length; i += 1) { moveOne(grid, ordered[i], ordered[i].x, ordered[i].y); } endBatch(grid); } catch (rollbackError) { try { endBatch(grid); } catch (ignore2) {} if (window.console && console.error) console.error('[GridStackMultiDrag rollback]', rollbackError); } showNotice('Группу нельзя поместить сюда: место занято или выходит за границы сетки.'); return false; } try { state.element.dispatchEvent(new CustomEvent('gridstack:multidragstop', { bubbles: true, detail: { items: active.snapshot.map(function (item) { return item.element; }), dx: dx, dy: dy } })); } catch (ignore3) {} return true; } function showNotice(message) { var old = document.getElementById('gs-multidrag-notice'); if (old && old.parentNode) old.parentNode.removeChild(old); var notice = document.createElement('div'); notice.id = 'gs-multidrag-notice'; notice.textContent = message; document.body.appendChild(notice); window.setTimeout(function () { if (notice.parentNode) notice.parentNode.removeChild(notice); }, 3500); } function onMouseDownCapture(state, event) { if (event.button !== 0) return; var item = directItem(event.target, state.element); if (item && isInteractive(event.target, item)) return; pruneSelection(state); if (event.ctrlKey || event.metaKey) { startMarquee(state, event, item); event.preventDefault(); event.stopImmediatePropagation(); return; } if (!item) return; state.pendingPlainClick = { item: item, startX: event.clientX, startY: event.clientY, moved: false, dragged: false }; if (state.selected.indexOf(item) === -1) selectOnly(state, item); } function onDragStart(state, event) { var leader = directItem(event.target, state.element); if (!leader) return; if (state.pendingPlainClick) state.pendingPlainClick.dragged = true; pruneSelection(state); if (state.selected.indexOf(leader) === -1) selectOnly(state, leader); if (state.selected.length < 2) { state.active = null; return; } state.active = { leader: leader, snapshot: state.selected.map(readPosition), rawLeft: 0, rawTop: 0, dx: 0, dy: 0 }; state.element.classList.add('gs-multidrag-active'); } function onDrag(state, event, ui) { var active = state.active; if (!active || directItem(event.target, state.element) !== active.leader || !ui) return; var original = ui.originalPosition || { left: 0, top: 0 }; var current = ui.position || original; active.rawLeft = toNumber(current.left, 0) - toNumber(original.left, 0); active.rawTop = toNumber(current.top, 0) - toNumber(original.top, 0); active.dx = Math.round(active.rawLeft / getCellWidth(state)); active.dy = Math.round(active.rawTop / getRowPitch(state)); previewFollowers(active, active.rawLeft, active.rawTop); } function onDragStop(state, event, ui) { var active = state.active; if (!active || directItem(event.target, state.element) !== active.leader) return; if (ui) onDrag(state, event, ui); clearPreview(active); state.element.classList.remove('gs-multidrag-active'); state.active = null; /* jQuery UI emits dragstop just before GridStack writes the leader's final coordinates, so the group commit runs on the next task. */ window.setTimeout(function () { var delta = clampDelta(state, active.snapshot, active.dx, active.dy); if (delta.dx === 0 && delta.dy === 0) return; applyMove(state, active, delta.dx, delta.dy); }, 0); } function bindGrid(gridElement, grid) { var state = { element: gridElement, grid: grid, selected: [], active: null, marquee: null, pendingPlainClick: null, onMouseDown: null, onDocumentMouseMove: null, onDocumentMouseUp: null, onWindowBlur: null, $element: null }; state.onMouseDown = function (event) { onMouseDownCapture(state, event); }; state.onDocumentMouseMove = function (event) { onDocumentMouseMove(state, event); }; state.onDocumentMouseUp = function (event) { onDocumentMouseUp(state, event); }; state.onWindowBlur = function () { if (state.marquee) finishMarquee(state, { clientX: state.marquee.startX, clientY: state.marquee.startY }, true); state.pendingPlainClick = null; }; gridElement.addEventListener('mousedown', state.onMouseDown, true); document.addEventListener('mousemove', state.onDocumentMouseMove, true); document.addEventListener('mouseup', state.onDocumentMouseUp, true); window.addEventListener('blur', state.onWindowBlur); if (window.jQuery) { state.$element = window.jQuery(gridElement); state.$element.on('dragstart.gsMultiDrag', function (event, ui) { onDragStart(state, event, ui); }); state.$element.on('drag.gsMultiDrag', function (event, ui) { onDrag(state, event, ui); }); state.$element.on('dragstop.gsMultiDrag', function (event, ui) { onDragStop(state, event, ui); }); } else if (grid && typeof grid.on === 'function') { grid.on('dragstart', function (event) { onDragStart(state, event); }); grid.on('drag', function (event) { onDrag(state, event, event.detail); }); grid.on('dragstop', function (event) { onDragStop(state, event, event.detail); }); } gridElement.__gsMultiDragState = state; initialized.push(state); } function injectStyles() { if (styleElement) return; styleElement = document.createElement('style'); styleElement.id = 'gridstack-multidrag-styles'; styleElement.textContent = '.grid-stack-item.' + SELECTED_CLASS + '>.grid-stack-item-content{' + 'outline:3px solid #2f80ed!important;outline-offset:-3px;' + 'box-shadow:0 0 0 2px rgba(47,128,237,.22)!important}' + '.grid-stack-item.' + FOLLOWER_CLASS + '{pointer-events:none!important;opacity:.88!important}' + '.gs-multidrag-marquee{position:fixed;z-index:2147483646;pointer-events:none;' + 'box-sizing:border-box;border:1px solid #2f80ed;background:rgba(47,128,237,.16);' + 'box-shadow:0 0 0 1px rgba(255,255,255,.65) inset}' + '.gs-multidrag-selecting,.gs-multidrag-selecting *{' + 'cursor:crosshair!important;-webkit-user-select:none!important;user-select:none!important}' + '#gs-multidrag-notice{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);' + 'z-index:2147483647;max-width:min(560px,calc(100vw - 32px));padding:11px 16px;' + 'border-radius:8px;background:#202124;color:#fff;font:14px/1.4 Arial,sans-serif;' + 'box-shadow:0 5px 22px rgba(0,0,0,.28)}'; (document.head || document.documentElement).appendChild(styleElement); } function scan() { var grids = document.querySelectorAll(GRID_SELECTOR); var found = false; var i; for (i = 0; i < grids.length; i += 1) { if (grids[i].__gsMultiDragState) continue; var grid = getGridInstance(grids[i]); if (!grid) continue; injectStyles(); bindGrid(grids[i], grid); found = true; } return found; } function scheduleScan() { if (scanTimer) return; scanTimer = window.setTimeout(function () { scanTimer = null; scan(); }, 80); } function clearAll() { var i; for (i = 0; i < initialized.length; i += 1) { if (initialized[i].marquee) finishMarquee(initialized[i], { clientX: initialized[i].marquee.startX, clientY: initialized[i].marquee.startY }, true); initialized[i].pendingPlainClick = null; clearSelection(initialized[i]); } } function destroy() { var i; if (observer) observer.disconnect(); observer = null; if (scanTimer) window.clearTimeout(scanTimer); scanTimer = null; if (pollTimer) window.clearInterval(pollTimer); pollTimer = null; for (i = 0; i < initialized.length; i += 1) { var state = initialized[i]; clearPreview(state.active); if (state.marquee) finishMarquee(state, { clientX: state.marquee.startX, clientY: state.marquee.startY }, true); clearSelection(state); state.element.removeEventListener('mousedown', state.onMouseDown, true); document.removeEventListener('mousemove', state.onDocumentMouseMove, true); document.removeEventListener('mouseup', state.onDocumentMouseUp, true); window.removeEventListener('blur', state.onWindowBlur); if (state.$element) state.$element.off('.gsMultiDrag'); try { delete state.element.__gsMultiDragState; } catch (ignore) { state.element.__gsMultiDragState = null; } } initialized = []; if (styleElement && styleElement.parentNode) styleElement.parentNode.removeChild(styleElement); styleElement = null; } document.addEventListener('mousedown', function (event) { var node = event.target; while (node && node !== document) { if (node.nodeType === 1 && hasClass(node, 'grid-stack')) return; node = node.parentNode; } clearAll(); }, true); document.addEventListener('keydown', function (event) { if (event.key === 'Escape' || event.keyCode === 27) clearAll(); }); function start() { scan(); /* Attaching $.data('gridstack') does not necessarily mutate the DOM, so a lightweight poll complements MutationObserver when this file is loaded in before the host application initializes GridStack. */ pollTimer = window.setInterval(scan, 1000); if (window.MutationObserver) { observer = new MutationObserver(scheduleScan); observer.observe(document.documentElement, { childList: true, subtree: true }); } } window.GridStackMultiDrag = { version: VERSION, rescan: scan, clear: clearAll, destroy: destroy }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } }(window, document));