diff --git a/apps/client/package.json b/apps/client/package.json index 4459d2cb2..dceb6e8b8 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -35,6 +35,7 @@ "autocomplete.js": "0.38.1", "bootstrap": "5.3.8", "boxicons": "2.1.4", + "color": "5.0.2", "dayjs": "1.11.18", "dayjs-plugin-utc": "0.1.2", "debounce": "2.2.0", diff --git a/apps/client/src/services/css_class_manager.ts b/apps/client/src/services/css_class_manager.ts index f5d2e9649..3b7f8ce1f 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,34 @@ 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; + } + + const lightness = labColor.l(); + + // For the light theme, limit the maximum lightness + const lightThemeColor = labColor.l(Math.min(lightness, lightThemeMaxLightness)).hex(); + + // For the dark theme, limit the minimum lightness + const darkThemeColor = labColor.l(Math.max(lightness, 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..9b9ffc887 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 perceptual lightness for the custom color in the light theme (%): */ + --tree-item-light-theme-max-color-lightness: 60; + + /* The minimum perceptual 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..886247881 --- /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 = parseFloat(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/pnpm-lock.yaml b/pnpm-lock.yaml index bae171169..77e4d877e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,6 +184,9 @@ importers: boxicons: specifier: 2.1.4 version: 2.1.4 + color: + specifier: 5.0.2 + version: 5.0.2 dayjs: specifier: 1.11.18 version: 1.11.18 @@ -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: @@ -15083,6 +15092,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 +15263,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: @@ -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: {}