diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts index 6f054f896..4de7a4eb4 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefDefaults.ts @@ -28,6 +28,7 @@ export const PrefDefaults = { [PrefKey.NotesHideTags]: false, [PrefKey.NotesHideEditorIcon]: false, [PrefKey.UseSystemColorScheme]: false, + [PrefKey.UseTranslucentUI]: true, [PrefKey.AutoLightThemeIdentifier]: 'Default', [PrefKey.AutoDarkThemeIdentifier]: NativeFeatureIdentifier.TYPES.DarkTheme, [PrefKey.NoteAddToParentFolders]: true, diff --git a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts index 51c34ac2f..07bc4c0f2 100644 --- a/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts +++ b/packages/models/src/Domain/Syncable/UserPrefs/PrefKey.ts @@ -29,6 +29,7 @@ export enum PrefKey { NotesHideTags = 'hideTags', NotesHideEditorIcon = 'hideEditorIcon', UseSystemColorScheme = 'useSystemColorScheme', + UseTranslucentUI = 'useTranslucentUI', AutoLightThemeIdentifier = 'autoLightThemeIdentifier', AutoDarkThemeIdentifier = 'autoDarkThemeIdentifier', NoteAddToParentFolders = 'noteAddToParentFolders', @@ -66,6 +67,7 @@ export type PrefValue = { [PrefKey.NotesHideTags]: boolean [PrefKey.NotesHideEditorIcon]: boolean [PrefKey.UseSystemColorScheme]: boolean + [PrefKey.UseTranslucentUI]: boolean [PrefKey.AutoLightThemeIdentifier]: string [PrefKey.AutoDarkThemeIdentifier]: string [PrefKey.NoteAddToParentFolders]: boolean diff --git a/packages/ui-services/src/Theme/Color.spec.ts b/packages/ui-services/src/Theme/Color.spec.ts new file mode 100644 index 000000000..2b8d22482 --- /dev/null +++ b/packages/ui-services/src/Theme/Color.spec.ts @@ -0,0 +1,64 @@ +import { Color } from './Color' + +describe('Color', () => { + it('should throw an error if the color is invalid', () => { + expect(() => new Color('#ff')).toThrowError('Invalid color') + }) + + it('should parse a rgb string', () => { + const color = new Color('rgb(255, 0, 0)') + expect(color.r).toBe(255) + expect(color.g).toBe(0) + expect(color.b).toBe(0) + expect(color.a).toBe(1) + }) + + it('should throw error if rgb string is invalid', () => { + expect(() => new Color('rgb(255, 0)')).toThrowError('Invalid color') + expect(() => new Color('rgb(266, -1, 0)')).toThrowError('Invalid color') + }) + + it('should parse a hex string', () => { + const color = new Color('#ff0000') + expect(color.r).toBe(255) + expect(color.g).toBe(0) + expect(color.b).toBe(0) + expect(color.a).toBe(1) + }) + + it('should throw error if hex string is invalid', () => { + expect(() => new Color('#ff')).toThrowError('Invalid color') + expect(() => new Color('#ff000')).toThrowError('Invalid color') + expect(() => new Color('#ff00000')).toThrowError('Invalid color') + }) + + it('should set the alpha value', () => { + const color = new Color('rgb(255, 0, 0)') + color.setAlpha(0.5) + expect(color.a).toBe(0.5) + }) + + it('should throw error if alpha value is invalid', () => { + const color = new Color('rgb(255, 0, 0)') + expect(() => color.setAlpha(-1)).toThrowError('Invalid alpha value') + expect(() => color.setAlpha(1.1)).toThrowError('Invalid alpha value') + }) + + it('should convert to string', () => { + const color = new Color('rgb(255, 0, 0)') + color.setAlpha(0.5) + expect(color.toString()).toBe('rgba(255, 0, 0, 0.5)') + }) + + it('should return correct isDark value', () => { + const autobiographyThemeBG = 'rgb(237, 228, 218)' + const darkThemeBG = 'rgb(15, 16, 17)' + const solarizedThemeBG = 'rgb(0, 43, 54)' + const titaniumThemeBG = 'rgb(238, 239, 241)' + + expect(new Color(autobiographyThemeBG).isDark()).toBe(false) + expect(new Color(darkThemeBG).isDark()).toBe(true) + expect(new Color(solarizedThemeBG).isDark()).toBe(true) + expect(new Color(titaniumThemeBG).isDark()).toBe(false) + }) +}) diff --git a/packages/ui-services/src/Theme/Color.ts b/packages/ui-services/src/Theme/Color.ts new file mode 100644 index 000000000..62a438763 --- /dev/null +++ b/packages/ui-services/src/Theme/Color.ts @@ -0,0 +1,84 @@ +const RGBRegex = /-?\b[0-9]{1,3}\b/g + +export class Color { + r: number = 0 + g: number = 0 + b: number = 0 + a: number = 1 + + constructor(color: string) { + if (color.startsWith('rgb')) { + this.setFromRGB(color) + } else if (color.startsWith('#')) { + this.setFromHex(color) + } else { + throw new Error('Invalid color') + } + } + + /** + * Sets the color from a hex string + * @param hex - The hex string to set + */ + setFromHex(hex: string) { + if (!hex.startsWith('#')) { + throw new Error('Invalid color') + } + const hexValue = hex.substring(1) + if (hexValue.length !== 3 && hexValue.length !== 6) { + throw new Error('Invalid color') + } + const r = parseInt(hexValue.substring(0, 2), 16) + const g = parseInt(hexValue.substring(2, 4), 16) + const b = parseInt(hexValue.substring(4, 6), 16) + this.r = r + this.g = g + this.b = b + } + + /** + * Sets the color from an RGB string + * @param color - The RGB string to set + */ + setFromRGB(color: string) { + if (!color.startsWith('rgb')) { + throw new Error('Invalid color') + } + const regexMatches = color.match(RGBRegex) + if (!regexMatches || regexMatches.length !== 3) { + throw new Error('Invalid color') + } + const [r, g, b] = regexMatches.map((value) => parseInt(value, 10)) + if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) { + throw new Error('Invalid color') + } + this.r = r + this.g = g + this.b = b + } + + /** + * Sets the alpha value of the color + * @param alpha - The alpha value to set (0-1) + */ + setAlpha(alpha: number) { + if (alpha < 0 || alpha > 1) { + throw new Error('Invalid alpha value') + } + this.a = alpha + return this + } + + toString() { + return `rgba(${this.r}, ${this.g}, ${this.b}, ${this.a})` + } + + /** + * Returns true if the color is dark + * Based on RGB->YIQ equation https://24ways.org/2010/calculating-color-contrast + */ + isDark() { + const yiq = (this.r * 299 + this.g * 587 + this.b * 114) / 1000 + return yiq <= 128 + } +} diff --git a/packages/ui-services/src/Theme/ThemeManager.ts b/packages/ui-services/src/Theme/ThemeManager.ts index 234fc3032..583a0cce5 100644 --- a/packages/ui-services/src/Theme/ThemeManager.ts +++ b/packages/ui-services/src/Theme/ThemeManager.ts @@ -5,6 +5,7 @@ import { LocalStorageDecryptedContextualPayload, PrefKey, ThemeInterface, + PrefDefaults, } from '@standardnotes/models' import { InternalEventBusInterface, @@ -21,6 +22,7 @@ import { AbstractUIServicee } from '../Abstract/AbstractUIService' import { GetAllThemesUseCase } from './GetAllThemesUseCase' import { Uuid } from '@standardnotes/domain-core' import { ActiveThemeList } from './ActiveThemeList' +import { Color } from './Color' const CachedThemesKey = 'cachedThemes' const TimeBeforeApplyingColorScheme = 5 @@ -82,6 +84,8 @@ export class ThemeManager extends AbstractUIServicee { return } + this.toggleTranslucentUIColors() + let hasChange = false const { features, uuids } = this.components.getActiveThemesIdentifiers() @@ -169,6 +173,8 @@ export class ThemeManager extends AbstractUIServicee { } private async handlePreferencesChangeEvent() { + this.toggleTranslucentUIColors() + const useDeviceThemeSettings = this.application.getPreference(PrefKey.UseSystemColorScheme, false) const hasPreferenceChanged = useDeviceThemeSettings !== this.lastUseDeviceThemeSettings @@ -339,6 +345,8 @@ export class ThemeManager extends AbstractUIServicee { .handleThemeSchemeChange(packageInfo.isDark ?? false, this.getBackgroundColor()) }) } + + this.toggleTranslucentUIColors() } document.getElementsByTagName('head')[0].appendChild(link) } @@ -356,8 +364,11 @@ export class ThemeManager extends AbstractUIServicee { this.themesActiveInTheUI.remove(id) - if (this.themesActiveInTheUI.isEmpty() && this.application.isNativeMobileWeb()) { - this.application.mobileDevice().handleThemeSchemeChange(false, '#ffffff') + if (this.themesActiveInTheUI.isEmpty()) { + if (this.application.isNativeMobileWeb()) { + this.application.mobileDevice().handleThemeSchemeChange(false, '#ffffff') + } + this.toggleTranslucentUIColors() } } @@ -366,6 +377,31 @@ export class ThemeManager extends AbstractUIServicee { return bgColor.length ? bgColor : '#ffffff' } + private shouldUseTranslucentUI() { + return this.application.getPreference(PrefKey.UseTranslucentUI, PrefDefaults[PrefKey.UseTranslucentUI]) + } + + private toggleTranslucentUIColors() { + if (!this.shouldUseTranslucentUI()) { + document.documentElement.style.removeProperty('--popover-background-color') + document.documentElement.style.removeProperty('--popover-backdrop-filter') + document.body.classList.remove('translucent-ui') + return + } + try { + const backgroundColor = new Color(this.getBackgroundColor()) + const backdropFilter = backgroundColor.isDark() + ? 'blur(12px) saturate(190%) contrast(70%) brightness(80%)' + : 'blur(12px) saturate(190%) contrast(50%) brightness(130%)' + const translucentBackgroundColor = backgroundColor.setAlpha(0.65).toString() + document.documentElement.style.setProperty('--popover-background-color', translucentBackgroundColor) + document.documentElement.style.setProperty('--popover-backdrop-filter', backdropFilter) + document.body.classList.add('translucent-ui') + } catch (error) { + console.error(error) + } + } + /** * Syncs the active theme's background color to the 'theme-color' meta tag * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name/theme-color diff --git a/packages/web/src/components/assets/org.standardnotes.theme-focus/index.css b/packages/web/src/components/assets/org.standardnotes.theme-focus/index.css index 275426607..ade8aa640 100644 --- a/packages/web/src/components/assets/org.standardnotes.theme-focus/index.css +++ b/packages/web/src/components/assets/org.standardnotes.theme-focus/index.css @@ -47,5 +47,10 @@ --sn-stylekit-menu-border: 1px solid #424242; --navigation-item-selected-background-color: var(--background-color); --normal-button-background-color: var(--sn-stylekit-passive-color-5); - --menu-border-color: var(--sn-stylekit-border-color); + --popover-border-color: var(--sn-stylekit-passive-color-3); + --separator-color: var(--sn-stylekit-passive-color-3); +} + +.translucent-ui [role='dialog'] { + --sn-stylekit-border-color: var(--sn-stylekit-passive-color-3); } diff --git a/packages/web/src/javascripts/Components/AlertDialog/AlertDialog.tsx b/packages/web/src/javascripts/Components/AlertDialog/AlertDialog.tsx index a1c109e65..c060aa3a6 100644 --- a/packages/web/src/javascripts/Components/AlertDialog/AlertDialog.tsx +++ b/packages/web/src/javascripts/Components/AlertDialog/AlertDialog.tsx @@ -40,10 +40,13 @@ const AlertDialog = ({ />
{children}
diff --git a/packages/web/src/javascripts/Components/ChallengeModal/ChallengeModal.tsx b/packages/web/src/javascripts/Components/ChallengeModal/ChallengeModal.tsx index 592cdba81..762a4ad04 100644 --- a/packages/web/src/javascripts/Components/ChallengeModal/ChallengeModal.tsx +++ b/packages/web/src/javascripts/Components/ChallengeModal/ChallengeModal.tsx @@ -253,7 +253,7 @@ const ChallengeModal: FunctionComponent = ({ close={cancelChallenge} className={{ content: classNames( - 'sn-component challenge-modal relative m-0 flex h-full w-full flex-col items-center rounded border-solid border-border bg-default p-0 md:h-auto md:!w-max md:border', + 'sn-component challenge-modal relative m-0 flex h-full w-full flex-col items-center rounded border-solid border-border bg-default p-0 md:h-auto md:!w-max md:border md:translucent-ui:bg-[--popover-background-color] translucent-ui:[backdrop-filter:var(--popover-backdrop-filter)]', !isMobileScreen && 'shadow-overlay-light', isMobileOverlay && 'shadow-overlay-light border border-solid border-border', ), diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx index 7186c525a..75cabc46a 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx @@ -203,7 +203,11 @@ const ChangeEditorMenu: FunctionComponent = ({ return ( -
+
{group.items.map((menuItem) => { const onClickEditorItem = () => { handleMenuSelection(menuItem).catch(console.error) diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/NewNotePreferences.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/NewNotePreferences.tsx index 56d03a4c1..ceda06beb 100644 --- a/packages/web/src/javascripts/Components/ContentListView/Header/NewNotePreferences.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/Header/NewNotePreferences.tsx @@ -207,7 +207,7 @@ const NewNotePreferences: FunctionComponent = ({