super-buckets, dnd insert cursor for cards + buckets, drag-state survives polls
Super-buckets: a bucket can act as a dropdown of nested children, 1-level deep, created by dragging one bucket onto the center of another. The parent shows up as a single tile in the bar with a stack icon and the active member's name; clicking opens a popover that lists parent + all children. Selecting an entry sets state.activeBucket. Implementation: * src/Database.php — ALTER TABLE buckets ADD COLUMN parent_id + index. * public/index.php — PATCH /api/buckets accepts parent_id with depth / cycle / collab guards. DELETE on a parent un-nests its children rather than cascade-deleting. The /api/import handler does a second pass to remap parent_id through the new bucket id map so backup + restore round-trips the hierarchy. * public/js/modules/buckets.js renderBar() — top-level buckets only; buckets with ≥1 child render as a `.bucket-super` tile with data-bucket-super=<parent.id> and data-super-shown=<active member>. Three drop zones per tile: left/right 25% → reorder via insert cursor, center 50% → nest (PATCH parent_id) with .bucket-nest-target outline. canNestInto() mirrors the backend rules client-side. openSuperDropdown() renders a popover under the tile with parent + children as real .bucket-tab[data-bucket=…] entries (so the collab plugin's MutationObserver still picks them up); appended inside #bucket-bar but `position:fixed` so the bar's overflow:auto can't clip it. showBucketCtx() now scopes move-left/right to siblings (top-level peers OR same parent_id) so the action works in both the bar and the dropdown. Drag-and-drop insertion cursor for cards (matches the bucket fix): * public/js/modules/board.js bindCardEvents() — single #card-drop-cursor element inside #board, repositioned by per-card dragover. Three zones (left/right 25% → cursor in gap, center 50% → card-drop-group highlight). dragleave does NOT hide the cursor (was flickering as the mouse moved between adjacent cards); only dragend clears it. handleCardDrop recomputes the drop zone from e.clientX since the cursor approach no longer tags the target with .card-drop-before / .card-drop-after. * "Vor der ersten Card" works automatically: the first card's left 25% positions the cursor at offsetLeft - 6, which sits inside #board's px-6 padding and stays visible. * public/css/style.css — old .card-drop-before::before / .card-drop-after::after pseudo-elements removed in favour of the #card-drop-cursor rule. Drag ghost survives background polls: * board.js draggingCardIds: Set<string> at module scope. dragstart fills it; dragend clears. renderBoard() re-applies the .card-dragging class to every member of the set after every render so caldav / collab polls calling _reloadAll mid-drag don't visually pop the source card back to its normal state. Tests (public/plugins/tests/super-buckets.test.js): * API parent_id: nest, un-nest, self-parent rejected, level-2 rejected (bucket with own children, target is itself a child), DELETE un-nests, export → import preserves parent_id. * Bar rendering: super-tile DOM markers, label = parent name when no child active, label = active child name when selected, popover opens with parent + children entries, click child sets activeBucket, click super-tile twice toggles popover closed, plain bucket stays plain. * Drop cursor element exists. * Both describe blocks reset state.activeBucket = 'all' in afterEach — the rendering tests set it to a child id and the leak was breaking downstream Card: Due Date & Done / Card: Grouping tests by filtering every newly-created card with bucket_id=null off the board. chore: bump version to 0.9.3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2059e50ce3
commit
08f73dfd19
8 files changed
+696
-51
No files matched your search
+83
-10
@@ -23,6 +23,12 @@ const boardPreviewTags = $('board-preview-tags');
|
||||
const boardPreviewMeta = $('board-preview-meta');
|
||||
let activeDragEl = null;
|
||||
let dragGhost = null;
|
||||
// Set of card ids currently being dragged. Module-scoped so a poll-driven
|
||||
// re-render (caldav/collab) can re-apply the `.card-dragging` ghost class
|
||||
// to the matching DOM nodes after `board.innerHTML = …` wipes them. Without
|
||||
// this the dragged card would visually pop back to its normal state every
|
||||
// time a background poll completed mid-drag.
|
||||
const draggingCardIds = new Set();
|
||||
|
||||
// ── Multi-select ──
|
||||
const selectedIds = state.selectedIds;
|
||||
@@ -614,6 +620,16 @@ export function renderBoard() {
|
||||
|
||||
bindCardEvents();
|
||||
|
||||
// Re-apply the dragging ghost to any card whose drag is still in
|
||||
// flight. Polls (caldav, collab) call _reloadAll → renderBoard mid-
|
||||
// drag, which would otherwise wipe the visual ghost state without
|
||||
// the underlying drag operation noticing.
|
||||
if (draggingCardIds.size) {
|
||||
draggingCardIds.forEach(did => {
|
||||
board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging');
|
||||
});
|
||||
}
|
||||
|
||||
// Restore selection state
|
||||
selectedIds.forEach(id => {
|
||||
const el = board.querySelector(`[data-id="${id}"]`);
|
||||
@@ -843,6 +859,36 @@ function hideFan() {
|
||||
// ── Card event binding ──
|
||||
|
||||
function bindCardEvents() {
|
||||
// Single insertion-cursor element that lives inside #board (which is
|
||||
// `relative`) and gets repositioned by the card-level dragover handlers
|
||||
// below. Replaces the previous `.card-drop-before::before` /
|
||||
// `.card-drop-after::after` pseudo-elements so the cursor is a real
|
||||
// element in the gap rather than a sliver glued to the side of a tile.
|
||||
let cardDropCursor = board.querySelector('#card-drop-cursor');
|
||||
if (!cardDropCursor) {
|
||||
cardDropCursor = document.createElement('div');
|
||||
cardDropCursor.id = 'card-drop-cursor';
|
||||
board.appendChild(cardDropCursor);
|
||||
}
|
||||
function showCardDropCursor(targetEl, before) {
|
||||
// offsetLeft / offsetTop / offsetHeight are relative to the
|
||||
// offsetParent (#board) so the cursor positions correctly even
|
||||
// when #board is scrolled vertically.
|
||||
const x = before
|
||||
? targetEl.offsetLeft - 6
|
||||
: targetEl.offsetLeft + targetEl.offsetWidth + 2;
|
||||
cardDropCursor.style.left = x + 'px';
|
||||
cardDropCursor.style.top = (targetEl.offsetTop + 8) + 'px';
|
||||
cardDropCursor.style.height = (targetEl.offsetHeight - 16) + 'px';
|
||||
cardDropCursor.style.display = 'block';
|
||||
}
|
||||
function hideCardDropCursor() {
|
||||
cardDropCursor.style.display = 'none';
|
||||
}
|
||||
// Expose so the global dragend handler (defined outside this function)
|
||||
// can hide the cursor too.
|
||||
board._hideCardDropCursor = hideCardDropCursor;
|
||||
|
||||
board.querySelectorAll('[data-group]').forEach(badge => {
|
||||
badge.addEventListener('click', e => { e.stopPropagation(); state.activeGroup = badge.dataset.group; renderBoard(); });
|
||||
});
|
||||
@@ -932,8 +978,11 @@ function bindCardEvents() {
|
||||
const isMulti = selectedIds.has(String(id)) && selectedIds.size > 1;
|
||||
const dragIds = isMulti ? [...selectedIds] : [String(id)];
|
||||
|
||||
// Ghost all dragged cards
|
||||
// Ghost all dragged cards + remember the ids so a poll-driven
|
||||
// re-render can re-apply the ghost state.
|
||||
draggingCardIds.clear();
|
||||
dragIds.forEach(did => {
|
||||
draggingCardIds.add(String(did));
|
||||
board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging');
|
||||
});
|
||||
|
||||
@@ -979,20 +1028,36 @@ function bindCardEvents() {
|
||||
el.addEventListener('dragend', () => {
|
||||
// Remove ghost class from all dragged cards
|
||||
board.querySelectorAll('.card-dragging').forEach(c => c.classList.remove('card-dragging'));
|
||||
draggingCardIds.clear();
|
||||
activeDragEl = null;
|
||||
if (dragGhost) { dragGhost.remove(); dragGhost = null; }
|
||||
board.querySelectorAll('.card-drop-before,.card-drop-after,.card-drop-group').forEach(x =>
|
||||
x.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'));
|
||||
board.querySelectorAll('.card-drop-group').forEach(x => x.classList.remove('card-drop-group'));
|
||||
hideCardDropCursor();
|
||||
});
|
||||
el.addEventListener('dragover', e => {
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
|
||||
const rect = el.getBoundingClientRect();
|
||||
const edge = rect.width * 0.25, x = e.clientX - rect.left;
|
||||
el.classList.toggle('card-drop-before', x < edge);
|
||||
el.classList.toggle('card-drop-after', x > rect.width - edge);
|
||||
el.classList.toggle('card-drop-group', x >= edge && x <= rect.width - edge);
|
||||
// Three zones (matching the bucket fix): left 25% / right 25%
|
||||
// → insertion cursor in the gap, center 50% → group highlight
|
||||
// on the target card.
|
||||
const isBefore = x < edge;
|
||||
const isAfter = x > rect.width - edge;
|
||||
const isGroup = !isBefore && !isAfter;
|
||||
el.classList.toggle('card-drop-group', isGroup);
|
||||
if (isGroup) {
|
||||
hideCardDropCursor();
|
||||
} else {
|
||||
showCardDropCursor(el, isBefore);
|
||||
}
|
||||
});
|
||||
el.addEventListener('dragleave', () => {
|
||||
el.classList.remove('card-drop-group');
|
||||
// Don't hide the cursor here — the next card's dragover will
|
||||
// reposition it. Hiding on dragleave makes the cursor flicker
|
||||
// as the mouse moves across adjacent cards. Final cleanup
|
||||
// happens on dragend.
|
||||
});
|
||||
el.addEventListener('dragleave', () => el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'));
|
||||
el.addEventListener('drop', e => handleCardDrop(e, el, id));
|
||||
});
|
||||
}
|
||||
@@ -1001,9 +1066,17 @@ function bindCardEvents() {
|
||||
|
||||
async function handleCardDrop(e, el, targetId) {
|
||||
e.preventDefault();
|
||||
const wasGroup = el.classList.contains('card-drop-group');
|
||||
const wasBefore = el.classList.contains('card-drop-before');
|
||||
el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group');
|
||||
// Recompute the drop zone from the mouse position rather than reading
|
||||
// it back from CSS classes — the new cursor-based dragover doesn't
|
||||
// tag the target with `.card-drop-before` / `.card-drop-after` anymore,
|
||||
// only `.card-drop-group` for the center hover state.
|
||||
const rect = el.getBoundingClientRect();
|
||||
const edge = rect.width * 0.25;
|
||||
const x = e.clientX - rect.left;
|
||||
const wasGroup = !(x < edge) && !(x > rect.width - edge);
|
||||
const wasBefore = x < edge;
|
||||
el.classList.remove('card-drop-group');
|
||||
board.querySelector('#card-drop-cursor')?.style.setProperty('display', 'none');
|
||||
const dragId = e.dataTransfer.getData('text/plain');
|
||||
if (!dragId || dragId === targetId) return;
|
||||
|
||||
|
||||
Reference in new issue
Block a user