Merge branch 'master' into feat/design_agent_simple
Project CI / Repository checks (pull_request) Successful in 2m18s
Project CI / Frontend tests (pull_request) Successful in 2m59s
Project CI / Backend tests (pull_request) Successful in 7m37s
Project CI / Native shell tests (pull_request) Successful in 20m49s

This commit is contained in:
2026-09-03 11:42:40 +00:00
4 changed files with 493 additions and 38 deletions
@@ -1,12 +1,16 @@
import {
AlertTriangle,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ChevronDown,
ChevronUp,
Crosshair,
Info,
Move,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import {
pageRectFromSize,
@@ -30,6 +34,7 @@ export type TransformEditorProps = {
};
type Axis = 0 | 1;
type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
type AnchorPreset = {
id: string;
label: string;
@@ -53,6 +58,45 @@ const ROW_MODE_LABELS: Record<AnchorMode, string> = {
const COLUMN_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) =>
COLUMN_MODES.map((x) => ({
id: `${y}-${x}`,
@@ -74,10 +118,6 @@ const FIELD_HINTS = {
'锚点最小值:用父容器的比例位置(0 到 1)定义元素的左上边界。0 表示左侧或顶部,1 表示右侧或底部。',
anchor_max:
'锚点最大值:用父容器的比例位置(0 到 1)定义元素的右下边界。与最小值不同可让元素随父容器拉伸。',
offset_min:
'偏移最小值:相对于最小锚点的像素偏移,控制元素左侧和顶部的位置。',
offset_max:
'偏移最大值:相对于最大锚点的像素偏移,控制元素右侧和底部的位置。',
} as const;
function cloneTransform(transform: Transform): Transform {
@@ -166,9 +206,44 @@ function formatValue(value: number): string {
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({
label,
hint,
hideLabel = false,
stacked = false,
values,
step,
readOnly,
@@ -176,24 +251,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>
);
}
@@ -201,7 +297,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}
@@ -213,12 +309,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>
);
}
@@ -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({
transform,
parentSize,
readOnly = false,
onChange,
}: 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 [customOpen, setCustomOpen] = useState(
() => findPreset(transform).id === CUSTOM_PRESET.id,
@@ -369,6 +552,24 @@ export function TransformEditor({
transform.anchor_min[1] > transform.anchor_max[1] ||
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 (
<section
aria-readonly={readOnly}
@@ -523,22 +724,100 @@ export function TransformEditor({
) : null}
<div className="grid gap-2">
<VectorInputRow
label="偏移最小值"
hint={FIELD_HINTS.offset_min}
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)}
<FieldLabel
label="位置微调"
hint="调整当前选中角的原始 offset 值。"
/>
<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>
</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();
});
});