Files
Endoleap/src/components/canvas/headless-resource-picker.tsx
T

65 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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";
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)}/>;}
type Candidate = { id: Id; title: string };
export function HeadlessResourcePicker({engine,workspaceId,canvasId,position,close,onAdded}:{engine:ProjectEngine;workspaceId:Id;canvasId:Id;position:Point;close():void;onAdded(id:Id):void}) {
const host=useCanvasRuntimeHost(),[items,setItems]=useState<Candidate[]>([]),[loading,setLoading]=useState(true),[busy,setBusy]=useState<Id>(),[error,setError]=useState("");
const lifecycle=useRef<AbortController|null>(null),requesting=useRef(false),descriptionId=useId();
const dismiss=()=>{lifecycle.current?.abort();close();};
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');
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');
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;
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:'未命名图片'});
}
}
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]);
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;
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');
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});
if(result.status==='rejected')throw fault(result.code,result.targets);
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);}
}
return <Dialog open onOpenChange={open=>{if(!open)dismiss();}}><DialogContent 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>;
}