Skip to content
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

RichText: Keep caret visible when typing on mobile #5769

Merged
merged 8 commits into from
Apr 5, 2018

Conversation

mcsf
Copy link
Contributor

@mcsf mcsf commented Mar 23, 2018

Description

A smaller iteration of #4775. Partly fixes #4731.

This PR is a spin-off of #4775 that focuses on keeping in sight the area into which a user is typing. The secondary goal is to leave a small footprint and reducing exceptions (e.g. per viewport width, per user agent).

A notable departure from #4775 is that this PR doesn't just cover Enter presses, but also line wraps — i.e., when enough text is typed that it is placed on a new line in the editor. This aggressive change warrants some extra testing, but, in my own testing, is a welcome addition.

How Has This Been Tested?

  • Make sure the experience of editing content on a desktop device is unchanged: (un)focusing blocks, typing in RichText elements (type enough characters so as to experience line wrap), pressing Enter in multiline blocks (e.g. Quote) and in non-multiline (e.g. Paragraph).
  • Test the mobile experience, ideally also on non-iOS devices (of which I currently have none). Try the same things as for desktop.

Screenshots (jpeg or gifs if applicable):

Types of changes

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code has proper inline documentation.

@mcsf mcsf added Mobile Web Viewport sizes for mobile and tablet devices [Feature] Writing Flow Block selection, navigation, splitting, merging, deletion... labels Mar 23, 2018
window.scrollTo(
window.pageXOffset,
window.pageYOffset + caretHeight - toolbarOffset
);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scrollIntoView was behaving quite awkwardly for me, scrolling a bit unpredictably. I'd still rather use whatever our preferred interface is, at least one that looks at scrollable containers. For the sake of having something demoable I pushed this anyway (and there's something nice about just calling window.scrollTo).

@@ -28,6 +28,7 @@
// Collapse to minimum height of 50px, to fully occupy editor bottom pad.
height: 50px;
width: $content-width;
max-width: 100%;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't let this grow beyond the viewport, thereby avoiding the following:

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebasing this. Seems addressed in #5945. Dropping this line. Correct me if I'm wrong.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I misunderstood Miguel's initial comment here, but yes, changing to move the fixed value from the width to max-width should be better here. I.e.

width: 100%;
max-width: $content-width;

should be right.

@jasmussen
Copy link
Contributor

Holy guacamole, thank you so much for working on this.

Holy moly guacamole IT'S WORKING IT'S WORKING

https://media.giphy.com/media/9K2nFglCAQClO/200.gif

GIF:

itsworking

This cannot be merged in fast enough. SHIP IT SHIP IT!

I can't believe you two did it. Ella proved it was possible and nearly fixed it. This takes it home. While I imagine future improvements can be made, none of them feel critical at all to me, as this is working so well for me.

👏 👏 👏 👏 👏 👏 👏 👏 🔥

