支持资源图片卡按真实比例布局
图片预览返回并透传像素宽高。 统一计算图片卡边界并让布局碰撞、自动位置和分区范围消费实际尺寸。 依赖连线按逐资源矩形计算端点并补充模型与预览测试。
This commit is contained in:
@@ -24,6 +24,8 @@ pub(crate) struct LocalProjectImagePreview {
|
||||
pub(crate) path: String,
|
||||
pub(crate) media_type: String,
|
||||
pub(crate) byte_len: u64,
|
||||
pub(crate) pixel_width: u32,
|
||||
pub(crate) pixel_height: u32,
|
||||
pub(crate) data_url: String,
|
||||
}
|
||||
|
||||
@@ -32,6 +34,8 @@ pub(crate) struct AgentRuntimeInspectionImage {
|
||||
pub(crate) sha256: String,
|
||||
pub(crate) byte_len: u64,
|
||||
pub(crate) media_type: &'static str,
|
||||
pixel_width: u32,
|
||||
pixel_height: u32,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -86,6 +90,8 @@ pub(crate) fn load_local_project_image_preview_with_cancellation(
|
||||
path: image.relative_path.clone(),
|
||||
media_type: image.media_type.to_string(),
|
||||
byte_len: image.byte_len,
|
||||
pixel_width: image.pixel_width,
|
||||
pixel_height: image.pixel_height,
|
||||
data_url: image.data_url_with_cancellation(cancellation)?,
|
||||
})
|
||||
}
|
||||
@@ -360,6 +366,8 @@ fn read_agent_runtime_inspection_image_with_cancellation(
|
||||
sha256,
|
||||
byte_len: bytes.len() as u64,
|
||||
media_type,
|
||||
pixel_width: width,
|
||||
pixel_height: height,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6142,6 +6142,12 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
|
||||
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
|
||||
.expect("auto preview");
|
||||
assert_eq!(preview.media_type, "image/png");
|
||||
assert_eq!(preview.pixel_width, 1);
|
||||
assert_eq!(preview.pixel_height, 1);
|
||||
let serialized_preview =
|
||||
serde_json::to_value(&preview).expect("serialize image preview");
|
||||
assert_eq!(serialized_preview["pixelWidth"], 1);
|
||||
assert_eq!(serialized_preview["pixelHeight"], 1);
|
||||
|
||||
fs::write(root.join("assets/unregistered.png"), &preview_bytes).expect("unregistered image");
|
||||
let unregistered_error = read_local_project_image_preview_at(
|
||||
|
||||
+25
-6
@@ -13,8 +13,8 @@ import type {
|
||||
ProjectResourceCanvasSection,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
RESOURCE_CANVAS_CARD_HEIGHT,
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
resourceCanvasCardSize,
|
||||
type ResourceCanvasCardSizeByResourceId,
|
||||
} from './resourceCanvasLayoutModel';
|
||||
import {
|
||||
type ProjectResourceGraph,
|
||||
@@ -40,6 +40,7 @@ export type ResourceDependencyOverlayProps = {
|
||||
positions: readonly ProjectResourceCanvasPosition[];
|
||||
section: ProjectResourceCanvasSection;
|
||||
visibleResourceIds: ReadonlySet<string>;
|
||||
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId;
|
||||
geometryRevision?: string;
|
||||
};
|
||||
|
||||
@@ -458,7 +459,14 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
ResourceDependencyOverlayHandle,
|
||||
ResourceDependencyOverlayProps
|
||||
>(function ResourceDependencyOverlay(
|
||||
{ geometryRevision = '', graph, positions, section, visibleResourceIds },
|
||||
{
|
||||
cardSizeByResourceId,
|
||||
geometryRevision = '',
|
||||
graph,
|
||||
positions,
|
||||
section,
|
||||
visibleResourceIds,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, '');
|
||||
@@ -545,6 +553,10 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const cardSize = resourceCanvasCardSize(
|
||||
position.resourceId,
|
||||
cardSizeByResourceId,
|
||||
);
|
||||
result.set(position.resourceId, {
|
||||
x:
|
||||
dragPreview?.resourceId === position.resourceId
|
||||
@@ -554,12 +566,19 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
dragPreview?.resourceId === position.resourceId
|
||||
? dragPreview.y
|
||||
: position.y,
|
||||
width: RESOURCE_CANVAS_CARD_WIDTH,
|
||||
height: RESOURCE_CANVAS_CARD_HEIGHT,
|
||||
width: cardSize.width,
|
||||
height: cardSize.height,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [dragPreview, graph.resourceIds, positions, section, visibleResourceIds]);
|
||||
}, [
|
||||
cardSizeByResourceId,
|
||||
dragPreview,
|
||||
graph.resourceIds,
|
||||
positions,
|
||||
section,
|
||||
visibleResourceIds,
|
||||
]);
|
||||
const sectionByResourceId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
|
||||
+326
-64
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,17 @@ export type ProjectResourceCardPreviewKind =
|
||||
| 'version'
|
||||
| 'placeholder';
|
||||
|
||||
export type ProjectResourceImageDimensions = {
|
||||
pixelWidth: number;
|
||||
pixelHeight: number;
|
||||
};
|
||||
|
||||
export type ProjectResourceCardPreviewPayload = {
|
||||
path: string;
|
||||
mediaType: string;
|
||||
byteLen: number;
|
||||
pixelWidth?: number;
|
||||
pixelHeight?: number;
|
||||
sourceUrl?: string;
|
||||
content?: string;
|
||||
};
|
||||
@@ -33,6 +40,29 @@ export type ProjectResourceCardPreviewTransportPayload = Omit<
|
||||
dataUrl?: string;
|
||||
};
|
||||
|
||||
export function projectResourceCardPreviewImageDimensions(
|
||||
preview: Pick<
|
||||
ProjectResourceCardPreviewPayload,
|
||||
'pixelWidth' | 'pixelHeight'
|
||||
>,
|
||||
): ProjectResourceImageDimensions | null {
|
||||
const { pixelWidth, pixelHeight } = preview;
|
||||
if (pixelWidth === undefined && pixelHeight === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof pixelWidth !== 'number' ||
|
||||
typeof pixelHeight !== 'number' ||
|
||||
!Number.isSafeInteger(pixelWidth) ||
|
||||
!Number.isSafeInteger(pixelHeight) ||
|
||||
pixelWidth <= 0 ||
|
||||
pixelHeight <= 0
|
||||
) {
|
||||
throw new Error('图片预览像素尺寸无效');
|
||||
}
|
||||
return { pixelWidth, pixelHeight };
|
||||
}
|
||||
|
||||
export type ProjectResourceCardPreviewCacheEntry = {
|
||||
identity: string;
|
||||
retainedBytes: number;
|
||||
|
||||
+32
@@ -23,6 +23,7 @@ import {
|
||||
PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT,
|
||||
projectResourceCardPreviewEvictionIdentities,
|
||||
projectResourceCardPreviewIdentity,
|
||||
projectResourceCardPreviewImageDimensions,
|
||||
projectResourceCardPreviewKind,
|
||||
type ProjectResourceCardPreviewPayload,
|
||||
type ProjectResourceCardPreviewState,
|
||||
@@ -133,6 +134,8 @@ function materializeProjectResourceCardPreview(
|
||||
path: transport.path,
|
||||
mediaType: transport.mediaType,
|
||||
byteLen: transport.byteLen,
|
||||
pixelWidth: transport.pixelWidth,
|
||||
pixelHeight: transport.pixelHeight,
|
||||
content: transport.content,
|
||||
},
|
||||
retainedBytes:
|
||||
@@ -142,6 +145,7 @@ function materializeProjectResourceCardPreview(
|
||||
objectUrl: null,
|
||||
};
|
||||
}
|
||||
const imageDimensions = projectResourceCardPreviewImageDimensions(transport);
|
||||
const prefix = `data:${transport.mediaType};base64,`;
|
||||
if (!transport.dataUrl.startsWith(prefix)) {
|
||||
throw new Error('媒体预览内容无效:data URL 类型与媒体类型不一致');
|
||||
@@ -166,6 +170,8 @@ function materializeProjectResourceCardPreview(
|
||||
path: transport.path,
|
||||
mediaType: transport.mediaType,
|
||||
byteLen: transport.byteLen,
|
||||
pixelWidth: imageDimensions?.pixelWidth,
|
||||
pixelHeight: imageDimensions?.pixelHeight,
|
||||
sourceUrl: objectUrl,
|
||||
},
|
||||
retainedBytes: blob.size,
|
||||
@@ -640,6 +646,31 @@ export function useProjectResourceCardPreviews(input: {
|
||||
requestPreview,
|
||||
]);
|
||||
|
||||
const imageDimensionsByResourceId = useMemo(() => {
|
||||
const result = new Map<
|
||||
string,
|
||||
NonNullable<ReturnType<typeof projectResourceCardPreviewImageDimensions>>
|
||||
>();
|
||||
for (const resource of input.resources) {
|
||||
const identity = identityByResourceId.get(resource.id);
|
||||
const state = identity ? previews.get(identity) : undefined;
|
||||
const kind = projectResourceCardPreviewKind(resource);
|
||||
if (
|
||||
state?.status !== 'loaded' ||
|
||||
(kind !== 'raster-image' && kind !== 'media-image')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const dimensions = projectResourceCardPreviewImageDimensions(
|
||||
state.preview,
|
||||
);
|
||||
if (dimensions) {
|
||||
result.set(resource.id, dimensions);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [identityByResourceId, input.resources, previews]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelLocalProjectResourcePreviewScope(scopeIdRef.current);
|
||||
@@ -695,6 +726,7 @@ export function useProjectResourceCardPreviews(input: {
|
||||
return {
|
||||
identityByResourceId,
|
||||
previews,
|
||||
imageDimensionsByResourceId,
|
||||
idlePreview: IDLE_PROJECT_RESOURCE_CARD_PREVIEW,
|
||||
observePreview,
|
||||
requestPreview,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ProjectResourceCanvasSection } from '../../../packages/shared/src/
|
||||
import {
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
||||
type ResourceCanvasCardSize,
|
||||
} from '../src/view/project-development/resourceCanvasLayoutModel';
|
||||
import {
|
||||
normalizeProjectResourceGraph,
|
||||
@@ -538,6 +539,52 @@ describe('ResourceDependencyOverlay', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('routes references from each resource actual card rectangle', async () => {
|
||||
const graph = graphFixture();
|
||||
const cardSizeByResourceId = new Map<string, ResourceCanvasCardSize>([
|
||||
['source:one', { width: 220, height: 180 }],
|
||||
['target:one', { width: 96, height: 180 }],
|
||||
]);
|
||||
render(
|
||||
React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'resource-dependency-overlay' },
|
||||
React.createElement(
|
||||
'div',
|
||||
{ 'data-resource-section-scroll': 'art' },
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
'data-resource-section-plane': 'art',
|
||||
'data-resource-section-scale': '1',
|
||||
},
|
||||
React.createElement(ResourceDependencyOverlay, {
|
||||
graph,
|
||||
positions: [
|
||||
position('source:one', 0, 0),
|
||||
position('target:one', 268, 0),
|
||||
],
|
||||
section: 'art',
|
||||
visibleResourceIds: new Set(['source:one', 'target:one']),
|
||||
cardSizeByResourceId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const overlay = await screen.findByTestId('resource-dependency-overlay');
|
||||
const reference = await waitFor(() => {
|
||||
const path = overlay.querySelector<SVGPathElement>(
|
||||
'[data-edge-kind="asset-reference"]',
|
||||
);
|
||||
expect(path).not.toBeNull();
|
||||
return path as SVGPathElement;
|
||||
});
|
||||
expect(reference.getAttribute('data-route-axis')).toBe('horizontal');
|
||||
expect(reference.getAttribute('d')).toBe('M 220 90 L 258 90');
|
||||
});
|
||||
|
||||
it('keeps a relationship mounted while scrolling partial cards and marks an offscreen continuation', async () => {
|
||||
const graph = graphFixture();
|
||||
const originalGetBoundingClientRect =
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
createEmptyResourceCanvasLayout,
|
||||
createResourceCanvasCardSizeByResourceId,
|
||||
DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
|
||||
moveResourceCanvasPosition,
|
||||
reconcileResourceCanvasLayout,
|
||||
RESOURCE_CANVAS_CARD_HEIGHT,
|
||||
@@ -13,8 +15,11 @@ import {
|
||||
RESOURCE_CANVAS_COLUMN_GAP,
|
||||
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
||||
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
|
||||
type ResourceCanvasCardSize,
|
||||
resourceCanvasImageCardSize,
|
||||
type ResourceCanvasItem,
|
||||
type ResourceCanvasLayoutTopology,
|
||||
resourceCanvasSectionExtent,
|
||||
} from '../src/view/project-development/resourceCanvasLayoutModel';
|
||||
|
||||
function resource(
|
||||
@@ -582,3 +587,113 @@ describe('resource canvas layout model', () => {
|
||||
expect(performance.now() - startedAt).toBeLessThan(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resource canvas variable card geometry', () => {
|
||||
it.each([
|
||||
[1, 1, { width: 180, height: 180 }],
|
||||
[16, 9, { width: 220, height: 124 }],
|
||||
[9, 16, { width: 101, height: 180 }],
|
||||
[2, 3, { width: 120, height: 180 }],
|
||||
[3, 2, { width: 220, height: 147 }],
|
||||
[100, 1, { width: 220, height: 110 }],
|
||||
[1, 100, { width: 96, height: 180 }],
|
||||
])(
|
||||
'bounds a %d:%d image inside the shared card limits',
|
||||
(pixelWidth, pixelHeight, expected) => {
|
||||
expect(resourceCanvasImageCardSize({ pixelWidth, pixelHeight })).toEqual(
|
||||
expected,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps non-images and images without metadata at the fixed fallback size', () => {
|
||||
const sizes = createResourceCanvasCardSizeByResourceId(
|
||||
[resource('image', 'art'), resource('document', 'document')],
|
||||
new Map([
|
||||
['image', { pixelWidth: 1920, pixelHeight: 1080 }],
|
||||
['document', { pixelWidth: 1920, pixelHeight: 1080 }],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(sizes.get('image')).toEqual({ width: 220, height: 124 });
|
||||
expect(sizes.get('document')).toEqual(DEFAULT_RESOURCE_CANVAS_CARD_SIZE);
|
||||
expect(
|
||||
createResourceCanvasCardSizeByResourceId(
|
||||
[resource('pending-image', 'art')],
|
||||
new Map(),
|
||||
).get('pending-image'),
|
||||
).toEqual(DEFAULT_RESOURCE_CANVAS_CARD_SIZE);
|
||||
});
|
||||
|
||||
it('uses actual widths and heights for type placement collision checks', () => {
|
||||
const resources = [resource('wide-a', 'art'), resource('wide-b', 'art')];
|
||||
const cardSizes = new Map<string, ResourceCanvasCardSize>([
|
||||
['wide-a', { width: 220, height: 180 }],
|
||||
['wide-b', { width: 220, height: 180 }],
|
||||
]);
|
||||
const layout = reconcileResourceCanvasLayout(
|
||||
createEmptyResourceCanvasLayout('variable-type', 'type'),
|
||||
resources,
|
||||
undefined,
|
||||
cardSizes,
|
||||
).layout;
|
||||
|
||||
expect(positionById(layout, 'wide-a')).toMatchObject({ x: 0, y: 0 });
|
||||
expect(positionById(layout, 'wide-b')).toMatchObject({
|
||||
x: 2 * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP),
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses actual dimensions for dependency columns, rows, and section extent', () => {
|
||||
const resources = [
|
||||
resource('source-a', 'art', 0),
|
||||
resource('source-b', 'art', 0),
|
||||
resource('target', 'art', 1),
|
||||
];
|
||||
const cardSizes = new Map<string, ResourceCanvasCardSize>([
|
||||
['source-a', { width: 220, height: 180 }],
|
||||
['source-b', { width: 96, height: 180 }],
|
||||
['target', { width: 180, height: 128 }],
|
||||
]);
|
||||
const layout = reconcileResourceCanvasLayout(
|
||||
createEmptyResourceCanvasLayout('variable-dependency', 'dependency'),
|
||||
resources,
|
||||
undefined,
|
||||
cardSizes,
|
||||
).layout;
|
||||
|
||||
expect(positionById(layout, 'source-a')).toMatchObject({ x: 0, y: 0 });
|
||||
expect(positionById(layout, 'source-b')).toMatchObject({
|
||||
x: 0,
|
||||
y: 180 + RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
|
||||
});
|
||||
expect(positionById(layout, 'target')).toMatchObject({
|
||||
x: 220 + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
||||
y: 0,
|
||||
});
|
||||
expect(resourceCanvasSectionExtent(layout.positions, cardSizes)).toEqual({
|
||||
width: 620,
|
||||
height: 416,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts card sizes directly on resource items for hook integration', () => {
|
||||
const wide = {
|
||||
...resource('wide', 'art', 0),
|
||||
cardSize: { width: 220, height: 180 },
|
||||
};
|
||||
const target = {
|
||||
...resource('target', 'art', 1),
|
||||
cardSize: { width: 180, height: 128 },
|
||||
};
|
||||
const layout = reconcileResourceCanvasLayout(
|
||||
createEmptyResourceCanvasLayout('item-size', 'dependency'),
|
||||
[wide, target],
|
||||
).layout;
|
||||
|
||||
expect(positionById(layout, 'target').x).toBe(
|
||||
wide.cardSize.width + RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,9 @@ function preview(path: string, mediaType = 'image/png') {
|
||||
path,
|
||||
mediaType,
|
||||
byteLen: 4,
|
||||
...(mediaType.startsWith('image/')
|
||||
? { pixelWidth: 640, pixelHeight: 360 }
|
||||
: {}),
|
||||
dataUrl: `data:${mediaType};base64,dGVzdA==`,
|
||||
};
|
||||
}
|
||||
@@ -125,6 +128,10 @@ describe('useProjectResourceCardPreviews', () => {
|
||||
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
|
||||
);
|
||||
expect(previewReadCalls(invoke)).toHaveLength(1);
|
||||
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
|
||||
pixelWidth: 640,
|
||||
pixelHeight: 360,
|
||||
});
|
||||
expect(previewReadCalls(invoke)[0]?.[1]).toMatchObject({
|
||||
relativePath: art.path,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user