From f7f468876560c643f46b1218311a4023cf30e1eb Mon Sep 17 00:00:00 2001 From: provi Date: Tue, 16 Jun 2026 23:31:48 -0700 Subject: [PATCH] [lexical-utils] Make the fake-selection rect color configurable (#7492) markSelection's default styler hardcodes background: 'Highlight', so a consumer that wants a different selection color has to re-derive the whole styler. This adds an optional `colors` argument ({ background }) to markSelection, selectionAlwaysOnDisplay, and SelectionAlwaysOnDisplayExtension, and exposes a createDefaultOnReposition(colors) factory so a custom onReposition can reuse the default rect styling with a different color. Defaults to 'Highlight', so every current call site is byte-identical. The other half of #7492 (the selection going invisible over text that has its own background, e.g. code blocks) is a paint-order problem rather than a color choice; the contrast measurement and the proposed fix are in the PR description. --- .../src/SelectionAlwaysOnDisplayExtension.ts | 13 +++- .../lexical-utils/flow/LexicalUtils.js.flow | 8 +++ .../unit/createDefaultOnReposition.test.ts | 34 ++++++++++ packages/lexical-utils/src/index.ts | 6 +- packages/lexical-utils/src/markSelection.ts | 66 ++++++++++++------- .../src/selectionAlwaysOnDisplay.ts | 5 +- 6 files changed, 103 insertions(+), 29 deletions(-) create mode 100644 packages/lexical-utils/src/__tests__/unit/createDefaultOnReposition.test.ts diff --git a/packages/lexical-extension/src/SelectionAlwaysOnDisplayExtension.ts b/packages/lexical-extension/src/SelectionAlwaysOnDisplayExtension.ts index 558c7a85131..44b24b46505 100644 --- a/packages/lexical-extension/src/SelectionAlwaysOnDisplayExtension.ts +++ b/packages/lexical-extension/src/SelectionAlwaysOnDisplayExtension.ts @@ -6,7 +6,10 @@ * */ -import {selectionAlwaysOnDisplay} from '@lexical/utils'; +import { + type MarkSelectionColors, + selectionAlwaysOnDisplay, +} from '@lexical/utils'; import {defineExtension, safeCast} from 'lexical'; import {namedSignals} from './namedSignals'; @@ -15,6 +18,7 @@ import {effect} from './signals'; export interface SelectionAlwaysOnDisplayConfig { disabled: boolean; onReposition: undefined | ((node: readonly HTMLElement[]) => void); + colors: undefined | MarkSelectionColors; } /** @@ -25,6 +29,7 @@ export const SelectionAlwaysOnDisplayExtension = /* @__PURE__ */ defineExtension({ build: (editor, config, state) => namedSignals(config), config: /* @__PURE__ */ safeCast({ + colors: undefined, disabled: false, onReposition: undefined, }), @@ -33,7 +38,11 @@ export const SelectionAlwaysOnDisplayExtension = const stores = state.getOutput(); return effect(() => { if (!stores.disabled.value) { - return selectionAlwaysOnDisplay(editor, stores.onReposition.value); + return selectionAlwaysOnDisplay( + editor, + stores.onReposition.value, + stores.colors.value, + ); } }); }, diff --git a/packages/lexical-utils/flow/LexicalUtils.js.flow b/packages/lexical-utils/flow/LexicalUtils.js.flow index 5237b99c193..f8666a1e0fa 100644 --- a/packages/lexical-utils/flow/LexicalUtils.js.flow +++ b/packages/lexical-utils/flow/LexicalUtils.js.flow @@ -86,9 +86,16 @@ declare export function $findMatchingParent( findFn: (LexicalNode) => boolean, ): LexicalNode | null; declare export function mergeRegister(...func: (() => void)[]): () => void; +export type MarkSelectionColors = { + background?: string, +}; +declare export function createDefaultOnReposition( + colors?: MarkSelectionColors, +): (nodes: HTMLElement[]) => void; declare export function markSelection( editor: LexicalEditor, onReposition?: (node: HTMLElement[]) => void, + colors?: MarkSelectionColors, ): () => void; declare export function positionNodeOnRange( editor: LexicalEditor, @@ -98,6 +105,7 @@ declare export function positionNodeOnRange( declare export function selectionAlwaysOnDisplay( editor: LexicalEditor, onReposition?: (node: HTMLElement[]) => void, + colors?: MarkSelectionColors, ): () => void; declare export function $getNearestBlockElementAncestorOrThrow( startNode: LexicalNode, diff --git a/packages/lexical-utils/src/__tests__/unit/createDefaultOnReposition.test.ts b/packages/lexical-utils/src/__tests__/unit/createDefaultOnReposition.test.ts new file mode 100644 index 00000000000..dd01a082985 --- /dev/null +++ b/packages/lexical-utils/src/__tests__/unit/createDefaultOnReposition.test.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {createDefaultOnReposition} from '@lexical/utils'; +import {describe, expect, it} from 'vitest'; + +describe('createDefaultOnReposition', () => { + it('defaults the rect background to the system Highlight color', () => { + const defaultNode = document.createElement('div'); + createDefaultOnReposition()([defaultNode]); + const explicitNode = document.createElement('div'); + createDefaultOnReposition({background: 'Highlight'})([explicitNode]); + expect(defaultNode.style.background).toBe(explicitNode.style.background); + }); + + it('applies a custom background color when one is given', () => { + const node = document.createElement('div'); + createDefaultOnReposition({background: 'rgb(0, 100, 200)'})([node]); + expect(node.style.background).toBe('rgb(0, 100, 200)'); + }); + + it('keeps the margin and padding offsets regardless of color', () => { + const node = document.createElement('div'); + createDefaultOnReposition({background: 'Highlight'})([node]); + expect(node.style.marginTop).toBe('-1.5px'); + expect(node.style.paddingTop).toBe('4px'); + expect(node.style.paddingBottom).toBe('0px'); + }); +}); diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index cb0d1542fee..26d086e6207 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -78,7 +78,11 @@ import { ValueOrUpdater, } from 'lexical'; -export {default as markSelection} from './markSelection'; +export { + createDefaultOnReposition, + default as markSelection, + type MarkSelectionColors, +} from './markSelection'; export {default as positionNodeOnRange} from './positionNodeOnRange'; export {default as selectionAlwaysOnDisplay} from './selectionAlwaysOnDisplay'; export { diff --git a/packages/lexical-utils/src/markSelection.ts b/packages/lexical-utils/src/markSelection.ts index 4dacb1243aa..929a48be0ae 100644 --- a/packages/lexical-utils/src/markSelection.ts +++ b/packages/lexical-utils/src/markSelection.ts @@ -65,26 +65,46 @@ function $rangeFromPoints( return range; } -function defaultOnReposition(domNodes: readonly HTMLElement[]): void { - for (const domNode of domNodes) { - const domNodeStyle = domNode.style; +export interface MarkSelectionColors { + /** + * CSS color for the selection rect background. Defaults to the system + * `'Highlight'` color, which preserves the previous behavior. + */ + background?: string; +} - if (domNodeStyle.background !== 'Highlight') { - domNodeStyle.background = 'Highlight'; - } - if (domNodeStyle.color !== 'HighlightText') { - domNodeStyle.color = 'HighlightText'; - } - if (domNodeStyle.marginTop !== px(-1.5)) { - domNodeStyle.marginTop = px(-1.5); - } - if (domNodeStyle.paddingTop !== px(4)) { - domNodeStyle.paddingTop = px(4); - } - if (domNodeStyle.paddingBottom !== px(0)) { - domNodeStyle.paddingBottom = px(0); +/** + * Build the default `onReposition` styler used by {@link markSelection}, + * optionally overriding the selection rect color. Exposed so a consumer that + * passes its own `onReposition` can reuse the default rect styling with a + * different color, instead of re-deriving the margin and padding offsets. + */ +export function createDefaultOnReposition( + colors?: MarkSelectionColors, +): (domNodes: readonly HTMLElement[]) => void { + const background = + colors && colors.background != null ? colors.background : 'Highlight'; + return domNodes => { + for (const domNode of domNodes) { + const domNodeStyle = domNode.style; + + if (domNodeStyle.background !== background) { + domNodeStyle.background = background; + } + if (domNodeStyle.color !== 'HighlightText') { + domNodeStyle.color = 'HighlightText'; + } + if (domNodeStyle.marginTop !== px(-1.5)) { + domNodeStyle.marginTop = px(-1.5); + } + if (domNodeStyle.paddingTop !== px(4)) { + domNodeStyle.paddingTop = px(4); + } + if (domNodeStyle.paddingBottom !== px(0)) { + domNodeStyle.paddingBottom = px(0); + } } - } + }; } /** @@ -97,8 +117,10 @@ function defaultOnReposition(domNodes: readonly HTMLElement[]): void { */ export default function markSelection( editor: LexicalEditor, - onReposition: (node: readonly HTMLElement[]) => void = defaultOnReposition, + onReposition?: (node: readonly HTMLElement[]) => void, + colors?: MarkSelectionColors, ): () => void { + const reposition = onReposition ?? createDefaultOnReposition(colors); let previousAnchorNode: null | TextNode | ElementNode = null; let previousAnchorNodeDOM: null | HTMLElement = null; let previousAnchorOffset: null | number = null; @@ -154,11 +176,7 @@ export default function markSelection( currentEndNodeDOM, ); removeRangeListener(); - removeRangeListener = positionNodeOnRange( - editor, - range, - onReposition, - ); + removeRangeListener = positionNodeOnRange(editor, range, reposition); } previousAnchorNode = currentStartNode; previousAnchorNodeDOM = currentStartNodeDOM; diff --git a/packages/lexical-utils/src/selectionAlwaysOnDisplay.ts b/packages/lexical-utils/src/selectionAlwaysOnDisplay.ts index 40edb092771..355edb4510f 100644 --- a/packages/lexical-utils/src/selectionAlwaysOnDisplay.ts +++ b/packages/lexical-utils/src/selectionAlwaysOnDisplay.ts @@ -8,11 +8,12 @@ import {LexicalEditor} from 'lexical'; -import markSelection from './markSelection'; +import markSelection, {type MarkSelectionColors} from './markSelection'; export default function selectionAlwaysOnDisplay( editor: LexicalEditor, onReposition?: (node: readonly HTMLElement[]) => void, + colors?: MarkSelectionColors, ): () => void { let removeSelectionMark: (() => void) | null = null; @@ -33,7 +34,7 @@ export default function selectionAlwaysOnDisplay( } } else { if (removeSelectionMark === null) { - removeSelectionMark = markSelection(editor, onReposition); + removeSelectionMark = markSelection(editor, onReposition, colors); } } };