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:
@@ -76,11 +76,13 @@ const priorityFlag = computed(() =>
|
|||||||
|
|
||||||
const cardStyle = computed(() => ({
|
const cardStyle = computed(() => ({
|
||||||
cursor: props.selectMode ? 'pointer' : 'grab',
|
cursor: props.selectMode ? 'pointer' : 'grab',
|
||||||
opacity: props.dragging ? 0.45 : 1,
|
opacity: props.dragging ? 0.4 : 1,
|
||||||
|
transform: props.dragging ? 'scale(0.98)' : 'none',
|
||||||
|
transition: 'opacity 180ms ease, transform 180ms ease, box-shadow 180ms ease',
|
||||||
margin: 0,
|
margin: 0,
|
||||||
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
||||||
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
||||||
boxShadow: token.value.boxShadowTertiary,
|
boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import AddDealButton from './AddDealButton.vue';
|
import AddDealButton from './AddDealButton.vue';
|
||||||
import DealCard from './DealCard.vue';
|
import DealCard from './DealCard.vue';
|
||||||
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
|
import type { PipelineDeal, PipelineStage, PipelineUiCopy } from '@/lib/pipeline-board';
|
||||||
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, pointerDropIndex } from '@/lib/pipeline-board';
|
import { DEAL_BOARD_COPY, PIPELINE_ACCENT, dragScrollColumn, pointerDropIndex, stopDragScroll } from '@/lib/pipeline-board';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -51,6 +51,8 @@ const emit = defineEmits<{
|
|||||||
const { Text: ATypographyText } = Typography;
|
const { Text: ATypographyText } = Typography;
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
|
const bodyEl = ref<HTMLElement | null>(null);
|
||||||
|
const insertAt = ref<number | null>(null);
|
||||||
const renaming = ref(false);
|
const renaming = ref(false);
|
||||||
const draft = ref('');
|
const draft = ref('');
|
||||||
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
|
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
|
||||||
@@ -106,16 +108,49 @@ const bodyStyle = computed(() => ({
|
|||||||
margin: 0,
|
margin: 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function onCardDragOver(stageId: string, event: DragEvent) {
|
function trackDrag(event: DragEvent, index: number | null) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
dragScrollColumn(bodyEl.value, event.clientY);
|
||||||
|
if (index != null) insertAt.value = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDragUi() {
|
||||||
|
insertAt.value = null;
|
||||||
|
stopDragScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCardDragOver(stageId: string, index: number, event: DragEvent) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
trackDrag(event, pointerDropIndex(index, event));
|
||||||
emit('dragOver', stageId, event);
|
emit('dragOver', stageId, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onColumnDrop(event: DragEvent) {
|
||||||
|
const at = insertAt.value ?? undefined;
|
||||||
|
clearDragUi();
|
||||||
|
emit('drop', props.stage.id, event, at);
|
||||||
|
}
|
||||||
|
|
||||||
function onCardDrop(stageId: string, index: number, event: DragEvent) {
|
function onCardDrop(stageId: string, index: number, event: DragEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
emit('drop', stageId, event, pointerDropIndex(index, event));
|
const at = insertAt.value ?? pointerDropIndex(index, event);
|
||||||
|
clearDragUi();
|
||||||
|
emit('drop', stageId, event, at);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 startRename() {
|
function startRename() {
|
||||||
@@ -174,8 +209,8 @@ function onMenuClick({ key }: { key: string | number }) {
|
|||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
:style="columnStyle"
|
:style="columnStyle"
|
||||||
@dragover.prevent="emit('dragOver', stage.id, $event)"
|
@dragover.prevent="trackDrag($event, null); emit('dragOver', stage.id, $event)"
|
||||||
@drop.prevent="emit('drop', stage.id, $event)"
|
@drop.prevent="onColumnDrop"
|
||||||
>
|
>
|
||||||
<div :style="headStyle">
|
<div :style="headStyle">
|
||||||
<span
|
<span
|
||||||
@@ -260,13 +295,18 @@ function onMenuClick({ key }: { key: string | number }) {
|
|||||||
</a-dropdown>
|
</a-dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div role="list" :aria-label="stage.title" :style="bodyStyle">
|
<div ref="bodyEl" role="list" :aria-label="stage.title" :style="bodyStyle">
|
||||||
<div
|
<div
|
||||||
v-for="(deal, index) in stage.deals"
|
v-for="(deal, index) in stage.deals"
|
||||||
:key="deal.id"
|
:key="deal.id"
|
||||||
@dragover="onCardDragOver(stage.id, $event)"
|
@dragover="onCardDragOver(stage.id, index, $event)"
|
||||||
@drop="onCardDrop(stage.id, index, $event)"
|
@drop="onCardDrop(stage.id, index, $event)"
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
:style="slotStyle(dropActive && insertAt === index)"
|
||||||
|
@dragover.stop.prevent="trackDrag($event, index); emit('dragOver', stage.id, $event)"
|
||||||
|
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, index)"
|
||||||
|
/>
|
||||||
<DealCard
|
<DealCard
|
||||||
:deal="deal"
|
:deal="deal"
|
||||||
:accent="stage.color"
|
:accent="stage.color"
|
||||||
@@ -275,13 +315,18 @@ function onMenuClick({ key }: { key: string | number }) {
|
|||||||
:select-mode="selectMode"
|
:select-mode="selectMode"
|
||||||
:selected="selectedIds.has(deal.id)"
|
:selected="selectedIds.has(deal.id)"
|
||||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||||
@drag-end="emit('dragEnd')"
|
@drag-end="clearDragUi(); emit('dragEnd')"
|
||||||
@open="emit('openDeal', $event)"
|
@open="emit('openDeal', $event)"
|
||||||
@menu="emit('menuDeal', $event)"
|
@menu="emit('menuDeal', $event)"
|
||||||
@toggle-select="emit('toggleSelect', $event)"
|
@toggle-select="emit('toggleSelect', $event)"
|
||||||
@bulk-delete="emit('bulkDelete')"
|
@bulk-delete="emit('bulkDelete')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
:style="slotStyle(dropActive && insertAt === stage.deals.length, true)"
|
||||||
|
@dragover.stop.prevent="trackDrag($event, stage.deals.length); emit('dragOver', stage.id, $event)"
|
||||||
|
@drop.stop.prevent="clearDragUi(); emit('drop', stage.id, $event, stage.deals.length)"
|
||||||
|
/>
|
||||||
<a-typography-text
|
<a-typography-text
|
||||||
v-if="!stage.deals.length"
|
v-if="!stage.deals.length"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
|
|||||||
@@ -431,6 +431,43 @@ export function pointerDropIndex(index: number, event: DragEvent): number {
|
|||||||
return index + (event.clientY >= rect.top + rect.height / 2 ? 1 : 0);
|
return index + (event.clientY >= rect.top + rect.height / 2 ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DRAG_SCROLL_EDGE = 72;
|
||||||
|
const DRAG_SCROLL_MAX = 16;
|
||||||
|
let dragScrollEl: HTMLElement | null = null;
|
||||||
|
let dragScrollY = 0;
|
||||||
|
let dragScrollRaf = 0;
|
||||||
|
|
||||||
|
function dragScrollTick() {
|
||||||
|
dragScrollRaf = 0;
|
||||||
|
const el = dragScrollEl;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
let delta = 0;
|
||||||
|
if (dragScrollY < rect.top + DRAG_SCROLL_EDGE) {
|
||||||
|
const closeness = (rect.top + DRAG_SCROLL_EDGE - dragScrollY) / DRAG_SCROLL_EDGE;
|
||||||
|
delta = -Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
|
||||||
|
} else if (dragScrollY > rect.bottom - DRAG_SCROLL_EDGE) {
|
||||||
|
const closeness = (dragScrollY - (rect.bottom - DRAG_SCROLL_EDGE)) / DRAG_SCROLL_EDGE;
|
||||||
|
delta = Math.ceil(DRAG_SCROLL_MAX * Math.min(1, Math.max(0, closeness)));
|
||||||
|
}
|
||||||
|
if (!delta || el.scrollHeight <= el.clientHeight) return;
|
||||||
|
el.scrollTop += delta;
|
||||||
|
dragScrollRaf = requestAnimationFrame(dragScrollTick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** نزدیک لبهٔ بالا یا پایین ستون، فهرست خودش حرکت میکند تا مقصد دیده شود. */
|
||||||
|
export function dragScrollColumn(el: HTMLElement | null, clientY: number) {
|
||||||
|
dragScrollEl = el;
|
||||||
|
dragScrollY = clientY;
|
||||||
|
if (!dragScrollRaf) dragScrollRaf = requestAnimationFrame(dragScrollTick);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopDragScroll() {
|
||||||
|
dragScrollEl = null;
|
||||||
|
if (dragScrollRaf) cancelAnimationFrame(dragScrollRaf);
|
||||||
|
dragScrollRaf = 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function moveStageToIndex(
|
export function moveStageToIndex(
|
||||||
stages: PipelineStage[],
|
stages: PipelineStage[],
|
||||||
stageId: string,
|
stageId: string,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||||
import { canManageSettings } from '@/lib/roles';
|
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 { useAuthStore } from '@/stores/auth';
|
||||||
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
|
import type { LeadBoardColumnDto, LeadDto, LeadStageDto } from '@mento/shared';
|
||||||
|
|
||||||
@@ -62,6 +62,8 @@ const addUnqualified = ref(false);
|
|||||||
const deleteTarget = ref<LeadStageDto | null>(null);
|
const deleteTarget = ref<LeadStageDto | null>(null);
|
||||||
const moveConfirm = ref<{ stageIds: string[]; message: string } | null>(null);
|
const moveConfirm = ref<{ stageIds: string[]; message: string } | null>(null);
|
||||||
const draggingId = ref<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);
|
const dropStageId = ref<string | null>(null);
|
||||||
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
|
/** بعد از درگ، کلیک باز شدن جزئیات را یک فریم نادیده بگیر */
|
||||||
let suppressCardClick = false;
|
let suppressCardClick = false;
|
||||||
@@ -468,17 +470,44 @@ function onDragStart(lead: LeadDto, event: DragEvent) {
|
|||||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
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() {
|
function onDragEnd() {
|
||||||
draggingId.value = null;
|
draggingId.value = null;
|
||||||
dropStageId.value = null;
|
dropStageId.value = null;
|
||||||
|
clearDragUi();
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
suppressCardClick = false;
|
suppressCardClick = false;
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragOver(stageId: string, event: DragEvent) {
|
function onDragOver(stageId: string, event: DragEvent, index?: number) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
dropStageId.value = stageId;
|
dropStageId.value = stageId;
|
||||||
|
dragScrollColumn(bodyEls.get(stageId) ?? null, event.clientY);
|
||||||
|
if (index != null) insertAt.value = index;
|
||||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,8 +516,10 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const leadId =
|
const leadId =
|
||||||
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
|
event.dataTransfer?.getData('text/plain') || draggingId.value || '';
|
||||||
|
const at = index ?? insertAt.value ?? undefined;
|
||||||
draggingId.value = null;
|
draggingId.value = null;
|
||||||
dropStageId.value = null;
|
dropStageId.value = null;
|
||||||
|
clearDragUi();
|
||||||
if (!leadId || moving.value) return;
|
if (!leadId || moving.value) return;
|
||||||
|
|
||||||
const fromCol = columns.value.find((c) =>
|
const fromCol = columns.value.find((c) =>
|
||||||
@@ -501,14 +532,14 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
|||||||
if (!lead) return;
|
if (!lead) return;
|
||||||
|
|
||||||
const same = fromCol.stage.id === stageId;
|
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);
|
const without = (same ? fromCol.leads : target.leads).filter((l) => l.id !== leadId);
|
||||||
let insertAt = index == null || index < 0 ? 0 : index;
|
let insertIndex = at == null || at < 0 ? 0 : at;
|
||||||
if (same && index != null && index > fromIndex) insertAt -= 1;
|
if (same && at != null && at > fromIndex) insertIndex -= 1;
|
||||||
insertAt = Math.max(0, Math.min(insertAt, without.length));
|
insertIndex = Math.max(0, Math.min(insertIndex, without.length));
|
||||||
if (same && insertAt === fromIndex) return;
|
if (same && insertIndex === fromIndex) return;
|
||||||
const nextLeads = [...without];
|
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) => ({
|
const prev = columns.value.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
@@ -533,7 +564,7 @@ async function onDrop(stageId: string, event: DragEvent, index?: number) {
|
|||||||
moving.value = true;
|
moving.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
try {
|
try {
|
||||||
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertAt });
|
await api.patch(`/leads/${leadId}/move`, { stageId, position: insertIndex });
|
||||||
} catch {
|
} catch {
|
||||||
columns.value = prev;
|
columns.value = prev;
|
||||||
error.value = 'جابهجایی لید ناموفق بود.';
|
error.value = 'جابهجایی لید ناموفق بود.';
|
||||||
@@ -656,24 +687,34 @@ onMounted(load);
|
|||||||
</a-flex>
|
</a-flex>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :style="columnBodyStyle">
|
<div
|
||||||
|
:ref="(el) => setBodyEl(col.stage.id, el)"
|
||||||
|
:style="columnBodyStyle"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(lead, index) in col.leads"
|
v-for="(lead, index) in col.leads"
|
||||||
:key="lead.id"
|
: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))"
|
@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
|
<a-card
|
||||||
hoverable
|
hoverable
|
||||||
:bordered="true"
|
:bordered="true"
|
||||||
:body-style="cardBodyStyle"
|
:body-style="cardBodyStyle"
|
||||||
:style="{
|
:style="{
|
||||||
cursor: 'pointer',
|
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,
|
flexShrink: 0,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
background: token.colorBgContainer,
|
background: token.colorBgContainer,
|
||||||
boxShadow: token.boxShadow,
|
boxShadow: draggingId === lead.id ? token.boxShadowSecondary : token.boxShadow,
|
||||||
}"
|
}"
|
||||||
@click="openLead(lead)"
|
@click="openLead(lead)"
|
||||||
>
|
>
|
||||||
@@ -804,6 +845,11 @@ onMounted(load);
|
|||||||
</a-flex>
|
</a-flex>
|
||||||
</a-card>
|
</a-card>
|
||||||
</div>
|
</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
|
<a-typography-text
|
||||||
v-if="!col.leads.length"
|
v-if="!col.leads.length"
|
||||||
|
|||||||
Reference in New Issue
Block a user