diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index d140d1e41..dc568b67e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -184,6 +184,7 @@ enum GenerationLedgerPhase { CandidateReady, AssetDurableCommitted, Failed, + Archiving, Archived, ReconciliationRequired, } @@ -201,6 +202,7 @@ impl GenerationLedgerPhase { Self::CandidateReady => "candidate-ready", Self::AssetDurableCommitted => "asset-durable-committed", Self::Failed => "failed", + Self::Archiving => "archiving", Self::Archived => "archived", Self::ReconciliationRequired => "reconciliation-required", } @@ -855,7 +857,7 @@ fn public_phase(phase: &GenerationLedgerPhase) -> Option Some(AssetCanvasGenerationStatus::Failed), - GenerationLedgerPhase::Archived => None, + GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived => None, GenerationLedgerPhase::ReconciliationRequired => { Some(AssetCanvasGenerationStatus::ReconciliationRequired) } @@ -979,11 +981,11 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<() } } match (&ledger.phase, ledger.archived_at) { - (GenerationLedgerPhase::Archived, Some(archived_at)) => { + (GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived, Some(archived_at)) => { validate_safe_revision(archived_at, "archivedAt")?; } - (GenerationLedgerPhase::Archived, None) => { - return Err("已归档的素材画布生成账本缺少 archivedAt".to_string()); + (GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived, None) => { + return Err("归档中的素材画布生成账本缺少 archivedAt".to_string()); } (_, Some(_)) => { return Err("未归档的素材画布生成账本不能包含 archivedAt".to_string()); @@ -2982,7 +2984,10 @@ async fn reconcile_generation( return committed_execution_from_private_result(root, &ledger); } migrate_retryable_credential_failure(root, &mut ledger)?; - if ledger.phase == GenerationLedgerPhase::Archived { + if matches!( + ledger.phase, + GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived + ) { return Err("素材画布失败生成已归档".to_string()); } if ledger.phase == GenerationLedgerPhase::Failed { @@ -3358,13 +3363,30 @@ async fn reconcile_generation( "candidate-reconciliation-required", )); } + let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?; + let persisted = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "候选写入后素材画布草稿不存在".to_string())?; + let candidate_layer_id = ledger + .candidate_layer_id + .as_deref() + .ok_or_else(|| "候选写入后生成账本缺少图层身份".to_string())?; + if !persisted.generations.iter().any(|record| { + record.generation_id == ledger.generation_id + && record.phase == AssetCanvasGenerationStatus::CandidateReady + }) || !persisted + .canvas + .layers + .iter() + .any(|layer| layer.layer_id == candidate_layer_id) + { + return Err("候选写入后回读校验失败".to_string()); + } set_private_phase( root, &mut ledger, GenerationLedgerPhase::CandidateReady, None, )?; - let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?; emit(generation_progress_event( &ledger, public_phase_name(&generation.phase), @@ -3456,8 +3478,16 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at( return Err("待归档的素材画布生成账本身份无效".to_string()); } + finish_generation_archive_at(root, &mut ledger, Some(input.expected_draft_revision)) +} + +fn finish_generation_archive_at( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + expected_draft_revision: Option, +) -> Result { let _draft_guard = acquire_asset_canvas_draft_lock(root)?; - let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? .ok_or_else(|| "待归档生成所属素材画布草稿不存在".to_string())?; if draft.project_id != ledger.project_id || draft.draft_id != ledger.draft_id @@ -3472,7 +3502,7 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at( .position(|record| record.generation_id == ledger.generation_id); if ledger.phase == GenerationLedgerPhase::Archived && generation_index.is_none() { return Ok(ArchiveAssetCanvasGenerationResult { - generation_id: ledger.generation_id, + generation_id: ledger.generation_id.clone(), phase: GenerationLedgerPhase::Archived.as_str().to_string(), archived_at: ledger .archived_at @@ -3480,25 +3510,36 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at( draft, }); } - if draft.revision != input.expected_draft_revision { + if generation_index.is_some() + && expected_draft_revision.is_some_and(|revision| draft.revision != revision) + { return Err("draft-revision-conflict".to_string()); } - let generation_index = - generation_index.ok_or_else(|| "待归档的失败生成不在当前素材画布草稿中".to_string())?; - let public_generation = &draft.generations[generation_index]; - if public_generation.intent_id != ledger.intent_id { - return Err("待归档的素材画布公开生成记录身份无效".to_string()); - } match ledger.phase { GenerationLedgerPhase::Failed => { + let generation_index = generation_index + .ok_or_else(|| "待归档的失败生成不在当前素材画布草稿中".to_string())?; + let public_generation = &draft.generations[generation_index]; + if public_generation.intent_id != ledger.intent_id { + return Err("待归档的素材画布公开生成记录身份无效".to_string()); + } if public_generation.phase != AssetCanvasGenerationStatus::Failed { return Err("只有公开状态明确失败的素材画布生成可以归档".to_string()); } - ledger.phase = GenerationLedgerPhase::Archived; + ledger.phase = GenerationLedgerPhase::Archiving; ledger.archived_at = Some(asset_canvas_now()); - write_generation_ledger(root, &mut ledger)?; + write_generation_ledger(root, ledger)?; + } + GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived => { + if let Some(generation_index) = generation_index { + let public_generation = &draft.generations[generation_index]; + if public_generation.intent_id != ledger.intent_id + || public_generation.phase != AssetCanvasGenerationStatus::Failed + { + return Err("待归档的素材画布公开生成记录身份无效".to_string()); + } + } } - GenerationLedgerPhase::Archived => {} GenerationLedgerPhase::ReconciliationRequired => { return Err("reconciliation-required: 结果未知的素材画布生成不能归档".to_string()); } @@ -3507,17 +3548,23 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at( let archived_at = ledger .archived_at - .ok_or_else(|| "已归档的素材画布生成账本缺少 archivedAt".to_string())?; - draft.generations.remove(generation_index); - draft.revision = draft - .revision - .checked_add(1) - .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; - validate_safe_revision(draft.revision, "草稿 revision")?; - draft.updated_at = asset_canvas_now(); - write_asset_canvas_draft_locked(root, &draft)?; + .ok_or_else(|| "归档中的素材画布生成账本缺少 archivedAt".to_string())?; + if let Some(generation_index) = generation_index { + draft.generations.remove(generation_index); + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(draft.revision, "草稿 revision")?; + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + } + if ledger.phase != GenerationLedgerPhase::Archived { + ledger.phase = GenerationLedgerPhase::Archived; + write_generation_ledger(root, ledger)?; + } Ok(ArchiveAssetCanvasGenerationResult { - generation_id: ledger.generation_id, + generation_id: ledger.generation_id.clone(), phase: ledger.phase.as_str().to_string(), archived_at, draft, @@ -3572,6 +3619,30 @@ pub(crate) async fn recover_asset_canvas_generations_at( events: Vec::new(), }); } + for generation_id in &generation_ids { + let Some(ledger) = read_generation_ledger(root, generation_id)? else { + continue; + }; + if ledger.project_id != input.expected_project_id + || ledger.draft_id != input.draft_id + || !matches!( + ledger.phase, + GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived + ) + { + continue; + } + let _guard = generation_singleflight_lock(&input.expected_project_id, generation_id).await; + let Some(mut current) = read_generation_ledger(root, generation_id)? else { + continue; + }; + if matches!( + current.phase, + GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived + ) { + finish_generation_archive_at(root, &mut current, None)?; + } + } let draft_status = { let _lock = acquire_asset_canvas_draft_lock(root)?; read_asset_canvas_draft_locked(root, &input.expected_project_id, &input.draft_id)? @@ -3599,6 +3670,7 @@ pub(crate) async fn recover_asset_canvas_generations_at( ledger.phase, GenerationLedgerPhase::CandidateReady | GenerationLedgerPhase::AssetDurableCommitted + | GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived ) && (ledger.phase != GenerationLedgerPhase::Failed @@ -3635,6 +3707,7 @@ pub(crate) async fn recover_asset_canvas_generations_at( GenerationLedgerPhase::CandidateReady | GenerationLedgerPhase::AssetDurableCommitted | GenerationLedgerPhase::Failed + | GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived ) { @@ -4178,13 +4251,34 @@ mod tests { set_private_phase( directory.path(), &mut ledger, - GenerationLedgerPhase::CandidateReady, + GenerationLedgerPhase::MediaDownloaded, None, ) - .expect("persist candidate-ready placement ledger"); + .expect("persist media-downloaded placement ledger"); let (record, updated, _) = persist_generation_candidate_at(directory.path(), &mut ledger) .expect("persist candidate at frozen placeholder"); + assert_eq!(ledger.phase, GenerationLedgerPhase::MediaDownloaded); + let mut replayed_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read replayable candidate ledger") + .expect("replayable candidate ledger exists"); + assert_eq!( + replayed_ledger.phase, + GenerationLedgerPhase::MediaDownloaded + ); + let (_, replayed, _) = + persist_generation_candidate_at(directory.path(), &mut replayed_ledger) + .expect("replay candidate persistence after restart"); + assert_eq!(replayed.canvas.layers.len(), updated.canvas.layers.len()); + assert_eq!(replayed.generations.len(), updated.generations.len()); + set_private_phase( + directory.path(), + &mut replayed_ledger, + GenerationLedgerPhase::CandidateReady, + None, + ) + .expect("publish candidate-ready only after candidate readback"); + ledger = replayed_ledger; assert_eq!( record.source_layer_id.as_deref(), Some(source_layer_id.as_str()) @@ -4852,6 +4946,63 @@ mod tests { .any(|record| record.generation_id == input.generation_id)); } + #[tokio::test] + async fn recovery_finishes_an_archive_interrupted_before_public_draft_removal() { + let project_id = "asset-canvas-interrupted-archive-project"; + let (directory, draft) = create_generation_fixture(project_id, "失败生成中断归档恢复测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "创建一个归档中断的测试图片", + ); + let mut ledger = + validate_and_prepare_ledger(directory.path(), &input).expect("prepare failed ledger"); + mark_generation_error( + directory.path(), + &mut ledger, + false, + "generation-rejected", + &mut |_| {}, + ) + .expect("persist failed generation"); + let failed_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read failed draft") + .expect("failed draft exists"); + + ledger.phase = GenerationLedgerPhase::Archiving; + ledger.archived_at = Some(asset_canvas_now()); + write_generation_ledger(directory.path(), &mut ledger) + .expect("persist interrupted archiving ledger"); + + let recovery = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |_| {}, + ) + .await + .expect("recovery finalizes interrupted archive without credentials"); + assert!(recovery.result.resumed_generation_ids.is_empty()); + let archived = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered archive ledger") + .expect("archive ledger remains"); + assert_eq!(archived.phase, GenerationLedgerPhase::Archived); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered archive draft") + .expect("recovered archive draft exists"); + assert_eq!(recovered_draft.revision, failed_draft.revision + 1); + assert!(!recovered_draft + .generations + .iter() + .any(|record| record.generation_id == input.generation_id)); + } + #[tokio::test] async fn generation_archive_rejects_running_and_reconciliation_required_ledgers() { for (reconciliation, expected_message) in [ diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx index df5c63927..c7d58ad17 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx @@ -152,6 +152,52 @@ type PendingGenerationIdentity = { }; const ASSET_CANVAS_WORLD_SIZE = 12_000; +const QUICK_EDIT_PANEL_GAP = 12; +const QUICK_EDIT_PANEL_PADDING = 12; + +export function resolveQuickEditPanelPosition(input: { + layer: Pick; + viewport: CanvasViewport; + canvasSize: { width: number; height: number }; + panelSize: { width: number; height: number }; +}) { + const { layer, viewport, canvasSize, panelSize } = input; + const usableWidth = Math.max(0, canvasSize.width - QUICK_EDIT_PANEL_PADDING * 2); + const usableHeight = Math.max( + 0, + canvasSize.height - QUICK_EDIT_PANEL_PADDING * 2, + ); + const panelWidth = Math.min(Math.max(0, panelSize.width), usableWidth); + const panelHeight = Math.min(Math.max(0, panelSize.height), usableHeight); + const anchorLeft = viewport.x + layer.x * viewport.scale; + const anchorRight = viewport.x + (layer.x + layer.width) * viewport.scale; + const anchorTop = viewport.y + layer.y * viewport.scale; + const anchorBottom = + viewport.y + (layer.y + layer.height) * viewport.scale; + const anchorCenter = (anchorLeft + anchorRight) / 2; + const minCenter = QUICK_EDIT_PANEL_PADDING + panelWidth / 2; + const maxCenter = canvasSize.width - QUICK_EDIT_PANEL_PADDING - panelWidth / 2; + const belowTop = anchorBottom + QUICK_EDIT_PANEL_GAP; + const aboveTop = anchorTop - QUICK_EDIT_PANEL_GAP - panelHeight; + const preferredTop = + belowTop + panelHeight <= canvasSize.height - QUICK_EDIT_PANEL_PADDING + ? belowTop + : aboveTop; + + return { + left: Math.min( + Math.max(anchorCenter, minCenter), + Math.max(minCenter, maxCenter), + ), + top: Math.min( + Math.max(preferredTop, QUICK_EDIT_PANEL_PADDING), + Math.max( + QUICK_EDIT_PANEL_PADDING, + canvasSize.height - QUICK_EDIT_PANEL_PADDING - panelHeight, + ), + ), + }; +} const GENERATION_ASPECT_RATIOS = ['1:1', '2:3', '3:2', '9:16', '16:9'] as const; @@ -346,6 +392,34 @@ function runtimeGenerationTaskFromRecord( }; } +function runtimeLayerFromDraftLayer( + layer: ImageCanvasDraft['canvas']['layers'][number], + previewUrl: string, +): RuntimeCanvasLayer { + return { + id: layer.layerId, + resourceId: layer.resourceId, + resourcePersistenceState: 'self-contained-local', + title: layer.title, + src: previewUrl, + mediaType: 'image', + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + originalWidth: layer.originalWidth, + originalHeight: layer.originalHeight, + zIndex: layer.zIndex, + sourceType: 'uploaded', + groupId: layer.groupId, + hidden: layer.hidden, + locked: layer.locked, + flipX: layer.flipX, + flipY: layer.flipY, + mediaRef: layer.mediaRef, + }; +} + const MODAL_FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', @@ -765,6 +839,11 @@ export function AssetCanvasSurface({ const [recoveryReloadToken, setRecoveryReloadToken] = useState(0); const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); const viewportElementRef = useRef(null); + const quickEditPanelRef = useRef(null); + const [quickEditPanelSize, setQuickEditPanelSize] = useState({ + width: 672, + height: 352, + }); const importInputRef = useRef(null); const layersRef = useRef(layers); const viewportRef = useRef(viewport); @@ -1052,28 +1131,7 @@ export function AssetCanvasSurface({ throw new Error('stale-asset-canvas-epoch'); } previewUrlsRef.current.add(preview.value.previewUrl); - return { - id: layer.layerId, - resourceId: layer.resourceId, - resourcePersistenceState: 'self-contained-local' as const, - title: layer.title, - src: preview.value.previewUrl, - mediaType: 'image' as const, - x: layer.x, - y: layer.y, - width: layer.width, - height: layer.height, - originalWidth: layer.originalWidth, - originalHeight: layer.originalHeight, - zIndex: layer.zIndex, - sourceType: 'uploaded' as const, - groupId: layer.groupId, - hidden: layer.hidden, - locked: layer.locked, - flipX: layer.flipX, - flipY: layer.flipY, - mediaRef: layer.mediaRef, - } satisfies RuntimeCanvasLayer; + return runtimeLayerFromDraftLayer(layer, preview.value.previewUrl); }), ); if (epoch !== epochRef.current) return; @@ -1399,6 +1457,32 @@ export function AssetCanvasSurface({ return () => observer.disconnect(); }, [draft]); + useLayoutEffect(() => { + if (!quickEditOpen) return undefined; + const element = quickEditPanelRef.current; + if (!element) return undefined; + const update = () => { + const rect = element.getBoundingClientRect(); + setQuickEditPanelSize((current) => { + const next = { + width: rect.width || element.offsetWidth || current.width, + height: rect.height || element.offsetHeight || current.height, + }; + return current.width === next.width && current.height === next.height + ? current + : next; + }); + }; + update(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + } + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, [quickEditOpen]); + const persistDraft = useCallback(async (): Promise => { const epoch = epochRef.current; @@ -1628,7 +1712,6 @@ export function AssetCanvasSurface({ const handleLocalImport = useCallback(async () => { if ( - !host.asset.importLocalImages || lifecycleRef.current.kind !== 'canvas.editing' || backgroundInteractionLockedRef.current ) { @@ -1653,6 +1736,12 @@ export function AssetCanvasSurface({ viewportSize: canvasSize, }); if (epoch !== epochRef.current) return; + if (imported.status === 'unsupported-capability') { + setImportPending(false); + setNotice(imported.message); + importInputRef.current?.click(); + return; + } if (imported.status !== 'ok') { setNotice( imported.status === 'conflict' @@ -2360,30 +2449,6 @@ export function AssetCanvasSurface({ createdAt: Date.now(), updatedAt: Date.now(), }; - const initialGenerationRecord: ImageCanvasGenerationRecord = { - generationId: identity.generationId, - intentId: identity.intentId, - phase: 'generation-running', - referenceResourceIds: [...generationReferenceResourceIds], - outputAssetId: null, - sourceLayerId: sourceLayer?.id ?? null, - placeholder, - errorCode: null, - createdAt: initialTask.createdAt, - updatedAt: initialTask.updatedAt, - }; - const draftWithGeneration = draftRef.current; - if (draftWithGeneration) { - const nextDraft = { - ...draftWithGeneration, - generations: [ - ...draftWithGeneration.generations, - initialGenerationRecord, - ], - }; - draftRef.current = nextDraft; - setDraft(nextDraft); - } setGenerationTasksCollapsed(false); setGenerationTaskTab('active'); setGenerationTasks((current) => [...current, initialTask]); @@ -2396,7 +2461,8 @@ export function AssetCanvasSurface({ const frozenImageSize = generationImageSize; const frozenAssetKind = assetKind; const frozenAssetName = assetName; - const needsDraftPersist = true; + const needsDraftPersist = + documentVersionRef.current !== persistedDocumentVersionRef.current; setGenerationDialog(null); setQuickEditOpen(false); dragRef.current = null; @@ -2486,13 +2552,39 @@ export function AssetCanvasSurface({ setLifecycle({ kind: 'canvas.editing', dirty: false }); return; } - result.value.images.forEach((image) => - URL.revokeObjectURL(image.previewUrl), + applyDraftCandidate(result.value.draft); + const imageByResourceId = new Map( + result.value.images.map((image) => [image.resourceId, image]), ); - await hydrateDraft(result.value.draft, epoch); - // Acknowledge the authoritative candidate with a canvas save. Until - // this first save succeeds, the backend keeps merging the new layer - // back into stale autosaves that were queued while generation ran. + const candidateLayers = result.value.draft.canvas.layers + .filter((layer) => imageByResourceId.has(layer.resourceId)) + .map((layer) => { + const image = imageByResourceId.get(layer.resourceId)!; + previewUrlsRef.current.add(image.previewUrl); + return runtimeLayerFromDraftLayer(layer, image.previewUrl); + }); + const mergedLayerIds = new Set(layersRef.current.map((layer) => layer.id)); + const appendedCandidateLayers = candidateLayers.filter( + (layer) => !mergedLayerIds.has(layer.id), + ); + const appendedPreviewUrls = new Set( + appendedCandidateLayers.map((layer) => layer.src), + ); + for (const image of result.value.images) { + if (!appendedPreviewUrls.has(image.previewUrl)) { + previewUrlsRef.current.delete(image.previewUrl); + URL.revokeObjectURL(image.previewUrl); + } + } + if (appendedCandidateLayers.length > 0) { + const nextLayers = [...layersRef.current, ...appendedCandidateLayers]; + layersRef.current = nextLayers; + setLayers(nextLayers); + } + // Serialize behind every already queued autosave, then acknowledge the + // candidate together with the latest local canvas state. This keeps + // edits made while generation was running instead of hydrating the + // generation response's older full-canvas snapshot. if (!(await persistDraft())) return; pendingGenerationRef.current = null; persistedDocumentVersionRef.current = documentVersionRef.current; @@ -2535,7 +2627,7 @@ export function AssetCanvasSurface({ generationReferenceResourceIds, generationTasks, host.generation, - hydrateDraft, + applyDraftCandidate, onSaveAttempt, onWalletBalanceMayHaveChanged, persistDraft, @@ -2548,7 +2640,6 @@ export function AssetCanvasSurface({ const archiveFailedGeneration = useCallback( async (generationId: string) => { if ( - !host.generation.archiveFailedGeneration || archivingGenerationIds.includes(generationId) || !generationTasks.some( (task) => @@ -2707,17 +2798,12 @@ export function AssetCanvasSurface({ ? (layers.find((layer) => layer.id === quickEditSourceLayerId) ?? null) : null; const quickEditPanelStyle: CSSProperties | null = quickEditSourceLayer - ? { - left: - viewport.x + - (quickEditSourceLayer.x + quickEditSourceLayer.width / 2) * - viewport.scale, - top: - viewport.y + - (quickEditSourceLayer.y + quickEditSourceLayer.height) * - viewport.scale + - 12, - } + ? resolveQuickEditPanelPosition({ + layer: quickEditSourceLayer, + viewport, + canvasSize, + panelSize: quickEditPanelSize, + }) : null; const currentFinalLayer = (draft?.lastCommit @@ -2894,13 +2980,7 @@ export function AssetCanvasSurface({ label="导入图片" title="导入本地图片" icon={