diff --git a/.github/workflows/desktop-packages.yml b/.github/workflows/desktop-packages.yml new file mode 100644 index 0000000..0427b00 --- /dev/null +++ b/.github/workflows/desktop-packages.yml @@ -0,0 +1,97 @@ +name: Build desktop packages + +on: + push: + branches: + - main + - copilot/** + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + package: + name: Package (${{ matrix.os_name }}) + runs-on: ${{ matrix.runner }} + env: + OMNIHOST_PACKAGES_USERNAME: ${{ github.actor }} + OMNIHOST_PACKAGES_TOKEN: ${{ secrets.OMNIHOST_PACKAGES_TOKEN }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + os_name: linux + framework: net8.0 + archive_name: lvgl-editor-desktop-linux + - runner: windows-latest + os_name: windows + framework: net8.0-windows + archive_name: lvgl-editor-desktop-windows + - runner: macos-latest + os_name: macos + framework: net8.0 + archive_name: lvgl-editor-desktop-macos + + steps: + - name: Check package feed credentials + id: package-feed + shell: bash + run: | + if [ -z "${OMNIHOST_PACKAGES_TOKEN}" ]; then + echo "enabled=false" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + echo "enabled=true" >> "${GITHUB_OUTPUT}" + + - name: Explain skipped packaging + if: steps.package-feed.outputs.enabled != 'true' + run: echo "desktop packaging skipped: set OMNIHOST_PACKAGES_TOKEN to enable OmniHost package restore." + + - name: Checkout + if: steps.package-feed.outputs.enabled == 'true' + uses: actions/checkout@v4 + + - name: Setup Node.js + if: steps.package-feed.outputs.enabled == 'true' + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Setup .NET + if: steps.package-feed.outputs.enabled == 'true' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Install web dependencies + if: steps.package-feed.outputs.enabled == 'true' + run: npm ci + + - name: Build desktop web assets + if: steps.package-feed.outputs.enabled == 'true' + run: npm run build:desktop-web + + - name: Publish desktop host + if: steps.package-feed.outputs.enabled == 'true' + run: dotnet publish ./desktop/LvglEditor.Desktop.csproj -c Release -f ${{ matrix.framework }} -o ./artifacts/${{ matrix.archive_name }} + + - name: Create zip artifact + if: steps.package-feed.outputs.enabled == 'true' + uses: thedoctor0/zip-release@0.7.6 + with: + type: zip + filename: ${{ matrix.archive_name }}.zip + directory: ./artifacts + path: ${{ matrix.archive_name }} + + - name: Upload packaged artifact + if: steps.package-feed.outputs.enabled == 'true' + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.archive_name }} + path: ./artifacts/${{ matrix.archive_name }}.zip diff --git a/.gitignore b/.gitignore index 8adcdb2..1ff98cc 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ dist-ssr *.sln *.sw? wasm/build/ +bin/ +obj/ +/.vs +/desktop/.vs/LvglEditor.Desktop diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..994163f --- /dev/null +++ b/NuGet.config @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 2692986..ee873f2 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,31 @@ VITE_BASE_PATH=/lvgl-editor/ VITE_ENABLE_COMPILE_PREVIEW=false npm run build:web 仓库内已提供 `.github/workflows/deploy-pages.yml`,默认会在推送到 `main` 时构建并发布到 GitHub Pages,同时关闭在线编译预览功能。 +### 桌面版(OmniHost) + +项目现已增加 `desktop/LvglEditor.Desktop`,使用 [OmniHost](https://github.com/maikebing/OmniHost) 将现有 Vite 前端包装为桌面应用。 + +```bash +# 先构建前端静态资源 +npm ci +npm run build:desktop-web + +# Linux +dotnet publish ./desktop/LvglEditor.Desktop/LvglEditor.Desktop.csproj -c Release -f net8.0 + +# Windows +dotnet publish ./desktop/LvglEditor.Desktop/LvglEditor.Desktop.csproj -c Release -f net8.0-windows +``` + +桌面壳默认启用 OmniHost 的 VSCode 风格内置标题栏,提供最大化、最小化和关闭按钮;编辑器内部额外增加了 VSCode 风格菜单栏(文件 / 编辑 / 视图 / 帮助)。 + +OmniHost 依赖当前通过 `maikebing` 的 GitHub Packages 分发,因此在本地或 CI 中构建桌面版前,需要设置: + +- `OMNIHOST_PACKAGES_USERNAME` +- `OMNIHOST_PACKAGES_TOKEN`(需要具备读取 `maikebing/OmniHost` GitHub Packages 的权限) + +仓库内已新增 `.github/workflows/desktop-packages.yml`,在配置好 `OMNIHOST_PACKAGES_TOKEN` secret 后,会在 Linux、macOS、Windows 三个平台分别构建并上传压缩包产物。由于 OmniHost 当前公开版本尚未提供 macOS 原生运行时,macOS 产物目前用于保持统一的构建与分发流程,运行时会给出平台限制提示。 + ## ⌨️ 快捷键 ### 基本操作 diff --git a/desktop/LvglEditor.Desktop.csproj b/desktop/LvglEditor.Desktop.csproj new file mode 100644 index 0000000..3a33dde --- /dev/null +++ b/desktop/LvglEditor.Desktop.csproj @@ -0,0 +1,38 @@ + + + + Exe + net8.0;net8.0-windows + true + enable + enable + LvglEditor.Desktop + LvglEditor.Desktop + 1.0.0 + MSB3277 + true + + + + WinExe + + + + + wwwroot\%(RecursiveDir)%(Filename)%(Extension) + Always + + + + + + + + + + + + + + + diff --git a/desktop/Program.cs b/desktop/Program.cs new file mode 100644 index 0000000..478d6b5 --- /dev/null +++ b/desktop/Program.cs @@ -0,0 +1,68 @@ +using OmniHost; +using OmniHost.Core; +using OmniHost.Gtk; +using OmniHost.WebKitGtk; +#if WINDOWS +using OmniHost.WebView2; +using OmniHost.Windows; +#endif + +var builder = OmniApp.CreateBuilder(args) + .Configure(options => + { + options.Title = "LVGL Editor"; + options.CustomScheme = "app"; + options.ContentRootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot"); + options.StartUrl = "app://localhost/index.html"; + options.Width = 1440; + options.Height = 960; + options.StartMaximized = true; + options.ScrollBarMode = OmniScrollBarMode.Auto; + options.BuiltInTitleBarStyle = OmniBuiltInTitleBarStyle.VsCode; + options.WindowStyle = OperatingSystem.IsWindows() + ? OmniWindowStyle.VsCode + : OmniWindowStyle.Frameless; + }) + .UseDesktopApp(new LvglEditorDesktopApp()); + +ConfigureCurrentPlatform(builder); + +await builder.Build().RunAsync(); + +static void ConfigureCurrentPlatform(OmniHostBuilder builder) +{ + if (OperatingSystem.IsLinux()) + { + builder + .UseAdapter(new WebKitGtkAdapterFactory()) + .UseRuntime(new GtkRuntime()); + return; + } + +#if WINDOWS + if (OperatingSystem.IsWindows()) + { + builder + .UseAdapter(new WebView2AdapterFactory()) + .UseRuntime(new Win32Runtime()); + return; + } +#endif + + if (OperatingSystem.IsMacOS()) + { + throw new PlatformNotSupportedException( + "OmniHost 当前版本尚未提供 macOS 原生运行时/适配器,当前构建仅用于保留统一的桌面打包产物。"); + } + + throw new PlatformNotSupportedException("当前平台暂不受支持。"); +} + +sealed class LvglEditorDesktopApp : IDesktopApp +{ + public Task OnStartAsync(IWebViewAdapter adapter, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task OnClosingAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; +} diff --git a/package.json b/package.json index a6b281a..74fb861 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "build:web": "vite build", + "build:desktop-web": "vite build", "lint": "eslint .", "preview": "vite preview", "test": "vitest --run" diff --git a/src/App.css b/src/App.css index ff20e39..ef1039a 100644 --- a/src/App.css +++ b/src/App.css @@ -25,13 +25,21 @@ body { /* Header */ .app-header { - height: 48px; + display: flex; + flex-direction: column; + align-items: stretch; + padding: 0; background: #1a1a2e; + flex-shrink: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.app-command-bar { + min-height: 48px; display: flex; align-items: center; padding: 0 16px; gap: 16px; - flex-shrink: 0; } .app-logo { diff --git a/src/App.tsx b/src/App.tsx index 3ed9cb7..616454e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import HelpPanel from './components/HelpPanel'; import Toast, { useToast } from './components/Toast'; import Modal, { modal } from './components/Modal'; import CodePreview from './components/CodePreview'; +import DesktopMenuBar from './components/DesktopMenuBar/DesktopMenuBar'; import { LogicEditor } from './components/LogicEditor'; import PreviewPanel from './components/Preview'; import WasmPreview from './components/WasmPreview'; @@ -518,70 +519,89 @@ const EditorView: React.FC = ({ return (
-
- - 📐 - {projectName || 'LVGL UI Editor'} -
+ useEditorStore.getState().undo()} + onRedo={() => useEditorStore.getState().redo()} + onSelectTab={setActiveTab} + onToggleResources={() => setShowResourcePanel(prev => !prev)} + onOpenSettings={() => setShowProjectSettings(true)} + onOpenHelp={() => setShowHelpPanel(true)} + /> - {/* Main tabs */} -
- - - - -
+
+
+ + 📐 + {projectName || 'LVGL UI Editor'} +
-
- - - -
- useEditorStore.getState().undo()} shortcut="Ctrl+Z" /> - useEditorStore.getState().redo()} shortcut="Ctrl+Y" /> -
- setShowResourcePanel(!showResourcePanel)} - active={showResourcePanel} - /> - setShowProjectSettings(true)} - /> -
- -
- setShowHelpPanel(true)} - shortcut="F1" - /> + {/* Main tabs */} +
+ + + + +
+ +
+ + + +
+ useEditorStore.getState().undo()} shortcut="Ctrl+Z" /> + useEditorStore.getState().redo()} shortcut="Ctrl+Y" /> +
+ setShowResourcePanel(!showResourcePanel)} + active={showResourcePanel} + /> + setShowProjectSettings(true)} + /> +
+ +
+ setShowHelpPanel(true)} + shortcut="F1" + /> +
void; +} + +interface DesktopMenuBarProps { + projectName: string; + activeTab: EditorTab; + showResourcePanel: boolean; + onNewProject: () => void; + onOpenProject: () => void; + onSaveProject: () => void; + onExportProject: () => void; + onImportProject: () => void; + onUndo: () => void; + onRedo: () => void; + onSelectTab: (tab: EditorTab) => void; + onToggleResources: () => void; + onOpenSettings: () => void; + onOpenHelp: () => void; +} + +const DesktopMenuBar = ({ + projectName, + activeTab, + showResourcePanel, + onNewProject, + onOpenProject, + onSaveProject, + onExportProject, + onImportProject, + onUndo, + onRedo, + onSelectTab, + onToggleResources, + onOpenSettings, + onOpenHelp, +}: DesktopMenuBarProps) => { + const [openMenuId, setOpenMenuId] = useState(null); + const hostMode = isDesktopHostAvailable() ? 'Desktop' : 'Web'; + const containerRef = useRef(null); + + useEffect(() => { + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setOpenMenuId(null); + } + }; + + window.addEventListener('mousedown', handlePointerDown); + return () => window.removeEventListener('mousedown', handlePointerDown); + }, []); + + const menus = useMemo( + () => [ + { + id: 'file', + label: '文件', + items: [ + { id: 'new', label: '新建项目', shortcut: 'Ctrl+N', onClick: onNewProject }, + { id: 'open', label: '打开项目', shortcut: 'Ctrl+O', onClick: onOpenProject }, + { id: 'save', label: '保存项目', shortcut: 'Ctrl+S', onClick: onSaveProject }, + { id: 'export', label: '导出项目', onClick: onExportProject }, + { id: 'import', label: '导入项目', onClick: onImportProject }, + ] satisfies MenuAction[], + }, + { + id: 'edit', + label: '编辑', + items: [ + { id: 'undo', label: '撤销', shortcut: 'Ctrl+Z', onClick: onUndo }, + { id: 'redo', label: '重做', shortcut: 'Ctrl+Y', onClick: onRedo }, + ] satisfies MenuAction[], + }, + { + id: 'view', + label: '视图', + items: [ + { id: 'design', label: '设计视图', active: activeTab === 'design', onClick: () => onSelectTab('design') }, + { id: 'logic', label: '逻辑视图', active: activeTab === 'logic', onClick: () => onSelectTab('logic') }, + { id: 'code', label: '代码视图', active: activeTab === 'code', onClick: () => onSelectTab('code') }, + { id: 'preview', label: '预览视图', active: activeTab === 'preview', onClick: () => onSelectTab('preview') }, + { id: 'resources', label: showResourcePanel ? '隐藏资源面板' : '显示资源面板', active: showResourcePanel, onClick: onToggleResources }, + { id: 'settings', label: '项目设置', onClick: onOpenSettings }, + ] satisfies MenuAction[], + }, + { + id: 'help', + label: '帮助', + items: [ + { id: 'shortcuts', label: '快捷键帮助', shortcut: 'F1', onClick: onOpenHelp }, + ] satisfies MenuAction[], + }, + ], + [ + activeTab, + onExportProject, + onImportProject, + onNewProject, + onOpenHelp, + onOpenProject, + onOpenSettings, + onRedo, + onSaveProject, + onSelectTab, + onToggleResources, + onUndo, + showResourcePanel, + ], + ); + + const handleMenuAction = (action: MenuAction) => { + setOpenMenuId(null); + action.onClick(); + }; + + return ( +
+
+ {menus.map(menu => ( +
+ + {openMenuId === menu.id && ( +
+ {menu.items.map(item => ( + + ))} +
+ )} +
+ ))} +
+ +
+ {projectName || 'LVGL UI Editor'} + {hostMode} +
+
+ ); +}; + +export default DesktopMenuBar; diff --git a/src/utils/desktopHost.ts b/src/utils/desktopHost.ts new file mode 100644 index 0000000..12a7139 --- /dev/null +++ b/src/utils/desktopHost.ts @@ -0,0 +1,3 @@ +export function isDesktopHostAvailable() { + return typeof window !== 'undefined' && typeof window.omni !== 'undefined'; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index f10863c..5706733 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -6,3 +6,19 @@ declare module 'virtual:compile-preview' { const CompilePreview: FC; export default CompilePreview; } + +interface OmniHostWindowApi { + minimize: () => void; + maximize: () => void; + close: () => void; +} + +interface OmniHostBridge { + window: OmniHostWindowApi; + invoke?: (handler: string, payload?: unknown) => Promise; + on?: (event: string, handler: (payload: unknown) => void) => void; +} + +interface Window { + omni?: OmniHostBridge; +}