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

Fix selections with non-void non-editable focus #5716

Merged
merged 5 commits into from
Sep 12, 2024

Conversation

TyMick
Copy link
Contributor

@TyMick TyMick commented Sep 9, 2024

Description

Currently, when I drag my browser selection focus into a contentEditable={false} DOM element that's being rendered by a non-void Slate element renderer, Slate's selection doesn't get updated. At times, this causes non-highlighted text to be deleted when pressing Backspace (first example below). At other times, this causes a Backspace press to crash the page (second example below).

The fix in this PR is to set the focus point to the edge of the nearest Slate leaf when the selection focus is in a non-void, non-editable node, instead of disregarding such selections.

Issue

Fixes #5714

Example

These examples use https://www.slatejs.org/examples/check-lists, where the “non-void, non-editable” node is this <span>.

Incorrect deletion

Before

A demonstration of the bug in Slate's checklists example, in which pressing Backspace causes non-highlighted text to be deleted

After

A demonstration of this PR's fix of the incorrect deletion, in which pressing Backspace only deletes the highlighted text

Page crash

Before

A demonstration of the bug in Slate's checklists example, in which pressing Backspace causes the page to crash

After

A demonstration of this PR's fix of the page crash, in which pressing Backspace only deletes the highlighted text

Checks

  • The new code matches the existing patterns and styles.
  • The tests pass with yarn test.
  • The linter passes with yarn lint. (Fix errors with yarn fix.)
  • The relevant examples still work. (Run examples with yarn start.)
  • You've added a changeset if changing functionality. (Add one with yarn changeset add.)

"Non-void non-editable" refers to `contentEditable={false}` DOM nodes
that are rendered by a Slate element render but which are not void
elements. For instance, [the checkboxes in the checklists example][1].

[1]: https://github.com/ianstormtaylor/slate/blob/7e77a932f0489a9fff2d8a1957aa2dd9b324aa78/site/examples/check-lists.tsx#L153-L170
Copy link

changeset-bot bot commented Sep 9, 2024

🦋 Changeset detected

Latest commit: f4654c3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
slate-react Patch

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

const focusNodeSelectable =
ReactEditor.hasEditableTarget(editor, focusNode) ||
ReactEditor.isTargetInsideNonReadonlyVoid(editor, focusNode)
const focusNodeSelectable = ReactEditor.hasTarget(editor, focusNode)
Copy link
Contributor Author

@TyMick TyMick Sep 9, 2024

Choose a reason for hiding this comment

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

I'm not sure focusNodeSelectable is still the best name for this variable, since the non-void non-editable nodes still aren't selectable. ReactEditor.toSlateRange just changes the focus to a node that is selectable now. Can't think of a better name at the moment, though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Also, this PR only fixes non-selectable focus points now, but should non-selectable anchor points be fixed as well? It wouldn't be necessary for my project, but conceivably, if a non-void non-editable node contained non-interactive text, it would be easy to make a browser selection with the anchor inside that node and the focus outside it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixing non-selectable anchor points would add extra complexity in that we'd have to decide what to do when a selection starts and ends in the same non-selectable node... This PR is already an improvement on the existing behavior as-is, so maybe fixing anchors can be saved for a future PR? 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed the variable name to focusNodeInEditor with 98630d1.

@dylans
Copy link
Collaborator

dylans commented Sep 10, 2024

Thanks @TyMick I will look at this one sometime this week, and try to answer your open questions as well.

@TyMick
Copy link
Contributor Author

TyMick commented Sep 10, 2024

Thanks, Dylan!

@TyMick
Copy link
Contributor Author

TyMick commented Sep 10, 2024

I tried adding a Jest regression test for this, but my attempts to create a selection range with Testing Library's UserEvent.pointer weren't able to affect editor.selection, which remained null at the end of this test:

