Skip to content

Commit aa10303

Browse files
ellatrixclaude
andcommitted
DIAGNOSTIC: port main PR input handling to locate the html:70 fix
Temporary: brings the main PR's input-and-selection listener migration (subscribeOwnedListener, ensureSelectionSync, ownsSelection) onto the always-listen prep to test on CI whether the html:70 typing fix lives in this file. To be reverted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 431600a commit aa10303

3 files changed

Lines changed: 161 additions & 20 deletions

File tree

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

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { privateApis as composePrivateApis } from '@wordpress/compose';
99
import { getActiveFormats } from '../../get-active-formats';
1010
import { isCollapsed } from '../../is-collapsed';
1111
import { updateFormats } from '../../update-formats';
12+
import { ownsSelection } from '../../owns-selection';
13+
import { subscribeOwnedListener } from '../../subscribe-owned-listener';
1214
import { unlock } from '../../lock-unlock';
1315

1416
const { 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
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Returns true when the element owns the document selection: either the
3+
* element itself has focus, or its contentEditable editing host has focus
4+
* (e.g. an editable block editor canvas wrapper) and the selection is fully
5+
* contained within the element.
6+
*
7+
* @param {HTMLElement} element The editable element.
8+
*
9+
* @return {boolean} Whether the element owns the document selection.
10+
*/
11+
export function ownsSelection( element ) {
12+
const { ownerDocument } = element;
13+
const { activeElement } = ownerDocument;
14+
15+
if ( activeElement === element ) {
16+
return true;
17+
}
18+
19+
if (
20+
! activeElement ||
21+
! activeElement.isContentEditable ||
22+
! element.isContentEditable ||
23+
! activeElement.contains( element )
24+
) {
25+
return false;
26+
}
27+
28+
const selection = ownerDocument.defaultView.getSelection();
29+
const { anchorNode, focusNode } = selection;
30+
31+
return (
32+
!! anchorNode &&
33+
!! focusNode &&
34+
element.contains( anchorNode ) &&
35+
element.contains( focusNode )
36+
);
37+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { privateApis as composePrivateApis } from '@wordpress/compose';
5+
6+
/**
7+
* Internal dependencies
8+
*/
9+
import { ownsSelection } from './owns-selection';
10+
import { unlock } from './lock-unlock';
11+
12+
const { subscribeDelegatedListener } = unlock( composePrivateApis );
13+
14+
/**
15+
* Subscribes a callback for events owned by the given editable element:
16+
* events targeting the element or its descendants, and events targeting a
17+
* focused editing host (e.g. an editable block editor canvas wrapper) while
18+
* the element contains the selection. In the latter case events target the
19+
* host, never the element, so an element-bound listener would not fire.
20+
*
21+
* @param {HTMLElement} element The editable element.
22+
* @param {string} eventType DOM event name.
23+
* @param {Function} callback Listener to be invoked with the event.
24+
* @param {boolean} capture Use the capture phase. Defaults to `false`.
25+
*
26+
* @return {Function} Unsubscribe function.
27+
*/
28+
export function subscribeOwnedListener(
29+
element,
30+
eventType,
31+
callback,
32+
capture = false
33+
) {
34+
return subscribeDelegatedListener(
35+
element.ownerDocument,
36+
eventType,
37+
( event ) => {
38+
if (
39+
! element.contains( event.target ) &&
40+
! ownsSelection( element )
41+
) {
42+
return;
43+
}
44+
callback( event );
45+
},
46+
capture
47+
);
48+
}

0 commit comments

Comments
 (0)