89 lines
8.8 KiB
TypeScript
89 lines
8.8 KiB
TypeScript
/**
|
||
* 从同工作区已打开的其它项目中发现可复用图片,授权接纳资源后在目标画布创建新图片元素。
|
||
* 面板不重新上传原文件;资源接纳与元素创建的事务、持久化和授权由engine.adoptResource负责。
|
||
*/
|
||
"use client";
|
||
|
||
import { useEffect, useId, useRef, useState } from "react";
|
||
import { ImageIcon } from "lucide-react";
|
||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
import { useCanvasRuntimeHost } from "@/components/workspace/canvas-runtime-host";
|
||
import type { ProjectEngine } from "@/lib/canvas-runtime/workspace";
|
||
import type { Id, Point } from "@/lib/canvas-core/types";
|
||
import { fault } from "@/lib/canvas-core/values";
|
||
import { errorText } from "@/lib/api";
|
||
|
||
/** 懒加载候选缩略图,失败后以通用图标代替;failed是该预览组件的局部显示状态,不修改资源记录。 */
|
||
// failed 不随 src 变化自动重置;宿主以资源 ID 作 key 隔离不同候选的预览实例。
|
||
function Preview({src}:{src:string}){const [failed,setFailed]=useState(false);return failed?<ImageIcon aria-hidden className="h-8 w-8 opacity-50"/>:<img src={src} alt="" loading="lazy" className="h-full w-full object-contain" onError={()=>setFailed(true)}/>;}
|
||
/** 列表只保存资源ID和展示标题;插入时重新由engine取得资源元数据,不能信任缩略图即代表可访问。 */
|
||
type Candidate = { id: Id; title: string };
|
||
/** position是打开面板时捕获的画布坐标,插入图片以此为中心;onAdded让调用方选择新元素。 */
|
||
export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,close,onAdded,viewId}:{engine:ProjectEngine;workspaceId:Id;canvasId:Id;position:Point;close():void;onAdded(id:Id):void;viewId:string}) {
|
||
const host=useCanvasRuntimeHost(),[items,setItems]=useState<Candidate[]>([]),[loading,setLoading]=useState(true),[busy,setBusy]=useState<Id>(),[error,setError]=useState("");
|
||
// controller取消本次面板操作,requesting同步防重复点击,busy用于禁用按钮和展示当前候选进度。
|
||
const lifecycle=useRef<AbortController|null>(null),requesting=useRef(false),descriptionId=useId();
|
||
/** 关闭前发取消信号;已经提交的事务不会由这个UI回调自动撤销。 */
|
||
const dismiss=()=>{lifecycle.current?.abort();close();};
|
||
// 初次挂载捕获目标canvas及父入口记录,异步接纳资源后仍须核对入口身份与生命周期。
|
||
// target 只在首次挂载捕获;canvasId/engine 更换时不能指望它自动变成新目标,宿主应重建面板。
|
||
const [target]=useState(()=>{const document=engine.getSnapshot().document,canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;return {canvas,entry};});
|
||
// 记录当前账户代次与工作区,收集已打开/正在打开的其它项目;不扫描服务端全部画布。
|
||
useEffect(()=>{
|
||
const controller=new AbortController();lifecycle.current=controller;
|
||
const identity=host.identity(),workspace=host.getWorkspaces().get(workspaceId);
|
||
void (async()=>{
|
||
if(!workspace?.isCurrentIdentity()||workspace.accountId!==identity.accountId)throw fault('identity_changed');
|
||
// 单个项目打开失败不阻断其它候选,allSettled保留每项结果;排除本项目避免重复从自身取资源。
|
||
const results=await Promise.allSettled([...workspace.getProjects()].filter(([id])=>id!==engine.getSnapshot().document.projectId).map(([,project])=>project));
|
||
if(controller.signal.aborted)return;
|
||
if(!workspace.isCurrentIdentity()||host.identity().authEpoch!==identity.authEpoch)throw fault('identity_changed');
|
||
// 按资源ID去重,排除本项目已有资源;其它项目若有本地未stored资源也跳过,避免复用未可靠保存的文件。
|
||
const candidates=new Map<Id,Candidate>(),owned=engine.getSnapshot().runtime.resources;
|
||
for(const result of results)if(result.status==='fulfilled'){
|
||
const snapshot=result.value.getSnapshot();
|
||
for(const element of snapshot.document.elements.values()){
|
||
const id=element.data.assetId;
|
||
if(element.structure!=='node'||typeof id!=='string'||!id||owned.has(id)||candidates.has(id)||snapshot.runtime.resources.has(id)&&!snapshot.runtime.resources.get(id)!.stored)continue;
|
||
// 只从明确声明assetId资源字段的组件提取,不能把任意同名业务字段当图片资源。
|
||
if(!result.value.editor.registry.components.get(element.componentId)?.resourceFields?.includes('assetId'))continue;
|
||
candidates.set(id,{id,title:typeof element.data.title==='string'&&element.data.title.trim()?element.data.title:'未命名图片'});
|
||
}
|
||
}
|
||
// 候选只在此 effect 扫描一次,不订阅其它项目后续新增/移除的资源;真正插入仍需重新授权校验。
|
||
setItems([...candidates.values()]);setLoading(false);
|
||
})().catch(error=>{if(!controller.signal.aborted){setLoading(false);setError(errorText(error));}});
|
||
return()=>{controller.abort();if(lifecycle.current===controller)lifecycle.current=null;};
|
||
},[engine,host,workspaceId]);
|
||
/** 一次只接纳一个候选。生成新元素ID,在adoptResource提供的事务内校验目标并创建图片。
|
||
* 失败保持面板以便重试;关闭或卸载后不再更新错误/选中状态,取消的实际边界由engine处理。 */
|
||
async function insert(item:Candidate){
|
||
const signal=lifecycle.current?.signal;if(!signal||signal.aborted||requesting.current)return;
|
||
requesting.current=true;
|
||
setBusy(item.id);setError("");
|
||
try{
|
||
const id=crypto.randomUUID(),result=await engine.adoptResource(item.id,(resource,tx)=>{
|
||
const document=tx.getDocument(),canvas=document.canvases.get(canvasId),entry=canvas?.parent?document.elements.get(canvas.parent.entryElementId):undefined;
|
||
// 父入口ID变化或原入口incarnation变化都拒绝,避免资源授权等待期间目标已经重建。
|
||
if(!canvas||canvas.parent?.entryElementId!==target.canvas?.parent?.entryElementId||target.entry&&(entry?.id!==target.entry.id||entry.incarnation!==target.entry.incarnation))throw fault('asset_target_changed');
|
||
// 按原始宽高等比装入260×200边界,再以position为中心计算左上角;小图也可能被放大。
|
||
const scale=Math.min(260/resource.asset.width,200/resource.asset.height),width=resource.asset.width*scale,height=resource.asset.height*scale;
|
||
tx.dispatch({type:'element.create',element:{id,incarnation:1,structure:'node',componentId:'image',dataVersion:1,ownerCanvasId:canvasId,geometry:{x:position.x-width/2,y:position.y-height/2,width,height,rotation:0},data:{title:item.title,text:'',assetId:resource.id,src:resource.src}}});
|
||
},{signal});
|
||
// engine拒绝与异常统一进入错误展示;仅在面板仍活跃时通知onAdded并关闭。
|
||
if(result.status==='rejected')throw fault(result.code,result.targets);
|
||
// 这两个宿主回调仍在 try 内;它们抛错会显示失败,但已经提交的创建事务不会回滚。
|
||
if(!signal.aborted){onAdded(id);close();}
|
||
}catch(error){if(!signal.aborted)setError(error instanceof Error&&error.message==='not_found'?'无法读取这张图片,请确认仍有原画布的访问权限。':errorText(error));}
|
||
finally{requesting.current=false;if(!signal.aborted)setBusy(undefined);}
|
||
}
|
||
// Dialog关闭入口都走dismiss;缩略图使用工作区资源预览接口,标题截断仅影响显示。
|
||
return <Dialog open onOpenChange={open=>{if(!open)dismiss();}}><DialogContent data-canvas-ui-view={viewId} aria-describedby={descriptionId}>
|
||
<DialogHeader><DialogTitle>从其它画布添加图片</DialogTitle><DialogDescription id={descriptionId}>选择同一工作区已打开画布中的图片,无需再次上传。</DialogDescription></DialogHeader>
|
||
{loading?<p role="status">正在查找图片…</p>:items.length===0?<p>暂无可复用的图片。先打开有已上传图片的画布,再回到这里。</p>:<div className="grid max-h-80 grid-cols-2 gap-3 overflow-y-auto">{items.map(item=><button key={item.id} type="button" aria-label={`添加图片:${item.title}`} disabled={!!busy} onClick={()=>void insert(item)} className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border p-3 text-sm hover:bg-muted disabled:opacity-50">
|
||
<span className="flex h-20 w-full items-center justify-center overflow-hidden rounded bg-muted"><Preview src={`/api/foundry/workspaces/${encodeURIComponent(workspaceId)}/assets/${encodeURIComponent(item.id)}?variant=preview`}/></span><span className="w-full truncate">{item.title}</span>{busy===item.id&&<span role="status">正在添加…</span>}
|
||
</button>)}</div>}
|
||
{error&&<p role="alert">{error}</p>}
|
||
</DialogContent></Dialog>;
|
||
}
|