Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/image/image.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ref, computed, defineComponent, watchEffect } from 'vue';
import type { PropType } from 'vue';
import { useIntersectionObserver } from '@vueuse/core';
import { CloseIcon } from 'tdesign-icons-vue-next';

Expand Down
1 change: 1 addition & 0 deletions src/upload/demos/multiple.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<t-upload
:default-files="files"
multiple
draggable
Comment thread
Wesley-0808 marked this conversation as resolved.
:max="10"
:grid-config="gridConfig"
:action="action"
Expand Down
158 changes: 158 additions & 0 deletions src/upload/hooks/useDrag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { ComputedRef, ref, Ref, onMounted, onBeforeUnmount } from 'vue';
import type { TdUploadProps, UploadFile } from '../type';

interface UploadFileWithUid extends UploadFile {
__uid?: string;
}

export default function useDrag(
props: TdUploadProps,
setUploadValue: (value: UploadFile[], ...args: any[]) => void,
uploadClass: ComputedRef<string>,
listRef: Ref<HTMLElement | undefined>,
) {
const dragIndex = ref<number>(-1);
let pendingTargetIndex = -1;

// 缓存网格布局信息
let baseData = {
itemWidth: 0,
itemHeight: 0,
columns: 0,
wrapLeft: 0,
wrapTop: 0,
};

// 生成文件唯一ID,用于拖拽排序识别
const getFileId = (file: UploadFileWithUid) => {
if (file.__uid) return file.__uid;
const uid = `u_${Math.random().toString(36).slice(2, 9)}`;
file.__uid = uid;
return uid;
};

const updateDragBaseData = () => {
const listEl = listRef.value;
const itemEl = listEl?.querySelector(`.${uploadClass.value}__item`);
if (listEl && itemEl) {
const rect = listEl.getBoundingClientRect();
const itemWidth = (itemEl as HTMLElement).offsetWidth;
const itemHeight = (itemEl as HTMLElement).offsetHeight;
const style = window.getComputedStyle(listEl);
const gapX = parseInt(style.columnGap || style.gap, 10) || 8;
const gapY = parseInt(style.rowGap || style.gap, 10) || 16;
const paddingLeft = parseInt(style.paddingLeft, 10) || 0;
const paddingTop = parseInt(style.paddingTop, 10) || 0;

baseData = {
itemWidth: itemWidth + gapX,
itemHeight: itemHeight + gapY,
columns:
Math.round(
(rect.width - paddingLeft - (parseInt(style.paddingRight, 10) || 0) + gapX) / (itemWidth + gapX),
) || 4,
wrapLeft: rect.left + paddingLeft,
wrapTop: rect.top + paddingTop,
};
}
};

let resizeObserver: ResizeObserver | null = null;

onMounted(() => {
updateDragBaseData();
if (listRef.value && window.ResizeObserver) {
resizeObserver = new ResizeObserver(() => {
updateDragBaseData();
});
resizeObserver.observe(listRef.value);
}
});

onBeforeUnmount(() => {
resizeObserver?.disconnect();
resizeObserver = null;
});

const performSort = (index: number, displayFiles: UploadFile[], e: TouchEvent) => {
if (index === -1 || index === dragIndex.value) {
pendingTargetIndex = -1;
return;
}
if (index === pendingTargetIndex) return;

pendingTargetIndex = index;
const sourceIdx = dragIndex.value;
const newFiles = [...displayFiles];

if (sourceIdx === -1 || sourceIdx === index) return;

const [item] = newFiles.splice(sourceIdx, 1);
newFiles.splice(index, 0, item);

setUploadValue(newFiles, { e, trigger: 'sort', index, file: item, files: newFiles });
if (dragIndex.value !== -1) {
dragIndex.value = index;
}
};

// 根据网格坐标计算目标索引
const onTouchmove = (e: TouchEvent, displayFiles: UploadFile[]) => {
if (!props.draggable || dragIndex.value === -1) return;
if (e.cancelable) e.preventDefault();

const touch = e.touches[0];
const { itemWidth, itemHeight, columns, wrapLeft, wrapTop } = baseData;

const x = touch.clientX - wrapLeft;
const y = touch.clientY - wrapTop;

let curX = Math.floor(x / itemWidth);
const curY = Math.floor(y / itemHeight);

// 将 X 限制在列范围内,确保在行边界内滑动
curX = Math.max(0, Math.min(columns - 1, curX));

let targetIndex = curX + columns * curY;
targetIndex = Math.max(0, Math.min(displayFiles.length - 1, targetIndex));

if (targetIndex !== dragIndex.value) {
const targetX = targetIndex % columns;
const targetY = Math.floor(targetIndex / columns);
const sourceX = dragIndex.value % columns;
const sourceY = Math.floor(dragIndex.value / columns);

const xProgress = x / itemWidth - targetX;
const yProgress = y / itemHeight - targetY;

let shouldSort = false;
if (targetY !== sourceY) {
shouldSort = targetY > sourceY ? yProgress > 0.5 : yProgress < 0.5;
} else {
shouldSort = targetX > sourceX ? xProgress > 0.5 : xProgress < 0.5;
}

if (shouldSort) {
performSort(targetIndex, displayFiles, e);
} else if (pendingTargetIndex !== -1) {
pendingTargetIndex = -1;
}
}
};

const resetDrag = () => {
dragIndex.value = -1;
pendingTargetIndex = -1;
};

return {
onTouchstart: (e: TouchEvent, index: number) => {
if (!props.draggable) return;
dragIndex.value = index;
},
onTouchmove,
onTouchend: resetDrag,
dragIndex,
getFileId,
};
}
2 changes: 2 additions & 0 deletions src/upload/hooks/useUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export default function useUpload(props: TdUploadProps): {
onNormalFileChange: (e: Event) => void;
onInnerRemove: (context: UploadRemoveContext) => void;
cancelUpload: (context?: { file?: UploadFile; e?: MouseEvent }) => void;
setUploadValue: (value: UploadFile[], context: UploadChangeContext) => void;
} {
const inputRef = ref<HTMLInputElement>();
const { disabled, autoUpload, isBatchUpload, multiple, files, modelValue, defaultFiles } = toRefs(props);
Expand Down Expand Up @@ -399,5 +400,6 @@ export default function useUpload(props: TdUploadProps): {
onNormalFileChange,
onInnerRemove,
cancelUpload,
setUploadValue,
};
}
13 changes: 11 additions & 2 deletions src/upload/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export default {
type: Boolean,
default: undefined,
},
/** 是否启用拖拽上传 */
draggable: {
type: Boolean,
default: undefined,
},
/** 用于完全自定义文件列表界面内容(UI),单文件和多文件均有效 */
fileListDisplay: {
type: Function as PropType<TdUploadProps['fileListDisplay']>,
Expand Down Expand Up @@ -134,13 +139,13 @@ export default {
sizeLimit: {
type: [Number, Object] as PropType<TdUploadProps['sizeLimit']>,
},
/** 是否在同一个请求中上传全部文件,默认一个请求上传一个文件。多文件上传时有效 */
uploadAllFilesInOneRequest: Boolean,
/** 是否在请求时间超过 300ms 后显示模拟进度。上传进度有模拟进度和真实进度两种。一般大小的文件上传,真实的上传进度只有 0 和 100,不利于交互呈现,因此组件内置模拟上传进度。真实上传进度一般用于大文件上传 */
useMockProgress: {
type: Boolean,
default: true,
},
/** 是否在同一个请求中上传全部文件,默认一个请求上传一个文件。多文件上传时有效 */
uploadAllFilesInOneRequest: Boolean,
/** 已上传文件列表,同 `files`。TS 类型:`UploadFile` */
value: {
type: Array as PropType<TdUploadProps['value']>,
Expand All @@ -163,6 +168,10 @@ export default {
onChange: Function as PropType<TdUploadProps['onChange']>,
/** 点击上传区域时触发 */
onClickUpload: Function as PropType<TdUploadProps['onClickUpload']>,
/** 拖拽开始时触发 */
onDrag: Function as PropType<TdUploadProps['onDrag']>,
/** 拖拽结束后触发,返回上传的文件列表(拖拽后的文件顺序) */
onDrop: Function as PropType<TdUploadProps['onDrop']>,
/** 上传失败后触发。`response` 指接口响应结果,`response.error` 会作为错误文本提醒。如果希望判定为上传失败,但接口响应数据不包含 `error` 字段,可以使用 `formatResponse` 格式化 `response` 数据结构。如果是多文件多请求上传场景,请到事件 `onOneFileFail` 中查看 `response` */
onFail: Function as PropType<TdUploadProps['onFail']>,
/** 多文件/图片场景下,单个文件上传失败后触发,如果一个请求上传一个文件,则会触发多次。单文件/图片不会触发 */
Expand Down
64 changes: 41 additions & 23 deletions src/upload/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,16 @@ export interface TdUploadProps<T extends UploadFile = UploadFile> {
* @default true
*/
autoUpload?: boolean;
/**
* 如果是自动上传模式 `autoUpload=true`,表示单个文件上传之前的钩子函数,若函数返回值为 `false` 则表示不上传当前文件。<br/>如果是非自动上传模式 `autoUpload=false`,函数返回值为 `false` 时表示从上传文件中剔除当前文件
*/
/**
* 如果是自动上传模式 `autoUpload=true`,表示全部文件上传之前的钩子函数,函数参数为上传的文件,函数返回值决定是否继续上传,若返回值为 `false` 则终止上传。<br/>如果是非自动上传模式 `autoUpload=false`,则函数返回值为 `false` 时表示本次选中的文件不会加入到文件列表中,即不触发 `onChange` 事件
*/
beforeAllFilesUpload?: (file: UploadFile[]) => boolean | Promise<boolean>;
/**
* 如果是自动上传模式 `autoUpload=true`,表示单个文件上传之前的钩子函数,若函数返回值为 `false` 则表示不上传当前文件。<br/>如果是非自动上传模式 `autoUpload=false`,函数返回值为 `false` 时表示从上传文件中剔除当前文件
*/
beforeUpload?: (file: UploadFile) => boolean | Promise<boolean>;
/**
* 图片选取模式,可选值为 camera (直接调起摄像头)
* @default ''
*/
capture?: string | boolean;
/**
Expand All @@ -58,6 +57,10 @@ export interface TdUploadProps<T extends UploadFile = UploadFile> {
* 是否禁用组件
*/
disabled?: boolean;
/**
* 是否启用拖拽上传
*/
draggable?: boolean;
/**
* 用于完全自定义文件列表界面内容(UI),单文件和多文件均有效
*/
Expand Down Expand Up @@ -92,35 +95,35 @@ export interface TdUploadProps<T extends UploadFile = UploadFile> {
* 透传 Image 组件全部属性
*/
imageProps?: ImageProps;
/**
* 用于控制文件上传数量,值为 0 则不限制
* @default 0
*/
/**
* 多个文件是否作为一个独立文件包,整体替换,整体删除。不允许追加文件,只允许替换文件。`theme=file-flow` 时有效
* @default false
*/
isBatchUpload?: boolean;
/**
* 用于控制文件上传数量,值为 0 则不限制
* @default 0
*/
max?: number;
/**
* HTTP 请求类型
* @default POST
*/
method?: 'POST' | 'GET' | 'PUT' | 'OPTIONS' | 'PATCH' | 'post' | 'get' | 'put' | 'options' | 'patch';
/**
* 模拟进度间隔时间,单位:毫秒,默认:300。由于原始的上传请求,小文件上传进度只有 0 和 100,故而新增模拟进度,每间隔 `mockProgressDuration` 毫秒刷新一次模拟进度。小文件设置小一点,大文件设置大一点。注意:当 `useMockProgress` 为真时,当前设置有效
*/
mockProgressDuration?: number;
/**
* 支持多文件上传
* @default false
*/
multiple?: boolean;
/**
* 文件上传时的名称
* @default file
*/
name?: string;
/**
* 模拟进度间隔时间,单位:毫秒,默认:300。由于原始的上传请求,小文件上传进度只有 0 和 100,故而新增模拟进度,每间隔 `mockProgressDuration` 毫秒刷新一次模拟进度。小文件设置小一点,大文件设置大一点。注意:当 `useMockProgress` 为真时,当前设置有效
*/
mockProgressDuration?: number;
multiple?: boolean;
/**
* 是否支持图片预览,文件没有预览
* @default true
Expand All @@ -139,16 +142,16 @@ export interface TdUploadProps<T extends UploadFile = UploadFile> {
* 图片文件大小限制,默认单位 KB。可选单位有:`'B' | 'KB' | 'MB' | 'GB'`。示例一:`1000`。示例二:`{ size: 2, unit: 'MB', message: '图片大小不超过 {sizeLimit} MB' }`
*/
sizeLimit?: number | SizeLimitObj;
/**
* 是否在请求时间超过 300ms 后显示模拟进度。上传进度有模拟进度和真实进度两种。一般大小的文件上传,真实的上传进度只有 0 和 100,不利于交互呈现,因此组件内置模拟上传进度。真实上传进度一般用于大文件上传
* @default true
*/
useMockProgress?: boolean;
/**
* 是否在同一个请求中上传全部文件,默认一个请求上传一个文件。多文件上传时有效
* @default false
*/
uploadAllFilesInOneRequest?: boolean;
/**
* 是否在请求时间超过 300ms 后显示模拟进度。上传进度有模拟进度和真实进度两种。一般大小的文件上传,真实的上传进度只有 0 和 100,不利于交互呈现,因此组件内置模拟上传进度。真实上传进度一般用于大文件上传
* @default true
*/
useMockProgress?: boolean;
/**
* 已上传文件列表,同 `files`。TS 类型:`UploadFile`
* @default []
Expand All @@ -164,15 +167,15 @@ export interface TdUploadProps<T extends UploadFile = UploadFile> {
* @default []
*/
modelValue?: Array<T>;
/**
* 点击「取消上传」时触发
*/
onCancelUpload?: () => void;
/**
* 上传请求时是否携带 cookie
* @default false
*/
withCredentials?: boolean;
/**
* 点击「取消上传」时触发
*/
onCancelUpload?: () => void;
/**
* 已上传文件列表发生变化时触发,`trigger` 表示触发本次的来源
*/
Expand All @@ -181,6 +184,14 @@ export interface TdUploadProps<T extends UploadFile = UploadFile> {
* 点击上传区域时触发
*/
onClickUpload?: (context: { e: MouseEvent }) => void;
/**
* 拖拽开始时触发
*/
onDrag?: () => void;
/**
* 拖拽结束后触发,返回上传的文件列表(拖拽后的文件顺序)
*/
onDrop?: (value: Array<T>) => void;
/**
* 上传失败后触发。`response` 指接口响应结果,`response.error` 会作为错误文本提醒。如果希望判定为上传失败,但接口响应数据不包含 `error` 字段,可以使用 `formatResponse` 格式化 `response` 数据结构。如果是多文件多请求上传场景,请到事件 `onOneFileFail` 中查看 `response`
*/
Expand Down Expand Up @@ -306,7 +317,14 @@ export interface UploadChangeContext {
files?: UploadFile[];
}

export type UploadChangeTrigger = 'add' | 'remove' | 'abort' | 'progress-success' | 'progress' | 'progress-fail';
export type UploadChangeTrigger =
| 'add'
| 'remove'
| 'abort'
| 'progress-success'
| 'progress'
| 'progress-fail'
| 'sort';

export interface UploadFailContext {
e?: ProgressEvent;
Expand Down
Loading
Loading