From d7b3321df16a3237d121cdaf34eca11f9a9d9f0b Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Mon, 9 Nov 2026 16:49:06 +0800 Subject: [PATCH] Add a permission-checked cross-project image reuse picker --- .../canvas/headless-graph-canvas.tsx | 7 +- .../canvas/headless-resource-picker.tsx | 64 +++++++++++++++++++ src/lib/api.ts | 2 + tests/headless/renderer.integration.mjs | 17 +++-- 4 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 src/components/canvas/headless-resource-picker.tsx diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index c5340bc..a511bd1 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -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 engine.sessions.tool(viewId, 'select')} /> engine.sessions.tool(viewId, 'pan')} /> {if(selected&&selectedResource)report(engine.fitImage({id:selected.id,incarnation:selected.incarnation},selectedResource.id));}} /> setShowAI(value => !value)} /> - add('plain-text')} /> add('text')} /> imageInput.current?.click()} /> add('frame')} /> + add('plain-text')} /> add('text')} /> imageInput.current?.click()} />{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})});}} /> add('frame')} /> { 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)&&} + {resourcePicker&&resourcePicker.canvasId===view.canvasId&&setResourcePicker(null)} onAdded={id=>engine.sessions.select(viewId,[id])} />} {showAI && aiElement && setShowAI(false)} />} {showRecovery && setShowRecovery(false)} />} {error &&
{error} {displayState.engine===engine&&displayState.viewId===viewId&&displayState.failures>0&&}
} diff --git a/src/components/canvas/headless-resource-picker.tsx b/src/components/canvas/headless-resource-picker.tsx new file mode 100644 index 0000000..39e7bce --- /dev/null +++ b/src/components/canvas/headless-resource-picker.tsx @@ -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?: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([]),[loading,setLoading]=useState(true),[busy,setBusy]=useState(),[error,setError]=useState(""); + const lifecycle=useRef(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(),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 {if(!open)dismiss();}}> + 从其它画布添加图片选择同一工作区已打开画布中的图片,无需再次上传。 + {loading?

正在查找图片…

:items.length===0?

暂无可复用的图片。先打开有已上传图片的画布,再回到这里。

:
{items.map(item=>)}
} + {error&&

{error}

} +
; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index a124626..dca8ffa 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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: "图片上传结果无效,画布内容未替换,请重试。", diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 713c155..d8cf93f 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -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);