feat: 实现可扩展的字段编辑器系统与专业资产选择器 (#227)

This commit is contained in:
YHH
2025-11-19 14:54:03 +08:00
committed by GitHub
parent caed5428d5
commit ecfef727c8
18 changed files with 1330 additions and 11 deletions

View File

@@ -14,7 +14,8 @@ import {
FileActionRegistry,
EditorPluginManager,
InspectorRegistry,
PropertyRendererRegistry
PropertyRendererRegistry,
FieldEditorRegistry
} from '@esengine/editor-core';
import { TauriFileAPI } from '../../adapters/TauriFileAPI';
import { DIContainer } from '../../core/di/DIContainer';
@@ -37,6 +38,13 @@ import {
ArrayRenderer,
FallbackRenderer
} from '../../infrastructure/property-renderers';
import {
AssetFieldEditor,
Vector2FieldEditor,
Vector3FieldEditor,
Vector4FieldEditor,
ColorFieldEditor
} from '../../infrastructure/field-editors';
export interface EditorServices {
uiRegistry: UIRegistry;
@@ -61,6 +69,7 @@ export interface EditorServices {
notification: NotificationService;
inspectorRegistry: InspectorRegistry;
propertyRendererRegistry: PropertyRendererRegistry;
fieldEditorRegistry: FieldEditorRegistry;
}
export class ServiceRegistry {
@@ -123,6 +132,15 @@ export class ServiceRegistry {
propertyRendererRegistry.register(new ArrayRenderer());
propertyRendererRegistry.register(new FallbackRenderer());
const fieldEditorRegistry = new FieldEditorRegistry();
Core.services.registerInstance(FieldEditorRegistry, fieldEditorRegistry);
fieldEditorRegistry.register(new AssetFieldEditor());
fieldEditorRegistry.register(new Vector2FieldEditor());
fieldEditorRegistry.register(new Vector3FieldEditor());
fieldEditorRegistry.register(new Vector4FieldEditor());
fieldEditorRegistry.register(new ColorFieldEditor());
return {
uiRegistry,
messageHub,
@@ -145,7 +163,8 @@ export class ServiceRegistry {
dialog,
notification,
inspectorRegistry,
propertyRendererRegistry
propertyRendererRegistry,
fieldEditorRegistry
};
}

View File

@@ -674,6 +674,29 @@ export function AssetBrowser({ projectPath, locale, onOpenScene }: AssetBrowserP
onClick={() => handleAssetClick(asset)}
onDoubleClick={() => handleAssetDoubleClick(asset)}
onContextMenu={(e) => handleContextMenu(e, asset)}
draggable={asset.type === 'file'}
onDragStart={(e) => {
if (asset.type === 'file') {
e.dataTransfer.effectAllowed = 'copy';
// 设置拖拽的数据
e.dataTransfer.setData('asset-path', asset.path);
e.dataTransfer.setData('asset-name', asset.name);
e.dataTransfer.setData('asset-extension', asset.extension || '');
e.dataTransfer.setData('text/plain', asset.path);
// 设置拖拽时的视觉效果
const dragImage = e.currentTarget.cloneNode(true) as HTMLElement;
dragImage.style.position = 'absolute';
dragImage.style.top = '-9999px';
dragImage.style.opacity = '0.8';
document.body.appendChild(dragImage);
e.dataTransfer.setDragImage(dragImage, 0, 0);
setTimeout(() => document.body.removeChild(dragImage), 0);
}
}}
style={{
cursor: asset.type === 'file' ? 'grab' : 'pointer'
}}
>
{getFileIcon(asset)}
<div className="asset-info">

View File

@@ -630,10 +630,29 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
<div key={node.path}>
<div
className={`tree-node ${isSelected ? 'selected' : ''}`}
style={{ paddingLeft: `${indent}px` }}
style={{ paddingLeft: `${indent}px`, cursor: node.type === 'file' ? 'grab' : 'pointer' }}
onClick={() => !isRenaming && handleNodeClick(node)}
onDoubleClick={() => !isRenaming && handleNodeDoubleClick(node)}
onContextMenu={(e) => handleContextMenu(e, node)}
draggable={node.type === 'file' && !isRenaming}
onDragStart={(e) => {
if (node.type === 'file' && !isRenaming) {
e.dataTransfer.effectAllowed = 'copy';
// 设置拖拽的数据
e.dataTransfer.setData('asset-path', node.path);
e.dataTransfer.setData('asset-name', node.name);
const ext = node.name.includes('.') ? node.name.split('.').pop() : '';
e.dataTransfer.setData('asset-extension', ext || '');
e.dataTransfer.setData('text/plain', node.path);
// 添加视觉反馈
e.currentTarget.style.opacity = '0.5';
}
}}
onDragEnd={(e) => {
// 恢复透明度
e.currentTarget.style.opacity = '1';
}}
>
<span className="tree-arrow">
{node.type === 'folder' ? (

View File

@@ -0,0 +1,180 @@
/* 虚幻引擎风格的资产选择框 */
.asset-field {
margin-bottom: 6px;
}
.asset-field__label {
display: block;
font-size: 11px;
color: #999;
margin-bottom: 4px;
}
.asset-field__container {
display: flex;
align-items: center;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
transition: all 0.15s ease;
position: relative;
}
.asset-field__container.hovered {
border-color: #444;
}
.asset-field__container.dragging {
border-color: #4ade80;
background: #1a2a1a;
box-shadow: 0 0 0 1px rgba(74, 222, 128, 0.2);
}
/* 资产图标区域 */
.asset-field__icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
background: #262626;
border-right: 1px solid #333;
color: #888;
}
.asset-field__container.hovered .asset-field__icon {
color: #aaa;
}
/* 资产输入区域 */
.asset-field__input {
flex: 1;
padding: 0 8px;
height: 26px;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
min-width: 0;
}
.asset-field__input:hover {
background: rgba(255, 255, 255, 0.02);
}
.asset-field__value {
font-size: 11px;
color: #e0e0e0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-field__input.empty .asset-field__value {
color: #666;
font-style: italic;
}
/* 操作按钮组 */
.asset-field__actions {
display: flex;
align-items: center;
gap: 1px;
padding: 0 1px;
}
.asset-field__button {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
background: transparent;
border: none;
border-radius: 2px;
color: #888;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.asset-field__button:hover {
background: #333;
color: #e0e0e0;
}
.asset-field__button:active {
background: #444;
}
/* 清除按钮特殊样式 */
.asset-field__button--clear:hover {
background: #4a2020;
color: #f87171;
}
/* 禁用状态 */
.asset-field__container[disabled] {
opacity: 0.6;
pointer-events: none;
}
/* 下拉菜单样式(如果需要) */
.asset-field__dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 2px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.asset-field__dropdown-item {
padding: 6px 8px;
font-size: 11px;
color: #e0e0e0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.asset-field__dropdown-item:hover {
background: #262626;
}
.asset-field__dropdown-item-icon {
color: #888;
}
/* 动画效果 */
@keyframes highlight {
0% {
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4);
}
100% {
box-shadow: 0 0 0 4px rgba(74, 222, 128, 0);
}
}
.asset-field__container.dragging {
animation: highlight 0.5s ease;
}
/* 响应式调整 */
@media (max-width: 768px) {
.asset-field__button {
width: 20px;
height: 20px;
}
.asset-field__icon {
width: 26px;
}
}

View File

@@ -0,0 +1,194 @@
import React, { useState, useRef, useCallback } from 'react';
import { FileText, Search, X, FolderOpen, ArrowRight, Package } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import './AssetField.css';
interface AssetFieldProps {
label: string;
value: string | null;
onChange: (value: string | null) => void;
fileExtension?: string; // 例如: '.btree'
placeholder?: string;
readonly?: boolean;
onNavigate?: (path: string) => void; // 导航到资产
}
export function AssetField({
label,
value,
onChange,
fileExtension = '',
placeholder = 'None',
readonly = false,
onNavigate
}: AssetFieldProps) {
const [isDragging, setIsDragging] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const inputRef = useRef<HTMLDivElement>(null);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!readonly) {
setIsDragging(true);
}
}, [readonly]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (readonly) return;
// 处理从文件系统拖入的文件
const files = Array.from(e.dataTransfer.files);
const file = files.find(f =>
!fileExtension || f.name.endsWith(fileExtension)
);
if (file) {
// Web File API 没有 path 属性,使用 name
onChange(file.name);
return;
}
// 处理从资产面板拖入的文件路径
const assetPath = e.dataTransfer.getData('asset-path');
if (assetPath && (!fileExtension || assetPath.endsWith(fileExtension))) {
onChange(assetPath);
return;
}
// 兼容纯文本拖拽
const text = e.dataTransfer.getData('text/plain');
if (text && (!fileExtension || text.endsWith(fileExtension))) {
onChange(text);
}
}, [onChange, fileExtension, readonly]);
const handleBrowse = useCallback(async () => {
if (readonly) return;
try {
const selected = await open({
multiple: false,
filters: fileExtension ? [{
name: `${fileExtension} Files`,
extensions: [fileExtension.replace('.', '')]
}] : []
});
if (selected) {
onChange(selected as string);
}
} catch (error) {
console.error('Failed to open file dialog:', error);
}
}, [onChange, fileExtension, readonly]);
const handleClear = useCallback(() => {
if (!readonly) {
onChange(null);
}
}, [onChange, readonly]);
const getFileName = (path: string) => {
const parts = path.split(/[\\/]/);
return parts[parts.length - 1];
};
return (
<div className="asset-field">
<label className="asset-field__label">{label}</label>
<div
className={`asset-field__container ${isDragging ? 'dragging' : ''} ${isHovered ? 'hovered' : ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 资产图标 */}
<div className="asset-field__icon">
{value ? (
fileExtension === '.btree' ?
<FileText size={14} /> :
<Package size={14} />
) : (
<Package size={14} style={{ opacity: 0.5 }} />
)}
</div>
{/* 资产选择框 */}
<div
ref={inputRef}
className={`asset-field__input ${value ? 'has-value' : 'empty'}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={!readonly ? handleBrowse : undefined}
title={value || placeholder}
>
<span className="asset-field__value">
{value ? getFileName(value) : placeholder}
</span>
</div>
{/* 操作按钮组 */}
<div className="asset-field__actions">
{/* 浏览按钮 */}
{!readonly && (
<button
className="asset-field__button"
onClick={(e) => {
e.stopPropagation();
handleBrowse();
}}
title="浏览..."
>
<Search size={12} />
</button>
)}
{/* 导航按钮 */}
{value && onNavigate && (
<button
className="asset-field__button"
onClick={(e) => {
e.stopPropagation();
onNavigate(value);
}}
title="在资产浏览器中显示"
>
<ArrowRight size={12} />
</button>
)}
{/* 清除按钮 */}
{value && !readonly && (
<button
className="asset-field__button asset-field__button--clear"
onClick={(e) => {
e.stopPropagation();
handleClear();
}}
title="清除"
>
<X size={12} />
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
import { AssetField } from '../../components/inspectors/fields/AssetField';
export class AssetFieldEditor implements IFieldEditor<string | null> {
readonly type = 'asset';
readonly name = 'Asset Field Editor';
readonly priority = 100;
canHandle(fieldType: string): boolean {
return fieldType === 'asset' || fieldType === 'assetReference' || fieldType === 'resourcePath';
}
render({ label, value, onChange, context }: FieldEditorProps<string | null>): React.ReactElement {
const fileExtension = context.metadata?.fileExtension || '';
const placeholder = context.metadata?.placeholder || '拖拽或选择资源文件';
return (
<AssetField
label={label}
value={value}
onChange={onChange}
fileExtension={fileExtension}
placeholder={placeholder}
readonly={context.readonly}
/>
);
}
}

View File

@@ -0,0 +1,199 @@
import React, { useState, useRef, useEffect } from 'react';
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
interface Color {
r: number;
g: number;
b: number;
a: number;
}
function rgbaToHex(color: Color): string {
const toHex = (c: number) => Math.round(c * 255).toString(16).padStart(2, '0');
return `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
}
function hexToRgba(hex: string): Color {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (result && result[1] && result[2] && result[3]) {
return {
r: parseInt(result[1], 16) / 255,
g: parseInt(result[2], 16) / 255,
b: parseInt(result[3], 16) / 255,
a: 1
};
}
return { r: 0, g: 0, b: 0, a: 1 };
}
export class ColorFieldEditor implements IFieldEditor<Color> {
readonly type = 'color';
readonly name = 'Color Field Editor';
readonly priority = 100;
canHandle(fieldType: string): boolean {
return fieldType === 'color' || fieldType === 'rgba' || fieldType === 'rgb';
}
render({ label, value, onChange, context }: FieldEditorProps<Color>): React.ReactElement {
const color = value || { r: 1, g: 1, b: 1, a: 1 };
const [showPicker, setShowPicker] = useState(false);
const pickerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
setShowPicker(false);
}
};
if (showPicker) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showPicker]);
const hexColor = rgbaToHex(color);
const rgbDisplay = `rgba(${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}, ${color.a.toFixed(2)})`;
return (
<div className="property-field" style={{ position: 'relative' }}>
<label className="property-label">{label}</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button
onClick={() => !context.readonly && setShowPicker(!showPicker)}
disabled={context.readonly}
style={{
width: '32px',
height: '24px',
backgroundColor: hexColor,
border: '2px solid #444',
borderRadius: '3px',
cursor: context.readonly ? 'default' : 'pointer',
position: 'relative',
overflow: 'hidden'
}}
>
{color.a < 1 && (
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: 'repeating-conic-gradient(#808080 0% 25%, #fff 0% 50%)',
backgroundPosition: '0 0, 8px 8px',
backgroundSize: '16px 16px',
opacity: 1 - color.a
}}
/>
)}
</button>
<span style={{ fontSize: '11px', color: '#888', fontFamily: 'monospace' }}>
{rgbDisplay}
</span>
{showPicker && (
<div
ref={pickerRef}
style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
zIndex: 1000,
backgroundColor: '#2a2a2a',
border: '1px solid #444',
borderRadius: '4px',
padding: '8px',
boxShadow: '0 4px 8px rgba(0,0,0,0.3)'
}}
>
<div style={{ marginBottom: '8px' }}>
<label style={{ fontSize: '10px', color: '#888' }}>Hex: </label>
<input
type="color"
value={hexColor}
onChange={(e) => {
const newColor = hexToRgba(e.target.value);
onChange({ ...newColor, a: color.a });
}}
style={{ marginLeft: '4px' }}
/>
</div>
<div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
<input
type="number"
value={Math.round(color.r * 255)}
onChange={(e) => onChange({ ...color, r: (parseInt(e.target.value) || 0) / 255 })}
min={0}
max={255}
style={{
width: '50px',
padding: '2px',
backgroundColor: '#1e1e1e',
border: '1px solid #444',
borderRadius: '2px',
color: '#e0e0e0',
fontSize: '11px'
}}
/>
<input
type="number"
value={Math.round(color.g * 255)}
onChange={(e) => onChange({ ...color, g: (parseInt(e.target.value) || 0) / 255 })}
min={0}
max={255}
style={{
width: '50px',
padding: '2px',
backgroundColor: '#1e1e1e',
border: '1px solid #444',
borderRadius: '2px',
color: '#e0e0e0',
fontSize: '11px'
}}
/>
<input
type="number"
value={Math.round(color.b * 255)}
onChange={(e) => onChange({ ...color, b: (parseInt(e.target.value) || 0) / 255 })}
min={0}
max={255}
style={{
width: '50px',
padding: '2px',
backgroundColor: '#1e1e1e',
border: '1px solid #444',
borderRadius: '2px',
color: '#e0e0e0',
fontSize: '11px'
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<label style={{ fontSize: '10px', color: '#888' }}>Alpha:</label>
<input
type="range"
value={color.a}
onChange={(e) => onChange({ ...color, a: parseFloat(e.target.value) })}
min={0}
max={1}
step={0.01}
style={{ flex: 1 }}
/>
<span style={{ fontSize: '10px', color: '#888', minWidth: '30px' }}>
{(color.a * 100).toFixed(0)}%
</span>
</div>
</div>
)}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,153 @@
import React from 'react';
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
interface Vector2 { x: number; y: number; }
interface Vector3 extends Vector2 { z: number; }
interface Vector4 extends Vector3 { w: number; }
const VectorInput: React.FC<{
label: string;
value: number;
onChange: (value: number) => void;
readonly?: boolean;
}> = ({ label, value, onChange, readonly }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ color: '#888', fontSize: '10px', minWidth: '12px' }}>{label}:</span>
<input
type="number"
value={value}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
disabled={readonly}
step={0.1}
style={{
width: '60px',
padding: '2px 4px',
backgroundColor: '#2a2a2a',
border: '1px solid #444',
borderRadius: '3px',
color: '#e0e0e0',
fontSize: '11px'
}}
/>
</div>
);
export class Vector2FieldEditor implements IFieldEditor<Vector2> {
readonly type = 'vector2';
readonly name = 'Vector2 Field Editor';
readonly priority = 100;
canHandle(fieldType: string): boolean {
return fieldType === 'vector2' || fieldType === 'vec2';
}
render({ label, value, onChange, context }: FieldEditorProps<Vector2>): React.ReactElement {
const v = value || { x: 0, y: 0 };
return (
<div className="property-field">
<label className="property-label">{label}</label>
<div style={{ display: 'flex', gap: '8px' }}>
<VectorInput
label="X"
value={v.x}
onChange={(x) => onChange({ ...v, x })}
readonly={context.readonly}
/>
<VectorInput
label="Y"
value={v.y}
onChange={(y) => onChange({ ...v, y })}
readonly={context.readonly}
/>
</div>
</div>
);
}
}
export class Vector3FieldEditor implements IFieldEditor<Vector3> {
readonly type = 'vector3';
readonly name = 'Vector3 Field Editor';
readonly priority = 100;
canHandle(fieldType: string): boolean {
return fieldType === 'vector3' || fieldType === 'vec3';
}
render({ label, value, onChange, context }: FieldEditorProps<Vector3>): React.ReactElement {
const v = value || { x: 0, y: 0, z: 0 };
return (
<div className="property-field">
<label className="property-label">{label}</label>
<div style={{ display: 'flex', gap: '8px' }}>
<VectorInput
label="X"
value={v.x}
onChange={(x) => onChange({ ...v, x })}
readonly={context.readonly}
/>
<VectorInput
label="Y"
value={v.y}
onChange={(y) => onChange({ ...v, y })}
readonly={context.readonly}
/>
<VectorInput
label="Z"
value={v.z}
onChange={(z) => onChange({ ...v, z })}
readonly={context.readonly}
/>
</div>
</div>
);
}
}
export class Vector4FieldEditor implements IFieldEditor<Vector4> {
readonly type = 'vector4';
readonly name = 'Vector4 Field Editor';
readonly priority = 100;
canHandle(fieldType: string): boolean {
return fieldType === 'vector4' || fieldType === 'vec4' || fieldType === 'quaternion';
}
render({ label, value, onChange, context }: FieldEditorProps<Vector4>): React.ReactElement {
const v = value || { x: 0, y: 0, z: 0, w: 0 };
return (
<div className="property-field">
<label className="property-label">{label}</label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<VectorInput
label="X"
value={v.x}
onChange={(x) => onChange({ ...v, x })}
readonly={context.readonly}
/>
<VectorInput
label="Y"
value={v.y}
onChange={(y) => onChange({ ...v, y })}
readonly={context.readonly}
/>
<VectorInput
label="Z"
value={v.z}
onChange={(z) => onChange({ ...v, z })}
readonly={context.readonly}
/>
<VectorInput
label="W"
value={v.w}
onChange={(w) => onChange({ ...v, w })}
readonly={context.readonly}
/>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,3 @@
export * from './AssetFieldEditor';
export * from './VectorFieldEditors';
export * from './ColorFieldEditor';

View File

@@ -252,23 +252,54 @@
align-items: center;
padding: 12px 8px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s ease;
cursor: grab;
transition: all 0.2s ease;
user-select: none;
position: relative;
}
.asset-item[draggable="true"]:active {
cursor: grabbing;
}
.asset-item:hover {
background: #2a2d2e;
transform: translateY(-1px);
}
.asset-item.selected {
background: #094771;
box-shadow: 0 0 0 1px rgba(14, 108, 170, 0.3);
}
.asset-item.selected:hover {
background: #0e6caa;
}
/* 拖拽中的样式 */
.asset-item.dragging {
opacity: 0.5;
transform: scale(0.95);
}
/* 拖拽时的指示器 */
.asset-item::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
border: 2px dashed transparent;
border-radius: 6px;
transition: border-color 0.2s ease;
pointer-events: none;
}
.asset-item[draggable="true"]:hover::before {
border-color: rgba(74, 222, 128, 0.3);
}
.asset-icon {
width: 48px;
height: 48px;

View File

@@ -70,7 +70,16 @@
cursor: pointer;
font-size: var(--font-size-base);
white-space: nowrap;
transition: background 0.1s ease;
transition: all 0.15s ease;
position: relative;
}
.tree-node[draggable="true"] {
cursor: grab;
}
.tree-node[draggable="true"]:active {
cursor: grabbing;
}
.tree-node:hover {
@@ -81,6 +90,16 @@
background: #37373d;
}
/* 拖拽时的样式 */
.tree-node.dragging {
opacity: 0.5;
}
.tree-node[draggable="true"]:hover {
background: #2a2d2e;
box-shadow: inset 2px 0 0 rgba(74, 222, 128, 0.5);
}
.tree-arrow {
width: 16px;
height: 16px;