diff --git a/src/cmem/markdown/Markdown.stories.tsx b/src/cmem/markdown/Markdown.stories.tsx index d067e7912..2754e20cf 100644 --- a/src/cmem/markdown/Markdown.stories.tsx +++ b/src/cmem/markdown/Markdown.stories.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Blockquote } from "@blueprintjs/core"; import { Meta, StoryFn } from "@storybook/react"; import { Markdown } from "./../../../index"; diff --git a/src/common/index.ts b/src/common/index.ts index ed25944ee..5a4d72cc1 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1,4 +1,5 @@ import { invisibleZeroWidthCharacters } from "./utils/characters"; +import { colorCalculateDistance } from "./utils/colorCalculateDistance"; import decideContrastColorValue from "./utils/colorDecideContrastvalue"; import getColorConfiguration from "./utils/getColorConfiguration"; import { getScrollParent } from "./utils/getScrollParent"; @@ -8,6 +9,7 @@ import { openInNewTab } from "./utils/openInNewTab"; export const utils = { openInNewTab, decideContrastColorValue, + colorCalculateDistance, getColorConfiguration, invisibleZeroWidthCharacters, getGlobalVar, diff --git a/src/common/utils/CssCustomProperties.ts b/src/common/utils/CssCustomProperties.ts index ec9495c1d..a7166fcc9 100644 --- a/src/common/utils/CssCustomProperties.ts +++ b/src/common/utils/CssCustomProperties.ts @@ -13,6 +13,7 @@ interface getLocalCssStyleRulePropertiesProps extends getLocalCssStyleRulesProps propertyType?: "all" | "normal" | "custom"; } interface getCustomPropertiesProps extends getLocalCssStyleRulesProps { + filterName?: (name: string) => boolean; removeDashPrefix?: boolean; returnObject?: boolean; } @@ -87,7 +88,7 @@ export default class CssCustomProperties { const { propertyType = "all", ...otherFilters } = filter; return CssCustomProperties.listLocalCssStyleRules(otherFilters) .map((cssrule) => { - return [...(cssrule as any).style].map((propertyname) => { + return [...(cssrule as AllowedCSSRule).style].map((propertyname) => { return [propertyname.trim(), (cssrule as CSSStyleRule).style.getPropertyValue(propertyname).trim()]; }); }) @@ -104,17 +105,21 @@ export default class CssCustomProperties { }; static listCustomProperties = (props: getCustomPropertiesProps = {}) => { - const { removeDashPrefix = true, returnObject = true, ...filterProps } = props; + const { removeDashPrefix = true, returnObject = true, filterName = () => true, ...filterProps } = props; const customProperties = CssCustomProperties.listLocalCssStyleRuleProperties({ ...filterProps, propertyType: "custom", - }).map((declaration) => { - if (removeDashPrefix) { - return [declaration[0].substr(2), declaration[1]]; - } - return declaration; - }); + }) + .filter((declaration) => { + return filterName(declaration[0]); + }) + .map((declaration) => { + if (removeDashPrefix) { + return [declaration[0].substr(2), declaration[1]]; + } + return declaration; + }); return returnObject ? Object.fromEntries(customProperties) : customProperties; }; diff --git a/src/common/utils/colorCalculateDistance.ts b/src/common/utils/colorCalculateDistance.ts new file mode 100644 index 000000000..2dd04df27 --- /dev/null +++ b/src/common/utils/colorCalculateDistance.ts @@ -0,0 +1,28 @@ +import Color from "color"; + +export type colorValue = Color | string; + +export interface colorCalculateDistanceProps { + // Color used to calculate distance to other color. + color1: colorValue; + // Other color used to calculate distance to color. + color2: colorValue; +} + +/** + * Calculates the distance between 2 colors. + * To keep it simple the CIE76 formula is used. + * @see https://en.wikipedia.org/wiki/Color_difference#CIE76 + */ +export const colorCalculateDistance = ({ color1, color2 }: colorCalculateDistanceProps): number | null => { + let colorDistance: number | null = null; + try { + const lab1 = Color(color1).lab(); + const lab2 = Color(color2).lab(); + colorDistance = ((lab1.l() - lab2.l()) ** 2 + (lab1.a() - lab2.a()) ** 2 + (lab1.b() - lab2.b()) ** 2) ** 0.5; + } catch (error) { + // eslint-disable-next-line no-console + console.warn("Received invalid colors", { color1, color2, error }); + } + return colorDistance; +}; diff --git a/src/components/Application/_colors.scss b/src/components/Application/_colors.scss new file mode 100644 index 000000000..fbff34a9c --- /dev/null +++ b/src/components/Application/_colors.scss @@ -0,0 +1,15 @@ +@use "sass:map"; +@use "sass:list"; + +:root { + @each $palette-group-name, $palette-group-tints in $eccgui-color-palette-light { + @each $palette-tint-name, $palette-tint-colors in $palette-group-tints { + @for $i from 1 through list.length($palette-tint-colors) { + #{eccgui-color-name($palette-group-name, $palette-tint-name, ($i * 2 - 1) * 100)}: #{list.nth( + $palette-tint-colors, + $i + )}; + } + } + } +} diff --git a/src/components/Application/application.scss b/src/components/Application/application.scss index 78d1b5d45..ea0de1677 100644 --- a/src/components/Application/application.scss +++ b/src/components/Application/application.scss @@ -1,4 +1,5 @@ // @import 'config'; +@import "colors"; @import "header"; @import "toolbar"; diff --git a/src/components/Application/stories/Application.stories.tsx b/src/components/Application/stories/Application.stories.tsx index e2feb5907..7e0aa6cf4 100644 --- a/src/components/Application/stories/Application.stories.tsx +++ b/src/components/Application/stories/Application.stories.tsx @@ -29,11 +29,11 @@ interface ApplicationBasicExampleProps { } function ApplicationBasicExample(args: ApplicationBasicExampleProps) { - return <>; + return args ? <> : <>; } export default { - title: "Components/Application", + title: "Components/Application/Elements", component: ApplicationBasicExample, subcomponents: { ApplicationContainer, diff --git a/src/components/Application/stories/ColorPalettes.stories.tsx b/src/components/Application/stories/ColorPalettes.stories.tsx new file mode 100644 index 000000000..d0bdf3229 --- /dev/null +++ b/src/components/Application/stories/ColorPalettes.stories.tsx @@ -0,0 +1,640 @@ +import React from "react"; +import { render } from "react-dom"; +import { Meta, StoryFn } from "@storybook/react"; +import Color from "color"; + +import CssCustomProperties from "./../../../common/utils/CssCustomProperties"; +import { + ApplicationContainer, + Badge, + Button, + CLASSPREFIX as eccgui, + ContextMenu, + FieldItem, + FieldItemRow, + FlexibleLayoutContainer, + FlexibleLayoutItem, + IconButton, + MenuItem, + Section, + SectionHeader, + Spacing, + Switch, + Tabs, + TabTitle, + Tag, + TextField, + TitleSubsection, + utils, +} from "./../../../index"; + +interface ColorPaletteConfiguratorProps { + /** Color palette as custom CSS properties */ + customColorProperties?: string; + /** Default value for minimal color distance */ + distanceMin?: number; + /** Default value for minimal contrast */ + contrastMin?: number; + /** Enable color checks by default */ + enableCalculations?: boolean; +} + +const ColorPaletteConfigurator = ({ + customColorProperties, + distanceMin = 10, // @see https://wisotop.de/farbabstand-farben-vergleichen.php + contrastMin = 4, + enableCalculations = false, +}: ColorPaletteConfiguratorProps) => { + const palettePrefix = `--${eccgui}-color-palette-`; + const userInputDelayTime = 500; + const correctionStep = 0.01; + let userInputDelay; // timeout id + const refConfigurator = React.useRef(null); + const [calculateDistanceWarnings, setCalculateDistanceWarnings] = React.useState(enableCalculations); + const [calculateContrastWarnings, setCalculateContrastWarnings] = React.useState(enableCalculations); + const [minimalDistance, setMinimalDistance] = React.useState(distanceMin); + const [minimalContrast, setMinimalContrast] = React.useState(contrastMin); + const [paletteData, setPaletteData] = React.useState(undefined); + const userPaletteRef = React.useRef(null); + + const createPaletteData = (csscustomprops: string | undefined) => { + const colors = ( + csscustomprops + ? csscustomprops.split(";").map((rule: string) => { + return rule.split(":").map((rulepart: string) => { + return rulepart.trim(); + }); + }) + : new CssCustomProperties({ + selectorText: `:root`, + filterName: (name: string) => { + return name.includes(palettePrefix); + }, + removeDashPrefix: false, + returnObject: false, + }).customProperties() + ) + .filter((colorconfig: object) => { + if (!Array.isArray(colorconfig)) { + return false; + } + if (colorconfig.length !== 2) { + return false; + } + return true; + }) + .map((colorconfig: object) => { + return [colorconfig[0].replace(palettePrefix, ""), Color(colorconfig[1]).rgb()]; + }); + + const data = new Object(); + + for (const [key, value] of colors) { + const hierarchy = key.split("-"); + if (!data[hierarchy[0]]) { + data[hierarchy[0]] = new Object(); + } + if (!data[hierarchy[0]][hierarchy[1]]) { + data[hierarchy[0]][hierarchy[1]] = new Object(); + } + if (!data[hierarchy[0]][hierarchy[1]][hierarchy[2]]) { + data[hierarchy[0]][hierarchy[1]][hierarchy[2]] = value; + } + } + + return data; + }; + + const createCustomPropsSerialization = (data: object) => { + let serialization = ""; + for (const [group, tints] of Object.entries(data)) { + for (const [tint, weights] of Object.entries(tints as object)) { + for (const [weight, value] of Object.entries(weights)) { + serialization = + serialization + + `--${eccgui}-color-palette-${group}-${tint}-${weight}: ${(value as Color).hex()};\n`; + } + } + } + return serialization.trim(); + }; + + const createSassSerialization = (data: object) => { + const createTintData = (tint: string, weights: object) => { + return `\t\t"${tint}": eccgui-create-color-tints(${Object.values(weights) + .map((color) => color.hex()) + .join(" ")}),\n`; + }; + + const createGroupData = (group: string, tints: object) => { + let groupData = `\t"${group}": (\n`; + for (const [tint, weights] of Object.entries(tints)) { + groupData = groupData + createTintData(tint, weights); + } + return groupData + `\t),\n`; + }; + + let sassData = `$eccgui-color-palette-light: (\n`; + + for (const [group, tints] of Object.entries(data)) { + sassData = sassData + createGroupData(group, tints); + } + + return sassData + `) !default;`; + }; + + React.useEffect(() => { + if (refConfigurator.current) { + const panelConfig = document.getElementById("bp5-tab-panel_colorconfig_editor"); + if (panelConfig) { + const warnings = Array.from(panelConfig.getElementsByClassName("eccgui-badge")) + .map((warning: Element) => { + return (warning as HTMLElement).textContent; + }) + .reduce((partial, value) => { + return partial + parseInt(value ?? ""); + }, 0 as number); + const warningsTarget = document.getElementById("sumWarnings"); + if (warningsTarget) { + if (warnings > 0) { + render({warnings}, warningsTarget); + } else { + render(<>, warningsTarget); + } + } + } + } + }); + + React.useEffect(() => { + const paletteData = createPaletteData(customColorProperties); + setPaletteData(paletteData); + }, [customColorProperties]); + + React.useEffect(() => { + if (userPaletteRef && userPaletteRef.current) { + userPaletteRef.current.value = createCustomPropsSerialization(paletteData || {}); + } + }, [paletteData]); + + const fixColorByLuminosity = ( + color: Color, + colorTest: Color, + testFn: (color1: Color, color2: Color) => boolean + ) => { + let fixedColor = color as Color; + let check = testFn(fixedColor, colorTest); + while (check === true && fixedColor.luminosity() > 0 && fixedColor.luminosity() < 1) { + if (fixedColor.luminosity() < (colorTest as Color).luminosity()) { + fixedColor = fixedColor.darken(correctionStep); + } else { + fixedColor = fixedColor.lighten(correctionStep); + } + check = testFn(fixedColor, colorTest); + } + + return fixedColor; + }; + + const createWarnings = (id: string[], colors: object) => { + if ( + (!calculateDistanceWarnings && !calculateContrastWarnings) || + !colors[id[0]] || + !colors[id[0]][id[1]] || + !colors[id[0]][id[1]][id[2]] + ) { + return undefined; + } + const color = colors[id[0]][id[1]][id[2]]; + const warningsDistance: React.ReactElement[] = []; + const warningsContrast: React.ReactElement[] = []; + for (const [group, tints] of Object.entries(colors)) { + for (const [tint, weights] of Object.entries(tints as object)) { + for (const [weight, value] of Object.entries(weights)) { + if (color.hex().toString() !== (value as Color).hex().toString()) { + if (calculateDistanceWarnings) { + // color distance + const distance = utils.colorCalculateDistance({ color1: color, color2: value as Color }); + if (distance && distance < minimalDistance) { + warningsDistance.push( + + Fix with{" "} + {tint + weight} ( + {distance.toPrecision(2)}) + + } + > + + Fix{" "} + + {`${id[1]}}${id[2]}}`} + + + } + onClick={() => { + colors[id[0]][id[1]][id[2]] = fixColorByLuminosity( + color, + value as Color, + (c1, c2) => { + const distance = + utils.colorCalculateDistance({ color1: c1, color2: c2 }) ?? + 0; + // eslint-disable-next-line no-console + console.log(`${c1.hex()} -> ${distance}`); + return distance < minimalDistance; + } + ); + setPaletteData({ ...colors }); + }} + /> + + Fix{" "} + + {`${tint}${weight}`} + + + } + onClick={() => { + colors[group][tint][weight] = fixColorByLuminosity( + value as Color, + color, + (c1, c2) => { + const distance = + utils.colorCalculateDistance({ color1: c1, color2: c2 }) ?? + 0; + // eslint-disable-next-line no-console + console.log(`${c1.hex()} -> ${distance}`); + return distance < minimalDistance; + } + ); + setPaletteData({ ...colors }); + }} + /> + + ); + } + } + if (calculateContrastWarnings) { + // color contrasts + if ( + // test to text/background colors in identity group + (group === "identity" && (tint === "text" || tint === "background")) || + // test to same color tint + (group === id[0] && tint === id[1]) + ) { + if ( + // only calculate light versions to dark versions b/c other usage combination would not make sense at all + (color.isDark() && (value as Color).isLight()) || + (color.isLight() && (value as Color).isDark()) + ) { + if (color.contrast(value as Color) < minimalContrast) { + warningsContrast.push( + + Fix with{" "} + + {`${tint}${weight}`} ( + {color.contrast(value as Color).toPrecision(2)}) + + + } + > + + Fix{" "} + + {`${id[1]}}${id[2]}}`} + + + } + onClick={() => { + colors[id[0]][id[1]][id[2]] = fixColorByLuminosity( + color, + value as Color, + (c1, c2) => { + const contrast = c1.contrast(c2 as Color); + // eslint-disable-next-line no-console + console.log(`${c1.hex()} -> ${contrast}`); + return contrast < minimalContrast; + } + ); + setPaletteData({ ...colors }); + }} + /> + + Fix{" "} + + {`${tint}${weight}`} + + + } + onClick={() => { + colors[group][tint][weight] = fixColorByLuminosity( + value as Color, + color, + (c1, c2) => { + const contrast = c1.contrast(c2 as Color); + // eslint-disable-next-line no-console + console.log(`${c1.hex()} -> ${contrast}`); + return contrast < minimalContrast; + } + ); + setPaletteData({ ...colors }); + }} + /> + + ); + } + } + } + } + } + } + } + } + return warningsDistance.length + warningsContrast.length > 0 ? ( + + } + > + {warningsDistance.length > 0 ? Distances : <>} + <>{warningsDistance} + {warningsContrast.length > 0 ? Contrasts : <>} + <>{warningsContrast} + + ) : undefined; + }; + + const renderColorInput = ( + paletteData: object = {}, + label: string, + id: string[], + updateFn: (color: string) => void + ) => { + if (!paletteData[id[0]] || !paletteData[id[0]][id[1]] || !paletteData[id[0]][id[1]][id[2]]) { + return <>; + } + const color = paletteData[id[0]][id[1]][id[2]]; + const menuWarnings = createWarnings(id, paletteData); + return ( + + { + if (userInputDelay) { + clearTimeout(userInputDelay); + } + userInputDelay = setTimeout(() => { + updateFn(newcolor); + }, userInputDelayTime); + }} + intent={menuWarnings ? "warning" : undefined} + rightElement={menuWarnings} + /> + + ); + }; + + const editorPanel = ( +
+ + + { + if (userInputDelay) { + clearTimeout(userInputDelay); + } + userInputDelay = setTimeout(() => { + setMinimalDistance(parseInt(value, 10)); + }, userInputDelayTime); + }} + rightElement={ + setCalculateDistanceWarnings(!calculateDistanceWarnings)} + /> + } + /> + + + { + if (userInputDelay) { + clearTimeout(userInputDelay); + } + userInputDelay = setTimeout(() => { + setMinimalContrast(parseFloat(value)); + }, userInputDelayTime); + }} + rightElement={ + setCalculateContrastWarnings(!calculateContrastWarnings)} + /> + } + /> + + + {paletteData && + Object.keys(paletteData).map((group, id) => { + return ( +
+ + {group} + + + {Object.keys(paletteData[group]).map((tint, id) => { + return ( + + + + {Object.keys(paletteData[group][tint]).map((weight) => { + return renderColorInput( + paletteData, + `${tint}${weight}`, + [group, tint, weight], + (newcolor) => { + paletteData[group][tint][weight] = Color(newcolor).rgb(); + setPaletteData({ ...paletteData }); + } + ); + })} + + + + { + const tintValues = Object.values( + paletteData[group][tint] + ) as Color[]; + const tintKeys = Object.keys(paletteData[group][tint]); + if (tintValues.length > 0) { + const tint100 = tintValues[0]; + const tint900 = tintValues[tintValues.length - 1]; + tintKeys.forEach((weight, id) => { + paletteData[group][tint][weight] = Color(tint100).mix( + Color(tint900), + id / (tintValues.length - 1) + ); + // eslint-disable-next-line no-console + console.log( + `mix ${Color(tint100).hex()} with ${Color( + tint900 + ).hex()} by ${id / (tintValues.length - 1)} -> ${ + paletteData[group][tint][weight] + }` + ); + }); + } + setPaletteData({ ...paletteData }); + }} + /> + + + ); + })} + +
+ ); + })} +
+ ); + + return ( + +
+ {}} + tabs={[ + { + id: "editor", + panel: editorPanel, + title: } />, + }, + { + id: "css", + panel: ( +
+