feat(canvas): 画布插件与视觉 - 合并 PR #289 并保留任务计费边界
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
|
||||
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
|
||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
|
||||
type InsertableAsset = Extract<Asset, { kind: "text" | "image" | "video" | "audio" }>;
|
||||
|
||||
export type InsertAssetPayload =
|
||||
| { kind: "text"; content: string; title: string; assetId?: string }
|
||||
| { kind: "image"; dataUrl: string; title: string; storageKey?: string; assetId?: string }
|
||||
| { kind: "image"; dataUrl: string; title: string; url?: string; storageKey?: string; width?: number; height?: number; bytes?: number; mimeType?: string; assetId?: string }
|
||||
| { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number; durationMs?: number; bytes?: number; mimeType?: string; assetId?: string }
|
||||
| { kind: "audio"; url: string; title: string; storageKey?: string; durationMs?: number; bytes?: number; mimeType?: string; assetId?: string }
|
||||
| { kind: "character"; title: string; assetId: string; versionId: string; prompt: string; aliases: string[]; definition: Record<string, unknown>; coverUrl?: string; visualStatus: string; voiceStatus: string; voiceName?: string; voiceProfile?: { name: string; provider: string; language: string; timbre: string }; voiceInstructions?: string };
|
||||
@@ -22,17 +24,27 @@ const categoryLabels: Record<string, string> = { all: "全部素材", character:
|
||||
|
||||
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const externalAssetSources = useExternalAssetSources(open);
|
||||
const insertableAssets = useMemo(() => assets.filter((asset): asset is InsertableAsset => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio"), [assets]);
|
||||
const items = useMemo<AssetLibraryPickerItem[]>(() => insertableAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.category || "other",
|
||||
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
|
||||
asset,
|
||||
searchText: asset.tags.join(" "),
|
||||
})), [insertableAssets]);
|
||||
const items = useMemo<AssetLibraryPickerItem[]>(() => [
|
||||
...insertableAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title,
|
||||
category: asset.category || "other",
|
||||
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
|
||||
asset,
|
||||
searchText: asset.tags.join(" "),
|
||||
})),
|
||||
...externalAssetSources.items,
|
||||
], [externalAssetSources.items, insertableAssets]);
|
||||
|
||||
const insert = (id: string) => {
|
||||
const pickerItem = items.find((item) => item.id === id);
|
||||
if (pickerItem?.external) {
|
||||
onInsert(externalAssetToInsertPayload(pickerItem.external));
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const asset = insertableAssets.find((item) => item.id === id);
|
||||
if (!asset) throw new Error("所选素材已不存在,请重新选择");
|
||||
if (asset.kind === "text") onInsert({ kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id });
|
||||
@@ -46,7 +58,9 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
<AssetLibraryPickerModal
|
||||
open={open}
|
||||
items={items}
|
||||
categoryLabels={categoryLabels}
|
||||
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
folders={externalAssetSources.folders}
|
||||
footerNote={externalAssetSources.error || undefined}
|
||||
multiple={false}
|
||||
confirmLabel={() => "插入所选素材"}
|
||||
emptyDescription="先在素材库中添加图片、视频、音频或文本。"
|
||||
@@ -55,3 +69,14 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function externalAssetToInsertPayload(reference: ExternalAssetPickerReference): InsertAssetPayload {
|
||||
const item = reference.item;
|
||||
const url = item.fileUrl || "";
|
||||
if (!url) throw new Error(`“${item.title}”暂时无法读取,请先在 Eagle 中确认文件可用`);
|
||||
const assetId = `external:${reference.sourceId}:${item.id}`;
|
||||
if (item.kind === "image") return { kind: "image", dataUrl: url, url, title: item.title || "素材图片", width: item.width, height: item.height, bytes: item.bytes, mimeType: item.mimeType, assetId };
|
||||
if (item.kind === "video") return { kind: "video", url, title: item.title, width: item.width, height: item.height, bytes: item.bytes, mimeType: item.mimeType, assetId };
|
||||
if (item.kind === "audio") return { kind: "audio", url, title: item.title, bytes: item.bytes, mimeType: item.mimeType, assetId };
|
||||
throw new Error(`“${item.title}”不是可插入画布的媒体文件`);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,14 @@ const TRAY_DEFAULT_HEIGHT = 520;
|
||||
const TRAY_MIN_HEIGHT = 400;
|
||||
const TRAY_BOTTOM_SAFE_SPACE = 82;
|
||||
|
||||
type ResizeSession = {
|
||||
pointerId: number;
|
||||
startY: number;
|
||||
startHeight: number;
|
||||
nextHeight: number;
|
||||
frameId: number | null;
|
||||
};
|
||||
|
||||
function getMaxTrayHeight() {
|
||||
if (typeof window === "undefined") return 520;
|
||||
return Math.max(360, window.innerHeight - TRAY_BOTTOM_SAFE_SPACE);
|
||||
@@ -49,7 +57,10 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
|
||||
const [tab, setTab] = useState<TrayTab>(() => showLibrary ? "library" : "canvas");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [trayHeight, setTrayHeight] = useState(() => clampTrayHeight(TRAY_DEFAULT_HEIGHT));
|
||||
const resizeRef = useRef<{ startY: number; startHeight: number } | null>(null);
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const resizeRef = useRef<ResizeSession | null>(null);
|
||||
const resizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
const resizeHandleRef = useRef<{ element: HTMLButtonElement; pointerId: number } | null>(null);
|
||||
const query = keyword.trim().toLowerCase();
|
||||
const filteredAssets = useMemo(() => assetImages.filter((asset) => !query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query)), [assetImages, query]);
|
||||
const filteredNodes = useMemo(() => canvasImages.filter((node) => !query || canvasImageTitle(node).toLowerCase().includes(query)), [canvasImages, query]);
|
||||
@@ -63,29 +74,70 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
|
||||
event.dataTransfer.setData("text/plain", asset.title);
|
||||
};
|
||||
|
||||
const startResize = useCallback(
|
||||
(event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
resizeRef.current = { startY: event.clientY, startHeight: safeTrayHeight };
|
||||
const paintResize = useCallback(() => {
|
||||
const session = resizeRef.current;
|
||||
if (!session || session.frameId !== null) return;
|
||||
session.frameId = window.requestAnimationFrame(() => {
|
||||
session.frameId = null;
|
||||
if (resizeRef.current !== session || !panelRef.current) return;
|
||||
panelRef.current.style.height = String(session.nextHeight) + "px";
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateHeight = (moveEvent: PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
setTrayHeight(clampTrayHeight(resizeRef.current.startHeight + resizeRef.current.startY - moveEvent.clientY));
|
||||
};
|
||||
const stopResize = () => {
|
||||
resizeRef.current = null;
|
||||
window.removeEventListener("pointermove", updateHeight);
|
||||
window.removeEventListener("pointerup", stopResize);
|
||||
window.removeEventListener("pointercancel", stopResize);
|
||||
};
|
||||
const stopResize = useCallback(() => {
|
||||
const session = resizeRef.current;
|
||||
if (!session) return;
|
||||
resizeCleanupRef.current?.();
|
||||
resizeCleanupRef.current = null;
|
||||
if (session.frameId !== null) {
|
||||
window.cancelAnimationFrame(session.frameId);
|
||||
session.frameId = null;
|
||||
}
|
||||
const finalHeight = session.nextHeight;
|
||||
if (panelRef.current) {
|
||||
panelRef.current.style.height = String(finalHeight) + "px";
|
||||
panelRef.current.classList.remove("is-resizing");
|
||||
}
|
||||
const handle = resizeHandleRef.current;
|
||||
if (handle?.element.hasPointerCapture(handle.pointerId)) handle.element.releasePointerCapture(handle.pointerId);
|
||||
resizeHandleRef.current = null;
|
||||
resizeRef.current = null;
|
||||
setTrayHeight(finalHeight);
|
||||
}, []);
|
||||
|
||||
window.addEventListener("pointermove", updateHeight);
|
||||
window.addEventListener("pointerup", stopResize, { once: true });
|
||||
window.addEventListener("pointercancel", stopResize, { once: true });
|
||||
},
|
||||
[safeTrayHeight],
|
||||
);
|
||||
const startResize = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
stopResize();
|
||||
const session: ResizeSession = { pointerId: event.pointerId, startY: event.clientY, startHeight: safeTrayHeight, nextHeight: safeTrayHeight, frameId: null };
|
||||
const updateHeight = (moveEvent: PointerEvent) => {
|
||||
if (moveEvent.pointerId !== session.pointerId) return;
|
||||
session.nextHeight = clampTrayHeight(session.startHeight + session.startY - moveEvent.clientY);
|
||||
paintResize();
|
||||
};
|
||||
const finish = (endEvent: PointerEvent) => {
|
||||
if (endEvent.pointerId === session.pointerId) stopResize();
|
||||
};
|
||||
resizeRef.current = session;
|
||||
resizeHandleRef.current = { element: event.currentTarget, pointerId: event.pointerId };
|
||||
panelRef.current?.classList.add("is-resizing");
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
window.addEventListener("pointermove", updateHeight);
|
||||
window.addEventListener("pointerup", finish);
|
||||
window.addEventListener("pointercancel", finish);
|
||||
resizeCleanupRef.current = () => {
|
||||
window.removeEventListener("pointermove", updateHeight);
|
||||
window.removeEventListener("pointerup", finish);
|
||||
window.removeEventListener("pointercancel", finish);
|
||||
};
|
||||
}, [paintResize, safeTrayHeight, stopResize]);
|
||||
|
||||
useEffect(() => () => {
|
||||
resizeCleanupRef.current?.();
|
||||
const session = resizeRef.current;
|
||||
if (session && session.frameId !== null) window.cancelAnimationFrame(session.frameId);
|
||||
resizeRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncHeight = () => setTrayHeight((height) => clampTrayHeight(height));
|
||||
@@ -134,11 +186,12 @@ export function CanvasAssetTray({ assetImages, canvasImages, showLibrary = true,
|
||||
animate={{ opacity: 1, y: 0, scale: 1, rotateX: 0 }}
|
||||
exit={{ opacity: 0, y: 14, scale: 0.95, rotateX: 3 }}
|
||||
transition={aceternityMotion.spring.panel}
|
||||
className="aceternity-floating-panel absolute bottom-[var(--canvas-dock-popover-offset)] left-0 flex w-[min(88vw,312px)] origin-bottom-left flex-col overflow-hidden rounded-[var(--r-2xl)] border p-2.5 backdrop-blur-2xl"
|
||||
ref={panelRef}
|
||||
className="canvas-asset-tray-panel aceternity-floating-panel absolute bottom-[var(--canvas-dock-popover-offset)] left-0 flex w-[min(88vw,312px)] origin-bottom-left flex-col overflow-hidden rounded-[var(--r-2xl)] border p-2.5 backdrop-blur-2xl"
|
||||
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, height: safeTrayHeight, minHeight: Math.min(TRAY_MIN_HEIGHT, getMaxTrayHeight()), maxHeight: "calc(100vh - 6rem)", boxShadow: `0 32px 100px ${theme.spatial.shadow}` }}
|
||||
>
|
||||
<div className="absolute inset-x-10 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${theme.spatial.glowStrong}, transparent)` }} />
|
||||
<button type="button" className="absolute left-1/2 top-1 z-10 flex h-5 w-28 -translate-x-1/2 cursor-ns-resize items-center justify-center rounded-full opacity-35 transition-opacity hover:opacity-75" onPointerDown={startResize} aria-label="从顶部调整素材托盘高度" title="拖动调整高度">
|
||||
<button type="button" className="canvas-asset-tray-resize-handle absolute left-1/2 top-1 z-10 flex h-5 w-28 -translate-x-1/2 items-center justify-center rounded-full opacity-35 transition-opacity hover:opacity-75" onPointerDown={startResize} aria-label="从顶部调整素材托盘高度" title="拖动调整高度">
|
||||
<span className="h-1 w-12 rounded-full bg-current" />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -65,24 +65,41 @@ export const ConnectionPath = React.memo(function ConnectionPath({
|
||||
/>
|
||||
{showVisual ? <path
|
||||
d={pathD}
|
||||
stroke={emphasized ? theme.accent.primary : theme.node.muted}
|
||||
strokeWidth={emphasized ? 1.4 : 1}
|
||||
stroke={theme.node.muted}
|
||||
strokeWidth={emphasized ? 5 : 3.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeOpacity={emphasized ? 0.42 : 0.16}
|
||||
strokeOpacity={emphasized ? 0.18 : 0.16}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ pointerEvents: "none" }}
|
||||
/> : null}
|
||||
{showVisual ? <path
|
||||
d={pathD}
|
||||
stroke={emphasized ? theme.accent.primary : theme.node.muted}
|
||||
strokeWidth={emphasized ? 2.2 : 1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeOpacity={emphasized ? 0.9 : 0.72}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ pointerEvents: "none" }}
|
||||
/> : null}
|
||||
{showVisual ? <>
|
||||
<circle cx={startX} cy={startY} r={emphasized ? 3.5 : 2.5} fill={emphasized ? theme.accent.primary : theme.node.muted} fillOpacity={emphasized ? 0.9 : 0.72} vectorEffect="non-scaling-stroke" style={{ pointerEvents: "none" }} />
|
||||
<circle cx={endX} cy={endY} r={emphasized ? 3.5 : 2.5} fill={emphasized ? theme.accent.primary : theme.node.muted} fillOpacity={emphasized ? 0.9 : 0.72} vectorEffect="non-scaling-stroke" style={{ pointerEvents: "none" }} />
|
||||
</> : null}
|
||||
{showVisual && emphasized ? <path
|
||||
className="canvas-connection-flow"
|
||||
d={pathD}
|
||||
stroke={`url(#${gradientId})`}
|
||||
strokeWidth="1.8"
|
||||
strokeWidth="2.2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeOpacity="0.72"
|
||||
strokeOpacity="0.84"
|
||||
strokeDasharray="18 26"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ pointerEvents: "none" }}
|
||||
/> : null}
|
||||
</g>
|
||||
|
||||
@@ -99,10 +99,13 @@ function DrawingContent({ node, theme, drawingProjectId }: CanvasNodeContentProp
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="绘图预览" className="absolute inset-0 h-full w-full object-cover" draggable={false} />
|
||||
) : (
|
||||
<div className="absolute inset-0 grid place-items-center" style={{ backgroundImage: `radial-gradient(circle, ${theme.node.stroke} 1px, transparent 1px)`, backgroundSize: "18px 18px" }}>
|
||||
<span className="grid size-12 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.panel, color: theme.node.muted }}>
|
||||
<Pencil className="size-5" />
|
||||
</span>
|
||||
<div className="absolute inset-0 grid place-items-center" style={{ background: theme.node.panel, backgroundImage: `radial-gradient(circle, ${theme.node.stroke} 1px, transparent 1px)`, backgroundSize: "18px 18px" }}>
|
||||
<div className="flex flex-col items-center gap-2 rounded-[var(--r-lg)] px-4 py-3" style={{ border: `1px solid ${theme.node.edge}`, background: theme.node.fill, color: theme.node.muted }}>
|
||||
<span className="grid size-10 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.panel, border: `1px solid ${theme.node.edge}`, color: theme.node.text }}>
|
||||
<Pencil className="size-5" />
|
||||
</span>
|
||||
<span className="text-[var(--fs-tiny)] font-medium">打开绘图</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-3 px-4 pb-3 pt-12" style={{ background: `linear-gradient(to top, ${theme.node.fill}, ${theme.node.fill}e6 55%, transparent)` }}>
|
||||
|
||||
@@ -104,6 +104,20 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
const activeReferenceCount = activeReferences.length;
|
||||
const videoFrameOptions = mentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl }));
|
||||
const monochromeAccent = theme.node.activeStroke;
|
||||
const composerTokens = {
|
||||
"--canvas-composer-surface": theme.node.panel,
|
||||
"--canvas-composer-control-surface": theme.toolbar.itemHover,
|
||||
"--canvas-composer-control-hover": theme.toolbar.activeBg,
|
||||
"--canvas-composer-shadow": theme.node.shadow,
|
||||
"--cn-text": theme.node.text,
|
||||
} as CSSProperties;
|
||||
const composerSurfaceStyle = {
|
||||
...composerTokens,
|
||||
border: `1px solid ${theme.node.edge}`,
|
||||
background: theme.node.panel,
|
||||
color: theme.node.text,
|
||||
boxShadow: theme.node.shadow,
|
||||
} as CSSProperties;
|
||||
const controlSurface = "var(--canvas-composer-control-surface)";
|
||||
const promptBounds = promptEditorBounds(false, activeReferenceCount > 0);
|
||||
const expandedPromptBounds = promptEditorBounds(true, activeReferenceCount > 0);
|
||||
@@ -187,7 +201,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
<Tooltip title="放大编辑">
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-6 shrink-0 place-items-center rounded-md transition hover:bg-white/[.06] hover:brightness-125 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0"
|
||||
className="canvas-node-composer-icon-button grid size-6 shrink-0 place-items-center rounded-md transition-[background-color,filter] hover:brightness-125 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 motion-reduce:hover:translate-y-0"
|
||||
style={{ background: controlSurface, color: theme.node.text, outlineColor: monochromeAccent }}
|
||||
onClick={() => setExpandedPromptOpen(true)}
|
||||
aria-label="放大编辑提示词"
|
||||
@@ -333,6 +347,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
return (
|
||||
<div
|
||||
className="canvas-node-composer"
|
||||
style={composerSurfaceStyle}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
@@ -346,7 +361,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
<div className="canvas-node-composer-parameters overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 px-2 py-1 text-[var(--fs-micro)] font-medium transition-colors hover:bg-white/[.04]"
|
||||
className="canvas-node-composer-parameters-toggle flex w-full items-center gap-1.5 rounded-[var(--r-md)] px-2 py-1 text-[var(--fs-micro)] font-medium transition-colors"
|
||||
style={{ color: theme.node.muted }}
|
||||
onClick={() => setParamsExpanded(!paramsExpanded)}
|
||||
aria-expanded={paramsExpanded}
|
||||
@@ -379,11 +394,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
setExpandedPromptOpen(false);
|
||||
}}
|
||||
styles={{
|
||||
container: { border: 0, borderRadius: "var(--canvas-composer-radius)", padding: 0, overflow: "hidden", background: "var(--canvas-composer-surface)", boxShadow: "var(--canvas-composer-shadow)" },
|
||||
container: { border: `1px solid ${theme.node.edge}`, borderRadius: "var(--canvas-composer-radius)", padding: 0, overflow: "hidden", background: theme.node.panel, boxShadow: theme.node.shadow },
|
||||
body: { minHeight: 0, padding: 0 },
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-0 flex-col gap-2.5 p-3" style={{ color: theme.node.text }}>
|
||||
<div className="flex min-h-0 flex-col gap-2.5 p-3" style={{ ...composerTokens, color: theme.node.text }}>
|
||||
<div className="shrink-0 pr-8">{renderComposerHeader(true)}</div>
|
||||
{renderPromptEditor(true)}
|
||||
{mode === "video" && !simpleMode ? (
|
||||
|
||||
@@ -291,7 +291,9 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
data-node-state={nodeState}
|
||||
data-state={data.metadata?.status || (isActive ? "active" : isRelated ? "related" : "idle")}
|
||||
style={{
|
||||
background: hasImageContent || hasVideoContent ? "transparent" : "var(--card-surface)",
|
||||
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||
border: `1px solid ${theme.node.edge}`,
|
||||
boxShadow: isSelected ? `0 0 0 2px ${theme.node.activeStroke}, ${theme.node.hoverShadow}` : hovered ? theme.node.hoverShadow : theme.node.shadow,
|
||||
}}
|
||||
onMouseDown={(event) => onMouseDown(event, data.id)}
|
||||
onDoubleClick={(event) => {
|
||||
@@ -329,7 +331,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script ? "overflow-visible" : "overflow-hidden"}`}
|
||||
style={
|
||||
{
|
||||
background: hasImageContent || hasVideoContent ? "transparent" : "var(--card-surface)",
|
||||
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
|
||||
"--batch-from-x": `${batchMotion?.x || 0}px`,
|
||||
"--batch-from-y": `${batchMotion?.y || 0}px`,
|
||||
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
|
||||
@@ -632,7 +634,17 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
|
||||
return (
|
||||
<div
|
||||
className="canvas-node-external-header absolute bottom-full left-0 z-[var(--node-z-overlay)] flex h-6 items-center gap-1 overflow-hidden"
|
||||
style={{ maxWidth: maxHeaderWidth, color: active ? theme.node.text : theme.node.label, transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`, transformOrigin: "left bottom" }}
|
||||
style={{
|
||||
maxWidth: maxHeaderWidth,
|
||||
border: `1px solid ${theme.node.edge}`,
|
||||
borderRadius: "var(--r-sm)",
|
||||
background: theme.node.panel,
|
||||
paddingInline: "var(--space-1-half)",
|
||||
color: active ? theme.node.text : theme.node.label,
|
||||
boxShadow: active ? theme.node.shadow : undefined,
|
||||
transform: `scale(var(--canvas-live-inverse-scale, ${inverseScale}))`,
|
||||
transformOrigin: "left bottom",
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
|
||||
import type { InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
|
||||
import { externalAssetToInsertPayload, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
|
||||
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
|
||||
@@ -13,6 +14,7 @@ type ProjectPickerItem = { id: string; category: string; folderId?: string; proj
|
||||
|
||||
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", initialFolderId = "all", onClose, onInsert, onInsertFolder }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; initialFolderId?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void; onInsertFolder?: (folderId: string) => Promise<void> | void }) {
|
||||
const mediaAssets = useAssetStore((state) => state.assets);
|
||||
const externalAssetSources = useExternalAssetSources(open);
|
||||
const items = useMemo<ProjectPickerItem[]>(() => {
|
||||
const mediaById = new Map(mediaAssets.map((asset) => [asset.id, asset]));
|
||||
const projectItems = (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => {
|
||||
@@ -26,7 +28,7 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
|
||||
.filter((asset) => asset.kind !== "model" && asset.kind !== "entity")
|
||||
.map((media): ProjectPickerItem => ({ id: media.id, category: media.category || "other", media }));
|
||||
}, [detail?.assets, mediaAssets]);
|
||||
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
|
||||
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
|
||||
const character = item.character;
|
||||
const project = item.project;
|
||||
const media = item.media;
|
||||
@@ -50,24 +52,30 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
|
||||
searchText: [media?.tags.join(" ") || "", project?.previewText || ""].join(" "),
|
||||
};
|
||||
}), [items]);
|
||||
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => [...localPickerItems, ...externalAssetSources.items], [externalAssetSources.items, localPickerItems]);
|
||||
|
||||
return (
|
||||
<AssetLibraryPickerModal
|
||||
open={open}
|
||||
items={pickerItems}
|
||||
categoryLabels={categoryLabels}
|
||||
categoryLabels={{ ...categoryLabels, ...externalAssetSources.categoryLabels }}
|
||||
initialCategory={initialCategory}
|
||||
initialFolderId={initialFolderId}
|
||||
folders={detail?.assetFolders || []}
|
||||
folders={externalAssetSources.folders}
|
||||
folderActionSource="local"
|
||||
title="项目资产"
|
||||
confirmLabel={(count) => `引入已选资产${count ? `(${count})` : ""}`}
|
||||
emptyTitle="此分类没有可引用资产"
|
||||
emptyDescription={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,可先在素材库添加内容。"}
|
||||
footerNote="角色引用会在生成时解析当前角色版本"
|
||||
footerNote={externalAssetSources.error || "角色引用会在生成时解析当前角色版本"}
|
||||
onFolderAction={onInsertFolder ? async (folderId) => { await onInsertFolder(folderId); onClose(); } : undefined}
|
||||
onClose={onClose}
|
||||
onConfirm={async (ids) => {
|
||||
const payloads = await Promise.all(items.filter((item) => ids.includes(item.id)).map(async (item) => {
|
||||
const payloads = await Promise.all(ids.map(async (id) => {
|
||||
const external = externalAssetSources.items.find((item) => item.id === id)?.external;
|
||||
if (external) return externalAssetToInsertPayload(external);
|
||||
const item = items.find((candidate) => candidate.id === id);
|
||||
if (!item) throw new Error("所选资产已不存在,请重新选择");
|
||||
if (item.media || item.character || !item.project) return toInsertPayload(item);
|
||||
const { asset } = await getRemoteAsset(item.project.id);
|
||||
return toInsertPayload({ ...item, media: asset });
|
||||
|
||||
Reference in New Issue
Block a user