-
Notifications
You must be signed in to change notification settings - Fork 102
feat: add keyboard suport for the canvas view #237
Conversation
Features: - Panning - Zooming - Fit to view - Reset view
🦋 Changeset detectedLatest commit: 65784f1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@rthor is attempting to deploy a commit to the Stately Team on Vercel. A member of the Team first needs to authorize it. |
This pull request is being automatically deployed with Vercel (learn more). 🔍 Inspect: https://vercel.com/statelyai/xstate-viz/8SiyzoquNwXNP8wfztuTbJVufoJg |
src/CanvasContainer.tsx
Outdated
if (positive.includes(key)) { | ||
return delta; | ||
} else if (negative.includes(key)) { | ||
return -delta; |
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.
return -delta; | |
return 0 - delta; |
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 a nit, feel free to ignore
@rthor Very nice! Spotted some odd behaviour in the top-right when you're on the tabs: |
An idea for a fix for the above would be not allowing WASD navigation if the element you're focusing has a role of 'tab'. Kinda weird, maybe there's a better way of doing this @Andarist? export const isTabLikeElement = (el: HTMLElement) => {
return el.getAttribute('role') === 'tab';
}; |
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.
Thank you for an excellent PR! I have left some nits and requested minor changes. I've tested this though and the core of the functionality looks great. It would be nice to get some tests for this - but it's definitely more important for this to be merged in sooner than later so if you don't have time to figure out how to write Cypress tests for this - it's OK. We should add them eventually :p
BTW. I'm also working today on the Hand icon so you could switch to the Pan mode when that gets pressed. I hope this will also be a nice improvement in this area.
src/CanvasContainer.tsx
Outdated
const dx = getDeltaX(e.key, e.shiftKey); | ||
const dy = getDeltaY(e.key, e.shiftKey); | ||
|
||
if (e.key === '+') { |
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.
q: I've noticed in a different diagramming software that =
works for this and Shift+= gets actually ignored. I'm OK with the chosen solution - just wondering if you know what's more common, from your experience?
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.
In my experience +
seems to be more common, but I have no preference, and am happy to do whichever you prefer.
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.
Let's stick to +
then 👍 I definitely have no preference of my own here
src/CanvasContainer.tsx
Outdated
} else if (e.key === 'f') { | ||
e.preventDefault(); | ||
canvasService.send('FIT_TO_VIEW'); | ||
} else if (dx !== 0 || dy !== 0) { |
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.
nit: I would probably prefer for this condition to be more explicit. All preceding branches test the key first, before mapping that to any inner logic - and while I don't care if we list the exact keys here (although I find it useful), I feel like not having that explicit key-based test here blurs the logic a little bit.
With keys/tests listed here, we can quickly look at this to check what keys are actually handled by this listener - by hiding it in an abstraction that is called for all keys (even those that we don't care about at all) we have to learn that those other keys are actually ignored and by ignored I mean that 0 gets returned. This, of course, is a simple piece of code that can be understood quickly but is still a little bit more complex for what it is.
Imagine that you draw out this flow with a statechart - to implement this exact logic you would have to assign
those values to context, then go through tests and eventually test against those temporarily assigned values. Whereas if you go with tests-first approach you only have a list of conditions mapped to appropriate actions - which, IMHO, ends up being a little bit easier :)
src/CanvasContainer.tsx
Outdated
useEffect(() => { | ||
function keydownListener(e: KeyboardEvent) { | ||
const target = e.target as HTMLElement; | ||
if (isTextInputLikeElement(target)) { |
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.
q: from your experience - are those kinds of shortcuts always "global"? and by global I mean - are they also activatable from controls like buttons etc (assuming that the currently focused control doesn't come with an action of its own for this particular shortcut)?
I'm asking because we need to also ignore target.tagName === 'INPUT' && target.type === 'range'
, at least for the relevant keys. If those shortcuts are global then this should be only selectively ignored for arrows but not for WSAD keys.
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.
So attaching the listeners directly to the canvas container? I was going to do this initially, only opted for global since that is being done for the spacebar lock logic.
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 often easy to focus body
or some other element - so hooking into a global thing like window
seems to be preferable because we won't miss keystrokes.
Sorry if my question was confusing - the question was about if conceptually those kinds of things are truly global, which includes those being triggered also on activatable elements. It was sort of an introduction to the comment about the range input because I've wanted to establish what kind of filtering logic we need to support.
I don't really have a better idea than this - it's just a bit of a problem because there are a lot of default interactions that we could forget about and logic like this should, ideally, not conflict with those. The only two approaches I can think of for handling this is to:
The problem with the second approach is that it's hard to apply it globally and it requires adding this almost everywhere - while also requiring listeners for native button behavior ("clicking" using Enter/Space), etc. So it, IMHO, doesn't scale. This makes me stick with the first approach - although it has its shortcomings as well. #usetheplatform |
Thanks for the quick reviews! I'll go through those and make appropriate fixes. Regarding the control logic for panning, would these types of events make sense? {
on: {
'PAN.LEFT': {
actions: canvasModel.assign({
pan: (ctx, e) => ({
dx: ctx.pan.dx - (e.isLongPan ? LONG_PAN : SHORT_PAN),
dy: ctx.pan.dy,
}),
}),
target: '.throttling',
internal: false,
},
'PAN.RIGHT': {
// ...
},
'PAN.UP': {
// ...
},
'PAN.DOWN': {
// ...
},
} Then for choosing the correct event per key, would we just like a switch case: switch (e.key) {
case 'a':
case 'ArrowLeft':
return canvasService.send('PAN.LEFT')
case 'd':
// ...
} Or is there a better way to do this? |
If we want to move more of this logic to the machine itself then the proposed changes sound OK to me. |
Moved the logic to canvasMachine, increased the small pan delta, and fixed the tab behavior with your proposed solution @mattpocock. Testing this should be quite straightforward. Can either do it in this PR or follow up. Just don't have time right now.
Looking forward to this!! |
src/CanvasContainer.tsx
Outdated
function keydownListener(e: KeyboardEvent) { | ||
const target = e.target as HTMLElement; | ||
|
||
if (isTextInputLikeElement(target) || isTabLikeElement(target)) { |
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, of course, nit-picking (to some extent) but it seems to me that if we want to have a bullet-proof logic that works in the very same way for different interactions then we should selectively apply those filters based on the pressed key.
Why this is IMHO important? If we allow, let's say, WSAD keys on buttons then why we don't allow them on tab elements? Tab elements don't have WSAD-based interactions of their own, after all.
In addition to that - we should not handle arrow keys here when the target is an input of type range.
src/utils.ts
Outdated
@@ -210,3 +210,6 @@ export const isTextInputLikeElement = (el: HTMLElement) => { | |||
el.isContentEditable | |||
); | |||
}; | |||
|
|||
export const isTabLikeElement = (el: HTMLElement) => | |||
el.getAttribute('role') === 'tab'; |
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.
An interesting fact is that @testing-library/dom
cheks all space-delimited roles:
https://github.com/testing-library/dom-testing-library/blob/fbbb29a6d9655d41bc8f91d49dc64326f588c0d6/src/queries/role.js#L107-L112
Should we use the same logic for that? 🤔
nit: this probably should be called isTabElement
or hasTabRole
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'll probably have the very same issue with arrows triggering canvas interactions when navigating through menus. Damn, handling all the cases here is hard! 😬
# Conflicts: # src/CanvasContainer.tsx # src/canvasMachine.tsx # src/utils.ts
ce08d9b
to
7599a26
Compare
@rthor I'm deeply sorry that this has somehow slipped us and that we didn't follow up on this sooner. We've been busy with a lot of other stuff recently - and our focus has partially shifted away from the viz to other things. This was a very important PR - it contains a very important feature that can improve the accessibility of the app. It was also amazing to receive such an impactful PR from an external contributor. Thank you for the work done here ❤️ and don't hesitate in the future to ping us if we stall with stuff like that. I've picked this up today, rebased, I've done some additional research, and tried to cover as many cases as possible to avoid breaking existing functionality. The PR is awaiting review from the other team members - but I think we are very close to landing this (and releasing soon). |
@Andarist no worries at all! I’m at fault too, I got busy with other projects as well and didn’t finish or follow up. Sorry about that! I’ve been using the local version of the repo in the meantime, and it’s great! Just wrote a new state machine this morning using the visualizer :) I’ll try to contribute more in the future as xstate (and now stately’s viz) are vital to my projects! Have a great week! Looking forward to seeing this one go live :) |
Wanted to echo what @Andarist said above. This is a super-cool PR. On to feedback - main functionality appears to be working nicely, but fit-to-view seems to be broken when I test this locally? |
That's probably an unwanted side-effect of my math adjustments - gonna fix this 🔜 |
// entry contains `contentRect` but we are interested in the `clientRect` | ||
// height/width are going to be the same but not the offsets | ||
const clientRect = entry.target.getBoundingClientRect(); |
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 not be ideal to read it like this here - but it works for now. This fixes the problem depicted here: https://www.loom.com/share/271104072f2d45d2854b4c4b90b40386
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.
@rthor Testing this now and I realized all the keybindings are working except for zooming.
Pressing -
zooms out but zooming in happens only via Shift
and +
.
I'm on the latest Chrome.
@farskid see the comment here: #237 (comment) . I'm OK with adjusting this however we want, I just have not researched this enough. For instance, Excalidraw requires Shift for both zooming out and zooming in |
My issue with it is that why we need shift for one when the other one doesn't |
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.
Looks great, fit-to-view is working. The +, - thing doesn't bother me particularly, happy to approve without fiddling with that.
@farskid I've changed this to not require Shift for both zoom in/out. I've also added numpad handling 😱 since it has -/+ too |
Why?
I can't use a mouse/trackpad with scroll functionality and therefore need keyboard support to be able to view and navigate large statecharts.
What this PR does:
Adds keyboard support for:
Note to maintainers:
I already talked with @davidkpiano about needing this functionality, and I hope it's okay that I just went ahead and added it. Wanted to use the new visualizer ASAP :) it's such a great workflow improvement for dealing with xstate! If you want this done differently, I am happy to help, or otherwise close this PR.
And congratz on the release 🎉