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.
This commit is contained in:
+178
@@ -0,0 +1,178 @@
|
||||
/* 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();
|
||||
})();
|
||||
Reference in New Issue
Block a user