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

Android related improvements #5315

Merged
merged 2 commits into from
Feb 27, 2023
Merged

Android related improvements #5315

merged 2 commits into from
Feb 27, 2023

Conversation

clauderic
Copy link
Collaborator

Description

This PR introduces two changes:

  1. Consumers can define an onInput prop on the <Editable> component, but it wasn't being invoked, which is now fixed.
  2. The restore-dom-manager no longer reverts characterData mutations

These changes are introduced so that consumers can listen to the onInput event to flush pending diffs on Android on every change:

<Editable
  onInput={() => {
    // Reconcile the editor state with the pending changes on every input event.
    const pendingDiffs = ReactEditor.androidPendingDiffs(editor)
    const scheduleFlush = pendingDiffs ? pendingDiffs.length > 0 : false

    if (scheduleFlush) {
      ReactEditor.androidScheduleFlush(editor)
    }
  }}

Before this PR, it was not possible to reconcile the editor state on every input event, because of the textContent update when reverting the characterData mutations. This would interrupt the current composition, and lead to unexpected results while typing quickly or holding backspace.

Issue
Related: #5078

Examples

In isolation, this is what the change looks like:

Before

Screen.Recording.2023-02-22.at.7.34.14.PM.mov

After

Screen.Recording.2023-02-22.at.7.33.12.PM.mov

Here is a before/after of what this PR combined with #5306 unlocks:

Before

Screen.Recording.2023-02-22.at.8.03.33.PM.mov

After

Screen.Recording.2023-02-22.at.8.00.16.PM.mov

Playground

/**
 * This is an example we can use to test and debug the Android input manager
 *
 * Note:
 * The example needs to be added to `[example].tsx` before it can be used.
 */

import React, { useCallback, useMemo, useState } from 'react'
import isHotkey from 'is-hotkey'
import { ReactEditor, Editable, withReact, useSlate, Slate } from 'slate-react'
import {
  Editor,
  Transforms,
  createEditor,
  Descendant,
  Element as SlateElement,
} from 'slate'
import { withHistory } from 'slate-history'

import { Button, Icon, Toolbar } from '../components'

const HOTKEYS = {
  'mod+b': 'bold',
  'mod+i': 'italic',
  'mod+u': 'underline',
  'mod+`': 'code',
}

const LIST_TYPES = ['numbered-list', 'bulleted-list']
const TEXT_ALIGN_TYPES = ['left', 'center', 'right', 'justify']

const AndroidExample = () => {
  const renderElement = useCallback(props => <Element {...props} />, [])
  const renderLeaf = useCallback(props => <Leaf {...props} />, [])
  const editor = useMemo(() => withHistory(withReact(createEditor())), [])
  const [value, setValue] = useState(initialValue)

  return (
    <Slate editor={editor} value={value} onChange={setValue}>
      <Toolbar>
        <MarkButton format="bold" icon="format_bold" />
        <MarkButton format="italic" icon="format_italic" />
        <MarkButton format="underline" icon="format_underlined" />
        <MarkButton format="code" icon="code" />
        <BlockButton format="heading-one" icon="looks_one" />
        <BlockButton format="heading-two" icon="looks_two" />
        <BlockButton format="block-quote" icon="format_quote" />
        <BlockButton format="numbered-list" icon="format_list_numbered" />
        <BlockButton format="bulleted-list" icon="format_list_bulleted" />
        <BlockButton format="left" icon="format_align_left" />
        <BlockButton format="center" icon="format_align_center" />
        <BlockButton format="right" icon="format_align_right" />
        <BlockButton format="justify" icon="format_align_justify" />
      </Toolbar>
      <Editable
        renderElement={renderElement}
        renderLeaf={renderLeaf}
        placeholder="Enter some rich text…"
        spellCheck
        autoFocus
        onKeyDown={event => {
          for (const hotkey in HOTKEYS) {
            if (isHotkey(hotkey, event as any)) {
              event.preventDefault()
              const mark = HOTKEYS[hotkey]
              toggleMark(editor, mark)
            }
          }
        }}
        onInput={() => {
          // Reconcile the editor state with the pending changes on every input event.
          const pendingDiffs = ReactEditor.androidPendingDiffs(editor)
          const scheduleFlush = pendingDiffs ? pendingDiffs.length > 0 : false

          if (scheduleFlush) {
            ReactEditor.androidScheduleFlush(editor)
          }
        }}
      />
      <pre>{JSON.stringify(value, null, 2)}</pre>
    </Slate>
  )
}

const toggleBlock = (editor, format) => {
  const isActive = isBlockActive(
    editor,
    format,
    TEXT_ALIGN_TYPES.includes(format) ? 'align' : 'type'
  )
  const isList = LIST_TYPES.includes(format)

  Transforms.unwrapNodes(editor, {
    match: n =>
      !Editor.isEditor(n) &&
      SlateElement.isElement(n) &&
      LIST_TYPES.includes(n.type) &&
      !TEXT_ALIGN_TYPES.includes(format),
    split: true,
  })
  let newProperties: Partial<SlateElement>
  if (TEXT_ALIGN_TYPES.includes(format)) {
    newProperties = {
      align: isActive ? undefined : format,
    }
  } else {
    newProperties = {
      type: isActive ? 'paragraph' : isList ? 'list-item' : format,
    }
  }
  Transforms.setNodes<SlateElement>(editor, newProperties)

  if (!isActive && isList) {
    const block = { type: format, children: [] }
    Transforms.wrapNodes(editor, block)
  }
}

