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

[lexical][lexical-table] Feature: Scrollable tables with experimental getDOMSlot API #6759

Merged
merged 33 commits into from
Nov 13, 2024

Conversation

etrepum
Copy link
Collaborator

@etrepum etrepum commented Oct 23, 2024

Description

This refactors the reconciler's treatment of ElementNode to allow you to specify where children should be inserted, which should allow for nodes that have specific requirements like a wrapping DIV element for display or UI reasons. Currently exposed as an @internal API.

As a demonstration it implements the horizontal scroll for tables from #6713. The TablePlugin has a new hasHorizontalScroll property that defaults to false (for backwards compatibility considerations) but the playground has a new setting tableHorizontalScroll that defaults to true and can (only) be disabled with a query string setting.

Screen recording showing scrollable table
scrollable-tables.mov

It also incorporates the img linebreak hack for Safari from #6282 since that part of the reconciler was modified anyway. I don't think it's perfect, I believe there may still be some arrow key navigation quirks in that specific scenario (end of line decorator), but it's a net improvement that can be refined further. I suspect that the full solution would include hiding or removing these img nodes during arrow key navigation.

Safari end-of-line decorator before
safari-imghack-before.mov
Safari end-of-line decorator after imghack
safari-imghack-after.mov

Closes #6713
Closes #6282
Closes #6480
Closes #4487
Closes #6587
Closes #5981

0.20 Upgrade Notes

  • Scrollable tables are disabled by default in this initial release for compatibility reasons. You can add support for them with <TablePlugin hasHorizontalScroll={true} />.
  • A new theme property tableScrollableWrapper was added, a warning will be issued if you are using scrollable tables without it. It should include overflow-x: auto;.

New concepts

DOMSlot is an abstraction much like a DOM ParentNode with methods like insertChild, replaceChild, etc. Notably there are three components to a DOMSlot: element, after, and before.

  • element is the parentNode for any managed lexical children of this ElementNode. The default is the result of createDOM.
  • after is a DOM node (or null) that all managed children will be after. Default is null.
  • before is a DOM node (or null) that all managed children will be before (e.g. this.element.insertChild(child, this.before) which is equivalent to this.element.appendNode(child) in the null case). Default is null.

All internal interactions with managing children have been ported to use elementNode.getDOMSlot(dom), so that the ElementNode has an opportunity to set up a non-default DOMSlot.

setDOMUnmanaged / isDOMUnmanaged - These functions are used to mark a DOM node as unmanaged, much like a decorator node, which means that the mutation observer shouldn't worry about it. You would use this for DOM nodes in a DOMSlot that do not have corresponding LexicalNodes.

LexicalNode.reconcileObservedMutation - some of the functionality that was hard-coded into the mutation observer and branched on node type has been moved to this method

Test plan

  • Unit tests were added for <TablePlugin hasHorizontalScroll={false} /> and <TablePlugin hasHorizontalScroll={true} /> to demonstrate that the tables are wrapped in a div.
  • The e2e test suite currently sets tableHorizontalScroll set to false in several suites so fewer tests needed to be updated to account for the wrappers, but otherwise it is tested by default. The legacy behavior without a wrapper can be tested with the environment E2E_TABLE_MODE=legacy, but I opted not to add that full suite to our already long e2e tests since a lot of functionality is still tested in legacy mode outside of the Tables.spec.mjs suite.
  • All existing e2e and unit tests pass

Copy link

vercel bot commented Oct 23, 2024

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 Nov 11, 2024 4:53pm
lexical-playground ✅ Ready (Inspect) Visit Preview 💬 Add feedback Nov 11, 2024 4:53pm

@facebook-github-bot facebook-github-bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 23, 2024
Copy link

github-actions bot commented Oct 23, 2024

size-limit report 📦

Path Size
lexical - cjs 29.94 KB (0%)
lexical - esm 29.75 KB (0%)
@lexical/rich-text - cjs 38.56 KB (0%)
@lexical/rich-text - esm 31.64 KB (0%)
@lexical/plain-text - cjs 37.18 KB (0%)
@lexical/plain-text - esm 28.95 KB (0%)
@lexical/react - cjs 40.35 KB (0%)
@lexical/react - esm 33.07 KB (0%)

@etrepum etrepum force-pushed the reconciler-children branch from 14fda1c to 9d9fc14 Compare October 24, 2024 04:49
@etrepum etrepum force-pushed the reconciler-children branch from 9d9fc14 to 6ce96d0 Compare October 24, 2024 05:22
@etrepum etrepum force-pushed the reconciler-children branch from 6ce96d0 to 480aaeb Compare October 24, 2024 05:26
@etrepum etrepum force-pushed the reconciler-children branch from 480aaeb to 6899a28 Compare October 29, 2024 16:23
@etrepum etrepum force-pushed the reconciler-children branch from 6899a28 to b6f65bb Compare October 29, 2024 21:57
@GermanJablo
Copy link
Contributor

