ianstormtaylor / slate

A completely customizable framework for building rich text editors. (Currently in beta.)
http://slatejs.org
MIT License
30.01k stars 3.26k forks source link

Attempting to limit text insertion by overriding `insertText` causes content to be out of sync with node representation #5010

Open davisg123 opened 2 years ago

davisg123 commented 2 years ago

Description We're attempting to limit the content to a maximum length by overriding insertText, but as of version 0.67 Slate will continue to display characters that are typed even though they don't exist in the node representation. This can lead to data loss

  editor.insertText = (text) => {
    const currentLength = getChildrenStringLength(editor.children);
    const totalLength = currentLength + text.length;
    if (totalLength <= limit) {
      insertText(text);
    } else {
      const available = limit - currentLength;
      if (available > 0) {
        insertText(text.substr(0, available));
      }
    }
  };

Recording

https://user-images.githubusercontent.com/2341305/170587569-57485465-c84e-4f64-8a78-2d3946e80eb2.mov

Sandbox https://codesandbox.io/s/slate-character-limit-bug-rdursq?file=/index.js

Change slate-react to < 0.67 to see the desired behavior

Steps To reproduce the behavior:

  1. Override insertText
  2. Type characters in the editor

Expectation The editor should not allow any characters to be typed

Environment

Context

annatraussnig commented 2 years ago

Facing the exact same issue.

–– EDIT ––

Adding an if clause to the onKeyDown handler of <Editable /> helps with the behaviour:

<Editable
  renderElement={renderElement}
  renderLeaf={renderLeaf}
  onKeyDown={(event) => {
    // disable key down events if max signs is reached
    // (except nav keys such as Tab/Arrows)
    if (
      config.maxSigns &&
      getCharCount(editor.children) >= config.maxSigns &&
      !NAV_KEYS.concat('Backspace').includes(event.key)
    ) {
      event.preventDefault()
      return false
    }
  }}
/>
OceanApart commented 2 years ago

Same issue. Only number input is limited.

pchiwan commented 2 years ago

Facing the exact same issue.

–– EDIT ––

Adding an if clause to the onKeyDown handler of <Editable /> helps with the behaviour:

<Editable
  renderElement={renderElement}
  renderLeaf={renderLeaf}
  onKeyDown={(event) => {
    // disable key down events if max signs is reached
    // (except nav keys such as Tab/Arrows)
    if (
      config.maxSigns &&
      getCharCount(editor.children) >= config.maxSigns &&
      !NAV_KEYS.concat('Backspace').includes(event.key)
    ) {
      event.preventDefault()
      return false
    }
  }}
/>

this worked perfectly, thanks a lot @annatraussnig! 🙌

guoyianlin commented 2 years ago

Facing the exact same issue.

–– EDIT ––

Adding an if clause to the onKeyDown handler of <Editable /> helps with the behaviour:

<Editable
  renderElement={renderElement}
  renderLeaf={renderLeaf}
  onKeyDown={(event) => {
    // disable key down events if max signs is reached
    // (except nav keys such as Tab/Arrows)
    if (
      config.maxSigns &&
      getCharCount(editor.children) >= config.maxSigns &&
      !NAV_KEYS.concat('Backspace').includes(event.key)
    ) {
      event.preventDefault()
      return false
    }
  }}
/>

If it is a Chinese input method, it will make an error.

mshndev commented 1 year ago

I have two more different approaches.

The first one is based on predicting the result:

<Editable
  renderLeaf={renderLeaf}
  renderElement={renderElement}
  renderPlaceholder={renderPlaceholder}
  placeholder={placeholder}
  onDOMBeforeInput={(e) => {
    if (
      e.inputType !== 'insertText' &&
      e.inputType !== 'insertFromPaste'
    )
      return

    const input = e.data || e.dataTransfer?.getData('text/plain')
    if (!input) return

    const sel = [
      editor.selection?.anchor?.offset || 0,
      editor.selection?.focus?.offset || 0
    ].sort()

    const text = serializeString(editor.children)

    const newText =
      text.substring(0, sel[0]) + input + text.substring(sel[1])

    if (newText.length > config.maxSigns) e.preventDefault()
  }}
/>

It also works with pasting some (not every 🤷‍♂️) clipboard data.

The second one I made for formatting on the go, so it may be overkill for just limiting the characters length.

In the editor:

const editor = useMemo(
  () =>
    withFormatting(
     withHistory(withReact(createEditor())),
      (s) =>
        s.trimStart().replace(/\s+/gi, ' ') // some formatting
          .substring(0, config.maxSigns) // limit char length
    ),
  []
)

the plugin:

function withFormatting<T extends Editor>(editor: T, format?: FormatCb) {
  const { insertText, deleteFragment, deleteBackward, deleteForward } = editor

  const canFormat = typeof format === 'function'
  if (!canFormat) return editor

  editor.insertText = (textPart) => {
    insertText(textPart)

    const text = serializeString(currentNode(editor))
    const sel = editor.selection?.anchor?.offset || text.length
    const formattedText = format(text)

    // replacing with formatted text
    Transforms.insertText(editor, formattedText, {
      at: changeSelectionOffset(editor.selection, [0, text.length])
    })

    const textDiff = formattedText.length - text.length
    const newOffset = Math.min(sel + textDiff, formattedText.length)
    editor.selection = changeSelectionOffset(editor.selection, newOffset)
  }

  editor.deleteFragment = (d) => {
    deleteFragment(d)
    editor.insertText('')
  }
  editor.deleteBackward = (d) => {
    deleteBackward(d)
    editor.insertText('')
  }
  editor.deleteForward = (d) => {
    deleteForward(d)
    editor.insertText('')
  }

  return editor
}

function currentNode<T extends Editor>({
  children,
  selection
}: T): Descendant[] {
  if (!selection) return children
  const path = selection?.anchor.path || [0, 0]
  const url = path
    .slice(0, path.length - 1)
    .map((i) => `[${i}]`)
    .join('.children')

  const node = _.get(children, url)
  return node?.children || children
}

function changeSelectionOffset(
  selection: BaseSelection,
  offsetOrArray: [number, number] | number
) {
  const offset =
    typeof offsetOrArray === 'number' ? [offsetOrArray] : offsetOrArray
  return {
    anchor: {
      ...selection.anchor,
      offset: offset[0]
    },
    focus: {
      ...selection.focus,
      offset: offset[1] || offset[0]
    }
  }
}

function serializeString (value?: Descendant[]){
  return (value || []).map((n) => Node.string(n)).join()
}
codeingforcoffee commented 1 year ago

not work while useing Chinese.....😮‍💨

delijah commented 1 year ago

Thx for the workarounds, but shouldn't this work correctly out of the box?

cguellner commented 8 months ago

how can i get the NAV_KEYS variable?