修复资源画布视口与生成恢复
统一资源详情操作样式并恢复空态页面滚动 按排序模式和栏目记忆视口并改进复位适配算法 完善候选确认屏障与连续提交恢复对账 补充前后端回归测试并同步产品和技术文档
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
+74
-1
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user