调整角点选择器垂直对齐
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Backend tests (pull_request) Failing after 23s
Project CI / Frontend tests (pull_request) Successful in 3m12s
Project CI / Native shell tests (pull_request) Successful in 18m49s

保持左侧角点控件为紧凑正方形

让角点选择器在右侧位置微调控件中垂直居中

完善位置微调控件布局
This commit is contained in:
2026-09-03 13:11:20 +08:00
parent 29b24695fb
commit be35687537
3 changed files with 181 additions and 114 deletions
@@ -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 或后端接口。