feat(editor): 添加 ECS UI 系统和编辑器更新优化 (#238)

This commit is contained in:
YHH
2025-11-26 11:08:10 +08:00
committed by GitHub
parent 3fb6f919f8
commit 7b14fa2da4
62 changed files with 8745 additions and 235 deletions

View File

@@ -0,0 +1,454 @@
import React, { useState, useEffect, useRef } from 'react';
import { Component } from '@esengine/ecs-framework';
import type { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core';
import { UITransformComponent, AnchorPreset } from '@esengine/ui';
const DraggableNumberInput: React.FC<{
axis?: 'x' | 'y' | 'z' | 'w';
label?: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
readOnly?: boolean;
}> = ({ axis, label, value, onChange, min, max, step = 0.1, readOnly }) => {
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef({ x: 0, value: 0 });
const handleMouseDown = (e: React.MouseEvent) => {
if (readOnly) return;
e.preventDefault();
setIsDragging(true);
dragStartRef.current = { x: e.clientX, value: value ?? 0 };
};
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const delta = e.clientX - dragStartRef.current.x;
const sensitivity = e.shiftKey ? 0.01 : step;
let newValue = dragStartRef.current.value + delta * sensitivity;
if (min !== undefined) newValue = Math.max(min, newValue);
if (max !== undefined) newValue = Math.min(max, newValue);
onChange(Math.round(newValue * 1000) / 1000);
};
const handleMouseUp = () => setIsDragging(false);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, onChange, step, min, max]);
const axisClass = axis ? `property-vector-axis-${axis}` : '';
const displayLabel = label || (axis ? axis.toUpperCase() : '');
return (
<div className="property-vector-axis-compact">
<span
className={`property-vector-axis-label ${axisClass}`}
onMouseDown={handleMouseDown}
style={{ cursor: readOnly ? 'default' : 'ew-resize' }}
>
{displayLabel}
</span>
<input
type="number"
className="property-input property-input-number-compact"
value={value ?? 0}
min={min}
max={max}
step={step}
disabled={readOnly}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
onFocus={(e) => e.target.select()}
/>
</div>
);
};
const Vector2Row: React.FC<{
label: string;
valueX: number;
valueY: number;
onChangeX: (value: number) => void;
onChangeY: (value: number) => void;
min?: number;
max?: number;
step?: number;
readOnly?: boolean;
}> = ({ label, valueX, valueY, onChangeX, onChangeY, min, max, step, readOnly }) => (
<div className="property-field">
<label className="property-label">{label}</label>
<div className="property-vector-compact">
<DraggableNumberInput axis="x" value={valueX} onChange={onChangeX} min={min} max={max} step={step} readOnly={readOnly} />
<DraggableNumberInput axis="y" value={valueY} onChange={onChangeY} min={min} max={max} step={step} readOnly={readOnly} />
</div>
</div>
);
const NumberRow: React.FC<{
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
readOnly?: boolean;
}> = ({ label, value, onChange, min, max, step = 0.1, readOnly }) => {
const [isDragging, setIsDragging] = useState(false);
const [dragStartX, setDragStartX] = useState(0);
const [dragStartValue, setDragStartValue] = useState(0);
const handleMouseDown = (e: React.MouseEvent) => {
if (readOnly) return;
setIsDragging(true);
setDragStartX(e.clientX);
setDragStartValue(value);
e.preventDefault();
};
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
const delta = e.clientX - dragStartX;
const sensitivity = e.shiftKey ? 0.01 : step;
let newValue = dragStartValue + delta * sensitivity;
if (min !== undefined) newValue = Math.max(min, newValue);
if (max !== undefined) newValue = Math.min(max, newValue);
onChange(parseFloat(newValue.toFixed(3)));
};
const handleMouseUp = () => setIsDragging(false);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, dragStartX, dragStartValue, step, min, max, onChange]);
return (
<div className="property-field">
<label
className="property-label property-label-draggable"
onMouseDown={handleMouseDown}
style={{ cursor: readOnly ? 'default' : 'ew-resize' }}
>
{label}
</label>
<input
type="number"
className="property-input property-input-number"
value={value ?? 0}
min={min}
max={max}
step={step}
disabled={readOnly}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
onFocus={(e) => e.target.select()}
/>
</div>
);
};
const BooleanRow: React.FC<{
label: string;
value: boolean;
onChange: (value: boolean) => void;
readOnly?: boolean;
}> = ({ label, value, onChange, readOnly }) => (
<div className="property-field property-field-boolean">
<label className="property-label">{label}</label>
<button
className={`property-toggle ${value ? 'property-toggle-on' : 'property-toggle-off'}`}
disabled={readOnly}
onClick={() => onChange(!value)}
>
<span className="property-toggle-thumb" />
</button>
</div>
);
const AnchorPresetGrid: React.FC<{
currentPreset: string;
onSelect: (preset: AnchorPreset) => void;
}> = ({ currentPreset, onSelect }) => {
const presets: AnchorPreset[][] = [
[AnchorPreset.TopLeft, AnchorPreset.TopCenter, AnchorPreset.TopRight],
[AnchorPreset.MiddleLeft, AnchorPreset.MiddleCenter, AnchorPreset.MiddleRight],
[AnchorPreset.BottomLeft, AnchorPreset.BottomCenter, AnchorPreset.BottomRight],
];
const getAnchorPosition = (preset: AnchorPreset): { x: number; y: number } => {
const positions: Record<AnchorPreset, { x: number; y: number }> = {
[AnchorPreset.TopLeft]: { x: 3, y: 3 },
[AnchorPreset.TopCenter]: { x: 10, y: 3 },
[AnchorPreset.TopRight]: { x: 17, y: 3 },
[AnchorPreset.MiddleLeft]: { x: 3, y: 10 },
[AnchorPreset.MiddleCenter]: { x: 10, y: 10 },
[AnchorPreset.MiddleRight]: { x: 17, y: 10 },
[AnchorPreset.BottomLeft]: { x: 3, y: 17 },
[AnchorPreset.BottomCenter]: { x: 10, y: 17 },
[AnchorPreset.BottomRight]: { x: 17, y: 17 },
[AnchorPreset.StretchAll]: { x: 10, y: 10 },
};
return positions[preset];
};
return (
<div className="property-field" style={{ alignItems: 'flex-start' }}>
<label className="property-label">Anchor</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 24px)',
gridTemplateRows: 'repeat(3, 24px)',
gap: '2px',
padding: '4px',
background: 'var(--color-bg-inset)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border-default)',
}}>
{presets.flat().map((preset) => {
const pos = getAnchorPosition(preset);
const isActive = currentPreset === preset;
return (
<button
key={preset}
onClick={() => onSelect(preset)}
style={{
width: '24px',
height: '24px',
padding: 0,
border: '1px solid',
borderColor: isActive ? 'var(--color-primary)' : 'var(--color-border-default)',
borderRadius: 'var(--radius-sm)',
background: isActive ? 'var(--color-primary-subtle)' : 'var(--color-bg-elevated)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all var(--transition-fast)',
}}
title={preset}
>
<svg width="20" height="20" viewBox="0 0 20 20">
<rect
x="2" y="2" width="16" height="16"
fill="none"
stroke={isActive ? 'var(--color-primary)' : 'var(--color-text-tertiary)'}
strokeWidth="1"
strokeDasharray="2,2"
/>
<circle
cx={pos.x} cy={pos.y} r="3"
fill={isActive ? 'var(--color-primary)' : 'var(--color-text-secondary)'}
/>
</svg>
</button>
);
})}
</div>
<button
onClick={() => onSelect(AnchorPreset.StretchAll)}
style={{
width: '100%',
height: '22px',
padding: '0 8px',
border: '1px solid',
borderColor: currentPreset === AnchorPreset.StretchAll ? 'var(--color-primary)' : 'var(--color-border-default)',
borderRadius: 'var(--radius-sm)',
background: currentPreset === AnchorPreset.StretchAll ? 'var(--color-primary-subtle)' : 'var(--color-bg-elevated)',
color: currentPreset === AnchorPreset.StretchAll ? 'var(--color-primary)' : 'var(--color-text-secondary)',
cursor: 'pointer',
fontSize: '10px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
transition: 'all var(--transition-fast)',
}}
title="Stretch All"
>
<svg width="14" height="14" viewBox="0 0 14 14">
<rect x="1" y="1" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1" />
<line x1="3" y1="7" x2="11" y2="7" stroke="currentColor" strokeWidth="1.5" />
<line x1="7" y1="3" x2="7" y2="11" stroke="currentColor" strokeWidth="1.5" />
</svg>
Stretch
</button>
</div>
</div>
);
};
export class UITransformInspector implements IComponentInspector<UITransformComponent> {
readonly id = 'uitransform-inspector';
readonly name = 'UITransform Inspector';
readonly priority = 100;
readonly targetComponents = ['UITransform', 'UITransformComponent'];
canHandle(component: Component): component is UITransformComponent {
return component instanceof UITransformComponent ||
component.constructor.name === 'UITransformComponent';
}
render(context: ComponentInspectorContext): React.ReactElement {
const transform = context.component as UITransformComponent;
const onChange = context.onChange;
const handleChange = (prop: string, value: number | boolean) => {
onChange?.(prop, value);
};
const detectCurrentPreset = (): string => {
const { anchorMinX, anchorMinY, anchorMaxX, anchorMaxY } = transform;
if (anchorMinX === 0 && anchorMinY === 0 && anchorMaxX === 1 && anchorMaxY === 1) {
return AnchorPreset.StretchAll;
}
if (anchorMinX === anchorMaxX && anchorMinY === anchorMaxY) {
if (anchorMinX === 0 && anchorMinY === 0) return AnchorPreset.TopLeft;
if (anchorMinX === 0.5 && anchorMinY === 0) return AnchorPreset.TopCenter;
if (anchorMinX === 1 && anchorMinY === 0) return AnchorPreset.TopRight;
if (anchorMinX === 0 && anchorMinY === 0.5) return AnchorPreset.MiddleLeft;
if (anchorMinX === 0.5 && anchorMinY === 0.5) return AnchorPreset.MiddleCenter;
if (anchorMinX === 1 && anchorMinY === 0.5) return AnchorPreset.MiddleRight;
if (anchorMinX === 0 && anchorMinY === 1) return AnchorPreset.BottomLeft;
if (anchorMinX === 0.5 && anchorMinY === 1) return AnchorPreset.BottomCenter;
if (anchorMinX === 1 && anchorMinY === 1) return AnchorPreset.BottomRight;
}
return '';
};
const handlePresetSelect = (preset: AnchorPreset) => {
const presetValues: Record<AnchorPreset, [number, number, number, number]> = {
[AnchorPreset.TopLeft]: [0, 0, 0, 0],
[AnchorPreset.TopCenter]: [0.5, 0, 0.5, 0],
[AnchorPreset.TopRight]: [1, 0, 1, 0],
[AnchorPreset.MiddleLeft]: [0, 0.5, 0, 0.5],
[AnchorPreset.MiddleCenter]: [0.5, 0.5, 0.5, 0.5],
[AnchorPreset.MiddleRight]: [1, 0.5, 1, 0.5],
[AnchorPreset.BottomLeft]: [0, 1, 0, 1],
[AnchorPreset.BottomCenter]: [0.5, 1, 0.5, 1],
[AnchorPreset.BottomRight]: [1, 1, 1, 1],
[AnchorPreset.StretchAll]: [0, 0, 1, 1],
};
const [minX, minY, maxX, maxY] = presetValues[preset];
handleChange('anchorMinX', minX);
handleChange('anchorMinY', minY);
handleChange('anchorMaxX', maxX);
handleChange('anchorMaxY', maxY);
};
return (
<div className="property-inspector">
<AnchorPresetGrid
currentPreset={detectCurrentPreset()}
onSelect={handlePresetSelect}
/>
<Vector2Row
label="Position"
valueX={transform.x}
valueY={transform.y}
onChangeX={(v) => handleChange('x', v)}
onChangeY={(v) => handleChange('y', v)}
/>
<Vector2Row
label="Size"
valueX={transform.width}
valueY={transform.height}
onChangeX={(v) => handleChange('width', v)}
onChangeY={(v) => handleChange('height', v)}
min={0}
/>
<Vector2Row
label="Anchor Min"
valueX={transform.anchorMinX}
valueY={transform.anchorMinY}
onChangeX={(v) => handleChange('anchorMinX', v)}
onChangeY={(v) => handleChange('anchorMinY', v)}
min={0}
max={1}
step={0.01}
/>
<Vector2Row
label="Anchor Max"
valueX={transform.anchorMaxX}
valueY={transform.anchorMaxY}
onChangeX={(v) => handleChange('anchorMaxX', v)}
onChangeY={(v) => handleChange('anchorMaxY', v)}
min={0}
max={1}
step={0.01}
/>
<Vector2Row
label="Pivot"
valueX={transform.pivotX}
valueY={transform.pivotY}
onChangeX={(v) => handleChange('pivotX', v)}
onChangeY={(v) => handleChange('pivotY', v)}
min={0}
max={1}
step={0.01}
/>
<NumberRow
label="Rotation"
value={transform.rotation}
onChange={(v) => handleChange('rotation', v)}
step={0.01}
/>
<Vector2Row
label="Scale"
valueX={transform.scaleX}
valueY={transform.scaleY}
onChangeX={(v) => handleChange('scaleX', v)}
onChangeY={(v) => handleChange('scaleY', v)}
step={0.01}
/>
<NumberRow
label="Z Index"
value={transform.zIndex}
onChange={(v) => handleChange('zIndex', Math.round(v))}
step={1}
/>
<NumberRow
label="Alpha"
value={transform.alpha}
onChange={(v) => handleChange('alpha', v)}
min={0}
max={1}
step={0.01}
/>
<BooleanRow
label="Visible"
value={transform.visible}
onChange={(v) => handleChange('visible', v)}
/>
</div>
);
}
}

View File

@@ -0,0 +1 @@
export * from './UITransformInspector';