update news & event 1
This commit is contained in:
264
TWA-App/wwwroot/js/libs/js-AElementFixed.js
Normal file
264
TWA-App/wwwroot/js/libs/js-AElementFixed.js
Normal file
@ -0,0 +1,264 @@
|
||||
// AElementFixed.snap-up-near-ref.js
|
||||
// - Snap xuống (portal lên body) khi PROBE ra khỏi top viewport (như trước)
|
||||
// - Khi lướt LÊN lại: nếu TARGET (đang ở trong CHA) tiến gần REF (±snapUpProximity quanh ref.bottom+unsnapThreshold) → SNAP ABSOLUTE
|
||||
// - Unsnap khi PROBE vào lại viewport và |ref.top - target.top| > unsnapThreshold (giữ logic cũ)
|
||||
// - Khi đang snap: nếu đáy TARGET chạm/vượt đáy CHA → trả về CHA & pin bottom:0
|
||||
export default class AElementFixed extends window.AObject {
|
||||
constructor(opts = {}) {
|
||||
super();
|
||||
this.o = Object.assign({
|
||||
ref: null, // Element | selector
|
||||
target: null, // Element | selector
|
||||
breakpoint: 992, // < => mobile: unsnap
|
||||
unsnapThreshold: 40, // px: ngưỡng unsnap và offset top khi snap
|
||||
enableTargetBottomSnap: true, // snap nếu target ra đáy viewport (khi đang ở CHA)
|
||||
gutterLeft: 20, // px: left khi pin bottom về CHA
|
||||
snapUpProximity: 24 // px: khoảng "gần" để snap khi lướt lên lại
|
||||
}, opts);
|
||||
|
||||
this.ref = typeof this.o.ref === 'string' ? document.querySelector(this.o.ref) : this.o.ref;
|
||||
this.el = typeof this.o.target === 'string' ? document.querySelector(this.o.target) : this.o.target;
|
||||
if (!this.ref || !this.el) throw new Error('[AElementFixed] Missing ref/target');
|
||||
|
||||
// state
|
||||
this.snapped = false;
|
||||
this._isPortaled = false;
|
||||
this._hostParent = null; // CHA ban đầu
|
||||
this._loopId = null; // rAF: loop khi probe trong viewport (check snap-up & unsnap)
|
||||
this._snapLoopId = null; // rAF: monitor chạm đáy CHA khi đang snap
|
||||
this._needIntroPose = true;// hiệu ứng intro khi snap
|
||||
this._snapWidth = null; // lock width (px) khi portal
|
||||
|
||||
this._bind();
|
||||
this._initObservers();
|
||||
this._onResize();
|
||||
}
|
||||
|
||||
update(opts = {}) {
|
||||
Object.assign(this.o, opts);
|
||||
this._stopInViewLoop();
|
||||
this._stopSnapMonitor();
|
||||
this._teardownObservers();
|
||||
this._initObservers();
|
||||
this._onResize();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._stopInViewLoop();
|
||||
this._stopSnapMonitor();
|
||||
this._teardownObservers();
|
||||
this._unsnapAbsolute();
|
||||
window.removeEventListener('resize', this._onResize);
|
||||
}
|
||||
|
||||
// ===== private =====
|
||||
_bind() {
|
||||
this._onResize = this._onResize.bind(this);
|
||||
window.addEventListener('resize', this._onResize);
|
||||
}
|
||||
|
||||
_onResize() {
|
||||
const desktop = window.innerWidth >= this.o.breakpoint;
|
||||
if (!desktop) {
|
||||
this._stopInViewLoop();
|
||||
this._stopSnapMonitor();
|
||||
this._unsnapAbsolute();
|
||||
}
|
||||
}
|
||||
|
||||
_initObservers() {
|
||||
// PROBE: đặt TRƯỚC target trong CHA để giữ vị trí flow
|
||||
this.probe = document.createElement('div');
|
||||
this.probe.setAttribute('data-aef-probe', '');
|
||||
Object.assign(this.probe.style, { width: '1px', height: '1px', pointerEvents: 'none', opacity: 0 });
|
||||
this.el.parentElement.insertBefore(this.probe, this.el);
|
||||
|
||||
// PROBE với root = viewport
|
||||
this.ioProbeVP = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
// PROBE rời viewport phía TRÊN => SNAP ngay
|
||||
if (!e.isIntersecting && e.boundingClientRect.top < 0) {
|
||||
if (!this.snapped) this._snapAbsolute();
|
||||
this._stopInViewLoop();
|
||||
continue;
|
||||
}
|
||||
// PROBE vào viewport => bật loop để:
|
||||
// - nếu đang snap: xét UNSNAP
|
||||
// - nếu không snap: xét SNAP-UP khi target gần ref
|
||||
if (e.isIntersecting) {
|
||||
this._needIntroPose = true;
|
||||
this._startInViewLoop();
|
||||
}
|
||||
}
|
||||
}, { root: null, threshold: 0 });
|
||||
this.ioProbeVP.observe(this.probe);
|
||||
|
||||
// (Tuỳ chọn) TARGET với root = viewport: out đáy viewport (khi ở CHA) → snap
|
||||
if (this.o.enableTargetBottomSnap) {
|
||||
this.ioTargetVP = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (this.snapped) continue;
|
||||
// Bắt "đáy chạm đáy viewport" (vẫn intersect) để mượt hơn:
|
||||
const vpBottom = e.rootBounds ? e.rootBounds.bottom : document.documentElement.clientHeight;
|
||||
const EPS = 0.75;
|
||||
if (e.isIntersecting) {
|
||||
console.log(e.target.style.bottom);
|
||||
if (e.target.style.bottom === '0px') {
|
||||
this._startInViewLoop();
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { root: null, threshold: [0, 1] });
|
||||
this.ioTargetVP.observe(this.el);
|
||||
}
|
||||
}
|
||||
|
||||
_teardownObservers() {
|
||||
if (this.ioProbeVP) { this.ioProbeVP.disconnect(); this.ioProbeVP = null; }
|
||||
if (this.ioTargetVP) { this.ioTargetVP.disconnect(); this.ioTargetVP = null; }
|
||||
if (this.probe && this.probe.parentNode) this.probe.parentNode.removeChild(this.probe);
|
||||
this.probe = null;
|
||||
}
|
||||
|
||||
// ===== Loop khi PROBE đang trong viewport: xét SNAP-UP & UNSNAP =====
|
||||
_startInViewLoop() {
|
||||
if (this._loopId) return;
|
||||
const tick = () => {
|
||||
const rr = this.ref.getBoundingClientRect();
|
||||
const tr = this.el.getBoundingClientRect();
|
||||
const anchor = rr.bottom + this.o.unsnapThreshold; // mốc bám khi snap
|
||||
|
||||
if (this.snapped) {
|
||||
// UNSNAP: nếu lệch khỏi mốc quá ngưỡng (tránh dính khi user kéo mạnh)
|
||||
const dy = Math.abs(tr.top - rr.top);
|
||||
if (dy > this.o.unsnapThreshold) {
|
||||
this._unsnapAbsolute(); // trả về CHA, giữ width
|
||||
this._stopInViewLoop();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// SNAP-UP khi lướt lên: target gần ref (quanh anchor)
|
||||
const delta = Math.abs(tr.top - anchor);
|
||||
if (delta <= this.o.snapUpProximity) {
|
||||
this._snapAbsolute();
|
||||
// sau snap, tiếp tục monitor chạm đáy CHA; loop này tự dừng khi ioProbe báo ra viewport
|
||||
this._stopInViewLoop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._loopId = requestAnimationFrame(tick);
|
||||
};
|
||||
this._loopId = requestAnimationFrame(tick);
|
||||
}
|
||||
_stopInViewLoop() {
|
||||
if (this._loopId) { cancelAnimationFrame(this._loopId); this._loopId = null; }
|
||||
}
|
||||
|
||||
// ===== Snap monitor: đang snap → nếu đáy target chạm/vượt đáy CHA => pin bottom:0 =====
|
||||
_startSnapMonitor() {
|
||||
if (this._snapLoopId) return;
|
||||
const tickSnap = () => {
|
||||
if (!this.snapped || !this._isPortaled || !this._hostParent) {
|
||||
this._stopSnapMonitor();
|
||||
return;
|
||||
}
|
||||
const pr = this._hostParent.getBoundingClientRect();
|
||||
const er = this.el.getBoundingClientRect();
|
||||
if (er.bottom >= pr.bottom) {
|
||||
this._pinToHostBottom();
|
||||
this._stopSnapMonitor();
|
||||
return;
|
||||
}
|
||||
this._snapLoopId = requestAnimationFrame(tickSnap);
|
||||
};
|
||||
this._snapLoopId = requestAnimationFrame(tickSnap);
|
||||
}
|
||||
_stopSnapMonitor() {
|
||||
if (this._snapLoopId) { cancelAnimationFrame(this._snapLoopId); this._snapLoopId = null; }
|
||||
}
|
||||
|
||||
// ===== helpers =====
|
||||
_portalToBody() {
|
||||
if (this._isPortaled) return;
|
||||
this._hostParent = this.el.parentNode;
|
||||
|
||||
// lock width trước khi portal để không nhảy layout
|
||||
this._snapWidth = this.el.offsetWidth;
|
||||
if (this._snapWidth && Number.isFinite(this._snapWidth)) {
|
||||
this.el.style.width = this._snapWidth + 'px';
|
||||
}
|
||||
|
||||
document.body.appendChild(this.el);
|
||||
this._isPortaled = true;
|
||||
}
|
||||
|
||||
// Trả về CHA: append vào host (đứng sau mọi thứ; nếu probe ở cuối thì tự sau probe)
|
||||
_restoreToHostBeforeProbe() {
|
||||
if (!this._isPortaled) return;
|
||||
if (this._hostParent) this._hostParent.appendChild(this.el);
|
||||
else document.body.appendChild(this.el); // fallback hiếm
|
||||
this._isPortaled = false;
|
||||
// GIỮ style.width để giữ bề ngang như yêu cầu
|
||||
}
|
||||
|
||||
// Pin về CHA bottom:0 (absolute trong CHA), giữ width
|
||||
_pinToHostBottom() {
|
||||
this._restoreToHostBeforeProbe();
|
||||
|
||||
const host = this._hostParent || this.el.parentNode;
|
||||
if (host && getComputedStyle(host).position === 'static') {
|
||||
host.style.position = 'relative';
|
||||
}
|
||||
|
||||
this.el.style.position = 'absolute';
|
||||
this.el.style.left = this.o.gutterLeft + 'px';
|
||||
this.el.style.top = '';
|
||||
this.el.style.bottom = '0px';
|
||||
|
||||
this.snapped = false;
|
||||
}
|
||||
|
||||
// ===== Snap / Unsnap =====
|
||||
_snapAbsolute() {
|
||||
if (this.snapped) return;
|
||||
this.snapped = true;
|
||||
|
||||
// đo trước khi portal để giữ LEFT & intro pose
|
||||
const tRectBefore = this.el.getBoundingClientRect();
|
||||
const rRect = this.ref.getBoundingClientRect();
|
||||
|
||||
const pageLeft = Math.round(tRectBefore.left);
|
||||
const pageTopNow = Math.round(tRectBefore.top + this.o.unsnapThreshold);
|
||||
const pageTopFinal = Math.round(rRect.bottom + this.o.unsnapThreshold);
|
||||
|
||||
// portal lên body (đồng thời lock width)
|
||||
this._portalToBody();
|
||||
|
||||
// intro pose → frame kế tiếp về top final
|
||||
this.el.style.position = 'absolute';
|
||||
this.el.style.left = pageLeft + 'px';
|
||||
this.el.style.top = (this._needIntroPose ? pageTopNow : pageTopFinal) + 'px';
|
||||
this.el.style.bottom = '';
|
||||
|
||||
if (this._needIntroPose) {
|
||||
requestAnimationFrame(() => { this.el.style.top = pageTopFinal + 'px'; });
|
||||
this._needIntroPose = false;
|
||||
}
|
||||
|
||||
this._startSnapMonitor();
|
||||
}
|
||||
|
||||
_unsnapAbsolute() {
|
||||
if (!this.snapped) return;
|
||||
this.snapped = false;
|
||||
|
||||
// không xoá width → giữ nguyên bề ngang
|
||||
this.el.style.position = '';
|
||||
this.el.style.left = '';
|
||||
this.el.style.top = '';
|
||||
this.el.style.bottom = '';
|
||||
|
||||
this._restoreToHostBeforeProbe();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user