UNPKG

masonry-snap-grid-layout

Version:

A performant, responsive masonry layout library with smooth animations, dynamic columns, and zero dependencies.

264 lines (261 loc) 9.01 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { default: () => index_default }); module.exports = __toCommonJS(index_exports); // src/MasonrySnapGridLayout.ts var MasonrySnapGridLayout = class { constructor(container, options) { // Active DOM elements currently in the layout this.items = []; // Running height for each column (used for placement calculations) this.columnHeights = []; // Tracks a pending animation frame request for layout updates this.rafId = null; // Cache last measured container width to avoid unnecessary relayouts this.lastContainerWidth = 0; // Pool of DOM elements for recycling between renders (avoids costly re-creation) this.itemPool = []; // Flag to prevent operations after destruction this.isDestroyed = false; if (!container) { throw new Error("Container element is required"); } this.container = container; this.options = { gutter: 16, minColWidth: 250, animate: true, transitionDuration: 400, classNames: { container: "masonry-snap-grid-container", item: "masonry-snap-grid-item" }, ...options }; this.init(); } /** * Initialize layout: applies base classes, renders initial items, * and sets up resize monitoring. */ init() { if (this.isDestroyed) return; this.container.classList.add(this.options.classNames.container || ""); this.renderItems(); this.setupResizeObserver(); } /** * Renders items into the container using a pooled DOM strategy: * - Avoids DOM churn by reusing elements where possible * - Only creates new nodes when needed * - Removes unused pool items when shrinking */ renderItems() { if (this.isDestroyed) return; this.items.forEach((item) => { if (!this.options.items.some((_, i) => this.itemPool[i] === item)) { item.remove(); } }); this.items = []; this.columnHeights = []; const fragment = document.createDocumentFragment(); this.options.items.forEach((itemData, index) => { let itemElement = this.itemPool[index]; if (!itemElement) { itemElement = document.createElement("div"); itemElement.classList.add(this.options.classNames.item || ""); this.itemPool[index] = itemElement; } const content = this.options.renderItem(itemData); if (typeof content === "string") { itemElement.innerHTML = content; } else if (content instanceof Node) { itemElement.innerHTML = ""; itemElement.appendChild(content); } fragment.appendChild(itemElement); this.items.push(itemElement); }); while (this.itemPool.length > this.options.items.length) { const item = this.itemPool.pop(); item.remove(); } this.container.appendChild(fragment); this.updateLayout(); } /** * Sets up a ResizeObserver on the container to trigger re-layout * when width changes — throttled to animation frames for performance. */ setupResizeObserver() { if (this.resizeObserver) { this.resizeObserver.disconnect(); } this.resizeObserver = new ResizeObserver(() => { if (this.rafId) cancelAnimationFrame(this.rafId); this.rafId = requestAnimationFrame(() => { const newWidth = this.container.clientWidth; if (newWidth !== this.lastContainerWidth) { this.lastContainerWidth = newWidth; this.updateLayout(); } }); }); this.resizeObserver.observe(this.container); } /** * Core layout function: * - Calculates number of columns based on container width & min column width * - Measures all items to avoid forced reflows during positioning * - Positions items in the shortest column to maintain balance */ updateLayout() { if (this.isDestroyed || !this.container.isConnected) return; try { const { gutter, minColWidth, animate, transitionDuration } = this.options; const containerWidth = this.container.clientWidth; if (containerWidth <= 0) { this.container.style.height = "0"; return; } const columns = Math.max(1, Math.floor((containerWidth + gutter) / (minColWidth + gutter))); const colWidth = (containerWidth - (columns - 1) * gutter) / columns; this.columnHeights = new Array(columns).fill(0); const itemHeights = this.measureItems(colWidth); this.positionItems(colWidth, gutter, animate, transitionDuration, itemHeights); this.setContainerHeight(gutter); } catch (error) { console.error("Masonry layout failed:", error); this.applyFallbackLayout(); } } /** * Measures item heights without affecting layout: * - Temporarily forces block layout for accurate measurement * - Restores original styles after measuring */ measureItems(colWidth) { return this.items.map((item) => { const originalStyles = { display: item.style.display, visibility: item.style.visibility, position: item.style.position, width: item.style.width }; item.style.display = "block"; item.style.visibility = "hidden"; item.style.position = "absolute"; item.style.width = `${colWidth}px`; const height = item.offsetHeight; Object.assign(item.style, originalStyles); return height; }); } /** * Positions items column-by-column: * - Chooses the shortest column for each item to maintain balance * - Uses transform for GPU-accelerated positioning */ positionItems(colWidth, gutter, animate, transitionDuration, itemHeights) { this.items.forEach((item, index) => { const height = itemHeights[index]; const minCol = this.findShortestColumn(); const x = minCol * (colWidth + gutter); const y = this.columnHeights[minCol]; item.style.width = `${colWidth}px`; item.style.transform = `translate3d(${x}px, ${y}px, 0)`; item.style.transition = animate ? `transform ${transitionDuration}ms ease` : "none"; item.style.willChange = "transform"; this.columnHeights[minCol] += height + gutter; }); } /** * Sets the container height to match the tallest column * while subtracting trailing gutter space for a clean edge. */ setContainerHeight(gutter) { const maxHeight = Math.max(0, ...this.columnHeights); const containerHeight = maxHeight > 0 ? maxHeight - gutter : 0; this.container.style.height = `${containerHeight}px`; } /** * Simple fallback layout in case the Masonry calculation fails: * stacks items vertically in one column. */ applyFallbackLayout() { let top = 0; this.items.forEach((item) => { item.style.transform = `translate3d(0, ${top}px, 0)`; top += item.offsetHeight + this.options.gutter; }); this.container.style.height = `${top - this.options.gutter}px`; } /** * Finds the column with the least accumulated height. */ findShortestColumn() { let minIndex = 0; let minHeight = Infinity; this.columnHeights.forEach((height, index) => { if (height < minHeight) { minHeight = height; minIndex = index; } }); return minIndex; } /** * Public method to replace current items and trigger a full re-render. */ updateItems(newItems) { if (this.isDestroyed) return; this.options.items = newItems; this.renderItems(); } /** * Cleanly tears down the layout: * - Stops observing size changes * - Cancels pending animation frames * - Clears DOM references and resets container */ destroy() { if (this.isDestroyed) return; this.isDestroyed = true; this.resizeObserver?.disconnect(); this.resizeObserver = void 0; if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; } this.container.innerHTML = ""; this.container.removeAttribute("style"); this.container.classList.remove(this.options.classNames.container || ""); this.items = []; this.columnHeights = []; this.itemPool = []; } }; // src/index.ts var index_default = MasonrySnapGridLayout; //# sourceMappingURL=index.js.map