UNPKG

@atlaskit/editor-plugin-text-color

Version:

Text color plugin for @atlaskit/editor-core

294 lines (284 loc) 14.5 kB
/* ColorAccessibilityMenuItem.tsx generated by @compiled/babel-plugin v0.39.1 */ import "./ColorAccessibilityMenuItem.compiled.css"; import { ax, ix } from "@compiled/react/runtime"; import React from 'react'; import { useIntl } from 'react-intl'; import { IconButton } from '@atlaskit/button/new'; import { cx } from '@atlaskit/css'; import { useSharedPluginStateWithSelector } from '@atlaskit/editor-common/hooks'; import { colorAccessibilityMessages as messages } from '@atlaskit/editor-common/messages'; import { getHighlightColorInNonActiveTheme, getTextColorInNonActiveTheme, getTokenCSSVariableValue } from '@atlaskit/editor-common/ui-color'; import { 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'; 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, TRANSPARENT_HIGHLIGHT_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 = highlightColor => { if (!highlightColor || highlightColor === TRANSPARENT_HIGHLIGHT_COLOR) { return getTokenValue('elevation.surface', DEFAULT_BACKGROUND_COLOR); } const colorValue = hexToEditorTextBackgroundPaletteColor(highlightColor) || highlightColor; return resolveColorValue(colorValue, highlightColor); }; const useColorAccessibilityState = api => { return useSharedPluginStateWithSelector(api, ['textColor', 'highlight'], states => { var _states$textColorStat, _states$highlightStat, _states$highlightStat2, _states$highlightStat3, _states$textColorStat2, _states$textColorStat3, _states$textColorStat4; 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, highlightColorInNonActiveTheme: (_states$highlightStat2 = states.highlightState) === null || _states$highlightStat2 === void 0 ? void 0 : _states$highlightStat2.activeColorInNonActiveTheme, isMultiHighlightColor: (_states$highlightStat3 = states.highlightState) === null || _states$highlightStat3 === void 0 ? void 0 : _states$highlightStat3.isMultiHighlightColor, isMultiTextColor: (_states$textColorStat2 = states.textColorState) === null || _states$textColorStat2 === void 0 ? void 0 : _states$textColorStat2.isMultiTextColor, textColor: (_states$textColorStat3 = states.textColorState) === null || _states$textColorStat3 === void 0 ? void 0 : _states$textColorStat3.color, textColorInNonActiveTheme: (_states$textColorStat4 = states.textColorState) === null || _states$textColorStat4 === void 0 ? void 0 : _states$textColorStat4.colorInNonActiveTheme }; }); }; const getContrastRatio = (defaultColor, highlightColor, textColor) => { try { const contrastRatio = calcContrastRatio(getForegroundColor(textColor, defaultColor), getBackgroundColor(highlightColor)); return contrastRatio; } catch { // if we failed to calculate the contrast ratio, return null return null; } }; // Computes the contrast ratio for a (textColor, highlightColor) pair as it would // appear in the non-active theme, using the shared resolvers so the walker // evaluates the same colors the single-pair plugin-state path does. Returns null // if it cannot be calculated. const getNonActiveThemeContrastRatio = (defaultColor, highlightColor, textColor) => { try { return calcContrastRatio(getTextColorInNonActiveTheme(textColor !== null && textColor !== void 0 ? textColor : null, defaultColor || DEFAULT_COLOR.color), getHighlightColorInNonActiveTheme(highlightColor !== null && highlightColor !== void 0 ? highlightColor : null, { defaultBackgroundColor: DEFAULT_BACKGROUND_COLOR, transparentColor: TRANSPARENT_HIGHLIGHT_COLOR })); } 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'; } }; /** * Collects the worst accessibility status across all unique (textColor, highlightColor) * combinations in the current selection. * * When the selection spans multiple text colors and/or highlight colors, * this walks the document between selection boundaries and checks every * text node's color marks, computing the contrast ratio for each unique pair * (across both the active and non-active theme) and returning the worst * status found. */ const getWorstAccessibilityStatusFromSelection = (api, defaultColor) => { if (!(api !== null && api !== void 0 && api.core)) { return null; } // Track the lowest (worst) contrast ratio across all pairs; a lower ratio // means a worse accessibility status, so a single numeric comparison is // enough to find the worst pair. let worstContrastRatio = null; api.core.actions.execute(({ tr }) => { // 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 (!(tr.selection instanceof TextSelection)) { return null; } const { from, to } = tr.selection; const seen = new Set(); tr.doc.nodesBetween(from, to, node => { var _textColor, _highlightColor; // Text and highlight color marks only apply to text nodes, so skip // any non-text leaves (e.g. emoji, mentions, inline cards). Continue // descending into container nodes to reach their text children. if (!node.isText) { return !node.isLeaf; } // Extract both color marks in a single pass over the node's marks // rather than two `Array.find` scans. let textColor = null; let highlightColor = 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; } else if (mark.type.name === 'backgroundColor') { var _mark$attrs$color2; highlightColor = (_mark$attrs$color2 = mark.attrs.color) !== null && _mark$attrs$color2 !== void 0 ? _mark$attrs$color2 : null; } } const pairKey = `${(_textColor = textColor) !== null && _textColor !== void 0 ? _textColor : 'default'}|${(_highlightColor = highlightColor) !== null && _highlightColor !== void 0 ? _highlightColor : 'default'}`; // Skip pairs we have already evaluated so the expensive contrast // calculations run at most once per unique (text, highlight) pair. if (seen.has(pairKey)) { return; } seen.add(pairKey); const contrastRatio = getContrastRatio(defaultColor, highlightColor, textColor); if (contrastRatio === null) { return; } // Account for the non-active theme so the worst-case (least // accessible) contrast across both themes is used for each pair, // keeping multi-color selections consistent with the single-pair path. const nonActiveThemeContrastRatio = getNonActiveThemeContrastRatio(defaultColor, highlightColor, textColor); const mostCriticalContrastRatio = nonActiveThemeContrastRatio !== null ? Math.min(contrastRatio, nonActiveThemeContrastRatio) : contrastRatio; if (worstContrastRatio === null || mostCriticalContrastRatio < worstContrastRatio) { worstContrastRatio = mostCriticalContrastRatio; } }); // Read-only operation — do not dispatch return null; }); return worstContrastRatio === null ? null : getAccessibilityStatus(worstContrastRatio); }; 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, highlightColorInNonActiveTheme, isMultiHighlightColor, isMultiTextColor, textColor, textColorInNonActiveTheme } = useColorAccessibilityState(api); // The multi-color selection handling (walking every unique text/highlight // pair across the active and non-active theme) is only performed when the // patch gate is on. When the gate is off we fall back to the legacy behavior // of only evaluating the single active text/highlight color in the current // active theme const isMultiColorSelection = Boolean(isMultiTextColor || isMultiHighlightColor) && fg('platform_editor_lovability_text_bg_color_patch_1'); // The complement of `isMultiColorSelection`: the single active text/highlight // pair is evaluated whenever we are not walking a multi-color selection. This // covers both a genuinely single-color selection (gate on) and every // selection when the gate is off (legacy behavior). const isSingleColorSelection = !isMultiColorSelection; // Memoize the selection walk so we don't re-traverse the entire ProseMirror // document on every render. It only needs to recompute when the multi-color // selection state or the inputs to the walk change (the selection flags, // default color, or the plugin api). const multiColorStatus = React.useMemo(() => isMultiColorSelection ? getWorstAccessibilityStatusFromSelection(api, defaultColor) : null, // eslint-disable-next-line react-hooks/exhaustive-deps [isMultiColorSelection, isMultiTextColor, isMultiHighlightColor, defaultColor, api]); // The single active text/highlight pair path. Everything below only applies // when we are not walking a multi-color selection let singleColorStatus = null; if (isSingleColorSelection) { const singleColorContrastRatio = getContrastRatio(defaultColor, highlightColor, textColor); // Also account for the non-active theme (gate on only) so the worst-case // (least accessible) status across both themes is shown. let nonActiveThemeSingleColorRatio = null; if (textColorInNonActiveTheme && highlightColorInNonActiveTheme && fg('platform_editor_lovability_text_bg_color_patch_1')) { try { nonActiveThemeSingleColorRatio = calcContrastRatio(textColorInNonActiveTheme, highlightColorInNonActiveTheme); } catch { // if we failed to calculate the contrast ratio, leave as null nonActiveThemeSingleColorRatio = null; } } const mostCriticalContrastRatio = singleColorContrastRatio !== null && nonActiveThemeSingleColorRatio !== null ? Math.min(singleColorContrastRatio, nonActiveThemeSingleColorRatio) : singleColorContrastRatio; singleColorStatus = mostCriticalContrastRatio !== null ? getAccessibilityStatus(mostCriticalContrastRatio) : null; } const accessibilityStatus = isSingleColorSelection ? singleColorStatus : multiColorStatus; 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" })); };