调整角点选择器垂直对齐
保持左侧角点控件为紧凑正方形 让角点选择器在右侧位置微调控件中垂直居中 完善位置微调控件布局
This commit is contained in:
+156
-101
@@ -73,6 +73,35 @@ const CORNERS: readonly {
|
||||
},
|
||||
];
|
||||
|
||||
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) =>
|
||||
COLUMN_MODES.map((x) => ({
|
||||
id: `${y}-${x}`,
|
||||
@@ -218,6 +247,8 @@ function updateCorner(
|
||||
function VectorInputRow({
|
||||
label,
|
||||
hint,
|
||||
hideLabel = false,
|
||||
stacked = false,
|
||||
values,
|
||||
step,
|
||||
readOnly,
|
||||
@@ -225,24 +256,45 @@ function VectorInputRow({
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
hideLabel?: boolean;
|
||||
stacked?: boolean;
|
||||
values: readonly [number, number];
|
||||
step: number;
|
||||
readOnly: boolean;
|
||||
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 (
|
||||
<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} />
|
||||
{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)}
|
||||
/>
|
||||
))}
|
||||
{hideLabel ? (
|
||||
<span className="sr-only">{label}</span>
|
||||
) : (
|
||||
<FieldLabel label={label} hint={hint} />
|
||||
)}
|
||||
{fields}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -250,7 +302,7 @@ function VectorInputRow({
|
||||
function FieldLabel({ label, hint }: { label: string; hint: string }) {
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
{label}
|
||||
@@ -262,12 +314,6 @@ function FieldLabel({ label, hint }: { label: string; hint: string }) {
|
||||
>
|
||||
<Info size={11} aria-hidden="true" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -425,7 +471,7 @@ function DirectionButton({
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
className="grid size-10 place-items-center rounded-xl border border-emerald-200 bg-emerald-50 text-emerald-700 transition hover:border-emerald-300 hover:bg-emerald-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 disabled:cursor-default disabled:opacity-40"
|
||||
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) => {
|
||||
event.preventDefault();
|
||||
startRepeating(event.shiftKey ? 10 : 1);
|
||||
@@ -673,91 +719,100 @@ export function TransformEditor({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="relative h-28 rounded-2xl border border-(--platform-subpanel-border) bg-white/55">
|
||||
<div className="pointer-events-none absolute inset-7 grid place-items-center rounded-xl border border-dashed border-slate-300 text-[10px] font-semibold tracking-wide text-(--platform-text-soft)">
|
||||
偏移角点
|
||||
</div>
|
||||
{CORNERS.map((corner) => {
|
||||
const active = corner.id === selectedCorner;
|
||||
return (
|
||||
<button
|
||||
key={corner.id}
|
||||
type="button"
|
||||
aria-label={corner.label}
|
||||
aria-pressed={active}
|
||||
disabled={readOnly}
|
||||
className={`absolute ${corner.positionClass} z-10 grid size-12 place-items-center rounded-xl border text-[11px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-300 ${active ? 'border-orange-400 bg-orange-100 text-orange-700 shadow-sm' : 'border-slate-200 bg-white text-slate-500 hover:border-orange-200 hover:bg-orange-50 hover:text-orange-700'}`}
|
||||
onClick={() => {
|
||||
selectedCornerRef.current = corner.id;
|
||||
setSelectedCorner(corner.id);
|
||||
}}
|
||||
>
|
||||
{corner.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<VectorInputRow
|
||||
label={`${selectedCornerLabel}偏移`}
|
||||
hint="当前选中角的原始 offset 值。切换角点后,X/Y 会映射到对应的 offset_min 或 offset_max 分量。"
|
||||
values={selectedCornerValues}
|
||||
step={1}
|
||||
readOnly={readOnly}
|
||||
onCommit={(axis, value) =>
|
||||
onChange(updateCorner(transform, selectedCorner, axis, value))
|
||||
}
|
||||
<div className="grid gap-2">
|
||||
<FieldLabel
|
||||
label="位置微调"
|
||||
hint="调整当前选中角的原始 offset 值。"
|
||||
/>
|
||||
|
||||
<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-10 place-items-center rounded-xl bg-slate-100 text-[10px] font-semibold text-(--platform-text-soft)">
|
||||
{selectedCornerLabel}
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,20 +25,20 @@ describe('TransformEditor corner offset controls', () => {
|
||||
renderEditor();
|
||||
|
||||
expect(
|
||||
screen.getByRole('spinbutton', { name: '左上角偏移 X' }),
|
||||
screen.getByRole('spinbutton', { name: '位置微调 X' }),
|
||||
).toHaveProperty('value', '10');
|
||||
expect(
|
||||
screen.getByRole('spinbutton', { name: '左上角偏移 Y' }),
|
||||
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' }),
|
||||
screen.getByRole('spinbutton', { name: '位置微调 X' }),
|
||||
).toHaveProperty('value', '30');
|
||||
expect(
|
||||
screen.getByRole('spinbutton', { name: '右下角偏移 Y' }),
|
||||
screen.getByRole('spinbutton', { name: '位置微调 Y' }),
|
||||
).toHaveProperty('value', '40');
|
||||
});
|
||||
|
||||
@@ -46,9 +46,7 @@ describe('TransformEditor corner offset controls', () => {
|
||||
const { onChange } = renderEditor();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '右上角' }));
|
||||
const xInput = screen.getByRole('spinbutton', {
|
||||
name: '右上角偏移 X',
|
||||
});
|
||||
const xInput = screen.getByRole('spinbutton', { name: '位置微调 X' });
|
||||
fireEvent.change(xInput, { target: { value: '55' } });
|
||||
fireEvent.blur(xInput);
|
||||
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
四角是现有两个 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]` |
|
||||
| 选中角 | 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` 语义。切换角时读取同一组共享分量,修改后其他受影响角会随数据更新而同步。
|
||||
|
||||
@@ -31,6 +31,20 @@
|
||||
- 数字输入沿用现有行为:失焦或回车提交,Escape 恢复当前值,非法或空值不写回;只读状态禁用所有变更入口。
|
||||
- 锚点预设、自定义锚点、无效矩形 warning、根节点禁止修改等现有行为保持不变。
|
||||
|
||||
## 视觉约束
|
||||
|
||||
- 四角选择器使用 icon-only 的 L 形角标,按左上、右上、左下、右下方向旋转;不在按钮内显示角点文字。
|
||||
- 角点示意图中心不显示说明文字,仅保留淡色矩形和角点高亮。
|
||||
- 输入区不显示“偏移”标签,只保留 X/Y 字段;说明信息按钮 `i` 放在“位置微调”标题旁,完整语义通过 `aria-label` 和 `title` 提供。
|
||||
- 说明只使用浏览器原生 `title` 提示,不再渲染自定义悬浮气泡。
|
||||
- 方向键保持十字布局,使用紧凑的中性 icon-only 按钮;橙色只用于选中态和焦点/悬停强调,避免大面积绿色填充。
|
||||
- 角点选择器和方向键的可点击尺寸不小于 28px。
|
||||
- 主控区采用两列布局:左侧为无分隔线的方形四角选择器,右侧上方为四方向键、下方为唯一一组 X/Y 输入。
|
||||
- 四角选择器不绘制连接线或虚线框,依靠方形容器、角标图标和选中态表达空间关系。
|
||||
- “位置微调”作为右侧控制区的短标题显示在两列之上;两列内容从同一条顶线开始对齐。
|
||||
- 当前角图标只保留在方向键十字中心,数字输入行不再重复显示角点指示器。
|
||||
- 左侧四角选择器保持较小的正方形,并在右侧方向键、纵向 X/Y 输入区的整体高度中垂直居中;X/Y 输入在右侧纵向排列,以避免窄面板中的横向拥挤。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不新增四角持久化字段,不修改 `Transform` schema 或后端接口。
|
||||
|
||||
Reference in New Issue
Block a user