From 1ec7892338229687af0465c5e0b6e31ef1c90c53 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Thu, 16 Oct 2025 13:07:19 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/editor-app/src/App.tsx | 13 +- .../editor-app/src/components/MenuBar.tsx | 8 +- .../src/components/ProfilerDockPanel.tsx | 22 +- .../src/components/ProfilerWindow.tsx | 22 +- .../src/components/SettingsWindow.tsx | 295 ++++++++++++ .../editor-app/src/plugins/ProfilerPlugin.tsx | 66 ++- .../src/services/ProfilerService.ts | 35 +- .../src/services/SettingsService.ts | 67 +++ .../editor-app/src/styles/SettingsWindow.css | 426 ++++++++++++++++++ .../src/Services/SettingsRegistry.ts | 192 ++++++++ packages/editor-core/src/index.ts | 1 + 11 files changed, 1141 insertions(+), 6 deletions(-) create mode 100644 packages/editor-app/src/components/SettingsWindow.tsx create mode 100644 packages/editor-app/src/services/SettingsService.ts create mode 100644 packages/editor-app/src/styles/SettingsWindow.css create mode 100644 packages/editor-core/src/Services/SettingsRegistry.ts diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index a33bae4d..4733a01b 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { Core, Scene } from '@esengine/ecs-framework'; -import { EditorPluginManager, UIRegistry, MessageHub, SerializerRegistry, EntityStoreService, ComponentRegistry, LocaleService, ProjectService, ComponentDiscoveryService, ComponentLoaderService, PropertyMetadataService, LogService } from '@esengine/editor-core'; +import { EditorPluginManager, UIRegistry, MessageHub, SerializerRegistry, EntityStoreService, ComponentRegistry, LocaleService, ProjectService, ComponentDiscoveryService, ComponentLoaderService, PropertyMetadataService, LogService, SettingsRegistry } from '@esengine/editor-core'; import { SceneInspectorPlugin } from './plugins/SceneInspectorPlugin'; import { ProfilerPlugin } from './plugins/ProfilerPlugin'; import { StartupPage } from './components/StartupPage'; @@ -12,6 +12,7 @@ import { ProfilerPanel } from './components/ProfilerPanel'; import { PluginManagerWindow } from './components/PluginManagerWindow'; import { ProfilerWindow } from './components/ProfilerWindow'; import { PortManager } from './components/PortManager'; +import { SettingsWindow } from './components/SettingsWindow'; import { Viewport } from './components/Viewport'; import { MenuBar } from './components/MenuBar'; import { DockContainer, DockablePanel } from './components/DockContainer'; @@ -40,12 +41,14 @@ function App() { const [messageHub, setMessageHub] = useState(null); const [logService, setLogService] = useState(null); const [uiRegistry, setUiRegistry] = useState(null); + const [settingsRegistry, setSettingsRegistry] = useState(null); const { t, locale, changeLocale } = useLocale(); const [status, setStatus] = useState(t('header.status.initializing')); const [panels, setPanels] = useState([]); const [showPluginManager, setShowPluginManager] = useState(false); const [showProfiler, setShowProfiler] = useState(false); const [showPortManager, setShowPortManager] = useState(false); + const [showSettings, setShowSettings] = useState(false); const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0); const [isRemoteConnected, setIsRemoteConnected] = useState(false); @@ -139,6 +142,7 @@ function App() { const componentLoader = new ComponentLoaderService(messageHub, componentRegistry); const propertyMetadata = new PropertyMetadataService(); const logService = new LogService(); + const settingsRegistry = new SettingsRegistry(); Core.services.registerInstance(UIRegistry, uiRegistry); Core.services.registerInstance(MessageHub, messageHub); @@ -150,6 +154,7 @@ function App() { Core.services.registerInstance(ComponentLoaderService, componentLoader); Core.services.registerInstance(PropertyMetadataService, propertyMetadata); Core.services.registerInstance(LogService, logService); + Core.services.registerInstance(SettingsRegistry, settingsRegistry); const pluginMgr = new EditorPluginManager(); pluginMgr.initialize(coreInstance, Core.services); @@ -180,6 +185,7 @@ function App() { setMessageHub(messageHub); setLogService(logService); setUiRegistry(uiRegistry); + setSettingsRegistry(settingsRegistry); setStatus(t('header.status.ready')); } catch (error) { console.error('Failed to initialize editor:', error); @@ -426,6 +432,7 @@ function App() { onOpenPluginManager={() => setShowPluginManager(true)} onOpenProfiler={() => setShowProfiler(true)} onOpenPortManager={() => setShowPortManager(true)} + onOpenSettings={() => setShowSettings(true)} onToggleDevtools={handleToggleDevtools} />
@@ -460,6 +467,10 @@ function App() { {showPortManager && ( setShowPortManager(false)} /> )} + + {showSettings && settingsRegistry && ( + setShowSettings(false)} settingsRegistry={settingsRegistry} /> + )}
); } diff --git a/packages/editor-app/src/components/MenuBar.tsx b/packages/editor-app/src/components/MenuBar.tsx index 39a53bbb..1d924728 100644 --- a/packages/editor-app/src/components/MenuBar.tsx +++ b/packages/editor-app/src/components/MenuBar.tsx @@ -27,6 +27,7 @@ interface MenuBarProps { onOpenPluginManager?: () => void; onOpenProfiler?: () => void; onOpenPortManager?: () => void; + onOpenSettings?: () => void; onToggleDevtools?: () => void; } @@ -45,6 +46,7 @@ export function MenuBar({ onOpenPluginManager, onOpenProfiler, onOpenPortManager, + onOpenSettings, onToggleDevtools }: MenuBarProps) { const [openMenu, setOpenMenu] = useState(null); @@ -139,6 +141,7 @@ export function MenuBar({ pluginManager: 'Plugin Manager', tools: 'Tools', portManager: 'Port Manager', + settings: 'Settings', help: 'Help', documentation: 'Documentation', about: 'About', @@ -170,6 +173,7 @@ export function MenuBar({ pluginManager: '插件管理器', tools: '工具', portManager: '端口管理器', + settings: '设置', help: '帮助', documentation: '文档', about: '关于', @@ -222,7 +226,9 @@ export function MenuBar({ { label: t('devtools'), onClick: onToggleDevtools } ], tools: [ - { label: t('portManager'), onClick: onOpenPortManager } + { label: t('portManager'), onClick: onOpenPortManager }, + { separator: true }, + { label: t('settings'), onClick: onOpenSettings } ], help: [ { label: t('documentation'), disabled: true }, diff --git a/packages/editor-app/src/components/ProfilerDockPanel.tsx b/packages/editor-app/src/components/ProfilerDockPanel.tsx index e79234df..1a53a965 100644 --- a/packages/editor-app/src/components/ProfilerDockPanel.tsx +++ b/packages/editor-app/src/components/ProfilerDockPanel.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { Activity, Cpu, Layers, Package, Wifi, WifiOff, Maximize2 } from 'lucide-react'; import { ProfilerService, ProfilerData } from '../services/ProfilerService'; +import { SettingsService } from '../services/SettingsService'; import { Core } from '@esengine/ecs-framework'; import { MessageHub } from '@esengine/editor-core'; import '../styles/ProfilerDockPanel.css'; @@ -9,6 +10,25 @@ export function ProfilerDockPanel() { const [profilerData, setProfilerData] = useState(null); const [isConnected, setIsConnected] = useState(false); const [isServerRunning, setIsServerRunning] = useState(false); + const [port, setPort] = useState('8080'); + + useEffect(() => { + const settings = SettingsService.getInstance(); + setPort(settings.get('profiler.port', '8080')); + + const handleSettingsChange = ((event: CustomEvent) => { + const newPort = event.detail['profiler.port']; + if (newPort) { + setPort(newPort); + } + }) as EventListener; + + window.addEventListener('settings:changed', handleSettingsChange); + + return () => { + window.removeEventListener('settings:changed', handleSettingsChange); + }; + }, []); useEffect(() => { const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined; @@ -99,7 +119,7 @@ export function ProfilerDockPanel() {

Waiting for game connection...

-

Connect your game to port 8080

+

Connect to: ws://localhost:{port}

) : (
diff --git a/packages/editor-app/src/components/ProfilerWindow.tsx b/packages/editor-app/src/components/ProfilerWindow.tsx index a1be5fd8..17d20e80 100644 --- a/packages/editor-app/src/components/ProfilerWindow.tsx +++ b/packages/editor-app/src/components/ProfilerWindow.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { Core } from '@esengine/ecs-framework'; import { Activity, BarChart3, Clock, Cpu, RefreshCw, Pause, Play, X, Wifi, WifiOff, Server, Search, Table2, TreePine } from 'lucide-react'; import { ProfilerService } from '../services/ProfilerService'; +import { SettingsService } from '../services/SettingsService'; import '../styles/ProfilerWindow.css'; interface SystemPerformanceData { @@ -33,8 +34,27 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) { const [searchQuery, setSearchQuery] = useState(''); const [isConnected, setIsConnected] = useState(false); const [isServerRunning, setIsServerRunning] = useState(false); + const [port, setPort] = useState('8080'); const animationRef = useRef(); + useEffect(() => { + const settings = SettingsService.getInstance(); + setPort(settings.get('profiler.port', '8080')); + + const handleSettingsChange = ((event: CustomEvent) => { + const newPort = event.detail['profiler.port']; + if (newPort) { + setPort(newPort); + } + }) as EventListener; + + window.addEventListener('settings:changed', handleSettingsChange); + + return () => { + window.removeEventListener('settings:changed', handleSettingsChange); + }; + }, []); + // Check ProfilerService connection status useEffect(() => { const profilerService = (window as any).__PROFILER_SERVICE__ as ProfilerService | undefined; @@ -361,7 +381,7 @@ export function ProfilerWindow({ onClose }: ProfilerWindowProps) {
- Port: 8080 + ws://localhost:{port}
{isConnected ? (
diff --git a/packages/editor-app/src/components/SettingsWindow.tsx b/packages/editor-app/src/components/SettingsWindow.tsx new file mode 100644 index 00000000..5229b79e --- /dev/null +++ b/packages/editor-app/src/components/SettingsWindow.tsx @@ -0,0 +1,295 @@ +import { useState, useEffect } from 'react'; +import { X, Settings as SettingsIcon, ChevronRight } from 'lucide-react'; +import { SettingsService } from '../services/SettingsService'; +import { SettingsRegistry, SettingCategory, SettingDescriptor } from '@esengine/editor-core'; +import '../styles/SettingsWindow.css'; + +interface SettingsWindowProps { + onClose: () => void; + settingsRegistry: SettingsRegistry; +} + +export function SettingsWindow({ onClose, settingsRegistry }: SettingsWindowProps) { + const [categories, setCategories] = useState([]); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); + const [values, setValues] = useState>(new Map()); + const [errors, setErrors] = useState>(new Map()); + + useEffect(() => { + const allCategories = settingsRegistry.getAllCategories(); + setCategories(allCategories); + + if (allCategories.length > 0 && !selectedCategoryId) { + const firstCategory = allCategories[0]; + if (firstCategory) { + setSelectedCategoryId(firstCategory.id); + } + } + + const settings = SettingsService.getInstance(); + const allSettings = settingsRegistry.getAllSettings(); + const initialValues = new Map(); + + for (const [key, descriptor] of allSettings.entries()) { + const value = settings.get(key, descriptor.defaultValue); + initialValues.set(key, value); + } + + setValues(initialValues); + }, [settingsRegistry, selectedCategoryId]); + + const handleValueChange = (key: string, value: any, descriptor: SettingDescriptor) => { + const newValues = new Map(values); + newValues.set(key, value); + setValues(newValues); + + const newErrors = new Map(errors); + if (!settingsRegistry.validateSetting(descriptor, value)) { + newErrors.set(key, descriptor.validator?.errorMessage || 'Invalid value'); + } else { + newErrors.delete(key); + } + setErrors(newErrors); + }; + + const handleSave = () => { + if (errors.size > 0) { + return; + } + + const settings = SettingsService.getInstance(); + const changedSettings: Record = {}; + + for (const [key, value] of values.entries()) { + settings.set(key, value); + changedSettings[key] = value; + } + + window.dispatchEvent(new CustomEvent('settings:changed', { + detail: changedSettings + })); + + onClose(); + }; + + const handleCancel = () => { + onClose(); + }; + + const renderSettingInput = (setting: SettingDescriptor) => { + const value = values.get(setting.key) ?? setting.defaultValue; + const error = errors.get(setting.key); + + switch (setting.type) { + case 'boolean': + return ( +
+ + {error && {error}} +
+ ); + + case 'number': + return ( +
+ + handleValueChange(setting.key, parseInt(e.target.value) || 0, setting)} + placeholder={setting.placeholder} + min={setting.min} + max={setting.max} + step={setting.step} + /> + {error && {error}} +
+ ); + + case 'string': + return ( +
+ + handleValueChange(setting.key, e.target.value, setting)} + placeholder={setting.placeholder} + /> + {error && {error}} +
+ ); + + case 'select': + return ( +
+ + + {error && {error}} +
+ ); + + case 'range': + return ( +
+ +
+ handleValueChange(setting.key, parseFloat(e.target.value), setting)} + min={setting.min} + max={setting.max} + step={setting.step} + /> + {value} +
+ {error && {error}} +
+ ); + + case 'color': + return ( +
+ + handleValueChange(setting.key, e.target.value, setting)} + /> + {error && {error}} +
+ ); + + default: + return null; + } + }; + + const selectedCategory = categories.find(c => c.id === selectedCategoryId); + + return ( +
+
+
+
+ +

设置

+
+ +
+ +
+
+ {categories.map((category) => ( + + ))} +
+ +
+ {selectedCategory && selectedCategory.sections.map((section) => ( +
+

{section.title}

+ {section.description && ( +

{section.description}

+ )} + {section.settings.map((setting) => ( +
+ {renderSettingInput(setting)} +
+ ))} +
+ ))} + + {!selectedCategory && ( +
+ +

请选择一个设置分类

+
+ )} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/packages/editor-app/src/plugins/ProfilerPlugin.tsx b/packages/editor-app/src/plugins/ProfilerPlugin.tsx index b4140e52..9b43ebf0 100644 --- a/packages/editor-app/src/plugins/ProfilerPlugin.tsx +++ b/packages/editor-app/src/plugins/ProfilerPlugin.tsx @@ -1,5 +1,5 @@ import type { Core, ServiceContainer } from '@esengine/ecs-framework'; -import { IEditorPlugin, EditorPluginCategory, MenuItem, MessageHub, PanelDescriptor } from '@esengine/editor-core'; +import { IEditorPlugin, EditorPluginCategory, MenuItem, MessageHub, PanelDescriptor, SettingsRegistry } from '@esengine/editor-core'; import { ProfilerDockPanel } from '../components/ProfilerDockPanel'; import { ProfilerService } from '../services/ProfilerService'; @@ -22,6 +22,70 @@ export class ProfilerPlugin implements IEditorPlugin { async install(_core: Core, services: ServiceContainer): Promise { this.messageHub = services.resolve(MessageHub); + // 注册设置 + const settingsRegistry = services.resolve(SettingsRegistry); + settingsRegistry.registerCategory({ + id: 'profiler', + title: '性能分析器', + description: '配置性能分析器的行为和显示选项', + sections: [ + { + id: 'connection', + title: '连接设置', + description: '配置WebSocket服务器连接参数', + settings: [ + { + key: 'profiler.port', + label: '监听端口', + type: 'number', + defaultValue: 8080, + description: '性能分析器WebSocket服务器监听的端口号', + placeholder: '8080', + min: 1024, + max: 65535, + validator: { + validate: (value: number) => value >= 1024 && value <= 65535, + errorMessage: '端口号必须在1024到65535之间' + } + }, + { + key: 'profiler.autoStart', + label: '自动启动服务器', + type: 'boolean', + defaultValue: true, + description: '编辑器启动时自动启动性能分析器服务器' + } + ] + }, + { + id: 'display', + title: '显示设置', + description: '配置性能数据的显示选项', + settings: [ + { + key: 'profiler.refreshInterval', + label: '刷新间隔 (毫秒)', + type: 'range', + defaultValue: 100, + description: '性能数据刷新的时间间隔', + min: 50, + max: 1000, + step: 50 + }, + { + key: 'profiler.maxDataPoints', + label: '最大数据点数', + type: 'number', + defaultValue: 100, + description: '图表中保留的最大历史数据点数量', + min: 10, + max: 500 + } + ] + } + ] + }); + // 创建并启动 ProfilerService this.profilerService = new ProfilerService(); diff --git a/packages/editor-app/src/services/ProfilerService.ts b/packages/editor-app/src/services/ProfilerService.ts index 3817ff29..f1380b1c 100644 --- a/packages/editor-app/src/services/ProfilerService.ts +++ b/packages/editor-app/src/services/ProfilerService.ts @@ -1,4 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; +import { SettingsService } from './SettingsService'; export interface SystemPerformanceData { name: string; @@ -57,13 +58,45 @@ type ProfilerDataListener = (data: ProfilerData) => void; export class ProfilerService { private ws: WebSocket | null = null; private isServerRunning = false; - private wsPort = '8080'; + private wsPort: string; private listeners: Set = new Set(); private currentData: ProfilerData | null = null; private checkServerInterval: NodeJS.Timeout | null = null; private reconnectTimeout: NodeJS.Timeout | null = null; constructor() { + const settings = SettingsService.getInstance(); + this.wsPort = settings.get('profiler.port', '8080'); + + this.startServerCheck(); + this.listenToSettingsChanges(); + } + + private listenToSettingsChanges(): void { + window.addEventListener('settings:changed', ((event: CustomEvent) => { + const newPort = event.detail['profiler.port']; + if (newPort && newPort !== this.wsPort) { + this.wsPort = newPort; + this.reconnectWithNewPort(); + } + }) as EventListener); + } + + private async reconnectWithNewPort(): Promise { + this.disconnect(); + + if (this.checkServerInterval) { + clearInterval(this.checkServerInterval); + this.checkServerInterval = null; + } + + try { + await invoke('stop_profiler_server'); + this.isServerRunning = false; + } catch (error) { + console.error('[ProfilerService] Failed to stop server:', error); + } + this.startServerCheck(); } diff --git a/packages/editor-app/src/services/SettingsService.ts b/packages/editor-app/src/services/SettingsService.ts new file mode 100644 index 00000000..29d0fd6f --- /dev/null +++ b/packages/editor-app/src/services/SettingsService.ts @@ -0,0 +1,67 @@ +export class SettingsService { + private static instance: SettingsService; + private settings: Map = new Map(); + private storageKey = 'editor-settings'; + + private constructor() { + this.loadSettings(); + } + + public static getInstance(): SettingsService { + if (!SettingsService.instance) { + SettingsService.instance = new SettingsService(); + } + return SettingsService.instance; + } + + private loadSettings(): void { + try { + const stored = localStorage.getItem(this.storageKey); + if (stored) { + const data = JSON.parse(stored); + this.settings = new Map(Object.entries(data)); + } + } catch (error) { + console.error('[SettingsService] Failed to load settings:', error); + } + } + + private saveSettings(): void { + try { + const data = Object.fromEntries(this.settings); + localStorage.setItem(this.storageKey, JSON.stringify(data)); + } catch (error) { + console.error('[SettingsService] Failed to save settings:', error); + } + } + + public get(key: string, defaultValue: T): T { + if (this.settings.has(key)) { + return this.settings.get(key) as T; + } + return defaultValue; + } + + public set(key: string, value: T): void { + this.settings.set(key, value); + this.saveSettings(); + } + + public has(key: string): boolean { + return this.settings.has(key); + } + + public delete(key: string): void { + this.settings.delete(key); + this.saveSettings(); + } + + public clear(): void { + this.settings.clear(); + this.saveSettings(); + } + + public getAll(): Record { + return Object.fromEntries(this.settings); + } +} diff --git a/packages/editor-app/src/styles/SettingsWindow.css b/packages/editor-app/src/styles/SettingsWindow.css new file mode 100644 index 00000000..380a8dde --- /dev/null +++ b/packages/editor-app/src/styles/SettingsWindow.css @@ -0,0 +1,426 @@ +.settings-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + backdrop-filter: blur(4px); +} + +.settings-window { + background-color: var(--color-bg-base); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + width: 800px; + max-width: 90vw; + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-lg) var(--spacing-xl); + border-bottom: 1px solid var(--color-border-default); + background-color: var(--color-bg-elevated); +} + +.settings-title { + display: flex; + align-items: center; + gap: var(--spacing-sm); + color: var(--color-text-primary); +} + +.settings-title h2 { + margin: 0; + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); +} + +.settings-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); +} + +.settings-close-btn:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.settings-body { + flex: 1; + display: flex; + overflow: hidden; +} + +.settings-sidebar { + width: 220px; + background-color: var(--color-bg-elevated); + border-right: 1px solid var(--color-border-default); + overflow-y: auto; + flex-shrink: 0; +} + +.settings-sidebar::-webkit-scrollbar { + width: 8px; +} + +.settings-sidebar::-webkit-scrollbar-track { + background: transparent; +} + +.settings-sidebar::-webkit-scrollbar-thumb { + background: rgba(121, 121, 121, 0.3); + border-radius: 4px; +} + +.settings-sidebar::-webkit-scrollbar-thumb:hover { + background: rgba(100, 100, 100, 0.5); +} + +.settings-category-btn { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + padding: var(--spacing-md) var(--spacing-lg); + background: none; + border: none; + border-left: 3px solid transparent; + cursor: pointer; + text-align: left; + transition: all var(--transition-fast); + position: relative; +} + +.settings-category-btn:hover { + background-color: var(--color-bg-hover); +} + +.settings-category-btn.active { + background-color: var(--color-bg-base); + border-left-color: var(--color-primary); +} + +.settings-category-title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); +} + +.settings-category-desc { + font-size: var(--font-size-xs); + color: var(--color-text-tertiary); + margin-top: 2px; +} + +.settings-category-arrow { + position: absolute; + right: var(--spacing-md); + top: 50%; + transform: translateY(-50%); + color: var(--color-text-tertiary); + opacity: 0; + transition: opacity var(--transition-fast); +} + +.settings-category-btn.active .settings-category-arrow { + opacity: 1; +} + +.settings-content { + flex: 1; + overflow-y: auto; + padding: var(--spacing-xl); +} + +.settings-content::-webkit-scrollbar { + width: 14px; +} + +.settings-content::-webkit-scrollbar-track { + background: transparent; +} + +.settings-content::-webkit-scrollbar-thumb { + background: rgba(121, 121, 121, 0.4); + border-radius: 8px; + border: 3px solid transparent; + background-clip: padding-box; +} + +.settings-content::-webkit-scrollbar-thumb:hover { + background: rgba(100, 100, 100, 0.7); + background-clip: padding-box; +} + +.settings-section { + margin-bottom: var(--spacing-2xl); +} + +.settings-section:last-child { + margin-bottom: 0; +} + +.settings-section-title { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-md) 0; + padding-bottom: var(--spacing-sm); + border-bottom: 1px solid var(--color-border-subtle); +} + +.settings-section-description { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin: var(--spacing-sm) 0 var(--spacing-md) 0; +} + +.settings-field { + margin-bottom: var(--spacing-lg); +} + +.settings-field:last-child { + margin-bottom: 0; +} + +.settings-label { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + margin-bottom: var(--spacing-sm); +} + +.settings-label-checkbox { + flex-direction: row; + align-items: flex-start; + gap: var(--spacing-sm); + margin-bottom: 0; +} + +.settings-label-checkbox span:first-of-type { + font-weight: var(--font-weight-medium); +} + +.settings-hint { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-normal); + color: var(--color-text-tertiary); + margin-top: 2px; +} + +.settings-input { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + background-color: var(--color-bg-inset); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-sm); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-family: var(--font-family-mono); + transition: all var(--transition-fast); +} + +.settings-input:focus { + outline: none; + border-color: var(--color-primary); + background-color: var(--color-bg-base); +} + +.settings-input:hover:not(:focus) { + border-color: var(--color-border-strong); +} + +.settings-checkbox { + width: 18px; + height: 18px; + margin: 0; + cursor: pointer; + flex-shrink: 0; +} + +.settings-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing-md); + padding: var(--spacing-lg) var(--spacing-xl); + border-top: 1px solid var(--color-border-default); + background-color: var(--color-bg-elevated); +} + +.settings-btn { + padding: var(--spacing-sm) var(--spacing-lg); + border: none; + border-radius: var(--radius-sm); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all var(--transition-fast); +} + +.settings-btn-cancel { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); + border: 1px solid var(--color-border-default); +} + +.settings-btn-cancel:hover { + background-color: var(--color-bg-inset); + border-color: var(--color-border-strong); +} + +.settings-btn-save { + background-color: var(--color-primary); + color: var(--color-text-inverse); +} + +.settings-btn-save:hover { + background-color: var(--color-primary-hover); +} + +.settings-btn-save:active { + transform: scale(0.98); +} + +.settings-select { + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + background-color: var(--color-bg-inset); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-sm); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + cursor: pointer; + transition: all var(--transition-fast); +} + +.settings-select:focus { + outline: none; + border-color: var(--color-primary); + background-color: var(--color-bg-base); +} + +.settings-select:hover:not(:focus) { + border-color: var(--color-border-strong); +} + +.settings-range-wrapper { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.settings-range { + flex: 1; + height: 6px; + border-radius: 3px; + background: var(--color-bg-inset); + outline: none; + -webkit-appearance: none; +} + +.settings-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--color-primary); + cursor: pointer; + transition: transform var(--transition-fast); +} + +.settings-range::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +.settings-range::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--color-primary); + cursor: pointer; + border: none; + transition: transform var(--transition-fast); +} + +.settings-range::-moz-range-thumb:hover { + transform: scale(1.2); +} + +.settings-range-value { + min-width: 40px; + text-align: center; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + font-family: var(--font-family-mono); +} + +.settings-color-input { + width: 60px; + height: 40px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-sm); + cursor: pointer; + transition: border-color var(--transition-fast); +} + +.settings-color-input:hover { + border-color: var(--color-border-strong); +} + +.settings-input-error { + border-color: var(--color-error) !important; +} + +.settings-error { + display: block; + margin-top: var(--spacing-xs); + font-size: var(--font-size-xs); + color: var(--color-error); +} + +.settings-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--color-text-tertiary); + text-align: center; + gap: var(--spacing-md); +} + +.settings-empty svg { + opacity: 0.3; +} + +.settings-empty p { + margin: 0; + font-size: var(--font-size-base); +} diff --git a/packages/editor-core/src/Services/SettingsRegistry.ts b/packages/editor-core/src/Services/SettingsRegistry.ts new file mode 100644 index 00000000..1d1330dd --- /dev/null +++ b/packages/editor-core/src/Services/SettingsRegistry.ts @@ -0,0 +1,192 @@ +import { Injectable, IService } from '@esengine/ecs-framework'; + +export type SettingType = 'string' | 'number' | 'boolean' | 'select' | 'color' | 'range'; + +export interface SettingOption { + label: string; + value: any; +} + +export interface SettingValidator { + validate: (value: any) => boolean; + errorMessage: string; +} + +export interface SettingDescriptor { + key: string; + label: string; + type: SettingType; + defaultValue: any; + description?: string; + placeholder?: string; + options?: SettingOption[]; + validator?: SettingValidator; + min?: number; + max?: number; + step?: number; +} + +export interface SettingSection { + id: string; + title: string; + description?: string; + icon?: string; + settings: SettingDescriptor[]; +} + +export interface SettingCategory { + id: string; + title: string; + description?: string; + sections: SettingSection[]; +} + +@Injectable() +export class SettingsRegistry implements IService { + private categories: Map = new Map(); + + public dispose(): void { + this.categories.clear(); + } + + public registerCategory(category: SettingCategory): void { + if (this.categories.has(category.id)) { + console.warn(`[SettingsRegistry] Category ${category.id} already registered, overwriting`); + } + this.categories.set(category.id, category); + } + + public registerSection(categoryId: string, section: SettingSection): void { + let category = this.categories.get(categoryId); + + if (!category) { + category = { + id: categoryId, + title: categoryId, + sections: [] + }; + this.categories.set(categoryId, category); + } + + const existingIndex = category.sections.findIndex(s => s.id === section.id); + if (existingIndex >= 0) { + category.sections[existingIndex] = section; + console.warn(`[SettingsRegistry] Section ${section.id} in category ${categoryId} already exists, overwriting`); + } else { + category.sections.push(section); + } + } + + public registerSetting(categoryId: string, sectionId: string, setting: SettingDescriptor): void { + let category = this.categories.get(categoryId); + + if (!category) { + category = { + id: categoryId, + title: categoryId, + sections: [] + }; + this.categories.set(categoryId, category); + } + + let section = category.sections.find(s => s.id === sectionId); + if (!section) { + section = { + id: sectionId, + title: sectionId, + settings: [] + }; + category.sections.push(section); + } + + const existingIndex = section.settings.findIndex(s => s.key === setting.key); + if (existingIndex >= 0) { + section.settings[existingIndex] = setting; + console.warn(`[SettingsRegistry] Setting ${setting.key} in section ${sectionId} already exists, overwriting`); + } else { + section.settings.push(setting); + } + } + + public unregisterCategory(categoryId: string): void { + this.categories.delete(categoryId); + } + + public unregisterSection(categoryId: string, sectionId: string): void { + const category = this.categories.get(categoryId); + if (category) { + category.sections = category.sections.filter(s => s.id !== sectionId); + if (category.sections.length === 0) { + this.categories.delete(categoryId); + } + } + } + + public getCategory(categoryId: string): SettingCategory | undefined { + return this.categories.get(categoryId); + } + + public getAllCategories(): SettingCategory[] { + return Array.from(this.categories.values()); + } + + public getSetting(categoryId: string, sectionId: string, key: string): SettingDescriptor | undefined { + const category = this.categories.get(categoryId); + if (!category) return undefined; + + const section = category.sections.find(s => s.id === sectionId); + if (!section) return undefined; + + return section.settings.find(s => s.key === key); + } + + public getAllSettings(): Map { + const allSettings = new Map(); + + for (const category of this.categories.values()) { + for (const section of category.sections) { + for (const setting of section.settings) { + allSettings.set(setting.key, setting); + } + } + } + + return allSettings; + } + + public validateSetting(setting: SettingDescriptor, value: any): boolean { + if (setting.validator) { + return setting.validator.validate(value); + } + + switch (setting.type) { + case 'number': + if (typeof value !== 'number') return false; + if (setting.min !== undefined && value < setting.min) return false; + if (setting.max !== undefined && value > setting.max) return false; + return true; + + case 'boolean': + return typeof value === 'boolean'; + + case 'string': + return typeof value === 'string'; + + case 'select': + if (!setting.options) return false; + return setting.options.some(opt => opt.value === value); + + case 'range': + if (typeof value !== 'number') return false; + if (setting.min !== undefined && value < setting.min) return false; + if (setting.max !== undefined && value > setting.max) return false; + return true; + + case 'color': + return typeof value === 'string' && /^#[0-9A-Fa-f]{6}$/.test(value); + + default: + return true; + } + } +} diff --git a/packages/editor-core/src/index.ts b/packages/editor-core/src/index.ts index 14610f76..91f551b7 100644 --- a/packages/editor-core/src/index.ts +++ b/packages/editor-core/src/index.ts @@ -18,5 +18,6 @@ export * from './Services/ProjectService'; export * from './Services/ComponentDiscoveryService'; export * from './Services/ComponentLoaderService'; export * from './Services/LogService'; +export * from './Services/SettingsRegistry'; export * from './Types/UITypes';