-
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
Writing Flow: Collapse range in horizontal edge check by selection direction #6467
Conversation
I'll have a look in a bit. Some previous art, but unsure why it is done that way: https://github.com/tinymce/tinymce/blob/d941efc13979b175e6c4c61946b631edc79b75bc/src/core/main/ts/api/dom/Selection.ts#L472-L491. |
@aduth After a few attempts, I'm unsure how to test this PR? Could you explain in more detail or make a screen recording of the steps? Thanks! |
On master: From the beginning of the paragraph, making a shift-selection to the right works fine, but while continuing to hold Shift and starting to press left to reduce the selection, nothing happens. A bit hard to demonstrate in the GIF since you can't see me furiously trying to press Shift+Left 😄 |
Hm, I'm not able to get this to work... even when pressing Shift+ArrowLeft furiously. :) The selection just seems stuck which I guess should collapse on this branch. I'll have a closer look at the code. |
utils/dom.js
Outdated
range.collapse( isReverse ); | ||
|
||
// Collapse to the extent of the selection by direction. | ||
range.collapse( ! isSelectionForward( selection ) ); |
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 the goal to set this range to the document? This is just used for calculation purposes (a clone) right?
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 not quite sure here how the direction of a selection relates to this check. This would collapse a range at the edge of a field to the end or the start depending on direction (so in one case this will collapse to the end and the check fails), while we should check whether or not the range touches the edge? See 1bb9658
@iseulde I like it, makes sense that we'd not care to consider the edge as breached if pressing arrows while having a non-callapsed selection (since the intent of this is to simply collapse in the direction of the arrow press). Two thoughts though:
|
Feel free to merge your commit into this branch as well. |
I'm going to write an end-to-end test for verifying this expected behavior. |
This should be negligible and I'm not opposed to separating that. We'd have to generalise it for all input fields. |
A few more notes after playing with it a bit:
diff --git a/editor/components/writing-flow/index.js b/editor/components/writing-flow/index.js
index 48f7c08ce..25a0ba852 100644
--- a/editor/components/writing-flow/index.js
+++ b/editor/components/writing-flow/index.js
@@ -205,13 +205,13 @@ class WritingFlow extends Component {
// Moving from block multi-selection to single block selection
event.preventDefault();
this.moveSelection( isReverse );
- } else if ( isVertical && isVerticalEdge( target, isReverse, isShift ) ) {
+ } else if ( isVertical && isVerticalEdge( target, isReverse, ! isShift ) ) {
const closestTabbable = this.getClosestTabbable( target, isReverse );
if ( closestTabbable ) {
placeCaretAtVerticalEdge( closestTabbable, isReverse, this.verticalRect );
event.preventDefault();
}
- } else if ( isHorizontal && isHorizontalEdge( target, isReverse, isShift ) ) {
+ } else if ( isHorizontal && isHorizontalEdge( target, isReverse, ! isShift ) ) {
const closestTabbable = this.getClosestTabbable( target, isReverse );
placeCaretAtHorizontalEdge( closestTabbable, isReverse );
event.preventDefault();
diff --git a/utils/dom.js b/utils/dom.js
index 9e6c3f606..87ca3f18d 100644
--- a/utils/dom.js
+++ b/utils/dom.js
@@ -19,7 +19,7 @@ const { TEXT_NODE, ELEMENT_NODE } = window.Node;
*
* @return {boolean} True if at the horizontal edge, false if not.
*/
-export function isHorizontalEdge( container, isReverse, requireCollapsedRange = false ) {
+export function isHorizontalEdge( container, isReverse, requireCollapsedRange = true ) {
if ( includes( [ 'INPUT', 'TEXTAREA' ], container.tagName ) ) {
if ( container.selectionStart !== container.selectionEnd ) {
return false;
@@ -48,7 +48,7 @@ export function isHorizontalEdge( container, isReverse, requireCollapsedRange =
return false;
}
- if ( ! requireCollapsedRange && ! range.collapsed ) {
+ if ( requireCollapsedRange && ! range.collapsed ) {
return false;
} |
Specifically, another behavior I'm noticing (on master and here prior to the above changes) is that when having a selection which extends to the beginning or end of a block, pressing the arrow should still just collapse the selection, not advance the caret to the previous / next block (at least this is how it behaves in Google Docs). |
@aduth If I understand you correctly, you mean this: If my selection is forward, and I press arrow up or left, the selection should collapse and continue on the other side. If my selection is backward, and I press arrow up or left, the selection should not collapse and continue on the other side. |
bc7fb41
to
e2fc387
Compare
utils/dom.js
Outdated
* | ||
* @return {boolean} Whether there is a collapsed selection. | ||
*/ | ||
export function hasCollapsedSelection() { |
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.
Will this work on textarea
and input
? Should a container
be provided just like isHorizontalEdge
etc.?
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.
Ah, it should be considered, yes.
That said, do we need to pass the container? Shouldn't there only be a single selection (ignoring Firefox's multi-selection behavior), and if so, could we determine the type of container where the selection exists within automatically?
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's a good point, and we never actually seem to check if the selection is within the container
in case of an contenteditable
field. I do think we should provide the container (needed at least of textarea
and input
) and ensure that contains a collapsed selection, not just the document in general? Or do you view this differently?
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.
E.g. you might argue that since this is triggered by a keydown event, we can assume the selection is in the container
.
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.
not just the document in general
My thinking was toward the document selection in general. Like container = document.activeElement
.
Playing with the native textarea a bit, I think directionality of the selection has no impact here. If there is an uncollapsed selection, pressing left or right should simply collapse to the extent in the direction of the arrow press. If there is an uncollapsed selection, pressing up or down should move upward/downward as if the selection was collapsed. |
Noting that with recent changes, this isn't working as expected. Thinking implementing the end-to-end tests sooner than later can help in iterating toward the ideal behavior. |
utils/dom.js
Outdated
* @return {boolean} Whether there is a collapsed selection. | ||
*/ | ||
export function hasCollapsedSelection() { | ||
const selection = window.getSelection(); |
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.
There's also selection.isCollapsed
which might work too. https://developer.mozilla.org/en-US/docs/Web/API/Selection
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.
There's also
selection.isCollapsed
which might work too.
At that point, we may as well not even have this function, since window.getSelection().collapsed
is barely more verbose.
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.
Yeah :)
const { getSelection } = window;
getSelection().isCollapsed;
utils/dom.js
Outdated
selection.getRangeAt( 0 ).collapsed | ||
); | ||
} | ||
|
||
/** | ||
* Check whether the caret is horizontally at the edge of the container. |
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.
Might want to update "caret" to "selection", as "caret" implies a collapsed selection I think.
utils/dom.js
Outdated
if ( startContainer.nodeType === ELEMENT_NODE ) { | ||
const { childNodes } = startContainer; | ||
const selectedNode = childNodes[ | ||
// Make sure there can be no errors. |
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.
Were you able to determine why it's the case that sometimes startOffset
exceeds the number of childNodes
?
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've not encountered it. This is just to make sure it never does, if it would for some strange 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.
Hmm, not entirely comfortable leaving this as an unknown.
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.
Hm, me neither. Let's remove this check and address any problems if they come up?
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.
Yeah, I think so. Will give it another round of testing this morning.
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 issue with removing the safety check can be seen when, with two paragraphs, you press ArrowLeft from the second paragraph (to the first).
Specifically, it can occur that startOffset
is 1
even though the first paragraph only has a single child node (where startContainer.childNodes[ 1 ]
is undefined
). Based on the documentation for startOffset
, this might be expected ("number of child nodes between [...]"), where perhaps the caret is not considered as being within the text node, but rather after it?
While setting a bound with Math.min
avoids the error, I don't think it's returning an accurate result. In the above scenario, it returns a bounding rect for the entire text node, effectively the same box as the entire paragraph, whereas with a collapsed selection at the end of the paragraph, we'd expect it to be zero width at an X coordinate aligned to the end of the paragraph.
I'm still finding 573269e to be the most durable approach, despite the fact that it requires manipulating the DOM to retrieve the rect. I wonder if we could exhaust all reasonable attempts to retrieve the rect before using the temporary span
technique only if we still cannot get a valid rect (where invalid is either undefined
or one with 0
'd property values).
Annoyingly, this appears to be a main point of browser inconsistency / bugginess. For example, in this snippet, adding a new line to the contenteditable causes the rect to become zero'd in latest Chrome, despite a handful of resolved Chromium bug reports which have claimed to have addressed these sorts of issues ([1], [2]).
Seeing modules like rangefix has me sad for the state of browser compatibility on these functions, and sadder that our end-to-end approach won't be able to test against a broad set of browsers. Nothing I've found seems to work perfectly, aside from bounding-client-rect
which uses basically the same approach as in 573269e . If it's a point of frequent regression across non-Chromium browsers that our E2E suite can't cover well, we could always extract these out these normalization functions into a separate project which uses a more thorough end-to-end approach (e.g. Selenium).
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.
TinyMCE obviously handles this well, where replacing this line:
gutenberg/blocks/rich-text/index.js
Line 662 in 049645b
rect = getRectangleFromRange( this.editor.selection.getRng() ); |
...with:
rect = this.editor.selection.getBoundingClientRect();
...will return the value we expect. Unsure if we can/want to be able to leverage this directly from our generic DOM utilities 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.
Yeah, this is what TinyMCE does:
Maybe we can use this through tinyMCE.dom.Selection
.
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.
We're also using TinyMCE's isEmpty check here.
utils/dom.js
Outdated
// selection direction. If `isReverse` is does not align to selection | ||
// being backward, invert the position. | ||
let position = isReverse ? 'start' : 'end'; | ||
if ( isReverse === isSelectionForward( selection ) ) { |
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.
Starting to wonder if we could do something like:
function getDirectedRangeObject() {
return {
anchorNode: startContainer,
anchorOffset: startOffset,
focusNode: endContainer,
focusOffset: endOffset,
} = window.getSelection();
return {
startContainer,
startOffset,
endContainer,
endOffset,
};
}
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.
Or even replace start
and end
with anchor
and focus
.
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.
(And just look at the selection object, not care about ranges inside.)
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.
utils/dom.js
Outdated
const maxOffset = node.nodeType === TEXT_NODE ? node.nodeValue.length : node.childNodes.length; | ||
|
||
if ( ! isReverse && offset !== maxOffset ) { | ||
return false; | ||
} | ||
|
||
const order = isReverse ? 'first' : 'last'; |
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.
Would this also need to invert? I haven't checked.
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.
Would this also need to invert? I haven't checked.
I don't think it should need to, since this is for traversing through parents at the extent of the node, where selection direction doesn't matter.
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.
yeah, came to the same conclusion in my branch :)
} | ||
|
||
if ( ! range || ! range.collapsed ) { | ||
const range = selection.rangeCount ? selection.getRangeAt( 0 ) : null; |
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.
Do we need the same logic as above 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.
Which logic are you referring to? Since isVerticalEdge
is working primarily off the rect
, I don't think we'll need similar logic with focusNode
and such.
Using focus offset appears to work well. Cherry-picked those commits into this branch. |
@@ -307,7 +284,7 @@ export function placeCaretAtVerticalEdge( container, isReverse, rect, mayUseScro | |||
// equivalent to a point at half the height of a line of text. | |||
const buffer = rect.height / 2; | |||
const editableRect = container.getBoundingClientRect(); | |||
const x = rect.left + ( rect.width / 2 ); | |||
const x = rect.left; |
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.
Just curious: what prompted this change?
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.
Specifically for TinyMCE's overrides on inline boundary traversal
1fb193d
to
e087ddd
Compare
Since may be passed as fragment, not always as array.
format: 'tree' is only available in versions newer than 4.7.2, thus requiring update
I've spent the better part of the last day and a half hacking away at these behaviors, finding myself stumbling into several rabbit holes along the way. I'd been tempted to merge in its previous approved state, as it was largely functional, though I've noticed a few intermittent test failures which have prompted me to commit my local changes. Some of them are only tangentially related to the goal, and given the number of them, it may be wise to fragment a few into separate pull requests against which to rebase. Or, we could bite the bullet and merge it as one. Specifically, I noted several issues with our handling of inline boundaries, of TinyMCE's bogus nodes, and of lingering zero-width space characters. A few of which can be reproduced on current master: Unable to horizontally traverse in a paragraph containing only inline boundary node:
Issue: TinyMCE leaves a bogus Resolution: We should check if the intended caret navigation will land in a reachable node. After a few iterations here, I finally landed on one similar to the original proposal, except not considering nodes and instead the effective caret position within text. † It's my understanding this could be a bug within TinyMCE itself. In TinyMCE Fiddle, usually the bogus Demo: https://codepen.io/aduth/pen/qYxPEM Held horizontal navigation from within inline boundary forces selection restore:
Issue: I haven't nailed this one down precisely, but I believe it has something to do with TinyMCE's selection restoration. Zero-width space character from inline boundaries saved to post content:
Issue: We don't filter empty nodes in the
TinyMCE's inline boundary zero-width spaces linger after blurring the field, causing inconsistent edge traversal behavior:
Issue: TinyMCE's inline boundary zero-width character delineater remains when blurring the field, and the variation of its presence has an impact on where our Resolution: Not implemented here, but there is some "clean-up" that TinyMCE performs on Inline boundary zero-width space occurs at beginning of node for new inline node:
Issue: This is mainly problematic in detecting where the caret will land on a horizontal arrow press. It would be most convenient to check in a forward movement that the next character is a ZWSP, but in the above edge case this is not correct. Resolution: A workaround has been implemented to check for ZWSP at the beginning for forward arrow movement. |
May cause React to complain about lack of keys, but existing blocks map from value with expectation it's array (they probably shouldn't).
cc @spocke as well re: confirming potential bugs noted above. I've unfortunately needed to introduce a few "wait for" promises in the end-to-end tests, as it appears there can be some race conditions with TinyMCE readiness, or at least this appears to be the case. |
The same issue would appear if there is a trailing BR node without the bogus attribute, so wouldn't it be better to ignore trailing BR nodes in general? I'd prefer if we merge the previously approved state because I'm a bit confused with all the changes. Would be great if there's (a) PR(s) exactly fixing these cases where we can discuss the best solution and make sure there is no further performance degradation. |
I've been looking at this again between Friday and today, and in addition to the aforementioned pull requests, there's a few separate tasks which could be tackled independently:
The ZWSP handling in particular has kept me thinking. While it would be nice to take advantage of TinyMCE's tree form, since we can't use it consistently (for filtering split content), I think we'll still want to have support for removing empty nodes from a DOM tree. I think there's an opportunity to consolidate many like behaviors:
Work-in-progress looks something like: export function domToElement( node ) {
return nodeListToReact( node.childNodes );
}
export function domToString( node, editor ) {
return editor ? editor.serializer.serialize( node ) : node.innerHTML;
}
export function removeEmptyChildNodes( node ) {
for ( let i = 0; i < node.childNodes.length; i++ ) {
const child = node.childNodes[ i ];
switch ( child.nodeType ) {
case Node.TEXT_NODE:
if ( REGEXP_TINYMCE_ZWSP.test( child.nodeValue ) ) {
child.nodeValue = child.nodeValue.replace( REGEXP_TINYMCE_ZWSP, '' );
if ( ! child.nodeValue ) {
node.removeChild( child );
}
}
break;
case Node.ELEMENT_NODE:
if ( child.hasAttribute( 'data-mce-bogus' ) ) {
if ( child.getAttribute( 'data-mce-bogus' ) !== 'all' ) {
while ( child.firstChild ) {
node.insertBefore( child.firstChild, child );
}
}
node.removeChild( child );
}
break;
}
if ( child.nodeType === Node.TEXT_NODE && REGEXP_TINYMCE_ZWSP.test( child.nodeValue ) ) {
child.nodeValue = child.nodeValue.replace( REGEXP_TINYMCE_ZWSP, '' );
if ( ! child.nodeValue ) {
node.removeChild( child );
}
}
removeEmptyChildNodes( child );
}
}
export function domToFormat( nodes, format, editor ) {
const fragment = document.createDocumentFragment();
for ( let i = 0; i < nodes.length; i++ ) {
fragment.appendChild( nodes[ i ].cloneNode( true ) );
}
removeEmptyChildNodes( fragment );
switch ( format ) {
case 'string':
return domToString( fragment, editor );
default:
return domToElement( fragment );
}
} Needs to be particularly conscious of performance impact of creating / cloning nodes. Not sure if copying node attributes to an object form might be faster than There's more "clean-up" which needs to occur to strip TinyMCE attributes and placeholders in the filtering function. Ideally we could leverage some of TinyMCE's internals to avoid recreating this, or at least it should be based on a like implementation. |
This has stagnated and is beyond saving at this point. Many of the ideas have inspired other pull requests, and yet more ideas included here are still valid and could be pulled out into their own separate pull requests, which I'll explore. |
Fixes #5095
This pull request seeks to resolve an issue where it is not possible to unexpand a selection from the beginning of a paragraph block. I'm not entirely clear the intent with collapsing range in the
isHorizontalEdge
function, though it would seem that it should be based on the direction of the current selection itself, not necessarily the direction being considered as the edge.Testing instructions:
Verify that various shift-selection within and across blocks works as expected, particularly the previously failing behavior: