Files
Weircon-wind/weirsoe-ui.js
T
Asger Geel Weirsøe a04a3c0970 weirsoe-ui.js: reusable sortable (desktop DnD + clean drag image + mobile touch) and copy-link
Generic drag-and-drop sortable list + copy-to-clipboard, extracted from the wish
app so any consumer can reuse it. [data-wt-sortable] + data-order-url on a
container; [data-item-id] children; .drag-handle affordance. Desktop uses native
DnD with a solid drag image (no smeared translucent ghost); mobile uses
long-press to lift + drag-reorder + edge auto-scroll. Plus .drag-handle/.wt-dragging CSS.
2026-06-14 18:10:00 +02:00

179 lines
7.0 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');
e.dataTransfer.effectAllowed = 'move';
cleanDragImage(el, e);
});
el.addEventListener('dragend', function () {
el.classList.remove('wt-dragging');
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 to lift, drag to reorder, edge auto-scroll ----
// (native DnD doesn't fire on touch). Hold ~220ms to lift; a scroll-intent
// move before that cancels the lift so the page still scrolls normally.
var holdTimer = null, lifted = false, touchEl = null, startY = 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 endTouch() {
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
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;
// don't hijack taps on interactive controls
if (e.target.closest('a, button, input, select, textarea, summary, label')) return;
startY = e.touches[0].clientY;
holdTimer = setTimeout(function () {
lifted = true; touchEl = el; el.classList.add('wt-dragging');
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; stop the page from scrolling under us
var y = e.touches[0].clientY;
var after = afterElement(container, y);
if (after == null) container.appendChild(touchEl);
else container.insertBefore(touchEl, after);
var edge = 90, vh = window.innerHeight;
if (y < edge) scrollSpeed = -Math.ceil((edge - y) / 5);
else if (y > vh - edge) scrollSpeed = Math.ceil((y - (vh - edge)) / 5);
else scrollSpeed = 0;
}, { passive: false });
container.addEventListener('touchend', endTouch);
container.addEventListener('touchcancel', endTouch);
}
function wire() {
document.querySelectorAll('[data-wt-sortable]').forEach(initSortable);
}
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();
})();