diff --git a/desktop/src/menu.rs b/desktop/src/menu.rs index 1242ee91d..e8f4e9449 100644 --- a/desktop/src/menu.rs +++ b/desktop/src/menu.rs @@ -96,6 +96,12 @@ pub fn install_app_menu(app: &mut tauri::App) -> tauri::Re ) .build()?; + let theme_menu = SubmenuBuilder::new(app, "Theme") + .item(&MenuItemBuilder::new("Light").id("theme-light").build(app)?) + .item(&MenuItemBuilder::new("Dark").id("theme-dark").build(app)?) + .item(&MenuItemBuilder::new("Auto").id("theme-auto").build(app)?) + .build()?; + let view_menu = SubmenuBuilder::new(app, "View") .item( &MenuItemBuilder::new("Zoom In") @@ -141,6 +147,9 @@ pub fn install_app_menu(app: &mut tauri::App) -> tauri::Re .accelerator("CmdOrCtrl+\\") .build(app)?, ) + .separator() + .item(&theme_menu) + .separator() .item(&PredefinedMenuItem::fullscreen(app, None)?) .separator() .item( diff --git a/src/App.vue b/src/App.vue index 406959b6e..03f9976de 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,11 +7,13 @@ import { provideEditor } from '@open-pencil/vue' import AppToast from '@/components/AppToast.vue' import { useEditorStore } from '@/app/editor/active-store' import { toast } from '@/app/shell/ui' +import { useAppTheme } from '@/app/shell/theme' useHead({ titleTemplate: (title) => (title ? `${title} — OpenPencil` : 'OpenPencil') }) const store = useEditorStore() provideEditor(store) +useAppTheme() onMounted(() => { toast.setupGlobalErrorHandler() diff --git a/src/app.css b/src/app.css index 012400afc..996f7a1ed 100644 --- a/src/app.css +++ b/src/app.css @@ -40,6 +40,18 @@ --color-component: #9747ff; } +html[data-theme='light'] { + --color-panel: #ffffff; + --color-canvas: #f3f4f6; + --color-border: #d8dce3; + --color-hover: #eef1f5; + --color-accent: #2563eb; + --color-surface: #202124; + --color-muted: #6b7280; + --color-input: #ffffff; + --color-component: #7c3aed; +} + html, body, #app { diff --git a/src/app/shell/menu/app-menu.ts b/src/app/shell/menu/app-menu.ts index 54dc58e0b..e62f10e14 100644 --- a/src/app/shell/menu/app-menu.ts +++ b/src/app/shell/menu/app-menu.ts @@ -2,6 +2,7 @@ import { computed } from 'vue' import { useEditorStore } from '@/app/editor/active-store' import { openFileDialog } from '@/app/shell/menu/use' +import { useAppTheme } from '@/app/shell/theme' import { useEditorCommands, useI18n } from '@open-pencil/vue' import type { MenuEntry } from '@open-pencil/vue' @@ -15,6 +16,7 @@ export function useAppMenu(mod: string) { const store = useEditorStore() const { menuItem: commandMenuItem } = useEditorCommands() const { menu: t, panels: p, locale, availableLocales, localeLabels, setLocale } = useI18n() + const { theme, setTheme } = useAppTheme() const languageMenu = computed(() => availableLocales.map((code) => ({ @@ -123,6 +125,32 @@ export function useAppMenu(mod: string) { } }, { separator: true as const }, + { + label: 'Theme', + sub: [ + { + label: 'Light', + checked: theme.value === 'light', + onCheckedChange: (checked: boolean) => { + if (checked) setTheme('light') + } + }, + { + label: 'Dark', + checked: theme.value === 'dark', + onCheckedChange: (checked: boolean) => { + if (checked) setTheme('dark') + } + }, + { + label: 'Auto', + checked: theme.value === 'auto', + onCheckedChange: (checked: boolean) => { + if (checked) setTheme('auto') + } + } + ] + }, { label: t.value.language, sub: languageMenu.value diff --git a/src/app/shell/menu/use.ts b/src/app/shell/menu/use.ts index 7b399d4de..92749f5ae 100644 --- a/src/app/shell/menu/use.ts +++ b/src/app/shell/menu/use.ts @@ -2,6 +2,7 @@ import { tryOnScopeDispose } from '@vueuse/core' import { useEditorStore } from '@/app/editor/active-store' import { importFileDialog, openFileDialog } from '@/app/shell/menu/files' +import { useAppTheme } from '@/app/shell/theme' import { createTab, closeTab, activeTab } from '@/app/tabs' import { IS_TAURI } from '@/constants' @@ -38,10 +39,16 @@ export function useMenu() { if (!IS_TAURI) return let unlisten: (() => void) | undefined + const { setTheme } = useAppTheme() + const themeActions: Partial void>> = { + 'theme-dark': () => setTheme('dark'), + 'theme-light': () => setTheme('light'), + 'theme-auto': () => setTheme('auto') + } void import('@tauri-apps/api/event').then(({ listen }) => { void listen('menu-event', (event) => { - const action = MENU_ACTIONS[event.payload] + const action = MENU_ACTIONS[event.payload] ?? themeActions[event.payload] if (action) action() }).then((fn) => { unlisten = fn diff --git a/src/app/shell/theme.ts b/src/app/shell/theme.ts new file mode 100644 index 000000000..e27cd7cd8 --- /dev/null +++ b/src/app/shell/theme.ts @@ -0,0 +1,41 @@ +import { useLocalStorage, usePreferredDark } from '@vueuse/core' +import { computed, watchEffect } from 'vue' + +import { IS_BROWSER } from '@open-pencil/core/constants' + +export type AppTheme = 'dark' | 'light' | 'auto' + +const THEME_STORAGE_KEY = 'open-pencil:theme' +const DEFAULT_THEME: AppTheme = 'dark' + +const theme = useLocalStorage(THEME_STORAGE_KEY, DEFAULT_THEME) +const prefersDark = usePreferredDark() +const resolvedTheme = computed<'dark' | 'light'>(() => { + if (theme.value === 'auto') return prefersDark.value ? 'dark' : 'light' + return theme.value +}) + +function applyTheme(value: 'dark' | 'light', setting: AppTheme): void { + if (!IS_BROWSER) return + document.documentElement.dataset.theme = value + document.documentElement.dataset.themeSetting = setting + document.documentElement.style.colorScheme = value +} + +export function useAppTheme() { + watchEffect(() => applyTheme(resolvedTheme.value, theme.value)) + + const isLight = computed(() => resolvedTheme.value === 'light') + + function setTheme(value: AppTheme): void { + theme.value = value + } + + function toggleTheme(): void { + theme.value = isLight.value ? 'dark' : 'light' + } + + return { theme, resolvedTheme, isLight, setTheme, toggleTheme } +} + +applyTheme(resolvedTheme.value, theme.value) diff --git a/src/components/TabBar.vue b/src/components/TabBar.vue index 609be7bde..eca6e2af5 100644 --- a/src/components/TabBar.vue +++ b/src/components/TabBar.vue @@ -30,7 +30,7 @@ function onClose(e: MouseEvent, tabId: string) { v-if="tabs.length > 1" v-model="modelValue" activation-mode="automatic" - class="scrollbar-none flex h-9 shrink-0 items-end overflow-x-auto border-b border-border bg-[#1e1e1e]" + class="scrollbar-none flex h-9 shrink-0 items-end overflow-x-auto border-b border-border bg-canvas" >