重做变换角点偏移编辑器
新增四角选择状态与单组 X/Y 偏移输入 让方向键只调整当前选中角并支持 Shift 加速与长按
This commit is contained in:
+220
-18
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user