Feat: 拖拽功能增加移动端支持

This commit is contained in:
奇趣保罗 2026-03-21 02:56:39 +08:00
parent 1e17531e6a
commit f38493be2c
2 changed files with 205 additions and 45 deletions

View File

@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import BaseDrawer from "../ui/drawer.vue";
import BaseDrawer from "@/components/ui/drawer.vue";
import { Check } from "lucide-vue-next";
import { usePhotoDragSort } from "@/composables/use-photo-drag-sort";
interface MediaItem {
id: number;
@ -38,7 +39,6 @@ const tempSize = ref(props.initialSize);
const tempColumnCount = ref(props.initialColumnCount);
const tempShowPrice = ref(props.initialShowPrice);
const tempColumns = ref<MediaItem[][]>([]);
const draggingPhotoId = ref<number | null>(null);
watch(
() => props.initialSize,
@ -109,49 +109,24 @@ const emitColumns = (nextColumns: MediaItem[][]) => {
emit("update:columns", cloneColumns(nextColumns));
};
const handleDragStart = (photoId: number, event: DragEvent) => {
draggingPhotoId.value = photoId;
event.dataTransfer?.setData("text/plain", String(photoId));
event.dataTransfer?.setDragImage?.((event.target as HTMLElement) ?? new Image(), 10, 10);
};
const {
draggingPhotoId,
handleDragStart,
handleDragEnd,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
movePhotoToColumn,
movePhotoBeforeTarget,
consumeSuppressedClick,
} = usePhotoDragSort<MediaItem>({
columns: tempColumns,
onChange: emitColumns,
});
const handleDragEnd = () => {
draggingPhotoId.value = null;
};
const takeDraggedPhoto = (columnsValue: MediaItem[][]) => {
let draggedPhoto: MediaItem | undefined;
columnsValue.forEach((column) => {
const index = column.findIndex((photo) => photo.id === draggingPhotoId.value);
if (index !== -1) {
draggedPhoto = column.splice(index, 1)[0];
}
});
return draggedPhoto;
};
const movePhotoToColumn = (targetColumnIndex: number) => {
if (draggingPhotoId.value === null) return;
const columnsSnapshot = cloneColumns(tempColumns.value);
const draggedPhoto = takeDraggedPhoto(columnsSnapshot);
if (!draggedPhoto) return;
columnsSnapshot[targetColumnIndex]?.push(draggedPhoto);
emitColumns(columnsSnapshot);
};
const movePhotoBeforeTarget = (targetColumnIndex: number, targetIndex: number) => {
if (draggingPhotoId.value === null) return;
const columnsSnapshot = cloneColumns(tempColumns.value);
const draggedPhoto = takeDraggedPhoto(columnsSnapshot);
if (!draggedPhoto) return;
const targetColumn = columnsSnapshot[targetColumnIndex];
if (!targetColumn) return;
const insertIndex = Math.min(Math.max(targetIndex, 0), targetColumn.length);
targetColumn.splice(insertIndex, 0, draggedPhoto);
emitColumns(columnsSnapshot);
const handlePhotoButtonClick = (id: number) => {
if (consumeSuppressedClick()) return;
togglePhoto(id);
};
const resetPhotoOrder = () => {
@ -250,21 +225,29 @@ const resetPhotoOrder = () => {
class="space-y-2"
v-for="(column, index) in tempColumns"
:key="index"
:data-column-index="index"
@dragover.prevent
@drop.self.prevent="movePhotoToColumn(index)"
>
<button
v-for="(photo, photoIndex) in column"
:key="photo.id"
@click="togglePhoto(photo.id)"
:data-column-index="index"
:data-photo-index="photoIndex"
@click="handlePhotoButtonClick(photo.id)"
draggable="true"
@dragstart="handleDragStart(photo.id, $event)"
@dragend="handleDragEnd"
@dragover.prevent
@drop.stop.prevent="movePhotoBeforeTarget(index, photoIndex)"
@touchstart="handleTouchStart(photo.id, $event)"
@touchmove.prevent="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchEnd"
class="relative text-xs rounded-xl transition-colors"
:class="[
'w-full p-1 cursor-move',
draggingPhotoId === photo.id ? 'ring-2 ring-yellow-500 ring-offset-1' : '',
isPhotoHidden(photo.id) ? 'bg-gray-50 text-gray-400 opacity-60' : 'bg-yellow-300 text-gray-700',
]"
>

View File

@ -0,0 +1,177 @@
import { ref, type Ref } from "vue";
interface SortableItem {
id: number;
}
interface Point {
x: number;
y: number;
}
interface UsePhotoDragSortOptions<T extends SortableItem> {
columns: Ref<T[][]>;
onChange: (nextColumns: T[][]) => void;
}
const MOVE_START_THRESHOLD = 8;
export const usePhotoDragSort = <T extends SortableItem>({
columns,
onChange,
}: UsePhotoDragSortOptions<T>) => {
const draggingPhotoId = ref<number | null>(null);
const dragStartPoint = ref<Point | null>(null);
const hasTouchMoved = ref(false);
const suppressNextClick = ref(false);
const lastTouchTargetKey = ref<string | null>(null);
const cloneColumns = (columnsValue: T[][]) =>
columnsValue.map((column) => column.slice());
const emitColumns = (nextColumns: T[][]) => {
onChange(cloneColumns(nextColumns));
};
const getPhotoPosition = (columnsValue: T[][], photoId: number) => {
for (let columnIndex = 0; columnIndex < columnsValue.length; columnIndex += 1) {
const photoIndex = columnsValue[columnIndex].findIndex((photo) => photo.id === photoId);
if (photoIndex !== -1) {
return { columnIndex, photoIndex };
}
}
return null;
};
const movePhotoToColumn = (targetColumnIndex: number) => {
if (draggingPhotoId.value === null) return;
const columnsSnapshot = cloneColumns(columns.value);
const source = getPhotoPosition(columnsSnapshot, draggingPhotoId.value);
const targetColumn = columnsSnapshot[targetColumnIndex];
if (!source || !targetColumn) return;
const [draggedPhoto] = columnsSnapshot[source.columnIndex].splice(source.photoIndex, 1);
if (!draggedPhoto) return;
targetColumn.push(draggedPhoto);
emitColumns(columnsSnapshot);
};
const movePhotoBeforeTarget = (targetColumnIndex: number, targetPhotoIndex: number) => {
if (draggingPhotoId.value === null) return;
const columnsSnapshot = cloneColumns(columns.value);
const source = getPhotoPosition(columnsSnapshot, draggingPhotoId.value);
const targetColumn = columnsSnapshot[targetColumnIndex];
if (!source || !targetColumn) return;
const [draggedPhoto] = columnsSnapshot[source.columnIndex].splice(source.photoIndex, 1);
if (!draggedPhoto) return;
let insertIndex = Math.min(Math.max(targetPhotoIndex, 0), targetColumn.length);
if (source.columnIndex === targetColumnIndex && source.photoIndex < targetPhotoIndex) {
insertIndex -= 1;
}
targetColumn.splice(insertIndex, 0, draggedPhoto);
emitColumns(columnsSnapshot);
};
const resetTouchDragState = () => {
dragStartPoint.value = null;
hasTouchMoved.value = false;
lastTouchTargetKey.value = null;
};
const handleDragStart = (photoId: number, event: DragEvent) => {
draggingPhotoId.value = photoId;
event.dataTransfer?.setData("text/plain", String(photoId));
event.dataTransfer?.setDragImage?.((event.target as HTMLElement) ?? new Image(), 10, 10);
};
const handleDragEnd = () => {
draggingPhotoId.value = null;
resetTouchDragState();
};
const handleTouchStart = (photoId: number, event: TouchEvent) => {
const touch = event.touches[0];
if (!touch) return;
draggingPhotoId.value = photoId;
dragStartPoint.value = { x: touch.clientX, y: touch.clientY };
hasTouchMoved.value = false;
lastTouchTargetKey.value = null;
};
const handleTouchMove = (event: TouchEvent) => {
if (draggingPhotoId.value === null) return;
const touch = event.touches[0];
if (!touch) return;
if (!hasTouchMoved.value && dragStartPoint.value) {
const deltaX = touch.clientX - dragStartPoint.value.x;
const deltaY = touch.clientY - dragStartPoint.value.y;
const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
if (distance < MOVE_START_THRESHOLD) return;
}
hasTouchMoved.value = true;
event.preventDefault();
const target = document.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement | null;
if (!target) return;
const photoElement = target.closest<HTMLElement>("[data-photo-index][data-column-index]");
if (photoElement) {
const targetColumn = Number(photoElement.dataset.columnIndex);
const targetPhoto = Number(photoElement.dataset.photoIndex);
if (Number.isNaN(targetColumn) || Number.isNaN(targetPhoto)) return;
const key = `photo:${targetColumn}:${targetPhoto}`;
if (lastTouchTargetKey.value === key) return;
lastTouchTargetKey.value = key;
movePhotoBeforeTarget(targetColumn, targetPhoto);
return;
}
const columnElement = target.closest<HTMLElement>("[data-column-index]");
if (columnElement) {
const targetColumn = Number(columnElement.dataset.columnIndex);
if (Number.isNaN(targetColumn)) return;
const key = `column:${targetColumn}`;
if (lastTouchTargetKey.value === key) return;
lastTouchTargetKey.value = key;
movePhotoToColumn(targetColumn);
}
};
const handleTouchEnd = () => {
if (hasTouchMoved.value) {
suppressNextClick.value = true;
}
draggingPhotoId.value = null;
resetTouchDragState();
};
const consumeSuppressedClick = () => {
if (!suppressNextClick.value) return false;
suppressNextClick.value = false;
return true;
};
return {
draggingPhotoId,
handleDragStart,
handleDragEnd,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
movePhotoToColumn,
movePhotoBeforeTarget,
consumeSuppressedClick,
};
};