-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
Fix annotations text tracking #11861
Conversation
What are you trying to achieve? I think this is already possible? |
@iseulde I've updated the description with more context. |
With this example, the format stays in its place? Do you need a way to update the positions? wp.richText.registerFormatType( 'plugin/annotation', {
title: 'invisible',
tagName: 'mark',
className: 'annotation',
__experimentalGetPropsForEditableTreePreparation( select ) {
return {
isEnabled: select( 'core/edit-post' ).getActiveGeneralSidebarName() === 'edit-post/block',
};
},
__experimentalCreatePrepareEditableTree( props ) {
return ( formats, text ) => {
if ( ! props.isEnabled ) {
return formats;
}
const search = 'Gutenberg';
const index = text.indexOf( search );
if ( index === -1 ) {
return formats;
}
const start = index;
const end = index + search.length;
const newValue = wp.richText.applyFormat( { text, formats }, { type: 'plugin/annotation' }, start, end );
return newValue.formats;
};
},
} ); |
return record.formats; | ||
value = applyAnnotations( value, annotations ); | ||
|
||
return value; |
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.
Why do you need the ability to replace text?
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.
I don't think I do, it's was just easier in rich-text to pass the whole value. I can easily change it to only allow changing the formats like prepareEditableTree
.
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.
Maybe I should have commented this. We don't want to allow prepareEditableTree
to change anything other than the formats. See #11595 (comment)
I would love a more elaborate description of what's happening in this PR.
The above test code proves that this is already possible. |
@@ -801,29 +804,87 @@ export class RichText extends Component { | |||
return false; | |||
} | |||
|
|||
// Allow primitives and arrays: | |||
if ( ! isPlainObject( this.props[ name ] ) ) { | |||
return this.props[ name ] !== prevProps[ name ]; |
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.
Why are we comparing non format_
props?
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.
At this point in the code, it is guaranteed that name
starts with format_
.
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.
I think this needs more commenting. I don't understand what's going on.
Pardon my inability to communicate this in text. What I want is:BeforeRaw text:
Annotated (classes left out for brevity):
Annotation state: {
// ..
range: {
start: 9,
end: 13,
}
} AfterRaw text:
Annotated:
Annotation state: {
// ..
range: {
start: 9,
end: 14,
}
} Given the fact that the annotation state only has positions to work with. I cannot use the method in the Note: I do understand that for marking specific strings the technique provided in your |
return { | ||
annotations: select( 'core/annotations' ).__experimentalGetAnnotationsForRichText( blockClientId, richTextIdentifier ), | ||
}; | ||
return select( 'core/annotations' ).__experimentalGetAnnotationsForRichText( blockClientId, richTextIdentifier ); |
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.
Is this wrong? the method is called getProps
I expected it to return an object here?
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.
The thing I tried to solve here was not re-rendering unless necessary. select( 'core/annotations' ).__experimentalGetAnnotationsForRichText( blockClientId, richTextIdentifier )
is cached based on the annotations for a block, so it will only re-render if a different annotation is added.
With:
return {
annotations: select( 'core/annotations' ).__experimentalGetAnnotationsForRichText( blockClientId, richTextIdentifier ),
};
it would create a new object every time the function was called, so it would trigger a re-render. And I didn't want to introduce another memoized location, when a simpler solution was available.
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.
My hope/thoughts, was that withSelect
already checks the props (using shallow equality) and we can probably use the same mechanism to do the same.
So if I understand properly:
was solving the Formats => RichText (link) but we need an I think if we assume that the RichText component is capable of editing the format ranges. (which it is obviously doing since we have "boundaries" support), it's legitimate to expect the format to receive the changes (if it wants to do something with it). Still not sure about the implementation details, I need to think a little bit more but I agree with the issue we're trying to solve being legitimate. |
Yes, that's currently not in this PR. But my thinking is that I can update the annotation state based on the
That would work too.
I wanted to at least put something up that works. I'm open to better solutions. |
So what you currently have is: annotations state => value formats. So if we call same function with the value formats whenever they change, you can derive new annotation state from that? This probably requires you to pass some id to the format so you can identify it later on. I'm not sure how this PR does that. The lack of test doesn't make it clearer. |
What about this API? const format = {
prepareEditableValue: select => (value, context) => { return formats; }
onChangeEditableValue: dispatch => (formats, context) => { // do something }
} I feel this is simple enough to be understood properly it assumes we select props when rendering and we dispatch things onChange. The downside here is that we can't "optimize" Thoughts? |
Alternatively, if we were to keep the separation between "receiving the props" and "providing the formats based on the props and the value" to be able to bundle the performance optimization into the framework, I'll do the same for the opposite direction: const format = {
getPropsForEditableTreePreparation => (select, context) => props,
prepareEditableValue: (value, props, context) => { return formats; },
getPropsForEditableTreeChangeHandler => (dispatch, context) => props,
onChangeEditableValue: (formats, props, context) => { // do something }
} |
6b8344f
to
ea44b15
Compare
Yes.
Yes, I've implemented this in the latest commit: ea44b15.
It didn't yet update the annotations state because I hadn't gotten to that yet. In the last commit I've implemented that part. |
I think we cannot leave this hole open. If all
What is the benefit of the @youknowriad The version I have now works completely, I will try to implement your proposed second solution to see if that works. I don't know yet how I am going to work around/with https://github.com/WordPress/gutenberg/blob/master/packages/rich-text/src/create.js#L60, but I'll try to find a solution. |
I think we discussed this in a previous #core-js but I agree that it's not obvious :). Ideally we should never use The bug that can happen here is that you'd rely on the wrong registry. For example if you have a Gutenberg editor inside another Gutenberg editor (which can happen for reusable blocks), you won't be calling the right registry if you do |
@youknowriad I've implemented your suggestions in 44939f9. The downside of this approach compared to the I think maybe that approach is simpler as well, because it's very clear what is happening:
While the refactored approach works like this:
My takeaway is: The second diagram shows we are trying to build a functional reactive program, but because our internals are not that (TinyMCE) it's awkward. The first diagram shows an embrace of the fact that TinyMCE needs a two way binding. Given we are moving towards making |
I think |
Unit tests are failing. Probably docs needs to be regenerated or something. I'd love if we can update the annotations e2e test to check updates, say like we type in an annotation and we check that it's being updated properly in the state or something. |
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.
I don't see any tests at all. This would make it hard for anyone changing RichText in the future to know if they broke the API.
const positions = {}; | ||
|
||
formats.forEach( ( characterFormats, i ) => { | ||
characterFormats = characterFormats || []; |
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 should always be an array, so the casting seems unnecessary?
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.
It can be undefined
right, for when no formatting is present on that character?
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.
It's a sparse array, there's no indices set as undefined, they're just not there. forEach
will only loop over any indices that are set, which is only arrays.
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.
Try:
[ , [] ].forEach( ( { forEach } ) => {} );
[ undefined, [] ].forEach( ( { forEach } ) => {} );
|
||
formats.forEach( ( characterFormats, i ) => { | ||
characterFormats = characterFormats || []; | ||
characterFormats = characterFormats.filter( ( format ) => format.type === FORMAT_NAME ); |
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.
Could these two loops be merged?
When pasting, I think the annotations should stay in place if the text they apply to is still present. If I cut and paste a sentence from the middle of the paragraph to the beginning of the paragraph the annotations should probably stay in place. |
const positions = retrieveAnnotationPositions( formats ); | ||
const { removeAnnotation, updateAnnotationRange, annotations } = props; | ||
|
||
updateAnnotationsWithPositions( annotations, positions, { removeAnnotation, updateAnnotationRange } ); |
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.
Why can retrieveAnnotationPositions
and updateAnnotationsWithPositions
not be done in one go, something like updateAnnotationsFromFormats
?
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.
I felt like these two functions were good to make the code more understandable, they can be rolled into one if that's better.
* @param {Array} formats The formats of the latest rich-text value. | ||
* @param {string} text The text of the latest rich-text value. | ||
*/ | ||
onChangeEditableValue( { formats, text } ) { |
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.
Why is this not build in the rich-text
package instead of the component, just like __experimentalCreatePrepareEditableTree
? The RichText
component should not be aware of any editor-only formats. When a value is created with create
, the formats should already be gone. I could update this PR, but I'm not confident without any tests to verify the behaviour.
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.
I don't know if I understand the rich-text
package enough to be able to rewrite this code myself. Should I try to take a stab at writing those tests so you can verify your code?
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.
That sounds good!
@@ -57,10 +57,6 @@ function toFormat( { type, attributes } ) { | |||
return attributes ? { type, attributes } : { type }; | |||
} | |||
|
|||
if ( formatType.__experimentalCreatePrepareEditableTree ) { |
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.
https://github.com/WordPress/gutenberg/pull/11861/files#r234544043
In other words, these lines shouldn't be removed.
I think this is in a good place, it should only get some tests and move formats => positions to |
I've split out the annotations e2e into their own file so we can expand on that fairly easily. I've also added tests for the text tracking. I tested 3 scenario's:
@iseulde That should be enough to verify to verify the refactor. Do you miss any scenarios? |
This makes it possible to have an editor-only format that stays in position when typing inside `RichText`. Apply this to annotations, because it is necessary there.
When the user types before or in an annotation we want the annotation to be adjusted accordingly. We can determine the location of the mark elements in the `RichText` based on the value passed to `__experimentalCreateValueToFormat` so we can update the annotations based on the values expressed there.
Instead of having APIs for `formatToValue` and `valueToFormat`, create an `__experimentalCreateOnChangeEditableValue` function that is called when the editable value changes. The annotations API can use this to update its state.
b369505
to
492ce7a
Compare
return state.all; | ||
return flatMap( state, ( annotations ) => { | ||
return annotations; | ||
} ); |
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 equivalent to flatMap( state, identity )
, which can be reduced to flatten( state )
, unless I misread.
@@ -1,3 +1,3 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
|
|||
exports[`Using Plugins API Sidebar Should open plugins sidebar using More Menu item and render content 1`] = `"<div class=\\"components-panel__header edit-post-sidebar-header__small\\"><span class=\\"edit-post-sidebar-header__title\\">(no title)</span><button type=\\"button\\" aria-label=\\"Close plugin\\" class=\\"components-button components-icon-button\\"><svg aria-hidden=\\"true\\" role=\\"img\\" focusable=\\"false\\" class=\\"dashicon dashicons-no-alt\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"20\\" height=\\"20\\" viewBox=\\"0 0 20 20\\"><path d=\\"M14.95 6.46L11.41 10l3.54 3.54-1.41 1.41L10 11.42l-3.53 3.53-1.42-1.42L8.58 10 5.05 6.47l1.42-1.42L10 8.58l3.54-3.53z\\"></path></svg></button></div><div class=\\"components-panel__header edit-post-sidebar-header\\"><strong>Sidebar title plugin</strong><button type=\\"button\\" aria-label=\\"Unpin from toolbar\\" aria-expanded=\\"true\\" class=\\"components-button components-icon-button is-toggled\\"><svg aria-hidden=\\"true\\" role=\\"img\\" focusable=\\"false\\" class=\\"dashicon dashicons-star-filled\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"20\\" height=\\"20\\" viewBox=\\"0 0 20 20\\"><path d=\\"M10 1l3 6 6 .75-4.12 4.62L16 19l-6-3-6 3 1.13-6.63L1 7.75 7 7z\\"></path></svg></button><button type=\\"button\\" aria-label=\\"Close plugin\\" class=\\"components-button components-icon-button\\"><svg aria-hidden=\\"true\\" role=\\"img\\" focusable=\\"false\\" class=\\"dashicon dashicons-no-alt\\" xmlns=\\"http://www.w3.org/2000/svg\\" width=\\"20\\" height=\\"20\\" viewBox=\\"0 0 20 20\\"><path d=\\"M14.95 6.46L11.41 10l3.54 3.54-1.41 1.41L10 11.42l-3.53 3.53-1.42-1.42L8.58 10 5.05 6.47l1.42-1.42L10 8.58l3.54-3.53z\\"></path></svg></button></div><div class=\\"components-panel\\"><div class=\\"components-panel__body is-opened\\"><div class=\\"components-panel__row\\"><label for=\\"title-plain-text\\">Title:</label><textarea class=\\"editor-plain-text\\" id=\\"title-plain-text\\" placeholder=\\"(no title)\\" rows=\\"1\\" style=\\"overflow: hidden; overflow-wrap: break-word; resize: none; height: 18px;\\"></textarea></div><div class=\\"components-panel__row\\"><button type=\\"button\\" class=\\"components-button is-button is-primary\\">Reset</button></div><button type=\\"button\\" class=\\"components-button is-button is-primary\\">Add annotation</button></div></div>"`; |
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.
What is this?
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.
I think the difference here is that it was moved to a separate test. there's no annotations button anymore.
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.
Yes, that is the reason.
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.
I'm approving this as it seems in a decent shape to include in 4.5 but let's make sure we follow up on the comments.
Description
This makes it possible to have an editor-only format that stays in
position when typing inside
RichText
.Apply this to annotations, because it is necessary there.
Before this PR the annotations API worked via the functional
prepareEditableTree
. This works technically, but fails functionally. The rendering inside the TinyMCE instance is kept in sync with the annotations state. So when an annotation is from position 50 to position 100 the annotation will stay in those positions, this makes no sense from a user perspective. Instead what you want is that the annotation behaves like other formatting. It flows with edits of the text.This morning I tried a first iteration by watching for edits to the
RichText
value and updating the annotation state accordingly, moving positions based on what has changed in the content. This was very unreliable and probably hard to make performant. While with this PR this tracking comes for free, because the DOM does it for us.I'm thinking that I can iterate on this by having the annotations state be updated based on the formats the annotations module sees when running
valueToFormat
. Because that is the actual way that the annotation has moved in the text.Note: With a fully functional paradigm I would update the annotations as edits from the user come in via events, but that's not feasible in the current
RichText
paradigmHow has this been tested?
With the Yoast SEO 9.2-RC2 plugin and with the annotations-tester plugin.
Screenshots
Types of changes
Checklist: