Skip to content

Commit 725c21b

Browse files
committed
fix: projects board drag-drop reorder preview
1 parent 25dd726 commit 725c21b

2 files changed

Lines changed: 184 additions & 18 deletions

File tree

client/projects-board.js

Lines changed: 176 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ class ProjectsBoardUI {
1616
this.projects = [];
1717
this.filter = '';
1818
this.dragProjectKey = null;
19+
this.dragSourceColumnId = null;
20+
this.dragCardEl = null;
21+
this.dragPlaceholderEl = null;
22+
this._dragOverRaf = null;
23+
this._pendingDragOver = null;
1924
this.hideForks = false;
2025
this.githubRepos = [];
2126
this._escHandler = null;
@@ -463,14 +468,144 @@ class ProjectsBoardUI {
463468
}
464469
}
465470

471+
getColumnDropzone(columnEl) {
472+
if (!columnEl) return null;
473+
return columnEl.querySelector?.('.projects-board-column-body[data-dropzone="true"]') || null;
474+
}
475+
476+
ensureDragPlaceholder(cardEl) {
477+
if (!this.dragPlaceholderEl) {
478+
const el = document.createElement('div');
479+
el.className = 'projects-board-drop-placeholder';
480+
el.setAttribute('data-drop-placeholder', 'true');
481+
el.setAttribute('aria-hidden', 'true');
482+
this.dragPlaceholderEl = el;
483+
}
484+
485+
if (cardEl && this.dragPlaceholderEl) {
486+
try {
487+
const rect = cardEl.getBoundingClientRect();
488+
const h = Number(rect?.height || 0);
489+
if (h > 0) {
490+
this.dragPlaceholderEl.style.height = `${Math.round(h)}px`;
491+
this.dragPlaceholderEl.style.minHeight = `${Math.round(h)}px`;
492+
}
493+
} catch {}
494+
}
495+
496+
return this.dragPlaceholderEl;
497+
}
498+
499+
clearDragPlaceholder() {
500+
if (this.dragPlaceholderEl?.parentElement) {
501+
this.dragPlaceholderEl.parentElement.removeChild(this.dragPlaceholderEl);
502+
}
503+
}
504+
505+
computeClosestCardForPoint(cards, x, y) {
506+
const px = Number.isFinite(Number(x)) ? Number(x) : 0;
507+
const py = Number.isFinite(Number(y)) ? Number(y) : 0;
508+
let best = null;
509+
let bestDist = Number.POSITIVE_INFINITY;
510+
511+
for (const card of cards) {
512+
if (!card || card === this.dragPlaceholderEl) continue;
513+
let rect = null;
514+
try {
515+
rect = card.getBoundingClientRect();
516+
} catch {
517+
rect = null;
518+
}
519+
if (!rect) continue;
520+
521+
const dx = (px < rect.left) ? (rect.left - px) : (px > rect.right ? (px - rect.right) : 0);
522+
const dy = (py < rect.top) ? (rect.top - py) : (py > rect.bottom ? (py - rect.bottom) : 0);
523+
const dist = (dx * dx) + (dy * dy);
524+
if (dist < bestDist) {
525+
bestDist = dist;
526+
best = { card, rect };
527+
}
528+
}
529+
530+
return best;
531+
}
532+
533+
positionDragPlaceholder(dropzoneEl, { x, y } = {}) {
534+
const dropzone = dropzoneEl;
535+
if (!dropzone) return null;
536+
if (!this.dragProjectKey) return null;
537+
538+
const placeholder = this.ensureDragPlaceholder(this.dragCardEl);
539+
if (!placeholder) return null;
540+
541+
const empty = dropzone.querySelector?.('.projects-board-empty');
542+
const cards = Array.from(dropzone.querySelectorAll('.projects-board-card')).filter((el) => !el.classList.contains('dragging'));
543+
544+
if (!cards.length) {
545+
if (empty) dropzone.insertBefore(placeholder, empty);
546+
else dropzone.appendChild(placeholder);
547+
return placeholder;
548+
}
549+
550+
const closest = this.computeClosestCardForPoint(cards, x, y);
551+
const target = closest?.card || null;
552+
const rect = closest?.rect || null;
553+
if (!target || !rect) {
554+
dropzone.appendChild(placeholder);
555+
return placeholder;
556+
}
557+
558+
const cx = rect.left + rect.width / 2;
559+
const cy = rect.top + rect.height / 2;
560+
const dx = (Number.isFinite(Number(x)) ? Number(x) : 0) - cx;
561+
const dy = (Number.isFinite(Number(y)) ? Number(y) : 0) - cy;
562+
const useY = Math.abs(dy) >= Math.abs(dx);
563+
const after = useY ? (dy > 0) : (dx > 0);
564+
const insertBefore = after ? target.nextElementSibling : target;
565+
566+
if (insertBefore === placeholder) return placeholder;
567+
if (placeholder.parentElement !== dropzone) {
568+
dropzone.insertBefore(placeholder, insertBefore);
569+
return placeholder;
570+
}
571+
572+
const currentNext = placeholder.nextElementSibling;
573+
if (!after && target === currentNext) return placeholder;
574+
if (after && target === placeholder.previousElementSibling) return placeholder;
575+
576+
dropzone.insertBefore(placeholder, insertBefore);
577+
return placeholder;
578+
}
579+
580+
computeInsertIndexFromPlaceholder(dropzoneEl, projectKey) {
581+
const dropzone = dropzoneEl;
582+
const placeholder = dropzone?.querySelector?.('.projects-board-drop-placeholder[data-drop-placeholder="true"]') || null;
583+
if (!dropzone || !placeholder) return null;
584+
const key = String(projectKey || '').trim().replace(/\\/g, '/');
585+
586+
let index = 0;
587+
const children = Array.from(dropzone.children || []);
588+
for (const child of children) {
589+
if (child === placeholder) break;
590+
if (!child?.classList?.contains?.('projects-board-card')) continue;
591+
const childKey = String(child?.dataset?.projectKey || '').trim().replace(/\\/g, '/');
592+
if (!childKey || childKey === key) continue;
593+
index += 1;
594+
}
595+
return index;
596+
}
597+
466598
onDragStart(event) {
467599
if (event.target?.closest?.('[data-no-drag="true"]')) return;
468600
const card = event.target?.closest?.('.projects-board-card');
469601
if (!card) return;
470602
const key = String(card.dataset?.projectKey || '').trim();
471603
if (!key) return;
472604
this.dragProjectKey = key;
605+
this.dragSourceColumnId = this.getProjectColumn(key);
606+
this.dragCardEl = card;
473607
card.classList.add('dragging');
608+
this.ensureDragPlaceholder(card);
474609
try {
475610
event.dataTransfer?.setData?.('text/plain', key);
476611
event.dataTransfer.effectAllowed = 'move';
@@ -482,17 +617,42 @@ class ProjectsBoardUI {
482617
if (card) card.classList.remove('dragging');
483618
document.querySelectorAll('.projects-board-column.drag-over').forEach((el) => el.classList.remove('drag-over'));
484619
this.dragProjectKey = null;
620+
this.dragSourceColumnId = null;
621+
this.dragCardEl = null;
622+
this.clearDragPlaceholder();
623+
if (this._dragOverRaf) {
624+
window.cancelAnimationFrame(this._dragOverRaf);
625+
this._dragOverRaf = null;
626+
}
627+
this._pendingDragOver = null;
485628
}
486629

487630
onDragOver(event) {
488631
const col = event.target?.closest?.('.projects-board-column');
489632
if (!col) return;
490633
if (!this.dragProjectKey) return;
491634
event.preventDefault();
635+
document.querySelectorAll('.projects-board-column.drag-over').forEach((el) => {
636+
if (el !== col) el.classList.remove('drag-over');
637+
});
492638
col.classList.add('drag-over');
493639
try {
494640
event.dataTransfer.dropEffect = 'move';
495641
} catch {}
642+
643+
if (col.classList.contains('is-collapsed')) return;
644+
const dropzone = this.getColumnDropzone(col);
645+
if (!dropzone) return;
646+
647+
this._pendingDragOver = { dropzone, x: event.clientX, y: event.clientY };
648+
if (this._dragOverRaf) return;
649+
this._dragOverRaf = window.requestAnimationFrame(() => {
650+
this._dragOverRaf = null;
651+
const pending = this._pendingDragOver;
652+
this._pendingDragOver = null;
653+
if (!pending) return;
654+
this.positionDragPlaceholder(pending.dropzone, { x: pending.x, y: pending.y });
655+
});
496656
}
497657

498658
onDragLeave(event) {
@@ -511,35 +671,33 @@ class ProjectsBoardUI {
511671

512672
const columnId = String(col.dataset?.columnId || '').trim();
513673
const projectKey = String(this.dragProjectKey || '').trim() || String(event.dataTransfer?.getData?.('text/plain') || '').trim();
674+
const sourceColumnId = this.dragSourceColumnId || this.getProjectColumn(projectKey);
514675
this.dragProjectKey = null;
676+
this.dragSourceColumnId = null;
515677
if (!projectKey || !columnId) return;
516678

517-
const targetCard = event.target?.closest?.('.projects-board-card');
518-
const targetKey = targetCard ? String(targetCard.dataset?.projectKey || '').trim() : '';
519-
const insertAfter = (() => {
520-
if (!targetCard || !targetKey || targetKey === projectKey) return false;
521-
try {
522-
const rect = targetCard.getBoundingClientRect();
523-
return (event.clientY || 0) > rect.top + rect.height / 2;
524-
} catch {
525-
return false;
526-
}
527-
})();
679+
let insertIndex = null;
680+
const dropzone = col.classList.contains('is-collapsed') ? null : this.getColumnDropzone(col);
681+
if (dropzone) {
682+
this.positionDragPlaceholder(dropzone, { x: event.clientX, y: event.clientY });
683+
insertIndex = this.computeInsertIndexFromPlaceholder(dropzone, projectKey);
684+
}
685+
this.clearDragPlaceholder();
686+
this.dragCardEl = null;
528687

529-
const sourceColumnId = this.getProjectColumn(projectKey);
530688
const full = this.buildFullColumnModel();
531689
const sourceKeys = (full.get(sourceColumnId) || []).map((p) => p.key).filter((k) => k !== projectKey);
532690
const destinationKeysBase = sourceColumnId === columnId
533691
? sourceKeys.slice()
534692
: (full.get(columnId) || []).map((p) => p.key).filter((k) => k !== projectKey);
535693

536694
const destinationKeys = destinationKeysBase.slice();
537-
let insertIndex = destinationKeys.length;
538-
if (targetKey && targetKey !== projectKey) {
539-
const idx = destinationKeys.indexOf(targetKey);
540-
if (idx !== -1) insertIndex = insertAfter ? idx + 1 : idx;
541-
}
542-
destinationKeys.splice(insertIndex, 0, projectKey);
695+
const fallbackIndex = destinationKeys.length;
696+
const desired = Number(insertIndex);
697+
const safeIndex = Number.isFinite(desired)
698+
? Math.min(Math.max(Math.round(desired), 0), destinationKeys.length)
699+
: fallbackIndex;
700+
destinationKeys.splice(safeIndex, 0, projectKey);
543701

544702
const orderByColumn = { [columnId]: destinationKeys };
545703
if (sourceColumnId !== columnId) orderByColumn[sourceColumnId] = sourceKeys;

client/styles/projects-board.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,14 @@
224224
opacity: 0.65;
225225
}
226226

227+
.projects-board-drop-placeholder {
228+
border: 2px dashed rgba(79, 70, 229, 0.55);
229+
border-radius: var(--radius-sm);
230+
background: rgba(79, 70, 229, 0.08);
231+
min-height: 64px;
232+
pointer-events: none;
233+
}
234+
227235
.projects-board-card-top {
228236
display: flex;
229237
align-items: center;

0 commit comments

Comments
 (0)