From ecdce70296b655f568d8502ae134440042667c23 Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Mon, 17 Nov 2025 16:37:29 -0600 Subject: [PATCH] Preserve active formats when applying annotations. - Updated the `__experimentalCreatePrepareEditableTree` method to preserve user-defined active formats when applying annotations, ensuring that formats like bold are not lost. - Modified the `addInvisibleFormats` function to pass the entire value object, allowing for better management of active formats. - Enhanced the `getActiveFormats` function to filter out editor-only formats (like annotations) from active formats, ensuring they do not interfere with user selections. - Adjusted the `useRichText` hook to maintain active formats during updates when only formats change. - Updated event listeners to filter out editor-only formats when updating active formats during typing. These changes improve the user experience by maintaining formatting consistency when annotations are applied programmatically. Refs #71698 --- packages/annotations/src/format/annotation.js | 17 ++++++++- .../src/components/rich-text/index.js | 2 +- .../event-listeners/input-and-selection.js | 9 ++++- packages/rich-text/src/component/index.js | 20 ++++++++++ packages/rich-text/src/get-active-formats.js | 37 +++++++++++++++---- 5 files changed, 75 insertions(+), 10 deletions(-) diff --git a/packages/annotations/src/format/annotation.js b/packages/annotations/src/format/annotation.js index d4ade038d23b6c..83bd742dd3f74d 100644 --- a/packages/annotations/src/format/annotation.js +++ b/packages/annotations/src/format/annotation.js @@ -156,13 +156,28 @@ export const annotation = { }; }, __experimentalCreatePrepareEditableTree( { annotations } ) { - return ( formats, text ) => { + return ( formats, text, value ) => { if ( annotations.length === 0 ) { return formats; } let record = { formats, text }; + // Preserve activeFormats when applying annotations so user formats + // (like bold) are not lost when annotations are added programmatically. + const originalActiveFormats = value?.activeFormats + ? value.activeFormats.filter( + ( format ) => format.type !== FORMAT_NAME + ) + : undefined; + if ( originalActiveFormats ) { + record.activeFormats = originalActiveFormats; + } record = applyAnnotations( record, annotations ); + // Restore original activeFormats after applying annotations, since + // annotations are editor-only formats and shouldn't be in activeFormats. + if ( originalActiveFormats ) { + record.activeFormats = originalActiveFormats; + } return record.formats; }; }, diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index a06d7ef9ce0985..cbe9375d909422 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -376,7 +376,7 @@ export function RichTextWrapper( function addInvisibleFormats( value ) { return prepareHandlers.reduce( - ( accumulator, fn ) => fn( accumulator, value.text ), + ( accumulator, fn ) => fn( accumulator, value.text, value ), value.formats ); } diff --git a/packages/rich-text/src/component/event-listeners/input-and-selection.js b/packages/rich-text/src/component/event-listeners/input-and-selection.js index 621f1c59fab04e..7647c91fee4809 100644 --- a/packages/rich-text/src/component/event-listeners/input-and-selection.js +++ b/packages/rich-text/src/component/event-listeners/input-and-selection.js @@ -88,12 +88,18 @@ export default ( props ) => ( element ) => { const currentValue = createRecord(); const { start, activeFormats: oldActiveFormats = [] } = record.current; + // Filter out editor-only formats (like annotations) from activeFormats, + // since they shouldn't be applied when typing. + const filteredActiveFormats = oldActiveFormats.filter( + ( format ) => format.type !== 'core/annotation' + ); + // Update the formats between the last and new caret position. const change = updateFormats( { value: currentValue, start, end: currentValue.start, - formats: oldActiveFormats, + formats: filteredActiveFormats, } ); handleChange( change ); @@ -169,6 +175,7 @@ export default ( props ) => ( element ) => { ); // Update the value with the new active formats. + // getActiveFormats already filters out editor-only formats like annotations. newValue.activeFormats = newActiveFormats; // It is important that the internal value is updated first, diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index 7336eb2e63da8a..9e4935ba2a57ab 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -153,6 +153,12 @@ export function useRichText( { function applyFromProps() { // Get previous value before updating const previousValue = _valueRef.current; + // Preserve activeFormats from current record before updating + const preservedActiveFormats = recordRef.current?.activeFormats + ? recordRef.current.activeFormats.filter( + ( format ) => format.type !== 'core/annotation' + ) + : undefined; setRecordFromProps(); @@ -168,6 +174,20 @@ export function useRichText( { ref.current.ownerDocument.activeElement ); + // Preserve activeFormats when only formats changed (e.g., annotations added), + // not when content length changed or element doesn't have focus. + if ( + preservedActiveFormats && + ! contentLengthChanged && + hasFocus && + recordRef.current.start === recordRef.current.end + ) { + recordRef.current = { + ...recordRef.current, + activeFormats: preservedActiveFormats, + }; + } + // Skip re-applying the selection state when content changed from external source // (e.g., typing in sidebar input changes canvas text) const skipSelection = contentLengthChanged && ! hasFocus; diff --git a/packages/rich-text/src/get-active-formats.js b/packages/rich-text/src/get-active-formats.js index e3bc7d8415de58..065a5ac8bb69cc 100644 --- a/packages/rich-text/src/get-active-formats.js +++ b/packages/rich-text/src/get-active-formats.js @@ -24,20 +24,31 @@ export function getActiveFormats( value, EMPTY_ACTIVE_FORMATS = [] ) { if ( start === end ) { // For a collapsed caret, it is possible to override the active formats. if ( activeFormats ) { - return activeFormats; + // Filter out editor-only formats (like annotations) from activeFormats. + return activeFormats.filter( + ( format ) => format.type !== 'core/annotation' + ); } const formatsBefore = formats[ start - 1 ] || EMPTY_ACTIVE_FORMATS; const formatsAfter = formats[ start ] || EMPTY_ACTIVE_FORMATS; + // Filter out editor-only formats (like annotations) when calculating from formats. + const filteredFormatsBefore = formatsBefore.filter( + ( format ) => format.type !== 'core/annotation' + ); + const filteredFormatsAfter = formatsAfter.filter( + ( format ) => format.type !== 'core/annotation' + ); + // By default, select the lowest amount of formats possible (which means // the caret is positioned outside the format boundary). The user can // then use arrow keys to define `activeFormats`. - if ( formatsBefore.length < formatsAfter.length ) { - return formatsBefore; + if ( filteredFormatsBefore.length < filteredFormatsAfter.length ) { + return filteredFormatsBefore; } - return formatsAfter; + return filteredFormatsAfter; } // If there's no formats at the start index, there are not active formats. @@ -47,9 +58,8 @@ export function getActiveFormats( value, EMPTY_ACTIVE_FORMATS = [] ) { const selectedFormats = formats.slice( start, end ); - // Clone the formats so we're not mutating the live value. - const _activeFormats = [ ...selectedFormats[ 0 ] ]; let i = selectedFormats.length; + let _activeFormats; // For performance reasons, start from the end where it's much quicker to // realise that there are no active formats. @@ -62,6 +72,19 @@ export function getActiveFormats( value, EMPTY_ACTIVE_FORMATS = [] ) { return EMPTY_ACTIVE_FORMATS; } + // Filter out editor-only formats (like annotations) from formats at this index. + const filteredFormatsAtIndex = formatsAtIndex.filter( + ( format ) => format.type !== 'core/annotation' + ); + + // Clone the formats so we're not mutating the live value. + // Filter out editor-only formats (like annotations) from the start. + // Assign only when we know we'll use it (after early return check). + if ( _activeFormats === undefined ) { + _activeFormats = ( selectedFormats[ 0 ] || [] ).filter( + ( format ) => format.type !== 'core/annotation' + ); + } let ii = _activeFormats.length; // Loop over the active formats and remove any that are not present at @@ -70,7 +93,7 @@ export function getActiveFormats( value, EMPTY_ACTIVE_FORMATS = [] ) { const format = _activeFormats[ ii ]; if ( - ! formatsAtIndex.find( ( _format ) => + ! filteredFormatsAtIndex.find( ( _format ) => isFormatEqual( format, _format ) ) ) {