A new theme property tableScrollableWrapper was added, a warning will be issued if you are using scrollable tables without it. It should include overflow-x: auto;.

I would prefer to have the property handle adding overflow-x: auto. I understand your reasoning, it's CSS. But in this case I don't see any advantage in having to repeat the intention in the theme config.

@etrepum
Copy link
Collaborator Author

etrepum commented Nov 11, 2024

The intention is that it's useful to have a class for other reasons, and it does't really make sense to set both a class and a style. We could unconditionally set the style, I don't have a very strong opinion about it, but there's plenty of other things that don't work if the theme isn't configured appropriately.

Copy link
Collaborator

@ivailop7 ivailop7 left a comment

Choose a reason for hiding this comment

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

30% through, putting some comments will continue tomorrow

@@ -28,10 +28,11 @@ export default function Settings(): JSX.Element {
isAutocomplete,
showTreeView,
showNestedEditorTreeView,
disableBeforeInput,
// disableBeforeInput,
Copy link
Collaborator

Choose a reason for hiding this comment

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

maybe delete with the switch code below?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I was mostly commenting things out for space, the UI is not very good with as many options as we have now so I was trying to limit the ones that shouldn't really get used. I don't think there's any reason for a user to toggle this one and it has to refresh the page to work anyway so it might as well be done by URL only

Copy link
Collaborator

Choose a reason for hiding this comment

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

Let make the switches smaller, I don't think the url query arg will be discoverable enough. Anyhow, your call.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I left it out because I couldn't think of a good reason for someone to discover this toggle from the UX, but I'm happy to uncomment either way. I'll wait until the reviews are done before code churn on that nit.

showTableOfContents,
shouldUseLexicalContextMenu,
shouldPreserveNewLinesInMarkdown,
// tableHorizontalScroll,
Copy link
Collaborator

Choose a reason for hiding this comment

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

uncomment?

@@ -167,6 +168,13 @@ export default function Settings(): JSX.Element {
checked={shouldPreserveNewLinesInMarkdown}
text="Preserve newlines in Markdown"
/>
{/* <Switch
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm happy to have the switch in the playground 👍🏻

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I didn't really see a good reason why people would want to turn it on and off, we can do it in the URL with the tests, but I put it there just in case. No strong opinion here, but this UX is bad when there are a lot of options.

Copy link
Collaborator

@ivailop7 ivailop7 left a comment

Choose a reason for hiding this comment

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

LGTM, for @zurfyx to give this a look too.

@@ -1538,6 +1587,10 @@ function $handleArrowKey(
}
}
}
if (direction === 'down' && $isScrollableTablesActive(editor)) {
// Enable Firefox workaround
Copy link
Collaborator

Choose a reason for hiding this comment

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

Firefox-specific bug? Do we know more about this one?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes I think I wrote some more about it elsewhere in this PR but in Firefox natively moving the down arrow will skip over the table when wrapped with a div so the last cell get focused instead of the first. Couldn't find a CSS arrangement that allows scrolling with keyboard down arrow navigation works in Firefox at the same time. We really should have better selection APIs because there isn't really a way to fix it before reconciliation in "user" code

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

if (!tableNodeParentDOM) {
return undefined;
}

// TODO: Add support for nested tables
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think you can remove the TODO comment IMO, I don't think we'll support nested tables any time soon or if we should in a first place.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

People write bugs about nested tables at least once a month. I don't think they're that far off of working, someone just has to care enough to write those tests and PR.

(dom as Node & Record<typeof prop, NodeKey | undefined>)[prop] = key;
}

export function getNodeKeyFromDOMNode(
Copy link
Collaborator

Choose a reason for hiding this comment

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

excited about those utility functions!

@potatowagon potatowagon added this pull request to the merge queue Nov 13, 2024
Merged via the queue into facebook:main with commit 506ef89 Nov 13, 2024
76 checks passed
@matveeva-as
Copy link

Hi! Thanks a lot for this!! When are you planing to release a new version of Lexical with this changes?

@mnlkrs
Copy link

mnlkrs commented Nov 13, 2024

Thanks a lot for the work :)

@etrepum
Copy link
Collaborator Author

etrepum commented Nov 13, 2024

Releases typically happen monthly, but if you need to try it sooner you can always use a nightly release

Copy link
Member

@zurfyx zurfyx left a comment

Choose a reason for hiding this comment

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

Late review but that's awesome work! This decouples the strict Node to DOM Node map which has been an inconvenience in some cases.

Terminology nits

While I like the flexibility of the DOMSlotAPI, withAfter still seems confusing to me.

  1. Ideally, we would've had something like the DOM where insertBefore/insertAfter would work very closely to the DOM API.
  2. withAfter works the opposite way as I would anticipate .withAfter(tableElement.querySelector('colgroup')). To me this an element that inserts another element before.
  3. slot.element is fairly ambiguous when a wrapper is present. Why does createDOM have to return the wrapper whereas domSlot has control over the child?

Testing

Did we test DOMSlot with inlines? I believe that LexicalElementNode.test and the E2E tests cover the block case and the particularly domSlot API well but I think it's important to have a test that covers inlines with wrappers if it's not there already.

Similarly, validate how selection behaves when before and after elements are presents. The selection logic seems to cover resolution for discovery, but is this sufficient for adjusting anchor/focus points accordingly on all other events?

@etrepum
Copy link
Collaborator Author

etrepum commented Nov 14, 2024

DOM doesn't have an insertAfter API, only insertBefore(dom, before). The after and before properties (set by withAfter and withBefore) are the markers that would be used in those methods (if a native insertAfter existed). I don't think there is a non-confusing way to name those markers using the same terminology that DOM does, because the before sentinel is after the children and vice versa for after. If we name the properties relative to the children it will be opposite to how the DOM semantics are (we would be doing insertBefore(dom, after) in that case, which is more confusing IMO).

@etrepum
Copy link
Collaborator Author

etrepum commented Nov 14, 2024

That said, I'm not against changing the naming around, just that it's going to be confusing however you do it because there are two opposite and obvious points of reference (the markers relative to the children - what you were thinking, and the insertion of children relative to the markers - how DOM and this API work). The whole point of the slot's properties is to reference a specific element in the tree and provide those markers for insertBefore (and the simulated insertAfter).

@etrepum
Copy link
Collaborator Author

etrepum commented Nov 14, 2024

createDOM has to return the DOM that's inserted into the document, and if it didn't, we'd have to break compatibility by changing the LexicalNode interface. It was done this way so that no user code or types would have to change.

Naming of element is arbitrary, could be elementThatChildrenAreInsertedInto or something like that, I guess. If you have a good name then please suggest it. I think most names are going to be confusing in one way or another.

@etrepum
Copy link
Collaborator Author

etrepum commented Nov 14, 2024

Inlines will be tested when we have a use case to add them. I wasn't going to add another week or two of effort to come up with and test a use case that we don't have yet for an internal experimental API. I am sure there are going to be selection quirks because of how we use platform native arrow key navigation but don't have a good way to fix them up post-event in user code yet. I am pretty certain that the lexical selection will be mapped to the closest lexical point to where the DOM selection is, but visually the caret might not end up where the user wants (especially if they don't provide the correct css and contentEditable settings for their accessory elements, if present)

@matveeva-as
Copy link

matveeva-as commented Nov 15, 2024

Hi! I've found a bug but don't sure how it can be related to this PR, but 0.19.0 works good, 0.20.1-nightly.20241114.0 doesn't.

When we have two tables in the Editor and press arrowDown in the second table, focus goes to the first cell in the first table.
Do you have any idea why does it happen?

chrome-capture-2024-11-15

@etrepum
Copy link
Collaborator Author

etrepum commented Nov 15, 2024

Can you report this as a new issue please?

@etrepum
Copy link
Collaborator Author

etrepum commented Nov 15, 2024

Nevermind, should be fixed in #6839 and released with the next nightly

@JeffreyQ
Copy link

Hi, I had some questions around $getTableAndElementByKey.

I see that you implement it here in packages/lexical-table/src/LexicalTableObserver.ts.

However, you are importing it here from @lexical/table which is of version 0.20.0.

However, when I'm looking at LexicalTableUtils.d.ts file in version 0.20.0 on npm I do not see a $getTableAndElementByKey.
image

@etrepum
Copy link
Collaborator Author

etrepum commented Nov 17, 2024

The entire lexical monorepo is versioned together. The playground is not using @lexical/table v0.20 from npm, it is built with the exact version that is in the same commit as the playground. The versions don't update in git until a new release is created.

@JeffreyQ
Copy link

The entire lexical monorepo is versioned together. The playground is not using @lexical/table v0.20 from npm, it is built with the exact version that is in the same commit as the playground. The versions don't update in git until a new release is created.

Got it, thank you for clarifying!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Feature: Scroll for table Bug: Cannot place cursor at the end of the line (Safari)
9 participants