Skip to content

Commit 9bd8296

Browse files
committed
Enhance activeFormats handling in rich text components
- Updated the annotation handling to preserve user-defined formats when applying annotations programmatically. - Modified the `addInvisibleFormats` function to pass the full value object, allowing better management of active formats. - Improved the `getActiveFormats` function to clone formats only when necessary, optimizing performance. - Adjusted event listeners to filter out editor-only formats (like annotations) from active formats during typing and selection changes. These changes ensure that user formats are maintained correctly while managing annotations, enhancing the overall editing experience. Refs #71698
1 parent 6a0e7e1 commit 9bd8296

File tree

6 files changed

+108
-18
lines changed

6 files changed

+108
-18
lines changed

packages/annotations/src/format/annotation.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,13 +156,28 @@ export const annotation = {
156156
};
157157
},
158158
__experimentalCreatePrepareEditableTree( { annotations } ) {
159-
return ( formats, text ) => {
159+
return ( formats, text, value ) => {
160160
if ( annotations.length === 0 ) {
161161
return formats;
162162
}
163163

164164
let record = { formats, text };
165+
// Preserve activeFormats when applying annotations so user formats
166+
// (like bold) are not lost when annotations are added programmatically.
167+
const originalActiveFormats = value?.activeFormats
168+
? value.activeFormats.filter(
169+
( format ) => format.type !== FORMAT_NAME
170+
)
171+
: undefined;
172+
if ( originalActiveFormats ) {
173+
record.activeFormats = originalActiveFormats;
174+
}
165175
record = applyAnnotations( record, annotations );
176+
// Restore original activeFormats after applying annotations, since
177+
// annotations are editor-only formats and shouldn't be in activeFormats.
178+
if ( originalActiveFormats ) {
179+
record.activeFormats = originalActiveFormats;
180+
}
166181
return record.formats;
167182
};
168183
},

packages/block-editor/src/components/rich-text/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,7 @@ export function RichTextWrapper(
376376

377377
function addInvisibleFormats( value ) {
378378
return prepareHandlers.reduce(
379-
( accumulator, fn ) => fn( accumulator, value.text ),
379+
( accumulator, fn ) => fn( accumulator, value.text, value ),
380380
value.formats
381381
);
382382
}

packages/rich-text/src/component/event-listeners/format-boundaries.js

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { LEFT, RIGHT } from '@wordpress/keycodes';
77
* Internal dependencies
88
*/
99
import { isCollapsed } from '../../is-collapsed';
10+
import { isFormatEqual } from '../../is-format-equal';
1011

1112
const EMPTY_ACTIVE_FORMATS = [];
1213

@@ -65,28 +66,75 @@ export default ( props ) => ( element ) => {
6566
const formatsBefore = formats[ start - 1 ] || EMPTY_ACTIVE_FORMATS;
6667
const formatsAfter = formats[ start ] || EMPTY_ACTIVE_FORMATS;
6768
const destination = isReverse ? formatsBefore : formatsAfter;
68-
const isIncreasing = currentActiveFormats.every(
69-
( format, index ) => format === destination[ index ]
69+
70+
// Split formats into User formats (bold, italic, etc) and Annotations.
71+
// We treat Annotations as a "layer" on top of User formats.
72+
const isAnnotation = ( format ) => format.type === 'core/annotation';
73+
const currentAnnotations = currentActiveFormats.filter( isAnnotation );
74+
const currentUserFormats = currentActiveFormats.filter(
75+
( format ) => ! isAnnotation( format )
76+
);
77+
const destAnnotations = destination.filter( isAnnotation );
78+
const destUserFormats = destination.filter(
79+
( format ) => ! isAnnotation( format )
7080
);
7181

72-
let newActiveFormatsLength = currentActiveFormats.length;
82+
// Check if User formats match the destination
83+
const userFormatsMatch =
84+
currentUserFormats.length === destUserFormats.length &&
85+
currentUserFormats.every( ( format, index ) =>
86+
isFormatEqual( format, destUserFormats[ index ] )
87+
);
7388

74-
if ( ! isIncreasing ) {
75-
newActiveFormatsLength--;
76-
} else if ( newActiveFormatsLength < destination.length ) {
77-
newActiveFormatsLength++;
78-
}
89+
// Check if Annotations match the destination
90+
const annotationsMatch =
91+
currentAnnotations.length === destAnnotations.length &&
92+
currentAnnotations.every( ( format, index ) =>
93+
isFormatEqual( format, destAnnotations[ index ] )
94+
);
7995

80-
if ( newActiveFormatsLength === currentActiveFormats.length ) {
81-
record.current._newActiveFormats = destination;
96+
// console.log( 'Boundaries Debug:', {
97+
// direction: isReverse ? 'left' : 'right',
98+
// currentActive: currentActiveFormats.map( f => f.type ),
99+
// destination: destination.map( f => f.type ),
100+
// userMatch: userFormatsMatch,
101+
// annMatch: annotationsMatch
102+
// } );
103+
104+
if ( userFormatsMatch && annotationsMatch ) {
82105
return;
83106
}
84107

85108
event.preventDefault();
86109

87-
const origin = isReverse ? formatsAfter : formatsBefore;
88-
const source = isIncreasing ? destination : origin;
89-
const newActiveFormats = source.slice( 0, newActiveFormatsLength );
110+
let newActiveFormats;
111+
112+
// Logic: Annotations are on top. If User formats need to change, we must
113+
// strip the Annotation layer first.
114+
if ( ! userFormatsMatch && currentAnnotations.length > 0 ) {
115+
newActiveFormats = currentUserFormats;
116+
} else if ( ! userFormatsMatch ) {
117+
// Transition User formats using standard stack logic
118+
const isIncreasing = currentUserFormats.every( ( format, index ) =>
119+
isFormatEqual( format, destUserFormats[ index ] )
120+
);
121+
let newLength = currentUserFormats.length;
122+
123+
if ( ! isIncreasing ) {
124+
newLength--;
125+
} else if ( newLength < destUserFormats.length ) {
126+
newLength++;
127+
}
128+
129+
const source = isIncreasing ? destUserFormats : currentUserFormats;
130+
newActiveFormats = source.slice( 0, newLength );
131+
} else {
132+
// User formats match, but Annotations don't.
133+
// Transition Annotations (Add or Remove based on destination).
134+
// We always stack Annotations at the end.
135+
newActiveFormats = [ ...currentUserFormats, ...destAnnotations ];
136+
}
137+
90138
const newValue = {
91139
...record.current,
92140
activeFormats: newActiveFormats,

packages/rich-text/src/component/event-listeners/input-and-selection.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,18 @@ export default ( props ) => ( element ) => {
8888
const currentValue = createRecord();
8989
const { start, activeFormats: oldActiveFormats = [] } = record.current;
9090

91+
// Filter out editor-only formats (like annotations) from activeFormats,
92+
// since they shouldn't be applied when typing.
93+
const filteredActiveFormats = oldActiveFormats.filter(
94+
( format ) => format.type !== 'core/annotation'
95+
);
96+
9197
// Update the formats between the last and new caret position.
9298
const change = updateFormats( {
9399
value: currentValue,
94100
start,
95101
end: currentValue.start,
96-
formats: oldActiveFormats,
102+
formats: filteredActiveFormats,
97103
} );
98104

99105
handleChange( change );
@@ -169,6 +175,7 @@ export default ( props ) => ( element ) => {
169175
);
170176

171177
// Update the value with the new active formats.
178+
// getActiveFormats already filters out editor-only formats like annotations.
172179
newValue.activeFormats = newActiveFormats;
173180

174181
// It is important that the internal value is updated first,

packages/rich-text/src/component/index.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ export function useRichText( {
153153
function applyFromProps() {
154154
// Get previous value before updating
155155
const previousValue = _valueRef.current;
156+
// Preserve activeFormats from current record before updating
157+
const preservedActiveFormats = recordRef.current?.activeFormats;
156158

157159
setRecordFromProps();
158160

@@ -168,6 +170,20 @@ export function useRichText( {
168170
ref.current.ownerDocument.activeElement
169171
);
170172

173+
// Preserve activeFormats when only formats changed (e.g., annotations added),
174+
// not when content length changed or element doesn't have focus.
175+
if (
176+
preservedActiveFormats &&
177+
! contentLengthChanged &&
178+
hasFocus &&
179+
recordRef.current.start === recordRef.current.end
180+
) {
181+
recordRef.current = {
182+
...recordRef.current,
183+
activeFormats: preservedActiveFormats,
184+
};
185+
}
186+
171187
// Skip re-applying the selection state when content changed from external source
172188
// (e.g., typing in sidebar input changes canvas text)
173189
const skipSelection = contentLengthChanged && ! hasFocus;

packages/rich-text/src/get-active-formats.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,8 @@ export function getActiveFormats( value, EMPTY_ACTIVE_FORMATS = [] ) {
4747

4848
const selectedFormats = formats.slice( start, end );
4949

50-
// Clone the formats so we're not mutating the live value.
51-
const _activeFormats = [ ...selectedFormats[ 0 ] ];
5250
let i = selectedFormats.length;
51+
let _activeFormats;
5352

5453
// For performance reasons, start from the end where it's much quicker to
5554
// realise that there are no active formats.
@@ -62,6 +61,11 @@ export function getActiveFormats( value, EMPTY_ACTIVE_FORMATS = [] ) {
6261
return EMPTY_ACTIVE_FORMATS;
6362
}
6463

64+
// Clone the formats so we're not mutating the live value.
65+
// Assign only when we know we'll use it (after early return check).
66+
if ( _activeFormats === undefined ) {
67+
_activeFormats = selectedFormats[ 0 ] || [];
68+
}
6569
let ii = _activeFormats.length;
6670

6771
// Loop over the active formats and remove any that are not present at

0 commit comments

Comments
 (0)