@@ -9,6 +9,8 @@ import { privateApis as composePrivateApis } from '@wordpress/compose';
99import { getActiveFormats } from '../../get-active-formats' ;
1010import { isCollapsed } from '../../is-collapsed' ;
1111import { updateFormats } from '../../update-formats' ;
12+ import { ownsSelection } from '../../owns-selection' ;
13+ import { subscribeOwnedListener } from '../../subscribe-owned-listener' ;
1214import { unlock } from '../../lock-unlock' ;
1315
1416const { subscribeDelegatedListener } = unlock ( composePrivateApis ) ;
@@ -129,9 +131,13 @@ export default ( props ) => ( element ) => {
129131 return ;
130132 }
131133
132- // Ensure the active element is the rich text element. The listener
133- // stays subscribed but no-ops for instances that aren't focused.
134- if ( ownerDocument . activeElement !== element ) {
134+ // Ensure the active element is the rich text element, or that the
135+ // element owns the selection through a focused editing host (the
136+ // editable block editor canvas wrapper).
137+ if (
138+ ownerDocument . activeElement !== element &&
139+ ! ownsSelection ( element )
140+ ) {
135141 return ;
136142 }
137143
@@ -189,10 +195,11 @@ export default ( props ) => ( element ) => {
189195 }
190196
191197 function onCompositionStart ( ) {
198+ // Do not update the selection when characters are being composed as
199+ // this rerenders the component and might destroy internal browser
200+ // editing state. `handleSelectionChange` returns early while
201+ // composing.
192202 isComposing = true ;
193- // `handleSelectionChange` returns early while composing, so the
194- // selection is not updated as characters are composed (which rerenders
195- // the component and might destroy internal browser editing state).
196203 // Remove the placeholder. Since the rich text value doesn't update
197204 // during composition, the placeholder doesn't get removed. There's no
198205 // need to re-add it, when the value is updated on compositionend it
@@ -228,8 +235,24 @@ export default ( props ) => ( element ) => {
228235 props . current ;
229236
230237 // When the whole editor is editable, let writing flow handle
231- // selection.
238+ // selection state .
232239 if ( element . parentElement . closest ( '[contenteditable="true"]' ) ) {
240+ // A nested editable element does not receive a caret from being
241+ // focused, unlike an editing host. When the element does not
242+ // contain the selection, restore the internal record's selection,
243+ // or match the editing host behavior for programmatic focus and
244+ // place the caret at the start.
245+ const selection = defaultView . getSelection ( ) ;
246+ if (
247+ ! selection . anchorNode ||
248+ ! element . contains ( selection . anchorNode )
249+ ) {
250+ if ( isSelected && record . current . start !== undefined ) {
251+ applyRecord ( record . current ) ;
252+ } else {
253+ selection . collapse ( element , 0 ) ;
254+ }
255+ }
233256 return ;
234257 }
235258
@@ -257,22 +280,63 @@ export default ( props ) => ( element ) => {
257280 window . queueMicrotask ( handleSelectionChange ) ;
258281 }
259282
283+ /**
284+ * The native `selectionchange` event is asynchronous and may not have
285+ * been dispatched yet for a preceding selection change when the next
286+ * event arrives. Sync the record immediately so the handlers of the very
287+ * event being processed read a fresh record.
288+ */
289+ function ensureSelectionSync ( ) {
290+ if ( ownerDocument . activeElement === element ) {
291+ return ;
292+ }
293+ if ( ! ownsSelection ( element ) ) {
294+ return ;
295+ }
296+ handleSelectionChange ( ) ;
297+ }
298+
260299 // `input` and `compositionend` must run before block-editor's
261300 // `input-rules.js` element-level listeners, which call `getValue()`
262301 // reading `record.current` updated by our `onInput`. Use capture phase
263302 // so we fire before any ancestor bubble handlers.
264- const unsubscribeInput = subscribeDelegatedListener (
303+ const unsubscribeInput = subscribeOwnedListener (
265304 element ,
266305 'input' ,
267306 onInput ,
268307 true
269308 ) ;
270- const unsubscribeCompositionStart = subscribeDelegatedListener (
309+ // Bound to the document with capture so it runs before any other
310+ // listener of the event. Subscribed for every event type whose handlers
311+ // read the internal record.
312+ const unsubscribeEnsureSelection = [
313+ 'keydown' ,
314+ 'beforeinput' ,
315+ 'copy' ,
316+ 'cut' ,
317+ 'paste' ,
318+ ] . map ( ( eventType ) =>
319+ subscribeDelegatedListener (
320+ ownerDocument ,
321+ eventType ,
322+ ensureSelectionSync ,
323+ true
324+ )
325+ ) ;
326+ // Permanently subscribed: `handleSelectionChange` checks whether the
327+ // element has focus or owns the selection itself. The shared underlying
328+ // delegated listener keeps the number of native listeners constant.
329+ const unsubscribeSelectionChange = subscribeDelegatedListener (
330+ ownerDocument ,
331+ 'selectionchange' ,
332+ handleSelectionChange
333+ ) ;
334+ const unsubscribeCompositionStart = subscribeOwnedListener (
271335 element ,
272336 'compositionstart' ,
273337 onCompositionStart
274338 ) ;
275- const unsubscribeCompositionEnd = subscribeDelegatedListener (
339+ const unsubscribeCompositionEnd = subscribeOwnedListener (
276340 element ,
277341 'compositionend' ,
278342 onCompositionEnd ,
@@ -283,21 +347,13 @@ export default ( props ) => ( element ) => {
283347 'focusin' ,
284348 onFocus
285349 ) ;
286- // Permanently subscribed rather than added on focus and removed on blur:
287- // `handleSelectionChange` checks whether the element is focused itself,
288- // and the shared underlying delegated listener keeps the number of native
289- // listeners constant.
290- const unsubscribeSelectionChange = subscribeDelegatedListener (
291- ownerDocument ,
292- 'selectionchange' ,
293- handleSelectionChange
294- ) ;
295350
296351 return ( ) => {
297352 unsubscribeInput ( ) ;
353+ unsubscribeEnsureSelection . forEach ( ( unsubscribe ) => unsubscribe ( ) ) ;
354+ unsubscribeSelectionChange ( ) ;
298355 unsubscribeCompositionStart ( ) ;
299356 unsubscribeCompositionEnd ( ) ;
300357 unsubscribeFocus ( ) ;
301- unsubscribeSelectionChange ( ) ;
302358 } ;
303359} ;
0 commit comments