Repository navigation
Fix allow mouse users to edit link text when Link UI is active #59635
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 17 commits
65b37bc
f084f50
509a8ea
d4e6861
f1b33ee
fa591fb
d6eee85
d0c5015
3f63012
ceadb8f
e6c1f84
60bb62c
7dfee4f
2ea36cf
c190469
1242c56
31e309f
f923286
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,7 +2,7 @@ | |
| * WordPress dependencies | ||
| */ | ||
| import { __ } from '@wordpress/i18n'; | ||
| import { useState, useLayoutEffect } from '@wordpress/element'; | ||
| import { useState, useLayoutEffect, useEffect } from '@wordpress/element'; | ||
| import { | ||
| getTextContent, | ||
| applyFormat, | ||
|
|
@@ -38,10 +38,39 @@ function Edit( { | |
| onFocus, | ||
| contentRef, | ||
| } ) { | ||
| const [ addingLink, setAddingLink ] = useState( false ); | ||
| const [ editingLink, setEditingLink ] = useState( false ); | ||
| const [ creatingLink, setCreatingLink ] = useState( false ); | ||
|
|
||
| // We only need to store the button element that opened the popover. We can ignore the other states, as they will be handled by the onFocus prop to return to the rich text field. | ||
| const [ openedBy, setOpenedBy ] = useState( null ); | ||
|
|
||
| // Manages whether the Link UI popover should autofocus when shown. | ||
| const [ shouldAutoFocus, setShouldAutoFocus ] = useState( true ); | ||
|
|
||
| function setIsEditingLink( isEditing, { autoFocus = true } = {} ) { | ||
| setEditingLink( isEditing ); | ||
| setShouldAutoFocus( autoFocus ); | ||
| } | ||
|
|
||
| function setIsCreatingLink( isCreating ) { | ||
| // Don't add a new link if there is already an active link. | ||
|
getdave marked this conversation as resolved.
Resolved
|
||
| // The two states are mutually exclusive. | ||
| if ( isCreating === true && isActive ) { | ||
| return; | ||
| } | ||
| setCreatingLink( isCreating ); | ||
| } | ||
|
|
||
| useEffect( () => { | ||
| // When the link becomes inactive (i.e. isActive is false), reset the editingLink state | ||
| // and the creatingLink state. This means that if the Link UI is displayed and the link | ||
| // becomes inactive (e.g. used arrow keys to move cursor outside of link bounds), the UI will close. | ||
| if ( ! isActive ) { | ||
| setEditingLink( false ); | ||
| setCreatingLink( false ); | ||
| } | ||
| }, [ isActive ] ); | ||
|
|
||
| useLayoutEffect( () => { | ||
| const editableContentElement = contentRef.current; | ||
| if ( ! editableContentElement ) { | ||
|
|
@@ -52,14 +81,18 @@ function Edit( { | |
| // There is a situation whereby there is an existing link in the rich text | ||
| // and the user clicks on the leftmost edge of that link and fails to activate | ||
| // the link format, but the click event still fires on the `<a>` element. | ||
| // This causes the `addingLink` state to be set to `true` and the link UI | ||
| // This causes the `editingLink` state to be set to `true` and the link UI | ||
| // to be rendered in "creating" mode. We need to check isActive to see if | ||
| // we have an active link format. | ||
| if ( event.target.tagName !== 'A' || ! isActive ) { | ||
| if ( | ||
| ! event.target.closest( '[contenteditable] a' ) || // other formats (e.g. bold) may be nested within the link. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is an enhancement compared to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ...yes in terms of it fixes a regression introduced during the 6.5 cycle whereby links that had other formats inside them would not activate on click 👍 |
||
| ! isActive | ||
| ) { | ||
| setIsEditingLink( false ); | ||
| return; | ||
| } | ||
|
|
||
| setAddingLink( true ); | ||
| setIsEditingLink( true, { autoFocus: false } ); | ||
| } | ||
|
|
||
| editableContentElement.addEventListener( 'click', handleClick ); | ||
|
|
@@ -70,6 +103,7 @@ function Edit( { | |
| }, [ contentRef, isActive ] ); | ||
|
|
||
| function addLink( target ) { | ||
| setShouldAutoFocus( true ); | ||
| const text = getTextContent( slice( value ) ); | ||
|
|
||
| if ( ! isActive && text && isURL( text ) && isValidHref( text ) ) { | ||
|
|
@@ -90,7 +124,11 @@ function Edit( { | |
| if ( target ) { | ||
| setOpenedBy( target ); | ||
| } | ||
| setAddingLink( true ); | ||
| if ( ! isActive ) { | ||
| setIsCreatingLink( true ); | ||
| } else { | ||
| setIsEditingLink( true ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -109,7 +147,9 @@ function Edit( { | |
| // Otherwise, we rely on the passed in onFocus to return focus to the rich text field. | ||
|
|
||
| // Close the popover | ||
| setAddingLink( false ); | ||
| setIsEditingLink( false ); | ||
| setIsCreatingLink( false ); | ||
|
|
||
| // Return focus to the toolbar button or the rich text field | ||
| if ( openedBy?.tagName === 'BUTTON' ) { | ||
| openedBy.focus(); | ||
|
|
@@ -127,7 +167,8 @@ function Edit( { | |
| // 4. Press Escape | ||
| // 5. Focus should be on the Options button | ||
| function onFocusOutside() { | ||
| setAddingLink( false ); | ||
| setIsEditingLink( false ); | ||
| setIsCreatingLink( false ); | ||
| setOpenedBy( null ); | ||
| } | ||
|
|
||
|
|
@@ -136,6 +177,8 @@ function Edit( { | |
| speak( __( 'Link removed.' ), 'assertive' ); | ||
| } | ||
|
|
||
| const isEditingActiveLink = editingLink && isActive; | ||
|
|
||
| return ( | ||
| <> | ||
| <RichTextShortcut type="primary" character="k" onUse={ addLink } /> | ||
|
|
@@ -151,13 +194,13 @@ function Edit( { | |
| onClick={ ( event ) => { | ||
| addLink( event.currentTarget ); | ||
| } } | ||
| isActive={ isActive || addingLink } | ||
| isActive={ isActive || editingLink } | ||
| shortcutType="primary" | ||
| shortcutCharacter="k" | ||
| aria-haspopup="true" | ||
| aria-expanded={ addingLink } | ||
| aria-expanded={ editingLink } | ||
| /> | ||
| { addingLink && ( | ||
| { ( isEditingActiveLink || creatingLink ) && ( | ||
| <InlineLinkUI | ||
| stopAddingLink={ stopAddingLink } | ||
| onFocusOutside={ onFocusOutside } | ||
|
|
@@ -166,6 +209,7 @@ function Edit( { | |
| value={ value } | ||
| onChange={ onChange } | ||
| contentRef={ contentRef } | ||
| focusOnMount={ shouldAutoFocus ? 'firstElement' : false } | ||
| /> | ||
| ) } | ||
| </> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,8 @@ import { useState, useLayoutEffect } from '@wordpress/element'; | |
| /** @typedef {import('../register-format-type').WPFormat} WPFormat */ | ||
| /** @typedef {import('../types').RichTextValue} RichTextValue */ | ||
|
|
||
| const STABLE_OBJECT = {}; | ||
|
getdave marked this conversation as resolved.
Resolved
|
||
|
|
||
| /** | ||
| * Given a range and a format tag name and class name, returns the closest | ||
| * format element. | ||
|
|
@@ -138,7 +140,12 @@ function getAnchor( editableContentElement, tagName, className ) { | |
| * @return {Element|VirtualAnchorElement|undefined|null} The active element or selection range. | ||
| */ | ||
| export function useAnchor( { editableContentElement, settings = {} } ) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ellatrix I would very much appreciate your opinion on this change. The crux of this is that when clicking between multiple links in the same This was because the
The last is is probably the one that will peek interest. Whilst it's obvious that the It does in fact do this, but when you click on a link the Ultimately I decided what we needed is a way to distinguish between mutliple link formats in a single contenteditable. It is for this reason that I have introduced the As I'm not absolutely 100% confident in the API, I've elected to do two things
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @youknowriad also pointed out that #58900 seemed to make some large changes to the implementation of I'm going to continue to dive in here as in essence I would much prefer to avoid any change to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I reverted (locally only at this stage) Patch diffdiff --git a/packages/rich-text/src/component/use-anchor.js b/packages/rich-text/src/component/use-anchor.js
index 3e950d4d3c..5f99509c1f 100644
--- a/packages/rich-text/src/component/use-anchor.js
+++ b/packages/rich-text/src/component/use-anchor.js
@@ -140,48 +140,44 @@ function getAnchor( editableContentElement, tagName, className ) {
* @return {Element|VirtualAnchorElement|undefined|null} The active element or selection range.
*/
export function useAnchor( { editableContentElement, settings = {} } ) {
- const {
- tagName,
- className,
- isActive,
- __unstableActiveAttributes: activeAttributes = STABLE_OBJECT,
- } = settings;
+ const { tagName, className } = settings;
const [ anchor, setAnchor ] = useState( () =>
getAnchor( editableContentElement, tagName, className )
);
- const wasActive = usePrevious( isActive );
useLayoutEffect( () => {
if ( ! editableContentElement ) return;
const { ownerDocument } = editableContentElement;
- if (
- editableContentElement === ownerDocument.activeElement ||
- // When a link is created, we need to attach the popover to the newly created anchor.
- ( ! wasActive && isActive ) ||
- // Sometimes we're _removing_ an active anchor, such as the inline color popover.
- // When we add the color, it switches from a virtual anchor to a `<mark>` element.
- // When we _remove_ the color, it switches from a `<mark>` element to a virtual anchor.
- ( wasActive && ! isActive )
- ) {
+ function callback() {
setAnchor(
getAnchor( editableContentElement, tagName, className )
);
}
- }, [
- editableContentElement,
- tagName,
- className,
- isActive,
- wasActive,
- // Active attributes is defaulted to a stable object to avoid re-rendering,
- // but in specific circumstances it can be used to provide additional information
- // about the currently active format to disambiguate the format from other instances of the same format
- // within the same editable content element. This is useful for `core/link` when you
- // want to disambiguate between different links when clicking between them.
- activeAttributes,
- ] );
+
+ function attach() {
+ ownerDocument.addEventListener( 'selectionchange', callback );
+ }
+
+ function detach() {
+ ownerDocument.removeEventListener( 'selectionchange', callback );
+ }
+
+ if ( editableContentElement === ownerDocument.activeElement ) {
+ attach();
+ }
+
+ editableContentElement.addEventListener( 'focusin', attach );
+ editableContentElement.addEventListener( 'focusout', detach );
+
+ return () => {
+ detach();
+
+ editableContentElement.removeEventListener( 'focusin', attach );
+ editableContentElement.removeEventListener( 'focusout', detach );
+ };
+ }, [ editableContentElement, tagName, className ] );
return anchor;
}
This appears to fix the anchor positioning issue without requiring any further changes to Screen.Capture.on.2024-03-08.at.13-58-35.mp4Therefore I wonder if @jeryj could confirm whether he notices any other regressions as a result of reverting #58900? It not then I propose we revert the changes in #58900.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Try creating a link :) The isActive / wasActive checks allow for the popover to reposition when the selection changes the element underneath of it. Ie, going from no markup to an . This bug was attempted to be fixed by caching the position, but that just covered up the underlying issue and caused its own bug of being fixed position on the page when scrolling. Screen.Recording.2024-03-08.at.9.20.36.AM.mov
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah yes it was about creation of links. Right. Let's see if we can work this out then.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Circling back, here's what I've done to resolve this:
These changes were made in f923286. Rationale:
From what I can see the functionality is now working as expected but I could do with careful checking.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It's not just about backward compatibility, having the listeners is the right fix to position the anchor properly when you switch between links using the mouse.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds like a reasonable way forwards to me. Thanks @getdave! |
||
| const { tagName, className, isActive } = settings; | ||
| const { | ||
| tagName, | ||
| className, | ||
| isActive, | ||
| __unstableActiveAttributes: activeAttributes = STABLE_OBJECT, | ||
| } = settings; | ||
| const [ anchor, setAnchor ] = useState( () => | ||
| getAnchor( editableContentElement, tagName, className ) | ||
| ); | ||
|
|
@@ -162,7 +169,19 @@ export function useAnchor( { editableContentElement, settings = {} } ) { | |
| getAnchor( editableContentElement, tagName, className ) | ||
| ); | ||
| } | ||
| }, [ editableContentElement, tagName, className, isActive, wasActive ] ); | ||
| }, [ | ||
| editableContentElement, | ||
| tagName, | ||
| className, | ||
| isActive, | ||
| wasActive, | ||
| // Active attributes is defaulted to a stable object to avoid re-rendering, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's unclear what the "stable object" idea should offer.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree. See
...from this comment. I am loath to modify the API of We've run into this issue before but somehow our UI masked the error but it keeps surfacing.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Update: I confidence checked myself and no this would be incorrect. We need to provide a stable object as the default and so what I have in the PR now is correct. // I'm now proposing we DON'T do this...
const {
tagName,
className,
isActive,
__unstableActiveAttributes: activeAttributes = {}, // new object reference each time :(
} = settings;The reason why I want a stable object, is because it is one of the deps to the |
||
| // but in specific circumstances it can be used to provide additional information | ||
| // about the currently active format to disambiguate the format from other instances of the same format | ||
| // within the same editable content element. This is useful for `core/link` when you | ||
| // want to disambiguate between different links when clicking between them. | ||
|
getdave marked this conversation as resolved.
Resolved
|
||
| activeAttributes, | ||
| ] ); | ||
|
|
||
| return anchor; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the concept we've been missing.