refactor: delegate theme management completely to client via bootstrap

This commit is contained in:
Elian Doran
2026-04-06 19:43:19 +03:00
parent 21598f6189
commit bdd806efff
11 changed files with 134 additions and 99 deletions

View File

@@ -54,7 +54,7 @@ function initOnElectron() {
const currentWindow = electronRemote.getCurrentWindow();
const style = window.getComputedStyle(document.body);
initDarkOrLightMode(style);
initDarkOrLightMode();
initTransparencyEffects(style, currentWindow);
initFullScreenDetection(currentWindow);
@@ -119,11 +119,11 @@ function initTransparencyEffects(style: CSSStyleDeclaration, currentWindow: Elec
*
* @param style the root CSS element to read variables from.
*/
function initDarkOrLightMode(style: CSSStyleDeclaration) {
function initDarkOrLightMode() {
let themeSource: typeof nativeTheme.themeSource = "system";
const themeStyle = style.getPropertyValue("--theme-style");
if (style.getPropertyValue("--theme-style-auto") !== "true" && (themeStyle === "light" || themeStyle === "dark")) {
const themeStyle = window.glob.getThemeStyle();
if (themeStyle !== "auto") {
themeSource = themeStyle;
}

View File

@@ -1,3 +1,5 @@
import { getThemeStyle } from "./services/theme";
async function bootstrap() {
showSplash();
await setupGlob();
@@ -38,6 +40,7 @@ async function setupGlob() {
...json,
activeDialog: null
};
window.glob.getThemeStyle = getThemeStyle;
}
async function loadBootstrapCss() {
@@ -49,31 +52,65 @@ async function loadBootstrapCss() {
}
}
function loadStylesheets() {
const { device, assetPath, themeCssUrl, themeUseNextAsBase } = window.glob;
type StylesheetRef = {
href: string;
media?: string;
};
const cssToLoad: string[] = [];
if (device !== "print") {
cssToLoad.push(`${assetPath}/stylesheets/ckeditor-theme.css`);
cssToLoad.push(`api/fonts`);
cssToLoad.push(`${assetPath}/stylesheets/theme-light.css`);
if (themeCssUrl) {
cssToLoad.push(themeCssUrl);
}
if (themeUseNextAsBase === "next") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next.css`);
} else if (themeUseNextAsBase === "next-dark") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next-dark.css`);
} else if (themeUseNextAsBase === "next-light") {
cssToLoad.push(`${assetPath}/stylesheets/theme-next-light.css`);
}
cssToLoad.push(`${assetPath}/stylesheets/style.css`);
function getConfiguredThemeStylesheets(stylesheetsPath: string, theme: string, customThemeCssUrl?: string) {
if (theme === "auto") {
return [{ href: `${stylesheetsPath}/theme-dark.css`, media: "(prefers-color-scheme: dark)" }];
}
for (const href of cssToLoad) {
if (theme === "dark") {
return [{ href: `${stylesheetsPath}/theme-dark.css` }];
}
if (theme === "next") {
return [
{ href: `${stylesheetsPath}/theme-next-light.css` },
{ href: `${stylesheetsPath}/theme-next-dark.css`, media: "(prefers-color-scheme: dark)" }
];
}
if (theme === "next-light") {
return [{ href: `${stylesheetsPath}/theme-next-light.css` }];
}
if (theme === "next-dark") {
return [{ href: `${stylesheetsPath}/theme-next-dark.css` }];
}
if (theme !== "light" && customThemeCssUrl) {
return [{ href: customThemeCssUrl }];
}
return [];
}
function loadStylesheets() {
const { device, assetPath, theme, themeBase, customThemeCssUrl } = window.glob;
const stylesheetsPath = `${assetPath}/stylesheets`;
const cssToLoad: StylesheetRef[] = [];
if (device !== "print") {
cssToLoad.push({ href: `${stylesheetsPath}/ckeditor-theme.css` });
cssToLoad.push({ href: `api/fonts` });
cssToLoad.push({ href: `${stylesheetsPath}/theme-light.css` });
cssToLoad.push(...getConfiguredThemeStylesheets(stylesheetsPath, theme, customThemeCssUrl));
if (themeBase) {
cssToLoad.push(...getConfiguredThemeStylesheets(stylesheetsPath, themeBase));
}
cssToLoad.push({ href: `${stylesheetsPath}/style.css` });
}
for (const { href, media } of cssToLoad) {
const linkEl = document.createElement("link");
linkEl.href = href;
linkEl.rel = "stylesheet";
if (media) {
linkEl.media = media;
}
document.head.appendChild(linkEl);
}
}

View File

@@ -0,0 +1,35 @@
export function getThemeStyle(): "auto" | "light" | "dark" {
const configuredTheme = window.glob?.theme;
if (configuredTheme === "auto" || configuredTheme === "next") {
return "auto";
}
if (configuredTheme === "light" || configuredTheme === "dark") {
return configuredTheme;
}
if (configuredTheme === "next-light") {
return "light";
}
if (configuredTheme === "next-dark") {
return "dark";
}
const style = window.getComputedStyle(document.body);
const themeStyle = style.getPropertyValue("--theme-style");
if (style.getPropertyValue("--theme-style-auto") !== "true" && (themeStyle === "light" || themeStyle === "dark")) {
return themeStyle as "light" | "dark";
}
return "auto";
}
export function getEffectiveThemeStyle(): "light" | "dark" {
const themeStyle = getThemeStyle();
if (themeStyle === "auto") {
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
return themeStyle === "dark" ? "dark" : "light";
}

View File

@@ -1,11 +0,0 @@
/* Import the light color scheme.
* This is the base color scheme, always active and overridden by the dark
* color scheme stylesheet when necessary. */
@import url(./theme-next-light.css);
/* Import the dark color scheme when the system preference is set to dark mode */
@import url(./theme-next-dark.css) (prefers-color-scheme: dark);
:root {
--theme-style-auto: true;
}

View File

@@ -1,11 +0,0 @@
/* Import the light color scheme.
* This is the base color scheme, always active and overridden by the dark
* color scheme stylesheet when necessary. */
@import url(./theme-light.css);
/* Import the dark color scheme when the system preference is set to dark mode */
@import url(./theme-dark.css) (prefers-color-scheme: dark);
:root {
--theme-style-auto: true;
}

View File

@@ -24,6 +24,7 @@ interface CustomGlobals {
getReferenceLinkTitle: (href: string) => Promise<string>;
getReferenceLinkTitleSync: (href: string) => string;
getActiveContextNote: () => FNote | null;
getThemeStyle: () => "auto" | "light" | "dark";
ESLINT: Library;
appContext: AppContext;
froca: Froca;
@@ -51,8 +52,9 @@ interface CustomGlobals {
isElectron: boolean;
isRtl: boolean;
iconRegistry: IconRegistry;
themeCssUrl: string;
themeUseNextAsBase?: "next" | "next-light" | "next-dark";
theme: string;
themeBase?: "next" | "next-light" | "next-dark";
customThemeCssUrl?: string;
iconPackCss: string;
headingStyle: "plain" | "underline" | "markdown";
layoutOrientation: "vertical" | "horizontal";

View File

@@ -1,18 +1,21 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import "./NoteMap.css";
import { getThemeStyle, MapType, NoteMapWidgetMode, rgb2hex } from "./utils";
import { RefObject } from "preact";
import FNote from "../../entities/fnote";
import { useElementSize, useNoteLabel } from "../react/hooks";
import ForceGraph from "force-graph";
import { RefObject } from "preact";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import appContext from "../../components/app_context";
import FNote from "../../entities/fnote";
import link_context_menu from "../../menus/link_context_menu";
import hoisted_note from "../../services/hoisted_note";
import { t } from "../../services/i18n";
import { getEffectiveThemeStyle } from "../../services/theme";
import ActionButton from "../react/ActionButton";
import { useElementSize, useNoteLabel } from "../react/hooks";
import Slider from "../react/Slider";
import { loadNotesAndRelations, NoteMapLinkObject, NoteMapNodeObject, NotesAndRelationsData } from "./data";
import { CssData, setupRendering } from "./rendering";
import ActionButton from "../react/ActionButton";
import { t } from "../../services/i18n";
import link_context_menu from "../../menus/link_context_menu";
import appContext from "../../components/app_context";
import Slider from "../react/Slider";
import hoisted_note from "../../services/hoisted_note";
import { MapType, NoteMapWidgetMode, rgb2hex } from "./utils";
interface NoteMapProps {
note: FNote;
@@ -40,9 +43,9 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
return hoisted_note.getHoistedNoteId();
} else if (mapRootIdLabel) {
return mapRootIdLabel;
} else {
return appContext.tabManager.getActiveContext()?.parentNoteId ?? null;
}
return appContext.tabManager.getActiveContext()?.parentNoteId ?? null;
}, [ note ]);
// Build the note graph instance.
@@ -67,7 +70,7 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
noteIdToSizeMap: notesAndRelations.noteIdToSizeMap,
cssData,
notesAndRelations,
themeStyle: getThemeStyle(),
themeStyle: getEffectiveThemeStyle(),
widgetMode,
mapType
});
@@ -113,7 +116,7 @@ export default function NoteMap({ note, widgetMode, parentRef }: NoteMapProps) {
node.fx = undefined;
node.fy = undefined;
}
})
});
}, [ fixNodes, mapType ]);
return (
@@ -159,7 +162,7 @@ function MapTypeSwitcher({ icon, text, type, currentMapType, setMapType }: {
onClick={() => setMapType(type)}
frame
/>
)
);
}
function getCssData(container: HTMLElement, styleResolver: HTMLElement): CssData {
@@ -170,5 +173,5 @@ function getCssData(container: HTMLElement, styleResolver: HTMLElement): CssData
fontFamily: containerStyle.fontFamily,
textColor: rgb2hex(containerStyle.color),
mutedTextColor: rgb2hex(styleResolverStyle.color)
}
};
}

