重做变换角点偏移编辑器

新增四角选择状态与单组 X/Y 偏移输入

让方向键只调整当前选中角并支持 Shift 加速与长按
This commit is contained in:
2026-09-03 12:02:11 +08:00
parent c3a4dce3af
commit 7aaefbf6c1
@@ -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,21 @@ 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;
positionClass: string;
}[] = [
{ id: 'top-left', label: '左上角', positionClass: 'left-1 top-1' },
{ id: 'top-right', label: '右上角', positionClass: 'right-1 top-1' },
{ id: 'bottom-left', label: '左下角', positionClass: 'bottom-1 left-1' },
{
id: 'bottom-right',
label: '右下角',
positionClass: 'bottom-1 right-1',
},
];
const PRESETS: AnchorPreset[] = ROW_MODES.flatMap((y) =>
COLUMN_MODES.map((x) => ({
id: `${y}-${x}`,
@@ -74,10 +94,6 @@ const FIELD_HINTS = {
'锚点最小值:用父容器的比例位置(0 到 1)定义元素的左上边界。0 表示左侧或顶部,1 表示右侧或底部。',
anchor_max:
'锚点最大值:用父容器的比例位置(0 到 1)定义元素的右下边界。与最小值不同可让元素随父容器拉伸。',
offset_min:
'偏移最小值:相对于最小锚点的像素偏移,控制元素左侧和顶部的位置。',
offset_max:
'偏移最大值:相对于最大锚点的像素偏移,控制元素右侧和底部的位置。',
} as const;
function cloneTransform(transform: Transform): Transform {
@@ -166,6 +182,39 @@ 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,
@@ -320,12 +369,80 @@ 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);
};
return (
<button
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"
onPointerDown={(event) => {
event.preventDefault();
startRepeating(event.shiftKey ? 10 : 1);
}}
onPointerUp={stopRepeating}
onPointerCancel={stopRepeating}
onPointerLeave={stopRepeating}
onClick={(event) => {
if (!repeated.current) {
onAdjust(event.shiftKey ? 10 : 1);
}
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);
transformRef.current = transform;
selectedCornerRef.current = selectedCorner;
const [presetOpen, setPresetOpen] = useState(false);
const [customOpen, setCustomOpen] = useState(
() => findPreset(transform).id === CUSTOM_PRESET.id,
@@ -369,6 +486,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}
@@ -522,23 +657,90 @@ export function TransformEditor({
</div>
) : null}
<div className="grid gap-2">
<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}
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="偏移最小值"
hint={FIELD_HINTS.offset_min}
values={transform.offset_min}
label={`${selectedCornerLabel}偏移`}
hint="当前选中角的原始 offset 值。切换角点后,X/Y 会映射到对应的 offset_min 或 offset_max 分量。"
values={selectedCornerValues}
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)}
onCommit={(axis, value) =>
onChange(updateCorner(transform, selectedCorner, axis, value))
}
/>
<div
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>
<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>
</section>