update news & events

This commit is contained in:
2025-10-22 09:41:40 +07:00
parent 40cf6fe6f4
commit 8f81545293
348 changed files with 111475 additions and 623 deletions

View File

@ -0,0 +1,601 @@
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();
}
}