├── .github └── FUNDING.yml ├── .gitignore ├── src ├── ui │ ├── shaders │ │ ├── v_shader.glsl │ │ ├── f_shader.glsl │ │ └── utils.glsl │ ├── components │ │ ├── top-bar │ │ │ ├── top-bar.css │ │ │ ├── FileColorProfile │ │ │ │ ├── FileColorProfile.css │ │ │ │ └── FileColorProfile.tsx │ │ │ ├── SettingsToggle │ │ │ │ ├── SettingsToggle.css │ │ │ │ └── SettingsToggle.tsx │ │ │ ├── OklchRenderModeToggle │ │ │ │ ├── OklchRenderModeToggle.css │ │ │ │ └── OklchRenderModeToggle.tsx │ │ │ └── ContrastToggle │ │ │ │ ├── ContrastToggle.css │ │ │ │ └── ContrastToggle.tsx │ │ ├── icons │ │ │ ├── SquareOklchIcon │ │ │ │ ├── SquareOklchIcon.css │ │ │ │ └── SquareOklchIcon.tsx │ │ │ ├── TriangleOklchIcon │ │ │ │ ├── TriangleOklchIcon.css │ │ │ │ └── TriangleOklchIcon.tsx │ │ │ ├── CopyIcon │ │ │ │ └── CopyIcon.tsx │ │ │ ├── ThreeDotsIcon │ │ │ │ └── ThreeDotsIcon.tsx │ │ │ ├── InfoIcon │ │ │ │ └── InfoIcon.tsx │ │ │ ├── DownArrowIcon │ │ │ │ └── DownArrowIcon.tsx │ │ │ ├── TwoLinesWithDotsIcon │ │ │ │ └── TwoLinesWithDotsIcon.tsx │ │ │ ├── ClosedLockIcon │ │ │ │ └── ClosedLockIcon.tsx │ │ │ ├── OpenLockIcon │ │ │ │ └── OpenLockIcon.tsx │ │ │ ├── CrossIcon │ │ │ │ └── CrossIcon.tsx │ │ │ └── ContrastIcon │ │ │ │ └── ContrastIcon.tsx │ │ ├── ColorValueInputs │ │ │ └── helpers │ │ │ │ ├── getStepUpdateValue │ │ │ │ └── getStepUpdateValue.ts │ │ │ │ ├── getColorHxyaValueFormatedForInput │ │ │ │ └── getColorHxyaValueFormatedForInput.ts │ │ │ │ ├── clampColorHxyaValueInInputFormat │ │ │ │ └── clampColorHxyaValueInInputFormat.ts │ │ │ │ ├── formatAndSendNewValueToStore │ │ │ │ └── formatAndSendNewValueToStore.ts │ │ │ │ ├── handleInputOnBlur │ │ │ │ └── handleInputOnBlur.ts │ │ │ │ └── handleInputOnKeyDown │ │ │ │ └── handleInputOnKeyDown.ts │ │ ├── Toggle │ │ │ ├── Toggle.css │ │ │ └── Toggle.tsx │ │ ├── single-input-with-lock │ │ │ ├── BgOrFgToggle │ │ │ │ ├── BgOrFgToggle.css │ │ │ │ └── BgOrFgToggle.tsx │ │ │ ├── RelativeChromaInput │ │ │ │ ├── helpers │ │ │ │ │ ├── handleInputOnKeyDown │ │ │ │ │ │ └── handleInputOnKeyDown.ts │ │ │ │ │ └── handleInputOnBlur │ │ │ │ │ │ └── handleInputOnBlur.ts │ │ │ │ └── RelativeChromaInput.tsx │ │ │ └── ContrastInput │ │ │ │ └── helpers │ │ │ │ ├── getNewContrastValueFromArrowKey │ │ │ │ └── getNewContrastValueFromArrowKey.ts │ │ │ │ ├── handleInputOnKeyDown │ │ │ │ └── handleInputOnKeyDown.ts │ │ │ │ └── handleInputOnBlur │ │ │ │ └── handleInputOnBlur.ts │ │ ├── FillOrStrokeToggle │ │ │ └── FillOrStrokeToggle.css │ │ ├── ColorModelSelect │ │ │ └── ColorModelSelect.tsx │ │ ├── InfoHoverTooltip │ │ │ ├── InfoHoverTooltip.tsx │ │ │ └── InfoHoverTooltip.css │ │ ├── SettingsScreen │ │ │ └── SettingsScreen.css │ │ ├── Alert │ │ │ ├── Alert.css │ │ │ └── Alert.tsx │ │ ├── ColorPicker │ │ │ ├── helpers │ │ │ │ ├── handleWheel │ │ │ │ │ └── handleWheel.ts │ │ │ │ ├── getNewManipulatorPosition │ │ │ │ │ └── getNewManipulatorPosition.ts │ │ │ │ ├── getRelativeChromaStrokeLimit │ │ │ │ │ └── getRelativeChromaStrokeLimit.ts │ │ │ │ ├── getSrgbStrokeLimit │ │ │ │ │ └── getSrgbStrokeLimit.ts │ │ │ │ ├── handleKeyDown │ │ │ │ │ └── handleKeyDown.ts │ │ │ │ ├── getContrastStrokeLimit │ │ │ │ │ └── getContrastStrokeLimit.ts │ │ │ │ └── handleNewManipulatorPosition │ │ │ │ │ └── handleNewManipulatorPosition.ts │ │ │ └── ColorPicker.css │ │ ├── ColorCodeInputs │ │ │ └── helpers │ │ │ │ ├── getColorCodeStrings │ │ │ │ └── getColorCodeStrings.ts │ │ │ │ ├── isColorCodeInGoodFormat │ │ │ │ └── isColorCodeInGoodFormat.ts │ │ │ │ └── getNewColorHxya │ │ │ │ └── getNewColorHxya.ts │ │ └── sliders │ │ │ ├── HueSlider │ │ │ └── HueSlider.tsx │ │ │ └── OpacitySlider │ │ │ └── OpacitySlider.tsx │ ├── tsconfig.json │ ├── styles │ │ ├── others.css │ │ ├── shared-components │ │ │ ├── c-copy-action.css │ │ │ ├── c-dropdown.css │ │ │ ├── c-single-input-with-lock.css │ │ │ └── c-slider.css │ │ ├── main.css │ │ └── utilities.css │ ├── ui-messages.ts │ ├── index.html │ ├── helpers │ │ ├── colors │ │ │ ├── getClampedChroma │ │ │ │ ├── getClampedChroma.spec.ts │ │ │ │ └── getClampedChroma.ts │ │ │ ├── convertRelativeChromaToAbsolute │ │ │ │ ├── convertRelativeChromaToAbsolute.spec.ts │ │ │ │ └── convertRelativeChromaToAbsolute.ts │ │ │ ├── convertAbsoluteChromaToRelative │ │ │ │ ├── convertAbsoluteChromaToRelative.spec.ts │ │ │ │ └── convertAbsoluteChromaToRelative.ts │ │ │ ├── convertRgbToHxy │ │ │ │ ├── convertRgbToHxy.spec.ts │ │ │ │ └── convertRgbToHxy.ts │ │ │ ├── convertHxyToRgb │ │ │ │ ├── convertHxyToRgb.spec.ts │ │ │ │ └── convertHxyToRgb.ts │ │ │ ├── filterNewColorHxya │ │ │ │ ├── filterNewColorHxya.spec.ts │ │ │ │ └── filterNewColorHxya.ts │ │ │ ├── getHxyaInputRange │ │ │ │ └── getHxyaInputRange.ts │ │ │ ├── getColorPickerResolutionInfos │ │ │ │ └── getColorPickerResolutionInfos.ts │ │ │ └── getColorHxyDecimals │ │ │ │ └── getColorHxyDecimals.ts │ │ ├── limitMouseManipulatorPosition │ │ │ └── limitMouseManipulatorPosition.ts │ │ ├── sendMessageToBackend │ │ │ └── sendMessageToBackend.ts │ │ ├── getLinearMappedValue │ │ │ └── getLinearMappedValue.ts │ │ ├── selectInputContent │ │ │ └── selectInputContent.ts │ │ ├── contrasts │ │ │ ├── getContrastRange │ │ │ │ └── getContrastRange.ts │ │ │ ├── filterNewContrast │ │ │ │ ├── filterNewContrast.spec.ts │ │ │ │ └── filterNewContrast.ts │ │ │ ├── getContrastFromBgandFgRgba │ │ │ │ ├── getContrastFromBgandFgRgba.spec.ts │ │ │ │ └── getContrastFromBgandFgRgba.ts │ │ │ ├── getNewXandYFromContrast │ │ │ │ └── getNewXandYFromContrast.spec.ts │ │ │ └── WCAGcontrast │ │ │ │ └── WCAGcontrast.ts │ │ ├── inputs │ │ │ └── parseInputString │ │ │ │ └── parseInputString.ts │ │ ├── copyToClipboard │ │ │ └── copyToClipboard.ts │ │ └── setValuesForUiMessage │ │ │ └── setValuesForUiMessage.ts │ └── stores │ │ ├── selectionId │ │ └── selectionId.ts │ │ ├── figmaEditorType │ │ └── figmaEditorType.ts │ │ ├── isMouseInsideDocument │ │ └── isMouseInsideDocument.ts │ │ ├── settings │ │ ├── isSetttingsScreenOpen │ │ │ └── isSetttingsScreenOpen.ts │ │ └── userSettings │ │ │ └── userSettings.ts │ │ ├── mouseEventCallback │ │ └── mouseEventCallback.ts │ │ ├── currentKeysPressed │ │ └── currentKeysPressed.ts │ │ ├── colors │ │ ├── currentFileColorProfile │ │ │ └── currentFileColorProfile.ts │ │ ├── lockRelativeChroma │ │ │ └── lockRelativeChroma.ts │ │ ├── isColorCodeInputsOpen │ │ │ └── isColorCodeInputsOpen.ts │ │ ├── relativeChroma │ │ │ └── relativeChroma.ts │ │ ├── colorsRgba │ │ │ └── colorsRgba.ts │ │ └── colorHxya │ │ │ └── colorHxya.ts │ │ ├── oklchRenderMode │ │ └── oklchRenderMode.ts │ │ ├── contrasts │ │ ├── isContrastInputOpen │ │ │ └── isContrastInputOpen.ts │ │ ├── currentContrastMethod │ │ │ └── currentContrastMethod.ts │ │ ├── currentBgOrFg │ │ │ └── currentBgOrFg.ts │ │ ├── lockContrast │ │ │ └── lockContrast.ts │ │ └── contrast │ │ │ └── contrast.ts │ │ ├── uiMessage │ │ └── uiMessage.ts │ │ └── currentFillOrStroke │ │ └── currentFillOrStroke.ts ├── backend │ ├── tsconfig.json │ └── helpers │ │ ├── sendMessageToUi │ │ └── sendMessageToUi.ts │ │ ├── getWindowHeigh │ │ └── getWindowHeigh.ts │ │ └── updateShapeColor │ │ └── updateShapeColor.ts └── constants.ts ├── virtual-shaders-declaration.d.ts ├── .prettierrc ├── manifest.json ├── vite-backend.config.ts ├── vite-ui.config.ts ├── LICENSE.txt ├── eslint.config.mjs ├── README.md └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: dokozero -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules -------------------------------------------------------------------------------- /src/ui/shaders/v_shader.glsl: -------------------------------------------------------------------------------- 1 | attribute vec4 position; 2 | 3 | void main() { 4 | gl_Position = position; 5 | } 6 | -------------------------------------------------------------------------------- /virtual-shaders-declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@virtual:shaders/*' { 2 | const plainText: string 3 | export default plainText 4 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "semi": false, 5 | "quoteProps": "consistent", 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "printWidth": 150 9 | } 10 | -------------------------------------------------------------------------------- /src/ui/components/top-bar/top-bar.css: -------------------------------------------------------------------------------- 1 | .c-top-bar { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | padding: 8px 16px; 6 | 7 | border-bottom: 1px solid var(--figma-color-border); 8 | } 9 | -------------------------------------------------------------------------------- /src/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 5 | "strict": true, 6 | "moduleResolution": "bundler", 7 | "jsx": "react-jsx" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["ESNext"], 5 | "strict": true, 6 | "moduleResolution": "bundler", 7 | "typeRoots": ["../../node_modules/@types", "../../node_modules/@figma"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "OkColor", 3 | "id": "1173638098109123591", 4 | "api": "1.0.0", 5 | "editorType": ["figma", "figjam"], 6 | "main": "dist/backend.js", 7 | "ui": "dist/index.html", 8 | "networkAccess": { 9 | "allowedDomains": ["none"], 10 | "reasoning": "" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/styles/others.css: -------------------------------------------------------------------------------- 1 | body.deactivated .c-oklch-render-mode-toggle, 2 | body.deactivated .c-contrast-toggle, 3 | body.deactivated .c-contrast-input, 4 | body.deactivated .c-file-color-profile, 5 | body.deactivated .o-bottom-controls { 6 | pointer-events: none; 7 | opacity: 0.5; 8 | filter: grayscale(1); 9 | } 10 | -------------------------------------------------------------------------------- /src/ui/components/icons/SquareOklchIcon/SquareOklchIcon.css: -------------------------------------------------------------------------------- 1 | .c-square-oklch-icon__outerline, 2 | .c-square-oklch-icon__square-line { 3 | fill: var(--figma-color-icon); 4 | } 5 | 6 | .c-square-oklch-icon__square-fill { 7 | fill: #ababab; 8 | } 9 | 10 | .figma-dark .c-square-oklch-icon__square-fill { 11 | fill: #898989; 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/components/icons/TriangleOklchIcon/TriangleOklchIcon.css: -------------------------------------------------------------------------------- 1 | .c-triangle-oklch-icon__outerline, 2 | .c-triangle-oklch-icon__triangle-line { 3 | fill: var(--figma-color-icon); 4 | } 5 | 6 | .c-triangle-oklch-icon__triangle-fill { 7 | fill: #ababab; 8 | } 9 | 10 | .figma-dark .c-triangle-oklch-icon__triangle-fill { 11 | fill: #898989; 12 | } 13 | -------------------------------------------------------------------------------- /src/backend/helpers/sendMessageToUi/sendMessageToUi.ts: -------------------------------------------------------------------------------- 1 | import { MessageForUiData, MessageForUiTypes } from '../../../types' 2 | 3 | // We use this simple fonction to get type completion. 4 | export default function sendMessageToUi(props: { type: MessageForUiTypes; data: T }) { 5 | figma.ui.postMessage({ type: props.type, data: props.data }) 6 | } 7 | -------------------------------------------------------------------------------- /src/ui/ui-messages.ts: -------------------------------------------------------------------------------- 1 | export const uiMessageTexts = { 2 | no_selection: 'No shape selected.', 3 | not_all_shapes_have_fill_or_stroke: 'With multiple shapes selected, they should all have a fill or a stroke with a solid color.', 4 | not_supported_type: 'This type is not supported ($SHAPE).', 5 | no_color_in_shape: "This shape doesn't have color.", 6 | no_solid_color: 'Only solid colors are supported.' 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OkColor 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/getClampedChroma/getClampedChroma.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import getClampedChroma from './getClampedChroma' 3 | 4 | describe('getClampedChroma()', () => { 5 | test('0.267529', () => { 6 | expect( 7 | getClampedChroma( 8 | { 9 | h: 260, 10 | x: 0.4, 11 | y: 55 12 | }, 13 | 'p3' 14 | ) 15 | ).toStrictEqual(0.267529) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/ui/helpers/limitMouseManipulatorPosition/limitMouseManipulatorPosition.ts: -------------------------------------------------------------------------------- 1 | // To avoid getting the manipulators going off, for example the canvas of ColorPicker. 2 | export default function limitMouseManipulatorPosition(value: number): number { 3 | const minThreshold = 0.0001 4 | const maxThreshold = 1 - minThreshold 5 | 6 | if (value < minThreshold) return minThreshold 7 | else if (value > maxThreshold) return maxThreshold 8 | else return value 9 | } 10 | -------------------------------------------------------------------------------- /vite-backend.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig } from 'vite' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | build: { 7 | lib: { 8 | // Could also be a dictionary or array of multiple entry points. 9 | entry: resolve(__dirname, './src/backend/main.ts'), 10 | // the proper extensions will be added. 11 | fileName: 'backend', 12 | formats: ['es'] 13 | }, 14 | outDir: resolve(__dirname, 'dist'), 15 | emptyOutDir: false 16 | } 17 | }) 18 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/convertRelativeChromaToAbsolute/convertRelativeChromaToAbsolute.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import convertRelativeChromaToAbsolute from './convertRelativeChromaToAbsolute' 3 | 4 | describe('convertRelativeChromaToAbsolute()', () => { 5 | test('0.133755', () => { 6 | expect( 7 | convertRelativeChromaToAbsolute({ 8 | h: 260, 9 | y: 55, 10 | relativeChroma: 50, 11 | currentFileColorProfile: 'p3' 12 | }) 13 | ).toStrictEqual(0.133755) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/ui/components/top-bar/FileColorProfile/FileColorProfile.css: -------------------------------------------------------------------------------- 1 | .c-file-color-profile { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | gap: 2px; 6 | font-size: var(--base-font-size); 7 | color: var(--figma-color-text); 8 | } 9 | 10 | .c-file-color-profile__label { 11 | font-size: var(--base-font-size); 12 | color: var(--figma-color-text); 13 | margin-right: 20px; 14 | } 15 | 16 | .c-file-color-profile .select-wrapper { 17 | width: 82px; 18 | } 19 | 20 | .c-file-color-profile select { 21 | padding: 7px; 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/convertAbsoluteChromaToRelative/convertAbsoluteChromaToRelative.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import convertAbsoluteChromaToRelative from './convertAbsoluteChromaToRelative' 3 | 4 | describe('convertAbsoluteChromaToRelative()', () => { 5 | test('75', () => { 6 | expect( 7 | convertAbsoluteChromaToRelative({ 8 | colorHxy: { 9 | h: 260, 10 | x: 0.2, 11 | y: 55 12 | }, 13 | currentFileColorProfile: 'p3' 14 | }) 15 | ).toStrictEqual(75) 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/ui/components/icons/CopyIcon/CopyIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function CopyIcon() { 2 | return ( 3 | 4 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/stores/selectionId/selectionId.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../constants' 4 | import { SelectionId } from '../../../types' 5 | 6 | export const $selectionId = atom('') 7 | 8 | export const setSelectionId = action($selectionId, 'setSelectionId', (selectionId, newSelectionId: SelectionId) => { 9 | selectionId.set(newSelectionId) 10 | }) 11 | 12 | if (consoleLogInfos.includes('Store updates')) { 13 | logger({ 14 | selectionId: $selectionId 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/ui/helpers/sendMessageToBackend/sendMessageToBackend.ts: -------------------------------------------------------------------------------- 1 | import { useBackend } from '../../../constants' 2 | import { MessageForBackendData, MessageForBackendTypes } from '../../../types' 3 | 4 | // We use this simple fonction to get type completion. 5 | export default function sendMessageToBackend(props: { type: MessageForBackendTypes; data?: T }) { 6 | if (!useBackend) return 7 | 8 | parent.postMessage( 9 | { 10 | pluginMessage: { 11 | type: props.type, 12 | data: props.data 13 | } 14 | }, 15 | '*' 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/convertRgbToHxy/convertRgbToHxy.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import convertRgbToHxy from './convertRgbToHxy' 3 | 4 | describe('convertRgbToHxy()', () => { 5 | test('{ h: 244.8, x: 0.133961, y: 58.4 }', () => { 6 | expect( 7 | convertRgbToHxy({ 8 | colorRgb: { 9 | r: 0.25, 10 | g: 0.5, 11 | b: 0.75 12 | }, 13 | targetColorModel: 'oklch', 14 | gamut: 'p3' 15 | }) 16 | ).toStrictEqual({ 17 | h: 244.8, 18 | x: 0.133961, 19 | y: 58.4 20 | }) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/ui/components/icons/ThreeDotsIcon/ThreeDotsIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function ThreeDotsIcon() { 2 | return ( 3 | 4 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/components/top-bar/SettingsToggle/SettingsToggle.css: -------------------------------------------------------------------------------- 1 | .c-settings-toggle { 2 | padding: 5px; 3 | border-radius: var(--base-border-radius); 4 | } 5 | 6 | .c-settings-toggle:hover { 7 | background-color: var(--figma-color-bg-secondary); 8 | } 9 | 10 | .c-settings-toggle--active { 11 | background-color: var(--figma-color-bg-selected); 12 | } 13 | 14 | .c-settings-toggle--active:hover { 15 | background-color: var(--figma-color-bg-selected-secondary); 16 | } 17 | 18 | .c-settings-toggle svg { 19 | fill: var(--figma-color-icon); 20 | } 21 | 22 | .c-settings-toggle--active svg { 23 | fill: var(--figma-color-icon-brand); 24 | } 25 | -------------------------------------------------------------------------------- /src/ui/stores/figmaEditorType/figmaEditorType.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../constants' 4 | import { FigmaEditorType } from '../../../types' 5 | 6 | export const $figmaEditorType = atom(null) 7 | 8 | export const setFigmaEditorType = action($figmaEditorType, 'setFigmaEditorType', (figmaEditorType, newFigmaEditorType: FigmaEditorType) => { 9 | figmaEditorType.set(newFigmaEditorType) 10 | }) 11 | 12 | if (consoleLogInfos.includes('Store updates')) { 13 | logger({ 14 | figmaEditorType: $figmaEditorType 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/ui/components/icons/InfoIcon/InfoIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function InfoIcon() { 2 | return ( 3 | 4 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/helpers/getLinearMappedValue/getLinearMappedValue.ts: -------------------------------------------------------------------------------- 1 | type Props = { 2 | valueToMap: number 3 | originalRange: { 4 | min: number 5 | max: number 6 | } 7 | targetRange: { 8 | min: number 9 | max: number 10 | } 11 | } 12 | 13 | /** 14 | * Get valueToMap equivalent from originalRange in targetRange using linear interpolation. 15 | */ 16 | export default function getLinearMappedValue(props: Props): number { 17 | const { valueToMap, originalRange, targetRange } = props 18 | 19 | return ((valueToMap - originalRange.min) * (targetRange.max - targetRange.min)) / (originalRange.max - originalRange.min) + targetRange.min 20 | } 21 | -------------------------------------------------------------------------------- /src/ui/components/top-bar/OklchRenderModeToggle/OklchRenderModeToggle.css: -------------------------------------------------------------------------------- 1 | .c-oklch-render-mode-toggle { 2 | display: flex; 3 | gap: 4px; 4 | justify-content: space-between; 5 | align-items: center; 6 | } 7 | 8 | .c-oklch-render-mode-toggle__element { 9 | padding: 4px; 10 | border-radius: var(--base-border-radius); 11 | } 12 | .c-oklch-render-mode-toggle__element:hover { 13 | background-color: var(--figma-color-bg-secondary); 14 | } 15 | 16 | .c-oklch-render-mode-toggle__element--active { 17 | background-color: var(--figma-color-bg-secondary); 18 | } 19 | 20 | .c-oklch-render-mode-toggle--deactivated { 21 | pointer-events: none; 22 | opacity: 0.5; 23 | } 24 | -------------------------------------------------------------------------------- /src/ui/stores/isMouseInsideDocument/isMouseInsideDocument.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../constants' 4 | 5 | export const $isMouseInsideDocument = atom(false) 6 | 7 | export const setIsMouseInsideDocument = action( 8 | $isMouseInsideDocument, 9 | 'setIsMouseInsideDocument', 10 | (isMouseInsideDocument, newIsMouseInsideDocument: boolean) => { 11 | isMouseInsideDocument.set(newIsMouseInsideDocument) 12 | } 13 | ) 14 | 15 | if (consoleLogInfos.includes('Store updates')) { 16 | logger({ 17 | isMouseInsideDocument: $isMouseInsideDocument 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/ui/helpers/selectInputContent/selectInputContent.ts: -------------------------------------------------------------------------------- 1 | export default function selectInputContent(event: React.MouseEvent) { 2 | const eventTarget = event.target as HTMLInputElement 3 | 4 | eventTarget.select() 5 | 6 | // This is a fix as in some cases, if the user update the value of an input then click again inside it, in some cases the above select will not work. To counter this, we use this setTimeout callback. 7 | // Update, we deactivate it for now as updating multiple time the same input lead to error because the below select can happen while editing the input. 8 | // setTimeout(() => { 9 | // eventTarget.select() 10 | // }, 10) 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/stores/settings/isSetttingsScreenOpen/isSetttingsScreenOpen.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../../constants' 4 | 5 | export const $isSettingsScreenOpen = atom(false) 6 | 7 | export const setIsSettingsScreenOpen = action( 8 | $isSettingsScreenOpen, 9 | 'setIsSettingsScreenOpen', 10 | (isSettingsScreenOpen, newIsSettingsScreenOpen: boolean) => { 11 | isSettingsScreenOpen.set(newIsSettingsScreenOpen) 12 | } 13 | ) 14 | 15 | if (consoleLogInfos.includes('Store updates')) { 16 | logger({ 17 | isSettingsScreenOpen: $isSettingsScreenOpen 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/convertHxyToRgb/convertHxyToRgb.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import convertHxyToRgb from './convertHxyToRgb' 3 | 4 | describe('convertHxyToRgb()', () => { 5 | test('{ b: 0.8609332257422432, g: 0.40603947315377714, r: 0.20524954244022237 }', () => { 6 | expect( 7 | convertHxyToRgb({ 8 | colorHxy: { 9 | h: 260, 10 | x: 0.2, 11 | y: 55 12 | }, 13 | originColorModel: 'oklch', 14 | gamut: 'p3' 15 | }) 16 | ).toStrictEqual({ 17 | b: 0.8609332257422432, 18 | g: 0.40603947315377714, 19 | r: 0.20524954244022237 20 | }) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/ui/helpers/contrasts/getContrastRange/getContrastRange.ts: -------------------------------------------------------------------------------- 1 | import { ContrastRange } from '../../../../types' 2 | import { $currentContrastMethod } from '../../../stores/contrasts/currentContrastMethod/currentContrastMethod' 3 | 4 | export default function getContrastRange(currentContrastMethod = $currentContrastMethod.get()): ContrastRange { 5 | switch (currentContrastMethod) { 6 | case 'apca': 7 | return { 8 | negative: { min: -7, max: -108 }, 9 | positive: { min: 7, max: 106 } 10 | } 11 | 12 | case 'wcag': 13 | return { 14 | negative: { min: -1, max: -21 }, 15 | positive: { min: 1, max: 21 } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/ui/stores/mouseEventCallback/mouseEventCallback.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../constants' 4 | 5 | export const $mouseEventCallback = atom<((event: MouseEvent) => void) | null>(null) 6 | 7 | export const setMouseEventCallback = action( 8 | $mouseEventCallback, 9 | 'setMouseEventCallback', 10 | (mouseEventCallback, newMouseEventCallback: ((event: MouseEvent) => void) | null) => { 11 | mouseEventCallback.set(newMouseEventCallback) 12 | } 13 | ) 14 | 15 | if (consoleLogInfos.includes('Store updates')) { 16 | logger({ 17 | mouseEventCallback: $mouseEventCallback 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/ui/stores/currentKeysPressed/currentKeysPressed.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../constants' 4 | import { CurrentKeysPressed } from '../../../types' 5 | 6 | export const $currentKeysPressed = atom(['']) 7 | 8 | export const setCurrentKeysPressed = action( 9 | $currentKeysPressed, 10 | 'setCurrentKeysPressed', 11 | (currentKeysPressed, newCurrentKeysPressed: CurrentKeysPressed) => { 12 | currentKeysPressed.set(newCurrentKeysPressed) 13 | } 14 | ) 15 | 16 | if (consoleLogInfos.includes('Store updates')) { 17 | logger({ 18 | currentKeysPressed: $currentKeysPressed 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // Set to false to be able to run the index.html from dist/ in the browser (usefull for debugging, especially the rendering of color picker). 2 | export const useBackend = true 3 | 4 | export const WINDOW_WIDTH = 260 5 | 6 | export const PICKER_SIZE = WINDOW_WIDTH - 32 7 | 8 | // We use a different value for the slider as they take less room. 9 | export const SLIDER_SIZE = 161 10 | 11 | export const OKLCH_CHROMA_SCALE = 2.7 12 | 13 | export const MAX_CHROMA_P3 = 0.368 14 | 15 | // prettier-ignore 16 | // Comment the lines you don't want. 17 | export const consoleLogInfos = [ 18 | // 'Store updates', 19 | // 'Component renders', 20 | // 'App loading speed', 21 | // 'Color picker rendering speed', 22 | '' 23 | ] 24 | -------------------------------------------------------------------------------- /src/ui/components/top-bar/ContrastToggle/ContrastToggle.css: -------------------------------------------------------------------------------- 1 | .c-contrast-toggle { 2 | padding: 5px; 3 | border-radius: var(--base-border-radius); 4 | } 5 | 6 | .c-contrast-toggle--deactivated { 7 | pointer-events: none; 8 | opacity: 0.5; 9 | } 10 | 11 | .c-contrast-toggle:hover { 12 | background-color: var(--figma-color-bg-secondary); 13 | } 14 | 15 | .c-contrast-toggle--active { 16 | background-color: var(--figma-color-bg-selected); 17 | } 18 | 19 | .c-contrast-toggle--active:hover { 20 | background-color: var(--figma-color-bg-selected-secondary); 21 | } 22 | 23 | .c-contrast-toggle svg { 24 | fill: var(--figma-color-icon); 25 | } 26 | 27 | .c-contrast-toggle--active svg { 28 | fill: var(--figma-color-icon-brand); 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/components/ColorValueInputs/helpers/getStepUpdateValue/getStepUpdateValue.ts: -------------------------------------------------------------------------------- 1 | import { $currentColorModel } from '../../../../stores/colors/currentColorModel/currentColorModel' 2 | import { $currentKeysPressed } from '../../../../stores/currentKeysPressed/currentKeysPressed' 3 | import { $userSettings } from '../../../../stores/settings/userSettings/userSettings' 4 | 5 | export default function getStepUpdateValue(eventId: string): number { 6 | const shiftPressed = $currentKeysPressed.get().includes('shift') 7 | 8 | if (eventId === 'x' && $currentColorModel.get() === 'oklch') { 9 | if ($userSettings.get().useSimplifiedChroma) return shiftPressed ? 0.5 : 0.1 10 | else return shiftPressed ? 0.005 : 0.001 11 | } 12 | 13 | return shiftPressed ? 5 : 1 14 | } 15 | -------------------------------------------------------------------------------- /src/ui/styles/shared-components/c-copy-action.css: -------------------------------------------------------------------------------- 1 | .c-copy-action { 2 | visibility: hidden; 3 | padding: 0 5px 0 2px; 4 | position: absolute; 5 | right: 1px; 6 | height: 22px; 7 | border-radius: var(--base-border-radius); 8 | background-color: var(--figma-color-bg-secondary); 9 | font-weight: 400; 10 | font-size: var(--base-font-size); 11 | color: var(--figma-color-text-secondary); 12 | } 13 | 14 | .c-copy-action__wrapper { 15 | display: flex; 16 | align-items: center; 17 | } 18 | 19 | .c-copy-action svg { 20 | fill: var(--figma-color-icon-secondary); 21 | } 22 | 23 | .c-dropdown__content-wraper .input-wrapper:hover .c-copy-action { 24 | visibility: visible; 25 | } 26 | 27 | .c-copy-action--copied .c-copy-action__wrapper { 28 | opacity: 0.5; 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/components/Toggle/Toggle.css: -------------------------------------------------------------------------------- 1 | .c-toggle { 2 | padding: 8px; 3 | } 4 | 5 | .c-toggle-element { 6 | position: relative; 7 | width: 32px; 8 | height: 16px; 9 | border-radius: 99px; 10 | background-color: var(--figma-color-bg-tertiary); 11 | } 12 | 13 | .c-toggle-element::before { 14 | content: ' '; 15 | display: block; 16 | position: absolute; 17 | top: 1px; 18 | right: 17px; 19 | width: 14px; 20 | height: 14px; 21 | background-color: white; 22 | border-radius: 99px; 23 | transition: right 0.1s ease-in; 24 | } 25 | 26 | .figma-light .c-toggle-element--active, 27 | .figma-dark .c-toggle-element--active { 28 | background-color: var(--figma-color-bg-brand); 29 | } 30 | 31 | .c-toggle-element--active::before { 32 | left: unset; 33 | right: 1px; 34 | } 35 | -------------------------------------------------------------------------------- /src/ui/components/single-input-with-lock/BgOrFgToggle/BgOrFgToggle.css: -------------------------------------------------------------------------------- 1 | .c-bg-or-fg-toggle { 2 | display: flex; 3 | border-radius: var(--base-border-radius); 4 | background-color: var(--figma-color-bg-secondary); 5 | } 6 | 7 | .c-bg-or-fg-toggle__element { 8 | padding: 4px; 9 | border: 1px solid transparent; 10 | border-radius: var(--base-border-radius); 11 | } 12 | 13 | .c-bg-or-fg-toggle__element--active { 14 | border: 1px solid var(--figma-color-border); 15 | background-color: var(--figma-color-bg); 16 | } 17 | 18 | .c-bg-or-fg-toggle__element-label { 19 | font-size: var(--base-font-size); 20 | font-weight: 400; 21 | color: var(--figma-color-text-secondary); 22 | } 23 | 24 | .c-bg-or-fg-toggle__element--active .c-bg-or-fg-toggle__element-label { 25 | font-weight: 500; 26 | } 27 | -------------------------------------------------------------------------------- /src/ui/stores/colors/currentFileColorProfile/currentFileColorProfile.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../../constants' 4 | import { CurrentFileColorProfile } from '../../../../types' 5 | 6 | export const $currentFileColorProfile = atom('rgb') 7 | 8 | export const setCurrentFileColorProfile = action( 9 | $currentFileColorProfile, 10 | 'setCurrentFileColorProfile', 11 | (currentFileColorProfile, newCurrentFileColorProfile: CurrentFileColorProfile) => { 12 | currentFileColorProfile.set(newCurrentFileColorProfile) 13 | } 14 | ) 15 | 16 | if (consoleLogInfos.includes('Store updates')) { 17 | logger({ 18 | currentFileColorProfile: $currentFileColorProfile 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/ui/components/icons/DownArrowIcon/DownArrowIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function DownArrowIcon() { 2 | return ( 3 | 4 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/components/Toggle/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import { consoleLogInfos } from '../../../constants' 2 | 3 | type Props = { 4 | value: boolean 5 | setValue?: (value: boolean) => void 6 | } 7 | 8 | export default function Toggle(props: Props) { 9 | if (consoleLogInfos.includes('Component renders')) { 10 | console.log(`Component render — Toggle`) 11 | } 12 | 13 | const { value, setValue } = props 14 | 15 | const handleToggle = () => { 16 | // We test if setValue() exists because in some cases we can have the toggle action controlled by the onClick from the parent. 17 | if (setValue) setValue(!value) 18 | } 19 | 20 | return ( 21 |
22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/filterNewColorHxya/filterNewColorHxya.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import filterNewColorHxya from './filterNewColorHxya' 3 | import { setCurrentFileColorProfile } from '../../../stores/colors/currentFileColorProfile/currentFileColorProfile' 4 | 5 | describe('filterNewColorHxya()', () => { 6 | test('{ h: 270, x: 0.3, y: 50, a: 0.97 }', () => { 7 | setCurrentFileColorProfile('p3') 8 | expect( 9 | filterNewColorHxya({ 10 | newColorHxya: { 11 | h: 270, 12 | x: 0.3, 13 | y: 50, 14 | a: 0.9678 15 | }, 16 | lockRelativeChroma: false, 17 | lockContrast: false 18 | }) 19 | ).toStrictEqual({ 20 | h: 270, 21 | x: 0.3, 22 | y: 50, 23 | a: 0.97 24 | }) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/ui/helpers/contrasts/filterNewContrast/filterNewContrast.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import filterNewContrast from './filterNewContrast' 3 | import { setCurrentContrastMethod } from '../../../stores/contrasts/currentContrastMethod/currentContrastMethod' 4 | 5 | describe('filterNewContrast()', () => { 6 | test('7', () => { 7 | setCurrentContrastMethod('apca') 8 | expect(filterNewContrast(4)).toStrictEqual(7) 9 | }) 10 | test('-7', () => { 11 | setCurrentContrastMethod('apca') 12 | expect(filterNewContrast(-4)).toStrictEqual(-7) 13 | }) 14 | test('1', () => { 15 | setCurrentContrastMethod('wcag') 16 | expect(filterNewContrast(0)).toStrictEqual(1) 17 | }) 18 | test('-1', () => { 19 | setCurrentContrastMethod('wcag') 20 | expect(filterNewContrast(-1)).toStrictEqual(-1) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/ui/helpers/inputs/parseInputString/parseInputString.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * If string contains mathematical operators, evaluates the expression and returns the result. 3 | * If not, simply parses the string as a number. 4 | */ 5 | export default function parseInputString(inputString: string): number | null { 6 | // Check if the input contains mathematical operators 7 | const containsMathOperators = /[+\-*/]/.test(inputString) 8 | 9 | if (containsMathOperators) { 10 | try { 11 | // Use Function constructor to safely evaluate the expression 12 | return Function('return ' + inputString)() 13 | } catch { 14 | return null // Expression evaluation failed 15 | } 16 | } else { 17 | // Parse as a simple number 18 | const parsedValue = parseFloat(inputString) 19 | 20 | return isNaN(parsedValue) ? null : parsedValue 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/styles/shared-components/c-dropdown.css: -------------------------------------------------------------------------------- 1 | .c-dropdown { 2 | border-top: 1px solid var(--figma-color-border); 3 | font-size: var(--base-font-size); 4 | font-weight: 400; 5 | color: var(--figma-color-text); 6 | } 7 | .c-dropdown--open { 8 | font-weight: 500; 9 | } 10 | .c-dropdown__title-wrapper { 11 | display: flex; 12 | justify-content: space-between; 13 | align-items: center; 14 | padding: 16px; 15 | } 16 | .c-dropdown__arrow-icon { 17 | position: relative; 18 | top: 0px; 19 | opacity: 0.5; 20 | transform: rotate(-180deg); 21 | } 22 | 23 | .c-dropdown__arrow-icon svg { 24 | fill: var(--figma-color-icon); 25 | } 26 | 27 | .c-dropdown__title-wrapper:hover .c-dropdown__arrow-icon { 28 | opacity: 1; 29 | } 30 | .c-dropdown__arrow-icon--open { 31 | transform: rotate(0deg); 32 | } 33 | 34 | .c-dropdown__content-wraper { 35 | margin-top: -4px; 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/getClampedChroma/getClampedChroma.ts: -------------------------------------------------------------------------------- 1 | import { ColorHxy, RelativeChroma } from '../../../../types' 2 | import { $currentFileColorProfile } from '../../../stores/colors/currentFileColorProfile/currentFileColorProfile' 3 | import round from 'lodash/round' 4 | import getColorHxyDecimals from '../getColorHxyDecimals/getColorHxyDecimals' 5 | import { clampChroma } from 'culori' 6 | 7 | export default function getClampedChroma(colorHxy: ColorHxy, currentFileColorProfile = $currentFileColorProfile.get()): RelativeChroma { 8 | const clamped = clampChroma({ mode: 'oklch', l: colorHxy.y / 100, c: colorHxy.x, h: colorHxy.h }, 'oklch', currentFileColorProfile) 9 | 10 | // If we send a pure black to clampChroma (l and c to 0), clamped.c will be undefined. 11 | if (!clamped.c) return 0 12 | 13 | if (colorHxy.x > clamped.c) return round(clamped.c, getColorHxyDecimals().x) 14 | 15 | return colorHxy.x 16 | } 17 | -------------------------------------------------------------------------------- /src/ui/components/icons/TwoLinesWithDotsIcon/TwoLinesWithDotsIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function TwoLinesWithDotsIcon() { 2 | return ( 3 | 4 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/components/FillOrStrokeToggle/FillOrStrokeToggle.css: -------------------------------------------------------------------------------- 1 | .c-fill-or-stroke-toggle { 2 | position: relative; 3 | width: 38px; 4 | height: 38px; 5 | padding: 5px; 6 | border-radius: var(--base-border-radius); 7 | } 8 | .c-fill-or-stroke-toggle:hover { 9 | background-color: var(--figma-color-bg-hover); 10 | } 11 | .c-fill-or-stroke-toggle__fill, 12 | .c-fill-or-stroke-toggle__stroke { 13 | position: absolute; 14 | } 15 | .c-fill-or-stroke-toggle[data-active='fill'] .c-fill-or-stroke-toggle__fill { 16 | z-index: 1; 17 | } 18 | .c-fill-or-stroke-toggle[data-active='stroke'] .c-fill-or-stroke-toggle__stroke { 19 | z-index: 1; 20 | } 21 | .c-fill-or-stroke-toggle__stroke { 22 | left: 13px; 23 | top: 13px; 24 | } 25 | .c-fill-or-stroke-toggle[data-has-fill='false'] .c-fill-or-stroke-toggle__fill circle, 26 | .c-fill-or-stroke-toggle[data-has-stroke='false'] .c-fill-or-stroke-toggle__stroke path { 27 | fill: none; 28 | opacity: 0.3; 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/components/top-bar/SettingsToggle/SettingsToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useStore } from '@nanostores/react' 2 | import { consoleLogInfos } from '../../../../constants' 3 | import { $isSettingsScreenOpen, setIsSettingsScreenOpen } from '../../../stores/settings/isSetttingsScreenOpen/isSetttingsScreenOpen' 4 | import TwoLinesWithDotsIcon from '../../icons/TwoLinesWithDotsIcon/TwoLinesWithDotsIcon' 5 | 6 | const handleIsSettingsScreenOpen = () => { 7 | setIsSettingsScreenOpen(!$isSettingsScreenOpen.get()) 8 | } 9 | 10 | export default function SettingsToggle() { 11 | if (consoleLogInfos.includes('Component renders')) { 12 | console.log('Component render — SettingsToggle') 13 | } 14 | 15 | const isSettingsScreenOpen = useStore($isSettingsScreenOpen) 16 | 17 | return ( 18 |
19 | 20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/getHxyaInputRange/getHxyaInputRange.ts: -------------------------------------------------------------------------------- 1 | import { MAX_CHROMA_P3 } from '../../../../constants' 2 | import { HxyaLabels, HxyaTypes } from '../../../../types' 3 | import { $currentColorModel } from '../../../stores/colors/currentColorModel/currentColorModel' 4 | import { $userSettings } from '../../../stores/settings/userSettings/userSettings' 5 | 6 | type ReturnObject = { 7 | min: HxyaTypes 8 | max: HxyaTypes 9 | } 10 | 11 | export default function getHxyaInputRange(property: keyof typeof HxyaLabels, currentColorModel = $currentColorModel.get()): ReturnObject { 12 | switch (property) { 13 | case 'h': 14 | return { min: 0, max: 360 } 15 | 16 | case 'x': 17 | if (['okhsv', 'okhsl'].includes(currentColorModel)) return { min: 0, max: 100 } 18 | else return { min: 0, max: $userSettings.get().useSimplifiedChroma ? MAX_CHROMA_P3 * 100 : MAX_CHROMA_P3 } 19 | 20 | case 'y': 21 | return { min: 0, max: 100 } 22 | 23 | case 'a': 24 | return { min: 0, max: 100 } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /vite-ui.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import { defineConfig } from 'vite' 3 | import react from '@vitejs/plugin-react' 4 | import { viteSingleFile } from 'vite-plugin-singlefile' 5 | import plainText from 'vite-plugin-virtual-plain-text' 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | react(), 11 | plainText({ virtualNamespace: '@virtual:shaders/', dtsAutoGen: 'virtual-shaders-declaration' }), 12 | { 13 | name: 'watch-external', // https://stackoverflow.com/questions/63373804/rollup-watch-include-directory/63548394#63548394 14 | async buildStart() { 15 | this.addWatchFile('./src/ui/shaders/utils.glsl') 16 | this.addWatchFile('./src/ui/shaders/library.glsl') 17 | this.addWatchFile('./src/ui/shaders/v_shader.glsl') 18 | this.addWatchFile('./src/ui/shaders/f_shader.glsl') 19 | } 20 | }, 21 | viteSingleFile() 22 | ], 23 | root: './src/ui', 24 | build: { 25 | outDir: resolve(__dirname, 'dist'), 26 | emptyOutDir: false 27 | } 28 | }) 29 | -------------------------------------------------------------------------------- /src/ui/components/icons/ClosedLockIcon/ClosedLockIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function ClosedLockIcon() { 2 | return ( 3 | 4 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2025 Doko Zero 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/ui/helpers/contrasts/getContrastFromBgandFgRgba/getContrastFromBgandFgRgba.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import getContrastFromBgandFgRgba from './getContrastFromBgandFgRgba' 3 | 4 | describe('getContrastFromBgandFgRgba()', () => { 5 | test('-33', () => { 6 | expect( 7 | getContrastFromBgandFgRgba({ 8 | fg: { 9 | r: 0.25, 10 | g: 0.5, 11 | b: 0.75, 12 | a: 1 13 | }, 14 | bg: { 15 | r: 0, 16 | g: 0, 17 | b: 0 18 | }, 19 | currentContrastMethod: 'apca', 20 | currentFileColorProfile: 'p3' 21 | }) 22 | ).toStrictEqual(-33) 23 | }) 24 | 25 | test('-33', () => { 26 | expect( 27 | getContrastFromBgandFgRgba({ 28 | fg: { 29 | r: 0.25, 30 | g: 0.5, 31 | b: 0.75, 32 | a: 1 33 | }, 34 | bg: { 35 | r: 0, 36 | g: 0, 37 | b: 0 38 | }, 39 | currentContrastMethod: 'wcag', 40 | currentFileColorProfile: 'p3' 41 | }) 42 | ).toStrictEqual(-5) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /src/ui/helpers/copyToClipboard/copyToClipboard.ts: -------------------------------------------------------------------------------- 1 | // Thanks to https://forum.figma.com/t/write-to-clipboard-from-custom-plugin/11860/17 2 | const unsecuredCopyToClipboard = (textToCopy: string) => { 3 | // Create a textarea element. 4 | const textArea = document.createElement('textarea') 5 | textArea.value = textToCopy 6 | document.body.appendChild(textArea) 7 | 8 | textArea.focus() 9 | textArea.select() 10 | 11 | // Attempt to copy the text to the clipboard. 12 | try { 13 | document.execCommand('copy') 14 | } catch (error) { 15 | console.log('Unable to copy content to clipboard, error: ', error) 16 | } 17 | 18 | // Remove the textarea element from the DOM. 19 | document.body.removeChild(textArea) 20 | } 21 | 22 | export default function copyToClipboard(textToCopy: string) { 23 | // If the context is secure and clipboard API is available, use it. 24 | if (window.isSecureContext && typeof navigator?.clipboard?.writeText === 'function') { 25 | navigator.clipboard.writeText(textToCopy) 26 | } 27 | // Otherwise, use the unsecured fallback. 28 | else { 29 | unsecuredCopyToClipboard(textToCopy) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/ui/components/icons/OpenLockIcon/OpenLockIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function OpenLockIcon() { 2 | return ( 3 | 4 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/helpers/contrasts/getNewXandYFromContrast/getNewXandYFromContrast.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import getNewXandYFromContrast from './getNewXandYFromContrast' 3 | import { setCurrentContrastMethod } from '../../../stores/contrasts/currentContrastMethod/currentContrastMethod' 4 | import { setCurrentFileColorProfile } from '../../../stores/colors/currentFileColorProfile/currentFileColorProfile' 5 | 6 | describe('getNewXandYFromContrast()', () => { 7 | test('{x: 0.2, y: 57.4}', () => { 8 | setCurrentContrastMethod('apca') 9 | setCurrentFileColorProfile('p3') 10 | expect( 11 | getNewXandYFromContrast({ 12 | h: 270, 13 | x: 0.2, 14 | targetContrast: -30, 15 | lockRelativeChroma: false, 16 | currentBgOrFg: 'fg', 17 | colorsRgba: { 18 | parentFill: { 19 | r: 0, 20 | g: 0, 21 | b: 0 22 | }, 23 | fill: { 24 | r: 0, 25 | g: 0, 26 | b: 0, 27 | a: 1 28 | }, 29 | stroke: null 30 | } 31 | }) 32 | ).toStrictEqual({ x: 0.2, y: 57.4 }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/ui/components/icons/SquareOklchIcon/SquareOklchIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function SquareOklchIcon() { 2 | return ( 3 | 4 | 10 | 14 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/components/ColorValueInputs/helpers/getColorHxyaValueFormatedForInput/getColorHxyaValueFormatedForInput.ts: -------------------------------------------------------------------------------- 1 | import round from 'lodash/round' 2 | import { HxyaLabels, HxyaInputTypes } from '../../../../../types' 3 | import { $colorHxya } from '../../../../stores/colors/colorHxya/colorHxya' 4 | import { $userSettings } from '../../../../stores/settings/userSettings/userSettings' 5 | import { $currentColorModel } from '../../../../stores/colors/currentColorModel/currentColorModel' 6 | import getColorHxyDecimals from '../../../../helpers/colors/getColorHxyDecimals/getColorHxyDecimals' 7 | 8 | export default function getColorHxyaValueFormatedForInput(value: keyof typeof HxyaLabels): HxyaInputTypes { 9 | switch (value) { 10 | case 'h': 11 | return $colorHxya.get().h 12 | case 'x': 13 | if ($currentColorModel.get() === 'oklch' && $userSettings.get().useSimplifiedChroma) { 14 | return round($colorHxya.get().x * 100, 1) 15 | } else { 16 | return round($colorHxya.get().x, getColorHxyDecimals({ lockRelativeChroma: false }).x) 17 | } 18 | case 'y': 19 | return $colorHxya.get().y 20 | case 'a': 21 | return round($colorHxya.get().a * 100, 0) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ui/helpers/contrasts/filterNewContrast/filterNewContrast.ts: -------------------------------------------------------------------------------- 1 | import { ApcaContrast, WcagContrast } from '../../../../types' 2 | import { $currentContrastMethod } from '../../../stores/contrasts/currentContrastMethod/currentContrastMethod' 3 | import getContrastRange from '../getContrastRange/getContrastRange' 4 | 5 | /** 6 | * Like filterNewColorHxya(), contrast store actions can receive contrasts value that depending on the currentcontrast method are not allowed, thus the need to filter it. 7 | */ 8 | export default function filterNewContrast( 9 | newContrast: ApcaContrast | WcagContrast, 10 | currentContrastMethod = $currentContrastMethod.get() 11 | ): ApcaContrast | WcagContrast { 12 | let filteredNewContrast: ApcaContrast | WcagContrast = newContrast 13 | 14 | const contrastRange = getContrastRange() 15 | 16 | if (newContrast > contrastRange.negative.min && newContrast < 0) filteredNewContrast = contrastRange.negative.min 17 | else if (newContrast > 0 && newContrast < contrastRange.positive.min) filteredNewContrast = contrastRange.positive.min 18 | else if (currentContrastMethod === 'wcag' && newContrast === 0) filteredNewContrast = contrastRange.positive.min 19 | 20 | return filteredNewContrast 21 | } 22 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/getColorPickerResolutionInfos/getColorPickerResolutionInfos.ts: -------------------------------------------------------------------------------- 1 | import { PICKER_SIZE } from '../../../../constants' 2 | import { CurrentColorModel } from '../../../../types' 3 | import { $currentColorModel } from '../../../stores/colors/currentColorModel/currentColorModel' 4 | import { $userSettings } from '../../../stores/settings/userSettings/userSettings' 5 | 6 | type ReturnObject = { 7 | factor: number 8 | size: number 9 | } 10 | 11 | export default function getColorPickerResolutionInfos( 12 | currentColorModel: CurrentColorModel | 'oklchTransition' = $currentColorModel.get() 13 | ): ReturnObject { 14 | const returnObject: ReturnObject = { 15 | factor: 0, 16 | size: 0 17 | } 18 | 19 | switch (currentColorModel) { 20 | case 'oklchTransition': 21 | returnObject.factor = 0.8 22 | break 23 | 24 | case 'oklch': 25 | returnObject.factor = $userSettings.get().useHardwareAcceleration ? 0.25 : 0.8 26 | break 27 | 28 | case 'okhsl': 29 | case 'okhsv': 30 | returnObject.factor = $userSettings.get().useHardwareAcceleration ? 0.5 : 2.5 31 | break 32 | } 33 | 34 | returnObject.size = PICKER_SIZE / returnObject.factor 35 | 36 | return returnObject 37 | } 38 | -------------------------------------------------------------------------------- /src/ui/components/icons/TriangleOklchIcon/TriangleOklchIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function TriangleOklchIcon() { 2 | return ( 3 | 4 | 10 | 14 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/convertAbsoluteChromaToRelative/convertAbsoluteChromaToRelative.ts: -------------------------------------------------------------------------------- 1 | import { clampChroma } from 'culori' 2 | import { MAX_CHROMA_P3 } from '../../../../constants' 3 | import { ColorHxy, RelativeChroma, AbsoluteChroma, CurrentFileColorProfile } from '../../../../types' 4 | import { $currentFileColorProfile } from '../../../stores/colors/currentFileColorProfile/currentFileColorProfile' 5 | import clamp from 'lodash/clamp' 6 | 7 | type Props = { 8 | colorHxy: ColorHxy 9 | currentFileColorProfile?: CurrentFileColorProfile 10 | } 11 | 12 | export default function convertAbsoluteChromaToRelative(props: Props): RelativeChroma { 13 | const { colorHxy, currentFileColorProfile = $currentFileColorProfile.get() } = props 14 | 15 | // We do this test because with a lightness of 0, we get an undefined value for currentMaxChroma.c 16 | if (colorHxy.y === 0) return 0 17 | 18 | const currentMaxChroma: AbsoluteChroma = clampChroma( 19 | { mode: 'oklch', l: colorHxy.y / 100, c: MAX_CHROMA_P3, h: colorHxy.h }, 20 | 'oklch', 21 | currentFileColorProfile 22 | ).c 23 | 24 | if (currentMaxChroma === undefined) return 0 25 | 26 | // Sometimes we can get 101%, like with #FFFF00, so we use clamp(). 27 | return clamp(Math.round((colorHxy.x * 100) / currentMaxChroma), 0, 100) 28 | } 29 | -------------------------------------------------------------------------------- /src/ui/components/top-bar/ContrastToggle/ContrastToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useStore } from '@nanostores/react' 2 | import { consoleLogInfos } from '../../../../constants' 3 | import ContrastIcon from '../../icons/ContrastIcon/ContrastIcon' 4 | import { $isContrastInputOpen, setIsContrastInputOpenWithSideEffects } from '../../../stores/contrasts/isContrastInputOpen/isContrastInputOpen' 5 | import { $currentColorModel } from '../../../stores/colors/currentColorModel/currentColorModel' 6 | 7 | const handleIsContrastInputOpen = () => { 8 | setIsContrastInputOpenWithSideEffects({ newIsContrastInputOpen: !$isContrastInputOpen.get() }) 9 | } 10 | 11 | export default function ContrastToggle() { 12 | if (consoleLogInfos.includes('Component renders')) { 13 | console.log('Component render — ContrastToggle') 14 | } 15 | 16 | const isContrastInputOpen = useStore($isContrastInputOpen) 17 | const currentColorModel = useStore($currentColorModel) 18 | 19 | return ( 20 |
28 | 29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/ui/components/ColorModelSelect/ColorModelSelect.tsx: -------------------------------------------------------------------------------- 1 | import { consoleLogInfos } from '../../../constants' 2 | import { useStore } from '@nanostores/react' 3 | import { CurrentColorModel } from '../../../types' 4 | import { $currentColorModel, setCurrentColorModelWithSideEffects } from '../../stores/colors/currentColorModel/currentColorModel' 5 | 6 | const handleColorModel = (event: { target: HTMLSelectElement }) => { 7 | setCurrentColorModelWithSideEffects({ newCurrentColorModel: event.target.value as CurrentColorModel }) 8 | } 9 | 10 | export default function ColorModelSelect() { 11 | if (consoleLogInfos.includes('Component renders')) { 12 | console.log('Component render — ColorModelSelect') 13 | } 14 | 15 | const currentColorModel = useStore($currentColorModel) 16 | 17 | return ( 18 |
19 | 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/ui/components/icons/CrossIcon/CrossIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function CrossIcon() { 2 | return ( 3 | 4 | 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/convertRelativeChromaToAbsolute/convertRelativeChromaToAbsolute.ts: -------------------------------------------------------------------------------- 1 | import { clampChroma } from 'culori' 2 | import { MAX_CHROMA_P3 } from '../../../../constants' 3 | import { RelativeChroma, CurrentFileColorProfile, Lightness, Hue } from '../../../../types' 4 | import { $currentFileColorProfile } from '../../../stores/colors/currentFileColorProfile/currentFileColorProfile' 5 | import { $relativeChroma } from '../../../stores/colors/relativeChroma/relativeChroma' 6 | import getColorHxyDecimals from '../getColorHxyDecimals/getColorHxyDecimals' 7 | import round from 'lodash/round' 8 | 9 | type Props = { 10 | h: Hue 11 | y: Lightness 12 | relativeChroma?: RelativeChroma 13 | currentFileColorProfile?: CurrentFileColorProfile 14 | } 15 | 16 | export default function convertRelativeChromaToAbsolute(props: Props): RelativeChroma { 17 | const { h, y, relativeChroma = $relativeChroma.get(), currentFileColorProfile = $currentFileColorProfile.get() } = props 18 | 19 | // We do this test because with a lightness of 0, we get an undefined value for currentMaxChroma.c. 20 | if (y === 0) return 0 21 | 22 | const currentMaxChroma = clampChroma({ mode: 'oklch', l: y / 100, c: MAX_CHROMA_P3, h: h }, 'oklch', currentFileColorProfile).c 23 | 24 | const returnValue = (relativeChroma * currentMaxChroma) / 100 25 | 26 | return round(returnValue, getColorHxyDecimals().x) 27 | } 28 | -------------------------------------------------------------------------------- /src/ui/components/InfoHoverTooltip/InfoHoverTooltip.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import { consoleLogInfos } from '../../../constants' 3 | import InfoIcon from '../icons/InfoIcon/InfoIcon' 4 | 5 | type Props = { 6 | text: string 7 | position: 'left' | 'center' | 'right' 8 | width: number 9 | } 10 | 11 | /** 12 | * @param position for better results use an even value. 13 | */ 14 | export default function InfoHoverTooltip(props: Props) { 15 | if (consoleLogInfos.includes('Component renders')) { 16 | console.log('Component render — InfoHoverTooltip') 17 | } 18 | 19 | const { text, position = 'center', width } = props 20 | 21 | const tooltip = useRef(null) 22 | 23 | useEffect(() => { 24 | tooltip.current!.style.width = `${width}px` 25 | 26 | let marginLeft = '0px' 27 | if (position === 'left') marginLeft = `${-(width / 10)}px` 28 | else if (position === 'center') marginLeft = `${-(width / 2) + 8}px` 29 | else if (position === 'right') marginLeft = `${-width + 36}px` 30 | 31 | tooltip.current!.style.marginLeft = marginLeft 32 | }, []) 33 | 34 | return ( 35 |
36 |
37 |

{text}

38 |
39 | 40 | 41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/ui/components/single-input-with-lock/RelativeChromaInput/helpers/handleInputOnKeyDown/handleInputOnKeyDown.ts: -------------------------------------------------------------------------------- 1 | import clamp from 'lodash/clamp' 2 | import { setRelativeChromaWithSideEffects } from '../../../../../stores/colors/relativeChroma/relativeChroma' 3 | import { $currentKeysPressed } from '../../../../../stores/currentKeysPressed/currentKeysPressed' 4 | 5 | export default function handleInputOnKeyDown( 6 | event: React.KeyboardEvent, 7 | lastKeyPressed: React.MutableRefObject, 8 | keepInputSelected: React.MutableRefObject 9 | ) { 10 | const eventKey = event.key 11 | const eventTarget = event.target as HTMLInputElement 12 | 13 | if (['Enter', 'Tab', 'Escape'].includes(eventKey)) { 14 | lastKeyPressed.current = eventKey 15 | ;(event.target as HTMLInputElement).blur() 16 | } else if (['ArrowUp', 'ArrowDown'].includes(eventKey)) { 17 | let newValue = parseInt(eventTarget.value) 18 | 19 | event.preventDefault() 20 | keepInputSelected.current = true 21 | 22 | const stepUpdateValue = $currentKeysPressed.get().includes('shift') ? 5 : 1 23 | 24 | if (eventKey === 'ArrowUp') newValue += stepUpdateValue 25 | else if (eventKey === 'ArrowDown') newValue -= stepUpdateValue 26 | 27 | const clampedNewRelativeChroma = clamp(newValue, 0, 100) 28 | setRelativeChromaWithSideEffects({ newRelativeChroma: clampedNewRelativeChroma }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ui/components/ColorValueInputs/helpers/clampColorHxyaValueInInputFormat/clampColorHxyaValueInInputFormat.ts: -------------------------------------------------------------------------------- 1 | import clamp from 'lodash/clamp' 2 | import { HxyaLabels, HxyaInputTypes, AbsoluteChroma } from '../../../../../types' 3 | import getClampedChroma from '../../../../helpers/colors/getClampedChroma/getClampedChroma' 4 | import getHxyaInputRange from '../../../../helpers/colors/getHxyaInputRange/getHxyaInputRange' 5 | import { $colorHxya } from '../../../../stores/colors/colorHxya/colorHxya' 6 | import { $userSettings } from '../../../../stores/settings/userSettings/userSettings' 7 | import { $currentColorModel } from '../../../../stores/colors/currentColorModel/currentColorModel' 8 | 9 | export default function clampColorHxyaValueInInputFormat(eventId: keyof typeof HxyaLabels, newValue: HxyaInputTypes): HxyaInputTypes { 10 | let clampedNewValue: HxyaInputTypes 11 | 12 | if (eventId === 'x' && $currentColorModel.get() === 'oklch') { 13 | const formatedChroma: AbsoluteChroma = $userSettings.get().useSimplifiedChroma ? newValue / 100 : newValue 14 | clampedNewValue = getClampedChroma({ h: $colorHxya.get().h, x: formatedChroma, y: $colorHxya.get().y }) 15 | clampedNewValue = $userSettings.get().useSimplifiedChroma ? clampedNewValue * 100 : clampedNewValue 16 | } else { 17 | clampedNewValue = clamp(newValue, getHxyaInputRange(eventId).min, getHxyaInputRange(eventId).max) 18 | } 19 | 20 | return clampedNewValue 21 | } 22 | -------------------------------------------------------------------------------- /src/ui/components/InfoHoverTooltip/InfoHoverTooltip.css: -------------------------------------------------------------------------------- 1 | .c-info-hover-tooltip { 2 | position: relative; 3 | padding: 4px; 4 | } 5 | 6 | .c-info-hover-tooltip svg { 7 | fill: var(--figma-color-icon); 8 | opacity: 0.9; 9 | } 10 | 11 | .c-info-hover-tooltip__tooltip { 12 | z-index: 99; 13 | position: absolute; 14 | top: 26px; 15 | visibility: hidden; 16 | padding: 6px 6px; 17 | text-align: center; 18 | border-radius: var(--base-border-radius); 19 | color: #ffffff; 20 | line-height: 1.5; 21 | background-color: #232323; 22 | } 23 | 24 | .figma-light .c-info-hover-tooltip__tooltip { 25 | box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.3); 26 | } 27 | 28 | .figma-dark .c-info-hover-tooltip__tooltip { 29 | box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.6); 30 | border: 1px solid rgba(255, 255, 255, 0.05); 31 | } 32 | 33 | .c-info-hover-tooltip:hover .c-info-hover-tooltip__tooltip { 34 | visibility: visible; 35 | } 36 | 37 | .c-info-hover-tooltip__tooltip::after { 38 | content: ' '; 39 | position: absolute; 40 | bottom: 100%; 41 | margin-left: -5px; 42 | border-width: 5px; 43 | border-style: solid; 44 | border-color: transparent transparent #232323 transparent; 45 | } 46 | 47 | .c-info-hover-tooltip__tooltip--left::after { 48 | left: 15%; 49 | } 50 | 51 | .c-info-hover-tooltip__tooltip--center::after { 52 | left: 50%; 53 | } 54 | 55 | .c-info-hover-tooltip__tooltip--right::after { 56 | left: 85%; 57 | } 58 | -------------------------------------------------------------------------------- /src/ui/components/top-bar/FileColorProfile/FileColorProfile.tsx: -------------------------------------------------------------------------------- 1 | import { useStore } from '@nanostores/react' 2 | import { consoleLogInfos } from '../../../../constants' 3 | import { $currentFileColorProfile } from '../../../stores/colors/currentFileColorProfile/currentFileColorProfile' 4 | import InfoHoverTooltip from '../../InfoHoverTooltip/InfoHoverTooltip' 5 | import { $figmaEditorType } from '../../../stores/figmaEditorType/figmaEditorType' 6 | 7 | export default function FileColorProfile() { 8 | if (consoleLogInfos.includes('Component renders')) { 9 | console.log('Component render — FileColorProfile') 10 | } 11 | 12 | const currentFileColorProfile = useStore($currentFileColorProfile) 13 | 14 | let tooltipText = '' 15 | 16 | switch ($figmaEditorType.get()) { 17 | case 'figma': 18 | tooltipText = 'File color profile, specified in its settings.' 19 | break 20 | case 'figjam': 21 | tooltipText = 'File color profile, FigJam only supports sRGB.' 22 | break 23 | case 'slides': 24 | tooltipText = 'File color profile, Figma Slides only supports sRGB.' 25 | break 26 | default: 27 | tooltipText = 'File color profile, specified in its settings.' 28 | break 29 | } 30 | 31 | return ( 32 |
33 | {currentFileColorProfile === 'rgb' ? 'sRGB' : 'Display P3'} 34 | 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/ui/components/ColorValueInputs/helpers/formatAndSendNewValueToStore/formatAndSendNewValueToStore.ts: -------------------------------------------------------------------------------- 1 | import { HxyaLabels } from '../../../../../types' 2 | import { setColorHxyaWithSideEffects } from '../../../../stores/colors/colorHxya/colorHxya' 3 | import { $currentColorModel } from '../../../../stores/colors/currentColorModel/currentColorModel' 4 | import { $lockRelativeChroma } from '../../../../stores/colors/lockRelativeChroma/lockRelativeChroma' 5 | import { $oklchRenderMode } from '../../../../stores/oklchRenderMode/oklchRenderMode' 6 | import { $userSettings } from '../../../../stores/settings/userSettings/userSettings' 7 | 8 | export default function formatAndSendNewValueToStore(eventId: keyof typeof HxyaLabels, newValue: number) { 9 | let newValueFormated = newValue 10 | if (($currentColorModel.get() === 'oklch' && $userSettings.get().useSimplifiedChroma && eventId === 'x') || eventId === 'a') newValueFormated /= 100 11 | 12 | let localLockRelativeChroma = $lockRelativeChroma.get() 13 | 14 | // We lock the relative chroma locally because when in square OkLCH mode, we want to keep relative chroma fixed when updating the contrast. 15 | if ($oklchRenderMode.get() === 'square') { 16 | if (eventId === 'h' || eventId === 'y') { 17 | localLockRelativeChroma = true 18 | } 19 | } 20 | 21 | setColorHxyaWithSideEffects({ 22 | newColorHxya: { 23 | [eventId]: newValueFormated 24 | }, 25 | lockRelativeChroma: localLockRelativeChroma 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /src/backend/helpers/getWindowHeigh/getWindowHeigh.ts: -------------------------------------------------------------------------------- 1 | import { CurrentColorModel } from '../../../types' 2 | 3 | type Props = { 4 | currentColorModel: CurrentColorModel 5 | isColorCodeInputsOpen: boolean 6 | isContrastInputOpen: boolean 7 | } 8 | 9 | const pluginHeights = { 10 | okhsvl: { 11 | colorCodes: 572, 12 | noColorCodes: 440 13 | }, 14 | oklch: { 15 | contrastAndColorCodes: 639, 16 | contrastAndNoColorCodes: 507, 17 | noContrastAndColorCodes: 607, 18 | noContrastAndNoColorCodes: 475 19 | } 20 | } 21 | 22 | export default function getWindowHeigh(props: Props): number { 23 | const { currentColorModel, isColorCodeInputsOpen, isContrastInputOpen } = props 24 | 25 | if (['okhsv', 'okhsl'].includes(currentColorModel)) { 26 | if (isColorCodeInputsOpen) { 27 | return pluginHeights.okhsvl.colorCodes 28 | } else { 29 | return pluginHeights.okhsvl.noColorCodes 30 | } 31 | } else if (['oklch'].includes(currentColorModel)) { 32 | if (isContrastInputOpen) { 33 | if (isColorCodeInputsOpen) { 34 | return pluginHeights.oklch.contrastAndColorCodes 35 | } else { 36 | return pluginHeights.oklch.contrastAndNoColorCodes 37 | } 38 | } else { 39 | if (isColorCodeInputsOpen) { 40 | return pluginHeights.oklch.noContrastAndColorCodes 41 | } else { 42 | return pluginHeights.oklch.noContrastAndNoColorCodes 43 | } 44 | } 45 | } 46 | 47 | return pluginHeights.oklch.contrastAndColorCodes 48 | } 49 | -------------------------------------------------------------------------------- /src/ui/components/single-input-with-lock/RelativeChromaInput/helpers/handleInputOnBlur/handleInputOnBlur.ts: -------------------------------------------------------------------------------- 1 | import clamp from 'lodash/clamp' 2 | import { $relativeChroma, setRelativeChromaWithSideEffects } from '../../../../../stores/colors/relativeChroma/relativeChroma' 3 | import { $isMouseInsideDocument } from '../../../../../stores/isMouseInsideDocument/isMouseInsideDocument' 4 | import parseInputString from '../../../../../helpers/inputs/parseInputString/parseInputString' 5 | import round from 'lodash/round' 6 | 7 | export default function handleInputOnBlur(event: React.FocusEvent, lastKeyPressed: React.MutableRefObject) { 8 | const eventTarget = event.target 9 | 10 | const resetToOldValue = () => { 11 | eventTarget.value = $relativeChroma.get().toString() 12 | lastKeyPressed.current = '' 13 | return 14 | } 15 | 16 | const rawValue = parseInputString(eventTarget.value) 17 | 18 | if (rawValue === null) { 19 | return resetToOldValue() 20 | } 21 | 22 | const newValue = round(rawValue) 23 | 24 | const clampedNewRelativeChroma = clamp(newValue, 0, 100) 25 | 26 | if ( 27 | clampedNewRelativeChroma === $relativeChroma.get() || 28 | lastKeyPressed.current === 'Escape' || 29 | (!$isMouseInsideDocument.get() && !['Enter', 'Tab'].includes(lastKeyPressed.current)) 30 | ) { 31 | return resetToOldValue() 32 | } 33 | 34 | lastKeyPressed.current = '' 35 | setRelativeChromaWithSideEffects({ newRelativeChroma: clampedNewRelativeChroma }) 36 | } 37 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from 'eslint/config' 2 | import react from 'eslint-plugin-react' 3 | import typescriptEslint from '@typescript-eslint/eslint-plugin' 4 | import globals from 'globals' 5 | import tsParser from '@typescript-eslint/parser' 6 | import path from 'node:path' 7 | import { fileURLToPath } from 'node:url' 8 | import js from '@eslint/js' 9 | import { FlatCompat } from '@eslint/eslintrc' 10 | 11 | const __filename = fileURLToPath(import.meta.url) 12 | const __dirname = path.dirname(__filename) 13 | const compat = new FlatCompat({ 14 | baseDirectory: __dirname, 15 | recommendedConfig: js.configs.recommended, 16 | allConfig: js.configs.all 17 | }) 18 | 19 | export default defineConfig([ 20 | globalIgnores(['**/*.css']), 21 | { 22 | extends: compat.extends('eslint:recommended', 'plugin:react/recommended', 'plugin:@typescript-eslint/recommended'), 23 | 24 | plugins: { 25 | react, 26 | '@typescript-eslint': typescriptEslint 27 | }, 28 | 29 | languageOptions: { 30 | globals: { 31 | ...globals.browser 32 | }, 33 | 34 | parser: tsParser, 35 | ecmaVersion: 'latest', 36 | sourceType: 'module' 37 | }, 38 | 39 | settings: { 40 | react: { 41 | version: 'detect' 42 | } 43 | }, 44 | 45 | rules: { 46 | 'react/react-in-jsx-scope': 'off', 47 | 'no-unused-vars': 'off', 48 | '@typescript-eslint/no-unused-vars': 'error', 49 | '@typescript-eslint/no-explicit-any': 'off' 50 | } 51 | } 52 | ]) 53 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/getColorHxyDecimals/getColorHxyDecimals.ts: -------------------------------------------------------------------------------- 1 | import { ColorHxyDecimals, CurrentColorModel, OklchHlDecimalPrecisionRange } from '../../../../types' 2 | import { $currentColorModel } from '../../../stores/colors/currentColorModel/currentColorModel' 3 | import { $lockRelativeChroma } from '../../../stores/colors/lockRelativeChroma/lockRelativeChroma' 4 | import { $userSettings } from '../../../stores/settings/userSettings/userSettings' 5 | 6 | type Props = { 7 | currentColorModel?: CurrentColorModel 8 | useSimplifiedChroma?: boolean 9 | oklchHlDecimalPrecision?: OklchHlDecimalPrecisionRange 10 | lockRelativeChroma?: boolean 11 | forInputs?: boolean 12 | } 13 | 14 | export default function getColorHxyDecimals(props: Props = {}): ColorHxyDecimals { 15 | const { 16 | currentColorModel = $currentColorModel.get(), 17 | useSimplifiedChroma = $userSettings.get().useSimplifiedChroma, 18 | oklchHlDecimalPrecision = $userSettings.get().oklchHlDecimalPrecision, 19 | lockRelativeChroma = $lockRelativeChroma.get(), 20 | forInputs = false 21 | } = props 22 | 23 | const returnObject: ColorHxyDecimals = { h: 0, x: 0, y: 0 } 24 | 25 | if (currentColorModel === 'oklch') { 26 | returnObject.h = oklchHlDecimalPrecision 27 | returnObject.y = oklchHlDecimalPrecision 28 | 29 | if (forInputs && useSimplifiedChroma) { 30 | returnObject.x = 1 31 | } else { 32 | // TODO - no need if x is always 6 decimals. 33 | returnObject.x = lockRelativeChroma ? 6 : 6 34 | } 35 | } 36 | 37 | return returnObject 38 | } 39 | -------------------------------------------------------------------------------- /src/ui/styles/main.css: -------------------------------------------------------------------------------- 1 | @import './bases.css'; 2 | 3 | /* These a the styles that belong only to the React components. */ 4 | @import '../components/Alert/Alert.css'; 5 | @import '../components/top-bar/top-bar.css'; 6 | @import '../components/top-bar/FileColorProfile/FileColorProfile.css'; 7 | @import '../components/top-bar/OklchRenderModeToggle/OklchRenderModeToggle.css'; 8 | @import '../components/top-bar/ContrastToggle/ContrastToggle.css'; 9 | @import '../components/top-bar/SettingsToggle/SettingsToggle.css'; 10 | @import '../components/InfoHoverTooltip/InfoHoverTooltip.css'; 11 | @import '../components/Toggle/Toggle.css'; 12 | @import '../components/SettingsScreen/SettingsScreen.css'; 13 | @import '../components/ColorPicker/ColorPicker.css'; 14 | @import '../components/FillOrStrokeToggle/FillOrStrokeToggle.css'; 15 | @import '../components/single-input-with-lock/BgOrFgToggle/BgOrFgToggle.css'; 16 | @import '../components/icons/SquareOklchIcon/SquareOklchIcon.css'; 17 | @import '../components/icons/TriangleOklchIcon/TriangleOklchIcon.css'; 18 | 19 | /* These are a bit different than the previous styles, they are still for components but not for a specific one. For example, c-dropdown.css has shared styles used in ContrastInput.tsx and ColorCodeInputs.tsx, hence why these files are in the "styles" folder and not in "components". */ 20 | @import './shared-components/c-dropdown.css'; 21 | @import './shared-components/c-copy-action.css'; 22 | @import './shared-components/c-single-input-with-lock.css'; 23 | @import './shared-components/c-slider.css'; 24 | 25 | @import './utilities.css'; 26 | 27 | @import './others.css'; 28 | -------------------------------------------------------------------------------- /src/ui/styles/shared-components/c-single-input-with-lock.css: -------------------------------------------------------------------------------- 1 | .c-single-input-with-lock { 2 | height: 28px; 3 | display: flex; 4 | justify-content: space-between; 5 | align-items: center; 6 | } 7 | .c-single-input-with-lock--with-label { 8 | padding: 0 16px; 9 | } 10 | 11 | .c-single-input-with-lock--with-select { 12 | padding: 0 16px 0 15px; 13 | } 14 | 15 | .c-single-input-with-lock--deactivated { 16 | pointer-events: none; 17 | opacity: 0.5; 18 | } 19 | .c-single-input-with-lock__label { 20 | font-size: var(--base-font-size); 21 | color: var(--figma-color-text); 22 | white-space: nowrap; 23 | } 24 | 25 | .c-single-input-with-lock__input-wrapper { 26 | max-width: 40px; 27 | margin-left: 7px; 28 | } 29 | 30 | .c-single-input-with-lock__lock-wrapper { 31 | height: 100%; 32 | margin-left: 7px; 33 | padding: 2px 0; 34 | border-radius: var(--base-border-radius); 35 | } 36 | 37 | .c-single-input-with-lock__lock { 38 | height: 100%; 39 | padding: 0 4px; 40 | border-radius: var(--base-border-radius); 41 | display: flex; 42 | align-items: center; 43 | } 44 | .c-single-input-with-lock__lock:hover { 45 | background-color: var(--figma-color-bg-secondary); 46 | } 47 | .c-single-input-with-lock__lock--closed { 48 | background-color: var(--figma-color-bg-selected); 49 | } 50 | .c-single-input-with-lock__lock--closed:hover { 51 | background-color: var(--figma-color-bg-selected-secondary); 52 | } 53 | 54 | .c-single-input-with-lock__lock svg { 55 | fill: var(--figma-color-icon); 56 | } 57 | 58 | .c-single-input-with-lock__lock--closed svg { 59 | fill: var(--figma-color-icon-brand); 60 | } 61 | -------------------------------------------------------------------------------- /src/ui/components/single-input-with-lock/ContrastInput/helpers/getNewContrastValueFromArrowKey/getNewContrastValueFromArrowKey.ts: -------------------------------------------------------------------------------- 1 | import round from 'lodash/round' 2 | import { ApcaContrast, WcagContrast } from '../../../../../../types' 3 | import getContrastRange from '../../../../../helpers/contrasts/getContrastRange/getContrastRange' 4 | import { $currentContrastMethod } from '../../../../../stores/contrasts/currentContrastMethod/currentContrastMethod' 5 | import { $currentKeysPressed } from '../../../../../stores/currentKeysPressed/currentKeysPressed' 6 | 7 | export default function getNewContrastValueFromArrowKey( 8 | eventKey: 'ArrowDown' | 'ArrowUp', 9 | currentValue: ApcaContrast | WcagContrast 10 | ): ApcaContrast | WcagContrast { 11 | let newValue: ApcaContrast | WcagContrast 12 | 13 | const isShiftPressed = $currentKeysPressed.get().includes('shift') 14 | const currentContrastMethod = $currentContrastMethod.get() 15 | 16 | let stepUpdateValue: number 17 | if (currentContrastMethod === 'apca') stepUpdateValue = isShiftPressed ? 5 : 1 18 | else stepUpdateValue = isShiftPressed ? 1 : 0.1 19 | 20 | const contrastRange = getContrastRange() 21 | 22 | if (eventKey === 'ArrowUp') { 23 | if (currentValue === contrastRange.negative.min) newValue = currentContrastMethod === 'apca' ? 0 : 1 24 | else newValue = currentValue + stepUpdateValue 25 | } else if (eventKey === 'ArrowDown') { 26 | if (currentValue === contrastRange.positive.min) newValue = currentContrastMethod === 'apca' ? 0 : -1 27 | else newValue = currentValue - stepUpdateValue 28 | } 29 | 30 | // We need to this because in some cases we can have values like 1.2999999999999998. 31 | return round(newValue!, 1) 32 | } 33 | -------------------------------------------------------------------------------- /src/ui/components/SettingsScreen/SettingsScreen.css: -------------------------------------------------------------------------------- 1 | .c-settings-screen { 2 | z-index: 10; 3 | position: fixed; 4 | top: 40px; 5 | width: 100%; 6 | height: 100%; 7 | } 8 | 9 | .figma-light .c-settings-screen { 10 | background-color: rgba(0, 0, 0, 0.4); 11 | } 12 | 13 | .figma-dark .c-settings-screen { 14 | background-color: rgba(0, 0, 0, 0.65); 15 | } 16 | 17 | .c-settings-screen__wrapper { 18 | background-color: var(--figma-color-bg); 19 | } 20 | 21 | .c-settings-screen__items-group { 22 | padding: 7px 0; 23 | border-top: 1px solid var(--figma-color-border); 24 | } 25 | 26 | .c-settings-screen__items-group--one-child { 27 | padding: 5px 0; 28 | } 29 | 30 | .c-settings-screen__item { 31 | display: flex; 32 | justify-content: space-between; 33 | align-items: center; 34 | padding: 3px 15px 3px 16px; 35 | font-size: var(--base-font-size); 36 | color: var(--figma-color-text); 37 | } 38 | 39 | .c-settings-screen__item--with-toggle { 40 | padding-right: 8px; 41 | } 42 | 43 | .c-settings-screen__item-oklch-hl-decimal-precision-wrapper { 44 | width: 33px; 45 | } 46 | 47 | .c-settings-screen__item-oklch-input-order-wrapper { 48 | width: 58px; 49 | } 50 | 51 | .c-settings-screen__item--restart-message { 52 | height: 28px; 53 | padding-top: 0px; 54 | margin-top: -10px; 55 | font-size: var(--base-font-size); 56 | } 57 | 58 | /* We don't use --figma-color-text because it already has an opacity in it. For --figma-color-text-secondary, the contrast is not enought on light mode. */ 59 | .figma-light .c-settings-screen__item--restart-message { 60 | color: rgba(0, 0, 0, 0.65); 61 | } 62 | .figma-dark .c-settings-screen__item--restart-message { 63 | color: rgba(255, 255, 255, 0.7); 64 | } 65 | -------------------------------------------------------------------------------- /src/ui/components/single-input-with-lock/ContrastInput/helpers/handleInputOnKeyDown/handleInputOnKeyDown.ts: -------------------------------------------------------------------------------- 1 | import clamp from 'lodash/clamp' 2 | import { ApcaContrast, WcagContrast } from '../../../../../../types' 3 | import getContrastRange from '../../../../../helpers/contrasts/getContrastRange/getContrastRange' 4 | import { setContrastWithSideEffects } from '../../../../../stores/contrasts/contrast/contrast' 5 | import { $currentContrastMethod } from '../../../../../stores/contrasts/currentContrastMethod/currentContrastMethod' 6 | import getNewContrastValueFromArrowKey from '../getNewContrastValueFromArrowKey/getNewContrastValueFromArrowKey' 7 | 8 | export default function handleInputOnKeyDown( 9 | event: React.KeyboardEvent, 10 | lastKeyPressed: React.MutableRefObject, 11 | keepInputSelected: React.MutableRefObject 12 | ) { 13 | const eventKey = event.key 14 | const eventTarget = event.target as HTMLInputElement 15 | 16 | if (['Enter', 'Tab', 'Escape'].includes(eventKey)) { 17 | lastKeyPressed.current = eventKey 18 | ;(event.target as HTMLInputElement).blur() 19 | } else if (['ArrowUp', 'ArrowDown'].includes(eventKey)) { 20 | const currentValue: ApcaContrast | WcagContrast = 21 | $currentContrastMethod.get() === 'apca' ? parseInt(eventTarget.value) : parseFloat(eventTarget.value) 22 | 23 | event.preventDefault() 24 | keepInputSelected.current = true 25 | 26 | const newValue = getNewContrastValueFromArrowKey(eventKey as 'ArrowDown' | 'ArrowUp', currentValue) 27 | const clampedNewContrast = clamp(newValue, getContrastRange().negative.max, getContrastRange().positive.max) 28 | setContrastWithSideEffects({ newContrast: clampedNewContrast }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/ui/stores/oklchRenderMode/oklchRenderMode.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { OklchRenderMode, SyncOklchRenderModeData } from '../../../types' 3 | import merge from 'lodash/merge' 4 | import sendMessageToBackend from '../../helpers/sendMessageToBackend/sendMessageToBackend' 5 | 6 | export const $oklchRenderMode = atom('square') 7 | export const $isTransitionRunning = atom(false) 8 | 9 | export const setOklchRenderMode = action($oklchRenderMode, 'setOklchRenderMode', (oklchRenderMode, newOklchRenderMode: OklchRenderMode) => { 10 | oklchRenderMode.set(newOklchRenderMode) 11 | }) 12 | 13 | export const setIsTransitionRunning = action( 14 | $isTransitionRunning, 15 | 'setIsTransitionRunning', 16 | (IsTransitionRunning, newIsTransitionRunning: boolean) => { 17 | IsTransitionRunning.set(newIsTransitionRunning) 18 | } 19 | ) 20 | 21 | type SideEffects = { 22 | syncOklchRenderModeWithBackend: boolean 23 | } 24 | 25 | type Props = { 26 | newOklchRenderMode: OklchRenderMode 27 | sideEffects?: Partial 28 | } 29 | 30 | const defaultSideEffects: SideEffects = { 31 | syncOklchRenderModeWithBackend: true 32 | } 33 | 34 | export const setOklchRenderModeWithSideEffects = action($oklchRenderMode, 'setOklchRenderModeWithSideEffects', (oklchRenderMode, props: Props) => { 35 | const { newOklchRenderMode, sideEffects: partialSideEffects } = props 36 | 37 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 38 | merge(sideEffects, partialSideEffects) 39 | 40 | oklchRenderMode.set(newOklchRenderMode) 41 | 42 | if (sideEffects.syncOklchRenderModeWithBackend) { 43 | sendMessageToBackend({ 44 | type: 'syncOklchRenderMode', 45 | data: { 46 | newOklchRenderMode: newOklchRenderMode 47 | } 48 | }) 49 | } 50 | }) 51 | -------------------------------------------------------------------------------- /src/ui/shaders/f_shader.glsl: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | uniform vec2 resolution; 3 | uniform float chromaScale; 4 | uniform bool isGamutP3; 5 | uniform int colorModel; 6 | uniform int oklchRenderMode; 7 | uniform float hueRad; 8 | 9 | void main() { 10 | vec2 uv = gl_FragCoord.xy / resolution; 11 | 12 | // If colorModel is oklch (see "ColorModelList" types.ts for the list). 13 | if (colorModel == 0) { 14 | if (oklchRenderMode == 0) { 15 | float l = uv.y; 16 | float absoluteChroma = uv.x / chromaScale; 17 | float h = hueRad; 18 | 19 | vec3 oklch = vec3(l, absoluteChroma, h); 20 | vec3 oklchRgb = oklchToRgb(oklch, isGamutP3); 21 | 22 | if (isInBounds(oklchRgb)) { 23 | gl_FragColor = vec4(oklchRgb, 1.0); 24 | } else { 25 | // We use a transparency color for the pixels outsides of gamut, for the bg color, we use CSS on the canvas, see renderColorPickerCanvas() in ColorPicker. 26 | gl_FragColor = vec4(0.0); 27 | } 28 | } else if (oklchRenderMode == 1) { 29 | float h = clampRadian(hueRad); 30 | float relativeChroma = uv.x; 31 | float l = uv.y; 32 | 33 | vec3 oklch = vec3(h, relativeChroma, l); 34 | 35 | vec3 hslRgbSrgb = okhsl_to_srgb(oklch, true); 36 | gl_FragColor = vec4(hslRgbSrgb, 1.0); 37 | } 38 | } 39 | // Else if colorModel is okhsv ok okhsl. 40 | else { 41 | // clamp radian to [0,1] 42 | float h = clampRadian(hueRad); 43 | float s = uv.x; 44 | float vl = uv.y; 45 | 46 | vec3 hsvl = vec3(h, s, vl); 47 | 48 | if (colorModel == 1) { 49 | vec3 hslRgbSrgb = okhsl_to_srgb(hsvl, false); 50 | gl_FragColor = vec4(hslRgbSrgb, 1.0); 51 | } else if (colorModel == 2) { 52 | vec3 hsvRgbSrgb = okhsv_to_srgb(hsvl); 53 | gl_FragColor = vec4(hsvRgbSrgb, 1.0); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ui/components/icons/ContrastIcon/ContrastIcon.tsx: -------------------------------------------------------------------------------- 1 | export default function ContrastIcon() { 2 | return ( 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/components/single-input-with-lock/ContrastInput/helpers/handleInputOnBlur/handleInputOnBlur.ts: -------------------------------------------------------------------------------- 1 | import clamp from 'lodash/clamp' 2 | import { ApcaContrast, WcagContrast } from '../../../../../../types' 3 | import getContrastRange from '../../../../../helpers/contrasts/getContrastRange/getContrastRange' 4 | import { $contrast, setContrastWithSideEffects } from '../../../../../stores/contrasts/contrast/contrast' 5 | import { $currentContrastMethod } from '../../../../../stores/contrasts/currentContrastMethod/currentContrastMethod' 6 | import { $isMouseInsideDocument } from '../../../../../stores/isMouseInsideDocument/isMouseInsideDocument' 7 | import parseInputString from '../../../../../helpers/inputs/parseInputString/parseInputString' 8 | import round from 'lodash/round' 9 | 10 | export default function handleInputOnBlur(event: React.FocusEvent, lastKeyPressed: React.MutableRefObject) { 11 | const eventTarget = event.target 12 | 13 | const resetToOldValue = () => { 14 | eventTarget.value = String($contrast.get()) 15 | lastKeyPressed.current = '' 16 | return 17 | } 18 | 19 | const rawValue = parseInputString(eventTarget.value) 20 | 21 | if (rawValue === null) { 22 | return resetToOldValue() 23 | } 24 | 25 | const newValue: ApcaContrast | WcagContrast = $currentContrastMethod.get() === 'apca' ? round(rawValue) : rawValue 26 | 27 | const clampedNewContrast = clamp(newValue, getContrastRange().negative.max, getContrastRange().positive.max) 28 | 29 | if ( 30 | clampedNewContrast === $contrast.get() || 31 | lastKeyPressed.current === 'Escape' || 32 | (!$isMouseInsideDocument.get() && !['Enter', 'Tab'].includes(lastKeyPressed.current)) 33 | ) { 34 | return resetToOldValue() 35 | } 36 | 37 | lastKeyPressed.current = '' 38 | setContrastWithSideEffects({ newContrast: clampedNewContrast }) 39 | } 40 | -------------------------------------------------------------------------------- /src/ui/stores/colors/lockRelativeChroma/lockRelativeChroma.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../../constants' 4 | import { SyncLockRelativeChromaData } from '../../../../types' 5 | import sendMessageToBackend from '../../../helpers/sendMessageToBackend/sendMessageToBackend' 6 | import merge from 'lodash/merge' 7 | 8 | export const $lockRelativeChroma = atom(false) 9 | 10 | export const setLockRelativeChroma = action($lockRelativeChroma, 'setLockRelativeChroma', (lockRelativeChroma, newLockRelativeChroma: boolean) => { 11 | lockRelativeChroma.set(newLockRelativeChroma) 12 | }) 13 | 14 | type SideEffects = { 15 | syncLockRelativeChromaWithBackend: boolean 16 | } 17 | 18 | type Props = { 19 | newLockRelativeChroma: boolean 20 | sideEffects?: Partial 21 | } 22 | 23 | const defaultSideEffects: SideEffects = { 24 | syncLockRelativeChromaWithBackend: true 25 | } 26 | 27 | export const setLockRelativeChromaWithSideEffects = action( 28 | $lockRelativeChroma, 29 | 'setLockRelativeChromaWithSideEffects', 30 | (lockRelativeChroma, props: Props) => { 31 | const { newLockRelativeChroma, sideEffects: partialSideEffects } = props 32 | 33 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 34 | merge(sideEffects, partialSideEffects) 35 | 36 | lockRelativeChroma.set(newLockRelativeChroma) 37 | 38 | if (sideEffects.syncLockRelativeChromaWithBackend) { 39 | sendMessageToBackend({ 40 | type: 'syncLockRelativeChroma', 41 | data: { 42 | newLockRelativeChroma: newLockRelativeChroma 43 | } 44 | }) 45 | } 46 | } 47 | ) 48 | 49 | if (consoleLogInfos.includes('Store updates')) { 50 | logger({ 51 | lockRelativeChroma: $lockRelativeChroma 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /src/ui/helpers/contrasts/WCAGcontrast/WCAGcontrast.ts: -------------------------------------------------------------------------------- 1 | import { RgbaArray, RgbArray, WcagContrast } from '../../../../types' 2 | import round from 'lodash/round' 3 | 4 | // Formulas for contrast from https://www.w3.org/WAI/WCAG22/Techniques/general/G145#tests 5 | 6 | // For alphaCompositing, thanks to GPT-4 7 | const alphaCompositing = (fg: RgbaArray, bg: RgbArray): RgbArray => { 8 | const resultRgbColor: RgbArray = [0, 0, 0] 9 | 10 | const opacity = fg[3] < 0.2 ? 0.2 : fg[3] 11 | 12 | for (let i = 0; i < 3; i++) { 13 | resultRgbColor[i] = opacity * fg[i] + (1 - opacity) * bg[i] 14 | } 15 | 16 | return resultRgbColor 17 | } 18 | 19 | const getLuminanceOfRgbArray = (rgbArray: RgbaArray | RgbArray) => { 20 | for (let i = 0; i < rgbArray.length; i++) { 21 | if (rgbArray[i] < 0.04045) { 22 | rgbArray[i] = rgbArray[i] / 12.92 23 | } else { 24 | rgbArray[i] = Math.pow((rgbArray[i] + 0.055) / 1.055, 2.4) 25 | } 26 | } 27 | 28 | return rgbArray[0] * 0.2126 + rgbArray[1] * 0.7152 + rgbArray[2] * 0.0722 29 | } 30 | 31 | export default function WCAGcontrast(fg: RgbaArray, bg: RgbArray): WcagContrast { 32 | let fgWithAlphaCompositing: RgbArray = [fg[0], fg[1], fg[2]] 33 | 34 | if (fg[3] < 1) { 35 | fgWithAlphaCompositing = alphaCompositing(fg, bg) 36 | } 37 | 38 | const fgLuminance = getLuminanceOfRgbArray(fgWithAlphaCompositing) 39 | const bgLuminance = getLuminanceOfRgbArray(bg) 40 | 41 | let contrast: WcagContrast 42 | if (fgLuminance > bgLuminance || (fgLuminance === 0 && bgLuminance === 0)) { 43 | // We force the value to be negative to have the same behavior as in APCA, see comment in types.ts for "WcagContrast". 44 | contrast = -round((fgLuminance + 0.05) / (bgLuminance + 0.05), 1) 45 | } else { 46 | contrast = round((bgLuminance + 0.05) / (fgLuminance + 0.05), 1) 47 | } 48 | 49 | return contrast 50 | } 51 | -------------------------------------------------------------------------------- /src/ui/stores/contrasts/isContrastInputOpen/isContrastInputOpen.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../../constants' 4 | import { SyncIsContrastInputOpenData } from '../../../../types' 5 | import sendMessageToBackend from '../../../helpers/sendMessageToBackend/sendMessageToBackend' 6 | import merge from 'lodash/merge' 7 | 8 | export const $isContrastInputOpen = atom(false) 9 | 10 | export const setIsContrastInputOpen = action( 11 | $isContrastInputOpen, 12 | 'setIsContrastInputOpen', 13 | (isContrastInputOpen, newIsContrastInputOpen: boolean) => { 14 | isContrastInputOpen.set(newIsContrastInputOpen) 15 | } 16 | ) 17 | 18 | type SideEffects = { 19 | syncIsContrastInputOpenWithBackend: boolean 20 | } 21 | 22 | type Props = { 23 | newIsContrastInputOpen: boolean 24 | sideEffects?: Partial 25 | } 26 | 27 | const defaultSideEffects: SideEffects = { 28 | syncIsContrastInputOpenWithBackend: true 29 | } 30 | 31 | export const setIsContrastInputOpenWithSideEffects = action( 32 | $isContrastInputOpen, 33 | 'setIsContrastInputOpenWithSideEffects', 34 | (isContrastInputOpen, props: Props) => { 35 | const { newIsContrastInputOpen, sideEffects: partialSideEffects } = props 36 | 37 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 38 | merge(sideEffects, partialSideEffects) 39 | 40 | isContrastInputOpen.set(newIsContrastInputOpen) 41 | 42 | if (sideEffects.syncIsContrastInputOpenWithBackend) { 43 | sendMessageToBackend({ 44 | type: 'syncIsContrastInputOpen', 45 | data: { 46 | newIsContrastInputOpen: newIsContrastInputOpen 47 | } 48 | }) 49 | } 50 | } 51 | ) 52 | 53 | if (consoleLogInfos.includes('Store updates')) { 54 | logger({ 55 | isContrastInputOpen: $isContrastInputOpen 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /src/ui/components/ColorValueInputs/helpers/handleInputOnBlur/handleInputOnBlur.ts: -------------------------------------------------------------------------------- 1 | import round from 'lodash/round' 2 | import { HxyaLabels } from '../../../../../types' 3 | import getColorHxyDecimals from '../../../../helpers/colors/getColorHxyDecimals/getColorHxyDecimals' 4 | import { $isMouseInsideDocument } from '../../../../stores/isMouseInsideDocument/isMouseInsideDocument' 5 | import clampColorHxyaValueInInputFormat from '../clampColorHxyaValueInInputFormat/clampColorHxyaValueInInputFormat' 6 | import formatAndSendNewValueToStore from '../formatAndSendNewValueToStore/formatAndSendNewValueToStore' 7 | import getColorHxyaValueFormatedForInput from '../getColorHxyaValueFormatedForInput/getColorHxyaValueFormatedForInput' 8 | import parseInputString from '../../../../helpers/inputs/parseInputString/parseInputString' 9 | 10 | export default function handleInputOnBlur(event: React.FocusEvent, lastKeyPressed: React.MutableRefObject) { 11 | const eventTarget = event.target as HTMLInputElement 12 | const eventId = eventTarget.id as HxyaLabels 13 | const oldValue = getColorHxyaValueFormatedForInput(eventId) 14 | 15 | const resetToOldValue = () => { 16 | eventTarget.value = oldValue.toString() 17 | lastKeyPressed.current = '' 18 | return 19 | } 20 | 21 | const rawValue = parseInputString(eventTarget.value) 22 | 23 | if (rawValue === null) { 24 | return resetToOldValue() 25 | } 26 | 27 | const newValue = round(rawValue, getColorHxyDecimals({ forInputs: true })[`${eventId}`]) 28 | 29 | const clampedNewValue = clampColorHxyaValueInInputFormat(eventId, newValue) 30 | if ( 31 | clampedNewValue === oldValue || 32 | lastKeyPressed.current === 'Escape' || 33 | (!$isMouseInsideDocument.get() && !['Enter', 'Tab'].includes(lastKeyPressed.current)) 34 | ) { 35 | return resetToOldValue() 36 | } 37 | 38 | lastKeyPressed.current = '' 39 | formatAndSendNewValueToStore(eventId, clampedNewValue) 40 | } 41 | -------------------------------------------------------------------------------- /src/ui/stores/colors/isColorCodeInputsOpen/isColorCodeInputsOpen.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../../constants' 4 | import { SyncIsColorCodeInputsOpenData } from '../../../../types' 5 | import sendMessageToBackend from '../../../helpers/sendMessageToBackend/sendMessageToBackend' 6 | import merge from 'lodash/merge' 7 | 8 | export const $isColorCodeInputsOpen = atom(false) 9 | 10 | export const setIsColorCodeInputsOpen = action( 11 | $isColorCodeInputsOpen, 12 | 'setIsColorCodeInputsOpen', 13 | (isColorCodeInputsOpen, newIsColorCodeInputsOpen: boolean) => { 14 | isColorCodeInputsOpen.set(newIsColorCodeInputsOpen) 15 | } 16 | ) 17 | 18 | type SideEffects = { 19 | syncIsColorCodeInputsOpenWithBackend: boolean 20 | } 21 | 22 | type Props = { 23 | newIsColorCodeInputsOpen: boolean 24 | sideEffects?: Partial 25 | } 26 | 27 | const defaultSideEffects: SideEffects = { 28 | syncIsColorCodeInputsOpenWithBackend: true 29 | } 30 | 31 | export const setIsColorCodeInputsOpenWithSideEffects = action( 32 | $isColorCodeInputsOpen, 33 | 'setIsColorCodeInputsOpenWithSideEffects', 34 | (isColorCodeInputsOpen, props: Props) => { 35 | const { newIsColorCodeInputsOpen, sideEffects: partialSideEffects } = props 36 | 37 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 38 | merge(sideEffects, partialSideEffects) 39 | 40 | isColorCodeInputsOpen.set(newIsColorCodeInputsOpen) 41 | 42 | if (sideEffects.syncIsColorCodeInputsOpenWithBackend) { 43 | sendMessageToBackend({ 44 | type: 'syncIsColorCodeInputsOpen', 45 | data: { 46 | newIsColorCodeInputsOpen: newIsColorCodeInputsOpen 47 | } 48 | }) 49 | } 50 | } 51 | ) 52 | 53 | if (consoleLogInfos.includes('Store updates')) { 54 | logger({ 55 | isColorCodeInputsOpen: $isColorCodeInputsOpen 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /src/ui/components/Alert/Alert.css: -------------------------------------------------------------------------------- 1 | .c-alert { 2 | z-index: 999; 3 | position: absolute; 4 | display: none; 5 | justify-content: center; 6 | align-items: center; 7 | min-height: 100vh; 8 | padding: 16px; 9 | } 10 | 11 | .c-alert--active { 12 | display: flex; 13 | } 14 | 15 | .figma-light .c-alert { 16 | background-color: rgba(0, 0, 0, 0.4); 17 | } 18 | 19 | .figma-dark .c-alert { 20 | background-color: rgba(0, 0, 0, 0.65); 21 | } 22 | 23 | .c-alert__box { 24 | background-color: var(--figma-color-bg); 25 | width: 100%; 26 | margin-top: -10px; 27 | border-radius: var(--base-border-radius); 28 | 29 | color: var(--figma-color-text); 30 | font-size: var(--base-font-size); 31 | } 32 | 33 | .figma-light .c-alert__box { 34 | box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.3); 35 | } 36 | 37 | .figma-dark .c-alert__box { 38 | box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.6); 39 | } 40 | 41 | .c-alert__header { 42 | padding: 8px 9px 8px 16px; 43 | display: flex; 44 | justify-content: space-between; 45 | align-items: center; 46 | border-bottom: 1px solid var(--figma-color-border); 47 | } 48 | 49 | .c-alert__title { 50 | font-weight: 600; 51 | } 52 | 53 | .c-alert__close-icon { 54 | border-radius: var(--base-border-radius); 55 | } 56 | 57 | .c-alert__close-icon:hover { 58 | background-color: var(--figma-color-bg-secondary); 59 | } 60 | 61 | .c-alert__close-icon svg { 62 | fill: var(--figma-color-icon); 63 | } 64 | 65 | .c-alert__content { 66 | padding: 12px 16px 16px 16px; 67 | } 68 | 69 | .c-alert__content p { 70 | line-height: 1.5; 71 | } 72 | 73 | .c-alert__buttons-container { 74 | padding: 0px 16px 16px 16px; 75 | display: flex; 76 | justify-content: flex-end; 77 | } 78 | 79 | /* Buttons Styling */ 80 | .c-alert__button { 81 | background-color: var(--figma-color-bg-brand); 82 | color: white; 83 | font-size: var(--base-font-size); 84 | font-weight: 500; 85 | padding: 6px 12px; 86 | border: none; 87 | border-radius: var(--base-border-radius); 88 | } 89 | -------------------------------------------------------------------------------- /src/backend/helpers/updateShapeColor/updateShapeColor.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | 3 | import { ColorRgba, CurrentBgOrFg, CurrentFillOrStroke } from '../../../types' 4 | 5 | type Props = { 6 | newColorRgba: ColorRgba 7 | currentFillOrStroke: CurrentFillOrStroke 8 | currentBgOrFg: CurrentBgOrFg 9 | } 10 | 11 | export default function updateShapeColor(props: Props) { 12 | const { newColorRgba, currentFillOrStroke, currentBgOrFg } = props 13 | 14 | let copyNode 15 | const type = currentFillOrStroke + 's' 16 | 17 | for (const node of figma.currentPage.selection) { 18 | let parentObject 19 | 20 | // Deep copy of node[types] is necessary to update it as explained in Figma's dev doc: https://www.figma.com/plugin-docs/editing-properties/ 21 | // We use ts-ignore because know here that node will always have a fills or strokes properties because the user can't use the plugin if the selected shape(s) are not of the types from supportedNodeTypes. 22 | if (currentBgOrFg === 'bg') { 23 | parentObject = node.parent as any // We know that node.parent will have the properties if currentBgOrFg === 'bg'. 24 | while (parentObject) { 25 | if (parentObject.type !== 'GROUP' && parentObject.fills?.length !== 0) { 26 | break 27 | } else if (parentObject.parent) { 28 | parentObject = parentObject.parent 29 | } else { 30 | break 31 | } 32 | } 33 | // @ts-ignore 34 | copyNode = JSON.parse(JSON.stringify(parentObject.fills)) 35 | } else { 36 | // @ts-ignore 37 | copyNode = JSON.parse(JSON.stringify(node[type])) 38 | } 39 | 40 | copyNode[0].color.r = newColorRgba.r 41 | copyNode[0].color.g = newColorRgba.g 42 | copyNode[0].color.b = newColorRgba.b 43 | copyNode[0].opacity = newColorRgba.a 44 | 45 | if (currentBgOrFg === 'bg') { 46 | // @ts-ignore 47 | parentObject.fills = copyNode 48 | } else { 49 | // @ts-ignore 50 | node[type] = copyNode 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/ui/stores/contrasts/currentContrastMethod/currentContrastMethod.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../../constants' 4 | import { CurrentContrastMethod, SyncCurrentContrastMethodData } from '../../../../types' 5 | import sendMessageToBackend from '../../../helpers/sendMessageToBackend/sendMessageToBackend' 6 | import merge from 'lodash/merge' 7 | 8 | export const $currentContrastMethod = atom('apca') 9 | 10 | export const setCurrentContrastMethod = action( 11 | $currentContrastMethod, 12 | 'setCurrentContrastMethod', 13 | (currentContrastMethod, newCurrentContrastMethod: CurrentContrastMethod) => { 14 | currentContrastMethod.set(newCurrentContrastMethod) 15 | } 16 | ) 17 | 18 | type SideEffects = { 19 | syncCurrentContrastMethodWithBackend: boolean 20 | } 21 | 22 | type Props = { 23 | newCurrentContrastMethod: CurrentContrastMethod 24 | sideEffects?: Partial 25 | } 26 | 27 | const defaultSideEffects: SideEffects = { 28 | syncCurrentContrastMethodWithBackend: true 29 | } 30 | 31 | export const setCurrentContrastMethodWithSideEffects = action( 32 | $currentContrastMethod, 33 | 'setCurrentContrastMethodWithSideEffects', 34 | (currentContrastMethod, props: Props) => { 35 | const { newCurrentContrastMethod, sideEffects: partialSideEffects } = props 36 | 37 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 38 | merge(sideEffects, partialSideEffects) 39 | 40 | currentContrastMethod.set(newCurrentContrastMethod) 41 | 42 | if (sideEffects.syncCurrentContrastMethodWithBackend) { 43 | sendMessageToBackend({ 44 | type: 'syncCurrentContrastMethod', 45 | data: { 46 | newCurrentContrastMethod: newCurrentContrastMethod 47 | } 48 | }) 49 | } 50 | } 51 | ) 52 | 53 | if (consoleLogInfos.includes('Store updates')) { 54 | logger({ 55 | currentContrastMethod: $currentContrastMethod 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /src/ui/stores/colors/relativeChroma/relativeChroma.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../../constants' 4 | import { RelativeChroma } from '../../../../types' 5 | import convertRelativeChromaToAbsolute from '../../../helpers/colors/convertRelativeChromaToAbsolute/convertRelativeChromaToAbsolute' 6 | import { $colorHxya, setColorHxyaWithSideEffects } from '../colorHxya/colorHxya' 7 | import merge from 'lodash/merge' 8 | 9 | export const $relativeChroma = atom(0) 10 | 11 | export const setRelativeChroma = action($relativeChroma, 'setRelativeChroma', (relativeChroma, newRelativeChroma: RelativeChroma) => { 12 | relativeChroma.set(newRelativeChroma) 13 | }) 14 | 15 | type SideEffects = { 16 | syncColorHxya: boolean 17 | } 18 | 19 | type Props = { 20 | newRelativeChroma: RelativeChroma 21 | sideEffects?: Partial 22 | } 23 | 24 | const defaultSideEffects: SideEffects = { 25 | syncColorHxya: true 26 | } 27 | 28 | export const setRelativeChromaWithSideEffects = action($relativeChroma, 'setRelativeChromaWithSideEffects', (relativeChroma, props: Props) => { 29 | const { newRelativeChroma, sideEffects: partialSideEffects } = props 30 | 31 | // TODO - add SideEffects type (sideEffects: SideEffects) 32 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 33 | merge(sideEffects, partialSideEffects) 34 | 35 | relativeChroma.set(newRelativeChroma) 36 | 37 | if (sideEffects.syncColorHxya) { 38 | const newColorX = convertRelativeChromaToAbsolute({ 39 | h: $colorHxya.get().h, 40 | y: $colorHxya.get().y, 41 | relativeChroma: newRelativeChroma 42 | }) 43 | 44 | setColorHxyaWithSideEffects({ 45 | newColorHxya: { x: newColorX }, 46 | sideEffects: { 47 | syncRelativeChroma: false 48 | }, 49 | lockRelativeChroma: false 50 | }) 51 | } 52 | }) 53 | 54 | if (consoleLogInfos.includes('Store updates')) { 55 | logger({ 56 | relativeChroma: $relativeChroma 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /src/ui/styles/shared-components/c-slider.css: -------------------------------------------------------------------------------- 1 | .c-slider { 2 | position: relative; 3 | width: 178px; 4 | height: 16px; 5 | } 6 | .c-slider--deactivated { 7 | pointer-events: none; 8 | opacity: 0.5; 9 | } 10 | .c-slider__canvas { 11 | border-radius: 999px; 12 | box-shadow: rgb(0 0 0 / 20%) 0px 0px 0px 0.6px inset; 13 | } 14 | .c-slider__canvas { 15 | position: absolute; 16 | width: 100%; 17 | height: 100%; 18 | background-size: cover; 19 | } 20 | .c-slider__canvas--hue-bg-img { 21 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAWQAAAAgCAIAAACqx5W5AAABaWlDQ1BEaXNwbGF5IFAzAAAokXWQvUvDUBTFT6tS0DqIDh0cMolD1NIKdnFoKxRFMFQFq1P6+iU0MSQpUnETVyn4H1jBWXCwiFRwcXAQRAcR3Zw6Kbhoed6XVNoi3sfl/Ticc7lcwBsoMs3qBaDptplMxKS11Lrke4OHnlMqs4yooiwK/v276/PR9d5PiFmNZvUgsp+41s4ul3aeAlN//V3Vn8lajP5v6iAzTBvwyMTKtm0I3iUeMWkp4orgvMvHgtMunzuelWSc+JZYYgU1Q9wgltMder6DtWKJtXYQ2/uz+uqymEM9ijlswoKBIlSUIUFB+B//tOOPY4vcZZiUy6MAmzJRUsSELPE8dDBMQiYOIUgdEndu3e+hdT+5re29ArN1zvlFW1uoA6czdLJaWxuPAEMDwE3NUE3VkXqovbkc8H4CDKaA4TvKbFi5cMjd3h8D+l44/xgDfIdAs8L51xHnzSqFn4Er/Qce3WrASs9Z0QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAkVJREFUeJzt1EFu2zAQheF/hpLtBGnRLrrJ/Vc9QU+UXYECRdMk0sx0ITIK0A0P8D4YBjWSBdLkPPth3yGNapSRThkFdQwaaeC9ko1y8PGYn5/jVjnVSB9va1Qjj4+RjfBr8im4T9bCYQ0uwZqsybLTiiXxoCVLsiQtaYnnqFS/e1Ra4ON5L1piL7TCd1rgb/gz/rvaS3kx5kc7Z4/DMd1jYGDnOvtqj6JTjo3FY+R4Sf877MNgjHPUc4xjXIb17zAKdiOchN3YjYDdCWOHzdmNzdhgg1fjzXgxXo1n49n4A7/gydsTj1s9wld4oO6oO7hQV2qlVmqhVlihUQvV+t9x7BhjMbQ+176ldi44HRgLc5JxXqyPgaizWJAQUPRxFkBAjmKvj0rmeSuKqn6L8fMaT0b1+vv7y84H0s431/uc6UsI60sL71uOk41o1EKu5EJcyAt5Y799ieu3sM/BfXC3cw1uwXUf53fnsnHZWN9Y31j/sv6s9ro1dicau7MfewjhhLONnT9OwW4fToSfEz2K/UBZb8b3M1WjDXM0bO/N0bBHY2LjkI6m7pdtXDby/4Z10gm/JQ+9YR0RkQkKCxGZorAQkSkKCxGZorAQkSkKCxGZorAQkSkKCxGZorAQkSkKCxGZorAQkSkKCxGZorAQkSkKCxGZorAQkSkKCxGZorAQkSkKCxGZorAQkSkKCxGZorAQkSkKCxGZorAQkSkKCxGZorAQkSkKCxGZorAQkSkKCxGZorAQkSkKCxGZorAQkSn/AJCrT+RyCiKGAAAAAElFTkSuQmCC'); 22 | } 23 | 24 | .c-slider__manipulator-container { 25 | width: 178px; 26 | pointer-events: none; 27 | } 28 | 29 | .c-slider__manipulator { 30 | width: 18px; 31 | height: 18px; 32 | } 33 | -------------------------------------------------------------------------------- /src/ui/components/ColorValueInputs/helpers/handleInputOnKeyDown/handleInputOnKeyDown.ts: -------------------------------------------------------------------------------- 1 | import clamp from 'lodash/clamp' 2 | import round from 'lodash/round' 3 | import { HxyaLabels } from '../../../../../types' 4 | import getColorHxyDecimals from '../../../../helpers/colors/getColorHxyDecimals/getColorHxyDecimals' 5 | import getHxyaInputRange from '../../../../helpers/colors/getHxyaInputRange/getHxyaInputRange' 6 | import formatAndSendNewValueToStore from '../formatAndSendNewValueToStore/formatAndSendNewValueToStore' 7 | import getStepUpdateValue from '../getStepUpdateValue/getStepUpdateValue' 8 | import { KeepInputSelected } from '../../ColorValueInputs' 9 | 10 | export default function handleInputOnKeyDown( 11 | event: React.KeyboardEvent, 12 | lastKeyPressed: React.MutableRefObject, 13 | keepInputSelected: React.MutableRefObject 14 | ) { 15 | const eventKey = event.key 16 | 17 | if (['Enter', 'Tab', 'Escape'].includes(eventKey)) { 18 | lastKeyPressed.current = eventKey 19 | ;(event.target as HTMLInputElement).blur() 20 | } else if (['ArrowUp', 'ArrowDown'].includes(eventKey)) { 21 | if (['ArrowUp', 'ArrowDown'].includes(eventKey)) { 22 | const eventTarget = event.target as HTMLInputElement 23 | const eventId = eventTarget.id as HxyaLabels 24 | 25 | let newValue = parseFloat(eventTarget.value) 26 | 27 | event.preventDefault() 28 | keepInputSelected.current.state = true 29 | keepInputSelected.current.inputId = eventId 30 | 31 | const stepUpdateValue = getStepUpdateValue(eventId) 32 | if (eventKey === 'ArrowUp') newValue += stepUpdateValue 33 | else if (eventKey === 'ArrowDown') newValue -= stepUpdateValue 34 | 35 | // We need to round the value because sometimes we can get results like 55.8999999. 36 | newValue = round(newValue, getColorHxyDecimals({ forInputs: true })[`${eventId}`]) 37 | 38 | const clampedNewValue = clamp(newValue, getHxyaInputRange(eventId).min, getHxyaInputRange(eventId).max) 39 | formatAndSendNewValueToStore(eventId, clampedNewValue) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/convertHxyToRgb/convertHxyToRgb.ts: -------------------------------------------------------------------------------- 1 | import { ColorHxy, ColorModelList, CurrentFileColorProfile, ColorRgb } from '../../../../types' 2 | import { $currentColorModel } from '../../../stores/colors/currentColorModel/currentColorModel' 3 | import { $currentFileColorProfile } from '../../../stores/colors/currentFileColorProfile/currentFileColorProfile' 4 | import { converter } from 'culori' 5 | import type { Rgb, Okhsl, Okhsv, Oklch } from 'culori' 6 | import clamp from 'lodash/clamp' 7 | 8 | const convertToRgb = converter('rgb') 9 | const convertToP3 = converter('p3') 10 | 11 | type Props = { 12 | colorHxy: ColorHxy 13 | originColorModel?: keyof typeof ColorModelList 14 | gamut?: CurrentFileColorProfile 15 | } 16 | 17 | export default function convertHxyToRgb(props: Props): ColorRgb { 18 | const { colorHxy, originColorModel = $currentColorModel.get(), gamut = $currentFileColorProfile.get() } = props 19 | 20 | let culoriResult: Rgb | Okhsl | Okhsv | Oklch 21 | let newColorRgb: ColorRgb 22 | 23 | let colorObject 24 | 25 | switch (originColorModel) { 26 | case 'oklch': 27 | colorObject = { mode: 'oklch', h: colorHxy.h, c: colorHxy.x, l: colorHxy.y / 100 } 28 | break 29 | case 'okhsl': 30 | colorObject = { mode: 'okhsl', h: colorHxy.h, s: colorHxy.x / 100, l: colorHxy.y / 100 } 31 | break 32 | case 'okhsv': 33 | colorObject = { mode: 'okhsv', h: colorHxy.h, s: colorHxy.x / 100, v: colorHxy.y / 100 } 34 | break 35 | } 36 | 37 | if (gamut === 'rgb') { 38 | culoriResult = convertToRgb(colorObject) 39 | } else if (gamut === 'p3') { 40 | culoriResult = convertToP3(colorObject) 41 | } 42 | 43 | if (colorHxy.y === 0) { 44 | // If we have a black color (luminosity / value = 0), convertToRgb() return NaN for the RGB values so we fix this. 45 | newColorRgb = { 46 | r: 0, 47 | g: 0, 48 | b: 0 49 | } 50 | } else { 51 | newColorRgb = { 52 | r: clamp(culoriResult.r, 0, 1), 53 | g: clamp(culoriResult.g, 0, 1), 54 | b: clamp(culoriResult.b, 0, 1) 55 | } 56 | } 57 | 58 | return newColorRgb 59 | } 60 | -------------------------------------------------------------------------------- /src/ui/stores/uiMessage/uiMessage.ts: -------------------------------------------------------------------------------- 1 | import { action, map } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../constants' 4 | import { UiMessage } from '../../../types' 5 | import setValuesForUiMessage from '../../helpers/setValuesForUiMessage/setValuesForUiMessage' 6 | import { uiMessageTexts } from '../../ui-messages' 7 | import merge from 'lodash/merge' 8 | 9 | export const $uiMessage = map({ 10 | show: false, 11 | message: '' 12 | }) 13 | 14 | export const setUiMessage = action($uiMessage, 'setUiMessage', (uiMessage, newUiMessage: UiMessage) => { 15 | uiMessage.set(newUiMessage) 16 | }) 17 | 18 | type SideEffects = { 19 | syncBodyElement: boolean 20 | useValuesForUiMessageFunction: boolean 21 | } 22 | 23 | type Props = { 24 | messageCode: keyof typeof uiMessageTexts 25 | nodeType: string | null 26 | sideEffects?: Partial 27 | } 28 | 29 | const defaultSideEffects: SideEffects = { 30 | syncBodyElement: true, 31 | useValuesForUiMessageFunction: true 32 | } 33 | 34 | export const showUiMessageWithSideEffects = action($uiMessage, 'showUiMessageWithSideEffects', (uiMessage, props: Props) => { 35 | const { messageCode, nodeType, sideEffects: partialSideEffects } = props 36 | 37 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 38 | merge(sideEffects, partialSideEffects) 39 | 40 | let message = uiMessageTexts[`${messageCode}`] 41 | if (nodeType) { 42 | message = message.replace('$SHAPE', nodeType.toLowerCase()) 43 | } 44 | 45 | uiMessage.set({ show: true, message: message }) 46 | 47 | if (sideEffects.syncBodyElement) { 48 | document.body.classList.add('deactivated') 49 | } 50 | 51 | if (sideEffects.useValuesForUiMessageFunction) setValuesForUiMessage() 52 | }) 53 | 54 | export const hideUiMessageWithSideEffects = action($uiMessage, 'hideUiMessageWithSideEffects', (uiMessage, syncBodyElement = true) => { 55 | uiMessage.set({ show: false, message: '' }) 56 | 57 | if (syncBodyElement) { 58 | document.body.classList.remove('deactivated') 59 | } 60 | }) 61 | 62 | if (consoleLogInfos.includes('Store updates')) { 63 | logger({ 64 | uiMessage: $uiMessage 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /src/ui/components/ColorPicker/helpers/handleWheel/handleWheel.ts: -------------------------------------------------------------------------------- 1 | import round from 'lodash/round' 2 | import convertRelativeChromaToAbsolute from '../../../../helpers/colors/convertRelativeChromaToAbsolute/convertRelativeChromaToAbsolute' 3 | import getColorHxyDecimals from '../../../../helpers/colors/getColorHxyDecimals/getColorHxyDecimals' 4 | import { $colorHxya, setColorHxyaWithSideEffects } from '../../../../stores/colors/colorHxya/colorHxya' 5 | import { $oklchRenderMode } from '../../../../stores/oklchRenderMode/oklchRenderMode' 6 | 7 | export default function handleWheel(event: WheelEvent) { 8 | let currentH = $colorHxya.get().h 9 | let valueToAdd = event.deltaY / 8 10 | 11 | // If deltaY is not equal to 0 or -0 when shift is pressed, it means that user has a trackpad. 12 | // That's because with a mouse and whift pressed, system whill trigger horizontal scrolling, thus keeping deltaY to 0 and update deltaX instead, but not with trackpads like Apple's one. 13 | // In that case, we avoid constraining movement to value of 5 as the trackpad is not precise enough. 14 | if (event.shiftKey && event.deltaY === 0) { 15 | // Round the value to the nearest multiple of 5 value. E.g. if hue is 134, we round it to 135. 16 | // This is usefull to have value multiple of 5 when user press shift no matter the initial hue value. 17 | currentH = Math.round(currentH / 5) * 5 18 | 19 | if (event.deltaX > 0) { 20 | valueToAdd = 5 21 | } else { 22 | valueToAdd = -5 23 | } 24 | } 25 | 26 | let newH = round(currentH + valueToAdd, getColorHxyDecimals().h) 27 | 28 | // To allows for an infinite scroll loop. 29 | if (newH < 0) { 30 | newH = 360 31 | } else if (newH > 360) { 32 | newH = 0 33 | } 34 | 35 | if ($oklchRenderMode.get() === 'triangle') { 36 | setColorHxyaWithSideEffects({ 37 | newColorHxya: { 38 | h: newH 39 | } 40 | }) 41 | } else if ($oklchRenderMode.get() === 'square') { 42 | const newXValue = convertRelativeChromaToAbsolute({ 43 | h: newH, 44 | y: $colorHxya.get().y 45 | }) 46 | 47 | setColorHxyaWithSideEffects({ 48 | newColorHxya: { 49 | x: newXValue, 50 | h: newH 51 | }, 52 | sideEffects: { 53 | syncRelativeChroma: false 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ui/stores/contrasts/currentBgOrFg/currentBgOrFg.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../../constants' 4 | import { CurrentBgOrFg, ColorRgb, ColorRgba, Opacity } from '../../../../types' 5 | import convertRgbToHxy from '../../../helpers/colors/convertRgbToHxy/convertRgbToHxy' 6 | import { setColorHxyaWithSideEffects } from '../../colors/colorHxya/colorHxya' 7 | import { $colorsRgba } from '../../colors/colorsRgba/colorsRgba' 8 | import merge from 'lodash/merge' 9 | 10 | export const $currentBgOrFg = atom('fg') 11 | 12 | export const setCurrentBgOrFg = action($currentBgOrFg, 'setCurrentBgOrFg', (currentBgOrFg, newCurrentBgOrFg: CurrentBgOrFg) => { 13 | currentBgOrFg.set(newCurrentBgOrFg) 14 | }) 15 | 16 | type SideEffects = { 17 | syncColorHxya: boolean 18 | } 19 | 20 | type Props = { 21 | newCurrentBgOrFg: CurrentBgOrFg 22 | sideEffects?: Partial 23 | } 24 | 25 | const defaultSideEffects: SideEffects = { 26 | syncColorHxya: true 27 | } 28 | 29 | export const setCurrentBgOrFgWithSideEffects = action($currentBgOrFg, 'setCurrentBgOrFgWithSideEffects', (currentBgOrFg, props: Props) => { 30 | const { newCurrentBgOrFg, sideEffects: partialSideEffects } = props 31 | 32 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 33 | merge(sideEffects, partialSideEffects) 34 | 35 | currentBgOrFg.set(newCurrentBgOrFg) 36 | 37 | if (sideEffects.syncColorHxya) { 38 | let newColorRgba: ColorRgb | ColorRgba 39 | let opacity: Opacity = 1 40 | 41 | if ($currentBgOrFg.get() === 'bg') { 42 | newColorRgba = $colorsRgba.get().parentFill! 43 | } else { 44 | newColorRgba = $colorsRgba.get().fill! 45 | opacity = $colorsRgba.get().fill!.a 46 | } 47 | 48 | const newColorHxy = convertRgbToHxy({ colorRgb: newColorRgba }) 49 | 50 | setColorHxyaWithSideEffects({ 51 | newColorHxya: { ...newColorHxy, a: opacity }, 52 | sideEffects: { 53 | colorsRgba: { 54 | syncColorsRgba: false 55 | } 56 | }, 57 | lockRelativeChroma: false, 58 | lockContrast: false 59 | }) 60 | } 61 | }) 62 | 63 | if (consoleLogInfos.includes('Store updates')) { 64 | logger({ 65 | currentBgOrFg: $currentBgOrFg 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /src/ui/helpers/contrasts/getContrastFromBgandFgRgba/getContrastFromBgandFgRgba.ts: -------------------------------------------------------------------------------- 1 | import { APCAcontrast, sRGBtoY, alphaBlend, displayP3toY } from 'apca-w3' 2 | import { 3 | ColorRgba, 4 | ColorRgb, 5 | CurrentContrastMethod, 6 | ApcaContrast, 7 | WcagContrast, 8 | RgbArray, 9 | RgbaArray, 10 | CurrentFileColorProfile 11 | } from '../../../../types' 12 | import { $currentFileColorProfile } from '../../../stores/colors/currentFileColorProfile/currentFileColorProfile' 13 | import { $currentContrastMethod } from '../../../stores/contrasts/currentContrastMethod/currentContrastMethod' 14 | import WCAGcontrast from '../WCAGcontrast/WCAGcontrast' 15 | 16 | type Props = { 17 | fg: ColorRgba 18 | bg: ColorRgb 19 | currentContrastMethod?: CurrentContrastMethod 20 | currentFileColorProfile?: CurrentFileColorProfile 21 | } 22 | 23 | export default function getContrastFromBgandFgRgba(props: Props): ApcaContrast | WcagContrast { 24 | const { fg, bg, currentContrastMethod = $currentContrastMethod.get(), currentFileColorProfile = $currentFileColorProfile.get() } = props 25 | 26 | let bgRgb: RgbArray = [bg.r, bg.g, bg.b] 27 | let fgRgb: RgbaArray = [fg.r, fg.g, fg.b, fg.a] 28 | let APCAContrastResult: ApcaContrast | string 29 | let newContrast: ApcaContrast | WcagContrast = 0 30 | 31 | switch (currentContrastMethod) { 32 | case 'apca': 33 | if (currentFileColorProfile === 'rgb') { 34 | // sRGBtoY need these value between 0 and 255. 35 | bgRgb = [bg.r * 255, bg.g * 255, bg.b * 255] 36 | fgRgb = [fg.r * 255, fg.g * 255, fg.b * 255, fg.a] 37 | 38 | APCAContrastResult = APCAcontrast(sRGBtoY(alphaBlend(fgRgb, bgRgb)), sRGBtoY(bgRgb)) 39 | } else { 40 | APCAContrastResult = APCAcontrast(displayP3toY(alphaBlend(fgRgb, bgRgb, false)), displayP3toY(bgRgb)) 41 | } 42 | // From some reason, APCAcontrast() can return a string. so we need to convert it to number if that the case. 43 | if (typeof APCAContrastResult === 'string') newContrast = parseInt(APCAContrastResult) 44 | else newContrast = APCAContrastResult 45 | 46 | newContrast = Math.round(newContrast) 47 | break 48 | 49 | case 'wcag': 50 | newContrast = WCAGcontrast(fgRgb, bgRgb) 51 | break 52 | 53 | default: 54 | break 55 | } 56 | 57 | return newContrast 58 | } 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Easier color palettes and accessibility](https://ik.imagekit.io/cgavlsdta/tr:cp-true/okcolor/covert-art.webp?updatedAt=1745861931799) 2 | 3 | # OkColor 4 | 5 | This is a plugin for Figma, [see its community page](https://www.figma.com/community/plugin/1173638098109123591/OkColor). 6 | 7 | Check https://github.com/users/dokozero/projects/1 for the roadmap. 8 | 9 | – 10 | 11 | Picking color and creating balanced color palettes with Figma is not an easy task, HSL and HSB are not perceptually uniform, HSL's lightness is relative to the current hue, so for each of them, the real perceived 50% lightness is not at L 50. 12 | 13 | Same problem with hue, if we make a palette from hue 0 to 70 with the same incremental value, we'll get a palette that is not perceptually progressive, some hue changes will seem bigger than others. 14 | 15 | We also have a problem known as the “Abney effect”, mainly in the blue hues. If we take the hue 240, it shifts from blue to purple when we update the lightness. 16 | 17 | OkColor solves all these problems and more, its params are reliable and uniform, you know what you'll get. 18 | 19 | If we change a color hue in OkLCH and keep the same lightness value, we know that the resulting color will have the same perceived lightness. 20 | 21 | You can also easily create perceptually uniform color palettes, pick colors in P3 gamut, use the relative chroma ([see this thread for more infos](https://twitter.com/dokozero/status/1711379022553272371)) and more. 22 | 23 | For more details, you can check [plugin community's page](https://www.figma.com/community/plugin/1173638098109123591/OkColor). 24 | 25 | ## Credits 26 | 27 | This plugin is made possible by the [Culori JS library](https://culorijs.org/) and the creator of these color models: [Björn Ottosson](https://bottosson.github.io/). 28 | 29 | The rendering of the color picker is done by [freydev](https://github.com/freydev) using WebGL shaders. 30 | 31 | The APCA contrast feature is made possible by the work of [Myndex](https://www.myndex.com/APCA/) . 32 | 33 | To know more about these uniform color spaces, you can check the original article from Björn Ottosson: [Oksvh and Okhsl](https://bottosson.github.io/posts/colorpicker/) and the one from oklch.com's creators: [OKLCH in CSS: why we moved from RGB and HSL](https://evilmartians.com/chronicles/oklch-in-css-why-quit-rgb-hsl). 34 | -------------------------------------------------------------------------------- /src/ui/helpers/setValuesForUiMessage/setValuesForUiMessage.ts: -------------------------------------------------------------------------------- 1 | import { setColorHxyaWithSideEffects } from '../../stores/colors/colorHxya/colorHxya' 2 | import { setColorsRgbaWithSideEffects } from '../../stores/colors/colorsRgba/colorsRgba' 3 | import { setLockRelativeChroma } from '../../stores/colors/lockRelativeChroma/lockRelativeChroma' 4 | import { setRelativeChroma } from '../../stores/colors/relativeChroma/relativeChroma' 5 | import { $currentBgOrFg, setCurrentBgOrFg } from '../../stores/contrasts/currentBgOrFg/currentBgOrFg' 6 | import { setLockContrast } from '../../stores/contrasts/lockContrast/lockContrast' 7 | import { setCurrentFillOrStroke } from '../../stores/currentFillOrStroke/currentFillOrStroke' 8 | 9 | /** 10 | * Reset interface state, we use these values to have a nice base look when the UI message is on. 11 | */ 12 | export default function setValuesForUiMessage() { 13 | setLockRelativeChroma(false) 14 | setLockContrast(false) 15 | 16 | if (document.documentElement.classList.contains('figma-light')) { 17 | setColorsRgbaWithSideEffects({ 18 | newColorsRgba: { 19 | parentFill: null, 20 | fill: { r: 1, g: 1, b: 1, a: 1 }, 21 | stroke: { r: 1, g: 1, b: 1, a: 1 } 22 | }, 23 | sideEffects: { 24 | syncColorRgbWithBackend: false, 25 | colorHxya: { 26 | syncColorHxya: false 27 | }, 28 | syncContrast: false 29 | } 30 | }) 31 | } else if (document.documentElement.classList.contains('figma-dark')) { 32 | setColorsRgbaWithSideEffects({ 33 | newColorsRgba: { 34 | parentFill: null, 35 | fill: { r: 0.173, g: 0.173, b: 0.173, a: 1 }, 36 | stroke: { r: 0.173, g: 0.173, b: 0.173, a: 1 } 37 | }, 38 | sideEffects: { 39 | syncColorRgbWithBackend: false, 40 | colorHxya: { 41 | syncColorHxya: false 42 | }, 43 | syncContrast: false 44 | } 45 | }) 46 | } 47 | 48 | setCurrentFillOrStroke('fill') 49 | 50 | if ($currentBgOrFg.get() === 'bg') setCurrentBgOrFg('fg') 51 | 52 | // We send this color to get '0' on all values of the UI. 53 | setColorHxyaWithSideEffects({ 54 | newColorHxya: { h: 0, x: 0, y: 0, a: 0 }, 55 | sideEffects: { 56 | colorsRgba: { 57 | syncColorsRgba: false 58 | } 59 | } 60 | }) 61 | 62 | setRelativeChroma(0) 63 | } 64 | -------------------------------------------------------------------------------- /src/ui/components/Alert/Alert.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { consoleLogInfos } from '../../../constants' 3 | import { $currentColorModel } from '../../stores/colors/currentColorModel/currentColorModel' 4 | import CrossIcon from '../icons/CrossIcon/CrossIcon' 5 | import { useStore } from '@nanostores/react' 6 | 7 | export default function Alert() { 8 | if (consoleLogInfos.includes('Component renders')) { 9 | console.log('Component render — Alert') 10 | } 11 | 12 | const currentColorModel = useStore($currentColorModel) 13 | const [showAlert, setShowAlert] = useState(false) 14 | 15 | useEffect(() => { 16 | setShowAlert(currentColorModel === 'okhsl' || currentColorModel === 'okhsv') 17 | }, [currentColorModel]) 18 | 19 | useEffect(() => { 20 | // Don't show the alert when the plugin start. 21 | setShowAlert(false) 22 | 23 | const handleKeyDown = (event: KeyboardEvent) => { 24 | if ($currentColorModel.get() === 'oklch') return 25 | if (event.key !== 'Escape') return 26 | 27 | setShowAlert(false) 28 | } 29 | 30 | document.addEventListener('keydown', handleKeyDown) 31 | 32 | return () => { 33 | document.removeEventListener('keydown', handleKeyDown) 34 | } 35 | }, []) 36 | 37 | return ( 38 |
setShowAlert(false)} className={`c-alert${showAlert ? ' c-alert--active' : ''}`}> 39 |
{ 41 | e.stopPropagation() 42 | }} 43 | className="c-alert__box" 44 | > 45 |
46 |
Legacy color model
47 | 48 |
setShowAlert(false)} className="c-alert__close-icon"> 49 | 50 |
51 |
52 | 53 |
54 |

55 | Please use OkLCH, {$currentColorModel.get() === 'okhsl' ? 'OkHSL' : 'OkHSV'} works only in sRGB with no CSS support and will be removed. 56 | Email me at: contact@dokozero.design if you still need it. 57 |

58 |
59 | 60 |
61 | 64 |
65 |
66 |
67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /src/ui/components/ColorPicker/ColorPicker.css: -------------------------------------------------------------------------------- 1 | .c-color-picker { 2 | position: relative; 3 | 4 | padding: 0 16px; 5 | 6 | border-radius: var(--base-border-radius); 7 | } 8 | 9 | .c-color-picker__wrapper { 10 | position: relative; 11 | width: 100%; 12 | height: 100%; 13 | overflow: hidden; 14 | 15 | background-color: var(--figma-color-bg-secondary); 16 | border: 1px solid rgba(0, 0, 0, 0.02); 17 | 18 | border-radius: var(--base-border-radius); 19 | } 20 | 21 | .c-color-picker:focus { 22 | outline: none; 23 | } 24 | .c-color-picker__message-wrapper { 25 | display: none; 26 | z-index: 1; 27 | position: absolute; 28 | width: 100%; 29 | height: 100%; 30 | align-items: center; 31 | border-radius: var(--base-border-radius); 32 | } 33 | .c-color-picker__message-text { 34 | margin: 0; 35 | padding: 16px; 36 | width: 100%; 37 | font-size: 14px; 38 | font-weight: 400; 39 | letter-spacing: 0.01em; 40 | color: var(--figma-color-text); 41 | opacity: 0.7; 42 | text-align: center; 43 | line-height: 1.4; 44 | } 45 | 46 | .c-color-picker__gamut-label { 47 | z-index: 1; 48 | position: absolute; 49 | right: 5px; 50 | font-size: var(--base-font-size); 51 | user-select: none; 52 | transition: top 0.2s ease-out; 53 | } 54 | 55 | .c-color-picker__canvas, 56 | .c-color-picker__manipulator, 57 | .c-color-picker__srgb-limit-stroke, 58 | .c-color-picker__relative-chroma-stroke, 59 | .c-color-picker__contrast-stroke { 60 | position: absolute; 61 | } 62 | .c-color-picker__canvas { 63 | transform-origin: left top; 64 | } 65 | .c-color-picker__manipulator, 66 | .c-color-picker__srgb-limit-stroke, 67 | .c-color-picker__relative-chroma-stroke, 68 | .c-color-picker__contrast-stroke { 69 | pointer-events: none; 70 | } 71 | 72 | /* TODO - rename to 'disabled' */ 73 | .c-color-picker--deactivated .c-color-picker__canvas, 74 | .c-color-picker--deactivated .c-color-picker__manipulator, 75 | .c-color-picker--deactivated .c-color-picker__srgb-limit-stroke, 76 | .c-color-picker--deactivated .c-color-picker__relative-chroma-stroke, 77 | .c-color-picker--deactivated .c-color-picker__gamut-label, 78 | .c-color-picker--deactivated .c-color-picker__contrast-stroke { 79 | display: none; 80 | } 81 | 82 | .c-color-picker--deactivated .c-color-picker__message-wrapper { 83 | display: flex; 84 | } 85 | 86 | .c-color-picker__manipulator { 87 | position: absolute; 88 | top: 0; 89 | width: 18px; 90 | height: 18px; 91 | } 92 | -------------------------------------------------------------------------------- /src/ui/styles/utilities.css: -------------------------------------------------------------------------------- 1 | .u-display-none { 2 | display: none; 3 | } 4 | .u-visibility-hidden { 5 | visibility: hidden; 6 | } 7 | 8 | .u-position-absolute { 9 | position: absolute; 10 | } 11 | 12 | .u-flex { 13 | display: flex; 14 | } 15 | .u-items-center { 16 | align-items: center; 17 | } 18 | .u-flex-col { 19 | flex-direction: column; 20 | } 21 | .u-items-end { 22 | align-items: flex-end; 23 | } 24 | .u-justify-between { 25 | justify-content: space-between; 26 | } 27 | .u-flex-no-shrink { 28 | flex-shrink: 0; 29 | } 30 | .u-flex-basis-23 { 31 | flex-basis: 23px; 32 | } 33 | .u-flex-basis-62 { 34 | flex-basis: 62px; 35 | } 36 | 37 | .u-gap-6 { 38 | gap: 6px; 39 | } 40 | 41 | .u-h-28 { 42 | height: 28px; 43 | } 44 | .u-w-full { 45 | width: 100%; 46 | } 47 | .u-h-full { 48 | height: 100%; 49 | } 50 | 51 | .u-mt-4 { 52 | margin-top: 4px; 53 | } 54 | .u-mt-6 { 55 | margin-top: 6px; 56 | } 57 | .u-mt-7 { 58 | margin-top: 7px; 59 | } 60 | .u-mt-8 { 61 | margin-top: 8px; 62 | } 63 | .u-mt-10 { 64 | margin-top: 10px; 65 | } 66 | .u-mt-11 { 67 | margin-top: 11px; 68 | } 69 | .u-mt-12 { 70 | margin-top: 12px; 71 | } 72 | .u-mt-16 { 73 | margin-top: 16px; 74 | } 75 | .u-mt-18 { 76 | margin-top: 18px; 77 | } 78 | .u-mt-20 { 79 | margin-top: 20px; 80 | } 81 | .u-ml-8 { 82 | margin-left: 8px; 83 | } 84 | .-u-mb-10 { 85 | margin-bottom: -10px; 86 | } 87 | .u-mb-12 { 88 | margin-bottom: 12px; 89 | } 90 | .u-mb-16 { 91 | margin-bottom: 16px; 92 | } 93 | .u-mr-8 { 94 | margin-right: 8px; 95 | } 96 | .u-my-10 { 97 | margin-top: 10px; 98 | margin-bottom: 10px; 99 | } 100 | .u-px-15 { 101 | padding-left: 15px; 102 | padding-right: 15px; 103 | } 104 | .u-px-9 { 105 | padding-left: 9px; 106 | padding-right: 9px; 107 | } 108 | .u-px-15 { 109 | padding-left: 15px; 110 | padding-right: 15px; 111 | } 112 | .u-px-16 { 113 | padding-left: 16px; 114 | padding-right: 16px; 115 | } 116 | .u-ml-auto { 117 | margin-left: auto; 118 | } 119 | 120 | .u-max-w-50 { 121 | max-width: 50px; 122 | } 123 | 124 | .u-opacity-50 { 125 | opacity: 0.5; 126 | } 127 | 128 | .u-order-0 { 129 | order: 0; 130 | } 131 | .u-order-1 { 132 | order: 1; 133 | } 134 | .u-order-2 { 135 | order: 2; 136 | } 137 | .u-order-3 { 138 | order: 3; 139 | } 140 | 141 | .u-pointer-events-none { 142 | pointer-events: none; 143 | } 144 | -------------------------------------------------------------------------------- /src/ui/components/ColorPicker/helpers/getNewManipulatorPosition/getNewManipulatorPosition.ts: -------------------------------------------------------------------------------- 1 | import { OKLCH_CHROMA_SCALE, PICKER_SIZE } from '../../../../../constants' 2 | import getLinearMappedValue from '../../../../helpers/getLinearMappedValue/getLinearMappedValue' 3 | import { $colorHxya } from '../../../../stores/colors/colorHxya/colorHxya' 4 | import { $currentColorModel } from '../../../../stores/colors/currentColorModel/currentColorModel' 5 | import { $relativeChroma } from '../../../../stores/colors/relativeChroma/relativeChroma' 6 | import { $oklchRenderMode } from '../../../../stores/oklchRenderMode/oklchRenderMode' 7 | 8 | type Props = { 9 | position: number 10 | } 11 | 12 | let previousXManipulatorPosition = 0 13 | 14 | export default function getNewManipulatorPosition(props: Props) { 15 | const { position } = props 16 | 17 | const newManipulatorPosition = { 18 | x: 0, 19 | y: 0 20 | } 21 | 22 | if ($currentColorModel.get() === 'oklch') { 23 | let startPosition = 0 24 | let endPosition = 0 25 | 26 | if ($oklchRenderMode.get() === 'triangle') { 27 | startPosition = $colorHxya.get().x * OKLCH_CHROMA_SCALE 28 | endPosition = $relativeChroma.get() / 100 29 | newManipulatorPosition.x = getLinearMappedValue({ 30 | valueToMap: position, 31 | originalRange: { min: 0, max: 100 }, 32 | targetRange: { min: startPosition, max: endPosition } 33 | }) 34 | } else if ($oklchRenderMode.get() === 'square') { 35 | if ($colorHxya.get().y < 0.1 || $colorHxya.get().y > 99.9) { 36 | // Fix to avoid the manipulator going to left corner when Y is at 100 or 0. 37 | newManipulatorPosition.x = previousXManipulatorPosition 38 | } else { 39 | startPosition = $relativeChroma.get() / 100 40 | endPosition = $colorHxya.get().x * OKLCH_CHROMA_SCALE 41 | 42 | newManipulatorPosition.x = getLinearMappedValue({ 43 | valueToMap: position, 44 | originalRange: { min: 100, max: 0 }, 45 | targetRange: { min: startPosition, max: endPosition } 46 | }) 47 | 48 | previousXManipulatorPosition = newManipulatorPosition.x 49 | } 50 | } 51 | } else { 52 | newManipulatorPosition.x = $colorHxya.get().x / 100 53 | } 54 | 55 | newManipulatorPosition.x = PICKER_SIZE * newManipulatorPosition.x - 9 56 | newManipulatorPosition.y = PICKER_SIZE * (1 - $colorHxya.get().y / 100) - 9 57 | 58 | return newManipulatorPosition 59 | } 60 | -------------------------------------------------------------------------------- /src/ui/components/ColorPicker/helpers/getRelativeChromaStrokeLimit/getRelativeChromaStrokeLimit.ts: -------------------------------------------------------------------------------- 1 | import { PICKER_SIZE, OKLCH_CHROMA_SCALE, MAX_CHROMA_P3 } from '../../../../../constants' 2 | import { ColorHxya, CurrentFileColorProfile, OklchRenderMode, RelativeChroma, SvgPath } from '../../../../../types' 3 | import { $colorHxya } from '../../../../stores/colors/colorHxya/colorHxya' 4 | import { $currentFileColorProfile } from '../../../../stores/colors/currentFileColorProfile/currentFileColorProfile' 5 | import { $relativeChroma } from '../../../../stores/colors/relativeChroma/relativeChroma' 6 | import getLinearMappedValue from '../../../../helpers/getLinearMappedValue/getLinearMappedValue' 7 | import { clampChroma } from 'culori' 8 | 9 | type Props = { 10 | colorHxya?: ColorHxya 11 | currentFileColorProfile?: CurrentFileColorProfile 12 | relativeChroma?: RelativeChroma 13 | oklchRenderMode?: OklchRenderMode 14 | position: number 15 | } 16 | 17 | export default function getRelativeChromaStrokeLimit(props: Props): SvgPath { 18 | const { 19 | colorHxya = $colorHxya.get(), 20 | currentFileColorProfile = $currentFileColorProfile.get(), 21 | relativeChroma = $relativeChroma.get(), 22 | position 23 | } = props 24 | 25 | let d = 'M0 0 ' 26 | 27 | const precision = 0.5 28 | 29 | let relativeChromaMapped: number 30 | let maxChromaCurrentLineMapped: number 31 | 32 | let maxChromaCurrentLine: any 33 | 34 | let xPosition: number 35 | 36 | for (let l = 0; l < PICKER_SIZE; l += 1 / precision) { 37 | maxChromaCurrentLine = clampChroma( 38 | { 39 | mode: 'oklch', 40 | l: (PICKER_SIZE - l) / PICKER_SIZE, 41 | c: MAX_CHROMA_P3, 42 | h: colorHxya.h 43 | }, 44 | 'oklch', 45 | currentFileColorProfile 46 | ) 47 | 48 | maxChromaCurrentLineMapped = maxChromaCurrentLine.c * (relativeChroma / 100) * PICKER_SIZE * OKLCH_CHROMA_SCALE 49 | 50 | relativeChromaMapped = getLinearMappedValue({ 51 | valueToMap: relativeChroma, 52 | originalRange: { min: 0, max: 100 }, 53 | targetRange: { min: 0, max: PICKER_SIZE } 54 | }) 55 | 56 | xPosition = getLinearMappedValue({ 57 | valueToMap: position, 58 | originalRange: { 59 | min: 0, 60 | max: 100 61 | }, 62 | targetRange: { 63 | min: maxChromaCurrentLineMapped, 64 | max: relativeChromaMapped 65 | } 66 | }) 67 | 68 | d += `L${(xPosition - 1).toFixed(2)} ${l} ` 69 | } 70 | 71 | return d 72 | } 73 | -------------------------------------------------------------------------------- /src/ui/components/ColorPicker/helpers/getSrgbStrokeLimit/getSrgbStrokeLimit.ts: -------------------------------------------------------------------------------- 1 | import { PICKER_SIZE, OKLCH_CHROMA_SCALE, MAX_CHROMA_P3 } from '../../../../../constants' 2 | import { ColorHxya, OklchRenderMode, SvgPath } from '../../../../../types' 3 | import { $colorHxya } from '../../../../stores/colors/colorHxya/colorHxya' 4 | import { $oklchRenderMode } from '../../../../stores/oklchRenderMode/oklchRenderMode' 5 | import getLinearMappedValue from '../../../../helpers/getLinearMappedValue/getLinearMappedValue' 6 | import { clampChroma } from 'culori' 7 | 8 | type Props = { 9 | colorHxya?: ColorHxya 10 | oklchRenderMode?: OklchRenderMode 11 | position: number 12 | } 13 | 14 | export default function getSrgbStrokeLimit(props: Props): SvgPath { 15 | const { colorHxya = $colorHxya.get(), oklchRenderMode = $oklchRenderMode.get(), position } = props 16 | 17 | let d = 'M0 0 ' 18 | 19 | const precision = 0.5 20 | 21 | let sRGBMaxChroma: any 22 | let p3MaxChroma: any 23 | 24 | let xPosition: number 25 | let yPosition: number 26 | let sRGBMaxChromaMappedToMaxChromaP3: number 27 | 28 | for (let l = 0; l <= PICKER_SIZE; l += 1 / precision) { 29 | yPosition = getLinearMappedValue({ 30 | valueToMap: l, 31 | originalRange: { min: 0, max: PICKER_SIZE }, 32 | targetRange: { min: 100, max: 0.5 } 33 | }) 34 | 35 | // We do this to get a straight line near the bottom and avoid a zig-zag. 36 | if (oklchRenderMode === 'square' && position === 100 && yPosition < 10) { 37 | yPosition = 10 38 | } 39 | 40 | sRGBMaxChroma = clampChroma( 41 | { 42 | mode: 'oklch', 43 | l: yPosition / 100, 44 | c: MAX_CHROMA_P3, 45 | h: colorHxya.h 46 | }, 47 | 'oklch', 48 | 'rgb' 49 | ) 50 | 51 | p3MaxChroma = clampChroma( 52 | { 53 | mode: 'oklch', 54 | l: yPosition / 100, 55 | c: MAX_CHROMA_P3, 56 | h: colorHxya.h 57 | }, 58 | 'oklch', 59 | 'p3' 60 | ) 61 | 62 | sRGBMaxChromaMappedToMaxChromaP3 = getLinearMappedValue({ 63 | valueToMap: sRGBMaxChroma.c, 64 | originalRange: { min: 0, max: p3MaxChroma.c }, 65 | targetRange: { min: 0, max: MAX_CHROMA_P3 } 66 | }) 67 | 68 | xPosition = getLinearMappedValue({ 69 | valueToMap: position, 70 | originalRange: { 71 | min: 0, 72 | max: 100 73 | }, 74 | targetRange: { 75 | min: sRGBMaxChroma.c, 76 | max: sRGBMaxChromaMappedToMaxChromaP3 77 | } 78 | }) 79 | 80 | d += `L${(xPosition * PICKER_SIZE * OKLCH_CHROMA_SCALE).toFixed(2)} ${l} ` 81 | } 82 | 83 | return d 84 | } 85 | -------------------------------------------------------------------------------- /src/ui/components/ColorPicker/helpers/handleKeyDown/handleKeyDown.ts: -------------------------------------------------------------------------------- 1 | import clamp from 'lodash/clamp' 2 | import { $colorHxya, setColorHxyaWithSideEffects } from '../../../../stores/colors/colorHxya/colorHxya' 3 | import { $lockRelativeChroma } from '../../../../stores/colors/lockRelativeChroma/lockRelativeChroma' 4 | import { $relativeChroma, setRelativeChromaWithSideEffects } from '../../../../stores/colors/relativeChroma/relativeChroma' 5 | import { $lockContrast } from '../../../../stores/contrasts/lockContrast/lockContrast' 6 | import { $currentKeysPressed } from '../../../../stores/currentKeysPressed/currentKeysPressed' 7 | import { $oklchRenderMode } from '../../../../stores/oklchRenderMode/oklchRenderMode' 8 | import getColorHxyDecimals from '../../../../helpers/colors/getColorHxyDecimals/getColorHxyDecimals' 9 | import round from 'lodash/round' 10 | 11 | export default function handleKeyDown(eventKey: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight') { 12 | if ($lockRelativeChroma.get() && (eventKey === 'ArrowLeft' || eventKey === 'ArrowRight')) return 13 | 14 | if ($lockContrast.get() && (eventKey === 'ArrowUp' || eventKey === 'ArrowDown')) return 15 | 16 | let newValue = 0 17 | let stepUpdateValue = 0 18 | 19 | const axis = eventKey === 'ArrowUp' || eventKey === 'ArrowDown' ? 'y' : 'x' 20 | 21 | if (axis === 'x') { 22 | newValue = $relativeChroma.get() 23 | } else { 24 | newValue = $colorHxya.get().y 25 | } 26 | 27 | if ($currentKeysPressed.get().includes('shift')) { 28 | newValue = Math.round(newValue / 5) * 5 29 | } 30 | 31 | stepUpdateValue = $currentKeysPressed.get().includes('shift') ? 5 : 1 32 | 33 | if (eventKey === 'ArrowUp' || eventKey === 'ArrowRight') { 34 | newValue += stepUpdateValue 35 | } else if (eventKey === 'ArrowDown' || eventKey === 'ArrowLeft') { 36 | newValue -= stepUpdateValue 37 | } 38 | 39 | if (axis === 'y') { 40 | // Fix floating-point inaccuracies, for example 16.08 - 1 gives 15.079999999999998. 41 | newValue = round(newValue, getColorHxyDecimals().y) 42 | } 43 | 44 | // To avoid getting out of the color picker. 45 | newValue = clamp(newValue, 0, 100) 46 | 47 | if (axis === 'x') { 48 | setRelativeChromaWithSideEffects({ 49 | newRelativeChroma: newValue 50 | }) 51 | } else { 52 | let localLockRelativeChroma = $lockRelativeChroma.get() 53 | 54 | if ($oklchRenderMode.get() === 'square') { 55 | localLockRelativeChroma = true 56 | } 57 | 58 | setColorHxyaWithSideEffects({ 59 | newColorHxya: axis === 'y' ? { y: newValue } : { x: newValue }, 60 | lockRelativeChroma: localLockRelativeChroma 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/ui/stores/contrasts/lockContrast/lockContrast.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../../constants' 4 | import { SyncLockContrastData } from '../../../../types' 5 | import getNewXandYFromContrast from '../../../helpers/contrasts/getNewXandYFromContrast/getNewXandYFromContrast' 6 | import sendMessageToBackend from '../../../helpers/sendMessageToBackend/sendMessageToBackend' 7 | import { $colorHxya, setColorHxyaWithSideEffects } from '../../colors/colorHxya/colorHxya' 8 | import { $lockRelativeChroma } from '../../colors/lockRelativeChroma/lockRelativeChroma' 9 | import { $contrast } from '../contrast/contrast' 10 | import merge from 'lodash/merge' 11 | 12 | export const $lockContrast = atom(false) 13 | 14 | export const setLockContrast = action($lockContrast, 'setLockContrast', (lockContrast, newLockContrast: boolean) => { 15 | lockContrast.set(newLockContrast) 16 | }) 17 | 18 | type SideEffects = { 19 | syncLockContrastWithBackend: boolean 20 | } 21 | 22 | type Props = { 23 | newLockContrast: boolean 24 | sideEffects?: Partial 25 | } 26 | 27 | const defaultSideEffects: SideEffects = { 28 | syncLockContrastWithBackend: true 29 | } 30 | 31 | export const setLockContrastWithSideEffects = action($lockContrast, 'setLockContrastWithSideEffects', (lockContrast, props: Props) => { 32 | const { newLockContrast, sideEffects: partialSideEffects } = props 33 | 34 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 35 | merge(sideEffects, partialSideEffects) 36 | 37 | lockContrast.set(newLockContrast) 38 | 39 | if (sideEffects.syncLockContrastWithBackend) { 40 | sendMessageToBackend({ 41 | type: 'syncLockContrast', 42 | data: { 43 | newLockContrast: newLockContrast 44 | } 45 | }) 46 | } 47 | 48 | // if lockConstrat is true, we need to adjust x and y value as for example we can have multiple Y values for the same contrast, without this, when setting lockContrast to true, we can have the manipulator on the color picker slightly off the lock line. 49 | if (newLockContrast) { 50 | const newXy = getNewXandYFromContrast({ 51 | h: $colorHxya.get().h, 52 | x: $colorHxya.get().x, 53 | targetContrast: $contrast.get(), 54 | lockRelativeChroma: $lockRelativeChroma.get() 55 | }) 56 | 57 | setColorHxyaWithSideEffects({ 58 | newColorHxya: newXy, 59 | sideEffects: { 60 | colorsRgba: { 61 | syncContrast: false 62 | } 63 | } 64 | }) 65 | } 66 | }) 67 | 68 | if (consoleLogInfos.includes('Store updates')) { 69 | logger({ 70 | lockContrast: $lockContrast 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "okcolor", 3 | "description": "A Figma plugin to get OK Colors", 4 | "author": "Doko Zero", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "tests": "vitest run", 9 | "eslint-types": "pnpm exec eslint \"./src/types.ts\"", 10 | "eslint-constants": "pnpm exec eslint \"./src/constants.ts\"", 11 | "eslint-backend-helpers": "pnpm exec eslint \"./src/backend/helpers/**/*\"", 12 | "eslint-backend-main": "pnpm exec eslint \"./src/backend/main.ts\"", 13 | "eslint-ui-components": "pnpm exec eslint \"./src/ui/components/**/*\"", 14 | "eslint-ui-helpers": "pnpm exec eslint \"./src/ui/helpers/**/*\"", 15 | "eslint-ui-stores": "pnpm exec eslint \"./src/ui/stores/**/*\"", 16 | "eslint-ui-app": "pnpm exec eslint \"./src/ui/app.tsx\"", 17 | "eslint-checks": "pnpm eslint-types & pnpm eslint-constants & pnpm eslint-backend-helpers & pnpm eslint-backend-main & pnpm eslint-ui-components & pnpm eslint-ui-helpers & pnpm eslint-ui-stores & pnpm eslint-ui-app", 18 | "type-backend-checks": "tsc --noEmit --project ./src/backend/tsconfig.json", 19 | "type-ui-checks": "tsc --noEmit --project ./src/ui/tsconfig.json", 20 | "all-checks": "pnpm eslint-checks && pnpm type-backend-checks && pnpm type-ui-checks", 21 | "build-backend": "vite build --config vite-backend.config.ts", 22 | "build-ui": "vite build --config vite-ui.config.ts", 23 | "watch-backend": "vite build --watch --config vite-backend.config.ts", 24 | "watch-ui": "vite build --watch --config vite-ui.config.ts", 25 | "build": "pnpm build-backend & pnpm build-ui" 26 | }, 27 | "dependencies": { 28 | "@nanostores/logger": "^1.0.0", 29 | "@nanostores/react": "^1.0.0", 30 | "@types/apca-w3": "^0.1.3", 31 | "@types/lodash": "^4.17.16", 32 | "apca-w3": "^0.1.9", 33 | "culori": "^4.0.1", 34 | "lodash": "^4.17.21", 35 | "nanostores": "^0.9.5", 36 | "react": "^19.1.0", 37 | "react-dom": "^19.1.0", 38 | "twgl.js": "^6.1.1", 39 | "vite-plugin-virtual-plain-text": "^1.4.5" 40 | }, 41 | "devDependencies": { 42 | "@eslint/eslintrc": "^3.3.1", 43 | "@eslint/js": "^9.25.1", 44 | "@figma/plugin-typings": "^1.110.0", 45 | "@types/culori": "^2.1.1", 46 | "@types/node": "^22.15.3", 47 | "@types/react": "^19.1.2", 48 | "@types/react-dom": "^19.1.2", 49 | "@typescript-eslint/eslint-plugin": "^8.31.1", 50 | "@typescript-eslint/parser": "^8.31.1", 51 | "@vitejs/plugin-react": "^4.4.1", 52 | "eslint": "^9.25.1", 53 | "eslint-plugin-react": "^7.37.5", 54 | "globals": "^16.0.0", 55 | "prettier": "^3.5.3", 56 | "typescript": "^5.8.3", 57 | "vite": "^6.3.3", 58 | "vite-plugin-singlefile": "^2.2.0", 59 | "vitest": "^3.1.2" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/ui/components/top-bar/OklchRenderModeToggle/OklchRenderModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useStore } from '@nanostores/react' 2 | import { consoleLogInfos } from '../../../../constants' 3 | import { $currentColorModel } from '../../../stores/colors/currentColorModel/currentColorModel' 4 | import { $isTransitionRunning, $oklchRenderMode, setOklchRenderModeWithSideEffects } from '../../../stores/oklchRenderMode/oklchRenderMode' 5 | import { useEffect } from 'react' 6 | import TriangleOklchIcon from '../../icons/TriangleOklchIcon/TriangleOklchIcon' 7 | import SquareOklchIcon from '../../icons/SquareOklchIcon/SquareOklchIcon' 8 | import { $uiMessage } from '../../../stores/uiMessage/uiMessage' 9 | 10 | const handleOklchRenderMode = () => { 11 | if ($currentColorModel.get() !== 'oklch') return 12 | if ($isTransitionRunning.get()) return 13 | 14 | if ($oklchRenderMode.get() === 'square') { 15 | setOklchRenderModeWithSideEffects({ 16 | newOklchRenderMode: 'triangle' 17 | }) 18 | } else { 19 | setOklchRenderModeWithSideEffects({ 20 | newOklchRenderMode: 'square' 21 | }) 22 | } 23 | } 24 | 25 | export default function OklchRenderModeToggle() { 26 | if (consoleLogInfos.includes('Component renders')) { 27 | console.log('Component render — OklchRenderModeToggle') 28 | } 29 | 30 | const currentColorModel = useStore($currentColorModel) 31 | const oklchRenderMode = useStore($oklchRenderMode) 32 | 33 | useEffect(() => { 34 | document.addEventListener('keydown', (event) => { 35 | if ($currentColorModel.get() !== 'oklch') return 36 | if (!['t', 'T', 's', 'S'].includes(event.key)) return 37 | 38 | // We test if document.activeElement?.tagName is an input because we don't want to trigger this code if user type "c" while he's in one of them. 39 | if ($uiMessage.get().show || document.activeElement?.tagName === 'INPUT') return 40 | 41 | handleOklchRenderMode() 42 | }) 43 | }, []) 44 | 45 | return ( 46 |
50 |
54 | 55 |
56 |
60 | 61 |
62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/ui/stores/currentFillOrStroke/currentFillOrStroke.ts: -------------------------------------------------------------------------------- 1 | import { action, atom } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../constants' 4 | import { CurrentFillOrStroke, SyncCurrentFillOrStrokeData } from '../../../types' 5 | import convertRgbToHxy from '../../helpers/colors/convertRgbToHxy/convertRgbToHxy' 6 | import sendMessageToBackend from '../../helpers/sendMessageToBackend/sendMessageToBackend' 7 | import { setColorHxyaWithSideEffects } from '../colors/colorHxya/colorHxya' 8 | import { $colorsRgba } from '../colors/colorsRgba/colorsRgba' 9 | import { $lockContrast, setLockContrast } from '../contrasts/lockContrast/lockContrast' 10 | import merge from 'lodash/merge' 11 | 12 | export const $currentFillOrStroke = atom('fill') 13 | 14 | export const setCurrentFillOrStroke = action( 15 | $currentFillOrStroke, 16 | 'setCurrentFillOrStroke', 17 | (currentFillOrStroke, newCurrentFillOrStroke: CurrentFillOrStroke) => { 18 | currentFillOrStroke.set(newCurrentFillOrStroke) 19 | } 20 | ) 21 | 22 | type SideEffects = { 23 | syncColorHxya: boolean 24 | syncCurrentFillOrStrokeWithBackend: boolean 25 | } 26 | 27 | type Props = { 28 | newCurrentFillOrStroke: CurrentFillOrStroke 29 | sideEffects?: Partial 30 | } 31 | 32 | const defaultSideEffects: SideEffects = { 33 | syncColorHxya: true, 34 | syncCurrentFillOrStrokeWithBackend: true 35 | } 36 | 37 | export const setCurrentFillOrStrokeWithSideEffects = action( 38 | $currentFillOrStroke, 39 | 'setCurrentFillOrStrokeWithSideEffects', 40 | (currentFillOrStroke, props: Props) => { 41 | const { newCurrentFillOrStroke, sideEffects: partialSideEffects } = props 42 | 43 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 44 | merge(sideEffects, partialSideEffects) 45 | 46 | currentFillOrStroke.set(newCurrentFillOrStroke) 47 | 48 | if (sideEffects.syncCurrentFillOrStrokeWithBackend) { 49 | sendMessageToBackend({ 50 | type: 'syncCurrentFillOrStroke', 51 | data: { 52 | newCurrentFillOrStroke: newCurrentFillOrStroke 53 | } 54 | }) 55 | } 56 | 57 | if (newCurrentFillOrStroke === 'stroke' && $lockContrast.get()) setLockContrast(false) 58 | 59 | if (sideEffects.syncColorHxya) { 60 | const newColorRgba = $colorsRgba.get()[newCurrentFillOrStroke] 61 | const newColorHxy = convertRgbToHxy({ colorRgb: newColorRgba! }) 62 | 63 | setColorHxyaWithSideEffects({ 64 | newColorHxya: { ...newColorHxy, a: newColorRgba!.a }, 65 | lockRelativeChroma: false, 66 | lockContrast: false 67 | }) 68 | } 69 | } 70 | ) 71 | 72 | if (consoleLogInfos.includes('Store updates')) { 73 | logger({ 74 | currentFillOrStroke: $currentFillOrStroke 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/filterNewColorHxya/filterNewColorHxya.ts: -------------------------------------------------------------------------------- 1 | import { ApcaContrast, ColorHxya, WcagContrast } from '../../../../types' 2 | import { $colorHxya } from '../../../stores/colors/colorHxya/colorHxya' 3 | import { $currentColorModel } from '../../../stores/colors/currentColorModel/currentColorModel' 4 | import { $lockRelativeChroma } from '../../../stores/colors/lockRelativeChroma/lockRelativeChroma' 5 | import { $contrast } from '../../../stores/contrasts/contrast/contrast' 6 | import { $lockContrast } from '../../../stores/contrasts/lockContrast/lockContrast' 7 | import getNewXandYFromContrast from '../../contrasts/getNewXandYFromContrast/getNewXandYFromContrast' 8 | import convertRelativeChromaToAbsolute from '../convertRelativeChromaToAbsolute/convertRelativeChromaToAbsolute' 9 | import getClampedChroma from '../getClampedChroma/getClampedChroma' 10 | import round from 'lodash/round' 11 | 12 | type Props = { 13 | newColorHxya: Partial 14 | lockRelativeChroma?: boolean 15 | lockContrast?: boolean 16 | contrast?: ApcaContrast | WcagContrast 17 | } 18 | 19 | /** 20 | * This function is to filter the hxy values because when we are in oklch, we can receive values that are out of gamut or we might need to constrain them relative chroma or contrast are locked. 21 | */ 22 | export default function filterNewColorHxya(props: Props): ColorHxya { 23 | const { newColorHxya, lockRelativeChroma = $lockRelativeChroma.get(), lockContrast = $lockContrast.get(), contrast = $contrast.get() } = props 24 | 25 | const filteredColorHxya: ColorHxya = { 26 | h: newColorHxya.h !== undefined ? newColorHxya.h : $colorHxya.get().h, 27 | x: newColorHxya.x !== undefined ? newColorHxya.x : $colorHxya.get().x, 28 | y: newColorHxya.y !== undefined ? newColorHxya.y : $colorHxya.get().y, 29 | a: newColorHxya.a !== undefined ? newColorHxya.a : $colorHxya.get().a 30 | } 31 | 32 | filteredColorHxya.a = round(filteredColorHxya.a, 2) 33 | 34 | if (filteredColorHxya.y === 0.01) filteredColorHxya.y = 0 35 | else if (filteredColorHxya.y === 99.99) filteredColorHxya.y = 100 36 | 37 | // In these two color models, we don't have relative chroma or contrast enabled. 38 | if (['okhsv', 'okhsl'].includes($currentColorModel.get())) return filteredColorHxya 39 | 40 | if (lockRelativeChroma) { 41 | filteredColorHxya.x = convertRelativeChromaToAbsolute({ 42 | h: filteredColorHxya.h, 43 | y: filteredColorHxya.y 44 | }) 45 | } else { 46 | // If lockRelativeChroma is true, we don't need to clamp the chroma because it always be inside, hence the below code in the else. 47 | filteredColorHxya.x = getClampedChroma(filteredColorHxya) 48 | } 49 | 50 | if (lockContrast) { 51 | const newXy = getNewXandYFromContrast({ 52 | h: filteredColorHxya.h, 53 | x: filteredColorHxya.x, 54 | targetContrast: contrast 55 | }) 56 | filteredColorHxya.y = newXy.y 57 | } 58 | 59 | return filteredColorHxya 60 | } 61 | -------------------------------------------------------------------------------- /src/ui/components/ColorPicker/helpers/getContrastStrokeLimit/getContrastStrokeLimit.ts: -------------------------------------------------------------------------------- 1 | import { PICKER_SIZE, OKLCH_CHROMA_SCALE, MAX_CHROMA_P3 } from '../../../../../constants' 2 | import { ApcaContrast, ColorHxya, SvgPath, WcagContrast } from '../../../../../types' 3 | import getClampedChroma from '../../../../helpers/colors/getClampedChroma/getClampedChroma' 4 | import getNewXandYFromContrast from '../../../../helpers/contrasts/getNewXandYFromContrast/getNewXandYFromContrast' 5 | import getLinearMappedValue from '../../../../helpers/getLinearMappedValue/getLinearMappedValue' 6 | import { $colorHxya } from '../../../../stores/colors/colorHxya/colorHxya' 7 | import { $contrast } from '../../../../stores/contrasts/contrast/contrast' 8 | 9 | type Props = { 10 | colorHxya?: ColorHxya 11 | contrast?: ApcaContrast | WcagContrast 12 | position: number 13 | } 14 | 15 | export default function getContrastStrokeLimit(props: Props): SvgPath { 16 | const { colorHxya = $colorHxya.get(), contrast = $contrast.get(), position } = props 17 | 18 | let clampedChroma = getClampedChroma({ 19 | h: colorHxya.h, 20 | x: MAX_CHROMA_P3, 21 | y: colorHxya.y 22 | }) 23 | 24 | let path = '' 25 | 26 | const startXy = getNewXandYFromContrast({ 27 | h: colorHxya.h, 28 | x: 0, 29 | targetContrast: contrast, 30 | lockRelativeChroma: false 31 | }) 32 | 33 | const endXy = getNewXandYFromContrast({ 34 | h: colorHxya.h, 35 | x: clampedChroma, 36 | targetContrast: contrast, 37 | lockRelativeChroma: false 38 | }) 39 | 40 | clampedChroma = getClampedChroma({ 41 | h: colorHxya.h, 42 | x: MAX_CHROMA_P3, 43 | y: endXy.y 44 | }) 45 | 46 | const distanceFromEndXToMaxP3 = MAX_CHROMA_P3 - clampedChroma 47 | 48 | const endXShiftValue = getLinearMappedValue({ 49 | valueToMap: position, 50 | originalRange: { min: 0, max: 100 }, 51 | targetRange: { min: 0, max: distanceFromEndXToMaxP3 } 52 | }) 53 | 54 | endXy.x = clampedChroma + endXShiftValue 55 | 56 | path = `M0 ${PICKER_SIZE - (startXy.y * PICKER_SIZE) / 100} ` 57 | 58 | let i = 0 59 | let loopCountLimit = 0 60 | let previousY = startXy.y 61 | 62 | let newX: number 63 | let newY: number 64 | 65 | while (i < endXy.x && loopCountLimit < 100) { 66 | if (endXy.x - i > 0.01) { 67 | i += 0.01 68 | } else { 69 | i += 0.001 70 | } 71 | 72 | loopCountLimit++ 73 | 74 | newX = getLinearMappedValue({ 75 | valueToMap: i, 76 | originalRange: { min: 0, max: endXy.x }, 77 | targetRange: { min: 0, max: clampedChroma } 78 | }) 79 | 80 | newY = getNewXandYFromContrast({ 81 | h: colorHxya.h, 82 | x: newX, 83 | targetContrast: contrast, 84 | lockRelativeChroma: false 85 | }).y 86 | 87 | if (newY !== previousY) { 88 | path += `L${i * PICKER_SIZE * OKLCH_CHROMA_SCALE} ${PICKER_SIZE - (newY * PICKER_SIZE) / 100 - 0.5} ` 89 | previousY = newY 90 | } 91 | } 92 | 93 | path += `L${endXy.x * PICKER_SIZE * OKLCH_CHROMA_SCALE} ${PICKER_SIZE - (endXy.y * PICKER_SIZE) / 100}` 94 | 95 | return path 96 | } 97 | -------------------------------------------------------------------------------- /src/ui/stores/settings/userSettings/userSettings.ts: -------------------------------------------------------------------------------- 1 | import { action, map } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../../constants' 4 | import { OklchHlDecimalPrecisionRange, OklchInputOrderList, SyncUserSettingsData, UserSettings } from '../../../../types' 5 | import merge from 'lodash/merge' 6 | import sendMessageToBackend from '../../../helpers/sendMessageToBackend/sendMessageToBackend' 7 | import { $colorHxya, setColorHxyaWithSideEffects } from '../../colors/colorHxya/colorHxya' 8 | import { $currentColorModel } from '../../colors/currentColorModel/currentColorModel' 9 | import getColorHxyDecimals from '../../../helpers/colors/getColorHxyDecimals/getColorHxyDecimals' 10 | import round from 'lodash/round' 11 | import { $uiMessage } from '../../uiMessage/uiMessage' 12 | 13 | export const $userSettings = map({ 14 | oklchHlDecimalPrecision: 1, 15 | useSimplifiedChroma: false, 16 | oklchInputOrder: 'lch', 17 | useHardwareAcceleration: true 18 | }) 19 | 20 | export const setUserSettings = action($userSettings, 'setUserSettings', (userSettings, newUserSettings: UserSettings) => { 21 | userSettings.set(newUserSettings) 22 | }) 23 | 24 | export const setUserSettingsKey = action( 25 | $userSettings, 26 | 'setUserSettingsKey', 27 | (userSettings, key: keyof UserSettings, newValue: OklchHlDecimalPrecisionRange | boolean | keyof typeof OklchInputOrderList) => { 28 | userSettings.setKey(key, newValue) 29 | } 30 | ) 31 | 32 | type SideEffects = { 33 | syncUserSettingsWithBackend: boolean 34 | } 35 | 36 | type Props = { 37 | key: keyof UserSettings 38 | newValue: OklchHlDecimalPrecisionRange | boolean | keyof typeof OklchInputOrderList 39 | sideEffects?: Partial 40 | } 41 | 42 | const defaultSideEffects: SideEffects = { 43 | syncUserSettingsWithBackend: true 44 | } 45 | 46 | export const setUserSettingsKeyWithSideEffects = action($userSettings, 'setUserSettingsWithSideEffects', (userSettings, props: Props) => { 47 | const { key, newValue, sideEffects: partialSideEffects } = props 48 | 49 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 50 | merge(sideEffects, partialSideEffects) 51 | 52 | userSettings.setKey(key, newValue) 53 | 54 | if (sideEffects.syncUserSettingsWithBackend) { 55 | sendMessageToBackend({ 56 | type: 'SyncUserSettings', 57 | data: { 58 | newUserSettings: { ...$userSettings.get(), [key]: newValue } 59 | } 60 | }) 61 | } 62 | 63 | // We need to update colorHxya if decimal precision changes. 64 | if (key === 'oklchHlDecimalPrecision') { 65 | if ($currentColorModel.get() !== 'oklch' || $uiMessage.get().show) return 66 | setColorHxyaWithSideEffects({ 67 | newColorHxya: { 68 | h: round($colorHxya.get().h, getColorHxyDecimals().h), 69 | x: round($colorHxya.get().x, getColorHxyDecimals().x), 70 | y: round($colorHxya.get().y, getColorHxyDecimals().y) 71 | } 72 | }) 73 | } 74 | }) 75 | 76 | if (consoleLogInfos.includes('Store updates')) { 77 | logger({ 78 | userSettings: $userSettings 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /src/ui/components/ColorCodeInputs/helpers/getColorCodeStrings/getColorCodeStrings.ts: -------------------------------------------------------------------------------- 1 | import { clampChroma, formatHex, formatHex8 } from 'culori' 2 | import { ColorCodesInputValues, ColorHxya, ColorRgb, CurrentColorModel } from '../../../../../types' 3 | import convertHxyToRgb from '../../../../helpers/colors/convertHxyToRgb/convertHxyToRgb' 4 | import { $colorHxya } from '../../../../stores/colors/colorHxya/colorHxya' 5 | import { $currentColorModel } from '../../../../stores/colors/currentColorModel/currentColorModel' 6 | import round from 'lodash/round' 7 | 8 | type NewColorStrings = { 9 | [key in ColorCodesInputValues]: string 10 | } 11 | 12 | type Props = { 13 | colorHxya?: ColorHxya 14 | currentColorModel?: CurrentColorModel 15 | } 16 | 17 | export default function getColorCodeStrings(props: Props = {}): NewColorStrings { 18 | const { colorHxya = $colorHxya.get(), currentColorModel = $currentColorModel.get() } = props 19 | 20 | const newColorStrings: NewColorStrings = { 21 | currentColorModel: '', 22 | color: '', 23 | rgba: '', 24 | hex: '' 25 | } 26 | 27 | let clamped 28 | let rgbSrgb: ColorRgb = { 29 | r: 0, 30 | g: 0, 31 | b: 0 32 | } 33 | let rgbP3: ColorRgb = { 34 | r: 0, 35 | g: 0, 36 | b: 0 37 | } 38 | 39 | // We don't clamp chroma with the models that don't use it because they already work in sRGB. 40 | if (currentColorModel === 'oklch') { 41 | clamped = clampChroma({ mode: 'oklch', l: colorHxya.y / 100, c: colorHxya.x, h: colorHxya.h }, 'oklch', 'rgb') 42 | rgbSrgb = convertHxyToRgb({ 43 | colorHxy: { 44 | h: clamped.h, 45 | x: clamped.c, 46 | y: clamped.l * 100 47 | }, 48 | gamut: 'rgb' 49 | }) 50 | rgbP3 = convertHxyToRgb({ 51 | colorHxy: colorHxya, 52 | gamut: 'p3' 53 | }) 54 | } else { 55 | rgbSrgb = convertHxyToRgb({ 56 | colorHxy: colorHxya, 57 | gamut: 'rgb' 58 | }) 59 | } 60 | 61 | if (currentColorModel === 'oklch') { 62 | newColorStrings.currentColorModel = 63 | `oklch(${colorHxya.y}% ${round(colorHxya.x, 6)} ${colorHxya.h}` + (colorHxya.a !== 1 ? ` / ${colorHxya.a})` : ')') 64 | } else if (currentColorModel === 'okhsl') { 65 | newColorStrings.currentColorModel = `{mode: "okhsl", h: ${colorHxya.h}, s: ${colorHxya.x / 100}, l: ${colorHxya.y / 100}}` 66 | } else if (currentColorModel === 'okhsv') { 67 | newColorStrings.currentColorModel = `{mode: "okhsv", h: ${colorHxya.h}, s: ${colorHxya.x / 100}, v: ${colorHxya.y / 100}}` 68 | } 69 | 70 | if (currentColorModel === 'oklch') { 71 | newColorStrings.color = 72 | `color(display-p3 ${round(rgbP3.r, 4)} ${round(rgbP3.g, 4)} ${round(rgbP3.b, 4)}` + (colorHxya.a !== 1 ? ` / ${colorHxya.a})` : ')') 73 | } else { 74 | newColorStrings.color = 75 | `color(srgb ${round(rgbSrgb.r, 4)} ${round(rgbSrgb.g, 4)} ${round(rgbSrgb.b, 4)}` + (colorHxya.a !== 1 ? ` / ${colorHxya.a})` : ')') 76 | } 77 | 78 | newColorStrings.rgba = `rgba(${round(rgbSrgb.r * 255, 0)}, ${round(rgbSrgb.g * 255, 0)}, ${round(rgbSrgb.b * 255, 0)}, ${colorHxya.a})` 79 | 80 | if (colorHxya.a !== 1) { 81 | newColorStrings.hex = formatHex8( 82 | `rgba(${round(rgbSrgb.r * 255, 0)}, ${round(rgbSrgb.g * 255, 0)}, ${round(rgbSrgb.b * 255, 0)}, ${colorHxya.a})` 83 | )!.toUpperCase() 84 | } else { 85 | newColorStrings.hex = formatHex(`rgb(${round(rgbSrgb.r * 255, 0)}, ${round(rgbSrgb.g * 255, 0)}, ${round(rgbSrgb.b * 255, 0)})`)!.toUpperCase() 86 | } 87 | 88 | return newColorStrings 89 | } 90 | -------------------------------------------------------------------------------- /src/ui/helpers/colors/convertRgbToHxy/convertRgbToHxy.ts: -------------------------------------------------------------------------------- 1 | import { ColorRgb, ColorModelList, CurrentFileColorProfile, ColorHxy } from '../../../../types' 2 | import { $currentColorModel } from '../../../stores/colors/currentColorModel/currentColorModel' 3 | import { $currentFileColorProfile } from '../../../stores/colors/currentFileColorProfile/currentFileColorProfile' 4 | import { converter } from 'culori' 5 | import type { Rgb, Okhsl, Okhsv, Oklch } from 'culori' 6 | import getColorHxyDecimals from '../getColorHxyDecimals/getColorHxyDecimals' 7 | import round from 'lodash/round' 8 | 9 | const convertToOklch = converter('oklch') 10 | const convertToOkhsl = converter('okhsl') 11 | const convertToOkhsv = converter('okhsv') 12 | 13 | type Props = { 14 | colorRgb: ColorRgb 15 | targetColorModel?: keyof typeof ColorModelList 16 | gamut?: CurrentFileColorProfile 17 | } 18 | 19 | export default function convertRgbToHxy(props: Props): ColorHxy { 20 | const { colorRgb, targetColorModel = $currentColorModel.get(), gamut = $currentFileColorProfile.get() } = props 21 | 22 | let culoriResult: Rgb | Okhsl | Okhsv | Oklch 23 | let newColorHxy: ColorHxy 24 | 25 | // No need to go all through color conversion if we have a white of black color, we can manually find the corresponding values. 26 | // Also this is useful because if the color is white and we don't do this, we'll get a value with a hue of 90 and a saturation of 56 in OkHSL. 27 | if (colorRgb.r > 0.99 && colorRgb.g > 0.99 && colorRgb.b > 0.99) { 28 | return { 29 | h: 0, 30 | x: 0, 31 | y: 100 32 | } 33 | } else if (colorRgb.r === 0 && colorRgb.g === 0 && colorRgb.b === 0) { 34 | return { 35 | h: 0, 36 | x: 0, 37 | y: 0 38 | } 39 | } 40 | 41 | // color() function in CSS use different names for the color profile than the one used in this plugin. 42 | let colorFunctionGamut: string 43 | 44 | if (gamut === 'p3') { 45 | colorFunctionGamut = 'display-p3' 46 | } else { 47 | colorFunctionGamut = 'srgb' 48 | } 49 | 50 | switch (targetColorModel) { 51 | case 'oklch': 52 | culoriResult = convertToOklch(`color(${colorFunctionGamut} ${colorRgb.r} ${colorRgb.g} ${colorRgb.b})`) 53 | break 54 | case 'okhsl': 55 | culoriResult = convertToOkhsl(`color(srgb ${colorRgb.r} ${colorRgb.g} ${colorRgb.b})`) 56 | break 57 | case 'okhsv': 58 | culoriResult = convertToOkhsv(`color(srgb ${colorRgb.r} ${colorRgb.g} ${colorRgb.b})`) 59 | break 60 | } 61 | 62 | switch (targetColorModel) { 63 | case 'oklch': 64 | newColorHxy = { 65 | h: round(culoriResult.h, getColorHxyDecimals().h), 66 | x: round(culoriResult.c, getColorHxyDecimals().x), 67 | y: round(culoriResult.l * 100, getColorHxyDecimals().y) 68 | } 69 | break 70 | case 'okhsl': 71 | newColorHxy = { 72 | h: Math.round(culoriResult.h), 73 | x: Math.round(culoriResult.s * 100), 74 | y: Math.round(culoriResult.l * 100) 75 | } 76 | break 77 | case 'okhsv': 78 | newColorHxy = { 79 | h: Math.round(culoriResult.h), 80 | x: Math.round(culoriResult.s * 100), 81 | y: Math.round(culoriResult.v * 100) 82 | } 83 | break 84 | } 85 | 86 | // We need to do this because if for example we get a color like #888888, we will get Nan for newColorHxy.h, also, with others gray values we'll sometimes get a hue of 90 or 0. 87 | // The reason we use round() is because without it we can have for example param1 = 123.99995 and param2 = 123.99994. 88 | if ( 89 | round(colorRgb.r, 3) === round(colorRgb.g, 3) && 90 | round(colorRgb.r, 3) === round(colorRgb.b, 3) && 91 | round(colorRgb.g, 3) === round(colorRgb.b, 3) 92 | ) { 93 | newColorHxy.h = 0 94 | } 95 | 96 | return newColorHxy 97 | } 98 | -------------------------------------------------------------------------------- /src/ui/components/single-input-with-lock/RelativeChromaInput/RelativeChromaInput.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react' 2 | import { consoleLogInfos } from '../../../../constants' 3 | import { useStore } from '@nanostores/react' 4 | import selectInputContent from '../../../helpers/selectInputContent/selectInputContent' 5 | import { $currentColorModel } from '../../../stores/colors/currentColorModel/currentColorModel' 6 | import { setLockRelativeChromaWithSideEffects, $lockRelativeChroma } from '../../../stores/colors/lockRelativeChroma/lockRelativeChroma' 7 | import { $relativeChroma } from '../../../stores/colors/relativeChroma/relativeChroma' 8 | import { $uiMessage } from '../../../stores/uiMessage/uiMessage' 9 | import handleInputOnBlur from './helpers/handleInputOnBlur/handleInputOnBlur' 10 | import handleInputOnKeyDown from './helpers/handleInputOnKeyDown/handleInputOnKeyDown' 11 | import ClosedLockIcon from '../../icons/ClosedLockIcon/ClosedLockIcon' 12 | import OpenLockIcon from '../../icons/OpenLockIcon/OpenLockIcon' 13 | 14 | const handleLockRelativeChroma = () => { 15 | setLockRelativeChromaWithSideEffects({ newLockRelativeChroma: !$lockRelativeChroma.get() }) 16 | } 17 | 18 | export default function RelativeChromaInput() { 19 | if (consoleLogInfos.includes('Component renders')) { 20 | console.log('Component render — RelativeChromaInput') 21 | } 22 | 23 | const relativeChroma = useStore($relativeChroma) 24 | const currentColorModel = useStore($currentColorModel) 25 | const lockRelativeChroma = useStore($lockRelativeChroma) 26 | 27 | const [showRelativeChroma, setShowRelativeChroma] = useState(undefined) 28 | 29 | const input = useRef(null) 30 | 31 | const lastKeyPressed = useRef('') 32 | const keepInputSelected = useRef(false) 33 | 34 | useEffect(() => { 35 | setShowRelativeChroma(currentColorModel === 'oklch') 36 | }, [currentColorModel]) 37 | 38 | useEffect(() => { 39 | if ($currentColorModel.get() !== 'oklch') return 40 | 41 | input.current!.value = relativeChroma.toString() 42 | 43 | if (keepInputSelected.current) { 44 | input.current!.select() 45 | keepInputSelected.current = false 46 | } 47 | }, [relativeChroma]) 48 | 49 | useEffect(() => { 50 | document.addEventListener('keydown', (event) => { 51 | if (!['c', 'C'].includes(event.key)) return 52 | 53 | if ($currentColorModel.get() !== 'oklch') return 54 | 55 | // We test if document.activeElement?.tagName is an input because we don't want to trigger this code if user type "c" while he's in one of them. 56 | if ($uiMessage.get().show || document.activeElement?.tagName === 'INPUT') return 57 | 58 | handleLockRelativeChroma() 59 | }) 60 | }, []) 61 | 62 | return ( 63 |
69 |
Relative chroma
70 |
71 | { 75 | handleInputOnBlur(e, lastKeyPressed) 76 | }} 77 | onKeyDown={(e) => { 78 | handleInputOnKeyDown(e, lastKeyPressed, keepInputSelected) 79 | }} 80 | /> 81 |
%
82 |
83 | 84 |
85 |
86 | {!lockRelativeChroma ? : } 87 |
88 |
89 |
90 | ) 91 | } 92 | -------------------------------------------------------------------------------- /src/ui/components/sliders/HueSlider/HueSlider.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | import { consoleLogInfos, SLIDER_SIZE } from '../../../../constants' 3 | import { useStore } from '@nanostores/react' 4 | import limitMouseManipulatorPosition from '../../../helpers/limitMouseManipulatorPosition/limitMouseManipulatorPosition' 5 | import { $colorHxya, setColorHxyaWithSideEffects } from '../../../stores/colors/colorHxya/colorHxya' 6 | import { setMouseEventCallback } from '../../../stores/mouseEventCallback/mouseEventCallback' 7 | import getColorHxyDecimals from '../../../helpers/colors/getColorHxyDecimals/getColorHxyDecimals' 8 | import round from 'lodash/round' 9 | import { $isTransitionRunning, $oklchRenderMode } from '../../../stores/oklchRenderMode/oklchRenderMode' 10 | import convertRelativeChromaToAbsolute from '../../../helpers/colors/convertRelativeChromaToAbsolute/convertRelativeChromaToAbsolute' 11 | import { $currentColorModel } from '../../../stores/colors/currentColorModel/currentColorModel' 12 | import getLinearMappedValue from '../../../helpers/getLinearMappedValue/getLinearMappedValue' 13 | 14 | export default function HueSlider() { 15 | if (consoleLogInfos.includes('Component renders')) { 16 | console.log('Component render — HueSlider') 17 | } 18 | 19 | const colorHxya = useStore($colorHxya) 20 | 21 | const hueSlider = useRef(null) 22 | const manipulatorHueSlider = useRef(null) 23 | 24 | const handleNewManipulatorPosition = (event: MouseEvent) => { 25 | if ($isTransitionRunning.get()) return 26 | 27 | const rect = hueSlider.current!.getBoundingClientRect() 28 | const canvasY = event.clientX - rect.left - 7 29 | 30 | const newHValue = round(limitMouseManipulatorPosition(canvasY / SLIDER_SIZE) * 360, getColorHxyDecimals().h) 31 | 32 | if ($currentColorModel.get() !== 'oklch') { 33 | setColorHxyaWithSideEffects({ 34 | newColorHxya: { 35 | h: round(limitMouseManipulatorPosition(canvasY / SLIDER_SIZE) * 360, getColorHxyDecimals().h) 36 | } 37 | }) 38 | } else { 39 | if ($oklchRenderMode.get() === 'triangle') { 40 | setColorHxyaWithSideEffects({ 41 | newColorHxya: { 42 | h: newHValue 43 | } 44 | }) 45 | } else if ($oklchRenderMode.get() === 'square') { 46 | const newXValue = convertRelativeChromaToAbsolute({ 47 | h: newHValue, 48 | y: $colorHxya.get().y 49 | }) 50 | 51 | setColorHxyaWithSideEffects({ 52 | newColorHxya: { 53 | x: newXValue, 54 | h: newHValue 55 | }, 56 | sideEffects: { 57 | syncRelativeChroma: false 58 | } 59 | }) 60 | } 61 | } 62 | } 63 | 64 | useEffect(() => { 65 | const xPosition = getLinearMappedValue({ 66 | valueToMap: $colorHxya.get().h, 67 | originalRange: { min: 0, max: 360 }, 68 | targetRange: { min: -1, max: SLIDER_SIZE } 69 | }) 70 | 71 | manipulatorHueSlider.current!.style.transform = `translate(${xPosition}px, -1px)` 72 | }, [colorHxya.h]) 73 | 74 | useEffect(() => { 75 | hueSlider.current!.addEventListener('mousedown', () => { 76 | setMouseEventCallback(handleNewManipulatorPosition) 77 | }) 78 | }, []) 79 | 80 | return ( 81 |
82 |
83 |
84 |
85 | 86 |
87 |
88 | 89 | 90 | 91 | 92 |
93 |
94 |
95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /src/ui/components/ColorCodeInputs/helpers/isColorCodeInGoodFormat/isColorCodeInGoodFormat.ts: -------------------------------------------------------------------------------- 1 | import { converter } from 'culori' 2 | import { CurrentColorModel } from '../../../../../types' 3 | import { $currentColorModel } from '../../../../stores/colors/currentColorModel/currentColorModel' 4 | 5 | const convertToRgb = converter('rgb') 6 | 7 | type Props = { 8 | color: string 9 | format: string 10 | currentColorModel?: CurrentColorModel 11 | } 12 | 13 | export default function isColorCodeInGoodFormat(props: Props): boolean { 14 | const { color, format, currentColorModel = $currentColorModel.get() } = props 15 | 16 | let regex: RegExp 17 | let match: RegExpMatchArray | null 18 | 19 | let value1: number 20 | let value2: number 21 | let value3: number 22 | let value4: number | null 23 | 24 | if (format === 'oklch') { 25 | regex = /oklch\((\d+(\.\d+)?)%\s*(\d*(\.\d+)?)\s*(\d+(\.\d+)?)(\s*\/\s*(\d+(\.\d+)?))?\)/ 26 | match = color.match(regex) 27 | 28 | if (!match) return false 29 | 30 | value1 = parseFloat(match[1]) 31 | value2 = parseFloat(match[3]) 32 | value3 = parseFloat(match[5]) 33 | value4 = match[8] ? parseFloat(match[8]) : null 34 | 35 | if (value1 < 0 || value1 > 100) return false 36 | if (value2 < 0 || value2 > 1) return false 37 | if (value3 < 0 || value3 > 360) return false 38 | if (value4 !== null && (value4 < 0 || value4 > 1)) return false 39 | } else if (format === 'okhsl') { 40 | regex = /{mode:\s*"okhsl",\s*h:\s*(\d+)\s*,\s*s:\s*(\d+(\.\d+)?)\s*,\s*l:\s*(\d+(\.\d+)?)\s*}/ 41 | match = color.match(regex) 42 | 43 | if (!match) return false 44 | 45 | value1 = parseInt(match[1]) 46 | value2 = parseFloat(match[2]) 47 | value3 = parseFloat(match[4]) 48 | 49 | if (value1 < 0 || value1 > 360) return false 50 | if (value2 < 0 || value2 > 1) return false 51 | if (value3 < 0 || value3 > 1) return false 52 | } else if (format === 'okhsv') { 53 | regex = /{mode:\s*"okhsv",\s*h:\s*(\d+)\s*,\s*s:\s*(\d+(\.\d+)?)\s*,\s*v:\s*(\d+(\.\d+)?)\s*}/ 54 | match = color.match(regex) 55 | 56 | if (!match) return false 57 | 58 | value1 = parseInt(match[1]) 59 | value2 = parseFloat(match[2]) 60 | value3 = parseFloat(match[4]) 61 | 62 | if (value1 < 0 || value1 > 360) return false 63 | if (value2 < 0 || value2 > 1) return false 64 | if (value3 < 0 || value3 > 1) return false 65 | } else if (format === 'color') { 66 | if (['okhsv', 'okhsl'].includes(currentColorModel)) { 67 | regex = /color\(srgb\s*(\d+(\.\d+)?)\s*(\d+(\.\d+)?)\s*(\d+(\.\d+)?)(\s*\/\s*(\d+(\.\d+)?))?\)/ 68 | } else { 69 | regex = /color\(display-p3\s*(\d+(\.\d+)?)\s*(\d+(\.\d+)?)\s*(\d+(\.\d+)?)(\s*\/\s*(\d+(\.\d+)?))?\)/ 70 | } 71 | 72 | match = color.match(regex) 73 | 74 | if (!match) return false 75 | 76 | value1 = parseFloat(match[1]) 77 | value2 = parseFloat(match[3]) 78 | value3 = parseFloat(match[5]) 79 | value4 = match[8] ? parseFloat(match[8]) : null 80 | 81 | if (value1 < 0 || value1 > 1) return false 82 | if (value2 < 0 || value2 > 1) return false 83 | if (value3 < 0 || value3 > 1) return false 84 | if (value4 !== null && (value4 < 0 || value4 > 1)) return false 85 | } else if (format === 'rgba') { 86 | regex = /rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(,\s*(\d+(\.\d+)?))?\s*\)/ 87 | match = color.match(regex) 88 | 89 | if (!match) { 90 | regex = /rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/ 91 | match = color.match(regex) 92 | if (!match) return false 93 | } 94 | 95 | value1 = parseInt(match[1]) 96 | value2 = parseInt(match[2]) 97 | value3 = parseInt(match[3]) 98 | value4 = match[5] ? parseFloat(match[5]) : null 99 | 100 | if (value1 < 0 || value1 > 255) return false 101 | if (value2 < 0 || value2 > 255) return false 102 | if (value3 < 0 || value3 > 255) return false 103 | if (value4 !== null && (value4 < 0 || value4 > 1)) return false 104 | } else if (format === 'hex') { 105 | const newColorRgb = convertToRgb(color) 106 | if (!newColorRgb) return false 107 | } 108 | 109 | return true 110 | } 111 | -------------------------------------------------------------------------------- /src/ui/stores/colors/colorsRgba/colorsRgba.ts: -------------------------------------------------------------------------------- 1 | import { deepMap, action } from 'nanostores' 2 | import { logger } from '@nanostores/logger' 3 | import { consoleLogInfos } from '../../../../constants' 4 | import { ColorsRgba, ApcaContrast, WcagContrast, UpdateShapeColorData } from '../../../../types' 5 | import convertRgbToHxy from '../../../helpers/colors/convertRgbToHxy/convertRgbToHxy' 6 | import getContrastFromBgandFgRgba from '../../../helpers/contrasts/getContrastFromBgandFgRgba/getContrastFromBgandFgRgba' 7 | import { setContrast } from '../../contrasts/contrast/contrast' 8 | import { $lockContrast } from '../../contrasts/lockContrast/lockContrast' 9 | import { $currentFillOrStroke } from '../../currentFillOrStroke/currentFillOrStroke' 10 | import { $colorHxya, setColorHxyaWithSideEffects } from '../colorHxya/colorHxya' 11 | import { $currentColorModel } from '../currentColorModel/currentColorModel' 12 | import { $lockRelativeChroma } from '../lockRelativeChroma/lockRelativeChroma' 13 | import sendMessageToBackend from '../../../helpers/sendMessageToBackend/sendMessageToBackend' 14 | import { $currentBgOrFg } from '../../contrasts/currentBgOrFg/currentBgOrFg' 15 | import merge from 'lodash/merge' 16 | 17 | export const $colorsRgba = deepMap({ 18 | parentFill: null, 19 | fill: { 20 | r: 0, 21 | g: 0, 22 | b: 0, 23 | a: 0 24 | }, 25 | stroke: null 26 | }) 27 | 28 | export const setColorsRgba = action($colorsRgba, 'setColorsRgba', (colorsRgba, newColorsRgba: ColorsRgba) => { 29 | colorsRgba.set(newColorsRgba) 30 | }) 31 | 32 | type SideEffects = { 33 | syncColorRgbWithBackend: boolean 34 | colorHxya: Partial<{ 35 | syncColorHxya: boolean 36 | syncRelativeChroma: boolean 37 | }> 38 | syncContrast: boolean 39 | } 40 | 41 | type Props = { 42 | newColorsRgba: ColorsRgba 43 | sideEffects?: Partial 44 | lockRelativeChroma?: boolean 45 | lockContrast?: boolean 46 | } 47 | 48 | const defaultSideEffects: SideEffects = { 49 | syncColorRgbWithBackend: true, 50 | colorHxya: { 51 | syncColorHxya: true, 52 | syncRelativeChroma: true 53 | }, 54 | syncContrast: true 55 | } 56 | 57 | export const setColorsRgbaWithSideEffects = action($colorsRgba, 'setColorsRgbaWithSideEffects', (colorsRgba, props: Props) => { 58 | const { newColorsRgba, sideEffects: partialSideEffects, lockRelativeChroma = $lockRelativeChroma.get(), lockContrast = $lockContrast.get() } = props 59 | 60 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 61 | merge(sideEffects, partialSideEffects) 62 | 63 | colorsRgba.set(newColorsRgba) 64 | 65 | if (sideEffects.syncColorRgbWithBackend) { 66 | sendMessageToBackend({ 67 | type: 'updateShapeColor', 68 | data: { 69 | newColorRgba: 70 | $currentBgOrFg.get() === 'bg' ? { ...newColorsRgba.parentFill!, a: $colorHxya.get().a } : newColorsRgba[`${$currentFillOrStroke.get()}`]!, 71 | newCurrentBgOrFg: $currentBgOrFg.get() 72 | } 73 | }) 74 | } 75 | 76 | const newColorRgbaCurrentFillOrStroke = newColorsRgba[`${$currentFillOrStroke.get()}`] 77 | 78 | if (sideEffects.colorHxya.syncColorHxya) { 79 | const newColorHxy = convertRgbToHxy({ 80 | colorRgb: newColorRgbaCurrentFillOrStroke! 81 | }) 82 | 83 | setColorHxyaWithSideEffects({ 84 | newColorHxya: { ...newColorHxy, a: newColorRgbaCurrentFillOrStroke!.a }, 85 | sideEffects: { 86 | colorsRgba: { 87 | syncColorsRgba: false 88 | }, 89 | syncRelativeChroma: sideEffects.colorHxya.syncRelativeChroma 90 | }, 91 | lockRelativeChroma: lockRelativeChroma, 92 | lockContrast: lockContrast 93 | }) 94 | } 95 | 96 | if (sideEffects.syncContrast) { 97 | if (['okhsv', 'okhsl'].includes($currentColorModel.get())) return 98 | if (lockContrast || !newColorsRgba.parentFill || !newColorsRgba.fill) return 99 | 100 | const newContrast: ApcaContrast | WcagContrast = getContrastFromBgandFgRgba({ 101 | fg: newColorsRgba.fill!, 102 | bg: newColorsRgba.parentFill! 103 | }) 104 | setContrast(newContrast) 105 | } 106 | }) 107 | 108 | if (consoleLogInfos.includes('Store updates')) { 109 | logger({ 110 | colorsRgba: $colorsRgba 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /src/ui/stores/contrasts/contrast/contrast.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | 3 | import { action, atom } from 'nanostores' 4 | import { logger } from '@nanostores/logger' 5 | import { consoleLogInfos } from '../../../../constants' 6 | import { ApcaContrast, WcagContrast } from '../../../../types' 7 | import getContrastFromBgandFgRgba from '../../../helpers/contrasts/getContrastFromBgandFgRgba/getContrastFromBgandFgRgba' 8 | import getNewXandYFromContrast from '../../../helpers/contrasts/getNewXandYFromContrast/getNewXandYFromContrast' 9 | import { $colorHxya, setColorHxyaWithSideEffects } from '../../colors/colorHxya/colorHxya' 10 | import { $colorsRgba } from '../../colors/colorsRgba/colorsRgba' 11 | import filterNewContrast from '../../../helpers/contrasts/filterNewContrast/filterNewContrast' 12 | import merge from 'lodash/merge' 13 | import { $lockRelativeChroma } from '../../colors/lockRelativeChroma/lockRelativeChroma' 14 | import { $oklchRenderMode } from '../../oklchRenderMode/oklchRenderMode' 15 | 16 | export const $contrast = atom(0) 17 | 18 | export const setContrast = action($contrast, 'setContrast', (contrast, newContrast: ApcaContrast | WcagContrast) => { 19 | contrast.set(newContrast) 20 | }) 21 | 22 | type SideEffects = { 23 | syncColorHxya: boolean 24 | } 25 | 26 | type Props = { 27 | newContrast: ApcaContrast | WcagContrast 28 | sideEffects?: Partial 29 | } 30 | 31 | const defaultSideEffects: SideEffects = { 32 | syncColorHxya: true 33 | } 34 | 35 | export const setContrastWithSideEffects = action($contrast, 'setContrastWithSideEffects', (contrast, props: Props) => { 36 | const { newContrast, sideEffects: partialSideEffects } = props 37 | 38 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 39 | merge(sideEffects, partialSideEffects) 40 | 41 | const filteredNewContrast = filterNewContrast(newContrast) 42 | 43 | if (sideEffects.syncColorHxya) { 44 | let localLockRelativeChroma = $lockRelativeChroma.get() 45 | 46 | // We lock the relative chroma locally because when in square OkLCH mode, we want to keep relative chroma fixed when updating the contrast. 47 | if ($oklchRenderMode.get() === 'square') { 48 | localLockRelativeChroma = true 49 | } 50 | 51 | const newXy = getNewXandYFromContrast({ 52 | h: $colorHxya.get().h, 53 | x: $colorHxya.get().x, 54 | targetContrast: filteredNewContrast, 55 | lockRelativeChroma: localLockRelativeChroma 56 | }) 57 | 58 | setColorHxyaWithSideEffects({ 59 | newColorHxya: newXy, 60 | lockRelativeChroma: localLockRelativeChroma, 61 | sideEffects: { 62 | colorsRgba: { 63 | syncContrast: false 64 | } 65 | }, 66 | lockContrast: false 67 | }) 68 | } 69 | 70 | // In case we get a value that is bigger than what is possible, for example if user wants a contrast of 40 but with the current bg abd fg color the maximum is 30, we need to do this test, otherwize the value 40 will be kept in the contrast input. 71 | const newContrastClamped: ApcaContrast | WcagContrast = getContrastFromBgandFgRgba({ 72 | fg: $colorsRgba.get().fill!, 73 | bg: $colorsRgba.get().parentFill! 74 | }) 75 | 76 | if (newContrastClamped !== 0 && Math.abs(filteredNewContrast) > Math.abs(newContrastClamped)) contrast.set(newContrastClamped) 77 | // In APCA, if we are on a pure black bg with a pure white fg or the opposite, without this condition it would be possible to use arrow to go up to the limit. 78 | else if (newContrastClamped === 0 && ($colorHxya.get().y === 0 || $colorHxya.get().y === 100)) contrast.set(newContrastClamped) 79 | // In WCAG, if we are on a pure black bg with a pure white fg or the opposite, if the user is updating the contrast with arrow keys, when at 1 or -1, it will go back and forth to 1 and -1, because in ContrastInput(), we don't know when we are in that case, hence this XOR condition with ^ that is the same to say: "if filteredNewContrast = -1 and newContrastClamped = 1 or if filteredNewContrast = 1 and newContrastClamped = -1, then run contrast.set(newContrastClamped)". 80 | // @ts-ignore 81 | else if ((filteredNewContrast === -1) ^ (newContrastClamped === -1)) contrast.set(newContrastClamped) 82 | else contrast.set(filteredNewContrast) 83 | }) 84 | 85 | if (consoleLogInfos.includes('Store updates')) { 86 | logger({ 87 | contrast: $contrast 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /src/ui/components/ColorCodeInputs/helpers/getNewColorHxya/getNewColorHxya.ts: -------------------------------------------------------------------------------- 1 | import { ColorCodesInputValues, ColorHxya, ColorHxy, Opacity, CurrentBgOrFg, CurrentColorModel } from '../../../../../types' 2 | import convertRgbToHxy from '../../../../helpers/colors/convertRgbToHxy/convertRgbToHxy' 3 | import getClampedChroma from '../../../../helpers/colors/getClampedChroma/getClampedChroma' 4 | import { $colorHxya } from '../../../../stores/colors/colorHxya/colorHxya' 5 | import { $currentColorModel } from '../../../../stores/colors/currentColorModel/currentColorModel' 6 | import { $currentBgOrFg } from '../../../../stores/contrasts/currentBgOrFg/currentBgOrFg' 7 | import getColorHxyDecimals from '../../../../helpers/colors/getColorHxyDecimals/getColorHxyDecimals' 8 | import round from 'lodash/round' 9 | import { converter } from 'culori' 10 | 11 | const convertToRgb = converter('rgb') 12 | 13 | type Props = { 14 | eventTargetId: keyof typeof ColorCodesInputValues 15 | eventTargetValue: string 16 | colorHxya?: ColorHxya 17 | currentColorModel?: CurrentColorModel 18 | currentBgOrFg?: CurrentBgOrFg 19 | } 20 | 21 | export default function getNewColorHxya(props: Props): ColorHxya | undefined { 22 | const { 23 | eventTargetId, 24 | eventTargetValue, 25 | colorHxya = $colorHxya.get(), 26 | currentColorModel = $currentColorModel.get(), 27 | currentBgOrFg = $currentBgOrFg.get() 28 | } = props 29 | 30 | let regex: RegExp 31 | let matches: RegExpMatchArray | null 32 | 33 | let newColorHxy: ColorHxy = { 34 | h: 0, 35 | x: 0, 36 | y: 0 37 | } 38 | let newColorA: Opacity = 1 39 | 40 | if (eventTargetId === 'currentColorModel') { 41 | regex = /(\d+(\.\d+)?)/g 42 | matches = eventTargetValue.match(regex) 43 | // Just in case of the isColorCodeInGoodFormat() didn't catched an error. 44 | if (!matches) return 45 | 46 | if (currentColorModel === 'oklch') { 47 | newColorHxy = { 48 | h: round(parseFloat(matches[2]), getColorHxyDecimals().h), 49 | x: parseFloat(matches[1]), 50 | y: round(parseFloat(matches[0]), getColorHxyDecimals().h) 51 | } 52 | 53 | newColorHxy.x = round(getClampedChroma(newColorHxy), getColorHxyDecimals().x) 54 | 55 | if (matches[3]?.valueOf() && currentBgOrFg === 'fg') { 56 | newColorA = parseFloat(matches![3]) 57 | } 58 | } else { 59 | newColorHxy = { 60 | h: parseInt(matches[0]), 61 | x: round(parseFloat(matches[1]) * 100, getColorHxyDecimals().x), 62 | y: round(parseFloat(matches[2]) * 100, getColorHxyDecimals().y) 63 | } 64 | if (currentBgOrFg === 'fg') newColorA = colorHxya.a 65 | } 66 | } else if (eventTargetId === 'color') { 67 | regex = /(\b\d+(\.\d+)?\b)/g 68 | matches = eventTargetValue.match(regex) 69 | // Just in case of the isColorCodeInGoodFormat() didn't catched an error. 70 | if (!matches) return 71 | 72 | newColorHxy = convertRgbToHxy({ 73 | colorRgb: { 74 | r: parseFloat(matches![0]), 75 | g: parseFloat(matches![1]), 76 | b: parseFloat(matches![2]) 77 | }, 78 | gamut: currentColorModel === 'oklch' ? 'p3' : 'rgb' 79 | }) 80 | 81 | if (matches[3]?.valueOf() && currentBgOrFg === 'fg') { 82 | newColorA = parseFloat(matches![3]) 83 | } 84 | } else if (eventTargetId === 'rgba') { 85 | regex = /(\d+(\.\d+)?)/g 86 | matches = eventTargetValue.match(regex) 87 | // Just in case of the isColorCodeInGoodFormat() didn't catched an error. 88 | if (!matches) return 89 | 90 | newColorHxy = convertRgbToHxy({ 91 | colorRgb: { 92 | r: parseFloat(matches![0]) / 255, 93 | g: parseFloat(matches![1]) / 255, 94 | b: parseFloat(matches![2]) / 255 95 | }, 96 | gamut: 'rgb' 97 | }) 98 | 99 | if (matches[3]?.valueOf() && currentBgOrFg === 'fg') { 100 | newColorA = parseFloat(matches![3]) 101 | } 102 | } else if (eventTargetId === 'hex') { 103 | const newColorRgb = convertToRgb(eventTargetValue) 104 | if (!newColorRgb) return 105 | 106 | newColorHxy = convertRgbToHxy({ 107 | colorRgb: newColorRgb, 108 | gamut: 'rgb' 109 | }) 110 | 111 | if (newColorRgb.alpha && currentBgOrFg === 'fg') { 112 | newColorA = newColorRgb.alpha 113 | } 114 | } 115 | 116 | return { ...newColorHxy, a: newColorA } 117 | } 118 | -------------------------------------------------------------------------------- /src/ui/shaders/utils.glsl: -------------------------------------------------------------------------------- 1 | precision mediump float; 2 | 3 | float clampRadian(float radian) { 4 | return mod(radian, 2.0 * M_PI) / (2.0 * M_PI); 5 | } 6 | 7 | float inRange(float val, float min, float max) { 8 | return step(min, val) - step(max, val); 9 | } 10 | 11 | bool isInBounds(in vec3 v) { 12 | return all(greaterThanEqual(v, vec3(0.0))) && all(lessThanEqual(v, vec3(1.0))); 13 | } 14 | 15 | vec3 mul3(in mat3 m, in vec3 v) { 16 | return vec3( 17 | dot(v,m[0]), 18 | dot(v,m[1]), 19 | dot(v,m[2]) 20 | ); 21 | } 22 | 23 | vec3 lchToLab(in vec3 lch) { 24 | return vec3( 25 | lch.x, 26 | lch.y*cos(lch.z), 27 | lch.y*sin(lch.z) 28 | ); 29 | } 30 | 31 | // Conversation matrices used from culori.js 32 | vec3 oklabToLrgbSrgb(in vec3 lab) { 33 | mat3 labToLms = mat3( 34 | 0.9999999984505198, 0.39633779217376786, 0.2158037580607588, 35 | 1.0000000088817609, -0.10556134232365635, -0.06385417477170591, 36 | 1.0000000546724108, -0.08948418209496575, -1.2914855378640917 37 | ); 38 | 39 | // We first convert the values to absolutes one as with some GPU we have a rendering bug when using pow() with negative values in that case. 40 | vec3 absLms = abs(mul3(labToLms, lab)); 41 | vec3 lms = pow(absLms, vec3(3.0)); 42 | 43 | // We restore the original sign back. 44 | lms *= sign(mul3(labToLms, lab)); 45 | 46 | mat3 lmsToLrgb = mat3( 47 | 4.076741661347994, -3.307711590408193, 0.230969928729428, 48 | -1.2684380040921763, 2.6097574006633715, -0.3413193963102197, 49 | -0.004196086541837188, -0.7034186144594493, 1.7076147009309444 50 | ); 51 | 52 | return mul3(lmsToLrgb, lms); 53 | } 54 | 55 | vec3 lrgbToXyz(in vec3 rgb) { 56 | mat3 m1 = mat3( 57 | 0.4123907992659593, 0.357584339383878, 0.1804807884018343, 58 | 0.2126390058715102, 0.715168678767756, 0.0721923153607337, 59 | 0.0193308187155918, 0.119194779794626, 0.9505321522496607 60 | ); 61 | 62 | return mul3(m1, rgb); 63 | } 64 | 65 | vec3 xyzToLrgbP3(in vec3 xyz) { 66 | mat3 m1 = mat3( 67 | 2.4934969119414263, -0.9313836179191242, -0.402710784450717, 68 | -0.8294889695615749, 1.7626640603183465, 0.0236246858419436, 69 | 0.0358458302437845, -0.0761723892680418, 0.9568845240076871 70 | ); 71 | 72 | return mul3(m1, xyz); 73 | } 74 | 75 | // vec3 xyzToLrgbSrgb(in vec3 xyz) { 76 | // mat3 m1 = mat3( 77 | // 3.2409699419045226, -1.537383177570094, -0.4986107602930034, 78 | // -0.9692436362808796, 1.8759675015077204, 0.0415550574071756, 79 | // 0.0556300796969936, -0.2039769588889765, 1.0569715142428784 80 | // ); 81 | 82 | // return mul3(m1, xyz); 83 | // } 84 | 85 | vec3 lrgbToRgb(in vec3 rgb) { 86 | float absR = abs(rgb.r); 87 | float absG = abs(rgb.g); 88 | float absB = abs(rgb.b); 89 | 90 | float processedR; 91 | float processedG; 92 | float processedB; 93 | 94 | if (absR > 0.0031308) { 95 | processedR = sign(rgb.r) * (1.055 * pow(absR, 1.0 / 2.4) - 0.055); 96 | } else { 97 | processedR = rgb.r * 12.92; 98 | } 99 | 100 | if (absG > 0.0031308) { 101 | processedG = sign(rgb.g) * (1.055 * pow(absG, 1.0 / 2.4) - 0.055); 102 | } else { 103 | processedG = rgb.g * 12.92; 104 | } 105 | 106 | if (absB > 0.0031308) { 107 | processedB = sign(rgb.b) * (1.055 * pow(absB, 1.0 / 2.4) - 0.055); 108 | } else { 109 | processedB = rgb.b * 12.92; 110 | } 111 | 112 | return vec3(processedR, processedG, processedB); 113 | } 114 | 115 | vec3 oklchToRgb(in vec3 lch, in bool isGamutP3) { 116 | vec3 lab = lchToLab(lch); 117 | vec3 lrgbInSpace; 118 | 119 | if (!isGamutP3) { 120 | lrgbInSpace = oklabToLrgbSrgb(lab); 121 | } else { 122 | // Converting to rgb in P3 space needs a few more steps. 123 | vec3 lrgb = oklabToLrgbSrgb(lab); 124 | vec3 xyz = lrgbToXyz(lrgb); 125 | lrgbInSpace = xyzToLrgbP3(xyz); 126 | } 127 | 128 | vec3 rgbInSpace = lrgbToRgb(lrgbInSpace); 129 | return rgbInSpace; 130 | } 131 | 132 | // This alternative uses the same steps for sRGB than P3, in case for the future if bugs are found with sRGB render. 133 | // vec3 oklchToRgb(in vec3 lch, in bool isGamutP3) { 134 | // vec3 lab = lchToLab(lch); 135 | // vec3 lrgb = oklabToLrgbSrgb(lab); 136 | // vec3 xyz = lrgbToXyz(lrgb); 137 | 138 | // vec3 lrgbInSpace; 139 | // if (isGamutP3) { 140 | // lrgbInSpace = xyzToLrgbP3(xyz); 141 | // } else { 142 | // lrgbInSpace = xyzToLrgbSrgb(xyz); 143 | // } 144 | 145 | // vec3 rgbInSpace = lrgbToRgb(lrgbInSpace); 146 | // return rgbInSpace; 147 | // } -------------------------------------------------------------------------------- /src/ui/stores/colors/colorHxya/colorHxya.ts: -------------------------------------------------------------------------------- 1 | // We use default values in the atoms but they are not used, because we get these values from backend. 2 | // They are useful however to not use null in their type (although some of them have it when we have to). 3 | // More infos in the comment on top in App component. 4 | 5 | import { map, action } from 'nanostores' 6 | import { logger } from '@nanostores/logger' 7 | import { consoleLogInfos } from '../../../../constants' 8 | import { ColorHxya } from '../../../../types' 9 | import convertAbsoluteChromaToRelative from '../../../helpers/colors/convertAbsoluteChromaToRelative/convertAbsoluteChromaToRelative' 10 | import convertHxyToRgb from '../../../helpers/colors/convertHxyToRgb/convertHxyToRgb' 11 | import filterNewColorHxya from '../../../helpers/colors/filterNewColorHxya/filterNewColorHxya' 12 | import { $currentBgOrFg } from '../../contrasts/currentBgOrFg/currentBgOrFg' 13 | import { $currentFillOrStroke } from '../../currentFillOrStroke/currentFillOrStroke' 14 | import { setColorsRgbaWithSideEffects, $colorsRgba } from '../colorsRgba/colorsRgba' 15 | import { $currentColorModel } from '../currentColorModel/currentColorModel' 16 | import { $lockRelativeChroma } from '../lockRelativeChroma/lockRelativeChroma' 17 | import { setRelativeChroma } from '../relativeChroma/relativeChroma' 18 | import { $lockContrast } from '../../contrasts/lockContrast/lockContrast' 19 | import merge from 'lodash/merge' 20 | 21 | // This map contain the current color being used in the UI, it can be the fill or the stroke of the foreground but also the background (colorsRgba.parentFill) of the current selected object. 22 | export const $colorHxya = map({ 23 | h: 0, 24 | x: 0, 25 | y: 0, 26 | a: 0 27 | }) 28 | 29 | type SetColorHxyaProps = { 30 | newColorHxya: Partial 31 | lockRelativeChroma?: boolean 32 | lockContrast?: boolean 33 | } 34 | 35 | export const setColorHxya = action($colorHxya, 'setColorHxya', (colorHxya, props: SetColorHxyaProps) => { 36 | const { newColorHxya, lockRelativeChroma = $lockRelativeChroma.get(), lockContrast = $lockContrast.get() } = props 37 | 38 | colorHxya.set( 39 | filterNewColorHxya({ 40 | newColorHxya: newColorHxya, 41 | lockRelativeChroma: lockRelativeChroma, 42 | lockContrast: lockContrast 43 | }) 44 | ) 45 | }) 46 | 47 | export type SideEffects = { 48 | colorsRgba: Partial<{ 49 | syncColorsRgba: boolean 50 | syncContrast: boolean 51 | }> 52 | syncRelativeChroma: boolean 53 | } 54 | 55 | type SetColorHxyaWithSideEffectsProps = { 56 | newColorHxya: Partial 57 | sideEffects?: Partial 58 | lockRelativeChroma?: boolean 59 | lockContrast?: boolean 60 | } 61 | 62 | export const defaultSideEffects: SideEffects = { 63 | colorsRgba: { 64 | syncColorsRgba: true, 65 | syncContrast: true 66 | }, 67 | syncRelativeChroma: true 68 | } 69 | 70 | export const setColorHxyaWithSideEffects = action($colorHxya, 'setColorHxyaWithSideEffects', (colorHxya, props: SetColorHxyaWithSideEffectsProps) => { 71 | const { newColorHxya, sideEffects: partialSideEffects, lockRelativeChroma = $lockRelativeChroma.get(), lockContrast = $lockContrast.get() } = props 72 | 73 | const sideEffects = JSON.parse(JSON.stringify(defaultSideEffects)) 74 | merge(sideEffects, partialSideEffects) 75 | 76 | const filteredNewColorHxya = filterNewColorHxya({ 77 | newColorHxya: newColorHxya, 78 | lockRelativeChroma: lockRelativeChroma, 79 | lockContrast: lockContrast 80 | }) 81 | 82 | colorHxya.set(filteredNewColorHxya) 83 | 84 | const newColorRgb = convertHxyToRgb({ colorHxy: filteredNewColorHxya }) 85 | 86 | if (sideEffects.colorsRgba.syncColorsRgba) { 87 | const key = $currentBgOrFg.get() === 'bg' ? 'parentFill' : `${$currentFillOrStroke.get()}` 88 | 89 | setColorsRgbaWithSideEffects({ 90 | newColorsRgba: { ...$colorsRgba.get(), [key]: { ...newColorRgb, a: filteredNewColorHxya.a } }, 91 | sideEffects: { 92 | colorHxya: { 93 | syncColorHxya: false 94 | }, 95 | syncContrast: sideEffects.colorsRgba.syncContrast 96 | }, 97 | lockContrast: lockContrast 98 | }) 99 | } 100 | 101 | if (sideEffects.syncRelativeChroma) { 102 | if (['okhsv', 'okhsl'].includes($currentColorModel.get())) return 103 | 104 | // We don't want to get a new relative chroma value if the lock is on, but we also check if relativeChroma value is not undefined, if that the case we first need to set it. 105 | // And if lightness is 0 or 100, there is no need to continue either. 106 | if (lockRelativeChroma || newColorHxya.y === 0 || newColorHxya.y === 100) return 107 | 108 | setRelativeChroma( 109 | convertAbsoluteChromaToRelative({ 110 | colorHxy: filteredNewColorHxya 111 | }) 112 | ) 113 | } 114 | }) 115 | 116 | if (consoleLogInfos.includes('Store updates')) { 117 | logger({ 118 | colorHxya: $colorHxya 119 | }) 120 | } 121 | -------------------------------------------------------------------------------- /src/ui/components/ColorPicker/helpers/handleNewManipulatorPosition/handleNewManipulatorPosition.ts: -------------------------------------------------------------------------------- 1 | import round from 'lodash/round' 2 | import { PICKER_SIZE, MAX_CHROMA_P3 } from '../../../../../constants' 3 | import convertAbsoluteChromaToRelative from '../../../../helpers/colors/convertAbsoluteChromaToRelative/convertAbsoluteChromaToRelative' 4 | import convertRelativeChromaToAbsolute from '../../../../helpers/colors/convertRelativeChromaToAbsolute/convertRelativeChromaToAbsolute' 5 | import getColorHxyDecimals from '../../../../helpers/colors/getColorHxyDecimals/getColorHxyDecimals' 6 | import getLinearMappedValue from '../../../../helpers/getLinearMappedValue/getLinearMappedValue' 7 | import limitMouseManipulatorPosition from '../../../../helpers/limitMouseManipulatorPosition/limitMouseManipulatorPosition' 8 | import { $colorHxya, setColorHxyaWithSideEffects } from '../../../../stores/colors/colorHxya/colorHxya' 9 | import { $currentColorModel } from '../../../../stores/colors/currentColorModel/currentColorModel' 10 | import { $lockRelativeChroma } from '../../../../stores/colors/lockRelativeChroma/lockRelativeChroma' 11 | import { $lockContrast } from '../../../../stores/contrasts/lockContrast/lockContrast' 12 | import { $currentKeysPressed } from '../../../../stores/currentKeysPressed/currentKeysPressed' 13 | import { $oklchRenderMode } from '../../../../stores/oklchRenderMode/oklchRenderMode' 14 | 15 | type Props = { 16 | event: MouseEvent 17 | rect: DOMRect 18 | } 19 | 20 | export default function handleNewManipulatorPosition(props: Props) { 21 | const { event, rect } = props 22 | 23 | let setColorHxya = true 24 | 25 | // Get the new X and Y value between 0 and 100. 26 | const canvasY = limitMouseManipulatorPosition(1 - (event.clientY - rect.top) / PICKER_SIZE) * 100 27 | let canvasX = limitMouseManipulatorPosition((event.clientX - rect.left) / PICKER_SIZE) * 100 28 | 29 | let newXValue: number 30 | let newYValue: number 31 | 32 | if ($lockContrast.get()) { 33 | newYValue = $colorHxya.get().y 34 | } else { 35 | newYValue = round(canvasY, getColorHxyDecimals().y) 36 | } 37 | 38 | if ($lockRelativeChroma.get()) { 39 | newXValue = $colorHxya.get().x 40 | } else { 41 | if ($currentColorModel.get() !== 'oklch') { 42 | newXValue = round(canvasX, getColorHxyDecimals().x) 43 | } else { 44 | if ($oklchRenderMode.get() === 'triangle') { 45 | newXValue = getLinearMappedValue({ 46 | valueToMap: canvasX, 47 | originalRange: { min: 0, max: 100 }, 48 | targetRange: { min: 0, max: MAX_CHROMA_P3 } 49 | }) 50 | 51 | newXValue = round(newXValue, getColorHxyDecimals().x) 52 | } else { 53 | newXValue = convertRelativeChromaToAbsolute({ 54 | h: $colorHxya.get().h, 55 | y: newYValue, 56 | relativeChroma: canvasX 57 | }) 58 | } 59 | } 60 | } 61 | 62 | if ($currentKeysPressed.get().includes('shift')) { 63 | setColorHxya = false 64 | 65 | if (!$lockContrast.get() && round(newYValue) % 5 === 0) { 66 | newYValue = round(newYValue) 67 | 68 | setColorHxya = true 69 | } 70 | 71 | let relativeChromaToTest = canvasX 72 | 73 | if ($oklchRenderMode.get() === 'triangle') { 74 | relativeChromaToTest = convertAbsoluteChromaToRelative({ 75 | colorHxy: { 76 | h: $colorHxya.get().h, 77 | x: newXValue, 78 | y: newYValue 79 | } 80 | }) 81 | } 82 | 83 | if (!$lockRelativeChroma.get() && round(relativeChromaToTest) % 5 === 0) { 84 | canvasX = round(canvasX) 85 | 86 | if ($currentColorModel.get() !== 'oklch') { 87 | newXValue = round(canvasX, getColorHxyDecimals().x) 88 | } else { 89 | if ($oklchRenderMode.get() === 'triangle') { 90 | newXValue = getLinearMappedValue({ 91 | valueToMap: canvasX, 92 | originalRange: { min: 0, max: 100 }, 93 | targetRange: { min: 0, max: MAX_CHROMA_P3 } 94 | }) 95 | 96 | newXValue = round(newXValue, getColorHxyDecimals().x) 97 | } else { 98 | newXValue = convertRelativeChromaToAbsolute({ 99 | h: $colorHxya.get().h, 100 | y: newYValue, 101 | relativeChroma: canvasX 102 | }) 103 | } 104 | } 105 | 106 | setColorHxya = true 107 | } 108 | } 109 | 110 | if (setColorHxya) { 111 | let syncRelativeChroma = true 112 | 113 | // We use this to avoid getting relative chroma at 0 when in oklch square mode and lightness to 0 or 100. 114 | if ($oklchRenderMode.get() === 'square' && (newYValue < 0.1 || newYValue > 99.9)) { 115 | syncRelativeChroma = false 116 | } 117 | 118 | setColorHxyaWithSideEffects({ 119 | newColorHxya: { 120 | x: newXValue, 121 | y: newYValue 122 | }, 123 | sideEffects: { 124 | syncRelativeChroma: syncRelativeChroma 125 | } 126 | }) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/ui/components/sliders/OpacitySlider/OpacitySlider.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react' 2 | import { SLIDER_SIZE, consoleLogInfos } from '../../../../constants' 3 | import { useStore } from '@nanostores/react' 4 | import limitMouseManipulatorPosition from '../../../helpers/limitMouseManipulatorPosition/limitMouseManipulatorPosition' 5 | import { $colorHxya, setColorHxyaWithSideEffects } from '../../../stores/colors/colorHxya/colorHxya' 6 | import { $colorsRgba } from '../../../stores/colors/colorsRgba/colorsRgba' 7 | import { $currentBgOrFg } from '../../../stores/contrasts/currentBgOrFg/currentBgOrFg' 8 | import { $lockContrast } from '../../../stores/contrasts/lockContrast/lockContrast' 9 | import { $currentFillOrStroke } from '../../../stores/currentFillOrStroke/currentFillOrStroke' 10 | import { setMouseEventCallback } from '../../../stores/mouseEventCallback/mouseEventCallback' 11 | import getLinearMappedValue from '../../../helpers/getLinearMappedValue/getLinearMappedValue' 12 | 13 | const opacitysliderBackgroundImg = 14 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAWQAAAAgCAYAAAAlpQLuAAAACXBIWXMAAAsTAAALEwEAmpwYAAABaWlDQ1BEaXNwbGF5IFAzAAB4nHWQvUvDUBTFT6tS0DqIDh0cMolD1NIKdnFoKxRFMFQFq1OafgltfCQpUnETVyn4H1jBWXCwiFRwcXAQRAcR3Zw6KbhoeN6XVNoi3sfl/Ticc7lcwBtQGSv2AijplpFMxKS11Lrke4OHnlOqZrKooiwK/v276/PR9d5PiFlNu3YQ2U9cl84ul3aeAlN//V3Vn8maGv3f1EGNGRbgkYmVbYsJ3iUeMWgp4qrgvMvHgtMunzuelWSc+JZY0gpqhrhJLKc79HwHl4plrbWD2N6f1VeXxRzqUcxhEyYYilBRgQQF4X/8044/ji1yV2BQLo8CLMpESRETssTz0KFhEjJxCEHqkLhz634PrfvJbW3vFZhtcM4v2tpCAzidoZPV29p4BBgaAG7qTDVUR+qh9uZywPsJMJgChu8os2HmwiF3e38M6Hvh/GMM8B0CdpXzryPO7RqFn4Er/QcXKWq8MSlPPgAAATpJREFUeAHt2yFuAmEYBNCfphgCAgEH4P5XAg8IBASDoF1OMJQ0GfFeghs2O2bEJt9sv98/R2C5XI7tdptEx/l8HtfrNcrudrsod7vdxul0irKbzWasVqsoezgcopz++uuvf+KT/l8DgAoGGaCEQQYoYZABShhkgBIGGaCEQQYoYZABShhkgBKz4/EYXerN5/PXL/F4PF6/xGKxiHLvPPOdd73f71FOf/311z/xSf/Z81fyR6eb+uuvf0L/v/f3yQKghEEGKGGQAUoYZIASBhmghEEGKGGQAUoYZIASBhmghNNpp6NRTn/99f///t/TSWBiOl1cr9dRdjpdTJ+bnkNOz7tcLlF2Ol2c3jeRnkPqr7/++ic+6e+TBUAJgwxQwiADlDDIACUMMkAJgwxQwiADlDDIACUMMkCJH64TqqJTPwcKAAAAAElFTkSuQmCC' 15 | 16 | export default function OpacitySlider() { 17 | if (consoleLogInfos.includes('Component renders')) { 18 | console.log('Component render — OpacitySlider') 19 | } 20 | 21 | const colorHxya = useStore($colorHxya) 22 | const colorsRgba = useStore($colorsRgba) 23 | const currentBgOrFg = useStore($currentBgOrFg) 24 | const lockContrast = useStore($lockContrast) 25 | 26 | const opacitySliderWrapper = useRef(null) 27 | const opacitySlider = useRef(null) 28 | const manipulatorOpacitySlider = useRef(null) 29 | 30 | const updateManipulatorPosition = () => { 31 | const xPosition = getLinearMappedValue({ 32 | valueToMap: $colorHxya.get().a, 33 | originalRange: { min: 0, max: 1 }, 34 | targetRange: { min: -1, max: SLIDER_SIZE } 35 | }) 36 | 37 | manipulatorOpacitySlider.current!.style.transform = `translate(${xPosition}px, -1px)` 38 | } 39 | 40 | const handleNewManipulatorPosition = (event: MouseEvent) => { 41 | const rect = opacitySlider.current!.getBoundingClientRect() 42 | const canvasY = event.clientX - rect.left - 7 43 | 44 | setColorHxyaWithSideEffects({ 45 | newColorHxya: { 46 | a: limitMouseManipulatorPosition(canvasY / SLIDER_SIZE) 47 | } 48 | }) 49 | } 50 | 51 | useEffect(() => { 52 | updateManipulatorPosition() 53 | }, [colorHxya.a]) 54 | 55 | useEffect(() => { 56 | if (currentBgOrFg === 'bg' || lockContrast) { 57 | opacitySliderWrapper.current!.style.backgroundImage = `linear-gradient(to right, rgba(255, 255, 255, 0), rgba(0, 0, 0, 1) 90%), url(${opacitysliderBackgroundImg})` 58 | } else { 59 | opacitySliderWrapper.current!.style.backgroundImage = `linear-gradient(to right, rgba(255, 255, 255, 0), rgba(${ 60 | colorsRgba[`${$currentFillOrStroke.get()}`]!.r * 255 61 | }, ${colorsRgba[`${$currentFillOrStroke.get()}`]!.g * 255}, ${ 62 | colorsRgba[`${$currentFillOrStroke.get()}`]!.b * 255 63 | }, 1) 90%), url(${opacitysliderBackgroundImg})` 64 | } 65 | }, [colorsRgba, currentBgOrFg, lockContrast]) 66 | 67 | useEffect(() => { 68 | opacitySlider.current!.addEventListener('mousedown', () => { 69 | setMouseEventCallback(handleNewManipulatorPosition) 70 | }) 71 | }, []) 72 | 73 | return ( 74 |
75 |
76 |
77 |
78 | 79 |
80 |
81 | 82 | 83 | 84 | 85 |
86 |
87 |
88 | ) 89 | } 90 | -------------------------------------------------------------------------------- /src/ui/components/single-input-with-lock/BgOrFgToggle/BgOrFgToggle.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react' 2 | import { useStore } from '@nanostores/react' 3 | import { consoleLogInfos } from '../../../../constants' 4 | import convertHxyToRgb from '../../../helpers/colors/convertHxyToRgb/convertHxyToRgb' 5 | import { $colorHxya } from '../../../stores/colors/colorHxya/colorHxya' 6 | import { $colorsRgba } from '../../../stores/colors/colorsRgba/colorsRgba' 7 | import { $currentColorModel } from '../../../stores/colors/currentColorModel/currentColorModel' 8 | import { $currentBgOrFg, setCurrentBgOrFgWithSideEffects } from '../../../stores/contrasts/currentBgOrFg/currentBgOrFg' 9 | import { $currentFillOrStroke } from '../../../stores/currentFillOrStroke/currentFillOrStroke' 10 | import { $uiMessage } from '../../../stores/uiMessage/uiMessage' 11 | 12 | export default function BgOrFgToggle() { 13 | if (consoleLogInfos.includes('Component renders')) { 14 | console.log('Component render — BgOrFgToggle') 15 | } 16 | 17 | const bgOrFgToggle = useRef(null) 18 | const fgToggle = useRef(null) 19 | const bgToggle = useRef(null) 20 | const fgTogglelabel = useRef(null) 21 | const bgToggleLabel = useRef(null) 22 | 23 | const colorsRgba = useStore($colorsRgba) 24 | const colorHxya = useStore($colorHxya) 25 | const currentBgOrFg = useStore($currentBgOrFg) 26 | 27 | const handleBgOrFgToggle = () => { 28 | if ($currentBgOrFg.get() === 'bg') setCurrentBgOrFgWithSideEffects({ newCurrentBgOrFg: 'fg' }) 29 | else setCurrentBgOrFgWithSideEffects({ newCurrentBgOrFg: 'bg' }) 30 | } 31 | 32 | useEffect(() => { 33 | if ($currentFillOrStroke.get() === 'stroke' || $currentColorModel.get() !== 'oklch') return 34 | 35 | const bgColor = convertHxyToRgb({ 36 | colorHxy: { 37 | h: $colorHxya.get().h, 38 | x: 0.009, 39 | y: 30 40 | } 41 | }) 42 | 43 | const textColor = convertHxyToRgb({ 44 | colorHxy: { 45 | h: $colorHxya.get().h, 46 | x: 0.06, 47 | y: document.documentElement.classList.contains('figma-dark') ? 80 : 40 48 | } 49 | }) 50 | 51 | if (currentBgOrFg === 'bg') { 52 | if (document.documentElement.classList.contains('figma-dark')) { 53 | bgToggle.current!.style.backgroundColor = `rgb(${bgColor.r * 255}, ${bgColor.g * 255}, ${bgColor.b * 255})` 54 | fgToggle.current!.style.backgroundColor = 'unset' 55 | } else { 56 | fgToggle.current!.style.backgroundColor = 'var(--figma-color-bg)' 57 | } 58 | 59 | bgToggleLabel.current!.style.color = `rgb(${textColor.r * 255}, ${textColor.g * 255}, ${textColor.b * 255})` 60 | fgTogglelabel.current!.style.color = 'var(--figma-color-text-secondary)' 61 | } else { 62 | if (document.documentElement.classList.contains('figma-dark')) { 63 | fgToggle.current!.style.backgroundColor = `rgb(${bgColor.r * 255}, ${bgColor.g * 255}, ${bgColor.b * 255})` 64 | bgToggle.current!.style.backgroundColor = 'unset' 65 | } else { 66 | fgToggle.current!.style.backgroundColor = 'var(--figma-color-bg)' 67 | } 68 | 69 | fgTogglelabel.current!.style.color = `rgb(${textColor.r * 255}, ${textColor.g * 255}, ${textColor.b * 255})` 70 | bgToggleLabel.current!.style.color = 'var(--figma-color-text-secondary)' 71 | } 72 | }, [currentBgOrFg, colorHxya]) 73 | 74 | useEffect(() => { 75 | document.addEventListener('keydown', (event) => { 76 | if (!['b', 'B', 'f', 'F'].includes(event.key)) return 77 | 78 | if ($currentColorModel.get() !== 'oklch') return 79 | // We test if document.activeElement?.tagName is an input because we don't want to trigger this code if user type "c" while he's in one of them. 80 | if ($uiMessage.get().show || document.activeElement?.tagName === 'INPUT') return 81 | if (!$colorsRgba.get().parentFill || !$colorsRgba.get().fill || $currentFillOrStroke.get() === 'stroke') return 82 | 83 | handleBgOrFgToggle() 84 | }) 85 | }, []) 86 | 87 | return ( 88 |
93 |
94 |
95 | Bg 96 |
97 |
98 | 99 |
100 |
104 | Fg 105 |
106 |
107 |
108 | ) 109 | } 110 | --------------------------------------------------------------------------------