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

Vertical arrow key navigation #2988

Merged
merged 3 commits into from
Oct 24, 2017
Merged

Vertical arrow key navigation #2988

merged 3 commits into from
Oct 24, 2017

Conversation

ellatrix
Copy link
Member

@ellatrix ellatrix commented Oct 11, 2017

Description

This is a rebase of #2296.

It allows the caret in a block to be vertically repositioned across focusable elements.

How Has This Been Tested?

In e.g. the demo content, place the caret in an editable container. Use the UP and DOWN arrow keys to navigate across editable areas and blocks.

Checklist:

  • My code is tested. (No unit test because it uses layout.)
  • My code follows the WordPress code style.
  • My code follows has proper inline documentation.

@ellatrix ellatrix added the [Status] In Progress Tracking issues with work in progress label Oct 11, 2017
@ellatrix ellatrix changed the title Improved arrow key navigation Vertical arrow key navigation Oct 11, 2017
@ellatrix
Copy link
Member Author

ellatrix commented Oct 11, 2017

There's an issue with positioning the caret when you try to move it up vertically and the toolbar is in the way... Then it will try to focus a button in the toolbar. 😄 Fixed.

@ellatrix ellatrix force-pushed the try/improved-arrow-nav branch 4 times, most recently from 71002ed to 2a19bfc Compare October 11, 2017 17:30
@ellatrix
Copy link
Member Author

ellatrix commented Oct 11, 2017

So far it works perfectly in Chrome and Safari. Firefox is a complete mess again. Being addressed in #2986. Done.

@ellatrix
Copy link
Member Author

Getting quite happy with this. Squashed down a few other bugs. On rare occasions the browser fails to move position, have yet to figure out these few edge cases.

@ellatrix
Copy link
Member Author

Fixed an issue where the new range is not within the target container.
Fixed an issue where vertical arrow keys wouldn't work in an empty container.

@ellatrix
Copy link
Member Author

Encountered one more bug with the button block. It will attempt to put the caret next to it but fail. :) Will fix in a bit.

@jasmussen
Copy link
Contributor

This seems to be working QUITE well. Impressive. Lovely.

As you get near the end of the demo content, the page seems to scroll in jumps, so to speak. This can make it a little easy to lose focus of where the caret is. This is very possibly just normal browser behavior, in which case ignore crazy old Joen. But otherwise — what could this be and is there a way to make it smoother, perhaps that typewriter approach?

@jasmussen
Copy link
Contributor

Inside the code block, the range stuff doesn't work. Which is totally fine, it doesn't have to — but figured I'd mention it in case it's like a one line code change.

@ellatrix
Copy link
Member Author

ellatrix commented Oct 13, 2017

I think that might be the bug I mentioned. I know you'd like that typewriter feel in general, but let's attempt that in a separate PR. 😄
I'll look at the code block. We have to put at the start or end there, there's no way around that for inputs and text areas. :/

@jasmussen
Copy link
Contributor

I know you'd like that typewriter feel in general, but let's attempt that in a separate PR. 😄

Totally, no worries.

I'll look at the code block. We have to put at the start or end there, there's no way around that for inputs and text areas. :/

No worries — this enhancement is way more useful for the editables. Doesn't have to work elsewhere imo.

Copy link
Contributor

@mcsf mcsf left a comment

Choose a reason for hiding this comment

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

Here's a first review. I think we should tackle the matter of firstVerticalRect.

I will now switch context for a bit so that I can come back to this later with a clearer head and see if I can help.

return null;
}

return node;
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd noticed this usage of reduce just the other day in master, and my question still stands in this PR. The above method call can be modelled as:

xs.reduce( ( result, x, i ) => result || ( pred( x, i ) ? x : null )

which to me looks like Array#find in disguise (and more expensive). Is it not?

// import { find } from 'lodash'
find( xs, pred )

Feel free to correct me if I missed something. :)

