Merge branch 'master' into feat/design_agent_simple
This commit is contained in:
+317
-38
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user