fix(boards): keep kanban card drag from locking after a few moves

Reset hung move queues, defer drag visuals past dragstart, and prefer card drop identity so HTML5 DnD no longer dies mid-drag.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-11 16:58:19 +03:30
co-authored by Cursor
parent d87321c7bf
commit 8a4bd70ad4
4 changed files with 180 additions and 91 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import type { import type {
@@ -71,6 +71,43 @@ let peekLinger = 0;
const moving = ref(false); const moving = ref(false);
const stageBusy = ref(false); const stageBusy = ref(false);
const localOrder = ref<string[] | null>(null); const localOrder = ref<string[] | null>(null);
/** صف جابه‌جایی تا دراپ پشت‌سرهم قفل نشود؛ در watchdog هم ریست می‌شود */
let moveChain: Promise<void> = Promise.resolve();
let moveWatchdog = 0;
/** هویت درگ بیرون از reactivity تا clear/re-render وسط drop شناسه را نبلعد */
let dragSessionId: string | null = null;
let dragVisualTimer = 0;
function clearDragState() {
window.clearTimeout(dragVisualTimer);
dragSessionId = null;
draggingId.value = null;
dropStageId.value = null;
overlayDeal.value = null;
draggingColumnId.value = null;
}
function resetMoveLock() {
window.clearTimeout(moveWatchdog);
moving.value = false;
moveChain = Promise.resolve();
}
function armMoveWatchdog() {
window.clearTimeout(moveWatchdog);
moveWatchdog = window.setTimeout(() => {
resetMoveLock();
}, 12_000);
}
function enqueueMove(task: () => Promise<void>) {
const run = moveChain.then(task, task);
moveChain = run.then(
() => undefined,
() => undefined,
);
return run;
}
const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = { const KIND_BADGE: Record<ActivityStageDto['kind'], string | undefined> = {
open: undefined, open: undefined,
@@ -227,14 +264,12 @@ function onMenuActivity(id: string) {
} }
function onDragStart(deal: PipelineDeal, event: DragEvent) { function onDragStart(deal: PipelineDeal, event: DragEvent) {
// اگر شناسهٔ درگ ستون گیر کرده باشد، دراپ کارت را ستون‌جابه‌جا می‌بیند
draggingColumnId.value = null; draggingColumnId.value = null;
draggingId.value = deal.id; // قفل/صف مانده از درخواست قبلی را باز کن
overlayDeal.value = deal; if (moving.value) resetMoveLock();
const sid = findStageOf(deal.id);
overlayAccent.value = dragSessionId = deal.id;
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green'; // setData / setDragImage باید همزمان با dragstart باشند
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
event.dataTransfer?.setData('text/plain', deal.id); event.dataTransfer?.setData('text/plain', deal.id);
if (event.dataTransfer) { if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
@@ -242,21 +277,44 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0); event.dataTransfer.setDragImage(img, 0, 0);
} }
// بستن پیش‌نمایش وسط dragstart گاهی خودِ درگ را لغو می‌کند
if (quickId.value) void nextTick(() => closeQuick()); const sid = findStageOf(deal.id);
overlayAccent.value =
(props.stages.find((s) => s.id === sid)?.color as PipelineAccent | undefined) ?? 'green';
overlayPos.value = { x: (event.clientX || 0) + 12, y: (event.clientY || 0) + 12 };
// تغییر DOM (opacity/overlay/بستن peek) داخل dragstart درگ HTML5 را در کروم لغو می‌کند
window.clearTimeout(dragVisualTimer);
dragVisualTimer = window.setTimeout(() => {
if (dragSessionId !== deal.id) return;
draggingId.value = deal.id;
overlayDeal.value = deal;
if (quickId.value) closeQuick();
}, 0);
} }
function onDragEnd() { function onDragEnd() {
const endedId = draggingId.value; const endedId = dragSessionId ?? draggingId.value;
// drop معمولاً قبل از dragend است؛ کمی تأخیر تا fallback شناسه از بین نرود
window.setTimeout(() => { window.setTimeout(() => {
if (draggingId.value !== endedId) return; if (dragSessionId && dragSessionId !== endedId) return;
draggingId.value = null; if (draggingId.value && draggingId.value !== endedId) return;
dropStageId.value = null; clearDragState();
overlayDeal.value = null;
}, 0); }, 0);
} }
function onColumnDragStart(id: string) {
window.clearTimeout(dragVisualTimer);
dragSessionId = null;
draggingId.value = null;
overlayDeal.value = null;
draggingColumnId.value = id;
}
function onColumnDragEnd() {
draggingColumnId.value = null;
dropStageId.value = null;
}
function onDragOver(stageId: string, event: DragEvent) { function onDragOver(stageId: string, event: DragEvent) {
event.preventDefault(); event.preventDefault();
dropStageId.value = stageId; dropStageId.value = stageId;
@@ -269,17 +327,19 @@ function onDragOver(stageId: string, event: DragEvent) {
async function moveCard(activityId: string, toStageId: string, index?: number) { async function moveCard(activityId: string, toStageId: string, index?: number) {
const fromStageId = findStageOf(activityId); const fromStageId = findStageOf(activityId);
if (!fromStageId) return; if (!fromStageId) return;
const from = columns.value[fromStageId]!; const from = columns.value[fromStageId];
const to = columns.value[toStageId]; const to = columns.value[toStageId];
if (!to) return; if (!from || !to) return;
const fromIndex = from.items.findIndex((i) => i.id === activityId); const fromIndex = from.items.findIndex((i) => i.id === activityId);
const item = from.items[fromIndex]; const item = from.items[fromIndex];
if (!item) return; if (!item) return;
const toStage = props.stages.find((s) => s.id === toStageId)!; const toStage = props.stages.find((s) => s.id === toStageId);
if (!toStage) return;
const same = fromStageId === toStageId; const same = fromStageId === toStageId;
const without = (same ? from.items : to.items).filter((i) => i.id !== activityId); const without = (same ? from.items : to.items).filter((i) => i.id !== activityId);
if (same && index == null) return; if (same && index == null) return;
let insertAt = index == null || index < 0 ? 0 : index; // بدون ایندکس صریح → ته ستون (هم‌تراز با بورد معامله)
let insertAt = index == null || index < 0 ? without.length : index;
if (same && index != null && index > fromIndex) insertAt -= 1; if (same && index != null && index > fromIndex) insertAt -= 1;
insertAt = Math.max(0, Math.min(insertAt, without.length)); insertAt = Math.max(0, Math.min(insertAt, without.length));
if (same && insertAt === fromIndex) return; if (same && insertAt === fromIndex) return;
@@ -301,6 +361,7 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
} }
moving.value = true; moving.value = true;
armMoveWatchdog();
try { try {
const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, { const { data } = await api.patch<ActivityDto>(`/activities/${activityId}/stage`, {
stageId: toStageId, stageId: toStageId,
@@ -333,6 +394,7 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
} }
emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.')); emit('error', apiMessage(e, 'جابه‌جایی فعالیت ناموفق بود.'));
} finally { } finally {
window.clearTimeout(moveWatchdog);
moving.value = false; moving.value = false;
} }
} }
@@ -340,23 +402,24 @@ async function moveCard(activityId: string, toStageId: string, index?: number) {
async function onDrop(stageId: string, event: DragEvent, index?: number) { async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault(); event.preventDefault();
const transfer = event.dataTransfer?.getData('text/plain') || ''; const transfer = event.dataTransfer?.getData('text/plain') || '';
const cardId = draggingId.value; const cardId = dragSessionId || draggingId.value;
const columnId = draggingColumnId.value; const columnId = draggingColumnId.value;
dropStageId.value = null; clearDragState();
draggingId.value = null;
overlayDeal.value = null;
draggingColumnId.value = null;
const isStageDrag = transfer.startsWith('stage:'); // کارت همیشه بر payload ستون اولویت دارد تا درگ کارت به‌اشتباه reorder ستون نشود
if (isStageDrag || (!transfer && !cardId && columnId)) { const activityId =
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId; cardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
if (fromId) await reorderColumn(fromId, stageId); if (activityId) {
// fire-and-forget: await روی زنجیرهٔ گیرکرده نباید drop بعدی را قفل کند
void enqueueMove(() => moveCard(activityId, stageId, index));
return; return;
} }
const activityId = (!isStageDrag && transfer) || cardId || ''; const isStageDrag = transfer.startsWith('stage:');
if (!activityId || moving.value) return; if (isStageDrag || columnId) {
await moveCard(activityId, stageId, index); const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
if (fromId) await reorderColumn(fromId, stageId);
}
} }
async function persistOrder(ids: string[]) { async function persistOrder(ids: string[]) {
@@ -514,8 +577,8 @@ function openFullActivity(id: string) {
@rename-stage="onRenameStage" @rename-stage="onRenameStage"
@delete-stage="onDeleteStage" @delete-stage="onDeleteStage"
@move-stage="onMoveStage" @move-stage="onMoveStage"
@column-drag-start="(id: string) => { draggingId = null; overlayDeal = null; draggingColumnId = id; }" @column-drag-start="onColumnDragStart"
@column-drag-end="draggingColumnId = null; dropStageId = null" @column-drag-end="onColumnDragEnd"
@add-stage="onAddStage" @add-stage="onAddStage"
@load-more="loadMore" @load-more="loadMore"
/> />
@@ -105,9 +105,9 @@ const chevronStyle = computed(() => ({
const cardStyle = computed(() => ({ const cardStyle = computed(() => ({
cursor: props.selectMode ? 'pointer' : 'grab', cursor: props.selectMode ? 'pointer' : 'grab',
opacity: props.dragging ? 0.4 : 1, // تغییر opacity/transform وسط dragstart درگ HTML5 را در کروم لغو می‌کند؛ فقط سایه
transform: props.dragging ? 'scale(0.98)' : 'none', opacity: props.dragging ? 0.55 : 1,
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease', transition: 'opacity 120ms ease, box-shadow 120ms ease',
margin: 0, margin: 0,
position: highlighted.value ? 'relative' : undefined, position: highlighted.value ? 'relative' : undefined,
zIndex: highlighted.value ? 2 : undefined, zIndex: highlighted.value ? 2 : undefined,
+47 -31
View File
@@ -108,6 +108,7 @@ const overlayAccent = ref<PipelineStage['color']>('green');
const overlayPos = ref({ x: 0, y: 0 }); const overlayPos = ref({ x: 0, y: 0 });
const menuOpenId = ref<string | null>(null); const menuOpenId = ref<string | null>(null);
const moving = ref(false); const moving = ref(false);
let dragSessionId: string | null = null;
const pipelineOptions = computed(() => const pipelineOptions = computed(() =>
pipelines.value.map((p) => ({ pipelines.value.map((p) => ({
@@ -245,13 +246,8 @@ function onPointerMove(event: PointerEvent | MouseEvent) {
function onDragStart(deal: PipelineDeal, event: DragEvent) { function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingColumnId.value = null; draggingColumnId.value = null;
draggingId.value = deal.id; if (moving.value) moving.value = false;
overlayDeal.value = deal; dragSessionId = deal.id;
overlayAccent.value = findAccent(deal.id);
overlayPos.value = {
x: (event.clientX || 0) + 12,
y: (event.clientY || 0) + 12,
};
event.dataTransfer?.setData('text/plain', deal.id); event.dataTransfer?.setData('text/plain', deal.id);
event.dataTransfer!.effectAllowed = 'move'; event.dataTransfer!.effectAllowed = 'move';
if (event.dataTransfer) { if (event.dataTransfer) {
@@ -260,12 +256,25 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0); event.dataTransfer.setDragImage(img, 0, 0);
} }
overlayAccent.value = findAccent(deal.id);
overlayPos.value = {
x: (event.clientX || 0) + 12,
y: (event.clientY || 0) + 12,
};
// DOM را بعد از dragstart عوض کن تا کروم درگ را لغو نکند
window.setTimeout(() => {
if (dragSessionId !== deal.id) return;
draggingId.value = deal.id;
overlayDeal.value = deal;
}, 0);
} }
function onDragEnd() { function onDragEnd() {
const endedId = draggingId.value; const endedId = dragSessionId ?? draggingId.value;
window.setTimeout(() => { window.setTimeout(() => {
if (draggingId.value !== endedId) return; if (dragSessionId && dragSessionId !== endedId) return;
if (draggingId.value && draggingId.value !== endedId) return;
dragSessionId = null;
draggingId.value = null; draggingId.value = null;
dropStageId.value = null; dropStageId.value = null;
overlayDeal.value = null; overlayDeal.value = null;
@@ -310,37 +319,44 @@ async function applyUpdateDealStage(
async function onDrop(stageId: string, event: DragEvent, index?: number) { async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault(); event.preventDefault();
const transfer = event.dataTransfer?.getData('text/plain') || ''; const transfer = event.dataTransfer?.getData('text/plain') || '';
const draggedCardId = draggingId.value; const draggedCardId = dragSessionId || draggingId.value;
const columnId = draggingColumnId.value; const columnId = draggingColumnId.value;
dropStageId.value = null; dropStageId.value = null;
dragSessionId = null;
draggingId.value = null; draggingId.value = null;
overlayDeal.value = null; overlayDeal.value = null;
draggingColumnId.value = null; draggingColumnId.value = null;
const isStageDrag = transfer.startsWith('stage:'); // کارت بر payload ستون اولویت دارد
if (isStageDrag || (!transfer && !draggedCardId && columnId)) { const dealId =
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId; draggedCardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
if (fromId) await reorderColumn(fromId, stageId); if (dealId) {
const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId));
if (current?.id === stageId && index == null) return;
const next = updateDealStage(stages.value, dealId, stageId, index);
const unchanged = next.every((stage, stageIndex) => {
const prev = stages.value[stageIndex];
return (
stage.deals.length === (prev?.deals.length ?? -1) &&
stage.deals.every((deal, dealIndex) => deal.id === prev?.deals[dealIndex]?.id)
);
});
if (unchanged) return;
// اگر جابه‌جایی قبلی هنوز در جریان است، این یکی را صف نکن — فقط رد نکن بی‌صدا با early return قدیمی
if (moving.value) {
moving.value = false;
}
await applyUpdateDealStage(dealId, stageId, index);
return; return;
} }
const dealId = (!isStageDrag && transfer) || draggedCardId || ''; const isStageDrag = transfer.startsWith('stage:');
if (!dealId || moving.value) return; if (isStageDrag || columnId) {
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
const current = stages.value.find((stage) => stage.deals.some((deal) => deal.id === dealId)); if (fromId) await reorderColumn(fromId, stageId);
if (current?.id === stageId && index == null) return; }
const next = updateDealStage(stages.value, dealId, stageId, index);
const unchanged = next.every((stage, stageIndex) => {
const prev = stages.value[stageIndex];
return (
stage.deals.length === (prev?.deals.length ?? -1) &&
stage.deals.every((deal, dealIndex) => deal.id === prev?.deals[dealIndex]?.id)
);
});
if (unchanged) return;
await applyUpdateDealStage(dealId, stageId, index);
} }
function apiMessage(e: unknown, fallback: string) { function apiMessage(e: unknown, fallback: string) {
+33 -23
View File
@@ -56,6 +56,7 @@ const busy = ref(false);
const importMsg = ref(''); const importMsg = ref('');
const draggingId = ref<string | null>(null); const draggingId = ref<string | null>(null);
let dragSessionId: string | null = null;
const draggingColumnId = ref<string | null>(null); const draggingColumnId = ref<string | null>(null);
const dropStageId = ref<string | null>(null); const dropStageId = ref<string | null>(null);
const overlayDeal = ref<PipelineDeal | null>(null); const overlayDeal = ref<PipelineDeal | null>(null);
@@ -178,13 +179,8 @@ function onPointerMove(event: PointerEvent | MouseEvent) {
function onDragStart(deal: PipelineDeal, event: DragEvent) { function onDragStart(deal: PipelineDeal, event: DragEvent) {
draggingColumnId.value = null; draggingColumnId.value = null;
draggingId.value = deal.id; if (moving.value) moving.value = false;
overlayDeal.value = deal; dragSessionId = deal.id;
overlayAccent.value = findAccent(deal.id);
overlayPos.value = {
x: (event.clientX || 0) + 12,
y: (event.clientY || 0) + 12,
};
event.dataTransfer?.setData('text/plain', deal.id); event.dataTransfer?.setData('text/plain', deal.id);
event.dataTransfer!.effectAllowed = 'move'; event.dataTransfer!.effectAllowed = 'move';
if (event.dataTransfer) { if (event.dataTransfer) {
@@ -193,12 +189,24 @@ function onDragStart(deal: PipelineDeal, event: DragEvent) {
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
event.dataTransfer.setDragImage(img, 0, 0); event.dataTransfer.setDragImage(img, 0, 0);
} }
overlayAccent.value = findAccent(deal.id);
overlayPos.value = {
x: (event.clientX || 0) + 12,
y: (event.clientY || 0) + 12,
};
window.setTimeout(() => {
if (dragSessionId !== deal.id) return;
draggingId.value = deal.id;
overlayDeal.value = deal;
}, 0);
} }
function onDragEnd() { function onDragEnd() {
const endedId = draggingId.value; const endedId = dragSessionId ?? draggingId.value;
window.setTimeout(() => { window.setTimeout(() => {
if (draggingId.value !== endedId) return; if (dragSessionId && dragSessionId !== endedId) return;
if (draggingId.value && draggingId.value !== endedId) return;
dragSessionId = null;
draggingId.value = null; draggingId.value = null;
dropStageId.value = null; dropStageId.value = null;
overlayDeal.value = null; overlayDeal.value = null;
@@ -243,30 +251,32 @@ async function applyUpdateCardStage(
async function onDrop(stageId: string, event: DragEvent, index?: number) { async function onDrop(stageId: string, event: DragEvent, index?: number) {
event.preventDefault(); event.preventDefault();
const transfer = event.dataTransfer?.getData('text/plain') || ''; const transfer = event.dataTransfer?.getData('text/plain') || '';
const draggedCardId = draggingId.value; const draggedCardId = dragSessionId || draggingId.value;
const columnId = draggingColumnId.value; const columnId = draggingColumnId.value;
dropStageId.value = null; dropStageId.value = null;
dragSessionId = null;
draggingId.value = null; draggingId.value = null;
overlayDeal.value = null; overlayDeal.value = null;
draggingColumnId.value = null; draggingColumnId.value = null;
const cardId =
draggedCardId || (transfer && !transfer.startsWith('stage:') ? transfer : '');
if (cardId) {
const current = stages.value.find((s) => s.deals.some((d) => d.id === cardId));
const currentIndex = current?.deals.findIndex((d) => d.id === cardId) ?? -1;
if (current?.id === stageId && (index == null || index === currentIndex)) {
return;
}
if (moving.value) moving.value = false;
await applyUpdateCardStage(cardId, stageId, index);
return;
}
const isStageDrag = transfer.startsWith('stage:'); const isStageDrag = transfer.startsWith('stage:');
if (isStageDrag || (!transfer && !draggedCardId && columnId)) { if (isStageDrag || columnId) {
const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId; const fromId = isStageDrag ? transfer.slice('stage:'.length) : columnId;
if (fromId) await reorderColumn(fromId, stageId); if (fromId) await reorderColumn(fromId, stageId);
return;
} }
const cardId = (!isStageDrag && transfer) || draggedCardId || '';
if (!cardId || moving.value) return;
const current = stages.value.find((s) => s.deals.some((d) => d.id === cardId));
const currentIndex = current?.deals.findIndex((d) => d.id === cardId) ?? -1;
if (current?.id === stageId && (index == null || index === currentIndex)) {
return;
}
await applyUpdateCardStage(cardId, stageId, index);
} }
function onAddCard(stageId: string) { function onAddCard(stageId: string) {