So content can clear a fixed top navbar exactly instead of guessing (CSS guesses overlapped on mobile when the bar was taller). Measured on load/resize/orientation from .navbar.is-fixed-top (or [data-wt-fixed-nav]); consumers use padding-top: calc(var(--wt-nav-height) + gap).
216 lines
8.7 KiB
JavaScript
216 lines
8.7 KiB
JavaScript
/* weirsoe-ui.js — small, dependency-free UI behaviours for weirsoe-theme.
|
|
*
|
|
* • Sortable lists: mark a container with [data-wt-sortable] and a
|
|
* data-order-url; its [data-item-id] children become drag-reorderable, and
|
|
* on drop the new order is POSTed as urlencoded `order[]=…` to data-order-url.
|
|
* Add a .drag-handle inside each child as the grab affordance. A clean, solid
|
|
* drag image is used so translucent/blurred card surfaces don't smear.
|
|
*
|
|
* <div data-wt-sortable data-order-url="/list/order">
|
|
* <div data-item-id="1"><span class="drag-handle">⠿</span> …</div>
|
|
* </div>
|
|
*
|
|
* • Copy to clipboard: any [data-copy-link] button copies its attribute value
|
|
* and shows brief "Kopieret ✓" feedback.
|
|
*/
|
|
(function () {
|
|
function afterElement(container, y) {
|
|
var els = Array.prototype.slice.call(
|
|
container.querySelectorAll('[data-item-id]:not(.wt-dragging)')
|
|
);
|
|
var best = { offset: -Infinity, el: null };
|
|
els.forEach(function (child) {
|
|
var box = child.getBoundingClientRect();
|
|
var offset = y - box.top - box.height / 2;
|
|
if (offset < 0 && offset > best.offset) best = { offset: offset, el: child };
|
|
});
|
|
return best.el;
|
|
}
|
|
|
|
function cleanDragImage(el, e) {
|
|
// A solid clone as the drag image — avoids the browser snapshotting the
|
|
// card's translucent/blurred surface + page glow (which looks smeared).
|
|
try {
|
|
var g = el.cloneNode(true);
|
|
g.classList.remove('wt-dragging');
|
|
var cs = getComputedStyle(el);
|
|
var surface = getComputedStyle(document.documentElement)
|
|
.getPropertyValue('--color-surface').trim();
|
|
g.style.position = 'fixed';
|
|
g.style.top = '-9999px';
|
|
g.style.left = '-9999px';
|
|
g.style.width = el.offsetWidth + 'px';
|
|
g.style.margin = '0';
|
|
g.style.opacity = '1';
|
|
g.style.boxShadow = 'none';
|
|
g.style.backdropFilter = 'none';
|
|
g.style.webkitBackdropFilter = 'none';
|
|
g.style.background =
|
|
cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)'
|
|
? cs.backgroundColor
|
|
: surface || '#121a27';
|
|
document.body.appendChild(g);
|
|
e.dataTransfer.setDragImage(g, 16, 16);
|
|
setTimeout(function () { if (g.parentNode) g.parentNode.removeChild(g); }, 0);
|
|
} catch (err) { /* setDragImage unsupported — fall back to default ghost */ }
|
|
}
|
|
|
|
function persist(container) {
|
|
var url = container.getAttribute('data-order-url');
|
|
if (!url) return;
|
|
var ids = Array.prototype.slice
|
|
.call(container.querySelectorAll('[data-item-id]'))
|
|
.map(function (el) { return el.getAttribute('data-item-id'); });
|
|
var body = ids.map(function (id) { return 'order[]=' + encodeURIComponent(id); }).join('&');
|
|
fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: body,
|
|
credentials: 'same-origin',
|
|
}).catch(function () { /* non-fatal */ });
|
|
}
|
|
|
|
function initSortable(container) {
|
|
// ---- desktop: native HTML5 drag-and-drop ----
|
|
var dragEl = null;
|
|
container.querySelectorAll('[data-item-id]').forEach(function (el) {
|
|
el.setAttribute('draggable', 'true');
|
|
el.addEventListener('dragstart', function (e) {
|
|
dragEl = el;
|
|
el.classList.add('wt-dragging');
|
|
document.body.classList.add('wt-sorting');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
cleanDragImage(el, e);
|
|
});
|
|
el.addEventListener('dragend', function () {
|
|
el.classList.remove('wt-dragging');
|
|
document.body.classList.remove('wt-sorting');
|
|
dragEl = null;
|
|
persist(container);
|
|
});
|
|
});
|
|
container.addEventListener('dragover', function (e) {
|
|
e.preventDefault();
|
|
if (!dragEl) return;
|
|
var after = afterElement(container, e.clientY);
|
|
if (after == null) container.appendChild(dragEl);
|
|
else container.insertBefore(dragEl, after);
|
|
});
|
|
|
|
// ---- mobile: long-press lifts the card into a floating clone that follows
|
|
// the finger; the list reorders underneath; holding near a screen edge
|
|
// auto-scrolls. (Native DnD doesn't fire on touch.) Hold ~220ms to lift; a
|
|
// scroll-intent move before that cancels the lift so the page scrolls. ----
|
|
var holdTimer = null, lifted = false, touchEl = null, clone = null;
|
|
var startY = 0, offX = 0, offY = 0, scrollSpeed = 0, raf = null;
|
|
|
|
function startScroll() {
|
|
if (raf) return;
|
|
var tick = function () {
|
|
if (scrollSpeed !== 0) window.scrollBy(0, scrollSpeed);
|
|
raf = requestAnimationFrame(tick);
|
|
};
|
|
raf = requestAnimationFrame(tick);
|
|
}
|
|
function stopScroll() { if (raf) { cancelAnimationFrame(raf); raf = null; } scrollSpeed = 0; }
|
|
|
|
function makeClone(el, x, y) {
|
|
var r = el.getBoundingClientRect();
|
|
offX = x - r.left; offY = y - r.top;
|
|
clone = el.cloneNode(true);
|
|
clone.classList.remove('wt-dragging');
|
|
clone.classList.add('wt-drag-clone');
|
|
clone.style.width = r.width + 'px';
|
|
clone.style.transform = 'translate(' + (x - offX) + 'px,' + (y - offY) + 'px) scale(1.04)';
|
|
document.body.appendChild(clone);
|
|
}
|
|
function moveClone(x, y) {
|
|
if (clone) clone.style.transform = 'translate(' + (x - offX) + 'px,' + (y - offY) + 'px) scale(1.04)';
|
|
}
|
|
function endTouch() {
|
|
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
|
|
if (clone && clone.parentNode) clone.parentNode.removeChild(clone);
|
|
clone = null;
|
|
document.body.classList.remove('wt-sorting');
|
|
if (lifted && touchEl) { touchEl.classList.remove('wt-dragging'); persist(container); }
|
|
lifted = false; touchEl = null; stopScroll();
|
|
}
|
|
|
|
container.querySelectorAll('[data-item-id]').forEach(function (el) {
|
|
el.addEventListener('touchstart', function (e) {
|
|
if (e.touches.length !== 1) return;
|
|
if (e.target.closest('a, button, input, select, textarea, summary, label')) return;
|
|
var t = e.touches[0];
|
|
startY = t.clientY;
|
|
holdTimer = setTimeout(function () {
|
|
lifted = true; touchEl = el;
|
|
el.classList.add('wt-dragging');
|
|
document.body.classList.add('wt-sorting');
|
|
makeClone(el, t.clientX, t.clientY);
|
|
if (navigator.vibrate) navigator.vibrate(12);
|
|
startScroll();
|
|
}, 220);
|
|
}, { passive: true });
|
|
});
|
|
|
|
container.addEventListener('touchmove', function (e) {
|
|
if (!lifted) {
|
|
if (holdTimer && Math.abs(e.touches[0].clientY - startY) > 12) {
|
|
clearTimeout(holdTimer); holdTimer = null; // moved first → it's a scroll
|
|
}
|
|
return;
|
|
}
|
|
if (!touchEl) return;
|
|
e.preventDefault(); // we own the gesture now
|
|
var t = e.touches[0];
|
|
moveClone(t.clientX, t.clientY);
|
|
var after = afterElement(container, t.clientY);
|
|
if (after == null) container.appendChild(touchEl);
|
|
else container.insertBefore(touchEl, after);
|
|
// edge auto-scroll — generous zone so it triggers well before the edge
|
|
var edge = 130, vh = window.innerHeight;
|
|
if (t.clientY < edge) scrollSpeed = -Math.max(6, Math.ceil((edge - t.clientY) / 4));
|
|
else if (t.clientY > vh - edge) scrollSpeed = Math.max(6, Math.ceil((t.clientY - (vh - edge)) / 4));
|
|
else scrollSpeed = 0;
|
|
}, { passive: false });
|
|
|
|
container.addEventListener('touchend', endTouch);
|
|
container.addEventListener('touchcancel', endTouch);
|
|
}
|
|
|
|
function wire() {
|
|
document.querySelectorAll('[data-wt-sortable]').forEach(initSortable);
|
|
}
|
|
|
|
// Publish the real fixed-navbar height as --wt-nav-height so content can clear
|
|
// it exactly (CSS guesses overlap when the navbar is taller than assumed).
|
|
function setNavHeight() {
|
|
var nav = document.querySelector('[data-wt-fixed-nav], .navbar.is-fixed-top');
|
|
if (nav) document.documentElement.style.setProperty('--wt-nav-height', nav.offsetHeight + 'px');
|
|
}
|
|
setNavHeight();
|
|
window.addEventListener('load', setNavHeight);
|
|
window.addEventListener('resize', setNavHeight);
|
|
window.addEventListener('orientationchange', setNavHeight);
|
|
|
|
document.addEventListener('click', function (e) {
|
|
var btn = e.target.closest && e.target.closest('[data-copy-link]');
|
|
if (!btn) return;
|
|
e.preventDefault();
|
|
var text = btn.getAttribute('data-copy-link');
|
|
var done = function () {
|
|
var old = btn.textContent;
|
|
btn.textContent = 'Kopieret ✓';
|
|
setTimeout(function () { btn.textContent = old; }, 1600);
|
|
};
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(done, function () { window.prompt('Kopiér linket:', text); });
|
|
} else {
|
|
window.prompt('Kopiér linket:', text);
|
|
}
|
|
});
|
|
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
|
|
else wire();
|
|
})();
|