feat: add multi-select add-to-canvas UI for3D world/model history
- Add '批量添加到画布' panel in the right-side assets panel, with 全选可添加 toggle and '添加到画布 (N)' action button. - Add per-card '加画布 / 已选' toggle button (right-of-download icon), visible when task.status is success. Selected cards show yellow ring. - Wire selectedAddTaskIds Set state, addableTasks/selectedAddTasks memos, handleToggleAddSelection / handleSelectAllForAdd / handleBatchImportToCanvas handlers, and a useEffect to prune stale selections when the visible history changes. - handleBatchImportToCanvas calls addNodesBulk (added in store commit) which loops addNode and reuses its ring-scan auto-overlap logic, so multiple world3d/model3d nodes land at distinct canvas positions instead of stacking on the viewport center.
This commit is contained in:
parent
fdb5304111
commit
c9fe549e86
@ -3,45 +3,46 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bone,
|
||||
Box,
|
||||
Boxes,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Crosshair,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Grid3X3,
|
||||
Home,
|
||||
ImagePlus,
|
||||
Images,
|
||||
Layers3,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
Maximize,
|
||||
Package,
|
||||
Palette,
|
||||
PenLine,
|
||||
Play,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
Scissors,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
Link2,
|
||||
PanelTop,
|
||||
Video,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bone,
|
||||
Box,
|
||||
Boxes,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Crosshair,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Grid3X3,
|
||||
Home,
|
||||
ImagePlus,
|
||||
Images,
|
||||
Layers3,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
Maximize,
|
||||
Package,
|
||||
Palette,
|
||||
PenLine,
|
||||
Play,
|
||||
Plus,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
Scissors,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Globe,
|
||||
ImageIcon,
|
||||
Link2,
|
||||
PanelTop,
|
||||
Video,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { type MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@ -2283,51 +2284,255 @@ export default function CanvasModel3DPage() {
|
||||
);
|
||||
}, [shareableWorldTasks]);
|
||||
|
||||
const handleShareSelectedWorlds = useCallback(async () => {
|
||||
if (selectedWorldShareTasks.length < 2) {
|
||||
toast.error("请至少选择 2 个成功生成的 3D 世界");
|
||||
return;
|
||||
}
|
||||
const handleShareSelectedWorlds = useCallback(async () => {
|
||||
if (selectedWorldShareTasks.length <2) {
|
||||
toast.error("请至少选择2 个成功生成的3D 世界");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
taskIds: selectedWorldShareTasks.map((task) => task.taskId),
|
||||
shareEnabled: true,
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
items?: TaskRecordApiEntity[];
|
||||
shareToken?: string | null;
|
||||
};
|
||||
if (!response.ok || payload?.success === false) {
|
||||
throw new Error(payload?.error || "批量分享世界失败");
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/${id}/model3d-assets`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
taskIds: selectedWorldShareTasks.map((task) => task.taskId),
|
||||
shareEnabled: true,
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
items?: TaskRecordApiEntity[];
|
||||
shareToken?: string | null;
|
||||
};
|
||||
if (!response.ok || payload?.success === false) {
|
||||
throw new Error(payload?.error || "批量分享世界失败");
|
||||
}
|
||||
|
||||
const updatedTasks = Array.isArray(payload.items)
|
||||
? payload.items
|
||||
.map((item) => mapApiEntityToTaskRecord(item))
|
||||
.filter((item): item is TaskRecord => Boolean(item))
|
||||
: [];
|
||||
for (const updatedTask of updatedTasks) {
|
||||
upsertTaskHistory(updatedTask, { persist: false });
|
||||
}
|
||||
const updatedTasks = Array.isArray(payload.items)
|
||||
? payload.items
|
||||
.map((item) => mapApiEntityToTaskRecord(item))
|
||||
.filter((item): item is TaskRecord => Boolean(item))
|
||||
: [];
|
||||
for (const updatedTask of updatedTasks) {
|
||||
upsertTaskHistory(updatedTask, { persist: false });
|
||||
}
|
||||
|
||||
const shareToken = payload.shareToken || updatedTasks[0]?.shareToken;
|
||||
const shareUrl = buildWorldShareUrl(shareToken);
|
||||
if (!shareUrl) {
|
||||
throw new Error("分享链接生成失败");
|
||||
}
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast.success(`已复制包含 ${selectedWorldShareTasks.length} 个世界的分享链接`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "批量分享世界失败");
|
||||
}
|
||||
}, [id, selectedWorldShareTasks, upsertTaskHistory]);
|
||||
const shareToken = payload.shareToken || updatedTasks[0]?.shareToken;
|
||||
const shareUrl = buildWorldShareUrl(shareToken);
|
||||
if (!shareUrl) {
|
||||
throw new Error("分享链接生成失败");
|
||||
}
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast.success(`已复制包含 ${selectedWorldShareTasks.length} 个世界的分享链接`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "批量分享世界失败");
|
||||
}
|
||||
}, [id, selectedWorldShareTasks, upsertTaskHistory]);
|
||||
|
||||
//多选添加到画布的 handlers
|
||||
const handleToggleAddSelection = useCallback(
|
||||
(task: TaskRecord, event?: MouseEvent<HTMLButtonElement>) => {
|
||||
event?.stopPropagation();
|
||||
if (task.status !== "success") {
|
||||
toast.error("仅成功生成的世界/模型可添加到画布");
|
||||
return;
|
||||
}
|
||||
setSelectedAddTaskIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(task.taskId)) {
|
||||
next.delete(task.taskId);
|
||||
} else {
|
||||
next.add(task.taskId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSelectAllForAdd = useCallback(() => {
|
||||
setSelectedAddTaskIds((prev) => {
|
||||
if (prev.size === addableTasks.length) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(addableTasks.map((task) => task.taskId));
|
||||
});
|
||||
}, [addableTasks]);
|
||||
|
||||
//清理已不存在的选中项
|
||||
useEffect(() => {
|
||||
const addableIds = new Set(addableTasks.map((task) => task.taskId));
|
||||
setSelectedAddTaskIds((prev) => {
|
||||
const next = new Set<string>();
|
||||
for (const id of prev) {
|
||||
if (addableIds.has(id)) next.add(id);
|
||||
}
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [addableTasks]);
|
||||
|
||||
//批量添加到画布:为每个 task 计算节点配置,复用 addNode 的 ring-scan避让逻辑
|
||||
const handleBatchImportToCanvas = useCallback(async () => {
|
||||
const targets = selectedAddTasks;
|
||||
if (targets.length ===0) {
|
||||
toast.error("请至少选择1 个成功生成的世界/模型");
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportX = viewport.x ||0;
|
||||
const viewportY = viewport.y ||0;
|
||||
const viewportZoom = viewport.zoom ||1;
|
||||
const stageWidth =
|
||||
typeof window !== "undefined" ? window.innerWidth :1280;
|
||||
const stageHeight =
|
||||
typeof window !== "undefined" ? window.innerHeight :720;
|
||||
const targetWidth =380;
|
||||
const targetHeight =300;
|
||||
|
||||
const baseX =
|
||||
(stageWidth *0.48 - viewportX) / viewportZoom - targetWidth /2;
|
||||
const baseY =
|
||||
(stageHeight *0.45 - viewportY) / viewportZoom - targetHeight /2;
|
||||
|
||||
//收集每个 task 的节点配置(让 store内部做位置避让)
|
||||
const nodeConfigs: Array<
|
||||
Parameters<typeof addNode>[0]
|
||||
> = [];
|
||||
|
||||
for (const task of targets) {
|
||||
const {
|
||||
canImport,
|
||||
isWorldTask,
|
||||
modelUrl,
|
||||
primarySplatUrl,
|
||||
worldAssetPayload,
|
||||
worldImageUrl,
|
||||
} = getTaskImportContext(task);
|
||||
|
||||
if (!canImport) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nodeConfigs.push(
|
||||
isWorldTask
|
||||
? {
|
||||
type: "world3d",
|
||||
position: { x: baseX, y: baseY },
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
worldUrl: worldAssetPayload?.worldUrl || undefined,
|
||||
imageUrl: worldImageUrl || undefined,
|
||||
meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined,
|
||||
splatUrl: primarySplatUrl || undefined,
|
||||
previewImage:
|
||||
task.previewImageUrl ||
|
||||
worldAssetPayload?.thumbnailUrl ||
|
||||
undefined,
|
||||
prompt: task.prompt,
|
||||
modelVersion: task.modelVersion,
|
||||
status: "completed",
|
||||
activeView: primarySplatUrl
|
||||
? "splat"
|
||||
: worldImageUrl
|
||||
? "image"
|
||||
: "mesh",
|
||||
firstPerson: false,
|
||||
hotspots: [],
|
||||
semanticsMetadata:
|
||||
worldAssetPayload?.semanticsMetadata || undefined,
|
||||
metadata: {
|
||||
taskId: task.taskId,
|
||||
provider: task.provider,
|
||||
mode: task.mode,
|
||||
sourceTaskId: task.sourceTaskId,
|
||||
progress: task.progress,
|
||||
triangles: viewerStats.triangles,
|
||||
vertices: viewerStats.vertices,
|
||||
meshCount: viewerStats.meshCount,
|
||||
assetPayload: task.assetPayload ?? null,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
}
|
||||
: {
|
||||
type: "model3d",
|
||||
position: { x: baseX, y: baseY },
|
||||
size: { width: targetWidth, height: targetHeight },
|
||||
data: {
|
||||
src: modelUrl!,
|
||||
previewImage: task.previewImageUrl || undefined,
|
||||
prompt: task.prompt,
|
||||
modelVersion: task.modelVersion,
|
||||
format: extractModelFormat(modelUrl!),
|
||||
status: "completed",
|
||||
metadata: {
|
||||
taskId: task.taskId,
|
||||
provider: task.provider,
|
||||
mode: task.mode,
|
||||
sourceTaskId: task.sourceTaskId,
|
||||
progress: task.progress,
|
||||
triangles: viewerStats.triangles,
|
||||
vertices: viewerStats.vertices,
|
||||
meshCount: viewerStats.meshCount,
|
||||
assetPayload: task.assetPayload ?? null,
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (nodeConfigs.length ===0) {
|
||||
toast.error("选中的资产没有可添加到画布的内容");
|
||||
return;
|
||||
}
|
||||
|
||||
//复用 addNodesBulk(循环 addNode → 自动避让)
|
||||
const createdNodeIds = addNodesBulk(nodeConfigs);
|
||||
|
||||
//选中新增的节点,让用户立刻看到位置
|
||||
if (createdNodeIds.length >0) {
|
||||
selectNodes(createdNodeIds);
|
||||
}
|
||||
|
||||
// 清空选中状态,提示结果
|
||||
setSelectedAddTaskIds(new Set());
|
||||
const skipped = targets.length - nodeConfigs.length;
|
||||
const worldCount = nodeConfigs.filter((c) => c.type === "world3d").length;
|
||||
const modelCount = nodeConfigs.length - worldCount;
|
||||
const summary = [
|
||||
worldCount >0 ? `${worldCount} 个世界` : "",
|
||||
modelCount >0 ? `${modelCount} 个模型` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" + ");
|
||||
toast.success(
|
||||
skipped >0
|
||||
? `已批量添加 ${summary}(跳过 ${skipped} 个无资源项)`
|
||||
: `已批量添加 ${summary}到画布`
|
||||
);
|
||||
|
||||
try {
|
||||
await canvasService.saveCanvasState(id, getCanvasState());
|
||||
} catch (error) {
|
||||
console.error("[Model3DPage] 保存批量画布失败:", error);
|
||||
}
|
||||
}, [
|
||||
addNode,
|
||||
addNodesBulk,
|
||||
getCanvasState,
|
||||
getTaskImportContext,
|
||||
id,
|
||||
selectNodes,
|
||||
selectedAddTasks,
|
||||
viewerStats.meshCount,
|
||||
viewerStats.triangles,
|
||||
viewerStats.vertices,
|
||||
viewport.x,
|
||||
viewport.y,
|
||||
viewport.zoom,
|
||||
]);
|
||||
|
||||
const handleDisableCurrentShare = useCallback(async () => {
|
||||
if (!currentTask || currentTask.provider !== "worldlabs-3d") {
|
||||
@ -4080,62 +4285,150 @@ export default function CanvasModel3DPage() {
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{workspaceMode === "world" ? (
|
||||
<div className="space-y-2 border-white/10 border-b px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] text-zinc-500">
|
||||
<span>多世界分享</span>
|
||||
<span>已选 {selectedWorldShareTasks.length}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
className="h-8 border-white/10 bg-zinc-950/60 text-xs text-zinc-200 hover:bg-zinc-800"
|
||||
disabled={shareableWorldTasks.length === 0}
|
||||
onClick={handleSelectAllShareableWorlds}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{selectedWorldShareTaskIds.length === shareableWorldTasks.length &&
|
||||
shareableWorldTasks.length > 0
|
||||
? "取消全选"
|
||||
: "全选世界"}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 border-cyan-400/30 bg-cyan-400/15 text-xs text-cyan-100 hover:bg-cyan-400/25"
|
||||
disabled={selectedWorldShareTasks.length < 2}
|
||||
onClick={handleShareSelectedWorlds}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
分享所选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{workspaceMode === "world" ? (
|
||||
<div className="space-y-2 border-white/10 border-b px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] text-zinc-500">
|
||||
<span>多世界分享</span>
|
||||
<span>已选 {selectedWorldShareTasks.length}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
className="h-8 border-white/10 bg-zinc-950/60 text-xs text-zinc-200 hover:bg-zinc-800"
|
||||
disabled={shareableWorldTasks.length ===0}
|
||||
onClick={handleSelectAllShareableWorlds}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{selectedWorldShareTaskIds.length === shareableWorldTasks.length &&
|
||||
shareableWorldTasks.length >0
|
||||
? "取消全选"
|
||||
: "全选世界"}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 border-cyan-400/30 bg-cyan-400/15 text-xs text-cyan-100 hover:bg-cyan-400/25"
|
||||
disabled={selectedWorldShareTasks.length <2}
|
||||
onClick={handleShareSelectedWorlds}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
分享所选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{addableTasks.length >0 ? (
|
||||
<div className="space-y-2 border-white/10 border-b px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] text-zinc-500">
|
||||
<span>批量添加到画布</span>
|
||||
<span>已选 {selectedAddTaskIds.size}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
className="h-8 border-white/10 bg-zinc-950/60 text-xs text-zinc-200 hover:bg-zinc-800"
|
||||
disabled={addableTasks.length ===0}
|
||||
onClick={handleSelectAllForAdd}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{selectedAddTaskIds.size === addableTasks.length && addableTasks.length >0
|
||||
? "取消全选"
|
||||
: "全选可添加"}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 border-yellow-400/30 bg-yellow-400/15 text-xs text-yellow-100 hover:bg-yellow-400/25"
|
||||
disabled={selectedAddTaskIds.size ===0}
|
||||
onClick={() => void handleBatchImportToCanvas()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
添加到画布 ({selectedAddTaskIds.size})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="grid grid-cols-2 gap-2 p-3">
|
||||
{visibleTaskHistory.map((task) => {
|
||||
const canShareWorld =
|
||||
task.provider === "worldlabs-3d" && task.status === "success";
|
||||
const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId);
|
||||
{visibleTaskHistory.map((task) => {
|
||||
const canShareWorld =
|
||||
task.provider === "worldlabs-3d" && task.status === "success";
|
||||
const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId);
|
||||
const isSelectedForAdd =
|
||||
task.status === "success" && selectedAddTaskIds.has(task.taskId);
|
||||
const canAddToCanvas = task.status === "success";
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"group relative aspect-square overflow-hidden rounded-lg border border-white/5 bg-zinc-950 transition-all hover:border-yellow-500/50",
|
||||
currentTask?.taskId === task.taskId &&
|
||||
"border-yellow-500 ring-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]",
|
||||
isSelectedForShare &&
|
||||
"border-cyan-300 ring-2 ring-cyan-300/40"
|
||||
)}
|
||||
key={task.taskId}
|
||||
onClick={() => handleSelectTask(task)}
|
||||
type="button"
|
||||
>
|
||||
{currentTask?.taskId === task.taskId ? (
|
||||
<Badge className="absolute left-2 bottom-2 z-10 border-yellow-400/40 bg-yellow-500/20 text-[9px] text-yellow-50">
|
||||
当前
|
||||
</Badge>
|
||||
) : null}
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"group relative aspect-square overflow-hidden rounded-lg border border-white/5 bg-zinc-950 transition-all hover:border-yellow-500/50",
|
||||
currentTask?.taskId === task.taskId &&
|
||||
"border-yellow-500 ring-2 ring-yellow-500/30 shadow-[0_18px_40px_rgba(0,0,0,0.28)]",
|
||||
isSelectedForShare &&
|
||||
"border-cyan-300 ring-2 ring-cyan-300/40",
|
||||
isSelectedForAdd &&
|
||||
"border-yellow-300 ring-2 ring-yellow-300/40"
|
||||
)
|
||||
key={task.taskId}
|
||||
onClick={() => handleSelectTask(task)}
|
||||
type="button"
|
||||
>
|
||||
{currentTask?.taskId === task.taskId ? (
|
||||
<Badge className="absolute left-2 bottom-2 z-10 border-yellow-400/40 bg-yellow-500/20 text-[9px] text-yellow-50">
|
||||
当前
|
||||
</Badge>
|
||||
) : null}
|
||||
{canShareWorld ? (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute left-2 top-2 z-10 h-7 w-7 rounded-full border border-white/10 bg-black/60 text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
|
||||
task.shareEnabled &&
|
||||
"border-amber-400/40 bg-amber-400/20 text-amber-100 opacity-100"
|
||||
)
|
||||
onClick={(event) => void handleShareTask(task, event)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
{canShareWorld ? (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute left-10 top-2 z-10 h-7 rounded-full border border-white/10 bg-black/60 px-2 text-[10px] text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
|
||||
isSelectedForShare &&
|
||||
"border-cyan-300/60 bg-cyan-400/25 text-cyan-50 opacity-100"
|
||||
)
|
||||
onClick={(event) => handleToggleWorldShareSelection(task, event)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isSelectedForShare ? (
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
) : null}
|
||||
{isSelectedForShare ? "已选" : "选择"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canAddToCanvas ? (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute right-10 top-2 z-10 h-7 rounded-full border border-white/10 bg-black/60 px-2 text-[10px] text-zinc-100 opacity-0 shadow-lg backdrop-blur-sm transition-opacity hover:bg-black/80 group-hover:opacity-100",
|
||||
isSelectedForAdd &&
|
||||
"border-yellow-300/60 bg-yellow-400/25 text-yellow-50 opacity-100"
|
||||
)
|
||||
onClick={(event) => handleToggleAddSelection(task, event)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isSelectedForAdd ? (
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{isSelectedForAdd ? "已选" : "加画布"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canShareWorld ? (
|
||||
<Button
|
||||
className={cn(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user