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(() => ({
|
||||
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,
|
||||
background: props.selected ? token.value.colorPrimaryBg : token.value.colorBgContainer,
|
||||
borderColor: props.selected ? token.value.colorPrimary : token.value.colorBorderSecondary,
|
||||
boxShadow: token.value.boxShadowTertiary,
|
||||
boxShadow: props.dragging ? token.value.boxShadow : token.value.boxShadowTertiary,
|
||||
}));
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import AddDealButton from './AddDealButton.vue';
|
||||
import DealCard from './DealCard.vue';
|
||||
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(
|
||||
defineProps<{
|
||||
@@ -51,6 +51,8 @@ const emit = defineEmits<{
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const bodyEl = ref<HTMLElement | null>(null);
|
||||
const insertAt = ref<number | null>(null);
|
||||
const renaming = ref(false);
|
||||
const draft = ref('');
|
||||
const titleInput = ref<{ focus: () => void; select?: () => void } | null>(null);
|
||||
@@ -106,16 +108,49 @@ const bodyStyle = computed(() => ({
|
||||
margin: 0,
|
||||
}));
|
||||
|
||||
function onCardDragOver(stageId: string, event: DragEvent) {
|
||||
function trackDrag(event: DragEvent, index: number | null) {
|
||||
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();
|
||||
trackDrag(event, pointerDropIndex(index, 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) {
|
||||
event.preventDefault();
|
||||
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() {
|
||||
@@ -174,8 +209,8 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
<template>
|
||||
<section
|
||||
:style="columnStyle"
|
||||
@dragover.prevent="emit('dragOver', stage.id, $event)"
|
||||
@drop.prevent="emit('drop', stage.id, $event)"
|
||||
@dragover.prevent="trackDrag($event, null); emit('dragOver', stage.id, $event)"
|
||||
@drop.prevent="onColumnDrop"
|
||||
>
|
||||
<div :style="headStyle">
|
||||
<span
|
||||
@@ -260,13 +295,18 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
<div role="list" :aria-label="stage.title" :style="bodyStyle">
|
||||
<div ref="bodyEl" role="list" :aria-label="stage.title" :style="bodyStyle">
|
||||
<div
|
||||
v-for="(deal, index) in stage.deals"
|
||||
:key="deal.id"
|
||||
@dragover="onCardDragOver(stage.id, $event)"
|
||||
@dragover="onCardDragOver(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
|
||||
:deal="deal"
|
||||
:accent="stage.color"
|
||||
@@ -275,13 +315,18 @@ function onMenuClick({ key }: { key: string | number }) {
|
||||
:select-mode="selectMode"
|
||||
:selected="selectedIds.has(deal.id)"
|
||||
@drag-start="(d, e) => emit('dragStart', d, e)"
|
||||
@drag-end="emit('dragEnd')"
|
||||
@drag-end="clearDragUi(); emit('dragEnd')"
|
||||
@open="emit('openDeal', $event)"
|
||||
@menu="emit('menuDeal', $event)"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@bulk-delete="emit('bulkDelete')"
|
||||
/>
|
||||
</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
|
||||
v-if="!stage.deals.length"
|
||||
type="secondary"
|
||||
|
||||
Reference in New Issue
Block a user