完善资源画布无限坐标与分页交互
调整资源分页画布 viewport 与复位按钮交互 允许资源元素使用 -1,000,000 到 1,000,000 的持久化坐标 同步 TypeScript、Rust 合同、布局校验与负坐标回归测试 更新资源画布 PRD 与技术方案 保留本地 .env 未提交
This commit is contained in:
@@ -4378,6 +4378,17 @@ fn recover_asset_canvas_transaction_locked(
|
||||
event,
|
||||
));
|
||||
}
|
||||
if find_superseding_asset_canvas_commit(
|
||||
root,
|
||||
&journal,
|
||||
¤t_manifest,
|
||||
¤t_manifest_sha,
|
||||
¤t_revision,
|
||||
¤t_revision_sha,
|
||||
)? {
|
||||
return mark_asset_canvas_superseded_locked(root, journal, ledger)
|
||||
.map(|outcome| (outcome, None));
|
||||
}
|
||||
return mark_asset_canvas_reconciliation_locked(root, journal, ledger)
|
||||
.map(|outcome| (outcome, None));
|
||||
}
|
||||
|
||||
@@ -1060,6 +1060,65 @@ fn recovery_marks_interrupted_refine_commit_superseded_by_verified_later_commit(
|
||||
commit_asset_canvas_at(fixture.root(), &third_input).expect("commit after superseded recovery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_marks_completed_refine_commit_superseded_by_verified_later_commit() {
|
||||
let (fixture, first_commit_id, second_commit_id) =
|
||||
initialize_refine_fixture_with_later_commit();
|
||||
// initialize_refine_fixture_with_later_commit only forces the first
|
||||
// transaction into the unresolved state. Restore both completed ledgers so
|
||||
// this test models two successful commits followed by a restart.
|
||||
for commit_id in [&first_commit_id, &second_commit_id] {
|
||||
let mut journal = read_asset_canvas_journal(fixture.root(), commit_id)
|
||||
.expect("read completed transaction journal")
|
||||
.expect("completed transaction journal");
|
||||
let mut ledger = read_asset_canvas_ledger(fixture.root(), commit_id)
|
||||
.expect("read completed transaction ledger")
|
||||
.expect("completed transaction ledger");
|
||||
journal.stage = AssetCanvasTransactionStage::Committed;
|
||||
ledger.status = AssetCanvasLedgerStatus::Committed;
|
||||
write_asset_canvas_journal(fixture.root(), &journal)
|
||||
.expect("restore completed journal stage");
|
||||
write_asset_canvas_ledger(fixture.root(), &ledger)
|
||||
.expect("restore completed ledger status");
|
||||
}
|
||||
|
||||
let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
|
||||
.expect("recover two completed refine transactions");
|
||||
let first_outcome = recovered
|
||||
.result
|
||||
.outcomes
|
||||
.iter()
|
||||
.find(|outcome| outcome.commit_id == first_commit_id)
|
||||
.expect("first completed outcome");
|
||||
let second_outcome = recovered
|
||||
.result
|
||||
.outcomes
|
||||
.iter()
|
||||
.find(|outcome| outcome.commit_id == second_commit_id)
|
||||
.expect("second completed outcome");
|
||||
assert_eq!(
|
||||
first_outcome.status,
|
||||
RecoverAssetCanvasOutcomeStatus::Superseded
|
||||
);
|
||||
assert_eq!(
|
||||
second_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 recovery after supersede");
|
||||
assert!(repeated.result.outcomes.iter().any(|outcome| {
|
||||
outcome.commit_id == first_commit_id
|
||||
&& outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded
|
||||
}));
|
||||
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) =
|
||||
|
||||
@@ -12,7 +12,8 @@ const RESOURCE_LAYOUT_LOCK_PATH: &str = ".agent/workbench/resource-layouts/.layo
|
||||
const RESOURCE_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024;
|
||||
const RESOURCE_LAYOUT_MAX_POSITIONS: usize = 4096;
|
||||
const RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS: usize = 512;
|
||||
const RESOURCE_LAYOUT_MAX_COORDINATE: u32 = 1_000_000;
|
||||
const RESOURCE_LAYOUT_MIN_COORDINATE: i32 = -1_000_000;
|
||||
const RESOURCE_LAYOUT_MAX_COORDINATE: i32 = 1_000_000;
|
||||
const RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS: usize = 100;
|
||||
const RESOURCE_LAYOUT_LOCK_WAIT_MILLIS: u64 = 10;
|
||||
|
||||
@@ -337,11 +338,13 @@ fn validate_resource_layout_positions(
|
||||
if !resource_ids.insert(resource_id) {
|
||||
return Err("资源布局 resourceId 不能重复".to_string());
|
||||
}
|
||||
if position.x > RESOURCE_LAYOUT_MAX_COORDINATE
|
||||
if position.x < RESOURCE_LAYOUT_MIN_COORDINATE
|
||||
|| position.x > RESOURCE_LAYOUT_MAX_COORDINATE
|
||||
|| position.y < RESOURCE_LAYOUT_MIN_COORDINATE
|
||||
|| position.y > RESOURCE_LAYOUT_MAX_COORDINATE
|
||||
{
|
||||
return Err(format!(
|
||||
"资源布局坐标不能超过 {RESOURCE_LAYOUT_MAX_COORDINATE}"
|
||||
"资源布局坐标必须在 {RESOURCE_LAYOUT_MIN_COORDINATE} 到 {RESOURCE_LAYOUT_MAX_COORDINATE} 之间"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -526,7 +529,7 @@ mod tests {
|
||||
))
|
||||
}
|
||||
|
||||
fn layout_position(id: &str, x: u32) -> ProjectResourceCanvasPosition {
|
||||
fn layout_position(id: &str, x: i32) -> ProjectResourceCanvasPosition {
|
||||
ProjectResourceCanvasPosition {
|
||||
resource_id: id.to_string(),
|
||||
section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Art,
|
||||
@@ -883,11 +886,27 @@ mod tests {
|
||||
RESOURCE_LAYOUT_MAX_COORDINATE
|
||||
);
|
||||
|
||||
let coordinate_error = update_project_resource_canvas_layout_at(
|
||||
let lower_boundary = update_project_resource_canvas_layout_at(
|
||||
&root,
|
||||
ProjectResourceCanvasLayoutMode::Type,
|
||||
"layout-invalid",
|
||||
boundary.layout.revision,
|
||||
vec![layout_position(
|
||||
"asset-coordinate-lower-boundary",
|
||||
RESOURCE_LAYOUT_MIN_COORDINATE,
|
||||
)],
|
||||
)
|
||||
.expect("lower boundary coordinate must remain valid");
|
||||
assert_eq!(
|
||||
lower_boundary.layout.positions[0].x,
|
||||
RESOURCE_LAYOUT_MIN_COORDINATE
|
||||
);
|
||||
|
||||
let coordinate_error = update_project_resource_canvas_layout_at(
|
||||
&root,
|
||||
ProjectResourceCanvasLayoutMode::Type,
|
||||
"layout-invalid",
|
||||
lower_boundary.layout.revision,
|
||||
vec![layout_position(
|
||||
"asset-coordinate",
|
||||
RESOURCE_LAYOUT_MAX_COORDINATE + 1,
|
||||
@@ -895,6 +914,18 @@ mod tests {
|
||||
)
|
||||
.expect_err("oversized coordinate must fail");
|
||||
assert!(coordinate_error.contains("坐标"));
|
||||
let lower_coordinate_error = update_project_resource_canvas_layout_at(
|
||||
&root,
|
||||
ProjectResourceCanvasLayoutMode::Type,
|
||||
"layout-invalid",
|
||||
lower_boundary.layout.revision,
|
||||
vec![layout_position(
|
||||
"asset-coordinate-below-lower-boundary",
|
||||
RESOURCE_LAYOUT_MIN_COORDINATE - 1,
|
||||
)],
|
||||
)
|
||||
.expect_err("coordinate below lower boundary must fail");
|
||||
assert!(lower_coordinate_error.contains("坐标"));
|
||||
let id_error = update_project_resource_canvas_layout_at(
|
||||
&root,
|
||||
ProjectResourceCanvasLayoutMode::Type,
|
||||
|
||||
@@ -4878,6 +4878,11 @@ iframe.preview-frame {
|
||||
box-shadow: var(--platform-panel-shadow);
|
||||
}
|
||||
|
||||
.game-workbench-stage > .game-resource-manager {
|
||||
grid-row: 3;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-workbench-chat {
|
||||
border-color: var(--platform-line-soft);
|
||||
box-shadow: var(--platform-nav-active-shadow);
|
||||
@@ -5005,6 +5010,7 @@ iframe.preview-frame {
|
||||
.game-resource-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -5081,6 +5087,8 @@ iframe.preview-frame {
|
||||
.game-resource-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
@@ -5101,6 +5109,17 @@ iframe.preview-frame {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.game-resource-canvas--paged {
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
overscroll-behavior: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.game-resource-canvas--paged:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.game-resource-canvas-content {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -5116,6 +5135,190 @@ iframe.preview-frame {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-resource-canvas-content--paged {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-resource-page-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 212px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.game-resource-outline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 12px 2px;
|
||||
overflow: auto;
|
||||
border-right: 1px solid #eaded8;
|
||||
}
|
||||
|
||||
.game-resource-outline button {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 34px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #725348;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.game-resource-outline button:hover,
|
||||
.game-resource-outline button:focus-visible,
|
||||
.game-resource-outline button.is-active {
|
||||
background: #f6ece6;
|
||||
color: #a9502a;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.game-resource-outline button strong {
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-resource-outline button small {
|
||||
color: #a98a7c;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.game-resource-page {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-resource-page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.game-resource-page-heading span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #6d4b3f;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.game-resource-page-heading strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.game-resource-page-heading small {
|
||||
color: #a98a7c;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.game-resource-page-canvas {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ecdfd8;
|
||||
border-radius: 12px;
|
||||
background:
|
||||
radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px,
|
||||
#fffdfa;
|
||||
}
|
||||
|
||||
.game-resource-canvas-reset {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid rgb(227 207 197 / 88%);
|
||||
border-radius: 8px;
|
||||
background: rgb(255 253 250 / 88%);
|
||||
color: #8c6252;
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
box-shadow: 0 4px 12px rgb(112 70 52 / 10%);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.game-resource-canvas-reset:hover,
|
||||
.game-resource-canvas-reset:focus-visible {
|
||||
border-color: #ce7650;
|
||||
color: #bd5f37;
|
||||
outline: 2px solid rgb(206 118 80 / 22%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.game-resource-page-world {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.game-resource-page-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
padding: 24px;
|
||||
color: #b0958a;
|
||||
font-size: 12px;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-resource-next-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #e8d6cc;
|
||||
border-radius: 10px;
|
||||
background: #fff8f4;
|
||||
color: #725348;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-resource-next-page:hover,
|
||||
.game-resource-next-page:focus-visible {
|
||||
border-color: #d6946f;
|
||||
color: #a9502a;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.game-resource-next-page span {
|
||||
color: #a98a7c;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-resource-next-page strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.game-resource-section-stack {
|
||||
display: contents;
|
||||
}
|
||||
@@ -5374,6 +5577,18 @@ iframe.preview-frame {
|
||||
transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0);
|
||||
}
|
||||
|
||||
.game-resource-card {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.game-resource-card.is-dragging {
|
||||
cursor: grabbing;
|
||||
box-shadow:
|
||||
0 14px 32px rgb(195 105 62 / 24%),
|
||||
0 0 0 2px rgb(216 115 66 / 24%);
|
||||
}
|
||||
|
||||
.game-resource-card:hover,
|
||||
.game-resource-card:focus-within,
|
||||
.game-resource-card.is-selected {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+33
-82
@@ -6,6 +6,7 @@ import {
|
||||
|
||||
import {
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
|
||||
GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
|
||||
type ProjectResourceCanvasLayout,
|
||||
type ProjectResourceCanvasLayoutMode,
|
||||
@@ -29,15 +30,14 @@ export const RESOURCE_CANVAS_TYPE_COLUMNS = 3;
|
||||
export const RESOURCE_CANVAS_SECTION_MIN_WIDTH = 620;
|
||||
export const RESOURCE_CANVAS_SECTION_MIN_HEIGHT = 108;
|
||||
export const RESOURCE_CANVAS_CLUSTER_GAP = 48;
|
||||
export const RESOURCE_CANVAS_BOUNDARY_PADDING = 96;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
const sectionOrder: ProjectResourceCanvasSection[] = [
|
||||
'code',
|
||||
'document',
|
||||
'version',
|
||||
'art',
|
||||
'audio',
|
||||
];
|
||||
export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
|
||||
['document', 'art', 'audio', 'code', 'version'];
|
||||
|
||||
export type ResourceCanvasCardSize = {
|
||||
width: number;
|
||||
@@ -65,76 +65,24 @@ function clampResourceCanvasNumber(
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the dependency canvas inside the invisible boundary formed by every
|
||||
* resource plus a fixed screen-space safety margin. The minimum zoom is the
|
||||
* scale at which the whole boundary fits; panning never lets the resource
|
||||
* union move beyond the safety margin on either axis.
|
||||
* Normalizes an infinite resource-canvas viewport.
|
||||
*
|
||||
* The canvas has no navigational edge: x/y are intentionally left untouched
|
||||
* so users can pan the background and resources arbitrarily far in any
|
||||
* direction. Only invalid coordinates and the shared image-canvas scale
|
||||
* limits are normalized here.
|
||||
*/
|
||||
export function constrainResourceCanvasViewport({
|
||||
bounds,
|
||||
canvasSize,
|
||||
padding = RESOURCE_CANVAS_BOUNDARY_PADDING,
|
||||
viewport,
|
||||
}: {
|
||||
bounds: ResourceCanvasNavigationBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
padding?: number;
|
||||
viewport: CanvasViewport;
|
||||
}): CanvasViewport {
|
||||
const canvasWidth = Math.max(
|
||||
1,
|
||||
Number.isFinite(canvasSize.width) ? canvasSize.width : 1,
|
||||
);
|
||||
const canvasHeight = Math.max(
|
||||
1,
|
||||
Number.isFinite(canvasSize.height) ? canvasSize.height : 1,
|
||||
);
|
||||
const boundsX = Number.isFinite(bounds.x) ? bounds.x : 0;
|
||||
const boundsY = Number.isFinite(bounds.y) ? bounds.y : 0;
|
||||
const boundsWidth = Math.max(
|
||||
1,
|
||||
Number.isFinite(bounds.width) ? bounds.width : 1,
|
||||
);
|
||||
const boundsHeight = Math.max(
|
||||
1,
|
||||
Number.isFinite(bounds.height) ? bounds.height : 1,
|
||||
);
|
||||
const normalizedPadding = Math.max(0, Number.isFinite(padding) ? padding : 0);
|
||||
const horizontalPadding = Math.min(normalizedPadding, (canvasWidth - 1) / 2);
|
||||
const verticalPadding = Math.min(normalizedPadding, (canvasHeight - 1) / 2);
|
||||
const availableWidth = Math.max(1, canvasWidth - horizontalPadding * 2);
|
||||
const availableHeight = Math.max(1, canvasHeight - verticalPadding * 2);
|
||||
const minimumScale = clampResourceCanvasNumber(
|
||||
Math.min(1, availableWidth / boundsWidth, availableHeight / boundsHeight),
|
||||
MIN_SCALE,
|
||||
MAX_SCALE,
|
||||
);
|
||||
const scale = clampResourceCanvasNumber(
|
||||
Number.isFinite(viewport.scale) ? viewport.scale : minimumScale,
|
||||
minimumScale,
|
||||
MAX_SCALE,
|
||||
);
|
||||
const minimumX =
|
||||
canvasWidth - horizontalPadding - (boundsX + boundsWidth) * scale;
|
||||
const maximumX = horizontalPadding - boundsX * scale;
|
||||
const minimumY =
|
||||
canvasHeight - verticalPadding - (boundsY + boundsHeight) * scale;
|
||||
const maximumY = verticalPadding - boundsY * scale;
|
||||
const centeredX = canvasWidth / 2 - (boundsX + boundsWidth / 2) * scale;
|
||||
const centeredY = canvasHeight / 2 - (boundsY + boundsHeight / 2) * scale;
|
||||
const candidateX = Number.isFinite(viewport.x) ? viewport.x : centeredX;
|
||||
const candidateY = Number.isFinite(viewport.y) ? viewport.y : centeredY;
|
||||
|
||||
export function normalizeInfiniteResourceCanvasViewport(
|
||||
viewport: CanvasViewport,
|
||||
): CanvasViewport {
|
||||
return {
|
||||
x:
|
||||
minimumX <= maximumX
|
||||
? clampResourceCanvasNumber(candidateX, minimumX, maximumX)
|
||||
: centeredX,
|
||||
y:
|
||||
minimumY <= maximumY
|
||||
? clampResourceCanvasNumber(candidateY, minimumY, maximumY)
|
||||
: centeredY,
|
||||
scale,
|
||||
x: Number.isFinite(viewport.x) ? viewport.x : 0,
|
||||
y: Number.isFinite(viewport.y) ? viewport.y : 0,
|
||||
scale: clampResourceCanvasNumber(
|
||||
Number.isFinite(viewport.scale) ? viewport.scale : 1,
|
||||
MIN_SCALE,
|
||||
MAX_SCALE,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1185,7 +1133,7 @@ export function reconcileResourceCanvasLayout(
|
||||
resources.map((resource) => [resource.id, resource]),
|
||||
);
|
||||
const positionsBySection = new Map(
|
||||
sectionOrder.map((section) => [
|
||||
RESOURCE_CANVAS_SECTION_ORDER.map((section) => [
|
||||
section,
|
||||
[] as ProjectResourceCanvasPosition[],
|
||||
]),
|
||||
@@ -1202,7 +1150,10 @@ export function reconcileResourceCanvasLayout(
|
||||
preserved.map((position) => position.resourceId),
|
||||
);
|
||||
const newResourcesBySection = new Map(
|
||||
sectionOrder.map((section) => [section, [] as ResourceCanvasItem[]]),
|
||||
RESOURCE_CANVAS_SECTION_ORDER.map((section) => [
|
||||
section,
|
||||
[] as ResourceCanvasItem[],
|
||||
]),
|
||||
);
|
||||
for (const resource of resources) {
|
||||
if (!preservedIds.has(resource.id)) {
|
||||
@@ -1210,7 +1161,7 @@ export function reconcileResourceCanvasLayout(
|
||||
}
|
||||
}
|
||||
|
||||
for (const section of sectionOrder) {
|
||||
for (const section of RESOURCE_CANVAS_SECTION_ORDER) {
|
||||
const sectionPositions = positionsBySection.get(section) ?? [];
|
||||
const sectionResources = resources.filter(
|
||||
(resource) => resource.category === section,
|
||||
@@ -1255,7 +1206,7 @@ export function reconcileResourceCanvasLayout(
|
||||
}
|
||||
}
|
||||
|
||||
const ordered = sectionOrder.flatMap((section) =>
|
||||
const ordered = RESOURCE_CANVAS_SECTION_ORDER.flatMap((section) =>
|
||||
(positionsBySection.get(section) ?? []).sort(
|
||||
(left, right) =>
|
||||
left.y - right.y ||
|
||||
@@ -1283,11 +1234,11 @@ export function moveResourceCanvasPosition(
|
||||
const finiteY = Number.isFinite(y) ? y : 0;
|
||||
const normalizedX = Math.min(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||||
Math.max(0, Math.round(finiteX)),
|
||||
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteX)),
|
||||
);
|
||||
const normalizedY = Math.min(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||||
Math.max(0, Math.round(finiteY)),
|
||||
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteY)),
|
||||
);
|
||||
return {
|
||||
...layout,
|
||||
|
||||
+2
-7
@@ -10,6 +10,7 @@ import type {
|
||||
ProjectResourceCanvasLayoutMode,
|
||||
ProjectResourceCanvasSection,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { RESOURCE_CANVAS_SECTION_ORDER } from './resourceCanvasLayoutModel';
|
||||
import {
|
||||
clampProjectResourceSectionHeight,
|
||||
clampProjectResourceSectionZoom,
|
||||
@@ -29,13 +30,7 @@ const RESOURCE_LAYOUT_MODES: readonly ProjectResourceCanvasLayoutMode[] = [
|
||||
'dependency',
|
||||
'type',
|
||||
];
|
||||
const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [
|
||||
'code',
|
||||
'document',
|
||||
'version',
|
||||
'art',
|
||||
'audio',
|
||||
];
|
||||
const RESOURCE_SECTIONS = RESOURCE_CANVAS_SECTION_ORDER;
|
||||
|
||||
export type ProjectResourceSectionHeightState = {
|
||||
height: number;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,14 +2,15 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
|
||||
isSafeProjectResourceCanvasCoordinate,
|
||||
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
constrainResourceCanvasViewport,
|
||||
createEmptyResourceCanvasLayout,
|
||||
createResourceCanvasCardSizeByResourceId,
|
||||
DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
|
||||
moveResourceCanvasPosition,
|
||||
normalizeInfiniteResourceCanvasViewport,
|
||||
reconcileResourceCanvasLayout,
|
||||
RESOURCE_CANVAS_CARD_HEIGHT,
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
@@ -57,44 +58,42 @@ function positionById(
|
||||
}
|
||||
|
||||
describe('resource canvas layout model', () => {
|
||||
it('clamps dependency-canvas panning to the resource union and safety margin', () => {
|
||||
const common = {
|
||||
bounds: { x: 0, y: 0, width: 1_000, height: 800 },
|
||||
canvasSize: { width: 800, height: 600 },
|
||||
};
|
||||
|
||||
it('allows infinite background panning while keeping zoom within shared limits', () => {
|
||||
expect(
|
||||
constrainResourceCanvasViewport({
|
||||
...common,
|
||||
viewport: { x: 2_000, y: 2_000, scale: 1 },
|
||||
normalizeInfiniteResourceCanvasViewport({
|
||||
x: 2_000,
|
||||
y: 2_000,
|
||||
scale: 1,
|
||||
}),
|
||||
).toEqual({ x: 96, y: 96, scale: 1 });
|
||||
).toEqual({ x: 2_000, y: 2_000, scale: 1 });
|
||||
expect(
|
||||
constrainResourceCanvasViewport({
|
||||
...common,
|
||||
viewport: { x: -2_000, y: -2_000, scale: 1 },
|
||||
normalizeInfiniteResourceCanvasViewport({
|
||||
x: -2_000,
|
||||
y: -2_000,
|
||||
scale: 0.001,
|
||||
}),
|
||||
).toEqual({ x: -296, y: -296, scale: 1 });
|
||||
).toEqual({ x: -2_000, y: -2_000, scale: 0.025 });
|
||||
});
|
||||
|
||||
it('stops zooming out once all resources and the hidden margin fit', () => {
|
||||
expect(
|
||||
constrainResourceCanvasViewport({
|
||||
bounds: { x: 0, y: 0, width: 1_000, height: 800 },
|
||||
canvasSize: { width: 800, height: 600 },
|
||||
viewport: { x: -800, y: 900, scale: 0.001 },
|
||||
}),
|
||||
).toEqual({ x: 145, y: 96, scale: 0.51 });
|
||||
it('does not pull the viewport back when resource extents change', () => {
|
||||
const before = normalizeInfiniteResourceCanvasViewport({
|
||||
x: 12_000,
|
||||
y: -9_000,
|
||||
scale: 0.75,
|
||||
});
|
||||
const after = normalizeInfiniteResourceCanvasViewport(before);
|
||||
|
||||
expect(after).toEqual(before);
|
||||
});
|
||||
|
||||
it('centers a resource boundary smaller than the viewport instead of allowing blank panning', () => {
|
||||
it('repairs invalid viewport coordinates without adding a canvas boundary', () => {
|
||||
expect(
|
||||
constrainResourceCanvasViewport({
|
||||
bounds: { x: 20, y: 30, width: 200, height: 100 },
|
||||
canvasSize: { width: 800, height: 600 },
|
||||
viewport: { x: -5_000, y: 5_000, scale: 1 },
|
||||
normalizeInfiniteResourceCanvasViewport({
|
||||
x: Number.NaN,
|
||||
y: Number.POSITIVE_INFINITY,
|
||||
scale: Number.NaN,
|
||||
}),
|
||||
).toEqual({ x: 280, y: 220, scale: 1 });
|
||||
).toEqual({ x: 0, y: 0, scale: 1 });
|
||||
});
|
||||
|
||||
it('creates non-overlapping defaults and keeps the two modes independent', () => {
|
||||
@@ -179,6 +178,32 @@ describe('resource canvas layout model', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves negative manual positions within the symmetric canvas range', () => {
|
||||
const base = reconcileResourceCanvasLayout(
|
||||
createEmptyResourceCanvasLayout('project-negative', 'type'),
|
||||
[resource('a', 'document')],
|
||||
).layout;
|
||||
const moved = moveResourceCanvasPosition(
|
||||
base,
|
||||
'a',
|
||||
'document',
|
||||
-123.4,
|
||||
-45.6,
|
||||
);
|
||||
|
||||
expect(moved.positions[0]).toMatchObject({ x: -123, y: -46 });
|
||||
expect(
|
||||
isSafeProjectResourceCanvasCoordinate(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSafeProjectResourceCanvasCoordinate(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE - 1,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a persisted position whose section no longer matches the resource', () => {
|
||||
const layout = createEmptyResourceCanvasLayout('project-1', 'dependency');
|
||||
layout.positions = [
|
||||
@@ -555,6 +580,16 @@ describe('resource canvas layout model', () => {
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE + 1,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSafeProjectResourceCanvasCoordinate(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSafeProjectResourceCanvasCoordinate(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE - 1,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(deepPositions.map((position) => position.x)).toEqual([
|
||||
lastLegalColumn,
|
||||
lastLegalColumn,
|
||||
|
||||
Reference in New Issue
Block a user