const focusableNodes = this.getVisibleTabbables();
if ( direction === 'UP' ) {

if ( reverse ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Even though this.getVisibleTabbables will return a new instance on each call and we can see that detail in this very component, I tend to be careful with in-place modifications like Array#reverse. I'll leave it up to you, but I would either use lodash#reverse or even [ ...focusableNodes ].reverse().

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't really see the problem here? Should I change it?

const rangeRect =
range.startContainer.nodeType === window.Node.ELEMENT_NODE ?
range.startContainer.getBoundingClientRect() :
range.getClientRects()[ 0 ];
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor point, but I find that multi-line ternaries read better like:

a
  ? b
  : c

but currently our linter has no preference.

Copy link
Member Author

Choose a reason for hiding this comment

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

Fair enough. I've been used to this way. Maybe we should add a rule so that it's the same everywhere?

Choose a reason for hiding this comment

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

My only worry is that the Javascript interpreter of some browser out there will see the condition as the end of the statement if the question mark is not the same line. It may be changed in transpiling though so if Babble gets it right then I suppose I don't need to worry.

Copy link
Member Author

Choose a reason for hiding this comment

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

That's probably why I've been used to it this way.

Copy link
Contributor

Choose a reason for hiding this comment

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

I had a whole reply with an excerpt of the ECMA spec and an analysis making the case that only a faulty browser would be too eager to insert a semicolon in this case, but then I found the right Crockford source. :)

The ternary operator can be visually confusing, so ? question mark always begins a line and increase the indentation by 4 spaces, and : colon always begins a line, aligned with the ? question mark. The condition should be wrapped in parens.

My take is that Crockford has historically been a conservative reference on JS (and, apparently, a previous version of the same text had advised against having an operator at the beginning of a line), and thus his OK means that we're totally safe here and that it's ultimately a stylistic decision. How do y'all weigh in?

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't have a strong opinion. If browsers are a worry, we anyway process with Babel and everything gets minified.

// Editable was already focussed, it goes back to old range...
// This fixes it.
selection.removeAllRanges();
selection.addRange( range );
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you provide more detail on the need to repeat this? Is it a quirk?, is it something otherwise preventable?, etc.

Copy link
Member Author

Choose a reason for hiding this comment

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

I suspect it might be related to TinyMCE's editor.selection.lastFocusBookmark, but I need to investigate. Maybe @ephox-mogran or @EphoxJames know more.

Choose a reason for hiding this comment

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

I don't know off the top of my head but I will dig into the code and see if I can find out.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks! Ideally we wouldn't have to touch the TinyMCE editor instance at all here.


const range = caretRangeFromPoint( document, x, y );

container.style.zIndex = null;
Copy link
Contributor

Choose a reason for hiding this comment

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

Hm, bummer. :)

Local testing (on this very page) indicates that container.style.pointerEvents = 'none'; is just as effective. Should we prefer that one instead? I wonder if that way we minimize our chances of generating visual artefacts.

Copy link
Member Author

Choose a reason for hiding this comment

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

How would that be used? On the toolbar element? That would require us to have the toolbar element, which would be less isolated logic. In that case I'd prefer the z-index approach.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, it would be on the toolbar — I was looking at this so closely that I didn't weigh the drawback of having to deal with yet another entity. z-index may be the lesser evil, then.

*
* @type {DOMRect}
*/
let firstVerticalRect;
Copy link
Contributor

Choose a reason for hiding this comment

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

My biggest concern is this global here. It's a concern in general, but aggravated by the fact that we are in a utils module, where the expectation of lingering state is low, and that the main function that sets it is a seemingly innocent predicate, isVerticalEdge.

I see it as a definite code smell that we should tackle, probably before merging. Does it mean that the predicate should become something else? That it should be broken into different pieces? That it should become a function returning a DOMRect or null? Relatedly, onMouseDown={ resetVerticalPosition } on WritingFlow is an implementation detail that shouldn't be leaking.

Copy link
Member Author

@ellatrix ellatrix Oct 13, 2017

Choose a reason for hiding this comment

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

Any ideas would be welcome! I'll have a think.

Copy link
Contributor

Choose a reason for hiding this comment

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

Can firstVerticalRect just be passed in, and have its state / value managed in WritingFlow itself? It is essentially part of the state of writing flow, if that's where we are doing the cursor movement.

Copy link
Member Author

Choose a reason for hiding this comment

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

Okay, I changed this.

