From 2b460be63a94b8ebd224fe63c376d8ca2ac65abb Mon Sep 17 00:00:00 2001 From: Adorian Doran Date: Sat, 18 Oct 2025 21:05:34 +0300 Subject: [PATCH] client/note tree: adjust the custom color of tree items to maintain readability --- apps/client/src/services/css_class_manager.ts | 53 +++++++++++++++++-- apps/client/src/stylesheets/theme-dark.css | 4 ++ apps/client/src/stylesheets/theme-light.css | 4 ++ .../src/stylesheets/theme-next-dark.css | 6 ++- .../src/stylesheets/theme-next/base.css | 14 +++++ .../src/stylesheets/theme-next/shell.css | 2 +- apps/client/src/stylesheets/tree.css | 1 + apps/client/src/utils/css-var.ts | 45 ++++++++++++++++ apps/server/package.json | 1 + pnpm-lock.yaml | 34 +++++++++--- 10 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 apps/client/src/utils/css-var.ts diff --git a/apps/client/src/services/css_class_manager.ts b/apps/client/src/services/css_class_manager.ts index f5d2e9649..6c26b5dcb 100644 --- a/apps/client/src/services/css_class_manager.ts +++ b/apps/client/src/services/css_class_manager.ts @@ -1,5 +1,20 @@ +import {readCssVar} from "../utils/css-var"; +import Color, { ColorInstance } from "color"; + const registeredClasses = new Set(); +// Read the color lightness limits defined in the theme as CSS variables + +const lightThemeColorMaxLightness = readCssVar( + document.documentElement, + "tree-item-light-theme-max-color-lightness" + ).asNumber(70); + +const darkThemeColorMinLightness = readCssVar( + document.documentElement, + "tree-item-dark-theme-min-color-lightness" + ).asNumber(50); + function createClassForColor(color: string | null) { if (!color?.trim()) { return ""; @@ -13,9 +28,16 @@ function createClassForColor(color: string | null) { const className = `color-${normalizedColorName}`; + const adjustedColor = adjustColorLightness(color, lightThemeColorMaxLightness!, darkThemeColorMinLightness!); + if (!adjustedColor) return ""; + if (!registeredClasses.has(className)) { - // make the active fancytree selector more specific than the normal color setting - $("head").append(``); + $("head").append(``); registeredClasses.add(className); } @@ -23,6 +45,31 @@ function createClassForColor(color: string | null) { return className; } +/** + * Returns a pair of colors — one optimized for light themes and the other for dark themes, derived + * from the specified color to maintain sufficient contrast with each theme. + * The adjustment is performed by limiting the color’s lightness in the CIELAB color space, + * according to the lightThemeMaxLightness and darkThemeMinLightness parameters. + */ +function adjustColorLightness(color: string, lightThemeMaxLightness: number, darkThemeMinLightness: number) { + let labColor: ColorInstance | undefined = undefined; + + try { + // Parse the given color in the CIELAB color space + labColor = Color(color).lab(); + } catch (ex) { + console.error(`Failed to parse color: "${color}"`, ex); + return; + } + + // For the light theme, limit the maximum lightness + const lightThemeColor = labColor.l(Math.min(labColor.l(), lightThemeMaxLightness)).hex(); + // For the light theme, limit the minimum lightness + const darkThemeColor = labColor.l(Math.max(labColor.l(), darkThemeMinLightness)).hex(); + + return {lightThemeColor, darkThemeColor}; +} + export default { createClassForColor -}; +}; \ No newline at end of file diff --git a/apps/client/src/stylesheets/theme-dark.css b/apps/client/src/stylesheets/theme-dark.css index f56e73232..01bd125ec 100644 --- a/apps/client/src/stylesheets/theme-dark.css +++ b/apps/client/src/stylesheets/theme-dark.css @@ -82,6 +82,10 @@ body ::-webkit-calendar-picker-indicator { filter: invert(1); } +#left-pane span.fancytree-node { + --custom-color: var(--dark-theme-custom-color); +} + .excalidraw.theme--dark { --theme-filter: invert(80%) hue-rotate(180deg) !important; } diff --git a/apps/client/src/stylesheets/theme-light.css b/apps/client/src/stylesheets/theme-light.css index 7aca1b3f9..55203712e 100644 --- a/apps/client/src/stylesheets/theme-light.css +++ b/apps/client/src/stylesheets/theme-light.css @@ -81,3 +81,7 @@ html { --mermaid-theme: default; --native-titlebar-background: #ffffff00; } + +#left-pane span.fancytree-node { + --custom-color: var(--light-theme-custom-color); +} \ No newline at end of file diff --git a/apps/client/src/stylesheets/theme-next-dark.css b/apps/client/src/stylesheets/theme-next-dark.css index c54cb26b7..b2192a2a0 100644 --- a/apps/client/src/stylesheets/theme-next-dark.css +++ b/apps/client/src/stylesheets/theme-next-dark.css @@ -268,6 +268,10 @@ * Dark color scheme tweaks */ +#left-pane span.fancytree-node { + --custom-color: var(--dark-theme-custom-color); +} + body ::-webkit-calendar-picker-indicator { filter: invert(1); } @@ -278,4 +282,4 @@ body ::-webkit-calendar-picker-indicator { body .todo-list input[type="checkbox"]:not(:checked):before { border-color: var(--muted-text-color) !important; -} +} \ No newline at end of file diff --git a/apps/client/src/stylesheets/theme-next/base.css b/apps/client/src/stylesheets/theme-next/base.css index 1ec859c3a..54e0ebf04 100644 --- a/apps/client/src/stylesheets/theme-next/base.css +++ b/apps/client/src/stylesheets/theme-next/base.css @@ -82,6 +82,20 @@ /* Theme capabilities */ --tab-note-icons: true; + + /* To ensure that a tree item's custom color remains sufficiently contrasted and readable, + * the color is adjusted based on the current color scheme (light or dark). The lightness + * component of the color represented in the CIELAB color space, will be + * constrained to a certain percentage defined below. + * + * Note: the tree background may vary when background effects are enabled, so it is recommended + * to maintain a higher contrast margin than on the usual note tree solid background. */ + + /* The maximum lightness for the custom color in the light theme (%): */ + --tree-item-light-theme-max-color-lightness: 50; + + /* The minimum lightness for the custom color in the dark theme (%): */ + --tree-item-dark-theme-min-color-lightness: 60; } body.backdrop-effects-disabled { diff --git a/apps/client/src/stylesheets/theme-next/shell.css b/apps/client/src/stylesheets/theme-next/shell.css index 5e713c249..ff0245ab2 100644 --- a/apps/client/src/stylesheets/theme-next/shell.css +++ b/apps/client/src/stylesheets/theme-next/shell.css @@ -639,7 +639,7 @@ body.layout-vertical.background-effects div.quick-search .dropdown-menu { #left-pane span.fancytree-node.fancytree-active { position: relative; background: transparent !important; - color: var(--left-pane-item-selected-color); + color: var(--custom-color, var(--left-pane-item-selected-color)); } @keyframes left-pane-item-select { diff --git a/apps/client/src/stylesheets/tree.css b/apps/client/src/stylesheets/tree.css index a85ee7f63..9a718310e 100644 --- a/apps/client/src/stylesheets/tree.css +++ b/apps/client/src/stylesheets/tree.css @@ -40,6 +40,7 @@ span.fancytree-node.fancytree-hide { text-overflow: ellipsis; user-select: none !important; -webkit-user-select: none !important; + color: var(--custom-color, inherit); } .fancytree-node:not(.fancytree-loading) .fancytree-expander { diff --git a/apps/client/src/utils/css-var.ts b/apps/client/src/utils/css-var.ts new file mode 100644 index 000000000..a0a16402f --- /dev/null +++ b/apps/client/src/utils/css-var.ts @@ -0,0 +1,45 @@ +export function readCssVar(element: HTMLElement, varName: string) { + return new CssVarReader(getComputedStyle(element).getPropertyValue("--" + varName)); +} + +export class CssVarReader { + protected value: string; + + constructor(rawValue: string) { + this.value = rawValue; + } + + asString(defaultValue?: string) { + return (this.value) ? this.value : defaultValue; + } + + asNumber(defaultValue?: number) { + let number: Number = NaN; + + if (this.value) { + number = new Number(this.value); + } + + return (!isNaN(number.valueOf()) ? number.valueOf() : defaultValue) + } + + asEnum(enumType: T, defaultValue?: T[keyof T]): T[keyof T] | undefined { + let result: T[keyof T] | undefined; + + result = enumType[this.value as keyof T]; + + if (result === undefined) { + result = defaultValue; + } + + return result; + } + + asArray(delimiter: string = " "): CssVarReader[] { + // Note: ignoring delimiters inside quotation marks is currently unsupported + let values = this.value.split(delimiter); + + return values.map((v) => new CssVarReader(v)); + } + +} \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json index aff4901b2..f16ea110a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "better-sqlite3": "12.4.1", + "color": "5.0.2", "node-html-parser": "7.0.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bae171169..5b8c5382c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -452,6 +452,9 @@ importers: better-sqlite3: specifier: 12.4.1 version: 12.4.1 + color: + specifier: 5.0.2 + version: 5.0.2 node-html-parser: specifier: 7.0.1 version: 7.0.1 @@ -6328,10 +6331,18 @@ packages: color-parse@2.0.2: resolution: {integrity: sha512-eCtOz5w5ttWIUcaKLiktF+DxZO1R9KLNY/xhbV6CkhM7sR3GhVghmt6X6yOnzeaM24po+Z9/S1apbXMwA3Iepw==} + color-string@2.1.2: + resolution: {integrity: sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA==} + engines: {node: '>=18'} + color-support@1.1.3: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} hasBin: true + color@5.0.2: + resolution: {integrity: sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA==} + engines: {node: '>=18'} + colord@2.9.3: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} @@ -14681,8 +14692,6 @@ snapshots: '@ckeditor/ckeditor5-core': 47.1.0 '@ckeditor/ckeditor5-upload': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-ai@47.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)': dependencies: @@ -14892,6 +14901,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.1.0 '@ckeditor/ckeditor5-watchdog': 47.1.0 es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-dev-build-tools@43.1.0(@swc/helpers@0.5.17)(tslib@2.8.1)(typescript@5.9.3)': dependencies: @@ -15083,6 +15094,8 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) es-toolkit: 1.39.5 + transitivePeerDependencies: + - supports-color '@ckeditor/ckeditor5-editor-multi-root@47.1.0': dependencies: @@ -15252,8 +15265,6 @@ snapshots: '@ckeditor/ckeditor5-utils': 47.1.0 '@ckeditor/ckeditor5-widget': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-html-embed@47.1.0': dependencies: @@ -15669,8 +15680,6 @@ snapshots: '@ckeditor/ckeditor5-ui': 47.1.0 '@ckeditor/ckeditor5-utils': 47.1.0 ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41) - transitivePeerDependencies: - - supports-color '@ckeditor/ckeditor5-special-characters@47.1.0': dependencies: @@ -16338,7 +16347,7 @@ snapshots: make-fetch-happen: 10.2.1 nopt: 6.0.0 proc-log: 2.0.1 - semver: 7.7.2 + semver: 7.7.3 tar: 6.2.1 which: 2.0.2 transitivePeerDependencies: @@ -17685,7 +17694,7 @@ snapshots: '@npmcli/fs@2.1.2': dependencies: '@gar/promisify': 1.1.3 - semver: 7.7.2 + semver: 7.7.3 '@npmcli/fs@4.0.0': dependencies: @@ -21148,9 +21157,18 @@ snapshots: dependencies: color-name: 2.0.0 + color-string@2.1.2: + dependencies: + color-name: 2.0.0 + color-support@1.1.3: optional: true + color@5.0.2: + dependencies: + color-convert: 3.1.0 + color-string: 2.1.2 + colord@2.9.3: {} colorette@2.0.20: {}