export default class AGrid extends window.AObject { constructor(options) { super(); const defaultO = { element: ".AGrid", gridType: "row", hasRatio: false, desiredHeight: 200, gutter: 20 }; this._o = { ...defaultO, ...options }; this.ele = (typeof this._o.element === "string") ? document.querySelector(this._o.element) : this._o.element; if (!this.ele) throw new Error("AGrid: element not found"); this.timeout = false; this.ticking = false; this.isRender = false; this.loaded = false; this.cancelFunc = null; this.cancelResize = null; this.addSystemEvent("resize", window, this.reLayout.bind(this)); window.app?.on?.("App_Scrolling", ((_) => { if (window.app.checkVisible(this.ele) && !this.isRender) this.renderAnimation(); }).bind(this)); this.reElement(); } observerInit(options) { this._scrollEnd = { ... { enabled: true, // tắt/mở tính năng root: null, // null = viewport; có thể truyền 1 element scroll container rootMargin: "0px 0px 200px 0px", // nới 200px ở đáy để bắt sớm threshold: 0.01, // chỉ cần chạm nhẹ là báo onceUntilAppend: true // chỉ bắn 1 lần cho mỗi lần append; reset khi append thêm }, ...options }; this._io = null; // IntersectionObserver instance this._ioTriggered = false; } _setupScrollEndIO() { if (!this._scrollEnd.enabled) return; if (this._io) return; this._io = new IntersectionObserver(this._onScrollEndIntersect.bind(this), { root: this._scrollEnd.root || null, rootMargin: this._scrollEnd.rootMargin, threshold: this._scrollEnd.threshold }); } _getLastGridItemEl() { // Ưu tiên DOM order (an toàn cho append) let el = this.ele.querySelector(".grid-item:last-of-type"); if (el) return el; // Fallback theo Map (key lớn nhất) if (this.listGrid && this.listGrid.size) { let last = null, lastKey = -Infinity; for (const [k, rec] of this.listGrid) { if (k > lastKey) { lastKey = k; last = rec; } } if (last) return last.gridEl; } return null; } // Bắt đầu quan sát item cuối _observeLastItem() { if (!this._scrollEnd?.enabled) return; this._setupScrollEndIO(); if (!this._io) return; const lastEl = this._getLastGridItemEl(); this._io.disconnect(); // luôn quan sát lại đúng item cuối if (lastEl) this._io.observe(lastEl); // Nếu dùng chế độ bắn 1 lần đến khi append thêm: if (this._scrollEnd.onceUntilAppend) this._ioTriggered = false; } // Xử lý khi giao cắt _onScrollEndIntersect(entries) { for (const entry of entries) { if (!entry.isIntersecting) continue; if (this._ioTriggered && this._scrollEnd.onceUntilAppend) return; this._ioTriggered = true; //console.log(this); this.trigger("scrollEnd", { instance: this }); // Nếu không dùng onceUntilAppend thì vẫn muốn chống spam khi còn trong khung: if (!this._scrollEnd.onceUntilAppend) { setTimeout(() => { this._ioTriggered = false; }, 800); } } } // Public: cho phép tay reset cờ (nếu cần) resetScrollEndTrigger() { this._ioTriggered = false; } // Public: tắt/bật nhanh enableScrollEnd(v = true) { this._scrollEnd.enabled = !!v; if (!v && this._io) { this._io.disconnect(); } if (v) { this._setupScrollEndIO(); this._observeLastItem(); } } // ===== Khởi tạo lại list từ DOM hiện có ===== reElement() { this.gridWidth = this.ele.clientWidth; if (this._o.gridType === "waterfall") { this.initCols(); } this.listGrid = new Map(); this.keyI = 1; const imageLoadPromises = []; this.ele.querySelectorAll(".grid-item").forEach((el) => { const img = el.querySelector("img.mImg"); const key = this.keyI; this.listGrid.set(key, { gridEl: el, imgEl: img || null, aRatio: el.hasAttribute("aRatio") ? parseFloat(el.getAttribute("aRatio")) || 0 : 0, status: 0, bounding: el.getBoundingClientRect() }); const p = new Promise((resolve) => { if (!img) { const rec = this.listGrid.get(key); rec.status = 1; if (!rec.aRatio || !isFinite(rec.aRatio)) { rec.aRatio = 1; rec.gridEl.setAttribute("aRatio", rec.aRatio); } resolve(); return; } const done = () => { const rec = this.listGrid.get(key); rec.status = 1; if (img.naturalWidth > 0 && img.naturalHeight > 0) { rec.aRatio = img.naturalWidth / img.naturalHeight; } else if (!rec.aRatio || !isFinite(rec.aRatio)) { rec.aRatio = 1; } rec.gridEl.setAttribute("aRatio", rec.aRatio); rec.bounding = img.getBoundingClientRect(); this.removeSystemEvent?.("load", img, onLoad); this.removeSystemEvent?.("error", img, onError); resolve(); }; const onLoad = () => done(); const onError = () => { const r = this.listGrid.get(key); r.status = -1; done(); }; if (typeof img.decode === "function") { img.decode().then(done).catch(() => { if (img.complete) done(); else { this.addSystemEvent?.("load", img, onLoad); this.addSystemEvent?.("error", img, onError); } }); } else if (img.complete) done(); else { this.addSystemEvent?.("load", img, onLoad); this.addSystemEvent?.("error", img, onError); } }); imageLoadPromises.push(p); this.keyI++; }); Promise.allSettled(imageLoadPromises).then(() => { this.renderAnimation(); window.requestTimeout?.(this.layoutInit.bind(this), 100); }); } appendElement(input) { const items = this._normalizeToElements(input); if (!items.length) return; if (!this.listGrid) this.listGrid = new Map(); if (typeof this.keyI !== "number" || this.keyI < 1) this.keyI = this.listGrid.size + 1; const newKeys = []; for (const el of items) { if (!el.classList.contains("grid-item")) el.classList.add("grid-item"); this.ele.appendChild(el); const { key, rec } = this._registerItemSync(el); // đăng ký ngay (đồng bộ) newKeys.push(key); // ĐẶT VỊ TRÍ NGAY (không chờ ảnh) — giữ tối ưu single append if (this._o.gridType === "waterfall") { this._appendOptimizedSingleWaterfall(key, rec); this._observeLastItem(); } else { if (!this.listRows?.length) { this.isRender = false; this.layoutInit(); this.renderAnimation?.(); } else this._appendOptimizedSingleRow(key, rec); } // BẮT ĐẦU load ảnh BẤT ĐỒNG BỘ (nếu có) if (rec.imgEl) this._watchImageAsync(key, rec.imgEl); } if (newKeys.length > 1) { this.isRender = false; this.layoutInit(); if (window.app?.checkVisible?.(this.ele)) this.renderAnimation(); } } // Đăng ký item ngay lập tức + aRatio tạm nếu cần _registerItemSync(el) { el.style.width = this.colW + "px"; const img = el.querySelector("img.mImg"); const key = this.keyI++; const rec = { gridEl: el, imgEl: img || null, aRatio: el.hasAttribute("aRatio") ? parseFloat(el.getAttribute("aRatio")) : 0, status: 0, bounding: el.getBoundingClientRect() }; if (img && img.complete && img.naturalWidth > 0 && img.naturalHeight > 0) { // ảnh đã sẵn trong cache → dùng luôn tỉ lệ thật rec.aRatio = img.naturalWidth / img.naturalHeight; rec.status = 1; el.setAttribute("aRatio", rec.aRatio); rec.bounding = img.getBoundingClientRect(); } else { // dùng tạm tỉ lệ 1 nếu thiếu if (!rec.aRatio || !isFinite(rec.aRatio)) { rec.aRatio = 1; el.setAttribute("aRatio", rec.aRatio); } rec.status = img ? 0 : 1; // 0: đang chờ ảnh, 1: không có ảnh } this.listGrid.set(key, rec); return { key, rec }; } // Theo dõi load ảnh (BẤT ĐỒNG BỘ) → cập nhật tỉ lệ & re-layout (debounce) _watchImageAsync(key, img) { let done = false; const finalize = (ok) => { if (done) return; done = true; const rec = this.listGrid.get(key); if (!rec) return; if (ok && img.naturalWidth > 0 && img.naturalHeight > 0) { rec.aRatio = img.naturalWidth / img.naturalHeight; rec.gridEl.setAttribute("aRatio", rec.aRatio); rec.bounding = img.getBoundingClientRect(); rec.status = 1; } else { // lỗi ảnh → giữ aRatio hiện tại (đã có), mark lỗi if (!rec.aRatio || !isFinite(rec.aRatio)) { rec.aRatio = 1; rec.gridEl.setAttribute("aRatio", rec.aRatio); } rec.status = -1; } this.layoutInit(); // reflow lại sau khi ảnh sẵn sàng }; const onLoad = () => { cleanup(); finalize(true); }; const onError = () => { cleanup(); finalize(false); }; const cleanup = () => { img.removeEventListener("load", onLoad); img.removeEventListener("error", onError); if (timer) clearTimeout(timer); }; // Ưu tiên decode (async), fallback event load/error if (typeof img.decode === "function") { img.decode().then(() => finalize(true)).catch(() => { if (img.complete) finalize(true); else { img.addEventListener("load", onLoad, { once: true }); img.addEventListener("error", onError, { once: true }); } }); } else if (img.complete) { finalize(true); } else { img.addEventListener("load", onLoad, { once: true }); img.addEventListener("error", onError, { once: true }); } // Fallback timeout nếu load/error không bắn /// const timer = setTimeout(() => finalize(false), 8000); } _normalizeToElements(input) { if (typeof input === "string") { const tpl = document.createElement("template"); tpl.innerHTML = input.trim(); return Array.from(tpl.content.children); } if (input instanceof HTMLElement) return [input]; if (input instanceof DocumentFragment) return Array.from(input.children); if (input && (input instanceof NodeList || input instanceof HTMLCollection || Array.isArray(input))) { return Array.from(input).filter(n => n instanceof HTMLElement); } return []; } // ===== Layout chung ===== layoutInit() { this.gridWidth = this.ele.clientWidth; this.isRender = false; if (this._o.gridType === "row") { this.initRows(); this.layoutRows(); } else { this.initCols(); this.restructCol(); this.layoutCols(); } this._observeLastItem(); } restructCol() { this.ele.style.height = 'auto'; this.listGrid.forEach((v, k) => { v.gridEl.style.top = ''; v.gridEl.style.left = ''; v.gridEl.style.width = this.colW; v.gridEl.style.height = ''; v.gridEl.style.position = "relative"; v.bounding = v.gridEl.getBoundingClientRect(); }); } reLayout() { if (this.cancelResize) this.cancelResize(); if (!this.ticking) { if (this.cancelFunc) this.cancelFunc(); const animationId = window.requestAnimationFrame(() => { this.isRender = false; this.sTime = new Date(); this.timeout = false; this.ticking = true; this.layoutInit(); this.ticking = false; }); this.cancelFunc = (() => { cancelAnimationFrame(animationId); }); window.requestTimeout(() => { if (this.cancelFunc) this.cancelFunc(); this.layoutInit(); }, 500, (t) => { this.cancelResize = t; }); } } // ===== ROW (Justified) ===== initRows() { this.listRows = []; this.listRows.push(this.structureRow(this._o.desiredHeight)); } layoutRows() { let nRow = 0; this.listGrid.forEach((_, k) => { nRow = this.pushEleToRow(nRow, k); }); this.ele.style.height = this.calcPosTop(0, this.listRows.length) + "px"; if (window.app?.checkVisible?.(this.ele)) this.renderAnimation(); } pushEleToRow(nRow, k) { const grid = this.listGrid.get(k); const cRow = this.listRows[nRow]; const t1 = this._o.desiredHeight * (grid.aRatio * 1); const denom = this.gridWidth - ((cRow.listElement.length - 1) * (1 * this._o.gutter)); const errorH = (cRow.rowW + t1) / (denom <= 0 ? 1 : denom); if (errorH > 1.2 && cRow.listElement.length !== 0) { cRow.rowH = this.reCalcWidth(cRow); this.listRows.push(this.structureRow(this._o.desiredHeight)); return this.pushEleToRow(nRow + 1, k); } const g = (cRow.listElement.length > 0) ? this._o.gutter * 1 : 0; cRow.listElement.push({ key: k, top: this.calcPosTop(0, nRow), left: (cRow.rowW + g), render: false }); cRow.rowW += t1; cRow.numM = k; if (errorH <= 1.2 && errorH >= 0.8) cRow.rowH = this.reCalcWidth(cRow); return nRow; } calcPosTop(begin, end) { if (begin === 0 && end === 0) return 0; let rowH = 0; for (let i = begin; i <= end - 1; i++) rowH += this.listRows[i].rowH + 1 * this._o.gutter; return rowH; } reCalcWidth(cRow) { const rowW = this.gridWidth - (cRow.listElement.length - 1) * this._o.gutter * 1; let tRate = 0; for (let i = 0; i < cRow.listElement.length; i++) tRate += this.listGrid.get(cRow.listElement[i].key).aRatio * 1; const rrowH = rowW / (tRate === 0 ? 1 : tRate); cRow.rowW = 0; for (let i = 0; i < cRow.listElement.length; i++) { if (i === 0) cRow.listElement[i].left = 0; else { const prev = cRow.listElement[i - 1]; const wPrev = (this.listGrid.get(prev.key).aRatio * 1) * rrowH; cRow.rowW += wPrev; cRow.listElement[i].left = prev.left + wPrev + this._o.gutter * 1; } } return rrowH; } _appendOptimizedSingleRow(key, rec) { let lastIdx = this.listRows.length - 1; if (lastIdx < 0) { this.initRows(); lastIdx = 0; } const cRow = this.listRows[lastIdx]; const t1 = this._o.desiredHeight * (rec.aRatio || 1); const denom = this.gridWidth - (cRow.listElement.length - 1) * this._o.gutter; const errorH = (cRow.rowW + t1) / (denom <= 0 ? 1 : denom); const topBase = this.calcPosTop(0, lastIdx); if (errorH > 1.2 && cRow.listElement.length !== 0) { cRow.rowH = this.reCalcWidth(cRow); // render lại row cũ for (let i = 0; i < cRow.listElement.length; i++) { const it = cRow.listElement[i]; const g = this.listGrid.get(it.key); const w = cRow.rowH * (g.aRatio || 1); this._applyGridStyle(g.gridEl, it.top, it.left, w, cRow.rowH); if (g.imgEl) { g.imgEl.style.width = `${w.toFixed(2)}px`; g.imgEl.style.height = `${(+cRow.rowH).toFixed(2)}px`; } } const newRow = this.structureRow(this._o.desiredHeight); this.listRows.push(newRow); const topNew = this.calcPosTop(0, this.listRows.length - 1); const leftNew = 0; newRow.listElement.push({ key, top: topNew, left: leftNew, render: false }); newRow.rowW += t1; const wNew = this._o.desiredHeight * (rec.aRatio || 1); this._applyGridStyle(rec.gridEl, topNew, leftNew, wNew, this._o.desiredHeight); } else { const gspace = cRow.listElement.length > 0 ? this._o.gutter : 0; const left = cRow.rowW + gspace; cRow.listElement.push({ key, top: topBase, left, render: false }); cRow.rowW += t1; if (errorH <= 1.2 && errorH >= 0.8) cRow.rowH = this.reCalcWidth(cRow); for (let i = 0; i < cRow.listElement.length; i++) { const it = cRow.listElement[i]; const g = this.listGrid.get(it.key); const w = cRow.rowH * (g.aRatio || 1); this._applyGridStyle(g.gridEl, it.top, it.left, w, cRow.rowH); if (g.imgEl) { g.imgEl.style.width = `${w.toFixed(2)}px`; g.imgEl.style.height = `${(+cRow.rowH).toFixed(2)}px`; } } } this.ele.style.height = this.calcPosTop(0, this.listRows.length) + "px"; this.isRender = true; } // ===== WATERFALL (Masonry) ===== initCols() { this.wWidth = window.innerWidth || document.body.clientWidth; const colNum = this._resolveColNum(this.wWidth); this._buildCols(colNum, this.gridWidth); } layoutCols() { // phân bổ tuần tự sang cột (theo code gốc) let i = 0, col = 0, l = 0; this.listCols.forEach(c => { c.listElement = []; c.colH = 0; }); this.listGrid.forEach((v, k) => { let t = 0, cVal; const pC = v.gridEl.querySelector(".grid-content"); let hC = pC ? pC.clientHeight : 0; if (i % this.listCols.length === 0) { col = 0; cVal = this.listCols[col]; l = 0; } else { col++; cVal = this.listCols[col]; l += this.colW + 1 * this._o.gutter; } t = cVal.colH; hC = hC + this.colW / (1 * v.aRatio); // this._applyGridStyle(v.gridEl, t, l, this.colW, hC); cVal.listElement.push({ key: k, top: t, left: l, height: hC, render: false }); cVal.colH = t + hC + 1 * this._o.gutter; i++; }); this.ele.style.height = this.getColMaxHeight() + "px"; if (window.app?.checkVisible?.(this.ele)) this.renderAnimation(); } _resolveColNum(width) { const bp = this._o.breakpoints; if (bp && typeof bp === "object") { const keys = Object.keys(bp) .map(k => +k) .filter(Number.isFinite) .sort((a, b) => b - a); for (const k of keys) { if (width >= k) { const c = +bp[k]?.col; if (c > 0) return c; } } } return 1; } _buildCols(colNum, width) { const gutter = Number(this._o.gutter) || 0; this.colW = (width - (colNum - 1) * gutter) / colNum; this.listCols = []; let l = 0; for (let i = 0; i < colNum; i++) { this.listCols.push({ listElement: [], colH: 0, left: l, colW: this.colW }); l += this.colW + 1 * this._o.gutter; } } _appendOptimizedSingleWaterfall(key, rec) { // Chọn cột thấp nhấtlistCols let minIdx = 0; for (let i = 1; i < this.listCols.length; i++) { if (this.listCols[i].colH < this.listCols[minIdx].colH) minIdx = i; } const col = this.listCols[minIdx]; const pC = rec.gridEl.querySelector(".grid-content"); const hC = pC ? pC.getBoundingClientRect().height : 0; const height = (rec.aRatio == 0) ? rec.gridEl.clientHeight : hC + (this.colW / (1 * rec.aRatio)); const top = col.colH; const left = col.left; col.listElement.push({ key, top, left, height, render: true }); col.colH = top + height + 1 * (this._o.gutter || 0); this._applyGridStyle(rec.gridEl, top, left, this.colW, height); this.ele.style.height = this.getColMaxHeight() + "px"; this.isRender = true; } getColMaxHeight() { let m = 0; for (let i = 0; i < this.listCols.length; i++) if (m <= this.listCols[i].colH) m = this.listCols[i].colH; return m; } // ===== Render chung ===== renderAnimation() { this.isRender = true; if (this._o.gridType === "row") { if (this.listRows == null) return; this.listRows.forEach((row) => { for (let i = 0; i < row.listElement.length; i++) { const it = row.listElement[i]; const grid = this.listGrid.get(it.key); const w = row.rowH * (grid.aRatio || 1); this._applyGridStyle(grid.gridEl, it.top, it.left, w, row.rowH); if (grid.imgEl) { grid.imgEl.style.width = `${w.toFixed(2)}px`; grid.imgEl.style.height = `${(+row.rowH).toFixed(2)}px`; } } }); } else { if (this.listCols == null) return; this.listCols.forEach((col) => { for (let i = 0; i < col.listElement.length; i++) { const it = col.listElement[i]; const grid = this.listGrid.get(it.key); this._applyGridStyle(grid.gridEl, it.top, it.left, this.colW, it.height); } }); } } _applyGridStyle(el, top, left, width, height) { const s = el.style; if (s.position !== "absolute") s.position = "absolute"; s.top = `${(+top).toFixed(2)}px`; s.left = `${(+left).toFixed(2)}px`; s.width = `${(+width).toFixed(2)}px`; s.height = `${(+height).toFixed(2)}px`; } structureCol(colN) { return { listElement: [], colNum: colN, colH: 0, colW: 0 }; } structureRow(rowH = 0) { return { listElement: [], rowH, status: 0, numM: 0, rowW: 0 }; } // ===== Utility ===== empty() { this.ele.innerHTML = ''; this.listGrid?.clear(); this.listRows = []; this.listCols = []; this.keyI = 1; this.isRender = false; this.gridWidth = 0; this.ele.style.height = '0px'; this.gridWidth = this.ele.clientWidth; this.initCols(); } }