@atlaskit/editor-plugin-text-color
Version:
Text color plugin for @atlaskit/editor-core
342 lines (335 loc) • 15.3 kB
JavaScript
/* ColorAccessibilityMenuItem.tsx generated by @compiled/babel-plugin v2.0.0 */
import "./ColorAccessibilityMenuItem.compiled.css";
import { ax, ix } from "@compiled/react/runtime";
import React from 'react';
import { useIntl } from 'react-intl';
import { PanelType } from '@atlaskit/adf-schema/panel';
import IconButton from '@atlaskit/button/icon/button';
import { cx } from '@atlaskit/css';
import { useSharedPluginStateWithSelector } from '@atlaskit/editor-common/hooks';
import { colorAccessibilityMessages as messages } from '@atlaskit/editor-common/messages';
import { getPanelTypeBackgroundNoTokens } from '@atlaskit/editor-common/panel';
import { useEditorToolbar } from '@atlaskit/editor-common/toolbar';
import { getTextColorInNonActiveTheme, getTokenCSSVariableValue, getTokenCSSVariableValueForNonActiveTheme } from '@atlaskit/editor-common/ui-color';
import { hexToEditorBackgroundPaletteColor, hexToEditorTextBackgroundPaletteColor, hexToEditorTextPaletteColor } from '@atlaskit/editor-palette';
import { TextSelection } from '@atlaskit/editor-prosemirror/state';
import AccessibilityIcon from '@atlaskit/icon/core/accessibility';
import QuestionCircleIcon from '@atlaskit/icon/core/question-circle';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import { Box, Inline, Text } from '@atlaskit/primitives/compiled';
import { getTokenValue } from '@atlaskit/tokens/get-token-value';
import { getContrastRatio as calcContrastRatio } from '../pm-plugins/utils/color-contrast';
import { DEFAULT_COLOR, DEFAULT_BACKGROUND_COLOR, ACCESSIBLE_CONTRAST_RATIO, DIFFICULT_CONTRAST_RATIO } from '../pm-plugins/utils/constants';
const resolveColorValue = (color, fallback) => {
return getTokenCSSVariableValue(color) || (color.startsWith('var(') ? fallback : color);
};
const getForegroundColor = (textColor, defaultColor) => {
if (!textColor || defaultColor && textColor === defaultColor) {
return getTokenValue('color.text', defaultColor || DEFAULT_COLOR.color);
}
const colorValue = hexToEditorTextPaletteColor(textColor) || textColor;
return resolveColorValue(colorValue, textColor);
};
const getBackgroundColor = (backgroundColor, palette = 'text') => {
if (!backgroundColor || backgroundColor === 'none') {
return getTokenValue('elevation.surface', DEFAULT_BACKGROUND_COLOR);
}
const colorValue = palette === 'element' ? hexToEditorBackgroundPaletteColor(backgroundColor) || backgroundColor : hexToEditorTextBackgroundPaletteColor(backgroundColor) || backgroundColor;
return resolveColorValue(colorValue, backgroundColor);
};
const getBackgroundColorInNonActiveTheme = (backgroundColor, palette = 'text') => {
if (!backgroundColor || backgroundColor === 'none') {
return getTokenCSSVariableValueForNonActiveTheme("var(--ds-surface, #FFFFFF)", DEFAULT_BACKGROUND_COLOR);
}
const colorValue = palette === 'element' ? hexToEditorBackgroundPaletteColor(backgroundColor) || backgroundColor : hexToEditorTextBackgroundPaletteColor(backgroundColor) || backgroundColor;
return getTokenCSSVariableValueForNonActiveTheme(colorValue, backgroundColor);
};
const STANDARD_PANEL_BACKGROUND_COLORS = new Map([[PanelType.INFO, getPanelTypeBackgroundNoTokens(PanelType.INFO)], [PanelType.NOTE, getPanelTypeBackgroundNoTokens(PanelType.NOTE)], [PanelType.SUCCESS, getPanelTypeBackgroundNoTokens(PanelType.SUCCESS)], [PanelType.WARNING, getPanelTypeBackgroundNoTokens(PanelType.WARNING)], [PanelType.ERROR, getPanelTypeBackgroundNoTokens(PanelType.ERROR)]]);
const getResolvedBackgroundFromHighlight = highlightColor => {
if (!highlightColor) {
return {
color: null,
source: 'text'
};
}
return {
color: highlightColor,
source: 'text'
};
};
/**
* Resolves a background color from a container node that paints behind text.
* Callers walk ancestors so the nearest painted container wins.
*/
const getSupportedAncestorBackgroundColor = node => {
let backgroundColor;
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
backgroundColor = node.attrs.background;
} else if (node.type.name === 'panel' || node.type.name === 'panel_c1') {
if (node.attrs.panelType === PanelType.CUSTOM) {
backgroundColor = node.attrs.panelColor;
} else {
backgroundColor = STANDARD_PANEL_BACKGROUND_COLORS.get(node.attrs.panelType);
}
}
return backgroundColor ? {
color: backgroundColor,
source: 'element'
} : null;
};
/**
* Resolves the visible background behind a text node.
* Text highlight wins before falling back to the nearest element background.
*/
export const getResolvedBackgroundColorForTextNode = ($pos, node) => {
for (const mark of node.marks) {
if (mark.type.name === 'backgroundColor' && mark.attrs.color) {
return {
color: mark.attrs.color,
source: 'text'
};
}
}
for (let depth = $pos.depth; depth > 0; depth--) {
const resolvedBackgroundColor = getSupportedAncestorBackgroundColor($pos.node(depth));
if (resolvedBackgroundColor) {
return resolvedBackgroundColor;
}
}
return {
color: null,
source: 'text'
};
};
const getResolvedBackgroundColorForPosition = ($pos, highlightColor) => {
const highlightBackground = getResolvedBackgroundFromHighlight(highlightColor);
if (highlightColor) {
return highlightBackground;
}
for (let depth = $pos.depth; depth > 0; depth--) {
const resolvedBackgroundColor = getSupportedAncestorBackgroundColor($pos.node(depth));
if (resolvedBackgroundColor) {
return resolvedBackgroundColor;
}
}
return highlightBackground;
};
const getResolvedBackgroundColorValue = backgroundColor => {
if (backgroundColor.source === 'element') {
return getBackgroundColor(backgroundColor.color, 'element');
}
return getBackgroundColor(backgroundColor.color);
};
const getResolvedBackgroundColorValueInNonActiveTheme = backgroundColor => {
if (backgroundColor.source === 'element') {
return getBackgroundColorInNonActiveTheme(backgroundColor.color, 'element');
}
return getBackgroundColorInNonActiveTheme(backgroundColor.color);
};
const useColorAccessibilityState = api => {
return useSharedPluginStateWithSelector(api, ['textColor', 'highlight'], states => {
var _states$textColorStat, _states$highlightStat, _states$textColorStat2;
return {
defaultColor: (_states$textColorStat = states.textColorState) === null || _states$textColorStat === void 0 ? void 0 : _states$textColorStat.defaultColor,
highlightColor: (_states$highlightStat = states.highlightState) === null || _states$highlightStat === void 0 ? void 0 : _states$highlightStat.activeColor,
textColor: (_states$textColorStat2 = states.textColorState) === null || _states$textColorStat2 === void 0 ? void 0 : _states$textColorStat2.color
};
});
};
const getContrastRatio = (defaultColor, backgroundColor, textColor) => {
try {
const contrastRatio = calcContrastRatio(getForegroundColor(textColor, defaultColor), getResolvedBackgroundColorValue(backgroundColor));
return contrastRatio;
} catch {
// if we failed to calculate the contrast ratio, return null
return null;
}
};
// Computes the contrast ratio as it would appear in the non-active theme.
const getNonActiveThemeContrastRatio = (defaultColor, backgroundColor, textColor) => {
try {
return calcContrastRatio(getTextColorInNonActiveTheme(textColor !== null && textColor !== void 0 ? textColor : null, defaultColor || DEFAULT_COLOR.color), getResolvedBackgroundColorValueInNonActiveTheme(backgroundColor));
} catch {
// if we failed to calculate the contrast ratio, return null
return null;
}
};
const getAccessibilityStatus = contrastRatio => {
if (contrastRatio >= ACCESSIBLE_CONTRAST_RATIO) {
return 'accessible';
} else if (contrastRatio >= DIFFICULT_CONTRAST_RATIO) {
return 'difficultToRead';
} else {
return 'inaccessible';
}
};
/**
* Computes status from active foreground/background colors only.
* Used by the gate-off path so legacy behavior stays isolated.
*/
const getAccessibilityStatusFromColorPair = (defaultColor, backgroundColor, textColor) => {
const contrastRatio = getContrastRatio(defaultColor, backgroundColor, textColor);
return contrastRatio === null ? null : getAccessibilityStatus(contrastRatio);
};
/**
* Returns the lowest contrast ratio across active and non-active themes.
*/
const getMostCriticalContrastRatioAcrossThemes = (defaultColor, backgroundColor, textColor) => {
const contrastRatio = getContrastRatio(defaultColor, backgroundColor, textColor);
const nonActiveThemeContrastRatio = getNonActiveThemeContrastRatio(defaultColor, backgroundColor, textColor);
if (contrastRatio !== null && nonActiveThemeContrastRatio !== null) {
return Math.min(contrastRatio, nonActiveThemeContrastRatio);
}
return contrastRatio;
};
const getAccessibilityStatusFromColorPairAcrossThemes = (defaultColor, backgroundColor, textColor) => {
const mostCriticalContrastRatio = getMostCriticalContrastRatioAcrossThemes(defaultColor, backgroundColor, textColor);
return mostCriticalContrastRatio === null ? null : getAccessibilityStatus(mostCriticalContrastRatio);
};
/**
* Collects the worst accessibility status across all unique foreground/background
* combinations in the current selection.
*
* When the selection spans multiple text colors and/or resolved backgrounds,
* this checks each unique pair across both the active and non-active theme and
* returns the worst status found.
*/
const getWorstAccessibilityStatusFromEditorState = (state, defaultColor) => {
let worstContrastRatio = null;
// Text/highlight colors only apply to a text range. If the selection is
// not a text selection (e.g. a node, cell or all selection) there is no
// meaningful range of colored text to evaluate.
if (!(state.selection instanceof TextSelection) || state.selection.empty) {
return null;
}
const {
from,
to
} = state.selection;
const seen = new Set();
state.doc.nodesBetween(from, to, (node, pos) => {
var _textColor, _backgroundColor$colo;
if (!node.isText) {
return !node.isLeaf;
}
let textColor = null;
for (const mark of node.marks) {
if (mark.type.name === 'textColor') {
var _mark$attrs$color;
textColor = (_mark$attrs$color = mark.attrs.color) !== null && _mark$attrs$color !== void 0 ? _mark$attrs$color : null;
}
}
const backgroundColor = getResolvedBackgroundColorForTextNode(state.doc.resolve(pos), node);
const pairKey = `${(_textColor = textColor) !== null && _textColor !== void 0 ? _textColor : 'default'}|${backgroundColor.source}|${(_backgroundColor$colo = backgroundColor.color) !== null && _backgroundColor$colo !== void 0 ? _backgroundColor$colo : 'default'}`;
// Skip foreground/background pairs already evaluated. The source is
// part of the key because text highlights and element backgrounds use
// different editor palettes.
if (seen.has(pairKey)) {
return;
}
seen.add(pairKey);
const mostCriticalContrastRatio = getMostCriticalContrastRatioAcrossThemes(defaultColor, backgroundColor, textColor);
if (mostCriticalContrastRatio === null) {
return;
}
if (worstContrastRatio === null || mostCriticalContrastRatio < worstContrastRatio) {
worstContrastRatio = mostCriticalContrastRatio;
}
});
return worstContrastRatio === null ? null : getAccessibilityStatus(worstContrastRatio);
};
export const getAccessibilityStatusForCurrentSelection = (state, defaultColor, highlightColor, textColor) => {
if (state.selection instanceof TextSelection) {
if (!state.selection.empty) {
return getWorstAccessibilityStatusFromEditorState(state, defaultColor);
}
return getAccessibilityStatusFromColorPairAcrossThemes(defaultColor, getResolvedBackgroundColorForPosition(state.selection.$from, highlightColor), textColor);
}
return null;
};
const AccessibilityStatus = ({
accessibilityStatus,
formatMessage
}) => {
if (accessibilityStatus === 'accessible') {
return /*#__PURE__*/React.createElement(Text, {
as: "span",
size: "small",
color: "color.text.success"
}, formatMessage(messages.accessibleLabel));
} else if (accessibilityStatus === 'difficultToRead') {
return /*#__PURE__*/React.createElement(Text, {
as: "span",
size: "small",
color: "color.text.warning"
}, formatMessage(messages.difficultToReadLabel));
} else {
return /*#__PURE__*/React.createElement(Text, {
as: "span",
size: "small",
color: "color.text.danger"
}, formatMessage(messages.inaccessibleLabel));
}
};
const styles = {
container: "_19itglyw _19pku2gc _ca0q1b66 _n3td1b66 _19bvu2gc _u5f31b66 _1e0c1txw _4cvr1h6o _1bah1yb4",
containerPatch: "_19pkze3t _ca0qze3t _n3tdze3t _19bvze3t _u5f3ze3t"
};
export const ColorAccessibilityMenuItem = ({
api
}) => {
const {
formatMessage
} = useIntl();
const {
defaultColor,
highlightColor,
textColor
} = useColorAccessibilityState(api);
const {
editorView
} = useEditorToolbar();
const editorState = editorView === null || editorView === void 0 ? void 0 : editorView.state;
const accessibilityStatus = React.useMemo(() => editorState && fg('platform_editor_lovability_text_bg_color_patch_1') ? getAccessibilityStatusForCurrentSelection(editorState, defaultColor, highlightColor, textColor) : getAccessibilityStatusFromColorPair(defaultColor, getResolvedBackgroundFromHighlight(highlightColor), textColor), [defaultColor, editorState, highlightColor, textColor]);
if (accessibilityStatus === null) {
return /*#__PURE__*/React.createElement(React.Fragment, null);
}
const tooltipContent = accessibilityStatus => {
if (accessibilityStatus === 'accessible') {
return formatMessage(messages.accessibleTooltip);
} else if (accessibilityStatus === 'difficultToRead') {
return formatMessage(messages.difficultToReadTooltip);
} else {
return formatMessage(messages.inaccessibleTooltip);
}
};
return /*#__PURE__*/React.createElement(Box, {
xcss: cx(styles.container, fg('platform_editor_lovability_text_bg_color_patch_1') && styles.containerPatch)
}, /*#__PURE__*/React.createElement(Inline, {
alignBlock: "center",
space: "space.050"
}, /*#__PURE__*/React.createElement(AccessibilityIcon, {
label: "",
size: "medium",
color: fg('platform_editor_lovability_text_bg_color_patch_1') ? "var(--ds-icon-subtle, #505258)" : undefined
}), /*#__PURE__*/React.createElement(Text, {
as: "span",
size: "small",
color: "color.text.subtle"
}, formatMessage(messages.accessibility)), /*#__PURE__*/React.createElement(Text, {
as: "span",
size: "small",
color: "color.text.subtle",
"aria-hidden": "true"
}, "\u2022"), /*#__PURE__*/React.createElement(AccessibilityStatus, {
accessibilityStatus: accessibilityStatus,
formatMessage: formatMessage
})), /*#__PURE__*/React.createElement(IconButton, {
icon: QuestionCircleIcon,
shape: "circle",
label: tooltipContent(accessibilityStatus),
isTooltipDisabled: false,
spacing: "compact",
appearance: "subtle"
}));
};