const caretRect = this.getEditorSelectionRect();
const caretHeight = caretRect.y;
if ( caretHeight !== this.caretHeight ) {
const toolbarOffset = 100;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which toolbar does this refer to? And should the RichText component have built-in knowledge of said toolbar's height? When we redesign the toolbar, who will know to update this value accordingly?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And should the RichText component have built-in knowledge of said toolbar's height?

It shouldn't. :)

Which toolbar does this refer to?

I grabbed the name from a comment in the parent PR, but I actually think of it independently of any toolbars. It's more of a "grace offset" so that we don't scroll to place the caret at the very top of the viewport: by leaving some empty space, we can incidentally accommodate toolbars, but it's more about scrolling to position the caret in a more central place, and perhaps revealing the end of the previous block to provide context.

When we redesign the toolbar, who will know to update this value accordingly?

From my previous reasoning, there's no imperative to synchronize the values. That said, it is a magic constant, and is disconnected from the toolbar. Other ways of addressing include computing the offset dynamically, e.g. 1/3 · viewportHeight.

Until further feedback, I'll rename and comment to clarify the intent.


scrollToCaret() {
const caretRect = this.getEditorSelectionRect();
const caretHeight = caretRect.y;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it accurate to label this as a height value? Based on .y, I'd think it's a point offset.

Copy link
Contributor Author

@mcsf mcsf Mar 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, what isn't accurate is to grab .y, since

[1] IE and Edge support the non-standard MSDN: ClientRect which does not define x and y properties (#)

in non-IE, .y and .top hold the same value* for caretRect, so I'll just switch the property. Thanks for asking.

*: Returns the top coordinate value of the DOMRect (has the same value as y, or y + height if height is negative.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit: I'd written height when I meant top 🙈 . Updated.

scrollToCaret() {
const caretRect = this.getEditorSelectionRect();
const caretHeight = caretRect.y;
if ( caretHeight !== this.caretHeight ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This compares to an instance value, meaning on its first invocation, it will always call window.scrollTo. Do we need the instance value, or can we just compare against the desired pageYOffset to see if we need to perform a scroll?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Actually, one thing we can do is:

if ( caretHeight !== this.caretHeight ) {
  const delta = caretTop - toolbarOffset;
  if ( Math.abs( delta ) > epsilon ) {
    window.scrollTo( window.pageXOffset, window.pageYOffset + delta );
  }
}

where epsilon is as an arbitrarily small number, e.g. 10.

This solves the problem of pointless scrolling more generally.

@mcsf
Copy link
Contributor Author

mcsf commented Mar 26, 2018

16023be addresses Andrew's feedback and makes RichText more conservative about scrolling. d99d1ac, I find, greatly helps with the scrolling stutter experienced when pressing Enter into a new block.

if ( Math.abs( delta ) > epsilon ) {
window.scrollTo(
window.pageXOffset,
window.pageYOffset + caretTop - graceOffset
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: You could reuse delta here, window.pageYOffset + delta

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wondering why I was seeing unpredictable behavior on larger viewports. It seems specific to the fact that we assume window is the overflow area we want to scroll within. In desktop, the editor canvas is the scrollable region. Worse yet, desktop still tries to scroll (at least at shorter viewports where a scroll exists), but does so on the wrong element.

Should we use utils.dom.getScrollContainer ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I meant to circle back to getScrollContainer before merging, sorry it wasn't clear.

Minor: You could reuse delta here, window.pageYOffset + delta

D'oh, that was the idea.

scrollToCaret() {
const caretRect = this.getEditorSelectionRect();
const caretTop = caretRect.top;
if ( caretTop !== this.caretTop ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still not clear why we need this condition or the this.caretTop instance variable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My original thinking was that, when typing in a line, this would be a cheaper test than computing delta from window.pageYOffset. However, now reading the documentation for window.scrollY, of which pageYOffset is an alias, there's no mention of any incurred cost of reading this property. In fact, it reads:

Use this property to check that the document hasn't already been scrolled when using relative scroll functions such as scrollBy(), scrollByLines(), or scrollByPages().

Which is enough to convince me to drop this eq check.

@@ -558,6 +558,38 @@ export class RichText extends Component {
if ( keyCode === BACKSPACE ) {
this.onChange();
}

// `scrollToCaret` is called on `nodechange`, whereas calling it on
// `keyup` *when* moving to a new RichText element results in incorrect
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we assume backspace or enter would result in a new RichText element? Doesn't this depend on presence of onSplit and onMerge props?

Wondering also if this is a behavior that ought to be handled by individual instances of RichText, vs. a global handler (WritingFlow?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this depend on presence of onSplit and onMerge props?

It does; I've been trying out different heuristics but wanted to maintain momentum. Not sure what to converge on. In practice, I'm seeing good behaviors on paragraph, quote and list, which cover a decent set of multiline / onSplit / onMerge combinations.

WritingFlow?

On one hand, it makes architectural sense; on the other, RichText may be the best entity to track the caret in its own editor, so I'm not sure. It'd be great to have this in the next release, though, which is why I followed the approach of the parent PR.

// When scrolling, avoid positioning the caret at the very top of
// the viewport, providing some "air" and some textual context for
// the user, and avoiding toolbars.
const graceOffset = this.props.isViewportSmall ? 100 : 300;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't very with reading clientHeight on every key release, and also with the fact that it was blindly grabbing a third of the height when we don't know what the device actually looks like, so I went with this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't very with reading clientHeight on every key release

Perhaps I'm just uninformed, but what's the reason to think this is not a trivial property access?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to find a reference, but maybe I dreamt the whole thing. I thought I recalled Paul Lewis from Google, who often talks about performance, bringing up certain read interfaces of the DOM that were more expensive than one would suspect. I was looking here but didn't find anything. Maybe it's totally fine.

Anyway, per my other comment about rolling this out for mobile only, do you think it's still worth having clientHeight / 3.0, or would the magic 100 be enough?

@mcsf
Copy link
Contributor Author

mcsf commented Mar 26, 2018

One limitation right now is that there's no scrolling when a user presses shift+enter. This is because, though it would be straightforward to allow scrolling for enter if shift is also down, the caret computed at that instant points to the top of the RichText element and not where the caret really is.

I'd rather look at that later. The primary purpose of this PR was to improve the mobile experience, where by default no external keyboards are used and thus where shift + enter is not an expected input.

@jasmussen
Copy link
Contributor

I'd rather look at that later. The primary purpose of this PR was to improve the mobile experience, where by default no external keyboards are used and thus where shift + enter is not an expected input.

This is super key, and thanks again for working on this.

// When scrolling, avoid positioning the caret at the very top of
// the viewport, providing some "air" and some textual context for
// the user, and avoiding toolbars.
const graceOffset = this.props.isViewportSmall ? 100 : 300;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't very with reading clientHeight on every key release

Perhaps I'm just uninformed, but what's the reason to think this is not a trivial property access?

-webkit-overflow-scrolling: touch;
}
// Pad the scroll box so content on the bottom can be scrolled up.
padding-bottom: 50vh;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This padding causes an unexpected shift of collapsed meta boxes in desktop:

image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

D'oh, thanks. I tried placing the padding in other places, but they end up affecting mobile scrolling (though not this PR's), presumably due to the addition of a scrolling container. Since I'd like to retain this feature for mobile only and I'm short of ideas, I've pushed f1a4108.

const epsilon = 10;
const delta = caretTop - graceOffset;

if ( Math.abs( delta ) > epsilon ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we check to see whether the desired location is already within viewport, even if it's not necessarily at the "top"? It's odd when clicking around on blocks in desktop that the viewport jumps constantly, even when the blocks are already visible in screen.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yesterday I got lost in a rabbit hole with this, discovering strange edge cases, issues on desktop. I was initially happy as I thought I'd nicely add this to all devices, but now I'm thinking we should retain the is-mobile-device filter from the parent PR...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if it may be fine to have also on desktop, it would be nice to wrap an interim version of this PR because the improvements it means for mobile are so huge. If it means disabling it for desktop and potentially revisiting for desktop in the future, that's okay I feel.

@mcsf
Copy link
Contributor Author

mcsf commented Apr 2, 2018

Let's get this in and let folks test ahead of release. What do you say, @aduth?

const delta = caretTop - graceOffset;

if ( Math.abs( delta ) > epsilon ) {
container.scrollTo(
Copy link
Member

@aduth aduth Apr 2, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm finding this is not very durable, or at least I'm finding some browser inconsistencies. The container here will be document.body. If you open your browser's Developer Tools console right now (on GitHub.com) and enter:

document.body.scrollTo( 0, 500 );

I expect nothing will happen.

... That is, of course, unless you're running Safari or, more precisely, iOS Safari (though desktop works just as well), where it will scroll.

I'm not familiar with why this is different, though I suspect that when there is no explicit scroll container, Chrome and Firefox expect scrolls to be made via window.scrollTo (which does scroll).

Though the fact that getScrollContainer is returning document.body could itself be concerning since, as mentioned, it's not an explicit scroll container. It does qualify for document.body.scrollHeight > document.body.clientHeight. Maybe it's because there's an overflow-x: hidden ? I've had some previous experience with issues where even one direction of overflow has effects in both directions.

If we expect to call scrollTo on the result of getScrollableContainer, then I think we ought to normalize so that if the browser must call window.scrollTo on a non-explicit container, then getScrollableContainer should return window. This means either improving the detection of a scrollable container, or returning window when the result would otherwise be document.body.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm finding this is not very durable […] That is, of course, unless you're running Safari or, more precisely, iOS Safari (though desktop works just as well), where it will scroll.

Thanks for digging. The fact is I haven't tested this on Android yet, as I currently don't own any such device.

If we expect to call scrollTo on the result of getScrollableContainer, […]

All that sounds good, I'll see how much we can get out of the body->window normalization.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies for not testing this sooner — I only tested on iPhone and in the browser and it worked fine there.

I retested, and in the Android emulator it's working pretty damn well for me:

android emulator

I also tested using the Chrome "mobile simulator" (which is just desktop chrome with a small viewport and fancy mobile scrollbars, so it's not accurate), but I got a few JS errors there:

browser

Let me know how I can help with any other testing.

@mcsf mcsf added this to the 2.6 milestone Apr 5, 2018
return;
}

const { top: caretTop } = this.getEditorSelectionRect();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think getFocusPosition is now equivalent to getEditorSelectionRect? So we could pass that in nodeChange?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit tied up today, but if you're interested and have spare time, I'd be grateful if you had a go. :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure!

// scrolling. Though the following allows false positives, it results
// in much smoother scrolling.
if ( keyCode !== BACKSPACE && keyCode !== ENTER ) {
this.scrollToCaret();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really want to run this stuff on every keystroke, down the line: https://gist.github.com/paulirish/5d52fb081b3570c81e3a#range.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe this is fine if there are no layout changes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the list I was looking for the other week!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah but the layout is not invalidated... so this seems fine. I don't see any issues atm.


const { top: caretTop } = this.getEditorSelectionRect();

const container = getScrollContainer( this.editor.getBody() );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be cached?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say yes, but what would invalidate it? Resizing? Viewport breakpoints? Or do we assume the container to be constant throughout RichText's lifetime?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, right, I somehow thought it would remain constant.

@ellatrix ellatrix force-pushed the try/fix-ios-caret-visibility branch from f1a4108 to cf1729b Compare April 5, 2018 15:40
@ellatrix
Copy link
Member

ellatrix commented Apr 5, 2018

Rebased and addressed #5769 (comment).

While testing on iOS Safari I run into some strange issues... When I focus the text all text sometimes goes blank... Can't reproduce consistently and probably unrelated to this but just in case someone else experiences this...

@jasmussen
Copy link
Contributor

Gave it a spin and my previous issues are gone.

Wasn't able to easily test in the simulator, but assuming that hasn't regressed since I tested earlier today, this is 👍 👍 to ship.

The chief reason to ship this is that it brings huge improvements to iOS which frankly is in a somewhat sorry state right now. Even if it turns out that there are some aspects of this PR that aren't perfect, it might still be worth shipping this and fixing separately.

THANK YOU for working on this, Ella and Miguel. It's sooo much better than before.

@youknowriad youknowriad merged commit 8361a9c into master Apr 5, 2018
@youknowriad youknowriad deleted the try/fix-ios-caret-visibility branch April 5, 2018 17:01
@jasmussen
Copy link
Contributor

🔥 🔥 🔥 🔥 🔥 🔥

WOOOOOOOOOOO

ƪ(˘⌣˘)┐ ƪ(˘⌣˘)ʃ ┌(˘⌣˘)ʃ

(ノ^v^)ノ彡┻━┻

export default withSafeTimeout( RichText );
export default compose( [
withSelect( ( select ) => {
const { isViewportMatch = identity } = select( 'core/viewport' ) || {};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the fallbacks here for? Why = identity ? Why || {} ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the fallbacks here for? Why = identity ? Why || {} ?

Buuuuuuump.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't think of a good reason. We should remove that. Trying the new Suggestion feature!

Suggested change
const { isViewportMatch = identity } = select( 'core/viewport' ) || {};
const { isViewportMatch } = select( 'core/viewport' );

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestions cannot be applied while the pull request is closed.

😞

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I approve 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Feature] Writing Flow Block selection, navigation, splitting, merging, deletion... Mobile Web Viewport sizes for mobile and tablet devices
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Improve soft-keyboard handling on mobile
5 participants