支持 UI 编辑器Transform 到 CSS 属性的转换
新增 tf2css 函数及其单元测试
This commit is contained in:
@@ -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],
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user