const toggleMark = (editor, format) => {
  const isActive = isMarkActive(editor, format)

  if (isActive) {
    Editor.removeMark(editor, format)
  } else {
    Editor.addMark(editor, format, true)
  }
}

const isBlockActive = (editor, format, blockType = 'type') => {
  const { selection } = editor
  if (!selection) return false

  const [match] = Array.from(
    Editor.nodes(editor, {
      at: Editor.unhangRange(editor, selection),
      match: n =>
        !Editor.isEditor(n) &&
        SlateElement.isElement(n) &&
        n[blockType] === format,
    })
  )

  return !!match
}

const isMarkActive = (editor, format) => {
  const marks = Editor.marks(editor)
  return marks ? marks[format] === true : false
}

const Element = ({ attributes, children, element }) => {
  const style = { textAlign: element.align }
  switch (element.type) {
    case 'block-quote':
      return (
        <blockquote style={style} {...attributes}>
          {children}
        </blockquote>
      )
    case 'bulleted-list':
      return (
        <ul style={style} {...attributes}>
          {children}
        </ul>
      )
    case 'heading-one':
      return (
        <h1 style={style} {...attributes}>
          {children}
        </h1>
      )
    case 'heading-two':
      return (
        <h2 style={style} {...attributes}>
          {children}
        </h2>
      )
    case 'list-item':
      return (
        <li style={style} {...attributes}>
          {children}
        </li>
      )
    case 'numbered-list':
      return (
        <ol style={style} {...attributes}>
          {children}
        </ol>
      )
    default:
      return (
        <p style={style} {...attributes}>
          {children}
        </p>
      )
  }
}

const Leaf = ({ attributes, children, leaf }) => {
  if (leaf.bold) {
    children = <strong>{children}</strong>
  }

  if (leaf.code) {
    children = <code>{children}</code>
  }

  if (leaf.italic) {
    children = <em>{children}</em>
  }

  if (leaf.underline) {
    children = <u>{children}</u>
  }

  return <span {...attributes}>{children}</span>
}

const BlockButton = ({ format, icon }) => {
  const editor = useSlate()
  return (
    <Button
      active={isBlockActive(
        editor,
        format,
        TEXT_ALIGN_TYPES.includes(format) ? 'align' : 'type'
      )}
      onMouseDown={event => {
        event.preventDefault()
        toggleBlock(editor, format)
      }}
    >
      <Icon>{icon}</Icon>
    </Button>
  )
}

const MarkButton = ({ format, icon }) => {
  const editor = useSlate()
  return (
    <Button
      active={isMarkActive(editor, format)}
      onMouseDown={event => {
        event.preventDefault()
        toggleMark(editor, format)
      }}
    >
      <Icon>{icon}</Icon>
    </Button>
  )
}

const initialValue: Descendant[] = [
  {
    type: 'paragraph',
    children: [
      { text: 'This is editable ' },
      { text: 'rich', bold: true },
      { text: ' text, ' },
      { text: 'much', italic: true },
      { text: ' better than a ' },
      { text: '<textarea>', code: true },
      { text: '!' },
    ],
  },
  {
    type: 'paragraph',
    children: [
      {
        text:
          "Since it's rich text, you can do things like turn a selection of text ",
      },
      { text: 'bold', bold: true },
      {
        text:
          ', or add a semantically rendered block quote in the middle of the page, like this:',
      },
    ],
  },
  {
    type: 'block-quote',
    children: [{ text: 'A wise quote.' }],
  },
  {
    type: 'paragraph',
    align: 'center',
    children: [{ text: 'Try it out for yourself!' }],
  },
]

export default AndroidExample

Compatibility

I have tested this PR extensively, but would appreciate help in validating that this does not introduce regressions.

So far, I have tested this PR against:

  • ✅ Samsung Galaxy A32, Android 11
    • ✅ Samsung Keyboard
    • ✅ Gboard
    • ✅ SwiftKey
  • ✅ Google Pixel 7, Android 13
  • ✅ Google Pixel 6, Android 12
  • ✅ Google Pixel 5, Android 11
  • ✅ Samsung Galaxy S23, Android 13
  • ✅ Samsung Galaxy S22, Android 12
  • ✅ Samsung Galaxy S21, Android 11
  • ✅ OnePlus 9, Android 11

What's next

If we don't find any major regressions once this PR is merged, my recommendation would be to reconcile the editor state with the DOM on every input event by default, which is how the previous Android input manager used to work, and aligns with how Slate works on all other browsers.

It's imperative to keep the editor state in sync so that the other core parts of Slate, such as normalization, can function correctly.

This would also eliminate the need to handle Android differently when authoring plugins, such as how the current mentions example needs Android-specific logic to function properly on Android devices (see #5071 for example)

I haven't noticed any significant performance concerns with this approach on the devices I have been testing with, including real physical devices such as my mid-range 2021 Samsung Galaxy A32 running Android 11.

This would also allow removing the android-specific methods from the ReactEditor public API.

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.)

@changeset-bot
Copy link

changeset-bot bot commented Feb 24, 2023

🦋 Changeset detected

Latest commit: 88983de

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

@clauderic clauderic mentioned this pull request Feb 24, 2023
5 tasks
@clauderic clauderic marked this pull request as ready for review February 24, 2023 23:17
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.

2 participants