slate icon indicating copy to clipboard operation
slate copied to clipboard

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

Open davisg123 opened this issue 2 years ago • 8 comments

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

  • Slate Version: [e.g. 0.59] Regression in 0.67
  • Operating System: [e.g. iOS] macOS
  • Browser: [e.g. Chrome, Safari] Chrome, Safari
  • TypeScript Version: [e.g. 3.9.7 - required only if it's a TypeScript issue]

Context

davisg123 avatar May 26 '22 22:05 davisg123

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
    }
  }}
/>

annatraussnig avatar Jun 08 '22 13:06 annatraussnig

Same issue. Only number input is limited.

OceanApart avatar Jun 21 '22 11:06 OceanApart

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! 🙌

pchiwan avatar Aug 01 '22 11:08 pchiwan

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.

guoyianlin avatar Aug 09 '22 11:08 guoyianlin

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()
}

mshndev avatar Jan 12 '23 22:01 mshndev

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

codeingforcoffee avatar Apr 10 '23 07:04 codeingforcoffee

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

delijah avatar Oct 16 '23 08:10 delijah

how can i get the NAV_KEYS variable?

cguellner avatar Mar 20 '24 08:03 cguellner