支持 UI 编辑器Transform 到 CSS 属性的转换

新增 tf2css 函数及其单元测试
This commit is contained in:
2026-08-11 19:01:11 +08:00
parent 8f6573cff0
commit b426af27a3
2 changed files with 252 additions and 0 deletions
@@ -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<readonly [keyof Transform, 0 | 1]>;
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',
),
);
});
});
@@ -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<TransformVectorField>;
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],
),
};
}