mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Compare commits
4
Commits
bb9d01f57c
...
bdf94e2494
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf94e2494 | ||
|
|
1950c4cc3c | ||
|
|
aeddcc6e58 | ||
|
|
e61c66dc26 |
@@ -331,6 +331,19 @@ pub fn set_theme(state: State<'_, AppState>, theme: String) -> Result<(), String
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn set_system_themes(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
light: String,
|
||||||
|
dark: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
|
config.system_light_theme = light;
|
||||||
|
config.system_dark_theme = dark;
|
||||||
|
save_app_config(&config)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_accent_color(state: State<'_, AppState>, color: String) -> Result<(), String> {
|
pub fn set_accent_color(state: State<'_, AppState>, color: String) -> Result<(), String> {
|
||||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -354,12 +367,41 @@ pub fn save_custom_theme(state: State<'_, AppState>, theme: crate::types::Custom
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn delete_custom_theme(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
pub fn delete_custom_theme(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
|
clear_custom_theme_references(&mut config, &id);
|
||||||
|
save_app_config(&config)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_custom_theme_references(config: &mut AppConfig, id: &str) {
|
||||||
config.custom_themes.retain(|t| t.id != id);
|
config.custom_themes.retain(|t| t.id != id);
|
||||||
if config.theme == id {
|
if config.theme == id {
|
||||||
config.theme = "system".to_string();
|
config.theme = "system".to_string();
|
||||||
}
|
}
|
||||||
save_app_config(&config)?;
|
if config.system_light_theme == id {
|
||||||
Ok(())
|
config.system_light_theme = "light".to_string();
|
||||||
|
}
|
||||||
|
if config.system_dark_theme == id {
|
||||||
|
config.system_dark_theme = "dark".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod custom_theme_reference_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_custom_theme_resets_system_pair_references() {
|
||||||
|
let mut config = AppConfig::default();
|
||||||
|
config.theme = "custom-work".to_string();
|
||||||
|
config.system_light_theme = "custom-work".to_string();
|
||||||
|
config.system_dark_theme = "custom-work".to_string();
|
||||||
|
|
||||||
|
clear_custom_theme_references(&mut config, "custom-work");
|
||||||
|
|
||||||
|
assert_eq!(config.theme, "system");
|
||||||
|
assert_eq!(config.system_light_theme, "light");
|
||||||
|
assert_eq!(config.system_dark_theme, "dark");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ pub fn run() {
|
|||||||
commands::remove_vault,
|
commands::remove_vault,
|
||||||
commands::get_app_config,
|
commands::get_app_config,
|
||||||
commands::set_theme,
|
commands::set_theme,
|
||||||
|
commands::set_system_themes,
|
||||||
commands::set_accent_color,
|
commands::set_accent_color,
|
||||||
commands::save_custom_theme,
|
commands::save_custom_theme,
|
||||||
commands::delete_custom_theme,
|
commands::delete_custom_theme,
|
||||||
|
|||||||
+30
-1
@@ -114,6 +114,12 @@ pub struct AppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub active_bookmark_id: Option<String>,
|
pub active_bookmark_id: Option<String>,
|
||||||
pub theme: String,
|
pub theme: String,
|
||||||
|
/// Themes used when `theme` is "system": the frontend picks one by the OS color scheme.
|
||||||
|
/// Default to the plain "light"/"dark" schemes so existing configs keep their behavior.
|
||||||
|
#[serde(default = "default_system_light_theme")]
|
||||||
|
pub system_light_theme: String,
|
||||||
|
#[serde(default = "default_system_dark_theme")]
|
||||||
|
pub system_dark_theme: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub accent_color: Option<String>,
|
pub accent_color: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -269,6 +275,14 @@ fn default_ai_model() -> String {
|
|||||||
"claude-sonnet-4-6".to_string()
|
"claude-sonnet-4-6".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_system_light_theme() -> String {
|
||||||
|
"light".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_system_dark_theme() -> String {
|
||||||
|
"dark".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -276,6 +290,8 @@ impl Default for AppConfig {
|
|||||||
active_vault: None,
|
active_vault: None,
|
||||||
active_bookmark_id: None,
|
active_bookmark_id: None,
|
||||||
theme: "system".to_string(),
|
theme: "system".to_string(),
|
||||||
|
system_light_theme: default_system_light_theme(),
|
||||||
|
system_dark_theme: default_system_dark_theme(),
|
||||||
accent_color: None,
|
accent_color: None,
|
||||||
font_size: None,
|
font_size: None,
|
||||||
font_family: None,
|
font_family: None,
|
||||||
@@ -489,7 +505,7 @@ pub struct TaskItem {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod startup_view_tests {
|
mod startup_view_tests {
|
||||||
use super::StartupView;
|
use super::{AppConfig, StartupView};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn serializes_supported_startup_views() {
|
fn serializes_supported_startup_views() {
|
||||||
@@ -510,4 +526,17 @@ mod startup_view_tests {
|
|||||||
StartupView::All
|
StartupView::All
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_configs_default_system_theme_pair() {
|
||||||
|
let mut value = serde_json::to_value(AppConfig::default()).unwrap();
|
||||||
|
let object = value.as_object_mut().unwrap();
|
||||||
|
object.remove("system_light_theme");
|
||||||
|
object.remove("system_dark_theme");
|
||||||
|
|
||||||
|
let config: AppConfig = serde_json::from_value(value).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(config.system_light_theme, "light");
|
||||||
|
assert_eq!(config.system_dark_theme, "dark");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export async function setTheme(theme: string): Promise<void> {
|
|||||||
return invoke("set_theme", { theme });
|
return invoke("set_theme", { theme });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setSystemThemes(light: string, dark: string): Promise<void> {
|
||||||
|
return invoke("set_system_themes", { light, dark });
|
||||||
|
}
|
||||||
|
|
||||||
export async function setAccentColor(color: string): Promise<void> {
|
export async function setAccentColor(color: string): Promise<void> {
|
||||||
return invoke("set_accent_color", { color });
|
return invoke("set_accent_color", { color });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
showSearch,
|
showSearch,
|
||||||
showCommandPalette,
|
showCommandPalette,
|
||||||
theme,
|
theme,
|
||||||
|
resolvedTheme,
|
||||||
|
customThemes,
|
||||||
focusMode,
|
focusMode,
|
||||||
readOnly,
|
readOnly,
|
||||||
activeNote,
|
activeNote,
|
||||||
@@ -507,7 +509,8 @@
|
|||||||
createAndFocusNote();
|
createAndFocusNote();
|
||||||
return;
|
return;
|
||||||
case 'toggle-theme': {
|
case 'toggle-theme': {
|
||||||
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
const customTheme = $customThemes.find(theme => theme.id === $resolvedTheme);
|
||||||
|
const isDark = darkThemes.includes($resolvedTheme) || (customTheme?.is_dark ?? false);
|
||||||
const next = isDark ? 'light' : 'dark';
|
const next = isDark ? 'light' : 'dark';
|
||||||
$theme = next;
|
$theme = next;
|
||||||
setTheme(next);
|
setTheme(next);
|
||||||
@@ -565,23 +568,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(t: string) {
|
|
||||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
|
||||||
const root = document.documentElement;
|
|
||||||
root.classList.remove('dark');
|
|
||||||
root.removeAttribute('data-theme');
|
|
||||||
if (namedThemes.includes(t)) {
|
|
||||||
root.setAttribute('data-theme', t);
|
|
||||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
|
||||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
||||||
root.classList.add('dark');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
applyTheme($theme);
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
$collapsedNotebooks;
|
$collapsedNotebooks;
|
||||||
persistState();
|
persistState();
|
||||||
@@ -663,12 +649,6 @@
|
|||||||
prefetchPromise = readNote(lastNotePath).catch(() => null);
|
prefetchPromise = readNote(lastNotePath).catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyTheme($theme);
|
|
||||||
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
|
||||||
if ($theme === 'system') applyTheme('system');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Run sidebar and note list refresh in parallel
|
// Run sidebar and note list refresh in parallel
|
||||||
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,6 @@
|
|||||||
action: () => {
|
action: () => {
|
||||||
$theme = 'system';
|
$theme = 'system';
|
||||||
setTheme('system');
|
setTheme('system');
|
||||||
applyTheme('system');
|
|
||||||
$showCommandPalette = false;
|
$showCommandPalette = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -187,7 +186,7 @@
|
|||||||
if (namedThemes.includes(t)) {
|
if (namedThemes.includes(t)) {
|
||||||
root.setAttribute('data-theme', t);
|
root.setAttribute('data-theme', t);
|
||||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
} else if (t === 'dark') {
|
||||||
root.classList.add('dark');
|
root.classList.add('dark');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,8 +370,6 @@
|
|||||||
|
|
||||||
// Info panel
|
// Info panel
|
||||||
let showInfo = $state(false);
|
let showInfo = $state(false);
|
||||||
let infoPanelEl = $state<HTMLElement | null>(null);
|
|
||||||
let infoToggleBtnEl = $state<HTMLElement | null>(null);
|
|
||||||
let wordCount = $state(0);
|
let wordCount = $state(0);
|
||||||
let charCount = $state(0);
|
let charCount = $state(0);
|
||||||
let infoPathCopyState = $state<'idle' | 'copied' | 'error'>('idle');
|
let infoPathCopyState = $state<'idle' | 'copied' | 'error'>('idle');
|
||||||
@@ -4316,21 +4314,6 @@
|
|||||||
if ($sourceMode) updateCounts();
|
if ($sourceMode) updateCounts();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-close info panel on click outside
|
|
||||||
$effect(() => {
|
|
||||||
if (!showInfo) return;
|
|
||||||
function onInfoClickAway(e: MouseEvent) {
|
|
||||||
if (
|
|
||||||
infoPanelEl && !infoPanelEl.contains(e.target as Node) &&
|
|
||||||
infoToggleBtnEl && !infoToggleBtnEl.contains(e.target as Node)
|
|
||||||
) {
|
|
||||||
showInfo = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', onInfoClickAway);
|
|
||||||
return () => document.removeEventListener('mousedown', onInfoClickAway);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close in-note search when switching notes
|
// Close in-note search when switching notes
|
||||||
let prevSearchPath = '';
|
let prevSearchPath = '';
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -6142,7 +6125,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
bind:this={infoToggleBtnEl}
|
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
class:active={showInfo}
|
class:active={showInfo}
|
||||||
onclick={toggleInfo}
|
onclick={toggleInfo}
|
||||||
@@ -6487,7 +6469,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if showInfo && $activeNote}
|
{#if showInfo && $activeNote}
|
||||||
<div class="info-panel" bind:this={infoPanelEl}>
|
<div class="info-panel">
|
||||||
<div class="info-panel-header">
|
<div class="info-panel-header">
|
||||||
<span class="info-panel-title">Note Info</span>
|
<span class="info-panel-title">Note Info</span>
|
||||||
<button class="info-close-btn" onclick={() => showInfo = false}>
|
<button class="info-close-btn" onclick={() => showInfo = false}>
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
activeNotePath,
|
activeNotePath,
|
||||||
editorDirty,
|
editorDirty,
|
||||||
readOnly,
|
readOnly,
|
||||||
sourceMode,
|
sourceMode
|
||||||
theme
|
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
import { readNote } from '$lib/api';
|
import { readNote } from '$lib/api';
|
||||||
import { keybindings, matchAction } from '$lib/keybindings';
|
import { keybindings, matchAction } from '$lib/keybindings';
|
||||||
@@ -17,8 +16,6 @@
|
|||||||
|
|
||||||
let { notePath }: { notePath: string } = $props();
|
let { notePath }: { notePath: string } = $props();
|
||||||
|
|
||||||
import { darkThemes } from '$lib/platform';
|
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
const isMac = navigator.platform.startsWith('Mac');
|
const isMac = navigator.platform.startsWith('Mac');
|
||||||
let editor = $state<Editor>(null!);
|
let editor = $state<Editor>(null!);
|
||||||
@@ -36,20 +33,6 @@
|
|||||||
return () => { unlisten.then(fn => fn()); };
|
return () => { unlisten.then(fn => fn()); };
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const t = $theme || 'system';
|
|
||||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
|
||||||
const root = document.documentElement;
|
|
||||||
root.classList.remove('dark');
|
|
||||||
root.removeAttribute('data-theme');
|
|
||||||
if (namedThemes.includes(t)) {
|
|
||||||
root.setAttribute('data-theme', t);
|
|
||||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
|
||||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
||||||
root.classList.add('dark');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let lastMouseDown = 0;
|
let lastMouseDown = 0;
|
||||||
const RESIZE_EDGE = 6;
|
const RESIZE_EDGE = 6;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { showSettings, theme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
import { showSettings, theme, resolvedTheme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||||
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api';
|
import { setTheme, setSystemThemes, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, getAppConfig, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api';
|
||||||
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||||
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
@@ -418,11 +418,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let isThemeDark = $derived(
|
let isThemeDark = $derived(
|
||||||
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
|
darkThemes.includes($resolvedTheme) ||
|
||||||
($theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false))
|
($resolvedTheme.startsWith('custom-') && ($customThemes.find(c => c.id === $resolvedTheme)?.is_dark ?? false))
|
||||||
);
|
);
|
||||||
|
|
||||||
const themePresets = [
|
const themePresets = [
|
||||||
|
{ id: 'system', label: 'System', bg: '#ffffff', sidebar: '#1a1b26', accent: '#5b6abf' },
|
||||||
{ id: 'light', label: 'Light', bg: '#ffffff', sidebar: '#f8f9fa', accent: '#5b6abf' },
|
{ id: 'light', label: 'Light', bg: '#ffffff', sidebar: '#f8f9fa', accent: '#5b6abf' },
|
||||||
{ id: 'dark', label: 'Dark', bg: '#1a1b26', sidebar: '#1f2029', accent: '#89b4fa' },
|
{ id: 'dark', label: 'Dark', bg: '#1a1b26', sidebar: '#1f2029', accent: '#89b4fa' },
|
||||||
{ id: 'solarized-light', label: 'Solarized Light', bg: '#fdf6e3', sidebar: '#eee8d5', accent: '#268bd2' },
|
{ id: 'solarized-light', label: 'Solarized Light', bg: '#fdf6e3', sidebar: '#eee8d5', accent: '#268bd2' },
|
||||||
@@ -527,7 +528,45 @@
|
|||||||
let themeDropdownOpen = $state(false);
|
let themeDropdownOpen = $state(false);
|
||||||
let accentDropdownOpen = $state(false);
|
let accentDropdownOpen = $state(false);
|
||||||
let activeCustomTheme = $derived($customThemes.find(c => c.id === $theme) ?? null);
|
let activeCustomTheme = $derived($customThemes.find(c => c.id === $theme) ?? null);
|
||||||
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
|
let activeThemePreset = $derived(
|
||||||
|
themePresets.find(p => p.id === $theme) ?? themePresets.find(p => p.id === 'light')!
|
||||||
|
);
|
||||||
|
|
||||||
|
// Which theme each OS appearance maps to while `theme` is "system". Custom themes are offered
|
||||||
|
// alongside the built-ins so a light/dark pair can be built out of them.
|
||||||
|
let systemLightTheme = $state($appConfig?.system_light_theme ?? 'light');
|
||||||
|
let systemDarkTheme = $state($appConfig?.system_dark_theme ?? 'dark');
|
||||||
|
let systemPairOpen = $state<'light' | 'dark' | null>(null);
|
||||||
|
|
||||||
|
let pairPresets = $derived([
|
||||||
|
...themePresets.filter(p => p.id !== 'system'),
|
||||||
|
...$customThemes.map(ct => ({
|
||||||
|
id: ct.id,
|
||||||
|
label: ct.name,
|
||||||
|
bg: ct.colors.bg_primary,
|
||||||
|
sidebar: ct.colors.bg_secondary,
|
||||||
|
accent: ct.colors.text_primary,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let systemPairRows = $derived([
|
||||||
|
{ key: 'light' as const, title: 'Light appearance', selected: systemLightTheme },
|
||||||
|
{ key: 'dark' as const, title: 'Dark appearance', selected: systemDarkTheme },
|
||||||
|
]);
|
||||||
|
|
||||||
|
function selectSystemPairTheme(which: 'light' | 'dark', id: string) {
|
||||||
|
if (which === 'light') systemLightTheme = id;
|
||||||
|
else systemDarkTheme = id;
|
||||||
|
systemPairOpen = null;
|
||||||
|
if ($appConfig) {
|
||||||
|
$appConfig.system_light_theme = systemLightTheme;
|
||||||
|
$appConfig.system_dark_theme = systemDarkTheme;
|
||||||
|
$appConfig = { ...$appConfig };
|
||||||
|
}
|
||||||
|
setSystemThemes(systemLightTheme, systemDarkTheme).catch((e) =>
|
||||||
|
console.error('Failed to save system themes:', e)
|
||||||
|
);
|
||||||
|
}
|
||||||
let activeAccentPreset = $derived(accentPresets.find(p => p.name === activeAccent) ?? accentPresets[0]);
|
let activeAccentPreset = $derived(accentPresets.find(p => p.name === activeAccent) ?? accentPresets[0]);
|
||||||
let isCustomAccent = $derived(activeAccent.startsWith('#'));
|
let isCustomAccent = $derived(activeAccent.startsWith('#'));
|
||||||
let activeAccentColor = $derived(
|
let activeAccentColor = $derived(
|
||||||
@@ -553,7 +592,7 @@
|
|||||||
let customThemeImporting = $state(false);
|
let customThemeImporting = $state(false);
|
||||||
|
|
||||||
function openNewCustomThemeEditor() {
|
function openNewCustomThemeEditor() {
|
||||||
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) || $theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false);
|
const isDark = isThemeDark;
|
||||||
customThemeEditing = {
|
customThemeEditing = {
|
||||||
id: `custom-${Date.now()}`,
|
id: `custom-${Date.now()}`,
|
||||||
name: '',
|
name: '',
|
||||||
@@ -597,8 +636,8 @@
|
|||||||
root.removeAttribute('data-theme');
|
root.removeAttribute('data-theme');
|
||||||
for (const v of varsToClear) root.style.removeProperty(v);
|
for (const v of varsToClear) root.style.removeProperty(v);
|
||||||
const namedThemes = ['solarized-light','solarized-dark','catppuccin','nord','tokyo-night','github-light','github-dark','dracula','blueberry','forest-green','gruvbox','midnight-tide','cherry-blossom','synthwave','ember','moonlit','light-coffee','dark-coffee','cotton-candy','crimson','cloud','peach','material-dark','material-light','monokai','rose-pine','everforest','horizon','cyberpunk','black','one-dark'];
|
const namedThemes = ['solarized-light','solarized-dark','catppuccin','nord','tokyo-night','github-light','github-dark','dracula','blueberry','forest-green','gruvbox','midnight-tide','cherry-blossom','synthwave','ember','moonlit','light-coffee','dark-coffee','cotton-candy','crimson','cloud','peach','material-dark','material-light','monokai','rose-pine','everforest','horizon','cyberpunk','black','one-dark'];
|
||||||
if ($theme.startsWith('custom-')) {
|
if ($resolvedTheme.startsWith('custom-')) {
|
||||||
const ct = $customThemes.find(c => c.id === $theme);
|
const ct = $customThemes.find(c => c.id === $resolvedTheme);
|
||||||
if (ct) {
|
if (ct) {
|
||||||
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
|
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
|
||||||
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
|
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
|
||||||
@@ -613,10 +652,10 @@
|
|||||||
root.style.setProperty('--text-tertiary', ct.colors.text_secondary);
|
root.style.setProperty('--text-tertiary', ct.colors.text_secondary);
|
||||||
if (ct.is_dark) root.classList.add('dark');
|
if (ct.is_dark) root.classList.add('dark');
|
||||||
}
|
}
|
||||||
} else if (namedThemes.includes($theme)) {
|
} else if (namedThemes.includes($resolvedTheme)) {
|
||||||
root.setAttribute('data-theme', $theme);
|
root.setAttribute('data-theme', $resolvedTheme);
|
||||||
if (darkThemes.includes($theme)) root.classList.add('dark');
|
if (darkThemes.includes($resolvedTheme)) root.classList.add('dark');
|
||||||
} else if ($theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
} else if ($resolvedTheme === 'dark') {
|
||||||
root.classList.add('dark');
|
root.classList.add('dark');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -651,9 +690,15 @@
|
|||||||
async function removeCustomTheme(ct: CustomTheme) {
|
async function removeCustomTheme(ct: CustomTheme) {
|
||||||
try {
|
try {
|
||||||
await deleteCustomTheme(ct.id);
|
await deleteCustomTheme(ct.id);
|
||||||
|
if (systemLightTheme === ct.id) systemLightTheme = 'light';
|
||||||
|
if (systemDarkTheme === ct.id) systemDarkTheme = 'dark';
|
||||||
if ($appConfig) {
|
if ($appConfig) {
|
||||||
$appConfig.custom_themes = $appConfig.custom_themes.filter(t => t.id !== ct.id);
|
$appConfig = {
|
||||||
$appConfig = { ...$appConfig };
|
...$appConfig,
|
||||||
|
custom_themes: $appConfig.custom_themes.filter(t => t.id !== ct.id),
|
||||||
|
system_light_theme: systemLightTheme,
|
||||||
|
system_dark_theme: systemDarkTheme,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if ($theme === ct.id) {
|
if ($theme === ct.id) {
|
||||||
$theme = 'system';
|
$theme = 'system';
|
||||||
@@ -816,7 +861,9 @@
|
|||||||
$theme = preset.id;
|
$theme = preset.id;
|
||||||
setTheme(preset.id);
|
setTheme(preset.id);
|
||||||
themeDropdownOpen = false;
|
themeDropdownOpen = false;
|
||||||
selectCustomAccent(preset.accent);
|
// "System" has no palette of its own, it defers to whichever paired theme is active, so it
|
||||||
|
// must not overwrite the accent the way a concrete theme does.
|
||||||
|
if (preset.id !== 'system') selectCustomAccent(preset.accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAccent(preset: typeof accentPresets[0]) {
|
function selectAccent(preset: typeof accentPresets[0]) {
|
||||||
@@ -827,7 +874,7 @@
|
|||||||
|
|
||||||
function applyAccent(preset: typeof accentPresets[0]) {
|
function applyAccent(preset: typeof accentPresets[0]) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
const isDark = isThemeDark;
|
||||||
const color = isDark ? preset.dark : preset.light;
|
const color = isDark ? preset.dark : preset.light;
|
||||||
|
|
||||||
root.style.setProperty('--accent', color);
|
root.style.setProperty('--accent', color);
|
||||||
@@ -850,7 +897,7 @@
|
|||||||
|
|
||||||
function applyCustomAccent(hex: string) {
|
function applyCustomAccent(hex: string) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
const isDark = isThemeDark;
|
||||||
root.style.setProperty('--accent', hex);
|
root.style.setProperty('--accent', hex);
|
||||||
root.style.setProperty('--text-accent', hex);
|
root.style.setProperty('--text-accent', hex);
|
||||||
root.style.setProperty('--accent-hover', hex);
|
root.style.setProperty('--accent-hover', hex);
|
||||||
@@ -1003,9 +1050,9 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-apply when theme changes
|
// Re-apply when the concrete theme changes, including an OS appearance switch.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const _ = $theme;
|
const _ = $resolvedTheme;
|
||||||
const preset = accentPresets.find(p => p.name === activeAccent);
|
const preset = accentPresets.find(p => p.name === activeAccent);
|
||||||
if (preset) {
|
if (preset) {
|
||||||
applyAccent(preset);
|
applyAccent(preset);
|
||||||
@@ -1419,6 +1466,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if $theme === 'system'}
|
||||||
|
<span class="setting-hint">Follows the operating system appearance.</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
@@ -1467,6 +1517,54 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if $theme === 'system'}
|
||||||
|
{#each systemPairRows as row}
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>{row.title}</h3>
|
||||||
|
<div class="dropdown-wrap">
|
||||||
|
<button
|
||||||
|
class="dropdown-trigger"
|
||||||
|
class:open={systemPairOpen === row.key}
|
||||||
|
onclick={() => { systemPairOpen = systemPairOpen === row.key ? null : row.key; themeDropdownOpen = false; accentDropdownOpen = false; }}
|
||||||
|
>
|
||||||
|
{#each pairPresets.filter(p => p.id === row.selected) as preset}
|
||||||
|
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
|
||||||
|
<span class="preview-sidebar"></span>
|
||||||
|
<span class="preview-main">
|
||||||
|
<span class="preview-accent-bar"></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="dropdown-trigger-label">{preset.label}</span>
|
||||||
|
{/each}
|
||||||
|
<svg class="dropdown-chevron" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
{#if systemPairOpen === row.key}
|
||||||
|
<div class="dropdown-list" use:clickOutside={() => systemPairOpen = null}>
|
||||||
|
{#each pairPresets as preset}
|
||||||
|
<button
|
||||||
|
class="dropdown-item"
|
||||||
|
class:active={row.selected === preset.id}
|
||||||
|
onclick={() => selectSystemPairTheme(row.key, preset.id)}
|
||||||
|
>
|
||||||
|
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
|
||||||
|
<span class="preview-sidebar"></span>
|
||||||
|
<span class="preview-main">
|
||||||
|
<span class="preview-accent-bar"></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="dropdown-item-label">{preset.label}</span>
|
||||||
|
{#if row.selected === preset.id}
|
||||||
|
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Custom Themes -->
|
<!-- Custom Themes -->
|
||||||
|
|||||||
@@ -89,6 +89,29 @@ export const readOnly = writable(false);
|
|||||||
export const theme = writable<string>("system");
|
export const theme = writable<string>("system");
|
||||||
export const customThemes = derived(appConfig, ($c): CustomTheme[] => $c?.custom_themes ?? []);
|
export const customThemes = derived(appConfig, ($c): CustomTheme[] => $c?.custom_themes ?? []);
|
||||||
|
|
||||||
|
// Whether the OS is currently in dark mode. Held in a store (rather than read at each call site)
|
||||||
|
// so the theme re-resolves when the user flips appearance while the app is open.
|
||||||
|
const darkQuery =
|
||||||
|
typeof window !== "undefined" ? window.matchMedia("(prefers-color-scheme: dark)") : null;
|
||||||
|
export const systemPrefersDark = writable<boolean>(darkQuery?.matches ?? false);
|
||||||
|
darkQuery?.addEventListener("change", (e) => systemPrefersDark.set(e.matches));
|
||||||
|
|
||||||
|
// The theme to actually render: "system" resolves to the configured light/dark pair, everything
|
||||||
|
// else passes through. Falls back to the plain schemes when no pair is configured yet.
|
||||||
|
export const resolvedTheme = derived(
|
||||||
|
[theme, appConfig, systemPrefersDark],
|
||||||
|
([$theme, $config, $prefersDark]): string => {
|
||||||
|
if ($theme !== "system") return $theme;
|
||||||
|
const fallback = $prefersDark ? "dark" : "light";
|
||||||
|
const paired = $prefersDark ? $config?.system_dark_theme : $config?.system_light_theme;
|
||||||
|
if (!paired) return fallback;
|
||||||
|
if (paired.startsWith("custom-") && !$config?.custom_themes.some((item) => item.id === paired)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return paired;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Sync (WebDAV) - global status so the top-bar button reflects any sync,
|
// Sync (WebDAV) - global status so the top-bar button reflects any sync,
|
||||||
// whoever triggered it (manual button, settings, interval, on-change).
|
// whoever triggered it (manual button, settings, interval, on-change).
|
||||||
export const syncState = writable<{ running: boolean; error: string | null }>({
|
export const syncState = writable<{ running: boolean; error: string | null }>({
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export interface AppConfig {
|
|||||||
active_vault: string | null;
|
active_vault: string | null;
|
||||||
active_bookmark_id?: string | null;
|
active_bookmark_id?: string | null;
|
||||||
theme: string;
|
theme: string;
|
||||||
|
system_light_theme: string;
|
||||||
|
system_dark_theme: string;
|
||||||
accent_color: string | null;
|
accent_color: string | null;
|
||||||
font_size: number | null;
|
font_size: number | null;
|
||||||
font_family: string | null;
|
font_family: string | null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall, customThemes } from '$lib/stores/app';
|
import { resolvedTheme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||||
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
|
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||||
@@ -10,9 +10,11 @@
|
|||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
// Reactively apply theme class to <html> whenever $theme or custom themes change
|
// Reactively apply theme class to <html> whenever the resolved theme or custom themes change.
|
||||||
|
// $resolvedTheme already maps "system" onto the configured light/dark pair, so flipping the OS
|
||||||
|
// appearance re-runs this effect.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
applyTheme($theme, $customThemes);
|
applyTheme($resolvedTheme, $customThemes);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply link arrow visibility from config
|
// Apply link arrow visibility from config
|
||||||
|
|||||||
+10
-5
@@ -155,8 +155,13 @@
|
|||||||
});
|
});
|
||||||
$theme = config.theme || 'system';
|
$theme = config.theme || 'system';
|
||||||
|
|
||||||
// Apply theme immediately to prevent flash
|
// Apply theme immediately to prevent flash. Runs before the stores settle, so resolve
|
||||||
const themeValue = config.theme || 'system';
|
// "system" against the configured pair here the same way resolvedTheme does.
|
||||||
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
const rawTheme = config.theme || 'system';
|
||||||
|
const themeValue = rawTheme === 'system'
|
||||||
|
? (prefersDark ? config.system_dark_theme || 'dark' : config.system_light_theme || 'light')
|
||||||
|
: rawTheme;
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||||
root.classList.remove('dark');
|
root.classList.remove('dark');
|
||||||
@@ -164,7 +169,7 @@
|
|||||||
if (namedThemes.includes(themeValue)) {
|
if (namedThemes.includes(themeValue)) {
|
||||||
root.setAttribute('data-theme', themeValue);
|
root.setAttribute('data-theme', themeValue);
|
||||||
if (darkThemes.includes(themeValue)) root.classList.add('dark');
|
if (darkThemes.includes(themeValue)) root.classList.add('dark');
|
||||||
} else if (themeValue === 'dark' || (themeValue === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
} else if (themeValue === 'dark') {
|
||||||
root.classList.add('dark');
|
root.classList.add('dark');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,8 +197,8 @@
|
|||||||
}
|
}
|
||||||
// Apply saved accent
|
// Apply saved accent
|
||||||
if (config.accent_color) {
|
if (config.accent_color) {
|
||||||
const themeVal = config.theme || 'light';
|
const customTheme = config.custom_themes.find(theme => theme.id === themeValue);
|
||||||
const isDark = darkThemes.includes(themeVal) || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
const isDark = darkThemes.includes(themeValue) || (customTheme?.is_dark ?? false);
|
||||||
let color: string | null = null;
|
let color: string | null = null;
|
||||||
if (config.accent_color.startsWith('#')) {
|
if (config.accent_color.startsWith('#')) {
|
||||||
color = config.accent_color;
|
color = config.accent_color;
|
||||||
|
|||||||
Reference in New Issue
Block a user