feat(boards): ease dragging a card toward an off-screen spot
Scroll the column while a card is held near its edge and show a light gap where the card will land. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -32,7 +32,7 @@ import {
|
||||
import { api } from '@/lib/api';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
import { pointerDropIndex } from '@/lib/pipeline-board';
|
||||
import { dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
|
||||
|
||||
@@ -62,6 +62,8 @@ const addUnqualified = ref(false);
|
||||
const deleteTarget = ref<LeadStageDto | null>(null);
|
||||
const moveConfirm = ref<{ stageIds: string[]; message: string } | null>(null);
|
||||
const draggingId = ref<string | null>(null);
|
||||
const insertAt = ref<number | null>(null);
|
||||
const bodyEls = new Map<string, HTMLElement>();
|
||||
const dropStageId = ref<string | null>(null);
|
||||
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
|
||||
let suppressCardClick = false;
|
||||
@@ -468,17 +470,44 @@ function onDragStart(lead: LeadDto, event: DragEvent) {
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function setBodyEl(stageId: string, el: unknown) {
|
||||
if (el instanceof HTMLElement) bodyEls.set(stageId, el);
|
||||
else bodyEls.delete(stageId);
|
||||
}
|
||||
|
||||
function clearDragUi() {
|
||||
insertAt.value = null;
|
||||
stopDragScroll();
|
||||
}
|
||||
|
||||
function slotStyle(active: boolean, collapseGap = false) {
|
||||
return {
|
||||
height: active ? '36px' : '0px',
|
||||
marginTop: !active && collapseGap ? '-12px' : '0',
|
||||
borderRadius: `${token.value.borderRadius}px`,
|
||||
background: active ? token.value.colorPrimaryBg : 'transparent',
|
||||
outline: active ? `1px dashed ${token.value.colorPrimary}` : 'none',
|
||||
outlineOffset: '-1px',
|
||||
transition: 'height 180ms ease, margin-top 180ms ease, background 180ms ease',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
clearDragUi();
|
||||
window.setTimeout(() => {
|
||||
suppressCardClick = false;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onDragOver(stageId: string, event: DragEvent) {
|
||||
function onDragOver(stageId: string, event: DragEvent, index?: number) {
|
||||
event.preventDefault();
|
||||
dropStageId.value = stageId;
|
||||
dragScrollColumn(bodyEls.get(stageId) ?? null, event.clientY);
|
||||
if (index != null) insertAt.value = index;
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
@@ -487,8 +516,10 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||
event.stopPropagation();
|
||||
const leadId =
|
||||
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
|
||||
const at = index ?? insertAt.value ?? undefined;
|
||||
draggingId.value = null;
|
||||
dropStageId.value = null;
|
||||
clearDragUi();
|
||||
if (!leadId || moving.value) return;
|
||||
|
||||
const fromCol = columns.value.find((c) =>
|
||||
@@ -501,14 +532,14 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||
if (!lead) return;
|
||||
|
||||
const same = fromCol.stage.id === stageId;
|
||||
if (same && index == null) return;
|
||||
if (same && at == null) return;
|
||||
const without = (same ? fromCol.leads : target.leads).filter((l) => l.id !== leadId);
|
||||
let insertAt = index == null || index < 0 ? 0 : index;
|
||||
if (same && index != null && index > fromIndex) insertAt -= 1;
|
||||
insertAt = Math.max(0, Math.min(insertAt, without.length));
|
||||
if (same && insertAt === fromIndex) return;
|
||||
let insertIndex = at == null || at < 0 ? 0 : at;
|
||||
if (same && at != null && at > fromIndex) insertIndex -= 1;
|
||||
insertIndex = Math.max(0, Math.min(insertIndex, without.length));
|
||||
if (same && insertIndex === fromIndex) return;
|
||||
const nextLeads = [...without];
|
||||
nextLeads.splice(insertAt, 0, { ...lead, stageId, stageName: target.stage.name });
|
||||
nextLeads.splice(insertIndex, 0, { ...lead, stageId, stageName: target.stage.name });
|
||||
|
||||
const prev = columns.value.map((c) => ({
|
||||
...c,
|
||||
@@ -533,7 +564,7 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
||||
moving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertAt });
|
||||
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertIndex });
|
||||
} catch {
|
||||
columns.value = prev;
|
||||
error.value = 'جابهجایی لید ناموفق بود.';
|
||||
@@ -656,24 +687,34 @@ onMounted(load);
|
||||
</a-flex>
|
||||
</div>
|
||||
|
||||
<div :style="columnBodyStyle">
|
||||
<div
|
||||
:ref="(el) => setBodyEl(col.stage.id, el)"
|
||||
:style="columnBodyStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(lead, index) in col.leads"
|
||||
:key="lead.id"
|
||||
@dragover.prevent="onDragOver(col.stage.id, $event)"
|
||||
@dragover.prevent="onDragOver(col.stage.id, $event, pointerDropIndex(index, $event))"
|
||||
@drop.stop="onDrop(col.stage.id, $event, pointerDropIndex(index, $event))"
|
||||
>
|
||||
<div
|
||||
:style="slotStyle(dropStageId === col.stage.id && insertAt === index)"
|
||||
@dragover.stop.prevent="onDragOver(col.stage.id, $event, index)"
|
||||
@drop.stop.prevent="onDrop(col.stage.id, $event, index)"
|
||||
/>
|
||||
<a-card
|
||||
hoverable
|
||||
:bordered="true"
|
||||
:body-style="cardBodyStyle"
|
||||
:style="{
|
||||
cursor: 'pointer',
|
||||
opacity: draggingId === lead.id ? 0.45 : 1,
|
||||
opacity: draggingId === lead.id ? 0.4 : 1,
|
||||
transform: draggingId === lead.id ? 'scale(0.98)' : 'none',
|
||||
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
|
||||
flexShrink: 0,
|
||||
margin: 0,
|
||||
background: token.colorBgContainer,
|
||||
boxShadow: token.boxShadow,
|
||||
boxShadow: draggingId === lead.id ? token.boxShadowSecondary : token.boxShadow,
|
||||
}"
|
||||
@click="openLead(lead)"
|
||||
>
|
||||
@@ -804,6 +845,11 @@ onMounted(load);
|
||||
</a-flex>
|
||||
</a-card>
|
||||
</div>
|
||||
<div
|
||||
:style="slotStyle(dropStageId === col.stage.id && insertAt === col.leads.length, true)"
|
||||
@dragover.stop.prevent="onDragOver(col.stage.id, $event, col.leads.length)"
|
||||
@drop.stop.prevent="onDrop(col.stage.id, $event, col.leads.length)"
|
||||
/>
|
||||
|
||||
<a-typography-text
|
||||
v-if="!col.leads.length"
|
||||
|
||||
Reference in New Issue
Block a user