From b426af27a346589b695ebd303d7205e47f73ac72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 11 Aug 2026 19:01:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=20UI=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8Transform=20=E5=88=B0=20CSS=20=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E7=9A=84=E8=BD=AC=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 tf2css 函数及其单元测试 --- .../ui-editor/transform-utils/tf2css.test.tsx | 160 ++++++++++++++++++ .../ui-editor/transform-utils/tf2css.ts | 92 ++++++++++ 2 files changed, 252 insertions(+) create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/transform-utils/tf2css.test.tsx create mode 100644 apps/ai-game-creator-shell/src/features/ui-editor/transform-utils/tf2css.ts diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/transform-utils/tf2css.test.tsx b/apps/ai-game-creator-shell/src/features/ui-editor/transform-utils/tf2css.test.tsx new file mode 100644 index 000000000..9cd239099 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/transform-utils/tf2css.test.tsx @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; + +import type { Transform } from '../types'; +import { tf2css } from './tf2css'; + +const STRETCH_TRANSFORM: Transform = { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], +}; + +const TRANSFORM_SLOTS = [ + ['anchor_min', 0], + ['anchor_min', 1], + ['anchor_max', 0], + ['anchor_max', 1], + ['offset_min', 0], + ['offset_min', 1], + ['offset_max', 0], + ['offset_max', 1], +] as const satisfies ReadonlyArray; + +function withSlot( + field: keyof Transform, + index: 0 | 1, + value: number, +): Transform { + const transform: Transform = { + anchor_min: [...STRETCH_TRANSFORM.anchor_min], + anchor_max: [...STRETCH_TRANSFORM.anchor_max], + offset_min: [...STRETCH_TRANSFORM.offset_min], + offset_max: [...STRETCH_TRANSFORM.offset_max], + }; + transform[field][index] = value; + return transform; +} + +describe('tf2css', () => { + it('maps a fixed center anchor to four responsive CSS edges', () => { + expect( + tf2css({ + anchor_min: [0.5, 0.5], + anchor_max: [0.5, 0.5], + offset_min: [-50, -20], + offset_max: [50, 20], + }), + ).toEqual({ + position: 'absolute', + left: 'calc(50% - 50px)', + top: 'calc(50% - 20px)', + right: 'calc(50% - 50px)', + bottom: 'calc(50% - 20px)', + }); + }); + + it('maps stretch offsets to CSS inset margins', () => { + expect( + tf2css({ + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [12, 8], + offset_max: [-16, -24], + }), + ).toEqual({ + position: 'absolute', + left: '12px', + top: '8px', + right: '16px', + bottom: '24px', + }); + }); + + it('maps split anchors without requiring the parent pixel size', () => { + expect( + tf2css({ + anchor_min: [0.1, 0.2], + anchor_max: [0.9, 0.8], + offset_min: [10, 25], + offset_max: [-10, -15], + }), + ).toEqual({ + position: 'absolute', + left: 'calc(10% + 10px)', + top: 'calc(20% + 25px)', + right: 'calc(10% + 10px)', + bottom: 'calc(20% + 15px)', + }); + }); + + it('preserves full JavaScript precision for fractional anchors', () => { + expect( + tf2css({ + anchor_min: [1 / 3, 1 / 3], + anchor_max: [2 / 3, 2 / 3], + offset_min: [0, 0], + offset_max: [0, 0], + }), + ).toEqual({ + position: 'absolute', + left: '33.33333333333333%', + top: '33.33333333333333%', + right: '33.33333333333334%', + bottom: '33.33333333333334%', + }); + }); + + it('preserves out-of-range anchors and normalizes negative zero', () => { + expect( + tf2css({ + anchor_min: [-0.25, 1.25], + anchor_max: [1.5, -0.5], + offset_min: [-0, -0], + offset_max: [-0, -0], + }), + ).toEqual({ + position: 'absolute', + left: '-25%', + top: '125%', + right: '-50%', + bottom: '150%', + }); + + expect(tf2css(STRETCH_TRANSFORM)).toEqual({ + position: 'absolute', + left: '0px', + top: '0px', + right: '0px', + bottom: '0px', + }); + }); + + it.each(TRANSFORM_SLOTS)( + 'identifies a non-finite %s[%i] value', + (field, index) => { + expect(() => tf2css(withSlot(field, index, Number.NaN))).toThrowError( + new RangeError(`Transform.${field}[${index}] must be finite`), + ); + }, + ); + + it.each([Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'rejects the non-finite value %s', + (value) => { + expect(() => tf2css(withSlot('offset_max', 1, value))).toThrowError( + new RangeError('Transform.offset_max[1] must be finite'), + ); + }, + ); + + it('rejects finite anchors whose CSS percentage would overflow', () => { + expect(() => + tf2css(withSlot('anchor_min', 0, Number.MAX_VALUE)), + ).toThrowError( + new RangeError( + 'Transform.anchor_min[0] cannot be represented as a CSS percentage', + ), + ); + }); +}); diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/transform-utils/tf2css.ts b/apps/ai-game-creator-shell/src/features/ui-editor/transform-utils/tf2css.ts new file mode 100644 index 000000000..c3dfe4cef --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/transform-utils/tf2css.ts @@ -0,0 +1,92 @@ +import type { CSSProperties } from 'react'; + +import type { Transform } from '../types'; + +type TransformVectorField = keyof Transform; + +const TRANSFORM_VECTOR_FIELDS = [ + 'anchor_min', + 'anchor_max', + 'offset_min', + 'offset_max', +] as const satisfies ReadonlyArray; + +function normalizeZero(value: number): number { + return Object.is(value, -0) ? 0 : value; +} + +function assertFiniteTransform(transform: Transform): void { + for (const field of TRANSFORM_VECTOR_FIELDS) { + for (const index of [0, 1] as const) { + if (!Number.isFinite(transform[field][index])) { + throw new RangeError(`Transform.${field}[${index}] must be finite`); + } + } + } +} + +function toPercentage(value: number, fieldPath: string): number { + const percentage = normalizeZero(value * 100); + if (!Number.isFinite(percentage)) { + throw new RangeError( + `${fieldPath} cannot be represented as a CSS percentage`, + ); + } + return percentage; +} + +function toRemainingPercentage(value: number, fieldPath: string): number { + const percentage = normalizeZero(100 - value * 100); + if (!Number.isFinite(percentage)) { + throw new RangeError( + `${fieldPath} cannot be represented as a CSS percentage`, + ); + } + return percentage; +} + +function toCssLength(percentage: number, pixelOffset: number): string { + const normalizedPercentage = normalizeZero(percentage); + const normalizedOffset = normalizeZero(pixelOffset); + + if (normalizedPercentage === 0) { + return `${normalizedOffset}px`; + } + if (normalizedOffset === 0) { + return `${normalizedPercentage}%`; + } + + const operator = normalizedOffset < 0 ? '-' : '+'; + return `calc(${normalizedPercentage}% ${operator} ${Math.abs(normalizedOffset)}px)`; +} + +/** + * Converts the UI editor's Control-like anchors and edge offsets to CSS. + * + * The containing element must establish the absolute-positioning containing + * block. The resulting geometry matches `Transform::resolve` while the + * resolved width and height are non-negative for that containing block. + */ +export function tf2css(transform: Transform): CSSProperties { + assertFiniteTransform(transform); + + return { + position: 'absolute', + left: toCssLength( + toPercentage(transform.anchor_min[0], 'Transform.anchor_min[0]'), + transform.offset_min[0], + ), + top: toCssLength( + toPercentage(transform.anchor_min[1], 'Transform.anchor_min[1]'), + transform.offset_min[1], + ), + right: toCssLength( + toRemainingPercentage(transform.anchor_max[0], 'Transform.anchor_max[0]'), + -transform.offset_max[0], + ), + bottom: toCssLength( + toRemainingPercentage(transform.anchor_max[1], 'Transform.anchor_max[1]'), + -transform.offset_max[1], + ), + }; +}