修复资源画布视口与生成恢复
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 3m30s
Project CI / Native shell tests (pull_request) Successful in 12m52s

统一资源详情操作样式并恢复空态页面滚动

按排序模式和栏目记忆视口并改进复位适配算法

完善候选确认屏障与连续提交恢复对账

补充前后端回归测试并同步产品和技术文档
This commit is contained in:
2026-08-24 19:34:56 +08:00
parent f9bca1f9ad
commit a5cdd7f936
14 changed files with 1000 additions and 209 deletions
@@ -4102,100 +4102,117 @@ fn find_superseding_asset_canvas_commit(
if final_image_state_for_journal(root, journal)? != AssetCanvasFinalImageState::Matches {
return Ok(false);
}
for later in read_asset_canvas_transaction_journals(root)? {
if later.commit_id == journal.commit_id
|| later.project_id != journal.project_id
|| later.draft_id != journal.draft_id
|| later.asset_id != journal.asset_id
{
continue;
}
let Some(ledger) = read_asset_canvas_ledger(root, &later.commit_id)? else {
continue;
};
if !asset_canvas_journal_ledger_identity_matches(&later, &ledger)
|| ledger.status != AssetCanvasLedgerStatus::Committed
|| ledger.committed_project_revision != Some(later.target_project_revision)
|| ledger.committed_draft_revision != Some(later.target_draft_revision)
|| ledger.asset_id.as_deref() != Some(later.asset_id.as_str())
|| ledger.event_payload.is_none()
{
continue;
}
if later.expected_project_revision != journal.target_project_revision
|| later.manifest_before_sha256 != journal.manifest_after_sha256
|| later.project_revision_before_sha256.as_deref()
!= Some(journal.project_revision_after_sha256.as_str())
|| later.final_image_relative_path == journal.final_image_relative_path
|| !matches!(
let journals = read_asset_canvas_transaction_journals(root)?;
let manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&journal.commit_id,
"manifest.after.json",
&journal.manifest_after_sha256,
)?;
let revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&journal.commit_id,
"project-revision.after.json",
&journal.project_revision_after_sha256,
)?;
let mut pending = vec![(journal.clone(), manifest_after, revision_after)];
let mut visited = HashSet::from([journal.commit_id.clone()]);
while let Some((previous, previous_manifest_after, previous_revision_after)) = pending.pop() {
for later in &journals {
if visited.contains(&later.commit_id)
|| later.project_id != previous.project_id
|| later.draft_id != previous.draft_id
|| later.asset_id != previous.asset_id
|| later.expected_project_revision != previous.target_project_revision
|| later.manifest_before_sha256 != previous.manifest_after_sha256
|| later.project_revision_before_sha256.as_deref()
!= Some(previous.project_revision_after_sha256.as_str())
|| later.final_image_relative_path == previous.final_image_relative_path
{
continue;
}
let Some(ledger) = read_asset_canvas_ledger(root, &later.commit_id)? else {
continue;
};
let finalized_link = matches!(
later.stage,
AssetCanvasTransactionStage::Committed
| AssetCanvasTransactionStage::EventAttempted
)
{
continue;
}
) && ledger.status == AssetCanvasLedgerStatus::Committed;
let superseded_link = later.stage == AssetCanvasTransactionStage::Superseded
&& ledger.status == AssetCanvasLedgerStatus::Superseded;
if !asset_canvas_journal_ledger_identity_matches(later, &ledger)
|| (!finalized_link && !superseded_link)
|| ledger.committed_project_revision != Some(later.target_project_revision)
|| ledger.committed_draft_revision != Some(later.target_draft_revision)
|| ledger.asset_id.as_deref() != Some(later.asset_id.as_str())
|| ledger.event_payload.is_none()
{
continue;
}
let later_manifest_before: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.before.json",
&later.manifest_before_sha256,
)?;
let later_revision_before: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.before.json",
later
.project_revision_before_sha256
.as_deref()
.ok_or_else(|| "后续素材画布事务缺少 revision before 摘要".to_string())?,
)?;
let later_manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.after.json",
&later.manifest_after_sha256,
)?;
let later_revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.after.json",
&later.project_revision_after_sha256,
)?;
let later_manifest_before_sha256 =
asset_canvas_sha256(&asset_canvas_json_bytes(&later_manifest_before)?);
let later_revision_before_sha256 =
asset_canvas_sha256(&asset_canvas_json_bytes(&later_revision_before)?);
if later_manifest_before_sha256 != journal.manifest_after_sha256
|| later_revision_before_sha256 != journal.project_revision_after_sha256
|| current_manifest != &later_manifest_after
|| current_manifest_sha256 != later.manifest_after_sha256
|| current_revision != &later_revision_after
|| current_revision_sha256 != later.project_revision_after_sha256
|| current_revision.revision != later.target_project_revision
|| later_revision_after.revision != later.target_project_revision
|| later.target_project_revision != later.expected_project_revision.saturating_add(1)
|| final_image_state_for_journal(root, &later)? != AssetCanvasFinalImageState::Matches
{
continue;
}
let later_manifest_before: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.before.json",
&later.manifest_before_sha256,
)?;
let later_revision_before: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.before.json",
later
.project_revision_before_sha256
.as_deref()
.ok_or_else(|| "后续素材画布事务缺少 revision before 摘要".to_string())?,
)?;
let later_manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.after.json",
&later.manifest_after_sha256,
)?;
let later_revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.after.json",
&later.project_revision_after_sha256,
)?;
if later_manifest_before != previous_manifest_after
|| later_revision_before != previous_revision_after
|| later_revision_after.revision != later.target_project_revision
|| later.target_project_revision
!= later.expected_project_revision.saturating_add(1)
|| final_image_state_for_journal(root, later)?
!= AssetCanvasFinalImageState::Matches
{
continue;
}
let matching_assets = current_manifest
.assets
.iter()
.filter(|asset| asset.id == later.asset_id)
.collect::<Vec<_>>();
if matching_assets.len() != 1 {
continue;
let reaches_current_state = current_manifest == &later_manifest_after
&& current_manifest_sha256 == later.manifest_after_sha256
&& current_revision == &later_revision_after
&& current_revision_sha256 == later.project_revision_after_sha256
&& current_revision.revision == later.target_project_revision;
if reaches_current_state && finalized_link {
let matching_assets = current_manifest
.assets
.iter()
.filter(|asset| asset.id == later.asset_id)
.collect::<Vec<_>>();
if matching_assets.len() == 1
&& matching_assets[0].local_path == later.final_image_relative_path
&& matching_assets[0].media_type == later.staged_image.media_type
{
return Ok(true);
}
continue;
}
visited.insert(later.commit_id.clone());
pending.push((later.clone(), later_manifest_after, later_revision_after));
}
let asset = matching_assets[0];
if asset.local_path != later.final_image_relative_path
|| asset.media_type != later.staged_image.media_type
{
continue;
}
return Ok(true);
}
Ok(false)
}
@@ -188,7 +188,7 @@ fn initialize_refine_fixture_with_later_commit() -> (Fixture, String, String) {
&fixture,
&draft,
&staged,
Uuid::new_v4().to_string(),
"f0000000-0000-4000-8000-000000000001".to_string(),
Uuid::new_v4().to_string(),
);
first_input.source_layer_id = Some(first_layer_id.clone());
@@ -207,7 +207,7 @@ fn initialize_refine_fixture_with_later_commit() -> (Fixture, String, String) {
&fixture,
&draft_before_second,
&staged,
Uuid::new_v4().to_string(),
"10000000-0000-4000-8000-000000000002".to_string(),
Uuid::new_v4().to_string(),
);
second_input.source_layer_id = Some(second_layer_id);
@@ -1270,6 +1270,65 @@ fn recovery_marks_completed_refine_commit_superseded_by_verified_later_commit()
}));
}
#[test]
fn recovery_follows_verified_refine_commit_chain_to_current_state() {
let (fixture, first_commit_id, second_commit_id) =
initialize_refine_fixture_with_later_commit();
let mut first_journal = read_asset_canvas_journal(fixture.root(), &first_commit_id)
.expect("read first transaction before third commit")
.expect("first transaction retained");
let mut first_ledger = read_asset_canvas_ledger(fixture.root(), &first_commit_id)
.expect("read first ledger before third commit")
.expect("first ledger retained");
first_journal.stage = AssetCanvasTransactionStage::Committed;
first_ledger.status = AssetCanvasLedgerStatus::Committed;
write_asset_canvas_journal(fixture.root(), &first_journal)
.expect("restore first journal before third commit");
write_asset_canvas_ledger(fixture.root(), &first_ledger)
.expect("restore first ledger before third commit");
let draft_after_second =
read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id)
.expect("read draft after second commit")
.expect("draft retained after second commit");
let (draft_before_third, third_layer_id, third_media_sha256) =
add_candidate_layer(&fixture, &draft_after_second);
let staged = stage_image(&fixture, &draft_before_third);
let mut third_input = commit_input(
&fixture,
&draft_before_third,
&staged,
Uuid::new_v4().to_string(),
Uuid::new_v4().to_string(),
);
third_input.source_layer_id = Some(third_layer_id);
third_input.media_sha256 = Some(third_media_sha256);
let third_commit_id = third_input.commit_id.clone();
commit_asset_canvas_at(fixture.root(), &third_input).expect("commit third final image");
force_refine_transaction_unresolved(&fixture, &first_commit_id);
let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("recover three chained refine commits");
for commit_id in [&first_commit_id, &second_commit_id] {
assert!(recovered.result.outcomes.iter().any(|outcome| {
outcome.commit_id == *commit_id
&& outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded
}));
}
assert!(recovered.result.outcomes.iter().any(|outcome| {
outcome.commit_id == third_commit_id
&& outcome.status == RecoverAssetCanvasOutcomeStatus::AlreadyCommitted
}));
assert!(!recovered.result.outcomes.iter().any(|outcome| {
outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
}));
let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("repeat chained refine recovery");
assert!(!repeated.result.outcomes.iter().any(|outcome| {
outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
}));
}
#[test]
fn recovery_does_not_supersede_when_later_transaction_identity_is_broken() {
let (fixture, first_commit_id, second_commit_id) =
@@ -843,6 +843,7 @@ export function AssetCanvasSurface({
const dragRef = useRef<DragState | null>(null);
const suppressNextLayerClickRef = useRef(false);
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const pendingCandidateLayerIdsRef = useRef(new Set<string>());
const savePromiseRef = useRef<Promise<void> | null>(null);
const hostRevisionRef = useRef(expectedHostRevision);
const previewUrlsRef = useRef(new Set<string>());
@@ -1172,6 +1173,7 @@ export function AssetCanvasSurface({
const epoch = epochRef.current + 1;
epochRef.current = epoch;
saveQueueRef.current = Promise.resolve();
pendingCandidateLayerIdsRef.current.clear();
savePromiseRef.current = null;
pendingCommitRef.current = null;
pendingGenerationRef.current = null;
@@ -1267,6 +1269,28 @@ export function AssetCanvasSurface({
}
await hydrateDraft(nextDraft, epoch);
if (epoch !== epochRef.current) return;
if (
nextDraft.generations.some(
(generation) => generation.phase === 'candidate-ready',
)
) {
const acknowledged = await host.project.acknowledgeCandidateLayers({
scope: stableScope,
// The host ignores non-candidate and already acknowledged IDs. This
// closes the restart window without exposing private ledger state.
layerIds: nextDraft.canvas.layers.map((layer) => layer.layerId),
});
if (epoch !== epochRef.current) return;
if (acknowledged.status !== 'ok') {
throw new Error(
acknowledged.status === 'failed' ||
acknowledged.status === 'unsupported-capability'
? acknowledged.message
: '恢复候选图层确认发生草稿冲突',
);
}
applyDraftCandidate(acknowledged.value);
}
// 生成 operation 的恢复可能持续数分钟。画布草稿已安全 hydrate 后应立即可编辑,
// 后台恢复只更新任务投影,不能把整个画布继续锁在 recovering。
@@ -1350,6 +1374,7 @@ export function AssetCanvasSurface({
previewUrls.clear();
};
}, [
applyDraftCandidate,
applyGenerationProgressRevision,
expectedHostRevision,
host,
@@ -1463,13 +1488,64 @@ export function AssetCanvasSurface({
return () => observer.disconnect();
}, [draft]);
const persistDraft =
useCallback(async (): Promise<ImageCanvasDraft | null> => {
const acknowledgePendingCandidateLayers = useCallback(
async (epoch: number): Promise<ImageCanvasDraft | null> => {
const currentDraft = draftRef.current;
const layerIds = [...pendingCandidateLayerIdsRef.current];
if (!currentDraft || epoch !== epochRef.current) return null;
if (layerIds.length === 0) return currentDraft;
const result = await host.project.acknowledgeCandidateLayers({
scope: stableScope,
layerIds,
});
if (epoch !== epochRef.current) return null;
if (result.status !== 'ok') {
setLifecycle({
kind: 'canvas.failed',
operation: 'draft-save',
code: result.status === 'failed' ? result.code : result.status,
message:
result.status === 'failed' ||
result.status === 'unsupported-capability'
? result.message
: '候选图层确认发生草稿冲突',
reconciliationRequired: false,
});
return null;
}
for (const layerId of layerIds) {
pendingCandidateLayerIdsRef.current.delete(layerId);
}
applyDraftCandidate(result.value);
return result.value;
},
[applyDraftCandidate, host.project, stableScope],
);
const enqueuePendingCandidateAcknowledgements = useCallback(async () => {
const epoch = epochRef.current;
const task = saveQueueRef.current.then(() =>
acknowledgePendingCandidateLayers(epoch),
);
saveQueueRef.current = task.catch(() => undefined);
return await task;
}, [acknowledgePendingCandidateLayers]);
const persistDraft = useCallback(
async (
candidateLayerIds: readonly string[] = [],
): Promise<ImageCanvasDraft | null> => {
const epoch = epochRef.current;
const requestedVersion = documentVersionRef.current;
const task = saveQueueRef.current.then(async () => {
const currentDraft = draftRef.current;
if (!currentDraft || epoch !== epochRef.current) return null;
// Register a candidate only when its own save reaches the head of the
// FIFO. Registering at enqueue time would let an older autosave
// acknowledge it before this save has written the latest canvas.
for (const layerId of candidateLayerIds) {
pendingCandidateLayerIdsRef.current.add(layerId);
}
const result = await host.project.updateDraft({
scope: stableScope,
expectedDraftRevision: currentDraft.revision,
@@ -1509,17 +1585,42 @@ export function AssetCanvasSurface({
persistedDocumentVersionRef.current,
requestedVersion,
);
const acknowledged = await acknowledgePendingCandidateLayers(epoch);
if (!acknowledged) return null;
if (
requestedVersion === documentVersionRef.current &&
lifecycleRef.current.kind === 'canvas.editing'
) {
setLifecycle({ kind: 'canvas.editing', dirty: false });
}
return result.value;
return acknowledged;
});
saveQueueRef.current = task.catch(() => undefined);
return await task;
}, [applyDraftCandidate, host.project, stableScope]);
},
[
acknowledgePendingCandidateLayers,
applyDraftCandidate,
host.project,
stableScope,
],
);
const flushDraftPersistence = useCallback(
async ({ persistDirty = true }: { persistDirty?: boolean } = {}) => {
const epoch = epochRef.current;
await saveQueueRef.current;
if (epoch !== epochRef.current || !draftRef.current) return null;
if (
persistDirty &&
documentVersionRef.current !== persistedDocumentVersionRef.current
) {
return await persistDraft();
}
return await enqueuePendingCandidateAcknowledgements();
},
[enqueuePendingCandidateAcknowledgements, persistDraft],
);
useEffect(() => {
if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) {
@@ -1697,13 +1798,7 @@ export function AssetCanvasSurface({
) {
return;
}
if (
documentVersionRef.current !== persistedDocumentVersionRef.current &&
!(await persistDraft())
) {
return;
}
const currentDraft = draftRef.current;
const currentDraft = await flushDraftPersistence();
if (!currentDraft || lifecycleRef.current.kind !== 'canvas.editing') return;
const epoch = epochRef.current;
const historySnapshot = getCanvasHistorySnapshot();
@@ -1809,7 +1904,7 @@ export function AssetCanvasSurface({
captureHistory,
getCanvasHistorySnapshot,
host,
persistDraft,
flushDraftPersistence,
stableScope,
]);
@@ -1817,11 +1912,10 @@ export function AssetCanvasSurface({
async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = '';
const currentDraft = draftRef.current;
if (
lifecycleRef.current.kind !== 'canvas.editing' ||
backgroundInteractionLockedRef.current ||
!currentDraft ||
!draftRef.current ||
!files.length
)
return;
@@ -1854,6 +1948,15 @@ export function AssetCanvasSurface({
) {
return;
}
const currentDraft = await flushDraftPersistence();
if (
!currentDraft ||
epoch !== epochRef.current ||
lifecycleRef.current.kind !== 'canvas.editing' ||
backgroundInteractionLockedRef.current
) {
return;
}
const imported = await host.asset.importImages({
scope: stableScope,
expectedDraftRevision: currentDraft.revision,
@@ -1927,7 +2030,7 @@ export function AssetCanvasSurface({
markDirty();
setNotice(`已导入 ${additions.length} 张图片`);
},
[captureHistory, host.asset, markDirty, stableScope],
[captureHistory, flushDraftPersistence, host.asset, markDirty, stableScope],
);
const deleteCanvasLayer = useCallback(
@@ -2024,12 +2127,11 @@ export function AssetCanvasSurface({
});
const task = (async () => {
const epoch = saveEpoch;
if (!draftRef.current) return;
if (documentVersionRef.current !== persistedDocumentVersionRef.current) {
setLifecycle({ kind: 'canvas.saving', stage: 'draft' });
const persisted = await persistDraft();
if (!persisted) return;
}
const persisted = await flushDraftPersistence();
if (!persisted) return;
if (epoch !== epochRef.current || !draftRef.current) return;
setLifecycle({ kind: 'canvas.saving', stage: 'committing' });
const referenceResourceIds = draftRef.current.sourceResourceId
@@ -2156,7 +2258,7 @@ export function AssetCanvasSurface({
hydrateDraft,
onCommitted,
onSaveAttempt,
persistDraft,
flushDraftPersistence,
quickEditSourceLayerId,
renderImage,
sessionId,
@@ -2164,9 +2266,8 @@ export function AssetCanvasSurface({
]);
const discardCanvas = useCallback(() => {
const currentDraft = draftRef.current;
if (
!currentDraft ||
!draftRef.current ||
!['canvas.editing', 'canvas.failed'].includes(
lifecycleRef.current.kind,
) ||
@@ -2178,13 +2279,16 @@ export function AssetCanvasSurface({
}
setExitActionPending(true);
const epoch = epochRef.current;
void host.project
.discardDraft({
scope: stableScope,
expectedDraftRevision: currentDraft.revision,
void flushDraftPersistence({ persistDirty: false })
.then((currentDraft) => {
if (!currentDraft || epoch !== epochRef.current) return null;
return host.project.discardDraft({
scope: stableScope,
expectedDraftRevision: currentDraft.revision,
});
})
.then((result) => {
if (epoch !== epochRef.current) return;
if (!result || epoch !== epochRef.current) return;
if (result.status === 'ok') {
setExitDialogOpen(false);
onCancel?.({
@@ -2219,7 +2323,7 @@ export function AssetCanvasSurface({
.finally(() => {
if (epoch === epochRef.current) setExitActionPending(false);
});
}, [host.project, onCancel, stableScope]);
}, [flushDraftPersistence, host.project, onCancel, stableScope]);
const keepDraftAndExit = useCallback(() => {
if (
@@ -2237,7 +2341,7 @@ export function AssetCanvasSurface({
if (!currentDraft) return;
setExitActionPending(true);
const epoch = epochRef.current;
void persistDraft()
void flushDraftPersistence()
.then((persisted) => {
if (epoch !== epochRef.current || !persisted) return;
setExitDialogOpen(false);
@@ -2249,7 +2353,7 @@ export function AssetCanvasSurface({
.finally(() => {
if (epoch === epochRef.current) setExitActionPending(false);
});
}, [exitActionPending, onCancel, persistDraft, stableScope.draftId]);
}, [exitActionPending, flushDraftPersistence, onCancel, stableScope.draftId]);
const requestCanvasExit = useCallback(() => {
const currentDraft = draftRef.current;
@@ -2586,15 +2690,17 @@ export function AssetCanvasSurface({
return true;
};
const task = (async () => {
if (needsDraftPersist) {
const persisted = await persistDraft();
if (!persisted) {
const persisted = needsDraftPersist
? await persistDraft()
: await flushDraftPersistence();
if (!persisted) {
if (needsDraftPersist) {
await persistGenerationFailure(
'draft-save-failed',
'生成任务占位保存失败',
);
return;
}
return;
}
const currentDraft = draftRef.current;
if (
@@ -2755,10 +2861,16 @@ export function AssetCanvasSurface({
setGenerationTasks(
mergedGenerations.map(runtimeGenerationTaskFromRecord),
);
// 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.
if (!(await persistDraft())) return;
// Save the authoritative candidate, then acknowledge its private ledger
// in the same FIFO. Until both steps succeed, the backend keeps merging
// the new layer back into stale autosaves that were queued during generation.
if (
!(await persistDraft(
authoritativeLayers.map((layer) => layer.layerId),
))
) {
return;
}
pendingGenerationRef.current = null;
persistedDocumentVersionRef.current = documentVersionRef.current;
setLifecycle({ kind: 'canvas.editing', dirty: false });
@@ -2793,6 +2905,7 @@ export function AssetCanvasSurface({
generationPrompt,
generationReferenceResourceIds,
generationTasks,
flushDraftPersistence,
host.generation,
onSaveAttempt,
onWalletBalanceMayHaveChanged,
@@ -2815,13 +2928,7 @@ export function AssetCanvasSurface({
) {
return;
}
if (
documentVersionRef.current !== persistedDocumentVersionRef.current &&
!(await persistDraft())
) {
return;
}
const currentDraft = draftRef.current;
const currentDraft = await flushDraftPersistence();
if (!currentDraft) return;
const epoch = epochRef.current;
setArchivingGenerationIds((current) => [...current, generationId]);
@@ -2857,9 +2964,9 @@ export function AssetCanvasSurface({
},
[
archivingGenerationIds,
flushDraftPersistence,
generationTasks,
host.generation,
persistDraft,
stableScope,
],
);
@@ -154,8 +154,7 @@ export type TauriImageCanvasHostAdapter = Omit<
'project'
> & {
readonly kind: 'tauri';
readonly project: ImageCanvasProjectPort &
Required<Pick<ImageCanvasProjectPort, 'acknowledgeCandidateLayers'>>;
readonly project: ImageCanvasProjectPort;
readonly projectPath: string;
readonly expectedProjectId: string;
readMediaPreview(input: {
+1 -1
View File
@@ -6110,7 +6110,7 @@ iframe.preview-frame {
flex: 0 0 auto;
}
.game-resource-focus-actions > button:first-child {
.game-resource-focus-action {
min-height: 32px;
padding: 0 14px;
border: 1px solid #d78d69;
@@ -1,7 +1,6 @@
import type { ImageCanvasHostScope } from '@genarrative/image-canvas-core';
import {
type CanvasViewport,
fitViewportToBounds,
resolveViewportFromWheel,
} from '@genarrative/image-canvas-core';
import {
@@ -77,6 +76,7 @@ import {
} from './projectResourceLiveUpdateModel';
import {
createResourceCanvasCardSizeByResourceId,
fitResourceCanvasViewportToContent,
normalizeInfiniteResourceCanvasViewport,
RESOURCE_CANVAS_DRAG_THRESHOLD,
RESOURCE_CANVAS_FIT_PADDING,
@@ -85,6 +85,7 @@ import {
RESOURCE_CANVAS_SECTION_ORDER,
type ResourceCanvasCardSize,
resourceCanvasCardSize,
resourceCanvasContentBounds,
resourceCanvasSectionExtent,
} from './resourceCanvasLayoutModel';
import {
@@ -302,14 +303,22 @@ type ResourceCanvasViewportByCategory = Record<
ResourceCategory,
CanvasViewport
>;
type ResourceCanvasViewportBySortMode = Record<
ResourceSortMode,
ResourceCanvasViewportByCategory
>;
function defaultResourceCanvasViewports(): ResourceCanvasViewportByCategory {
return {
function defaultResourceCanvasViewports(): ResourceCanvasViewportBySortMode {
const byCategory = (): ResourceCanvasViewportByCategory => ({
document: { x: 48, y: 48, scale: 1 },
art: { x: 48, y: 48, scale: 1 },
audio: { x: 48, y: 48, scale: 1 },
code: { x: 48, y: 48, scale: 1 },
version: { x: 48, y: 48, scale: 1 },
});
return {
dependency: byCategory(),
type: byCategory(),
};
}
@@ -829,7 +838,7 @@ export default function ProjectDevelopmentView({
const [sortMode, setSortMode] = useState<ResourceSortMode>('dependency');
const [searchText, setSearchText] = useState('');
const [resourceCanvasViewports, setResourceCanvasViewports] =
useState<ResourceCanvasViewportByCategory>(defaultResourceCanvasViewports);
useState<ResourceCanvasViewportBySortMode>(defaultResourceCanvasViewports);
const [activeResourceCategory, setActiveResourceCategory] =
useState<ResourceCategory | null>(null);
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(
@@ -896,7 +905,7 @@ export default function ProjectDevelopmentView({
y: 48,
scale: 1,
});
const resourceCanvasFitKeyRef = useRef<string | null>(null);
const resourceCanvasFitKeysRef = useRef<Set<string>>(new Set());
const resourceCanvasPageWheelRef = useRef<{
activeCategory: ResourceCategory | null;
accumulatedDeltaY: number;
@@ -1476,8 +1485,28 @@ export default function ProjectDevelopmentView({
activePageExtent.y,
],
);
const activePageResources = activePageCategory
? (projectResourcesByCategory.get(activePageCategory) ?? [])
: [];
const activePageLayoutReady = activePageResources.every((resource) =>
resourcePositionById.has(resource.id),
);
const resourceCanvasFitBounds = useMemo(
() =>
resourceCanvasContentBounds(
activePageCategory
? (resourcePositionsByCategory.get(activePageCategory) ?? [])
: [],
resourceCardSizeByResourceId,
),
[
activePageCategory,
resourceCardSizeByResourceId,
resourcePositionsByCategory,
],
);
const activeResourceCanvasViewport = activePageCategory
? resourceCanvasViewports[activePageCategory]
? resourceCanvasViewports[sortMode][activePageCategory]
: { x: 48, y: 48, scale: 1 };
const setResourceCanvasViewport = useCallback(
(candidate: CanvasViewport) => {
@@ -1488,13 +1517,23 @@ export default function ProjectDevelopmentView({
}
const next = normalizeInfiniteResourceCanvasViewport(candidate);
resourceCanvasViewportRef.current = next;
setResourceCanvasViewports((current) =>
resourceCanvasViewportsEqual(current[activeCategory], next)
setResourceCanvasViewports((current) => {
const currentSortViewports = current[sortMode];
return resourceCanvasViewportsEqual(
currentSortViewports[activeCategory],
next,
)
? current
: { ...current, [activeCategory]: next },
);
: {
...current,
[sortMode]: {
...currentSortViewports,
[activeCategory]: next,
},
};
});
},
[],
[sortMode],
);
resourceCanvasViewportRef.current = activeResourceCanvasViewport;
const selectedResource =
@@ -2019,7 +2058,11 @@ export default function ProjectDevelopmentView({
]);
useLayoutEffect(() => {
if (mode !== 'resources' || !activePageCategory) {
if (
mode !== 'resources' ||
!activePageCategory ||
!activePageLayoutReady
) {
return undefined;
}
const canvas = resourceCanvasRef.current;
@@ -2027,38 +2070,46 @@ export default function ProjectDevelopmentView({
return undefined;
}
const fitKey = `${projectPath}\n${manifest.projectId}\n${sortMode}\n${activePageCategory}`;
const canvasSize = resourceCanvasElementSize(canvas);
if (resourceCanvasFitKeyRef.current !== fitKey) {
resourceCanvasFitKeyRef.current = fitKey;
setResourceCanvasViewport(
fitViewportToBounds({
bounds: resourceCanvasNavigationBounds,
canvasSize,
padding: RESOURCE_CANVAS_FIT_PADDING,
}),
);
} else {
const fitInitialViewportOrPreserve = () => {
if (!resourceCanvasFitKeysRef.current.has(fitKey)) {
const viewportElement = resourceCanvasViewportElement(canvas);
const rect = viewportElement?.getBoundingClientRect();
const measuredWidth = viewportElement?.clientWidth || rect?.width || 0;
const measuredHeight =
viewportElement?.clientHeight || rect?.height || 0;
if (measuredWidth > 0 && measuredHeight > 0) {
resourceCanvasFitKeysRef.current.add(fitKey);
setResourceCanvasViewport(
fitResourceCanvasViewportToContent({
bounds: resourceCanvasFitBounds,
canvasSize: { width: measuredWidth, height: measuredHeight },
padding: RESOURCE_CANVAS_FIT_PADDING,
}),
);
return;
}
}
// Resource extents can change after an image finishes measuring or a
// card is manually moved. Keep the user's infinite pan/zoom unchanged.
setResourceCanvasViewport(resourceCanvasViewportRef.current);
}
const normalizeViewport = () =>
setResourceCanvasViewport(resourceCanvasViewportRef.current);
};
fitInitialViewportOrPreserve();
const observer = window.ResizeObserver
? new window.ResizeObserver(normalizeViewport)
? new window.ResizeObserver(fitInitialViewportOrPreserve)
: null;
observer?.observe(canvas);
window.addEventListener('resize', normalizeViewport);
window.addEventListener('resize', fitInitialViewportOrPreserve);
return () => {
observer?.disconnect();
window.removeEventListener('resize', normalizeViewport);
window.removeEventListener('resize', fitInitialViewportOrPreserve);
};
}, [
activePageCategory,
activePageLayoutReady,
manifest.projectId,
mode,
projectPath,
resourceCanvasNavigationBounds,
resourceCanvasFitBounds,
setResourceCanvasViewport,
sortMode,
]);
@@ -2199,10 +2250,10 @@ export default function ProjectDevelopmentView({
setFocusedResourceId(null);
restoreResourceListScrollRef.current = false;
setActiveResourceCategory(null);
setResourceCanvasViewports(defaultResourceCanvasViewports());
resourceCanvasFitKeyRef.current = null;
resourceCanvasViewportRef.current =
defaultResourceCanvasViewports().document;
const defaultViewports = defaultResourceCanvasViewports();
setResourceCanvasViewports(defaultViewports);
resourceCanvasFitKeysRef.current.clear();
resourceCanvasViewportRef.current = defaultViewports.dependency.document;
if (resourceCanvasPageWheelRef.current.pendingTimerId !== null) {
window.clearTimeout(resourceCanvasPageWheelRef.current.pendingTimerId);
}
@@ -2653,13 +2704,13 @@ export default function ProjectDevelopmentView({
const resetResourceCanvasViewport = useCallback(() => {
const canvasSize = resourceCanvasElementSize(resourceCanvasRef.current);
setResourceCanvasViewport(
fitViewportToBounds({
bounds: resourceCanvasNavigationBounds,
fitResourceCanvasViewportToContent({
bounds: resourceCanvasFitBounds,
canvasSize,
padding: RESOURCE_CANVAS_FIT_PADDING,
}),
);
}, [resourceCanvasNavigationBounds, setResourceCanvasViewport]);
}, [resourceCanvasFitBounds, setResourceCanvasViewport]);
const handleResourceCardPointerDown = useCallback(
(
@@ -4119,6 +4170,7 @@ export default function ProjectDevelopmentView({
) ? (
<button
type="button"
className="game-resource-focus-action"
onClick={() =>
openCharacterAnimation(focusedResource)
}
@@ -4128,6 +4180,7 @@ export default function ProjectDevelopmentView({
) : null}
<button
type="button"
className="game-resource-focus-action"
onClick={() => openResourceEditor(focusedResource)}
>
编辑资源
@@ -4462,7 +4515,11 @@ export default function ProjectDevelopmentView({
) : null}
<div
ref={resourceCanvasRef}
className={`game-resource-canvas game-resource-canvas--paged game-resource-canvas--${sortMode}`}
className={`game-resource-canvas${
resourcePageCategories.length > 0
? ` game-resource-canvas--paged game-resource-canvas--${sortMode}`
: ''
}`}
aria-label={
sortMode === 'dependency' ? '资源依赖视图' : '资源类型视图'
}
@@ -4583,6 +4640,7 @@ export default function ProjectDevelopmentView({
<div
className="game-resource-page-world"
data-resource-boundary={`${resourceCanvasNavigationBounds.x},${resourceCanvasNavigationBounds.y},${resourceCanvasNavigationBounds.width},${resourceCanvasNavigationBounds.height}`}
data-resource-fit-boundary={`${resourceCanvasFitBounds.x},${resourceCanvasFitBounds.y},${resourceCanvasFitBounds.width},${resourceCanvasFitBounds.height}`}
data-resource-viewport={`${activeResourceCanvasViewport.x},${activeResourceCanvasViewport.y},${activeResourceCanvasViewport.scale}`}
style={{
width: `${resourceCanvasNavigationBounds.width}px`,
@@ -34,7 +34,7 @@ export const RESOURCE_CANVAS_CLUSTER_GAP = 48;
* Initial fit inset only. The resource canvas background itself is infinite;
* this value must not be used to clamp a later pan or zoom.
*/
export const RESOURCE_CANVAS_FIT_PADDING = 96;
export const RESOURCE_CANVAS_FIT_PADDING = 16;
export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
['document', 'art', 'audio', 'code', 'version'];
@@ -1298,3 +1298,76 @@ export function resourceCanvasSectionExtent(
height: Math.ceil(maxY - minY),
};
}
export function resourceCanvasContentBounds(
positions: readonly ProjectResourceCanvasPosition[],
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
) {
if (positions.length === 0) {
return {
x: 0,
y: 0,
width: RESOURCE_CANVAS_SECTION_MIN_WIDTH,
height: RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
};
}
const minX = Math.min(...positions.map((position) => position.x));
const minY = Math.min(...positions.map((position) => position.y));
const maxX = Math.max(
...positions.map(
(position) =>
position.x +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
.width,
),
);
const maxY = Math.max(
...positions.map(
(position) =>
position.y +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
.height,
),
);
return {
x: Math.floor(minX),
y: Math.floor(minY),
width: Math.max(1, Math.ceil(maxX - minX)),
height: Math.max(1, Math.ceil(maxY - minY)),
};
}
export function fitResourceCanvasViewportToContent({
bounds,
canvasSize,
padding = RESOURCE_CANVAS_FIT_PADDING,
}: {
bounds: { x: number; y: number; width: number; height: number };
canvasSize: { width: number; height: number };
padding?: number;
}): CanvasViewport {
const normalizedPadding = Math.max(0, padding);
const boundsWidth = Math.max(1, bounds.width);
const boundsHeight = Math.max(1, bounds.height);
const availableWidth = Math.max(1, canvasSize.width - normalizedPadding * 2);
const availableHeight = Math.max(
1,
canvasSize.height - normalizedPadding * 2,
);
const scale = clampResourceCanvasNumber(
Math.min(availableWidth / boundsWidth, availableHeight / boundsHeight),
MIN_SCALE,
MAX_SCALE,
);
return {
x:
normalizedPadding +
availableWidth / 2 -
(bounds.x + boundsWidth / 2) * scale,
y:
normalizedPadding +
availableHeight / 2 -
(bounds.y + boundsHeight / 2) * scale,
scale,
};
}
@@ -768,6 +768,142 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.getByRole('article', { name: /发布 Agent/ })).not.toBeNull();
});
it('preserves independent art viewports across sort and workbench mode switches', async () => {
const manifest = createGameCreationAppManifest(
'workbench-art-viewport-memory',
'美术资源视口记忆',
);
const codeTask = manifest.tasks.find((task) => task.id === 'code-prototype');
expect(codeTask).toBeDefined();
codeTask!.status = 'completed';
manifest.assets = [
{
id: 'entry-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/entry-art.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
];
window.__TAURI__ = {
core: {
invoke: vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
),
},
};
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-art-viewport-memory',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
const artCanvas = await screen.findByRole('region', {
name: '美术资源资源画布',
});
const readViewport = (canvas: HTMLElement) =>
canvas
.querySelector<HTMLElement>('[data-resource-viewport]')
?.getAttribute('data-resource-viewport');
const zoomOut = (deltaY: number) => {
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
ctrlKey: true,
deltaY,
clientX: 80,
clientY: 60,
});
act(() => {
expect(
screen
.getByRole('button', { name: '复位资源画布' })
.dispatchEvent(event),
).toBe(false);
});
};
zoomOut(120);
const dependencyViewport = readViewport(artCanvas);
expect(dependencyViewport).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const typeCanvas = await screen.findByRole('region', {
name: '美术资源资源画布',
});
await waitFor(() =>
expect(readViewport(typeCanvas)).not.toBe(dependencyViewport),
);
zoomOut(240);
zoomOut(240);
const typeViewport = readViewport(typeCanvas);
expect(typeViewport).toBeTruthy();
expect(typeViewport).not.toBe(dependencyViewport);
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
const restoredDependencyCanvas = await screen.findByRole('region', {
name: '美术资源资源画布',
});
await waitFor(() =>
expect(readViewport(restoredDependencyCanvas)).toBe(dependencyViewport),
);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const restoredTypeCanvas = await screen.findByRole('region', {
name: '美术资源资源画布',
});
await waitFor(() =>
expect(readViewport(restoredTypeCanvas)).toBe(typeViewport),
);
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
expect(screen.getByRole('region', { name: '运行表现层' })).not.toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
const restoredArtCanvas = await screen.findByRole('region', {
name: '美术资源资源画布',
});
await waitFor(() =>
expect(readViewport(restoredArtCanvas)).toBe(typeViewport),
);
});
it('uses one full-page canvas per resource section with dependency-only guide lines', async () => {
function addSectionResources(
manifest: ReturnType<typeof createGameCreationAppManifest>,
@@ -959,16 +1095,22 @@ export function registerProjectWorkbenchFoundationTests() {
'48,48,1',
),
);
const fittedScale = Number(
documentWorld?.getAttribute('data-resource-viewport')?.split(',')[2],
);
expect(fittedScale).toBeLessThan(0.6);
const readViewport = () =>
(documentWorld?.getAttribute('data-resource-viewport') ?? '')
.split(',')
.map(Number);
const readFitBounds = () =>
(documentWorld?.getAttribute('data-resource-fit-boundary') ?? '')
.split(',')
.map(Number);
const fittedViewport = readViewport();
const fittedBounds = readFitBounds();
expect(
Math.abs(fittedBounds[2]! * fittedViewport[2]! - 468) < 1 ||
Math.abs(fittedBounds[3]! * fittedViewport[2]! - 268) < 1,
).toBe(true);
const viewportBeforeZoom = readViewport();
const viewportBeforeZoom = fittedViewport;
const zoomWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
@@ -1261,6 +1403,16 @@ export function registerProjectWorkbenchFoundationTests() {
const assertOverview = () => {
expect(screen.queryByLabelText('资源栏目大纲')).toBeNull();
expect(
screen
.getByLabelText(/资源(?:依赖|类型)视图/)
.classList.contains('game-resource-canvas--paged'),
).toBe(false);
expect(
screen
.getByLabelText(/资源(?:依赖|类型)视图/)
.classList.contains('game-resource-canvas--dependency'),
).toBe(false);
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(5);
for (const label of [
'设计文档',
@@ -1535,6 +1687,12 @@ export function registerProjectWorkbenchFoundationTests() {
expect(within(heroFocus).getByText('assets/hero.png')).not.toBeNull();
expect(within(heroFocus).getByText('Agent 生成')).not.toBeNull();
expect(within(heroFocus).getByText('image/png')).not.toBeNull();
expect(
within(heroFocus).getByRole('button', { name: '生成动画' }).className,
).toContain('game-resource-focus-action');
expect(
within(heroFocus).getByRole('button', { name: '编辑资源' }).className,
).toContain('game-resource-focus-action');
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_image_preview',
@@ -3532,6 +3690,18 @@ export function registerProjectWorkbenchFoundationTests() {
expect(card?.getAttribute('style')).toContain('--resource-y: 24px');
});
expect(card).not.toBeNull();
const viewportElement = card?.closest<HTMLElement>(
'[data-resource-viewport]',
);
const viewportScale = Number(
viewportElement
?.getAttribute('data-resource-viewport')
?.split(',')
.at(2),
);
expect(viewportScale).toBeGreaterThan(0);
const expectedX = Math.round(12 + (-100 - 20) / viewportScale);
const expectedY = Math.round(24 + (60 - 30) / viewportScale);
const setCardPointerCapture = vi.fn();
Object.defineProperties(card, {
setPointerCapture: { configurable: true, value: setCardPointerCapture },
@@ -3560,8 +3730,12 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.queryByRole('dialog', { name: '布局持久化回执' })).toBeNull();
await waitFor(() => {
expect(card?.getAttribute('style')).toContain('--resource-x: -108px');
expect(card?.getAttribute('style')).toContain('--resource-y: 54px');
expect(card?.getAttribute('style')).toContain(
`--resource-x: ${expectedX}px`,
);
expect(card?.getAttribute('style')).toContain(
`--resource-y: ${expectedY}px`,
);
});
expect(invoke).toHaveBeenCalledWith(
'update_local_project_resource_canvas_layout',
@@ -3570,8 +3744,8 @@ export function registerProjectWorkbenchFoundationTests() {
expect.objectContaining({
resourceId,
section: 'document',
x: -108,
y: 54,
x: expectedX,
y: expectedY,
manuallyPlaced: true,
}),
]),
@@ -177,6 +177,8 @@ function memoryHost(input?: {
importGate?: Deferred<unknown>;
commitGate?: Deferred<void>;
generationGate?: Deferred<void>;
candidateAcknowledgementGate?: Deferred<void>;
initialUnacknowledgedCandidateLayerIds?: string[];
generationFailure?: { code: string; message: string };
generationFailureNotStarted?: boolean;
updateFailure?: { code: string; message: string };
@@ -196,6 +198,13 @@ function memoryHost(input?: {
| ((progress: ImageCanvasGenerationProgress) => void)
| undefined;
const updates: ImageCanvasDraftCanvas[] = [];
const persistenceOperations: Array<
| { kind: 'update'; canvas: ImageCanvasDraftCanvas }
| { kind: 'acknowledge'; layerIds: string[] }
> = [];
const unacknowledgedCandidateLayerIds = new Set(
input?.initialUnacknowledgedCandidateLayerIds ?? [],
);
const commits: Array<{
commitId: string;
idempotencyKey: string;
@@ -293,18 +302,52 @@ function memoryHost(input?: {
hostRevision: String(hostRevision),
};
}
updates.push(update.canvas);
const protectedLayers = [...update.canvas.layers];
for (const layerId of unacknowledgedCandidateLayerIds) {
const candidateLayer = draft.canvas.layers.find(
(layer) => layer.layerId === layerId,
);
if (
candidateLayer &&
!protectedLayers.some((layer) => layer.layerId === layerId)
) {
protectedLayers.push(candidateLayer);
}
}
const protectedCanvas = { ...update.canvas, layers: protectedLayers };
updates.push(protectedCanvas);
persistenceOperations.push({ kind: 'update', canvas: protectedCanvas });
draft = {
...draft,
revision: draft.revision + 1,
status: update.status,
canvas: update.canvas,
canvas: protectedCanvas,
generations: update.generations,
updatedAt: draft.updatedAt + 1,
};
return { status: 'ok' as const, value: draft };
},
);
const acknowledgeCandidateLayers = vi.fn(
async (
acknowledgement: Parameters<
ImageCanvasProjectPort['acknowledgeCandidateLayers']
>[0],
) => {
await input?.candidateAcknowledgementGate?.promise;
if (!draft) throw new Error('draft missing');
persistenceOperations.push({
kind: 'acknowledge',
layerIds: [...acknowledgement.layerIds],
});
for (const layerId of acknowledgement.layerIds) {
if (draft.canvas.layers.some((layer) => layer.layerId === layerId)) {
unacknowledgedCandidateLayerIds.delete(layerId);
}
}
return { status: 'ok' as const, value: draft };
},
);
const settleGenerationFailure = vi.fn(
async (
failureInput: Parameters<
@@ -399,6 +442,7 @@ function memoryHost(input?: {
return { status: 'ok', value: draft };
},
updateDraft,
acknowledgeCandidateLayers,
discardDraft,
},
asset: {
@@ -566,6 +610,7 @@ function memoryHost(input?: {
flipX: false,
flipY: false,
};
unacknowledgedCandidateLayerIds.add(candidateLayer.layerId);
draft = {
...draft,
revision: draft.revision + 1,
@@ -738,6 +783,7 @@ function memoryHost(input?: {
selectedCandidateCommits,
generationCalls,
updates,
persistenceOperations,
revokedSubscriptions,
nativeImportCalls,
loadDraft,
@@ -745,6 +791,7 @@ function memoryHost(input?: {
recoverImages,
settleGenerationFailure,
updateDraft,
acknowledgeCandidateLayers,
confirmGenerationServiceIdentity,
discardDraft,
getDraft: () => draft,
@@ -3241,6 +3288,188 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(screen.getByText('已设为最终图,精修草稿可继续编辑')).toBeTruthy();
expect(onCancel).not.toHaveBeenCalled();
});
it('候选确认形成导入屏障,确认后删除并再次保存不会被宿主保护逻辑复活', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const sourceLayer = {
...keyboardCanvas().layers[0],
layerId: 'source-layer-for-candidate-ack',
resourceId: 'local-asset:source-asset',
title: '候选确认入口图',
mediaRef: { kind: 'project-asset' as const, assetId: 'source-asset' },
};
const candidateAcknowledgementGate = deferred<void>();
const memory = memoryHost({
initialDraft: draftFixture(refineScope, {
...emptyCanvas(),
layers: [sourceLayer],
}),
candidateAcknowledgementGate,
});
renderSurface(memory.host, refineScope, vi.fn(), vi.fn(), vi.fn(), {
name: '候选确认素材',
kind: 'illustration',
});
fireEvent.click(
await screen.findByRole('button', { name: '选择图层 候选确认入口图' }),
);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEdit).getByLabelText('图片提示词'), {
target: { value: '生成后确认再删除' },
});
fireEvent.click(within(quickEdit).getByRole('button', { name: '修改' }));
const candidateButton = await screen.findByRole('button', {
name: '选择图层 候选确认素材 候选图',
});
await waitFor(() =>
expect(memory.acknowledgeCandidateLayers).toHaveBeenCalledTimes(1),
);
const candidateLayerId =
memory.acknowledgeCandidateLayers.mock.calls[0]?.[0].layerIds[0];
expect(candidateLayerId).toMatch(/^generated-layer-/);
fireEvent.click(screen.getByRole('button', { name: '导入图片' }));
await act(async () => {
await Promise.resolve();
});
expect(memory.nativeImportCalls).toHaveBeenCalledTimes(0);
await act(async () => {
candidateAcknowledgementGate.resolve();
await candidateAcknowledgementGate.promise;
});
await waitFor(() =>
expect(memory.nativeImportCalls).toHaveBeenCalledTimes(1),
);
const candidateSaveIndex = memory.persistenceOperations.findIndex(
(operation) =>
operation.kind === 'update' &&
operation.canvas.layers.some(
(layer) => layer.layerId === candidateLayerId,
),
);
expect(candidateSaveIndex).toBeGreaterThanOrEqual(0);
expect(
memory.persistenceOperations.findIndex(
(operation) =>
operation.kind === 'acknowledge' &&
operation.layerIds.includes(candidateLayerId!),
),
).toBeGreaterThan(candidateSaveIndex);
fireEvent.click(
await screen.findByRole('button', {
name: '选择图层 候选确认素材 候选图',
}),
);
const candidateQuickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
const savesBeforeDelete = memory.updateDraft.mock.calls.length;
fireEvent.click(
within(candidateQuickEdit).getByRole('button', { name: '删除' }),
);
await waitFor(() =>
expect(memory.updateDraft.mock.calls.length).toBeGreaterThan(
savesBeforeDelete,
),
);
expect(
memory
.getDraft()
?.canvas.layers.some((layer) => layer.layerId === candidateLayerId),
).toBe(false);
});
it('重新打开 candidate-ready 草稿时先幂等确认,后续删除不会复活', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const candidateLayer = {
...keyboardCanvas().layers[0],
layerId: 'recovered-candidate-layer',
resourceId: 'draft-media:recovered-candidate',
title: '恢复候选图',
mediaRef: {
kind: 'draft-media' as const,
mediaId: 'recovered-candidate',
mediaType: 'image/png' as const,
sha256: 'd'.repeat(64),
byteLength: 4,
pixelWidth: 40,
pixelHeight: 30,
},
};
const initialDraft = {
...draftFixture(refineScope, {
...emptyCanvas(),
layers: [candidateLayer],
selectedLayerIds: [candidateLayer.layerId],
primarySelectedLayerId: candidateLayer.layerId,
}),
generations: [
{
generationId: 'recovered-generation',
intentId: 'recovered-intent',
phase: 'candidate-ready' as const,
referenceResourceIds: [],
outputAssetId: null,
sourceLayerId: null,
placeholder: null,
errorCode: null,
createdAt: 1,
updatedAt: 2,
},
],
};
const memory = memoryHost({
initialDraft,
initialUnacknowledgedCandidateLayerIds: [candidateLayer.layerId],
});
renderSurface(memory.host, refineScope);
await waitFor(() =>
expect(memory.acknowledgeCandidateLayers).toHaveBeenCalledWith({
scope: refineScope,
layerIds: [candidateLayer.layerId],
}),
);
const candidateButton = await screen.findByRole('button', {
name: '选择图层 恢复候选图',
});
await waitFor(() =>
expect((candidateButton as HTMLButtonElement).disabled).toBe(false),
);
fireEvent.click(candidateButton);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
const savesBeforeDelete = memory.updateDraft.mock.calls.length;
fireEvent.click(within(quickEdit).getByRole('button', { name: '删除' }));
await waitFor(() =>
expect(memory.updateDraft.mock.calls.length).toBeGreaterThan(
savesBeforeDelete,
),
);
expect(
memory
.getDraft()
?.canvas.layers.some(
(layer) => layer.layerId === candidateLayer.layerId,
),
).toBe(false);
});
it('精修图片直接修改时保留源资源引用、来源图层和生成占位', async () => {
const refineScope: ImageCanvasHostScope = {
projectId: 'project-refine',
@@ -3725,6 +3954,11 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
manifest,
};
}
if (
command === 'acknowledge_local_project_asset_canvas_candidate_layers'
) {
return { draft: draftFixture(scope) };
}
throw new Error(`unexpected command: ${command}`);
},
);
@@ -3765,6 +3999,23 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(commitRequests).toHaveLength(2);
expect(commitRequests[0]?.[1]).toEqual(commitRequests[1]?.[1]);
const acknowledgement = await adapter.project.acknowledgeCandidateLayers({
scope,
layerIds: ['candidate-layer'],
});
expect(acknowledgement.status).toBe('ok');
expect(invokeSpy).toHaveBeenCalledWith(
'acknowledge_local_project_asset_canvas_candidate_layers',
expect.objectContaining({
input: expect.objectContaining({
projectPath: '/fixture/project',
expectedProjectId: scope.projectId,
draftId: scope.draftId,
layerIds: ['candidate-layer'],
}),
}),
);
const mismatch = await adapter.project.loadDraft({
...scope,
projectId: 'replacement-project',
@@ -3775,7 +4026,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
code: 'project-identity-conflict',
}),
);
expect(invokeSpy).toHaveBeenCalledTimes(3);
expect(invokeSpy).toHaveBeenCalledTimes(4);
const invalidRevision = await adapter.completion.commitImage({
...commitInput,
@@ -3786,7 +4037,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(invalidRevision).toEqual(
expect.objectContaining({ status: 'failed' }),
);
expect(invokeSpy).toHaveBeenCalledTimes(3);
expect(invokeSpy).toHaveBeenCalledTimes(4);
});
it('Tauri adapter 不向生成与恢复命令透传登录 Token 或 External API Key', async () => {
@@ -9,6 +9,7 @@ import {
createEmptyResourceCanvasLayout,
createResourceCanvasCardSizeByResourceId,
DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
fitResourceCanvasViewportToContent,
moveResourceCanvasPosition,
normalizeInfiniteResourceCanvasViewport,
reconcileResourceCanvasLayout,
@@ -18,6 +19,7 @@ import {
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
type ResourceCanvasCardSize,
resourceCanvasContentBounds,
resourceCanvasImageCardSize,
type ResourceCanvasItem,
type ResourceCanvasLayoutTopology,
@@ -789,6 +791,46 @@ describe('resource canvas variable card geometry', () => {
});
});
it('fits the exact resource card bounds to the reset viewport', () => {
const positions: ProjectResourceCanvasPosition[] = [
{
resourceId: 'negative-card',
section: 'art',
x: -240,
y: -160,
manuallyPlaced: true,
},
{
resourceId: 'positive-card',
section: 'art',
x: 480,
y: 320,
manuallyPlaced: true,
},
];
const bounds = resourceCanvasContentBounds(
positions,
new Map([
['negative-card', { width: 180, height: 128 }],
['positive-card', { width: 220, height: 180 }],
]),
);
expect(bounds).toEqual({
x: -240,
y: -160,
width: 940,
height: 660,
});
const viewport = fitResourceCanvasViewportToContent({
bounds,
canvasSize: { width: 800, height: 600 },
});
expect(viewport.scale).toBeCloseTo(768 / 940, 8);
expect(bounds.width * viewport.scale).toBeCloseTo(768, 8);
expect(viewport.x + bounds.x * viewport.scale).toBeCloseTo(16, 8);
});
it('accepts card sizes directly on resource items for hook integration', () => {
const wide = {
...resource('wide', 'art', 0),