Merge branch 'master' into fix/ui-editor-enexpected-select
This commit is contained in:
+317
-38
@@ -1,12 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
Info,
|
Info,
|
||||||
Move,
|
Move,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
pageRectFromSize,
|
pageRectFromSize,
|
||||||
@@ -30,6 +34,7 @@ export type TransformEditorProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type Axis = 0 | 1;
|
type Axis = 0 | 1;
|
||||||
|
type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||||
type AnchorPreset = {
|
type AnchorPreset = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -53,6 +58,45 @@ const ROW_MODE_LABELS: Record<AnchorMode, string> = {
|
|||||||
const COLUMN_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch'];
|
const COLUMN_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch'];
|
||||||
const ROW_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch'];
|
const ROW_MODES: AnchorMode[] = ['start', 'center', 'end', 'stretch'];
|
||||||
|
|
||||||
|
const CORNERS: readonly {
|
||||||
|
id: Corner;
|
||||||
|
label: string;
|
||||||
|
}[] = [
|
||||||
|
{ id: 'top-left', label: '左上角' },
|
||||||
|
{ id: 'top-right', label: '右上角' },
|
||||||
|
{ id: 'bottom-left', label: '左下角' },
|
||||||
|
{ id: 'bottom-right', label: '右下角' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function CornerIcon({
|
||||||
|
corner,
|
||||||
|
active = false,
|
||||||
|
}: {
|
||||||
|
corner: Corner;
|
||||||
|
active?: boolean;
|
||||||
|
}) {
|
||||||
|
const paths: Record<Corner, string> = {
|
||||||
|
'top-left': 'M5 11V5h6',
|
||||||
|
'top-right': 'M13 11V5H7',
|
||||||
|
'bottom-left': 'M5 9v6h6',
|
||||||
|
'bottom-right': 'M13 9v6H7',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
viewBox="0 0 18 20"
|
||||||
|
className={`size-5 ${active ? 'text-(--platform-accent)' : 'text-(--platform-text-soft)'}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={active ? 2.2 : 1.8}
|
||||||
|
>
|
||||||
|
<path d={paths[corner]} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const PRESETS: AnchorPreset[] = ROW_MODES.flatMap((y) =>
|
const PRESETS: AnchorPreset[] = ROW_MODES.flatMap((y) =>
|
||||||
COLUMN_MODES.map((x) => ({
|
COLUMN_MODES.map((x) => ({
|
||||||
id: `${y}-${x}`,
|
id: `${y}-${x}`,
|
||||||
@@ -74,10 +118,6 @@ const FIELD_HINTS = {
|
|||||||
'锚点最小值:用父容器的比例位置(0 到 1)定义元素的左上边界。0 表示左侧或顶部,1 表示右侧或底部。',
|
'锚点最小值:用父容器的比例位置(0 到 1)定义元素的左上边界。0 表示左侧或顶部,1 表示右侧或底部。',
|
||||||
anchor_max:
|
anchor_max:
|
||||||
'锚点最大值:用父容器的比例位置(0 到 1)定义元素的右下边界。与最小值不同可让元素随父容器拉伸。',
|
'锚点最大值:用父容器的比例位置(0 到 1)定义元素的右下边界。与最小值不同可让元素随父容器拉伸。',
|
||||||
offset_min:
|
|
||||||
'偏移最小值:相对于最小锚点的像素偏移,控制元素左侧和顶部的位置。',
|
|
||||||
offset_max:
|
|
||||||
'偏移最大值:相对于最大锚点的像素偏移,控制元素右侧和底部的位置。',
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function cloneTransform(transform: Transform): Transform {
|
function cloneTransform(transform: Transform): Transform {
|
||||||
@@ -166,9 +206,44 @@ function formatValue(value: number): string {
|
|||||||
return String(Number(value.toFixed(2)));
|
return String(Number(value.toFixed(2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cornerFields(corner: Corner): {
|
||||||
|
x: 'offset_min' | 'offset_max';
|
||||||
|
y: 'offset_min' | 'offset_max';
|
||||||
|
} {
|
||||||
|
switch (corner) {
|
||||||
|
case 'top-left':
|
||||||
|
return { x: 'offset_min', y: 'offset_min' };
|
||||||
|
case 'top-right':
|
||||||
|
return { x: 'offset_max', y: 'offset_min' };
|
||||||
|
case 'bottom-left':
|
||||||
|
return { x: 'offset_min', y: 'offset_max' };
|
||||||
|
case 'bottom-right':
|
||||||
|
return { x: 'offset_max', y: 'offset_max' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cornerValues(transform: Transform, corner: Corner): [number, number] {
|
||||||
|
const fields = cornerFields(corner);
|
||||||
|
return [transform[fields.x][0], transform[fields.y][1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCorner(
|
||||||
|
transform: Transform,
|
||||||
|
corner: Corner,
|
||||||
|
axis: Axis,
|
||||||
|
value: number,
|
||||||
|
): Transform {
|
||||||
|
const fields = cornerFields(corner);
|
||||||
|
const next = cloneTransform(transform);
|
||||||
|
next[axis === 0 ? fields.x : fields.y][axis] = value;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
function VectorInputRow({
|
function VectorInputRow({
|
||||||
label,
|
label,
|
||||||
hint,
|
hint,
|
||||||
|
hideLabel = false,
|
||||||
|
stacked = false,
|
||||||
values,
|
values,
|
||||||
step,
|
step,
|
||||||
readOnly,
|
readOnly,
|
||||||
@@ -176,24 +251,45 @@ function VectorInputRow({
|
|||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
hint: string;
|
hint: string;
|
||||||
|
hideLabel?: boolean;
|
||||||
|
stacked?: boolean;
|
||||||
values: readonly [number, number];
|
values: readonly [number, number];
|
||||||
step: number;
|
step: number;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
onCommit: (axis: Axis, value: number) => void;
|
onCommit: (axis: Axis, value: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const fields = AXIS_LABELS.map((axisLabel, axis) => (
|
||||||
|
<ScalarInput
|
||||||
|
key={axisLabel}
|
||||||
|
ariaLabel={`${label} ${axisLabel}`}
|
||||||
|
value={values[axis as Axis]}
|
||||||
|
step={step}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onCommit={(value) => onCommit(axis as Axis, value)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
if (stacked) {
|
||||||
|
return (
|
||||||
|
<div className="grid min-w-0 gap-2">
|
||||||
|
{hideLabel ? (
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
) : (
|
||||||
|
<FieldLabel label={label} hint={hint} />
|
||||||
|
)}
|
||||||
|
<div className="grid min-w-0 gap-2">{fields}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-w-0 grid-cols-[minmax(0,5rem)_minmax(0,1fr)_minmax(0,1fr)] items-center gap-2">
|
<div className="grid min-w-0 grid-cols-[minmax(0,5rem)_minmax(0,1fr)_minmax(0,1fr)] items-center gap-2">
|
||||||
<FieldLabel label={label} hint={hint} />
|
{hideLabel ? (
|
||||||
{AXIS_LABELS.map((axisLabel, axis) => (
|
<span className="sr-only">{label}</span>
|
||||||
<ScalarInput
|
) : (
|
||||||
key={axisLabel}
|
<FieldLabel label={label} hint={hint} />
|
||||||
ariaLabel={`${label} ${axisLabel}`}
|
)}
|
||||||
value={values[axis as Axis]}
|
{fields}
|
||||||
step={step}
|
|
||||||
readOnly={readOnly}
|
|
||||||
onCommit={(value) => onCommit(axis as Axis, value)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -201,7 +297,7 @@ function VectorInputRow({
|
|||||||
function FieldLabel({ label, hint }: { label: string; hint: string }) {
|
function FieldLabel({ label, hint }: { label: string; hint: string }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="group/field relative inline-flex items-center gap-1 text-[11px] font-semibold tracking-wide text-(--platform-text-soft)"
|
className="inline-flex items-center gap-1 text-[11px] font-semibold tracking-wide text-(--platform-text-soft)"
|
||||||
title={hint}
|
title={hint}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -213,12 +309,6 @@ function FieldLabel({ label, hint }: { label: string; hint: string }) {
|
|||||||
>
|
>
|
||||||
<Info size={11} aria-hidden="true" />
|
<Info size={11} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<span
|
|
||||||
role="tooltip"
|
|
||||||
className="pointer-events-none absolute bottom-[calc(100%+0.4rem)] left-0 z-40 hidden w-64 rounded-lg border border-(--platform-subpanel-border) bg-(--platform-neutral-bg) px-2.5 py-2 text-[10px] font-normal leading-relaxed text-(--platform-neutral-text) shadow-lg group-hover/field:block group-focus-within/field:block"
|
|
||||||
>
|
|
||||||
{hint}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -320,12 +410,105 @@ function ScalarInput({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DirectionButton({
|
||||||
|
ariaLabel,
|
||||||
|
disabled,
|
||||||
|
onAdjust,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
ariaLabel: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onAdjust: (step: number) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const repeatTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const repeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const repeated = useRef(false);
|
||||||
|
|
||||||
|
const stopRepeating = () => {
|
||||||
|
if (repeatTimeout.current) {
|
||||||
|
clearTimeout(repeatTimeout.current);
|
||||||
|
repeatTimeout.current = null;
|
||||||
|
}
|
||||||
|
if (repeatInterval.current) {
|
||||||
|
clearInterval(repeatInterval.current);
|
||||||
|
repeatInterval.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRepeating = (multiplier: number) => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
repeated.current = false;
|
||||||
|
repeatTimeout.current = setTimeout(() => {
|
||||||
|
repeated.current = true;
|
||||||
|
onAdjust(multiplier);
|
||||||
|
repeatInterval.current = setInterval(() => onAdjust(multiplier), 70);
|
||||||
|
}, 350);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (repeatTimeout.current) {
|
||||||
|
clearTimeout(repeatTimeout.current);
|
||||||
|
repeatTimeout.current = null;
|
||||||
|
}
|
||||||
|
if (repeatInterval.current) {
|
||||||
|
clearInterval(repeatInterval.current);
|
||||||
|
repeatInterval.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
disabled={disabled}
|
||||||
|
className="grid size-8 place-items-center rounded-lg border border-(--platform-subpanel-border) bg-white/70 text-(--platform-text-soft) transition hover:border-orange-300 hover:bg-orange-50 hover:text-orange-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-200 disabled:cursor-default disabled:opacity-40"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
startRepeating(event.shiftKey ? 10 : 1);
|
||||||
|
}}
|
||||||
|
onPointerUp={stopRepeating}
|
||||||
|
onPointerCancel={() => {
|
||||||
|
repeated.current = false;
|
||||||
|
stopRepeating();
|
||||||
|
}}
|
||||||
|
onPointerLeave={() => {
|
||||||
|
repeated.current = false;
|
||||||
|
stopRepeating();
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!repeated.current) {
|
||||||
|
onAdjust(event.shiftKey ? 10 : 1);
|
||||||
|
}
|
||||||
|
repeated.current = false;
|
||||||
|
stopRepeating();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TransformEditor({
|
export function TransformEditor({
|
||||||
transform,
|
transform,
|
||||||
parentSize,
|
parentSize,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
onChange,
|
onChange,
|
||||||
}: TransformEditorProps) {
|
}: TransformEditorProps) {
|
||||||
|
const [selectedCorner, setSelectedCorner] = useState<Corner>('top-left');
|
||||||
|
const transformRef = useRef(transform);
|
||||||
|
const selectedCornerRef = useRef(selectedCorner);
|
||||||
|
useEffect(() => {
|
||||||
|
transformRef.current = transform;
|
||||||
|
selectedCornerRef.current = selectedCorner;
|
||||||
|
}, [transform, selectedCorner]);
|
||||||
const [presetOpen, setPresetOpen] = useState(false);
|
const [presetOpen, setPresetOpen] = useState(false);
|
||||||
const [customOpen, setCustomOpen] = useState(
|
const [customOpen, setCustomOpen] = useState(
|
||||||
() => findPreset(transform).id === CUSTOM_PRESET.id,
|
() => findPreset(transform).id === CUSTOM_PRESET.id,
|
||||||
@@ -369,6 +552,24 @@ export function TransformEditor({
|
|||||||
transform.anchor_min[1] > transform.anchor_max[1] ||
|
transform.anchor_min[1] > transform.anchor_max[1] ||
|
||||||
geometry?.invalid;
|
geometry?.invalid;
|
||||||
|
|
||||||
|
const selectedCornerLabel =
|
||||||
|
CORNERS.find((corner) => corner.id === selectedCorner)?.label ?? '左上角';
|
||||||
|
const selectedCornerValues = cornerValues(transform, selectedCorner);
|
||||||
|
const adjustSelectedCorner = (
|
||||||
|
axis: Axis,
|
||||||
|
direction: -1 | 1,
|
||||||
|
multiplier = 1,
|
||||||
|
) => {
|
||||||
|
if (readOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentTransform = transformRef.current;
|
||||||
|
const currentCorner = selectedCornerRef.current;
|
||||||
|
const current = cornerValues(currentTransform, currentCorner)[axis];
|
||||||
|
const next = Number((current + direction * multiplier).toFixed(4));
|
||||||
|
onChange(updateCorner(currentTransform, currentCorner, axis, next));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-readonly={readOnly}
|
aria-readonly={readOnly}
|
||||||
@@ -523,22 +724,100 @@ export function TransformEditor({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<VectorInputRow
|
<FieldLabel
|
||||||
label="偏移最小值"
|
label="位置微调"
|
||||||
hint={FIELD_HINTS.offset_min}
|
hint="调整当前选中角的原始 offset 值。"
|
||||||
values={transform.offset_min}
|
|
||||||
step={1}
|
|
||||||
readOnly={readOnly}
|
|
||||||
onCommit={(axis, value) => updateVector('offset_min', axis, value)}
|
|
||||||
/>
|
|
||||||
<VectorInputRow
|
|
||||||
label="偏移最大值"
|
|
||||||
hint={FIELD_HINTS.offset_max}
|
|
||||||
values={transform.offset_max}
|
|
||||||
step={1}
|
|
||||||
readOnly={readOnly}
|
|
||||||
onCommit={(axis, value) => updateVector('offset_max', axis, value)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[minmax(7rem,8rem)_minmax(0,1fr)] items-center gap-4">
|
||||||
|
<div className="grid aspect-square w-full grid-cols-2 grid-rows-2 gap-1.5 justify-self-center rounded-2xl border border-(--platform-subpanel-border) bg-white/45 p-1.5">
|
||||||
|
{CORNERS.map((corner) => {
|
||||||
|
const active = corner.id === selectedCorner;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={corner.id}
|
||||||
|
type="button"
|
||||||
|
aria-label={corner.label}
|
||||||
|
aria-pressed={active}
|
||||||
|
disabled={readOnly}
|
||||||
|
title={corner.label}
|
||||||
|
className={`grid min-h-10 min-w-10 place-items-center rounded-xl border transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-300 ${active ? 'border-(--platform-accent) bg-(--platform-warm-bg)' : 'border-transparent hover:border-(--platform-accent) hover:bg-(--platform-warm-bg)'}`}
|
||||||
|
onClick={() => {
|
||||||
|
selectedCornerRef.current = corner.id;
|
||||||
|
setSelectedCorner(corner.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CornerIcon corner={corner.id} active={active} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid min-w-0 gap-3">
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
className="mx-auto grid grid-cols-3 grid-rows-3 gap-1.5"
|
||||||
|
aria-label="角点方向键"
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<DirectionButton
|
||||||
|
ariaLabel={`${selectedCornerLabel}向上`}
|
||||||
|
disabled={readOnly}
|
||||||
|
onAdjust={(multiplier) =>
|
||||||
|
adjustSelectedCorner(1, -1, multiplier * 1)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowUp size={17} aria-hidden="true" />
|
||||||
|
</DirectionButton>
|
||||||
|
<span />
|
||||||
|
<DirectionButton
|
||||||
|
ariaLabel={`${selectedCornerLabel}向左`}
|
||||||
|
disabled={readOnly}
|
||||||
|
onAdjust={(multiplier) =>
|
||||||
|
adjustSelectedCorner(0, -1, multiplier * 1)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={17} aria-hidden="true" />
|
||||||
|
</DirectionButton>
|
||||||
|
<div className="grid size-8 place-items-center rounded-lg bg-slate-100 text-(--platform-text-soft)">
|
||||||
|
<CornerIcon corner={selectedCorner} />
|
||||||
|
</div>
|
||||||
|
<DirectionButton
|
||||||
|
ariaLabel={`${selectedCornerLabel}向右`}
|
||||||
|
disabled={readOnly}
|
||||||
|
onAdjust={(multiplier) =>
|
||||||
|
adjustSelectedCorner(0, 1, multiplier * 1)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowRight size={17} aria-hidden="true" />
|
||||||
|
</DirectionButton>
|
||||||
|
<span />
|
||||||
|
<DirectionButton
|
||||||
|
ariaLabel={`${selectedCornerLabel}向下`}
|
||||||
|
disabled={readOnly}
|
||||||
|
onAdjust={(multiplier) =>
|
||||||
|
adjustSelectedCorner(1, 1, multiplier * 1)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowDown size={17} aria-hidden="true" />
|
||||||
|
</DirectionButton>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VectorInputRow
|
||||||
|
label="位置微调"
|
||||||
|
hideLabel
|
||||||
|
hint="当前选中角的原始 offset 值。切换角点后,X/Y 会映射到对应的 offset_min 或 offset_max 分量。"
|
||||||
|
values={selectedCornerValues}
|
||||||
|
step={1}
|
||||||
|
readOnly={readOnly}
|
||||||
|
stacked
|
||||||
|
onCommit={(axis, value) =>
|
||||||
|
onChange(updateCorner(transform, selectedCorner, axis, value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { Transform } from '../src/features/ui-editor/types/Transform';
|
||||||
|
import { TransformEditor } from '../src/view/ui-editor/components/Inspector/Transform/TransformEditor';
|
||||||
|
|
||||||
|
const transform: Transform = {
|
||||||
|
anchor_min: [0, 0],
|
||||||
|
anchor_max: [1, 1],
|
||||||
|
offset_min: [10, 20],
|
||||||
|
offset_max: [30, 40],
|
||||||
|
};
|
||||||
|
|
||||||
|
function firePointerDown(element: HTMLElement, button: number) {
|
||||||
|
const event = new Event('pointerdown', { bubbles: true });
|
||||||
|
Object.defineProperty(event, 'button', { value: button });
|
||||||
|
fireEvent(element, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditor(onChange = vi.fn()) {
|
||||||
|
return {
|
||||||
|
onChange,
|
||||||
|
...render(<TransformEditor transform={transform} onChange={onChange} />),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TransformEditor corner offset controls', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses one X/Y input pair for the selected corner', () => {
|
||||||
|
renderEditor();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('spinbutton', { name: '位置微调 X' }),
|
||||||
|
).toHaveProperty('value', '10');
|
||||||
|
expect(
|
||||||
|
screen.getByRole('spinbutton', { name: '位置微调 Y' }),
|
||||||
|
).toHaveProperty('value', '20');
|
||||||
|
expect(screen.getAllByRole('spinbutton')).toHaveLength(2);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '右下角' }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('spinbutton', { name: '位置微调 X' }),
|
||||||
|
).toHaveProperty('value', '30');
|
||||||
|
expect(
|
||||||
|
screen.getByRole('spinbutton', { name: '位置微调 Y' }),
|
||||||
|
).toHaveProperty('value', '40');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the selected corner input back to the matching offset component', () => {
|
||||||
|
const { onChange } = renderEditor();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '右上角' }));
|
||||||
|
const xInput = screen.getByRole('spinbutton', { name: '位置微调 X' });
|
||||||
|
fireEvent.change(xInput, { target: { value: '55' } });
|
||||||
|
fireEvent.blur(xInput);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({
|
||||||
|
...transform,
|
||||||
|
offset_max: [55, 40],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves only the selected corner with direction keys', () => {
|
||||||
|
const { onChange } = renderEditor();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '右下角' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '右下角向左' }));
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({
|
||||||
|
...transform,
|
||||||
|
offset_max: [29, 40],
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '右下角向上' }), {
|
||||||
|
shiftKey: true,
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({
|
||||||
|
...transform,
|
||||||
|
offset_max: [30, 30],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a click adjustment after a cancelled long press', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { onChange } = renderEditor();
|
||||||
|
const button = screen.getByRole('button', { name: '左上角向右' });
|
||||||
|
|
||||||
|
firePointerDown(button, 0);
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
fireEvent.pointerCancel(button);
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not start repeating for non-primary pointer buttons', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { onChange } = renderEditor();
|
||||||
|
const button = screen.getByRole('button', { name: '左上角向右' });
|
||||||
|
|
||||||
|
firePointerDown(button, 2);
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
- [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md)
|
- [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md)
|
||||||
- [UI 工作流资源桥接与 Runtime 执行](./【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)
|
- [UI 工作流资源桥接与 Runtime 执行](./【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)
|
||||||
- [UI 编辑器 Godot 容器布局](./technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md)
|
- [UI 编辑器 Godot 容器布局](./technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md)
|
||||||
|
- [UI 编辑器变换角点偏移编辑器](./technical/【设计】UI编辑器变换角点偏移编辑器-2026-09-03.md)
|
||||||
- [UI 编辑器子节点显示规则](./technical/【技术方案】UI编辑器子节点显示规则-2026-08-18.md)
|
- [UI 编辑器子节点显示规则](./technical/【技术方案】UI编辑器子节点显示规则-2026-08-18.md)
|
||||||
- [UI 编辑会话模块边界](./technical/【前端架构】UI编辑会话模块边界-2026-08-19.md)
|
- [UI 编辑会话模块边界](./technical/【前端架构】UI编辑会话模块边界-2026-08-19.md)
|
||||||
- [UI 编辑器撤销重做规范](./【UI编辑器】撤销重做规范-2026-09-03.md)
|
- [UI 编辑器撤销重做规范](./【UI编辑器】撤销重做规范-2026-09-03.md)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# UI 编辑器变换角点偏移编辑器
|
||||||
|
|
||||||
|
更新时间:`2026-09-03`
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
将 UI 编辑器 Inspector 中的 offset 编辑从两行 `offset_min/offset_max` 改为四角选择式编辑:界面展示左上、右上、左下、右下四个角,用户先选择一个角,再使用唯一的一组 X/Y 数字输入和四个方向键调整该角。
|
||||||
|
|
||||||
|
本次只改变编辑器表现和交互,不改变 `Transform` 数据结构、持久化格式或后端契约。
|
||||||
|
|
||||||
|
## 数据映射
|
||||||
|
|
||||||
|
四角是现有两个 offset 向量的投影,不是四组独立数据:
|
||||||
|
|
||||||
|
| 选中角 | X 输入写入 | Y 输入写入 |
|
||||||
|
| ------ | --------------- | --------------- |
|
||||||
|
| 左上 | `offset_min[0]` | `offset_min[1]` |
|
||||||
|
| 右上 | `offset_max[0]` | `offset_min[1]` |
|
||||||
|
| 左下 | `offset_min[0]` | `offset_max[1]` |
|
||||||
|
| 右下 | `offset_max[0]` | `offset_max[1]` |
|
||||||
|
|
||||||
|
输入框始终显示对应 `Transform` 的原始 offset 值,保留现有正负号;不转换成 CSS `right`/`bottom` 语义。切换角时读取同一组共享分量,修改后其他受影响角会随数据更新而同步。
|
||||||
|
|
||||||
|
## 交互
|
||||||
|
|
||||||
|
- 组件内部维护当前选中角,首次挂载时默认左上;切换角点由用户控制,编辑过程中不会因 offset 更新而跳回其他角。
|
||||||
|
- 四角按钮按左上、右上、左下、右下空间位置排布;当前角高亮并提供可访问名称。
|
||||||
|
- 页面只渲染一组 X/Y 数字输入和一组上、下、左、右方向键,二者都只作用于当前选中角。
|
||||||
|
- 方向键每次按 `1` 调整对应分量;按住支持连续触发,按住 `Shift` 时步长为 `10`。
|
||||||
|
- 方向键长按只响应主指针;指针取消或离开时清理长按抑制状态,正常释放仍抑制尾随点击。
|
||||||
|
- 方向键映射:左/右调整选中角的 X, 上/下调整选中角的 Y。具体写入字段遵循上表,因此移动角点可能改变矩形尺寸。
|
||||||
|
- 数字输入沿用现有行为:失焦或回车提交,Escape 恢复当前值,非法或空值不写回;只读状态禁用所有变更入口。
|
||||||
|
- 锚点预设、自定义锚点、无效矩形 warning、根节点禁止修改等现有行为保持不变。
|
||||||
|
|
||||||
|
## 视觉约束
|
||||||
|
|
||||||
|
- 四角选择器使用 icon-only 的 L 形角标,按左上、右上、左下、右下方向旋转;不在按钮内显示角点文字。
|
||||||
|
- 角点示意图中心不显示说明文字,仅保留淡色矩形和角点高亮。
|
||||||
|
- 输入区不显示“偏移”标签,只保留 X/Y 字段;说明信息按钮 `i` 放在“位置微调”标题旁,完整语义通过 `aria-label` 和 `title` 提供。
|
||||||
|
- 说明只使用浏览器原生 `title` 提示,不再渲染自定义悬浮气泡。
|
||||||
|
- 方向键保持十字布局,使用紧凑的中性 icon-only 按钮;橙色只用于选中态和焦点/悬停强调,避免大面积绿色填充。
|
||||||
|
- 角点选择器和方向键的可点击尺寸不小于 28px。
|
||||||
|
- 主控区采用两列布局:左侧为无分隔线的方形四角选择器,右侧上方为四方向键、下方为唯一一组 X/Y 输入。
|
||||||
|
- 四角选择器不绘制连接线或虚线框,依靠方形容器、角标图标和选中态表达空间关系。
|
||||||
|
- “位置微调”作为右侧控制区的短标题显示在两列之上;两列内容从同一条顶线开始对齐。
|
||||||
|
- 当前角图标只保留在方向键十字中心,数字输入行不再重复显示角点指示器。
|
||||||
|
- 左侧四角选择器保持较小的正方形,并在右侧方向键、纵向 X/Y 输入区的整体高度中垂直居中;X/Y 输入在右侧纵向排列,以避免窄面板中的横向拥挤。
|
||||||
|
|
||||||
|
## 非目标
|
||||||
|
|
||||||
|
- 不新增四角持久化字段,不修改 `Transform` schema 或后端接口。
|
||||||
|
- 不提供四角独立状态,也不提供整体平移快捷键。
|
||||||
|
- 不增加移动端专属布局或移动端交互。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
1. 四角选择能正确驱动唯一一组 X/Y 输入,并按数据映射表写回 `offset_min/offset_max`。
|
||||||
|
2. 四方向键只修改当前选中角对应分量,步长、Shift 加速、长按行为可用。
|
||||||
|
3. 输入提交、Escape 回滚、非法值、只读状态和现有 warning 行为不回归。
|
||||||
|
4. `Transform` 类型、持久化数据和现有锚点编辑行为保持不变。
|
||||||
Reference in New Issue
Block a user