*/
export function placeCaretAtEdge( container, start = false ) {
export function isVerticalEdge( container, reverse ) {
if ( [ 'INPUT', 'TEXTAREA' ].indexOf( container.tagName ) !== -1 ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: naming this, as is done in other parts of the diff, would be nice: const isInputOrTextarea =

Copy link
Member Author

Choose a reason for hiding this comment

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

I almost changed the other one. :) What makes you prefer it?

Copy link
Contributor

Choose a reason for hiding this comment

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

That indexOf isn't very descriptive and the variable assignment counters that. I would also go, instead, for lodash#includes.

const right = keyCode === RIGHT;
const reverse = up || left;
const horizontal = left || right;
const vertical = up || down;
Copy link
Contributor

Choose a reason for hiding this comment

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

As some of the functions here and in dom/utils grow, keeping track of boolean variables becomes harder. Aside from a [modal]+[main verb] nomenclature (with modal taking values in { is, should, can, may, will, … }), I'd love to see more descriptive names throughout. Examples:

  • upisUpKey
  • reverseisAscending
  • noScroll → logic reversal, becoming mayUseScroll

@ephox-mogran
Copy link
Contributor

@iseulde I've found one issue that might be making it a little inconsistent. It's most visible when using the table block. If you setup a paragraph below the table and after the table, and try to navigate from below the table up, or above the table down, then the caret doesn't stay in the same horizontal position when it is entering the table.

This is because of two reasons:

  1. There is padding on the table cells which interferes with y positions of nodes. This directly impacts (2)
  2. The caretRangeFromPosition method can return a range that has a bounding rectangle that does not include the (x, y) coordinate passed in. It is a 'close-enough' type of API that is designed for finding the nearest text. The problem is that when it finds a text node, but the (x, y) is not actually inside that text node, it gets the offset wrong and just chooses either the start of the node or the end of the node, which is not what you want.

In order to offset this, you can run the caretRangeFromPosition method again, but this time, use an (x, y) that is bounded within the getBoundingClientRect of the range it returned initially. In this case, just use the same x and use a y that is either the rect.top or rect.bottom (or potentially offset that number by one or two). This then means that it is looking within the text itself (and not inside the padding), and it will give you an accurate offset.

Now, you may get a problem where it will just keep recursing indefinitely, because it can't find a text position that is any better than it's first guess. Therefore, you should quit after some number of retries. It shouldn't be very many.

Note, the cursor definitely doesn't keep the same position when moving through the table itself, but that should be logged against TinyMCE.


@ellatrix ellatrix force-pushed the try/improved-arrow-nav branch 2 times, most recently from 55ebf3c to 5f731cd Compare October 18, 2017 17:51
@mcsf
Copy link
Contributor

mcsf commented Oct 20, 2017

@iseulde: a224863 fixes an occasional bug I was seeing, which can be reproduced consistently this way:

  1. Open the Gutenberg Demo
  2. Place the cursor at Of mountains & →|←printing presses
  3. Keep hitting the down arrow
  4. An error should be thrown when switching into block "... like this one, which is right aligned."

@ephox-mogran
Copy link
Contributor

I'm looking into seeing why

In the master branch, as well as in this branch, reverse horizontal navigation breaks if there is formatting. Press the left arrow key at the start of a paragraph, and it will end of at the end of a formatting tag in the previous editable. As far as I see, this seems to be caused by range.collapse().

is happening.

@ephox-mogran
Copy link
Contributor

ephox-mogran commented Oct 23, 2017

@iseulde , it looks like what you are seeing is a bug in tinymce. I've recreated it in a fiddle here (http://fiddle.tinymce.com/Scgaab/1). As soon as you remove the tinymce.init call, it works again.

@mcsf
Copy link
Contributor

mcsf commented Oct 23, 2017

@iseulde & @ephox-mogran, drive-by comment before I have to run: breaking on selectionchange yields the following:

screen shot 2017-10-23 at 18 35 48

}

return true;
} );
}