View File

@@ -27,7 +27,3 @@ export function generateColorFromString(str: string, themeStyle: "light" | "dark
return color;
}
export function getThemeStyle() {
const documentStyle = window.getComputedStyle(document.documentElement);
return documentStyle.getPropertyValue("--theme-style")?.trim() as "light" | "dark";
}

View File

@@ -1385,7 +1385,7 @@ export function useGetContextDataFrom<K extends keyof NoteContextDataMap>(
}
export function useColorScheme() {
const themeStyle = getThemeStyle();
const themeStyle = window.glob.getThemeStyle();
const defaultValue = themeStyle === "auto" ? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) : themeStyle === "dark";
const [ prefersDark, setPrefersDark ] = useState(defaultValue);
@@ -1400,12 +1400,3 @@ export function useColorScheme() {
return prefersDark ? "dark" : "light";
}
function getThemeStyle() {
const style = window.getComputedStyle(document.body);
const themeStyle = style.getPropertyValue("--theme-style");
if (style.getPropertyValue("--theme-style-auto") !== "true" && (themeStyle === "light" || themeStyle === "dark")) {
return themeStyle as "light" | "dark";
}
return "auto";
}

View File

@@ -11,9 +11,9 @@ import { generateCss, generateIconRegistry, getIconPacks, MIME_TO_EXTENSION_MAPP
import log from "../services/log.js";
import optionService from "../services/options.js";
import protectedSessionService from "../services/protected_session.js";
import { generateCsrfToken } from "./csrf_protection.js";
import sql from "../services/sql.js";
import { isDev, isElectron, isMac, isWindows11 } from "../services/utils.js";
import { generateCsrfToken } from "./csrf_protection.js";
type View = "desktop" | "mobile" | "print";
@@ -38,6 +38,7 @@ export function bootstrap(req: Request, res: Response) {
const view = getView(req);
const theme = options.theme;
const themeNote = attributeService.getNoteWithLabel("appTheme", theme);
const themeUseNextAsBase = themeNote?.getAttributeValue("label", "appThemeBase") ?? undefined;
const nativeTitleBarVisible = options.nativeTitleBarVisible === "true";
const iconPacks = getIconPacks();
const currentLocale = getCurrentLocale();
@@ -45,8 +46,9 @@ export function bootstrap(req: Request, res: Response) {
res.send({
device: view,
csrfToken,
themeCssUrl: getThemeCssUrl(theme, themeNote),
themeUseNextAsBase: themeNote?.getAttributeValue("label", "appThemeBase"),
theme,
themeBase: themeUseNextAsBase,
customThemeCssUrl: getCustomThemeCssUrl(theme, themeNote),
headingStyle: options.headingStyle,
layoutOrientation: options.layoutOrientation,
platform: process.platform,
@@ -117,25 +119,16 @@ function getView(req: Request): View {
return "desktop";
}
function getThemeCssUrl(theme: string, themeNote: BNote | null) {
if (theme === "auto") {
return `${assetPath}/stylesheets/theme.css`;
} else if (theme === "light") {
// light theme is always loaded as baseline
return false;
} else if (theme === "dark") {
return `${assetPath}/stylesheets/theme-dark.css`;
} else if (theme === "next") {
return `${assetPath}/stylesheets/theme-next.css`;
} else if (theme === "next-light") {
return `${assetPath}/stylesheets/theme-next-light.css`;
} else if (theme === "next-dark") {
return `${assetPath}/stylesheets/theme-next-dark.css`;
} else if (!process.env.TRILIUM_SAFE_MODE && themeNote) {
function getCustomThemeCssUrl(theme: string, themeNote: BNote | null) {
if (["auto", "light", "dark", "next", "next-light", "next-dark"].includes(theme)) {
return undefined;
}
if (!process.env.TRILIUM_SAFE_MODE && themeNote) {
return `api/notes/download/${themeNote.noteId}`;
}
// baseline light theme
return false;
return undefined;
}
function getAppCssNoteIds() {

View File

@@ -4,7 +4,7 @@
* There are three themes embedded in the application:
* `light`, located in `src\public\stylesheets\theme-light.css`
* `dark`, located in `src\public\stylesheets\theme-dark.css`
* `next`, located in `src\public\stylesheets\theme-next.css`.
* `next`, composed from `src\public\stylesheets\theme-next-light.css` and `src\public\stylesheets\theme-next-dark.css`.
* The default theme is set only once, when the database is created and is managed by `options_init#initNotSyncedOptions`.
* In the original implementation: On Electron, the choice between `light` and `dark` is done based on the OS preference. Otherwise, the theme is always `dark`.
* Now, we always choose `next` as the default theme.