facebook / lexical

Lexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.
https://lexical.dev
MIT License
17.5k stars 1.45k forks source link

Fix insertNodes when inserting into inline elements #5394

Closed birtles closed 4 months ago

birtles commented 5 months ago

This restores the behavior that changed in #5002 particularly when pasting into links and other inline elements.

Fixes facebook#5251.

vercel[bot] commented 5 months ago

The latest updates on your projects. Learn more about Vercel for Git β†—οΈŽ

Name Status Preview Comments Updated (UTC)
lexical βœ… Ready (Inspect) Visit Preview πŸ’¬ Add feedback Dec 27, 2023 9:29am
lexical-playground βœ… Ready (Inspect) Visit Preview πŸ’¬ Add feedback Dec 27, 2023 9:29am
birtles commented 5 months ago

This fixes a lot of the weirdness around pasting into links for me and at least gets all our E2E tests for pasting which were broken by 0.12.3 to pass again.

birtles commented 5 months ago

Looking at the CI failures, I think all of them existed prior to this PR except the newly added test appears to fail on Windows due to the selection range differing there. I guess I need to find a more cross-platform way of setting the correct selection range there. Something to look into next week, I guess.

Edit: Hopefully fixed the windows-specific failure.

ivailop7 commented 5 months ago

@zurfyx and @GermanJablo I think you guys are aware of this one, you might be more appropriate at having a look at this one.

birtles commented 5 months ago

Excellent, thank you!

Yes, I didn't expect this PR would be sufficient but wanted to get the conversation going. I need to fix all the regressions from 0.12.3 before we can update our app and this is the biggest one. We have a ruby editor where the user can paste text into the ruby (inline) element. This worked before 0.12.3 but not anymore.

Having links reject pasting into them might make sense but the default behavior should be to allow pasting into inline elements.

I'll make up tests for those cases you mentioned and see if I can get something sensible to happen for each.

birtles commented 4 months ago

I dug into this a bit more and here's what I found out:

removeTextAndSplitBlock is supposed to return an index into the block ancestor that can be used to insert the nodes but there are a number of cases where it returns the wrong result.

For example here:

https://github.com/facebook/lexical/blob/794e6dd0d9c1922ada322fe652ccc40e4e5dee74/packages/lexical/src/LexicalSelection.ts#L2852-L2854

We determine that the selection anchor lands on a non-text node and assume that the selection offset corresponds to an offset in the ancestor block, but it may be the offset into an inline element's child nodes.

Similarly here:

https://github.com/facebook/lexical/blob/794e6dd0d9c1922ada322fe652ccc40e4e5dee74/packages/lexical/src/LexicalSelection.ts#L2868-L2873

If we determine our parent is an inline element but see that we're at the start of it, we realize we don't need to split the inline element so we just return 0 despite the fact that it's not an index into the block ancestor's children. This is the cause of the weird behavior reported in #5251 and reproduced in the test case in this PR.

Even when we do split the parent inline element, we don't do it recursively, assuming that its parent must be a block ancestor:

https://github.com/facebook/lexical/blob/794e6dd0d9c1922ada322fe652ccc40e4e5dee74/packages/lexical/src/LexicalSelection.ts#L2875-L2880

So I think we can go a couple of directions here:

  1. Make removeTextAndSplitBlock handle inline elements properly included nested inlines and ensure it always returns an index into the nearest block ancestor.

  2. Allow nodes to be inserted into inlines and let inlines take care of ensuring the sanity of their children (their content model).

I think (2) is actually better for users and authors in the long term.

For example, even if Word and Google Docs do it, it's weird for a user that if they cut "abc" from a rich text editor and paste it into the middle of a link the link gets split, but if they cut the same "abc" from a plain text editor and paste it, the link doesn't get split. I know you can use Ctrl + Shift + V to paste plain text but 99% of users won't know that.

If we went with (2), for the case of pasting a link into a link, presumably LinkNode would have a node transform that handles that case. For other types of inline elements, nesting the same type of element might be valid so it should be up to the specific inline element to sanitize its child content.

Anyway, I will give (1) a try anyway to see what it looks like.

Update: I notice that if we do (2) we wouldn't need the special case for code blocksβ€”which possibly isn't quite right anyway since it only copies the first node's text content. The fact that this special case exists suggests that other custom node types may want similar handling.

birtles commented 4 months ago

I've updated this PR to implement approach 1 from https://github.com/facebook/lexical/pull/5394#issuecomment-1865426628

Before I go ahead and add more tests, however, it's probably worth discussing which approach we want to take.

If we end up going with approach 1 (i.e. always split inline elements) then in our app we'll probably intercept pastes, detect if the selection is one our custom nodes and, if so, strip the text from the pasted content and force-paste as text instead. I think that would be workable for us.

GermanJablo commented 4 months ago

Approach 2 would imply that Lexical would have to somehow expose a set of default transformations. It's something that will surely happen at some point, but not currently and it will probably require a lot of thought about how to do it.

On the other hand, converting the clipboard to plain text does not seem like a safe solution to me.

I'm not decidedly against approach 2, but it would require (1) default transformations on Lexical's part and (2) agreeing on what those transformations should be for nested inline nodes. Reaching agreement on these 2 points can be a longer and more complicated process than the simpler approach 1.

birtles commented 4 months ago

The schema proposal is interesting. I hadn't seen that. I was imagining something simpler along the lines of the existing canBeEmpty, canReplaceWith methods of the ElementNode interface. For example, a canInsertNode method which would allow parent nodes to restrict what nodes can be added as children. When it returns false, we'd split the node. That would seem to be sufficient for the purposes of this issue. If it further had a "see parent" sentinel value then I think it could represent HTML's content models including the transparent content model concept.

I'm more than happy to go ahead with approach 1 for now since I don't have the bandwidth to drive approach 2 forwards.

From the point of view of our app, doing the plain text conversion would give us something reasonably useful for now and if/when approach 2 is ready, we could switch to that to get higher fidelity pasting into our custom inline elements.

GermanJablo commented 4 months ago

I'm more than happy to go ahead with approach 1 for now since I don't have the bandwidth to drive approach 2 forwards [...] if/when approach 2 is ready, we could switch to that [...]

That seems perfect to me, I think it's the best way to approach this.

Disclaimer: PR approval is not up to me so, CC: @zurfyx @fantactuka @acywatson

birtles commented 4 months ago

That seems perfect to me, I think it's the best way to approach this.

Great, thank you! I'll add some tests to the PR and squash it down so it's easier to review.

I'd like to write an E2E test for pasting into nested inline elements but do you happen to know of any inline elements available in the playground that support child inline elements?

GermanJablo commented 4 months ago

I'd like to write an E2E test for pasting into nested inline elements but do you happen to know of any inline elements available in the playground that support child inline elements?

The only ElementNodes of type inline that I remember are LinkNode, AutoLinkNode and MarkNode.

I think that with the 4 tests I mentioned above, we would be covered.

birtles commented 4 months ago

I think that with the 4 tests I mentioned above, we would be covered.

I've added those tests and tidied up the patch so this PR should be ready for review now.

GermanJablo commented 4 months ago

Incredible work, thank you very much! This looks good to me πŸ‘Œ