Add a permission-checked cross-project image reuse picker
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ContextType } from "react";
|
||||
import { Background, BackgroundVariant, ConnectionMode, ReactFlow, ReactFlowProvider, useReactFlow, type Connection,type Viewport } from "@xyflow/react";
|
||||
import { Copy, Download, Frame, Hand, ImagePlus, Maximize, Minus, Ratio, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
|
||||
import { Copy, Download, Frame, Hand, ImagePlus, Images, Maximize, Minus, Ratio, MousePointer2, Plus, Redo2, Sparkles, StickyNote, Type, Undo2 } from "lucide-react";
|
||||
import type { Board } from "@/lib/workspace";
|
||||
import type { ReactRenderers } from "@/lib/canvas-bindings/react-flow";
|
||||
import { CanvasRendererCommandsContext, type CanvasRendererCommands } from "@/lib/canvas-bindings/react-flow";
|
||||
@@ -25,6 +25,7 @@ import { guardEditingLease } from "./editing-lease";
|
||||
import { NodeEditing } from "./transformable-node";
|
||||
import { EdgeEditing } from "./edge-editing";
|
||||
import { HeadlessAIPanel } from "./headless-ai-panel";
|
||||
import { HeadlessResourcePicker } from "./headless-resource-picker";
|
||||
import { HeadlessImports } from "./headless-imports";
|
||||
import { HeadlessRecoveryDialog } from "./headless-recovery-dialog";
|
||||
import { UnsupportedNode, UnsupportedEdge } from "./unsupported-elements";
|
||||
@@ -47,6 +48,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
const [editorFocused, setEditorFocused] = useState(false), focusEpoch = useRef(0);
|
||||
useEffect(() => { focusEpoch.current++; setEditorFocused(false); return () => { focusEpoch.current++; }; }, [engine,viewId]);
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [resourcePicker,setResourcePicker]=useState<{canvasId:Id;position:{x:number;y:number}}|null>(null);
|
||||
const copyAvailability=useMemo(()=>engine.imports.canDuplicate([...view.selection].map(id=>root.document.elements.get(id)).filter((element):element is ElementRecord=>!!element).map(({id,incarnation})=>({id,incarnation})),{viewCanvasId:view.canvasId}),[engine,root,view.selection,view.canvasId]);
|
||||
const [showAI, setShowAI] = useState(false), [showRecovery, setShowRecovery] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -259,10 +261,11 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi
|
||||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={view.tool === 'select'} onClick={() => engine.sessions.tool(viewId, 'select')} /><IconButton icon={Hand} label="抓手工具(H)" aria-pressed={view.tool === 'pan'} onClick={() => engine.sessions.tool(viewId, 'pan')} />
|
||||
<IconButton icon={Ratio} label="按图片比例调整" disabled={!!loading||!canFitImage} onClick={()=>{if(selected&&selectedResource)report(engine.fitImage({id:selected.id,incarnation:selected.incarnation},selectedResource.id));}} />
|
||||
<IconButton icon={Sparkles} label="AI 创作" disabled={!!loading||!aiElement} aria-pressed={showAI} onClick={() => setShowAI(value => !value)} />
|
||||
<IconButton icon={Type} label="添加纯文本" disabled={!canCreate} onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" disabled={!canCreate} onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={() => imageInput.current?.click()} /><IconButton icon={Frame} label="添加框选区" disabled={!canCreate} onClick={() => add('frame')} />
|
||||
<IconButton icon={Type} label="添加纯文本" disabled={!canCreate} onClick={() => add('plain-text')} /><IconButton icon={StickyNote} label="添加文本卡片" disabled={!canCreate} onClick={() => add('text')} /><IconButton icon={ImagePlus} label="添加图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={() => imageInput.current?.click()} /><IconButton icon={Images} label="复用其它画布图片" disabled={!engine.canStageFiles(view.canvasId).enabled} onClick={()=>{const rect=window.document.querySelector('[data-headless-canvas]')?.getBoundingClientRect();setResourcePicker({canvasId:view.canvasId,position:flow.screenToFlowPosition({x:(rect?.left??0)+(rect?.width??window.innerWidth)/2,y:(rect?.top??0)+(rect?.height??window.innerHeight)/2})});}} /><IconButton icon={Frame} label="添加框选区" disabled={!canCreate} onClick={() => add('frame')} />
|
||||
</div>
|
||||
<input ref={imageInput} type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden onChange={event => { const files = [...event.target.files ?? []]; event.target.value = ''; try { report(engine.stageFiles(files.map(file => ({ name: file.name, bytes: file, defaultSize: true })), (refs, tx) => { for (const ref of refs) add('image', ref, tx); })); } catch (e) { setError(errorText(e)); } }} />
|
||||
{(!loading||loading.version>=5)&&<HeadlessImports engine={engine} canvasId={view.canvasId} />}
|
||||
{resourcePicker&&resourcePicker.canvasId===view.canvasId&&<HeadlessResourcePicker engine={engine} workspaceId={board.workspace_id} canvasId={resourcePicker.canvasId} position={resourcePicker.position} close={()=>setResourcePicker(null)} onAdded={id=>engine.sessions.select(viewId,[id])} />}
|
||||
{showAI && aiElement && <HeadlessAIPanel key={`${aiElement.id}:${aiElement.incarnation}:${aiElement.ownerCanvasId}`} engine={engine} element={aiElement} close={() => setShowAI(false)} />}
|
||||
{showRecovery && <HeadlessRecoveryDialog engine={engine} close={() => setShowRecovery(false)} />}
|
||||
{error && <div className={styles.error} role="alert">{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&<button onClick={()=>{setError('');resourceView.current?.retry();}}>重试图片</button>}<button onClick={() => setError('')}>关闭</button></div>}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"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>;
|
||||
}
|
||||
@@ -100,6 +100,8 @@ export const errorText = (error: unknown) => {
|
||||
invalid_canvas: "画布数据未通过服务端校验,请导出备份后检查节点内容。",
|
||||
response_interrupted: "画布数据传输中断或超时,请重新加载;当前内容未被覆盖。",
|
||||
invalid_api_response: "接口返回了不完整或无法解析的数据,请重新加载。",
|
||||
asset_target_changed: "目标分区已变化,请重新选择添加位置。",
|
||||
asset_authorization_failed: "暂时无法读取这张图片,请确认仍有原画布的访问权限。",
|
||||
invalid_asset: "图片格式无法识别,请选择有效的 PNG、JPEG、WebP 或 GIF。",
|
||||
asset_too_large: "图片超出上传限制,请选择小于 20 MiB 的文件。",
|
||||
invalid_asset_response: "图片上传结果无效,画布内容未替换,请重试。",
|
||||
|
||||
@@ -12,7 +12,7 @@ const performanceMode=process.argv.includes('--performance'),performanceCount=Nu
|
||||
if(performanceMode&&(!Number.isSafeInteger(performanceCount)||performanceCount<1||performanceCount>10000))throw Error('invalid performance count');
|
||||
const source = resolve(import.meta.dirname, '../..'), directory = await mkdtemp(join(tmpdir(), 'endoleap-renderer-')), profile = await mkdtemp(join(tmpdir(), 'endoleap-renderer-browser-'));
|
||||
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||
const account = id(1), workspace = id(2), root = id(3), other = id(4), partition = id(5), calls = [], uploads = [], documents = new Map(), versions = new Map([[root, 1], [other, 1]]), assets = new Map(), aiRuns = new Map();
|
||||
const account = id(1), workspace = id(2), root = id(3), other = id(4), partition = id(5), calls = [], uploads = [], documents = new Map(), versions = new Map([[root, 1], [other, 1]]), assets = new Map(), assetBytes=new Map(), aiRuns = new Map();
|
||||
const node = (id, ownerCanvasId, x) => ({ id, ownerCanvasId, incarnation: 1, componentId: 'text', dataVersion: 1, structure: 'node', data: { title: 'Card ' + x, text: 'Editable content' }, geometry: { x, y: 150, width: 260, height: 200, rotation: 0 } });
|
||||
documents.set(root, createDocument(root, [{ id: root, parent: null }], [node('a', root, 80), node('b', root, 450), { id: 'edge', incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: root, source: { elementId: 'a' }, target: { elementId: 'b' }, data: {}, path: { type: 'bezier-chain', knots: [{ id: 'k', mode: 'corner', position: { x: 400, y: 250 }, in: { x: -20, y: 0 }, out: { x: 20, y: 0 } }] } }]));
|
||||
documents.set(other, createDocument(other, [{ id: other, parent: null }], []));
|
||||
@@ -29,7 +29,7 @@ let releaseImport;const importGate=new Promise(resolve=>{releaseImport=resolve;}
|
||||
let releaseApply;const applyGate=new Promise(resolve=>{releaseApply=resolve;});
|
||||
let releaseUpload; const uploadGate = new Promise(resolve => { releaseUpload = resolve; });
|
||||
const model = (document, complete = true, participants=[...document.canvases.keys()]) => ({ documentFormatVersion: 1, projectId: document.projectId, complete, canvases: [...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)), elements: [...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)), tombstones: Object.fromEntries(document.tombstones), versions: Object.fromEntries(participants.map(id => [id, versions.get(id)])), removedCanvasIds: [] });
|
||||
let rejectNextEdit=false,rejectedEdit;
|
||||
let rejectNextEdit=false,rejectedEdit,denyAssetMetadata=false,metadataWait,releaseMetadata;
|
||||
const upstream = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://fixture'), path = url.pathname, chunks = [];
|
||||
@@ -89,10 +89,12 @@ const upstream = createServer(async (request, response) => {
|
||||
if(aiPoll){const run=aiRuns.get(aiPoll[1]),after=Number(url.searchParams.get('after_seq')||0),events=run.events.filter(event=>event.seq>after);return reply(200,{run_id:aiPoll[1],status:run.status,plan:run.plan,steps:[],events,next_seq:events.at(-1)?.seq??after,has_more:false,outputs:[]});}
|
||||
if (path.endsWith('/assets') && request.method === 'POST') {
|
||||
const upload = new Request('http://fixture', { method: 'POST', body: bytes, headers: { 'Content-Type': request.headers['content-type'] } }), form = await upload.formData(), file = form.get('image');
|
||||
const asset = { id: request.headers['x-resource-id'], bytes: file.size, mime_type: 'image/png', width: 1, height: 1, url: `https://api.foundry.entroia.com/projects/endoleap/workspaces/${workspace}/assets/${request.headers['x-resource-id']}` };
|
||||
uploads.push(asset); assets.set(asset.id, asset); await uploadGate;
|
||||
const asset = { id: request.headers['x-resource-id'], bytes: file.size, mime_type: 'image/png', width: 4, height: 2, url: `https://api.foundry.entroia.com/projects/endoleap/workspaces/${workspace}/assets/${request.headers['x-resource-id']}` };
|
||||
uploads.push(asset); assets.set(asset.id, asset);assetBytes.set(asset.id,Buffer.from(await file.arrayBuffer())); await uploadGate;
|
||||
return reply(201, { protocol_version: 2, request_id: request.headers['idempotency-key'], status: 'applied', asset });
|
||||
}
|
||||
const assetMetadata=path.match(/\/assets\/metadata\/([^/]+)$/);
|
||||
if(assetMetadata){if(metadataWait)await metadataWait;if(denyAssetMetadata||!assets.has(assetMetadata[1]))return reply(404,{error:'not_found'});const original=assetBytes.get(assetMetadata[1]),sha256=[...new Uint8Array(await crypto.subtle.digest('SHA-256',original))].map(n=>n.toString(16).padStart(2,'0')).join('');return reply(200,{metadata_version:1,asset:assets.get(assetMetadata[1]),sha256});}
|
||||
if (/\/assets\/[^/]+$/.test(path)) { response.writeHead(200, { 'Content-Type': 'image/png' }); return response.end(png); }
|
||||
reply(404, { error: 'not_found' });
|
||||
} catch (error) { response.writeHead(500, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ error: error.message })); }
|
||||
@@ -294,6 +296,13 @@ try {
|
||||
for (let i = 0; i < 200 && documents.get(root).elements.size !== 6; i++) await pause(20);
|
||||
assert.equal(documents.get(root).elements.size, 6, 'view detach aborted the upload or document operation');
|
||||
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node").length'), 0, 'late result appeared on the wrong board');
|
||||
await click('复用其它画布图片');await until(`!!document.querySelector('[role="dialog"] [aria-label="添加图片:视觉参考"]')`,'other opened project image did not appear in the picker');
|
||||
const pickerScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-resource-picker.png',Buffer.from(pickerScreenshot.data,'base64'));
|
||||
denyAssetMetadata=true;await click('添加图片:视觉参考');await until(`document.querySelector('[role="dialog"] [role="alert"]')?.textContent.includes('访问权限')`,'fresh authorization rejection was not readable');assert.equal(documents.get(other).elements.size,0);assert.equal(await evaluate(`document.querySelector('[aria-label="撤销"]').disabled`),true);denyAssetMetadata=false;
|
||||
metadataWait=new Promise(resolve=>releaseMetadata=resolve);const metadataCalls=calls.filter(call=>call.path.includes('/assets/metadata/')).length;await click('添加图片:视觉参考');for(let i=0;i<100&&calls.filter(call=>call.path.includes('/assets/metadata/')).length===metadataCalls;i++)await pause(20);assert.ok(calls.filter(call=>call.path.includes('/assets/metadata/')).length>metadataCalls,'picker did not start authorization');await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await until(`!document.querySelector('[role="dialog"]')`,'Escape did not close the resource picker');releaseMetadata();metadataWait=undefined;await pause(100);assert.equal(documents.get(other).elements.size,0,'closed picker admitted a late authorization result');
|
||||
await click('复用其它画布图片');await until(`!!document.querySelector('[role="dialog"] [aria-label="添加图片:视觉参考"]')`,'resource picker did not reopen');await click('添加图片:视觉参考');await until(`!document.querySelector('[role="dialog"]')&&document.querySelectorAll('.react-flow__node').length===1`,'adopted image did not appear');for(let i=0;i<100&&documents.get(other).elements.size!==1;i++)await pause(20);const adopted=[...documents.get(other).elements.values()][0];assert.equal(adopted.data.assetId,uploads[0].id);assert.equal(uploads.length,1,'cross-project picker uploaded a duplicate');assert.equal(adopted.geometry.width/adopted.geometry.height,2);
|
||||
await click('撤销');for(let i=0;i<100&&documents.get(other).elements.size;i++)await pause(20);assert.equal(documents.get(other).elements.size,0);await click('重做');for(let i=0;i<100&&documents.get(other).elements.get(adopted.id)?.incarnation!==2;i++)await pause(20);assert.equal(documents.get(other).elements.get(adopted.id).incarnation,2);assert.equal(uploads.length,1);console.log('PASS cross-project resource picker reauthorizes, cancels a late result, creates one shared image and supports undo/redo without uploading again');
|
||||
|
||||
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`);
|
||||
await until('document.querySelectorAll(".react-flow__node").length === 5', 'returning project lost its engine');
|
||||
assert.equal(uploads.length, 1);
|
||||
|
||||
Reference in New Issue
Block a user