test('fixes DOM selections that end in non-editable nodes', async () => {
  const user = userEvent.setup()
  const editor = withReact(createEditor())
  const initialValue = [
    {
      type: 'check-list-item',
      checked: false,
      children: [{ text: 'Test item' }],
    },
  ]

  render(
    <>
      <div>Something outside the editor</div>
      <Slate editor={editor} initialValue={initialValue}>
        <Editable renderElement={CheckListItemElement} />
      </Slate>
    </>
  )

  await user.pointer([
    // Mouse down in the middle of the checklist item text
    { keys: '[MouseLeft>]', target: screen.getByText('Test item'), offset: 4 },
    // Move mouse outside editor
    { target: screen.getByText('Something outside the editor'), offset: 2 },
    // Move mouse to the checkbox
    { target: screen.getByRole('checkbox') },
    // Mouse up
    { keys: '[/MouseLeft]' },
  ])

  await waitFor(() => expect(editor.selection).toBeTruthy())
})

function CheckListItemElement({ attributes, children, element }: any) {
  const { checked } = element
  return (
    <div {...attributes}>
      <span contentEditable={false}>
        <input type="checkbox" checked={checked} />
      </span>
      <span suppressContentEditableWarning>{children}</span>
    </div>
  )
}

I think this may not be testable without adding an end-to-end framework like Playwright.

Rename `focusNodeSelectable` to `focusNodeIsSelectable`

A more accurate name given this PR's changes.
@dylans
Copy link
Collaborator

dylans commented Sep 11, 2024

I tried adding a Jest regression test for this, but my attempts to create a selection range with Testing Library's UserEvent.pointer weren't able to affect editor.selection, which remained null at the end of this test:

test('fixes DOM selections that end in non-editable nodes', async () => {
  const user = userEvent.setup()
  const editor = withReact(createEditor())
  const initialValue = [
    {
      type: 'check-list-item',
      checked: false,
      children: [{ text: 'Test item' }],
    },
  ]

  render(
    <>
      <div>Something outside the editor</div>
      <Slate editor={editor} initialValue={initialValue}>
        <Editable renderElement={CheckListItemElement} />
      </Slate>
    </>
  )

  await user.pointer([
    // Mouse down in the middle of the checklist item text
    { keys: '[MouseLeft>]', target: screen.getByText('Test item'), offset: 4 },
    // Move mouse outside editor
    { target: screen.getByText('Something outside the editor'), offset: 2 },
    // Move mouse to the checkbox
    { target: screen.getByRole('checkbox') },
    // Mouse up
    { keys: '[/MouseLeft]' },
  ])

  await waitFor(() => expect(editor.selection).toBeTruthy())
})

function CheckListItemElement({ attributes, children, element }: any) {
  const { checked } = element
  return (
    <div {...attributes}>
      <span contentEditable={false}>
        <input type="checkbox" checked={checked} />
      </span>
      <span suppressContentEditableWarning>{children}</span>
    </div>
  )
}

I think this may not be testable without adding an end-to-end framework like Playwright.

We do actually have Playright in there, just not too many tests yet.

@TyMick
Copy link
Contributor Author

TyMick commented Sep 11, 2024

Oh perfect, I should've looked around more! Taking a look at check-lists.test.ts now.

@TyMick
Copy link
Contributor Author

TyMick commented Sep 11, 2024

Nope. I have spent hours trying to

  1. mouse down in the first checklist item's text
  2. move mouse into the paragraph text above it
  3. move mouse into the checkbox
  4. mouse up
  5. press Backspace

in a Playwright test, but the behavior is far too flaky to be able to reliably create the same text selection by dragging the mouse. And I can't reproduce the bug with just the keyboard. Can't think of any other way to test it.

@dylans
Copy link
Collaborator

dylans commented Sep 12, 2024

@TyMick I appreciate you trying. These interactions are not easy to test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Slate selection doesn't update when dragging browser selection focus into contentEditable={false} element
2 participants