onKeyDown( event ) {
const { keyCode, target } = event;
Copy link
Contributor

Choose a reason for hiding this comment

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

I missed this until now, but FYI KeyboardEvent.keyCode has been deprecated and KeyboardEvent.key is to be preferred.

Something to revise in the future?

Copy link
Member Author

Choose a reason for hiding this comment

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

Probably. We've been using it in Gutenberg quite a lot, so does TinyMCE. Maybe better to discuss separately.

Copy link
Member

Choose a reason for hiding this comment

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

Previously I'd avoided key because of browser support; I don't recall if this is specifically an older IE thing, or due to the non-standard behavior described in the "Notes" at:

http://caniuse.com/#feat=keyboardevent-key

@ellatrix
Copy link
Member Author

@ephox-mogran Yeah, the fiddle illustrates this well. Only breaks in Chrome for me, not Safari.

@mcsf
Copy link
Contributor

mcsf commented Oct 24, 2017

Re

In the master branch, as well as in this branch, reverse horizontal navigation breaks if there is formatting. Press the left arrow key at the start of a paragraph, and it will end of at the end of a formatting tag in the previous editable. As far as I see, this seems to be caused by range.collapse().

Culprit appears to be a fix for browser quirks:

https://github.com/tinymce/tinymce/blob/d2c9a9a03e3e2adabcd01a46d65640e49f5beb75/src/core/src/main/js/util/Quirks.js#L529-L544

Fixes selection issues where the caret can be placed between two inline elements like <b>a</b>|<b>b</b> this fix will lean the caret right into the closest inline element.

I didn't spot any obvious override mechanism to get around this quirk fix. I played a bit with event interception and tinymce.init({ setup }), to no avail. @ephox-mogran, short of pushing an upstream change, is there any sort of TinyMCE-fu that could help us here? :)

@ephox-mogran
Copy link
Contributor

ephox-mogran commented Oct 24, 2017

@mcsf the fix in normalisation of ranges for this is in code review in the TinyMCE development team. Outside of waiting for that fix for core to be merged, there's not much you can do. It's a high priority.

@ephox-mogran
Copy link
Contributor

@mcsf , @iseulde , how many blocking issues are there still with this PR? Are there any we have replication cases for (aside from the normalisation range bug in TinyMCE above?)

Specifically, is this still happening?

Sometimes ranges are not set (no idea what the reason is), or they are set at the last focussed range. The latter is fixed with a hack for vertical navigation, so either we add this hack for horizontal too, or we find a better way to fix this.

@mcsf
Copy link
Contributor

mcsf commented Oct 24, 2017

the fix in normalisation of ranges for this is in code review in the TinyMCE development team.

Great to know! Can you keep us posted? Seems like we can hold off this PR till EOW, perhaps?

Specifically, is this still happening?

I don’t think I ever ran into that one.

how many blocking issues are there still with this PR?

None on my part. How about you, @iseulde?

@ephox-mogran
Copy link
Contributor

ephox-mogran commented Oct 24, 2017

Great to know! Can you keep us posted? Seems like we can hold off this PR till EOW, perhaps?

No problem. I'll keep you posted.

Also, given that this bug is already in master, and has to do with horizontal navigation and not vertical navigation, it probably shouldn't hold up this PR which is focusing on vertical navigation. Especially, as it's an internal TinyMCE bug. Would you agree @mcsf ?

@mcsf
Copy link
Contributor

mcsf commented Oct 24, 2017

Good point. We should also rebase and retest.

ellatrix and others added 3 commits October 24, 2017 21:37
- Split `getVerticalEdge` into two separate helpers: `computeCaretRect`
  and `isVerticalEdge`.

- Rearrange the logic in `WritingFlow#keyDown` to make the symmetries
  between horizontal- and vertical-navigation handlings more apparent,
  as well as the lifecycle of `this.verticalRect`.
Copy link
Contributor

@ephox-mogran ephox-mogran left a comment

Choose a reason for hiding this comment

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

Aside from my issue with using textContent for non text nodes when dealing with offsets (which is already in master, so not related to this PR), it looks good to me.

@mcsf mcsf merged commit 6e9b429 into master Oct 24, 2017
@mcsf mcsf deleted the try/improved-arrow-nav branch October 24, 2017 20:50
@ephox-mogran
Copy link
Contributor

@mcsf should I rebase #3038 now based on this and update the PR? Or do you still have another approach in mind that you want to try first?

@mcsf
Copy link
Contributor

mcsf commented Oct 24, 2017

You can go ahead and rebase the original approach, thanks!

@@ -71,7 +103,9 @@ class WritingFlow extends Component {
return (
<div
ref={ this.bindContainer }
onKeyDown={ this.onKeyDown }>
onKeyDown={ this.onKeyDown }
onMouseDown={ () => this.verticalRect = null }
Copy link
Member

Choose a reason for hiding this comment

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

It would have been good to assign this to a bound instance method so that it's not creating a new function reference (and thereby applying a change diff) on every render.

Copy link
Contributor

Choose a reason for hiding this comment

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

Darn, I'm usually stringent about those.

@ephox-mogran
Copy link
Contributor

@iseulde @mcsf

In the master branch, as well as in this branch, reverse horizontal navigation breaks if there is formatting. Press the left arrow key at the start of a paragraph, and it will end of at the end of a formatting tag in the previous editable. As far as I see, this seems to be caused by range.collapse().

This has been fixed as of TinyMCE 4.7.2.

@@ -20,6 +26,8 @@ class WritingFlow extends Component {

this.onKeyDown = this.onKeyDown.bind( this );
this.bindContainer = this.bindContainer.bind( this );

this.verticalRect = null;
Copy link
Member

Choose a reason for hiding this comment

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

Arriving at this pull request hopefully confused about what this variable is. Can we add some inline comments?

Copy link
Member

Choose a reason for hiding this comment

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

Might be good to make an issue :)

Copy link
Member Author

Choose a reason for hiding this comment

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

Comment coming.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Status] In Progress Tracking issues with work in progress
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants