-
Notifications
You must be signed in to change notification settings - Fork 124
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
position faux caret onFocus and onChange #2800
base: main
Are you sure you want to change the base?
position faux caret onFocus and onChange #2800
Conversation
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 a more complex example where I tapped from one thought to a thought at a different indent level. I got quite a wide range of results for this example, but overall I think it's possible to use the faux caret while only adding 1 layout call.
Okay, that seems reasonable.
The approach that I used is to change the faux caret
onTap
oronChange
, since it will need to move as they type.
As for typing, we can always hide the faux caret immediately when the user starts typing.
More generally, I don't think we should try to entirely replace the caret, as that opens up too much space for discrepancies from the native functionality. Replacing the caret at all times might require special handling of typing, selection, and other caret behaviors.
How can we only show the faux caret when the native caret is invisible? The challenge is getting it to line up with the hideCaret
functionality. transitionend
might be helpful here?
I don't believe we will need to make it blink if it is only visible during the indentation animation.
src/components/Editable.tsx
Outdated
@@ -88,6 +88,7 @@ interface EditableProps { | |||
*/ | |||
transient?: boolean | |||
onEdit?: (args: { path: Path; oldValue: string; newValue: string }) => void | |||
onThoughtFocus?: () => void |
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.
If you are looking for a way to avoid all the props drilling, perhaps you can create a new ministore that the FauxCaret component can subscribe to, that can be directly updated from Editable
. Or maybe you have another idea.
src/components/Editable.tsx
Outdated
@@ -420,6 +422,8 @@ const Editable = ({ | |||
throttledChangeRef.current.flush() | |||
thoughtChangeHandler(newValue, { rank, simplePath }) | |||
} else throttledChangeRef.current(newValue, { rank, simplePath }) | |||
|
|||
requestAnimationFrame(() => onThoughtFocus?.()) |
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.
Thoughts about requestAnimationFrame
?
While it's good for performance, it might cause the selection measurement to not occur in time for the first paint of the faux caret.
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.
As far as I can tell, the selection ranges aren't updated during the focus handler, so it needs to be delayed until the end of the frame using requestAnimationFrame
or setTimeout
.
|
||
// If the thought isCursor and edit mode is on, position the faux cursor at the point where the | ||
// selection is created. | ||
useEffect(() => { |
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 seems like I can get everything I need from the props. Let me know if you can think of any impact this would have on performance.
I haven't figured out how to style the faux caret the same as the real caret, but I think that this is ready for review in terms of the approach I've taken. Also, I feel like there should be a CSS variable like |
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.
Thanks for the update!
This seems to work on tap, but it is not triggered on other commands that change the indentation level such as Cursor Forward, Cursor Back, New Thought, Subcategorize One, Undo, Redo, etc. (Fwiw, I don't think that explicitly whitelisting all the commands that could trigger an indentation change is a good idea. Hopefully we can find a general condition that is a good fit.)
selectionchange
? transitionstart
? It's admittedly tricky as any false negatives or false positives can throw it off.
I haven't figured out how to style the faux caret the same as the real caret, but I think that this is ready for review in terms of the approach I've taken.
Also, I feel like there should be a CSS variable like
--webkit-caret-color
or something, but I don't see one. Do you have any information about the caret styles?
We will eventually be setting the caret to a custom color with ::selection, so we don't need to match the native color.
src/components/LayoutTree.tsx
Outdated
if (offset) { | ||
const rect = getBoundingClientRect() | ||
|
||
if (rect?.x || rect?.y) { |
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 fixed at least one issue with gestures/commands, but I think the deeper issue is that getBoundingClientRect
tends to return 0,0
when the parent element node is selected rather than its text node child. This is something that we discussed before here: #2753 (comment).
Has any work been done to investigate or resolve the discrepancy between selections that are element nodes and selections that are text nodes? It would be nice to know if there's somewhere that I can look.
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 sure it's possible to resolve the discrepancy exactly. The browser has two representations for the same state, so we will always need to handle both. However we can hide the details of the discrepancy within the selection module.
The difference can be detected with selection.focusNode.nodeType === Node.ELEMENT_NODE
. For ELEMENT_NODE, you should be able to call getBoundingClientRect
on the focusNode
rather than the selection range.
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.
Things are looking better now with the test cases you listed above. Do you have any more test cases for me to check?
There are a couple of issues that I haven't tackled yet:
- empty thoughts have different padding from thoughts with text in them, so the caret offset is a little different and there's more of a jump between the faux caret and the real caret
- when a thought is deleted the cursor moves to the previous thought, and the faux caret shows at the beginning of the thought while the real caret is positioned at the end
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.
- when a thought is deleted the cursor moves to the previous thought, and the faux caret shows at the beginning of the thought while the real caret is positioned at the end
I discovered that selection.focusOffset
is 1
when an ELEMENT_NODE is selected and the caret is at the end. Unfortunately, this isn't very helpful because getBoundingClientRect
only gives the width & height of the bounding box, whereas the caret will often be positioned at the end of a line that doesn't stretch the full width of the box.
The only thing I can think of to fix this is to hide the faux caret in those circumstances. What do you think?
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.
Editables have padding-right of 1em
and min-width of 3em
, so for inline thoughts that do not take up the full width, the end of the thought is always a fixed distance from the right edge of the bounding box.
If that doesn't help, could you provide a screenshot or steps to reproduce? I'm not quite able to visualize the problem.
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.
Thanks! I understand now.
How about getting the position of the superscript? That will always be right after the caret in the ELEMENT_NODE offset 1 case.
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.
Good thought. It looks like ThoughtAnnotation
relies on an inline-block
wrapper with a copy of the thought's value in it. Currently it looks like it only exists for thoughts with one of the annotations (showSuperscript || url || email || styleAnnotation
) so if we went that route, the hidden copy would need to exist on all thoughts. Does that seem reasonable?
I think then I would control it using another CSS variable like --faux-caret-line-end-opacity
that could be animated by hideCaret
and overridden on div[aria-label=tree-node]
if the normal faux caret should show, or not overridden if the line end caret should show.
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 should be okay. I imagine there are more efficient approaches to ThoughtAnnotation
itself, such as cloning the DOM node, but that's a separate issue.
If you believe this is the best way to move forward, let's give it a try. Thanks!
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 seemed to work out OK. Let me know what you think.
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 you provide exact steps to reproduce the ELEMENT_NODE with offset 1 case? I want to make sure I'm testing the same thing as you.
Nice! Hopefully these cases don't all need special handling, but I'm not sure how general your solution is yet so I thought I would just list as many cases as I could think of that might affect the indentation and caret.
|
After testing 77a6634, it looks like it's working quite well. I think the approach is successful, and the extra attention to detail will really help em look and feel like a native app. I noticed that the positioning of the faux caret is sometimes slightly off, so that make need some tweaks. The Puppeteer tests show some visual regressions with the layout, but you are probably already aware. We can do a more thorough review, starting with Trevin's review, whenever you think it's ready. Thanks so much! |
Fixes #2709
Here is a rough draft of how a faux caret would work. I can see the 1 additional layout call from
getBoundingClientRect
, which I call twice, once to get the offset parent and once to get the selection rect itself. It seems that they are batched into a single layout call, hopefully.Here is a simple example, tapping within the same thought.
main:
![Screenshot 2025-01-30 at 14 26 18](https://private-user-images.githubusercontent.com/987668/408331035-9876f99c-a574-44fc-85ac-9381b551a3be.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3Mzk2MjQ0ODMsIm5iZiI6MTczOTYyNDE4MywicGF0aCI6Ii85ODc2NjgvNDA4MzMxMDM1LTk4NzZmOTljLWE1NzQtNDRmYy04NWFjLTkzODFiNTUxYTNiZS5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjUwMjE1JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MDIxNVQxMjU2MjNaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT02YzE3MzVmYzdlNWE4MjY2YzU2OGRjOGNjOTk1ZDY1NWI0NjkxN2E4ZGI0Y2ZlY2IxODdlYTAzOWQxNDdmZmE5JlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.Q94GSmcIecSEsUk3FGe7P0zM22G3zAqut8lhgkajwqM)
this branch:
![Screenshot 2025-01-30 at 14 25 07](https://private-user-images.githubusercontent.com/987668/408331155-ea80c689-a4dc-4f54-82d6-846a8c796395.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3Mzk2MjQ0ODMsIm5iZiI6MTczOTYyNDE4MywicGF0aCI6Ii85ODc2NjgvNDA4MzMxMTU1LWVhODBjNjg5LWE0ZGMtNGY1NC04MmQ2LTg0NmE4Yzc5NjM5NS5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjUwMjE1JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MDIxNVQxMjU2MjNaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1jZDFlOWJhNTVjOWRiMDFmNjAwMjc2NWZmMzEzZDcwNzA0MTVjMTg2YTdjNmQxMzRiN2U0ZGNmODFkYjdiMzRhJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.dTIuOUOA_UbpaqE91KFKGbj5LySZOeXVA288pVPAAr8)
And a more complex example where I tapped from one thought to a thought at a different indent level. I got quite a wide range of results for this example, but overall I think it's possible to use the faux caret while only adding 1 layout call.
main:
![Screenshot 2025-01-30 at 14 40 00](https://private-user-images.githubusercontent.com/987668/408331487-14b65431-6abc-4574-8e9f-26b0a18b1b2f.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3Mzk2MjQ0ODMsIm5iZiI6MTczOTYyNDE4MywicGF0aCI6Ii85ODc2NjgvNDA4MzMxNDg3LTE0YjY1NDMxLTZhYmMtNDU3NC04ZTlmLTI2YjBhMThiMWIyZi5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjUwMjE1JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MDIxNVQxMjU2MjNaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT0zODYxMTc3Mjk2YjE3NWMxZmI1MmY2NzBiMmY0ZTQxNWJhM2EzNzI5NzNlOTI5OTc0NGM2NzM2NTE1YjgwYzA0JlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.iBUG9t937pcOGJhi94Qb4ToeVfP2T6iUJBGhZ8WKeNQ)
this branch:
![Screenshot 2025-01-30 at 14 36 33](https://private-user-images.githubusercontent.com/987668/408331515-88d77be8-19ef-4015-a632-4ca9ef730cc1.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3Mzk2MjQ0ODMsIm5iZiI6MTczOTYyNDE4MywicGF0aCI6Ii85ODc2NjgvNDA4MzMxNTE1LTg4ZDc3YmU4LTE5ZWYtNDAxNS1hNjMyLTRjYTllZjczMGNjMS5wbmc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBVkNPRFlMU0E1M1BRSzRaQSUyRjIwMjUwMjE1JTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MDIxNVQxMjU2MjNaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT1kYTAxZjMyOTFiNDkyNGY5ZDllMDQ4YTcyN2M3YjA0NTkzNDIzYzM5N2NiYjZiZjQ3N2ViMGJmOTdmMjAxMzJhJlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCJ9.T71fcLeEq1WtjSODoRveMiC7bcea8l12AhcEfPw2G7Q)
The approach that I used is to change the faux caret
onTap
oronChange
, since it will need to move as they type. This results in layout calls during typing. This approach would be combined with thehideCaret
animations to show/hide the faux caret when indent levels change (and make it blink, of course) but it does mean that the event handlers that control the faux caret are always on whether or not the indent level is changing.It would also be possible to enable/disable the event handlers so that they are only causing layout calls while the
hideCaret
animation is active, at the cost of additional code complexity. I think I can see a path to doing it without adding additional renders, but it's hard to say until I get into it.And of course, there are all sorts of weird positioning issues that are caused by different margins or whatever that still need to be ironed out.