DragLayer 拖拽预览层
Teleport 到 body 的浮层,跟随 pointer。订阅 useDragDrop 全局 store,配合 CfDraggable preview="none" 实现自定义 ghost。
基础用法
放一个 <CfDragLayer> 在页面任意位置(推荐根布局或路由顶层)。它 Teleport 到 body,position: fixed,按 pointer 位置实时更新。默认插槽参数:{ payload, over, canDrop }。
把 CfDraggable 的 preview="none" 打开后,源元素拖动时不变形,所有视觉都跑在 DragLayer 上。
背景 视口
<script setup lang="ts">
import { ref } from 'vue';
import {
CfDraggable,
CfDroppable,
CfDragLayer,
type DragPayload,
} from '@chufix-design/vue';
interface FileLike {
id: string;
name: string;
size: string;
}
const files: FileLike[] = [
{ id: '1', name: 'design-spec.md', size: '12 KB' },
{ id: '2', name: 'dashboard.png', size: '1.4 MB' },
{ id: '3', name: 'oncall-handoff.pdf', size: '342 KB' },
];
const droppedIds = ref<string[]>([]);
function onDrop(payload: DragPayload) {
const f = payload.data as FileLike;
if (!droppedIds.value.includes(f.id)) droppedIds.value.push(f.id);
}
</script>
<template>
<div class="dl-demo">
<div class="dl-files">
<CfDraggable
v-for="f in files"
:key="f.id"
type="file"
:data="f"
preview="none"
class="dl-file"
>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M3 1.5h6L13 5.5V14.5H3z" fill="none" stroke="currentColor" stroke-width="1.2" />
<path d="M9 1.5V5.5H13" fill="none" stroke="currentColor" stroke-width="1.2" />
</svg>
<span>{{ f.name }}</span>
<em>{{ f.size }}</em>
</CfDraggable>
</div>
<CfDroppable accept="file" @drop="onDrop">
<template #default="{ isOver }">
<div class="dl-target" :class="{ 'is-over': isOver }">
<strong>上传到云端</strong>
<span v-if="droppedIds.length === 0">拖文件到此处</span>
<ul v-else>
<li v-for="id in droppedIds" :key="id">已上传 {{ files.find((x) => x.id === id)?.name }}</li>
</ul>
</div>
</template>
</CfDroppable>
<CfDragLayer>
<template #default="{ payload, canDrop }">
<div class="dl-preview" :class="{ 'is-accepting': canDrop }">
<strong>{{ (payload.data as FileLike).name }}</strong>
<span>{{ canDrop ? '放下以上传' : '拖拽中…' }}</span>
</div>
</template>
</CfDragLayer>
</div>
</template>
<style scoped>
.dl-demo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.dl-files {
display: flex;
flex-direction: column;
gap: 8px;
}
.dl-file {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-2);
color: var(--fg-1);
font-size: var(--t-13);
}
.dl-file svg {
color: var(--accent-1);
flex-shrink: 0;
}
.dl-file span {
flex: 1;
}
.dl-file em {
color: var(--fg-3);
font-style: normal;
font-size: var(--t-12);
}
.dl-target {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
min-height: 180px;
color: var(--fg-2);
font-size: var(--t-12);
}
.dl-target strong {
color: var(--fg-1);
font-size: var(--t-13);
}
.dl-target ul {
margin: 0;
padding-left: 18px;
}
.dl-preview {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px;
background: var(--bg-2);
border: 1px solid var(--line-2);
border-radius: var(--r-3);
box-shadow: var(--shadow-3);
color: var(--fg-1);
font-size: var(--t-12);
min-width: 160px;
}
.dl-preview strong {
font-size: var(--t-13);
}
.dl-preview span {
color: var(--fg-3);
}
.dl-preview.is-accepting {
border-color: var(--accent-1);
background: var(--accent-soft);
}
.dl-preview.is-accepting span {
color: var(--accent-1);
}
</style> <script setup>
import { ref } from 'vue';
import {
CfDraggable,
CfDroppable,
CfDragLayer,
} from '@chufix-design/vue';
const files= [
{ id: '1', name: 'design-spec.md', size: '12 KB' },
{ id: '2', name: 'dashboard.png', size: '1.4 MB' },
{ id: '3', name: 'oncall-handoff.pdf', size: '342 KB' },
];
const droppedIds = ref<string[]>([]);
function onDrop(payload) {
const f = payload.data;
if (!droppedIds.value.includes(f.id)) droppedIds.value.push(f.id);
}
</script>
<template>
<div class="dl-demo">
<div class="dl-files">
<CfDraggable
v-for="f in files"
:key="f.id"
type="file"
:data="f"
preview="none"
class="dl-file"
>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M3 1.5h6L13 5.5V14.5H3z" fill="none" stroke="currentColor" stroke-width="1.2" />
<path d="M9 1.5V5.5H13" fill="none" stroke="currentColor" stroke-width="1.2" />
</svg>
<span>{{ f.name }}</span>
<em>{{ f.size }}</em>
</CfDraggable>
</div>
<CfDroppable accept="file" @drop="onDrop">
<template #default="{ isOver }">
<div class="dl-target" :class="{ 'is-over': isOver }">
<strong>上传到云端</strong>
<span v-if="droppedIds.length === 0">拖文件到此处</span>
<ul v-else>
<li v-for="id in droppedIds" :key="id">已上传 {{ files.find((x) => x.id === id)?.name }}</li>
</ul>
</div>
</template>
</CfDroppable>
<CfDragLayer>
<template #default="{ payload, canDrop }">
<div class="dl-preview" :class="{ 'is-accepting': canDrop }">
<strong>{{ (payload.data).name }}</strong>
<span>{{ canDrop ? '放下以上传' : '拖拽中…' }}</span>
</div>
</template>
</CfDragLayer>
</div>
</template>
<style scoped>
.dl-demo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.dl-files {
display: flex;
flex-direction: column;
gap: 8px;
}
.dl-file {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-2);
color: var(--fg-1);
font-size: var(--t-13);
}
.dl-file svg {
color: var(--accent-1);
flex-shrink: 0;
}
.dl-file span {
flex: 1;
}
.dl-file em {
color: var(--fg-3);
font-style: normal;
font-size: var(--t-12);
}
.dl-target {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px;
min-height: 180px;
color: var(--fg-2);
font-size: var(--t-12);
}
.dl-target strong {
color: var(--fg-1);
font-size: var(--t-13);
}
.dl-target ul {
margin: 0;
padding-left: 18px;
}
.dl-preview {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px;
background: var(--bg-2);
border: 1px solid var(--line-2);
border-radius: var(--r-3);
box-shadow: var(--shadow-3);
color: var(--fg-1);
font-size: var(--t-12);
min-width: 160px;
}
.dl-preview strong {
font-size: var(--t-13);
}
.dl-preview span {
color: var(--fg-3);
}
.dl-preview.is-accepting {
border-color: var(--accent-1);
background: var(--accent-soft);
}
.dl-preview.is-accepting span {
color: var(--accent-1);
}
</style> import { useState } from 'react';
import { CfDraggable, CfDroppable } from '@chufix-design/react';
export default function Demo() {
interface FileLike {
id: string;
name: string;
size: string;
}
const files: FileLike[] = [
{ id: '1', name: 'design-spec.md', size: '12 KB' },
{ id: '2', name: 'dashboard.png', size: '1.4 MB' },
{ id: '3', name: 'oncall-handoff.pdf', size: '342 KB' },
];
const [droppedIds, setDroppedIds] = useState<string[]>([]);
function onDrop(payload: DragPayload) {
const f = payload.data as FileLike;
if (!droppedIds.includes(f.id)) droppedIds.push(f.id);
}
return (
<>
<div className="dl-demo">
<div className="dl-files">
<CfDraggable v-for="f in files" key={f.id} type="file" data={f} preview="none" className="dl-file" >
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M3 1.5h6L13 5.5V14.5H3z" fill="none" stroke="currentColor" stroke-width="1.2" />
<path d="M9 1.5V5.5H13" fill="none" stroke="currentColor" stroke-width="1.2" />
</svg>
<span>{f.name}</span>
<em>{f.size}</em>
</CfDraggable>
</div>
<CfDroppable accept="file" onDrop={onDrop}>
<div className="dl-target" className={{ 'is-over': isOver }}>
<strong>上传到云端</strong>
<span v-if="droppedIds.length === 0">拖文件到此处</span>
<ul v-else>
<li v-for="id in droppedIds" key={id}>已上传 {files.find((x) => x.id === id)?.name}</li>
</ul>
</div>
</>
);
} import { useState } from 'react';
import { CfDraggable, CfDroppable } from '@chufix-design/react';
export default function Demo() {
const files= [
{ id: '1', name: 'design-spec.md', size: '12 KB' },
{ id: '2', name: 'dashboard.png', size: '1.4 MB' },
{ id: '3', name: 'oncall-handoff.pdf', size: '342 KB' },
];
const [droppedIds, setDroppedIds] = useState<string[]>([]);
function onDrop(payload) {
const f = payload.data;
if (!droppedIds.includes(f.id)) droppedIds.push(f.id);
}
return (
<>
<div className="dl-demo">
<div className="dl-files">
<CfDraggable v-for="f in files" key={f.id} type="file" data={f} preview="none" className="dl-file" >
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M3 1.5h6L13 5.5V14.5H3z" fill="none" stroke="currentColor" stroke-width="1.2" />
<path d="M9 1.5V5.5H13" fill="none" stroke="currentColor" stroke-width="1.2" />
</svg>
<span>{f.name}</span>
<em>{f.size}</em>
</CfDraggable>
</div>
<CfDroppable accept="file" onDrop={onDrop}>
<div className="dl-target" className={{ 'is-over': isOver }}>
<strong>上传到云端</strong>
<span v-if="droppedIds.length === 0">拖文件到此处</span>
<ul v-else>
<li v-for="id in droppedIds" key={id}>已上传 {files.find((x) => x.id === id)?.name}</li>
</ul>
</div>
</>
);
} Teleport 注意事项
CfDragLayer 内部用 <Teleport to="body"> (Vue) / createPortal(document.body) (React)。在 Astro 中嵌入 demo 时必须用 client:only="vue",不能用 client:load,否则 SSR/hydrate 时机不一致会导致 layer 不出现(同 Tooltip / Modal / Toaster)。
API
Props
| 属性 | 类型 | 默认 | 说明 |
|---|---|---|---|
offsetX | number | 12 | 预览相对 pointer 的水平偏移 (px) |
offsetY | number | 12 | 预览相对 pointer 的垂直偏移 (px) |
默认插槽
{ payload, over, canDrop }:
payload: { type, data }— 当前拖拽的数据over: HTMLElement \| null— pointer 下当前的 DroppablecanDrop: boolean— 该 over 是否接受这个 type
不传插槽时渲染默认小 chip 显示 type。
反馈与讨论
DragLayer 拖拽预览层 的讨论