UNPKG

apostrophe

Version:
1,715 lines (1,608 loc) • 50.3 kB
/** * * @typedef {{ * id: string, * side: string, * direction: string, * colspan: number, * colstart: number, * rowstart: number, * rowspan: number, * order: number * }} GhostDataWrite * * * * @typedef {{ * _id: string, * type: string, * order: number, * rowstart: number, * colstart: number, * colspan: number, * rowspan: number, * align: string, * justify: string * }} CurrentItem * * @typedef {ReturnType<typeof itemsToState>} GridState * @typedef {ReturnType<typeof createPositionIndex>} PositionIndex * * @typedef {{ * columns: number * desktop: { * rows: number, * }, * tablet: { * rows: number, * auto: boolean * }, * mobile: { * rows: number, * auto: boolean * } * }} LayoutMeta * * @typedef {{ * columns: number, * gap: string, * minSpan: number, * defaultSpan: number, * mobile: { * breakpoint: number * }, * tablet: { * breakpoint: number * }, * defaultCellHorizontalAlignment: string, * defaultCellVerticalAlignment: string * }} LayoutOptions */ /** * Accepts: * - items (array of instances of @apostrophecms/layout-column-widget) * - meta (total columns and per-device rows/auto) * - options (Browser options of @apostsrophecms/layout-widget) * - layoutMode (string, either 'layout', 'focus' or 'content') * - deviceMode (string, either 'desktop', 'tablet' or 'mobile') * * @param {Object} params * @param {CurrentItem[]} params.items - The items to be converted to state. * @param {LayoutMeta} params.meta - The meta information for the grid. * @param {LayoutOptions} params.options - The options for the grid. * @param {string} params.layoutMode - The layout mode of the grid. * @param {string} params.deviceMode - The device mode for the grid. */ export function itemsToState({ items, meta, options, layoutMode, deviceMode }) { const perDevice = { desktop: { items: [], rows: 1, auto: true }, tablet: { items: [], rows: 1, auto: true }, mobile: { items: [], rows: 1, auto: true } }; for (const [ device, record ] of Object.entries(perDevice)) { record.items = items.map(item => ({ ...item, ...item[device], _id: item._id, type: item.type, content: null // optimize memory, we don't need content here })); record.rows = meta[device]?.rows || 1; record.auto = meta[device]?.auto ?? false; } const current = perDevice[deviceMode] || perDevice.desktop; const gap = options.gap === '0' ? 0 : options.gap; const resolvedOptions = { ...options, columns: meta.columns || options.columns, gap: [ 'layout', 'focus' ].includes(layoutMode) ? gap || '2px' : options.gap, snapThresholdMove: 0.4, snapThresholdResize: 0.5 }; const positionsIndex = createPositionIndex(current.items, current.rows); const lookup = new Map(current.items.map(item => [ item._id, item ])); const originalItems = new Map(items.map(item => [ item._id, item ])); const state = { columns: meta.columns || options.columns, layoutMode, deviceMode, devices: perDevice, current, originalItems, lookup, options: resolvedOptions, positions: positionsIndex }; return state; } /** * Generate items for a new row. Attempt to fit the entire space, while * restricting each item's colspan to at least minColspan and ideally * defaultColspan (or anything between). Rowspan is always 1 and order * is sequential. * * @param {number} columns * @param {Object} [options] * @param {number} [options.minColspan] * @param {number} [options.defaultColspan] * @param {number} [options.row] The current row number (1-based) * * @returns {Pick< * CurrentItem, * 'rowstart' | 'rowspan' | 'colstart' | 'colspan' | 'order' * >[] * } */ export function provisionRow(columns, { minColspan = 2, defaultColspan = 3, row = 1 } = {}) { // Normalize inputs const C = Math.max(1, Math.floor(Number(columns) || 1)); const min = Math.max(1, Math.floor(Number(minColspan) || 1)); const ideal = Math.max(min, Math.floor(Number(defaultColspan) || min)); // If columns are fewer than min, fall back to a single full-width item if (C <= min) { return [ { rowstart: row, rowspan: 1, colstart: 1, colspan: C, order: 0 } ]; } // Choose the number of items n in [1..floor(C/min)] that best matches the // desired (near ideal) span while filling the row exactly. We evaluate a // small scoring function to bias toward: // - average span close to `ideal` // - exact division (no remainder) // - n close to C/ideal const nMax = Math.max(1, Math.floor(C / min)); const targetN = Math.max(1, Math.round(C / ideal)); let best = null; // { n, base, rem, score } for (let n = 1; n <= nMax; n++) { const base = Math.floor(C / n); if (base < min) { continue; } const rem = C - base * n; // number of items that will get +1 const avg = base + (rem / n); const closeness = Math.abs(avg - ideal); const evenPenalty = rem === 0 ? 0 : 0.05; const nPenalty = Math.abs(n - targetN) / targetN * 0.1; // light penalty for very many items (usability) const densityPenalty = n > 8 ? (n - 8) * 0.02 : 0; const score = closeness + evenPenalty + nPenalty + densityPenalty; if (!best || score < best.score || (score === best.score && (rem < best.rem || (rem === best.rem && Math.abs(n - targetN) < Math.abs(best.n - targetN))))) { best = { n, base, rem, score }; } } if (!best) { // Fallback to a single item spanning the whole row return [ { rowstart: row, rowspan: 1, colstart: 1, colspan: C, order: 0 } ]; } const { n, base, rem } = best; // Start with equal base widths, then distribute the remainder symmetrically const sizes = new Array(n).fill(base); let r = rem; if (n === 1) { sizes[0] = C; } else if (n % 2 === 1) { // Odd: center-first, then mirror outward in pairs const c = Math.floor(n / 2); if (r > 0) { sizes[c] += 1; r -= 1; } for (let k = 1; r >= 2 && (c - k) >= 0 && (c + k) < n; k++) { sizes[c - k] += 1; sizes[c + k] += 1; r -= 2; } if (r === 1) { // Last unmatched extra: place toward the side that keeps sizes near ideal const leftIdx = 0; const rightIdx = n - 1; const leftDelta = Math.abs((sizes[leftIdx] + 1) - ideal); const rightDelta = Math.abs((sizes[rightIdx] + 1) - ideal); const addLeft = leftDelta <= rightDelta; sizes[addLeft ? leftIdx : rightIdx] += 1; r = 0; } } else { // Even: two centers as the first symmetric pair, then expand outward const rc = n / 2; // right center index const lc = rc - 1; // left center index for (let k = 0; r >= 2 && (lc - k) >= 0 && (rc + k) < n; k++) { sizes[lc - k] += 1; sizes[rc + k] += 1; r -= 2; } if (r === 1) { // Bias to the side that yields closer-to-ideal span const leftIdx = Math.max(0, lc - Math.ceil((n - 2) / 2)); const rightIdx = Math.min(n - 1, rc + Math.ceil((n - 2) / 2)); const leftDelta = Math.abs((sizes[leftIdx] + 1) - ideal); const rightDelta = Math.abs((sizes[rightIdx] + 1) - ideal); const addLeft = leftDelta <= rightDelta; sizes[addLeft ? leftIdx : rightIdx] += 1; r = 0; } } // Build the items left-to-right const items = []; let colstart = 1; for (let i = 0; i < n; i++) { const span = sizes[i]; items.push({ rowstart: row, rowspan: 1, colstart, colspan: span, order: i }); colstart += span; } return items; } /** * Create a 2d map (rows x columns) to store items positions * * @param {CurrentItem[]} items * @returns {Map<number, Map<number, string>>} */ export function createPositionIndex(items, rows) { const sorted = items.slice().sort((a, b) => a.order - b.order); const positionsIndex = new Map(); for (let i = 1; i <= rows; i++) { positionsIndex.set(i, new Map()); } for (const item of sorted) { const { colstart, colspan, rowstart, rowspan } = item; const height = Math.max(1, rowspan || 1); for (let r = 0; r < height; r++) { const row = rowstart + r; const xIndex = positionsIndex.get(row); if (!xIndex) { continue; } for (let i = 0; i < colspan; i++) { xIndex.set(colstart + i, item._id); } } } return positionsIndex; } /** * Validates the new column span for all item rows. * Calculates the max available capacity in the provided direction * based on the existing items in the grid. * It's assumed that the item position is valid and within the grid bounds. * * Performance is critical as this is triggered on mouse move events. * * @param {Object} params - The parameters for validation. * @param {CurrentItem} params.item - The item being resized. * @param {string} params.side - The side of the item being resized ('east' or 'west'). * @param {string} params.direction - The direction of the resize ('east' or ' * west'). * @param {number} params.newColspan - The new column span being requested. * @param {PositionIndex} params.positionsIndex - The current positions index. * @param {number} params.maxColumns - The maximum number of columns in the grid. * @param {number} params.minSpan - The minimum column span allowed. * @returns {{ * _id: string, * colspan: number, * colstart: number * }} - The validated resize parameters plus the item ID and colstart. */ export function validateResizeX({ item, side, direction, newColspan, positionsIndex, maxColumns, minSpan }) { if (!item) { throw new Error('Item is required for resizing validation'); } const appliedMinSpan = Math.max(1, minSpan || 1); const delta = newColspan - item.colspan; if (delta === 0) { return { _id: item._id, colspan: item.colspan, colstart: item.colstart }; } if (delta < 0) { // Shrinking: keep the opposite edge fixed. // When shrinking from the west side, the colstart must move to the right by // the ACTUAL shrink amount (respecting minSpan). const targetColspan = Math.max(newColspan, appliedMinSpan); const appliedDelta = targetColspan - item.colspan; // negative or 0 const unclampedStart = side === 'west' ? (item.colstart - appliedDelta) // subtracting a negative == plus : item.colstart; // Clamp inside grid: 1 .. (maxColumns - targetColspan + 1) const maxStart = Math.max(1, maxColumns - targetColspan + 1); return { _id: item._id, colspan: targetColspan, colstart: Math.min(Math.max(unclampedStart, 1), maxStart) }; } // The below logic is used only for expanding const itemRows = Array.from({ length: item.rowspan }, (_, i) => item.rowstart + i); // Helper: count TOTAL free cells in a direction for a row index map. // East: columns in (endCell+1..maxColumns). // West: columns in (1..startCell-1). const countTotalFree = (rowIndex, from, to) => { // If rowIndex is missing, all cells in range are free. if (!rowIndex) { return Math.max(0, to - from + 1); } let count = 0; if (from <= to) { for (let c = from; c <= to; c++) { if (!rowIndex.has(c)) { count += 1; } } } return count; }; // Determine maximum extra columns we can add while expanding in the given // direction, aligned across all spanned rows // (take the minimum across rows). const startCell = item.colstart; const endCell = startCell + item.colspan - 1; const range = (direction === 'east') ? { from: endCell + 1, to: maxColumns } : { from: 1, to: startCell - 1 }; let maxExtra = Infinity; for (const row of itemRows) { const rowIndex = positionsIndex.get(row); const free = countTotalFree(rowIndex, range.from, range.to); maxExtra = Math.min(maxExtra, free); } maxExtra = Math.max(0, maxExtra); const maxColspan = item.colspan + maxExtra; const targetColspan = Math.max(appliedMinSpan, Math.min(newColspan, maxColspan)); // Compute resulting colstart respecting side and boundary const appliedDelta = targetColspan - item.colspan; let unclampedStart = item.colstart; if (side === 'west' && direction === 'west') { unclampedStart = item.colstart - appliedDelta; } const maxStart = Math.max(1, maxColumns - targetColspan + 1); const finalStart = Math.min(Math.max(unclampedStart, 1), maxStart); return { _id: item._id, colspan: targetColspan, colstart: finalStart }; } /** * Triggered on capturing the end of horizontal axis resize. * Returns the new position of the target item and all other affected items * if nudging is required. * * @param {Object} arg - The ghost data containing the item and its state. * @param {GhostDataWrite} arg.data - The ghost data containing the item and its state. * @param {GridState} arg.state - The current grid state. * @param {CurrentItem} arg.item - The item being resized. * * @returns {{ * _id: string, * colspan?: number, * colstart: number, * }[]} */ export function getResizeChanges({ data, state, item }) { if (!item || !data.direction) { return []; } const maxColumns = state.columns; const { direction, colspan: newColspan, colstart: newColstart } = data; const expanded = Math.max(0, newColspan - item.colspan); // Always include the current item patch const patches = [ { _id: item._id, colspan: newColspan, colstart: newColstart } ]; if (!expanded) { return patches; } // Collect required shift per neighbor across all affected rows const neighborShift = new Map(); // id -> shift amount (positive integer) // Rows occupied by the item const itemRows = Array.from({ length: item.rowspan }, (_, i) => item.rowstart + i); const itemsById = state.lookup; const inBounds = (col) => col >= 1 && col <= maxColumns; const advancePastNeighbor = direction === 'east' ? (item) => (item.colstart + item.colspan) : (item) => (item.colstart - 1); for (const row of itemRows) { const xIndex = state.positions.get(row); if (!xIndex) { continue; } // Build occupancy array [1..maxColumns] for this row const occupancy = new Array(maxColumns + 1).fill(null); for (let c = 1; c <= maxColumns; c++) { occupancy[c] = xIndex.get(c) || null; } const startCell = item.colstart; const endCell = startCell + item.colspan - 1; // Parameterize directional scanning to avoid duplicating logic const step = (direction === 'east') ? 1 : -1; const startScan = (direction === 'east') ? Math.min(endCell + 1, maxColumns + 1) : Math.max(startCell - 1, 1); // Create a gap of size `expanded` by cascading neighbors in `direction` let sinceLastEmpty = 0; let currentShift = expanded; let col = startScan; while (inBounds(col) && currentShift > 0) { const id = occupancy[col]; if (!id) { sinceLastEmpty++; col += step; continue; } // Reduce the required shift by empties since last neighbor currentShift = Math.max(0, currentShift - sinceLastEmpty); if (!currentShift) { break; } const _item = itemsById.get(id); if (!_item || id === item._id) { col += step; continue; } const prev = neighborShift.get(id) || 0; neighborShift.set(id, Math.max(prev, currentShift)); // Jump past this neighbor based on original positions col = advancePastNeighbor(_item); sinceLastEmpty = 0; } } // Convert neighbor shifts to patches (colstart only) for (const [ id, shift ] of neighborShift.entries()) { const n = itemsById.get(id); if (!n) { continue; } let newStart; if (direction === 'east') { newStart = Math.min(maxColumns - n.colspan + 1, n.colstart + shift); } else { newStart = Math.max(1, n.colstart - shift); } patches.push({ _id: id, colstart: newStart }); } return patches; } /** * Based on the current state, the ghost data and the item being moved, * returns an array of patches that represent the changes to be applied * to the grid items when the item is moved (including nudging * neighboring items if necessary). * If the positions are invalid or the item is not being moved, * an empty array is returned. * * @param {Object} arg * @param {GhostDataWrite} arg.data - The ghost data containing the item * and its state. * @param {GridState} arg.state - The current grid state. * @param {CurrentItem} arg.item - The item being moved. * @param {Object} [arg.precomp] - Optional precomputed move index produced * by prepareMoveIndex to speed up decisions. * * @return {{ * _id: string, * colstart?: number, * rowstart?: number, * order?: number * }[]} */ export function getMoveChanges({ data, state, item, precomp }) { const decided = decideMove({ data, state, item, precomp }); if (!decided) { return []; } const { posPatches } = decided; const orderPatches = computeOrderPatches({ state, posPatches }); return mergePositionAndOrderPatches(posPatches, orderPatches); } /** * Lightweight preview version of move calculations for high-frequency ghost snapping. * Returns only the prospective colstart/rowstart for the moving item (no ordering or * neighbor patches) or null if the move would be rejected. This shares the exact * decision logic with getMoveChanges via decideMove to avoid divergence. * * @param {Object} arg * @param {GhostDataWrite} arg.data * @param {GridState} arg.state * @param {CurrentItem} arg.item * @param {Object} [arg.precomp] * @returns {{ _id: string, colstart: number, rowstart: number } | null} */ export function previewMoveChanges({ data, state, item, precomp }) { const decided = decideMove({ data, state, item, precomp }); if (!decided) { return null; } // Moving item patch always first in our construction const moving = decided.posPatches.find(p => p._id === item._id); return moving ? { _id: moving._id, colstart: moving.colstart, rowstart: moving.rowstart } : null; } /** * Compute ghost snapping target (columns/rows and pixel offsets) for a move. * Stateless and DOM-free: caller must provide stepX/stepY (track + gap size). * * @param {Object} arg * @param {number} arg.left - Current ghost left (px) relative to grid container. * @param {number} arg.top - Current ghost top (px) relative to grid container. * @param {import('./grid-state.mjs').GridState} arg.state - Current grid state. * @param {import('./grid-state.mjs').CurrentItem} arg.item - Moving item. * @param {Object} [arg.precomp] - Optional precomputed move index. * @param {number} arg.columns - Number of columns. * @param {number} arg.rows - Number of rows. * @param {number} arg.stepX - Column track size incl. gap (px). * @param {number} arg.stepY - Row track size incl. gap (px). * @param {number} [arg.threshold] - Snap threshold [0..1], defaults to 0.6 if missing. * @returns {{ * colstart: number, * rowstart: number, * snapLeft: number, * snapTop: number * } | null} */ export function computeGhostMoveSnap({ left, top, state, item, precomp, columns, rows, stepX, stepY, threshold }) { if (!state || !item) { return null; } const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); const colspan = Math.max(1, item.colspan || 1); const rowspan = Math.max(1, item.rowspan || 1); const maxStartX = Math.max(1, columns - colspan + 1); const maxStartY = Math.max(1, rows - rowspan + 1); const t = Number(threshold ?? 0.6); const tClamped = clamp(Number.isFinite(t) ? t : 0.6, 0.05, 0.95); const shiftX = (1 - tClamped) * stepX; const shiftY = (1 - tClamped) * stepY; let c = Math.floor((left + shiftX) / stepX) + 1; let r = Math.floor((top + shiftY) / stepY) + 1; c = Math.max(1, Math.min(c, maxStartX)); r = Math.max(1, Math.min(r, maxStartY)); let colstart = c; let rowstart = r; // Hovered-neighbor threshold for swapping only // - If the ghost is hovering an occupied segment (neighbor) in the move direction // and a swap candidate is valid, compute the flip boundary using the hovered // neighbor's width instead of per-track threshold. // - Otherwise, use the faster track-based behavior. const width = colspan; const pre = precomp || prepareMoveIndex({ state, item }); const occSegs = pre?.segmentsByRow?.get(r) || []; const dir = (c > (item.colstart || 1)) ? 'east' : (c < (item.colstart || 1) ? 'west' : null); // Leading edge in pixels: right edge when moving east, left edge when moving west const widthPx = width * stepX; const leadPx = dir === 'east' ? (left + widthPx) : left; // Column index under the leading edge (1-based), independent of threshold shift const hoverCol = Math.max(1, Math.min(columns, Math.floor(leadPx / stepX) + 1)); /** @type {{ id: string, start: number, end: number } | null} */ let hoveredSeg = null; if (dir && occSegs.length) { for (const seg of occSegs) { if (seg.start <= hoverCol && hoverCol <= seg.end && seg.id !== item._id) { hoveredSeg = seg; break; } } } let validated = false; if (hoveredSeg) { const nStart = hoveredSeg.start; const nEnd = hoveredSeg.end; const nWidth = (nEnd - nStart + 1); let swapStart; if (dir === 'west') { // swap-left (before neighbor) swapStart = nStart; } else { // dir === 'east', place so end aligns to neighbor end const equalEnd = nEnd - width + 1; swapStart = Math.max(1, equalEnd); } // Clamp into bounds swapStart = Math.max(1, Math.min(swapStart, maxStartX)); // Decide based on hovered-neighbor threshold boundary in pixels const neighborStartPx = (nStart - 1) * stepX; const neighborWidthPx = nWidth * stepX; const neighborEndPx = neighborStartPx + neighborWidthPx; // Directional thresholds: // - east: flip when right-edge crosses start + t * width // - west: flip when left-edge crosses end - t * width (== start + (1 - t) * width) const flipPx = (dir === 'west') ? (neighborEndPx - (tClamped * neighborWidthPx)) : (neighborStartPx + tClamped * neighborWidthPx); const onSwapSide = dir === 'west' ? (leadPx <= flipPx) : (leadPx >= flipPx); if (onSwapSide) { const p = previewMoveChanges({ data: { id: item._id, colstart: swapStart, rowstart }, state, item, precomp: pre }); if (p) { colstart = p.colstart; rowstart = p.rowstart; validated = true; } } } // Validate the final candidate once with preview to ensure consistency // with drop-time logic. This is at most one call per mousemove. if (!validated && item && item._id) { const preview = previewMoveChanges({ data: { id: item._id, colstart, rowstart }, state, item, precomp: pre }); if (preview) { colstart = preview.colstart; rowstart = preview.rowstart; validated = true; } else { // Invalid move -> snap to current item position colstart = item.colstart ?? colstart; rowstart = item.rowstart ?? rowstart; validated = true; } } const snapLeft = Math.round((colstart - 1) * stepX); const snapTop = Math.round((rowstart - 1) * stepY); return { colstart, rowstart, snapLeft, snapTop }; } /** * Fast, stateless bailout to decide whether we need to recompute move snapping. * Computes a coarse signature made of: * - coarse column and row buckets (track-based) * - hovered neighbor segment id on the leading edge (if any) * - movement direction (east/west/null) * - whether the pointer is on the swap side of the hovered neighbor threshold * If this signature hasn't changed since the last call (prevMemo), higher-level * code can skip calling computeGhostMoveSnap without observable behavior change. * * Returns an object with: * - compute: boolean -> true if signature changed or prev missing * - memo: string -> the new signature to store for next comparison * * Note: This function is stateless and does no caching; callers store memo. * * @param {Object} arg * @param {number} arg.left * @param {number} arg.top * @param {import('./grid-state.mjs').GridState} arg.state * @param {import('./grid-state.mjs').CurrentItem} arg.item * @param {number} arg.columns * @param {number} arg.rows * @param {number} arg.stepX * @param {number} arg.stepY * @param {number} [arg.threshold] * @param {string} [arg.prevMemo] * @returns {{ compute: boolean, memo: string }} */ export function shouldComputeMoveSnap({ left, top, state, item, columns, rows, stepX, stepY, threshold, prevMemo }) { const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); const colspan = Math.max(1, item?.colspan || 1); const rowspan = Math.max(1, item?.rowspan || 1); const maxStartX = Math.max(1, columns - colspan + 1); const maxStartY = Math.max(1, rows - rowspan + 1); const t = Number(threshold ?? 0.6); const tClamped = clamp(Number.isFinite(t) ? t : 0.6, 0.05, 0.95); const shiftX = (1 - tClamped) * stepX; const shiftY = (1 - tClamped) * stepY; let c = Math.floor((left + shiftX) / stepX) + 1; let r = Math.floor((top + shiftY) / stepY) + 1; c = Math.max(1, Math.min(c, maxStartX)); r = Math.max(1, Math.min(r, maxStartY)); // Direction based on coarse col delta relative to original item let dir = null; if (item?.colstart != null) { if (c > item.colstart) { dir = 'east'; } else if (c < item.colstart) { dir = 'west'; } } // Leading edge and hovered neighbor segment at that row for swap-side check // Build contiguous segments from positions for row r let hoveredId = '-'; let onSwapSide = 0; const widthPx = colspan * stepX; const leadPx = dir === 'west' ? left : (left + widthPx); const hoverCol = Math.max(1, Math.min(columns, Math.floor(leadPx / stepX) + 1)); const rowIndex = state.positions?.get?.(r); if (rowIndex) { // Walk to find containing segment for hoverCol let segStart = 0; let segEnd = 0; let segId = null; // Expand outward from hoverCol to identify contiguous id block const idAt = (col) => rowIndex.get(col) || null; const centerId = idAt(hoverCol); if (centerId && centerId !== item._id) { segId = centerId; // walk left segStart = hoverCol; while (segStart - 1 >= 1 && idAt(segStart - 1) === segId) { segStart -= 1; } // walk right segEnd = hoverCol; while (segEnd + 1 <= columns && idAt(segEnd + 1) === segId) { segEnd += 1; } hoveredId = segId; // Compute flip boundary in px based on neighbor width const nStart = segStart; const nEnd = segEnd; const neighborStartPx = (nStart - 1) * stepX; const neighborEndPx = nEnd * stepX; const neighborWidthPx = (nEnd - nStart + 1) * stepX; const flipPx = dir === 'west' ? (neighborEndPx - (tClamped * neighborWidthPx)) : (neighborStartPx + (tClamped * neighborWidthPx)); onSwapSide = (dir === 'west') ? (leadPx <= flipPx ? 1 : 0) : (leadPx >= flipPx ? 1 : 0); } } const memo = `${r}|${c}|${hoveredId}|${dir || '-'}|${onSwapSide}`; return { compute: memo !== prevMemo, memo }; } // Core move decision logic (position-only). // Used by both getMoveChanges and previewMoveChanges. function decideMove({ data, state, item, precomp }) { // Guard against mismatched item/data identifiers when the target id exists in lookup if (data.id && state?.lookup?.has?.(data.id) && item?._id && data.id !== item._id) { return null; } if (!data.colstart || !data.rowstart || (data.colstart === item.colstart && data.rowstart === item.rowstart) ) { return null; } const maxColumns = state.columns; const maxRows = state.current?.rows || 1; // Geometry of the moving item (size is immutable for move) const width = item.colspan; const height = item.rowspan; const newStartCol = data.colstart; const newStartRow = data.rowstart; const newEndCol = newStartCol + width - 1; const newEndRow = newStartRow + (height || 1) - 1; // Basic bounds validation (ghost snapping should already ensure this) const inGrid = ( newStartCol >= 1 && newEndCol <= maxColumns && newStartRow >= 1 && newEndRow <= maxRows ); if (!inGrid) { return null; } // Strategy 1: place if destination is completely free (ignoring the moving item itself) const placeIfFree = attemptPlaceWithoutNudging({ state, item, newStartCol, newStartRow, precomp }); if (placeIfFree) { return { posPatches: [ { _id: item._id, colstart: newStartCol, rowstart: newStartRow } ] }; } // Strategy 2: horizontal nudge of neighbours only // We only reach here when placeIfFree is false // Decide preferred nudge directions based on movement intent and edge overlaps const oldStartCol = item.colstart; let primaryDir; if (newStartCol > oldStartCol) { primaryDir = 'east'; } else if (newStartCol < oldStartCol) { primaryDir = 'west'; } else { primaryDir = null; // vertical-only move } // Compute the most relevant overlapped target (first in scan order) const target = findPrimaryOverlappedTarget({ state, item, newStartCol, newStartRow, width, precomp }); // Decide attempt order according to the rules const tryDirs = (() => { if (!primaryDir) { return [ 'east', 'west' ]; } if (!target) { return primaryDir === 'east' ? [ 'east' ] : [ 'west' ]; } const ghostStart = newStartCol; const ghostEnd = newEndCol; const tStart = target.colstart; const tEnd = target.colstart + target.colspan - 1; if (primaryDir === 'east') { if (ghostEnd < tEnd) { return [ 'east' ]; } if (ghostEnd === tEnd) { return [ 'west', 'east' ]; } return [ 'west', 'east' ]; } else { if (ghostStart > tStart) { return [ 'west' ]; } if (ghostStart === tStart) { return [ 'east', 'west' ]; } return [ 'east', 'west' ]; } })(); for (const dir of tryDirs) { const patches = attemptHorizontalNudge({ state, item, newStartCol, newStartRow, dir, precomp }); if (patches) { // Success: include moved item position return { posPatches: [ { _id: item._id, colstart: newStartCol, rowstart: newStartRow }, ...patches ] }; } } // No strategy succeeded return null; } /** * Compute order patches for all items based on current state but with * virtual positions applied from posPatches. This ensures determinism even * if array order differs from grid order. * @param {Object} args * @param {GridState} args.state - The current grid state. * @param {Object[]} [args.posPatches] - Optional position patches to apply. * @returns */ function computeOrderPatches({ state, posPatches }) { /** @type {Map<string, { colstart?: number, rowstart?: number }>} */ const patchById = new Map(); for (const p of posPatches || []) { patchById.set(p._id, { colstart: p.colstart, rowstart: p.rowstart }); } const list = state.current.items.map(it => ({ _id: it._id, rowstart: (patchById.get(it._id)?.rowstart ?? it.rowstart ?? 1), colstart: (patchById.get(it._id)?.colstart ?? it.colstart ?? 1), order: (typeof it.order === 'number') ? it.order : Number.MAX_SAFE_INTEGER })) .filter(it => { if (patchById.has(it._id)) { return true; } return it.order !== state.lookup.get(it._id)?.order; }); // Deterministic ordering (top-to-bottom, left-to-right, previous order) list.sort((a, b) => { if (a.rowstart !== b.rowstart) { return a.rowstart - b.rowstart; } if (a.colstart !== b.colstart) { return a.colstart - b.colstart; } if (a.order !== b.order) { return a.order - b.order; } return 0; }); // Generate zero-based order patches for all items return list.map((it, index) => ({ _id: it._id, order: index })); } // Merge position patches (if any) with order patches, ensuring each item // gets a single patch object including 'order'. Items not present in // posPatches receive order-only patches. function mergePositionAndOrderPatches(posPatches, orderPatches) { /** @type {Map<string, any>} */ const result = new Map(); for (const op of posPatches || []) { result.set(op._id, { ...op }); } for (const ord of orderPatches || []) { const existing = result.get(ord._id); if (existing) { existing.order = ord.order; } else { result.set(ord._id, { _id: ord._id, order: ord.order }); } } return Array.from(result.values()); } // ---- Strategy helpers ---------------------------------------------------- function attemptPlaceWithoutNudging({ state, item, newStartCol, newStartRow, precomp }) { const width = item.colspan; const height = item.rowspan || 1; const endCol = newStartCol + width - 1; const endRow = newStartRow + height - 1; const positions = state.positions; for (let r = newStartRow; r <= endRow; r++) { if (precomp?.occByRow) { const occ = precomp.occByRow.get(r); if (occ) { for (let c = newStartCol; c <= endCol; c++) { const id = occ[c] || null; if (id && id !== item._id) { return false; } } continue; } } const rowIndex = positions.get(r); for (let c = newStartCol; c <= endCol; c++) { const occ = rowIndex?.get(c); if (occ && occ !== item._id) { return false; } } } return true; } function findPrimaryOverlappedTarget({ state, item, newStartCol, newStartRow, width, precomp }) { const height = item.rowspan || 1; const endCol = newStartCol + width - 1; const endRow = newStartRow + height - 1; const positions = state.positions; // Prefer earliest conflict in scan order across spanned rows let best = null; // { id, col, colstart, colspan } // Determine scan direction by whether colstart changed const dir = (newStartCol > item.colstart) ? 'east' : (newStartCol < item.colstart ? 'west' : null); for (let r = newStartRow; r <= endRow; r++) { const rowIndex = precomp?.rowIndexByRow?.get(r) || positions.get(r); if (!rowIndex) { continue; } if (dir === 'west') { for (let c = endCol; c >= newStartCol; c--) { const id = precomp?.occByRow?.get(r)?.[c] ?? rowIndex.get(c); if (id && id !== item._id) { const n = state.lookup.get(id); if (!n) { continue; } if (!best || c > best.col) { best = { id, col: c, colstart: n.colstart, colspan: n.colspan }; } break; } } } else { // east or vertical-only (use east for tie-breaker) for (let c = newStartCol; c <= endCol; c++) { const id = precomp?.occByRow?.get(r)?.[c] ?? rowIndex.get(c); if (id && id !== item._id) { const n = state.lookup.get(id); if (!n) { continue; } if (!best || c < best.col) { best = { id, col: c, colstart: n.colstart, colspan: n.colspan }; } break; } } } } if (!best) { return null; } const target = state.lookup.get(best.id); return target || null; } function attemptHorizontalNudge({ state, item, newStartCol, newStartRow, dir, precomp }) { const width = item.colspan; const height = item.rowspan || 1; const endCol = newStartCol + width - 1; const endRow = newStartRow + height - 1; const maxColumns = state.columns; // Collect all candidate neighbor ids that appear on any of the rows the // moving item will occupy. This ensures we can cascade shifts across the // row consistently without underestimating required moves. /** @type {Set<string>} */ const candidateIds = new Set(); for (let r = newStartRow; r <= endRow; r++) { const segs = precomp?.segmentsByRow?.get(r); if (segs && segs.length) { for (const seg of segs) { if (seg.id && seg.id !== item._id) { candidateIds.add(seg.id); } } continue; } // Fallback when segments are not provided: collect ids from the row index const rowIndex = state.positions.get(r); if (rowIndex) { for (const id of rowIndex.values()) { if (id && id !== item._id) { candidateIds.add(id); } } } } if (!candidateIds.size) { // No neighbors to consider, if there was overlap this implies an error. return null; } // Turn into item objects and sort in scanning order for the cascade. const candidates = Array.from(candidateIds) .map(id => state.lookup.get(id)) .filter(Boolean) .sort((a, b) => { if (dir === 'east') { return (a.colstart - b.colstart) || (a.order - b.order); } const aEnd = a.colstart + a.colspan - 1; const bEnd = b.colstart + b.colspan - 1; return (bEnd - aEnd) || (a.order - b.order); }); /** @type {Map<string, number>} */ const shiftById = new Map(); if (dir === 'east') { // Minimal cascade so every item to the left of or touching the ghost end // moves just enough to start at (endCol + 1), and cascades to the right // without overlaps. Items already beyond neededStart remain in place. let neededStart = endCol + 1; for (const n of candidates) { const len = n.colspan; const start0 = n.colstart; const end0 = start0 + len - 1; // Only items whose start is before the current neededStart can block. if (start0 < neededStart && end0 >= newStartCol) { const newStart = neededStart; const newEnd = newStart + len - 1; if (newEnd > maxColumns) { return null; } shiftById.set(n._id, Math.max(shiftById.get(n._id) || 0, newStart - start0)); neededStart = newEnd + 1; } else { // No shift for this item; it defines the next available start. neededStart = Math.max(neededStart, end0 + 1); } // No need to fail if neededStart exceeds grid after processing; // we already enforce bounds on each neighbor shift. } } else { // dir === 'west' let neededEnd = newStartCol - 1; for (const n of candidates) { const len = n.colspan; const start0 = n.colstart; const end0 = start0 + len - 1; // Only items whose end is after the current neededEnd can block. if (end0 > neededEnd && start0 <= endCol) { const newEnd = neededEnd; const newStart = newEnd - len + 1; if (newStart < 1) { return null; } shiftById.set(n._id, Math.max(shiftById.get(n._id) || 0, start0 - newStart)); neededEnd = newStart - 1; } else { // No shift; it defines the next available end. neededEnd = Math.min(neededEnd, start0 - 1); } // Similarly, don't fail solely due to neededEnd becoming < 0 // after processing; individual shifts are already bounded. } } if (!shiftById.size) { // Nothing to move means either no overlap or impossible scenario we don't handle here return null; } // Produce patches with new colstart per id const patches = []; for (const [ id, shift ] of shiftById.entries()) { const n = state.lookup.get(id); if (!n || !shift) { continue; } const newStart = dir === 'east' ? Math.min(maxColumns - n.colspan + 1, n.colstart + shift) : Math.max(1, n.colstart - shift); if (newStart === n.colstart) { return null; } patches.push({ _id: id, colstart: newStart }); } return patches; } // Optional precomputation hook for faster move decisions // This minimal implementation builds a shape you can replace with a smarter index. /** * Prepare precomputed data structures to speed up getMoveChanges on drop. * Replace/extend this as needed; pass the result as `precomp` to getMoveChanges. * * @param {Object} arg * @param {GridState} arg.state * @param {CurrentItem} arg.item * @returns {{ * // Example shape; extend as needed in caller-side implementation * // occupancyByRow: Map<number, (string|null)[]> * // id per col for each row, current item removed * }} */ export function prepareMoveIndex({ state, item }) { const maxColumns = state.columns; const maxRows = state.current?.rows || 1; const positions = state.positions; // Build per-row occupancy arrays (1..maxColumns) mapping col -> item id, // excluding the moving item id for collision checks. /** @type {Map<number, (string|null)[]>} */ const occByRow = new Map(); /** @type {Map<number, Map<number, string>>} */ const rowIndexByRow = new Map(); for (let r = 1; r <= maxRows; r++) { const rowIndex = positions.get(r) || new Map(); const arr = new Array(maxColumns + 1).fill(null); for (let c = 1; c <= maxColumns; c++) { const id = rowIndex.get(c) || null; arr[c] = (id === item._id) ? null : id; } occByRow.set(r, arr); // Provide a rowIndex variant that resolves to actual map, but callers // should rely on occByRow first when available for speed. rowIndexByRow.set(r, rowIndex); } // Precompute next occupied jump tables per row, both east and west. // These help skip empty runs quickly during scans. /** @type {Map<number, Int32Array>} */ const nextOccEast = new Map(); /** @type {Map<number, Int32Array>} */ const nextOccWest = new Map(); for (let r = 1; r <= maxRows; r++) { const arr = occByRow.get(r); const east = new Int32Array(maxColumns + 2); const west = new Int32Array(maxColumns + 2); // East: next index >= i that is occupied (or 0 if none) let next = 0; for (let c = maxColumns; c >= 1; c--) { next = (arr && arr[c + 1]) ? (c + 1) : next; if (arr && arr[c]) { next = c; } east[c] = next; } // West: previous index <= i that is occupied (or 0 if none) let prev = 0; for (let c = 1; c <= maxColumns; c++) { prev = (arr && arr[c - 1]) ? (c - 1) : prev; if (arr && arr[c]) { prev = c; } west[c] = prev; } nextOccEast.set(r, east); nextOccWest.set(r, west); } // Precompute neighbor segments per row: contiguous [start,end] groups by id /** @type {Map<number, Array<{id: string, start: number, end: number}>>} */ const segmentsByRow = new Map(); for (let r = 1; r <= maxRows; r++) { const arr = occByRow.get(r) || []; const segs = []; let c = 1; while (c <= maxColumns) { const id = arr[c]; if (!id) { c += 1; continue; } const start = c; let end = c; while (end + 1 <= maxColumns && arr[end + 1] === id) { end += 1; } segs.push({ id, start, end }); c = end + 1; } segmentsByRow.set(r, segs); } return { occByRow, rowIndexByRow, nextOccEast, nextOccWest, segmentsByRow, maxColumns, maxRows }; } /** * Recalculate the `order` property (zero based index) of all items in the grid * based on their current positions and no matter of their array index * order. * Optionally, pass an new item coordinates (colstart, colspan, rowstart, rowspan) * that will be inserted in the future. The position of the new item is guaranteed * to be valid and within the grid bounds. * * Optionally, pass a deleted item to remove it from the ordering. * * Returns an array of patch objects with _id and order properties. If an item * was passed, it will be included in the result with its proper order (_id * for the new item is optional). * * @param {Object} arg - The parameters for generating the reorder patch. * @param {GridState} arg.state - The current grid state. * @param {CurrentItem} [arg.item] - The item to include in the ordering. * @param {CurrentItem} [arg.deleted] - The item to remove from the ordering * * @returns {{ * _id: string, * order: number * }[]} */ export function getReorderPatch({ state, item, deleted }) { const list = (state.current?.items || []).map(it => ({ _id: it._id, rowstart: it.rowstart ?? 1, colstart: it.colstart ?? 1, order: typeof it.order === 'number' ? it.order : Number.MAX_SAFE_INTEGER })); // If a new item geometry is provided, include it virtually in the ordering if (item && (typeof item.colstart === 'number') && (typeof item.rowstart === 'number')) { list.push({ _id: item._id, rowstart: item.rowstart, colstart: item.colstart, order: Number.MAX_SAFE_INTEGER, isNew: true }); } // Remove deleted item from the list if (deleted?._id) { const index = list.findIndex(it => it._id === deleted._id); if (index !== -1) { list.splice(index, 1); } } // Deterministic ordering: top-to-bottom, then left-to-right, // then by previous order list.sort((a, b) => { if (a.rowstart !== b.rowstart) { return a.rowstart - b.rowstart; } if (a.colstart !== b.colstart) { return a.colstart - b.colstart; } if (a.order !== b.order) { return a.order - b.order; } return 0; }); // Produce zero-based consecutive orders as patches const patches = list.map((it, index) => { const patch = { order: index, _id: it._id }; if (it.isNew) { return Object.assign({}, item, patch); } return patch; }); return patches; } /** * Compute synthetic insertion slots for the current device state. * Each slot represents a potential position where a new item could be placed. * Constraints: * - rowspan is always 1 * - colspan is clamped to [minSpan, defaultSpan] * - choose the largest possible span that fits the contiguous empty segment * - one synthetic per contiguous empty segment (leftmost aligned) * * The returned items include an `_id`, `synthetic: true`, positioning * properties, and an `order` number computed to align with the grid's * deterministic ordering so CSS order works as expected. * * @param {import('./grid-state.mjs').GridState} state * @returns {Array<{ * _id: string, * synthetic: true, * colstart: number, * colspan: number, * rowstart: number, * rowspan: 1, * order: number, * align: string, * justify: string * }>} synthetic items */ export function computeSyntheticSlots(state) { if (!state || !state.current) { return []; } const maxRows = Math.max(1, Number(state.current.rows) || 1); const maxColumns = Math.max(1, Number(state.columns) || 1); const minSpan = Math.max(1, Number(state.options?.minSpan) || 1); const defaultSpan = Math.max(1, Number(state.options?.defaultSpan) || 1); // Collect raw synthetic slot drafts before assigning order const syntheticDrafts = []; for (let r = 1; r <= maxRows; r++) { const xIndex = state.positions.get(r); let c = 1; const isFreeAt = (col) => !(xIndex && xIndex.has(col)); while (c <= maxColumns) { const occupied = !isFreeAt(c); if (occupied) { c += 1; continue; } const start = c; while (c <= maxColumns && isFreeAt(c)) { c += 1; } const end = c - 1; let cur = start; while (cur <= end) { const remaining = end - cur + 1; const span = Math.min(defaultSpan, remaining); const id = `syn-r${r}-c${cur}-w${span}`; syntheticDrafts.push({ _id: id, synthetic: true, toosmall: remaining < minSpan, colstart: cur, colspan: span, rowstart: r, rowspan: 1, // placeholders; will compute order below order: Number.MAX_SAFE_INTEGER, align: 'stretch', justify: 'stretch' }); cur += span; } } } if (!syntheticDrafts.length) { return []; } // Compute a deterministic order for synthetic items alongside existing ones const existing = (state.current?.items || []).map(it => ({ _id: it._id, rowstart: it.rowstart ?? 1, colstart: it.colstart ?? 1, order: typeof it.order === 'number' ? it.order : Number.MAX_SAFE_INTEGER, isSynthetic: false })); const drafts = syntheticDrafts.map(s => ({ _id: s._id, rowstart: s.rowstart, colstart: s.colstart, order: Number.MAX_SAFE_INTEGER, isSynthetic: true })); const merged = existing.concat(drafts); merged.sort((a, b) => { if (a.rowstart !== b.rowstart) { return a.rowstart - b.rowstart; } if (a.colstart !== b.colstart) { return a.colstart - b.colstart; } if (a.order !== b.order) { return a.order - b.order; } return 0; }); // Assign order index