0.35.4
This commit is contained in:
1 parent
c038ad44b3
commit
cd3c28d390
33 files changed
+807
-221
No files matched your search
@@ -9,9 +9,9 @@ window.rustProxyInlineLayout = (() => {
|
||||
const hiddenList = $("overview-inline-hidden-list");
|
||||
const hiddenCount = $("overview-inline-hidden-count");
|
||||
const modalButton = $("overview-layout-open");
|
||||
const compactButton = $("overview-inline-compact");
|
||||
let draft = null;
|
||||
let original = null;
|
||||
let dragging = null;
|
||||
let opening = false;
|
||||
let saving = false;
|
||||
|
||||
@@ -72,10 +72,7 @@ window.rustProxyInlineLayout = (() => {
|
||||
if (widen) widen.disabled = at === widths.length - 1;
|
||||
}
|
||||
}
|
||||
function clearDrag() {
|
||||
dragging = null;
|
||||
for (const node of nodes()) node.classList.remove("is-overview-dragging", "is-overview-drop-before", "is-overview-drop-after");
|
||||
}
|
||||
function clearDrag() { if (pointerDrag) settle(false); }
|
||||
function move(id, target, after = false) {
|
||||
if (!draft || id === target) return;
|
||||
if (!visibleIds().includes(id) || !visibleIds().includes(target)) return;
|
||||
@@ -118,7 +115,7 @@ window.rustProxyInlineLayout = (() => {
|
||||
const title = label(id);
|
||||
const bar = el("div", undefined, "overview-inline-toolbar");
|
||||
const grip = control("⠿", `${t("overviewLayoutInlineDrag")}: ${title}`, "overview-inline-grip", () => { });
|
||||
grip.draggable = true;
|
||||
grip.setAttribute("aria-describedby", "overview-inline-hint");
|
||||
grip.dataset.overviewDragHandle = id;
|
||||
const sizeControls = el("span", undefined, "overview-inline-width");
|
||||
const widthValue = el("span", "", "overview-inline-width-value");
|
||||
@@ -142,6 +139,7 @@ window.rustProxyInlineLayout = (() => {
|
||||
actions.hidden = false;
|
||||
hint.hidden = false;
|
||||
hint.textContent = t("overviewLayoutInlineHint");
|
||||
if (compactButton) compactButton.title = t("overviewLayoutInlineCompactHint");
|
||||
if (hiddenShelf) { hiddenShelf.hidden = false; hiddenShelf.open = false; }
|
||||
if (modalButton) modalButton.disabled = true;
|
||||
for (const node of nodes()) toolbar(node);
|
||||
@@ -195,40 +193,185 @@ window.rustProxyInlineLayout = (() => {
|
||||
finally { saving = false; saveButton.disabled = cancelButton.disabled = unlock.disabled = false; }
|
||||
}
|
||||
function cancel() { if (draft && !saving) finish(false); }
|
||||
function dropTarget(event) {
|
||||
const widget = event.target?.closest?.("[data-dashboard-widget]");
|
||||
return widget && grid.contains(widget) && !widget.hidden && visibleIds().includes(widget.dataset.dashboardWidget) ? widget : null;
|
||||
// Pointer-driven sorting with a live placeholder. The grid never back-fills
|
||||
// holes while a block is lifted, so the placeholder shows exactly where the
|
||||
// block lands; neighbours glide (FLIP) instead of jumping.
|
||||
const DRAG_THRESHOLD = 5, EDGE = 64, MOVE_COOLDOWN_MS = 90;
|
||||
const reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
|
||||
let pointerDrag = null;
|
||||
function flip(mutate) {
|
||||
const before = new Map(nodes().filter(node => !node.hidden && node !== pointerDrag?.node)
|
||||
.map(node => [node, node.getBoundingClientRect()]));
|
||||
mutate();
|
||||
if (reducedMotion()) return;
|
||||
for (const [node, rect] of before) {
|
||||
const after = node.getBoundingClientRect();
|
||||
const dx = rect.left - after.left, dy = rect.top - after.top;
|
||||
if (!dx && !dy) continue;
|
||||
node.animate?.([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "translate(0, 0)" }],
|
||||
{ duration: 160, easing: "cubic-bezier(.2, .7, .3, 1)" });
|
||||
}
|
||||
}
|
||||
function positionAfter(widget, event) {
|
||||
const rect = widget.getBoundingClientRect();
|
||||
return event.clientX >= rect.left + rect.width / 2;
|
||||
function placeholderTarget(x, y) {
|
||||
const drag = pointerDrag;
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const widget = hit?.closest?.("[data-dashboard-widget]");
|
||||
if (widget && grid.contains(widget) && widget !== drag.node && !widget.hidden) return { node: widget };
|
||||
if (hit === drag.placeholder || drag.placeholder.contains(hit)) return null;
|
||||
if (hit !== grid) return null;
|
||||
// Empty grid area (gap or hole at a row end): attach to the nearest block in that row.
|
||||
const candidates = nodes().filter(node => node !== drag.node && !node.hidden);
|
||||
let best = null, bestDistance = Infinity;
|
||||
for (const node of candidates) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (y < rect.top - 8 || y > rect.bottom + 8) continue;
|
||||
const distance = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
|
||||
if (distance < bestDistance) { bestDistance = distance; best = { node, after: x > rect.left + rect.width / 2 }; }
|
||||
}
|
||||
if (best) return best;
|
||||
const last = candidates.at(-1);
|
||||
if (last && y > last.getBoundingClientRect().bottom) return { node: last, after: true };
|
||||
return null;
|
||||
}
|
||||
grid?.addEventListener("dragstart", event => {
|
||||
function updatePlaceholder(x, y) {
|
||||
const drag = pointerDrag;
|
||||
if (performance.now() < drag.cooldownUntil) return;
|
||||
const target = placeholderTarget(x, y);
|
||||
if (!target) return;
|
||||
const children = [...grid.children];
|
||||
const placeholderIndex = children.indexOf(drag.placeholder), targetIndex = children.indexOf(target.node);
|
||||
// Entering a block swaps the placeholder past it; this is stable and never oscillates.
|
||||
const after = target.after ?? placeholderIndex < targetIndex;
|
||||
const reference = after ? target.node.nextSibling : target.node;
|
||||
if (reference === drag.placeholder || (after ? target.node.nextSibling === drag.placeholder : target.node.previousSibling === drag.placeholder)) return;
|
||||
flip(() => grid.insertBefore(drag.placeholder, reference));
|
||||
drag.cooldownUntil = performance.now() + MOVE_COOLDOWN_MS;
|
||||
}
|
||||
function autoScroll() {
|
||||
const drag = pointerDrag;
|
||||
if (!drag?.active) return;
|
||||
const height = window.innerHeight;
|
||||
let speed = 0;
|
||||
if (drag.y < EDGE) speed = -Math.ceil((EDGE - drag.y) / 4);
|
||||
else if (drag.y > height - EDGE) speed = Math.ceil((drag.y - (height - EDGE)) / 4);
|
||||
if (speed) {
|
||||
const scroller = document.scrollingElement || document.documentElement;
|
||||
const previous = scroller.scrollTop;
|
||||
scroller.scrollTop += speed;
|
||||
if (scroller.scrollTop !== previous) updatePlaceholder(drag.x, drag.y);
|
||||
}
|
||||
drag.frame = requestAnimationFrame(autoScroll);
|
||||
}
|
||||
function liftBlock() {
|
||||
const drag = pointerDrag;
|
||||
enterFreeLayout();
|
||||
paint();
|
||||
const rect = drag.node.getBoundingClientRect();
|
||||
const placeholder = el("div", undefined, "overview-drop-placeholder");
|
||||
placeholder.dataset.dashboardSpan = drag.node.dataset.dashboardSpan || "2";
|
||||
placeholder.style.minHeight = `${Math.round(rect.height)}px`;
|
||||
placeholder.setAttribute("aria-hidden", "true");
|
||||
drag.origin = drag.node.nextSibling;
|
||||
drag.placeholder = placeholder;
|
||||
drag.offsetX = drag.startX - rect.left;
|
||||
drag.offsetY = drag.startY - rect.top;
|
||||
drag.width = rect.width;
|
||||
drag.height = rect.height;
|
||||
drag.active = true;
|
||||
drag.cooldownUntil = 0;
|
||||
grid.insertBefore(placeholder, drag.node);
|
||||
grid.classList.add("is-overview-sorting");
|
||||
drag.node.classList.add("is-overview-lifted");
|
||||
Object.assign(drag.node.style, {
|
||||
position: "fixed", left: "0px", top: "0px", width: `${rect.width}px`, height: `${rect.height}px`,
|
||||
zIndex: "60", pointerEvents: "none", margin: "0",
|
||||
});
|
||||
moveLifted();
|
||||
hint.textContent = t("overviewLayoutInlineDragging");
|
||||
drag.frame = requestAnimationFrame(autoScroll);
|
||||
}
|
||||
function moveLifted() {
|
||||
const drag = pointerDrag;
|
||||
drag.node.style.transform = `translate(${drag.x - drag.offsetX}px, ${drag.y - drag.offsetY}px) rotate(.6deg) scale(1.015)`;
|
||||
}
|
||||
function settle(commit) {
|
||||
const drag = pointerDrag;
|
||||
pointerDrag = null;
|
||||
if (!drag) return;
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
window.removeEventListener("pointercancel", onPointerCancel);
|
||||
window.removeEventListener("keydown", onDragKey, true);
|
||||
if (!drag.active) return;
|
||||
cancelAnimationFrame(drag.frame);
|
||||
for (const key of ["position", "left", "top", "width", "height", "zIndex", "pointerEvents", "margin", "transform"]) drag.node.style[key] = "";
|
||||
drag.node.classList.remove("is-overview-lifted");
|
||||
grid.classList.remove("is-overview-sorting");
|
||||
if (commit) drag.placeholder.replaceWith(drag.node);
|
||||
else { drag.placeholder.remove(); grid.insertBefore(drag.node, drag.origin?.parentNode === grid ? drag.origin : null); }
|
||||
hint.textContent = t("overviewLayoutInlineHint");
|
||||
if (commit) {
|
||||
const domOrder = nodes().map(node => node.dataset.dashboardWidget).filter(id => !draft.hidden.has(id));
|
||||
const iter = domOrder.values();
|
||||
draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value);
|
||||
}
|
||||
paint();
|
||||
drag.node.querySelector(".overview-inline-grip")?.focus({ preventScroll: true });
|
||||
drag.node.animate?.([{ boxShadow: "0 0 0 3px var(--accent)" }, { boxShadow: "0 0 0 0 transparent" }], { duration: 450 });
|
||||
}
|
||||
function onPointerMove(event) {
|
||||
const drag = pointerDrag;
|
||||
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||
drag.x = event.clientX; drag.y = event.clientY;
|
||||
if (!drag.active) {
|
||||
if (Math.hypot(drag.x - drag.startX, drag.y - drag.startY) < DRAG_THRESHOLD) return;
|
||||
liftBlock();
|
||||
}
|
||||
event.preventDefault();
|
||||
moveLifted();
|
||||
updatePlaceholder(drag.x, drag.y);
|
||||
}
|
||||
function onPointerUp(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(true); }
|
||||
function onPointerCancel(event) { if (pointerDrag && event.pointerId === pointerDrag.pointerId) settle(false); }
|
||||
function onDragKey(event) { if (event.key === "Escape" && pointerDrag) { event.preventDefault(); event.stopPropagation(); settle(false); } }
|
||||
grid?.addEventListener("pointerdown", event => {
|
||||
const handle = event.target?.closest?.("[data-overview-drag-handle]");
|
||||
if (!draft || !handle || !grid.contains(handle)) { event.preventDefault(); return; }
|
||||
dragging = handle.dataset.overviewDragHandle;
|
||||
event.dataTransfer?.setData("text/plain", dragging);
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||
nodeFor(dragging)?.classList.add("is-overview-dragging");
|
||||
});
|
||||
grid?.addEventListener("dragover", event => {
|
||||
if (!draft || !dragging) return;
|
||||
const target = dropTarget(event);
|
||||
if (!target || target.dataset.dashboardWidget === dragging) return;
|
||||
if (!draft || saving || pointerDrag || !handle || !grid.contains(handle) || event.button !== 0) return;
|
||||
const node = nodeFor(handle.dataset.overviewDragHandle);
|
||||
if (!node) return;
|
||||
event.preventDefault();
|
||||
for (const node of nodes()) node.classList.remove("is-overview-drop-before", "is-overview-drop-after");
|
||||
target.classList.add(positionAfter(target, event) ? "is-overview-drop-after" : "is-overview-drop-before");
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||
pointerDrag = { node, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: event.clientX, y: event.clientY, active: false };
|
||||
window.addEventListener("pointermove", onPointerMove, { passive: false });
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
window.addEventListener("pointercancel", onPointerCancel);
|
||||
window.addEventListener("keydown", onDragKey, true);
|
||||
});
|
||||
grid?.addEventListener("drop", event => {
|
||||
if (!draft || !dragging) return;
|
||||
grid?.addEventListener("keydown", event => {
|
||||
const handle = event.target?.closest?.("[data-overview-drag-handle]");
|
||||
if (!draft || !handle) return;
|
||||
const delta = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key];
|
||||
if (!delta) return;
|
||||
event.preventDefault();
|
||||
const target = dropTarget(event), from = dragging;
|
||||
clearDrag();
|
||||
if (target && target.dataset.dashboardWidget !== from)
|
||||
move(from, target.dataset.dashboardWidget, positionAfter(target, event));
|
||||
nudge(handle.dataset.overviewDragHandle, delta);
|
||||
nodeFor(handle.dataset.overviewDragHandle)?.querySelector(".overview-inline-grip")?.focus();
|
||||
});
|
||||
grid?.addEventListener("dragend", clearDrag);
|
||||
function compact() {
|
||||
// Explicit, one-off gap filling: reads the dense packing the browser would
|
||||
// produce and adopts it as the new order, so nothing moves unexpectedly later.
|
||||
if (!draft) return;
|
||||
enterFreeLayout();
|
||||
paint();
|
||||
flip(() => {
|
||||
grid.style.gridAutoFlow = "row dense";
|
||||
const placed = nodes().filter(node => !node.hidden).map(node => ({ id: node.dataset.dashboardWidget, rect: node.getBoundingClientRect() }));
|
||||
grid.style.gridAutoFlow = "";
|
||||
placed.sort((a, b) => Math.round(a.rect.top) - Math.round(b.rect.top) || a.rect.left - b.rect.left);
|
||||
const iter = placed.map(item => item.id).values();
|
||||
draft.order = draft.order.map(item => draft.hidden.has(item) ? item : iter.next().value);
|
||||
paint();
|
||||
});
|
||||
}
|
||||
compactButton?.addEventListener("click", compact);
|
||||
unlock?.addEventListener("click", () => { if (draft) void save(); else void begin(); });
|
||||
saveButton?.addEventListener("click", () => { void save(); });
|
||||
cancelButton?.addEventListener("click", cancel);
|
||||
|
||||
Reference in new issue
Block a user