ueberdosis / tiptap

The headless rich text editor framework for web artisans.
https://tiptap.dev
MIT License
27.51k stars 2.29k forks source link

The cursor auto move to end of editor #2940

Closed tiendatleo closed 1 year ago

tiendatleo commented 2 years ago

What’s the bug you are facing?

I am facing an issue about the position of the cursor on production environment.

Which browser was this experienced in? Are any special extensions installed?

OS: macOS 12.3.1 Browser: Chrome Version 103.0.5060.53 (Official Build) (arm64) Dependencies: "@tiptap/core": "^2.0.0-beta.181", "@tiptap/extension-color": "^2.0.0-beta.12", "@tiptap/extension-image": "^2.0.0-beta.30", "@tiptap/extension-link": "^2.0.0-beta.35", "@tiptap/extension-table": "^2.0.0-beta.54", "@tiptap/extension-table-cell": "^2.0.0-beta.23", "@tiptap/extension-table-header": "^2.0.0-beta.25", "@tiptap/extension-table-row": "^2.0.0-beta.22", "@tiptap/extension-text-align": "^2.0.0-beta.31", "@tiptap/extension-text-style": "^2.0.0-beta.26", "@tiptap/extension-underline": "^2.0.0-beta.25", "@tiptap/starter-kit": "^2.0.0-beta.190", "@tiptap/vue-2": "^2.0.0-beta.84",

How can we reproduce the bug on our side?

After I typed a word, the cursor is moved to end of the editor. Video below.

Can you provide a CodeSandbox?

No response

What did you expect to happen?

The position of cursor is not changed

Anything to add? (optional)

https://user-images.githubusercontent.com/25786110/176377309-9f2522f1-9031-4df9-b3a5-48a1ab28dbb4.mov

Did you update your dependencies?

Are you sponsoring us?

svenadlung commented 2 years ago

Hi @tiendatleo, are you able to create a simplified sandbox? It's hard to tell what's going wrong without more details.

Jemaz commented 2 years ago

Dependencies installed:

  • "@tiptap/extension-mention": "^2.0.0-beta.102"
  • "@tiptap/starter-kit": "^2.0.0-beta.190"
  • "@tiptap/vue-2": "^2.0.0-beta.84"

That happened to me as well. The bug is really hard to replicate honestly. But I attached the dependencies I've used if it should help.

What fixes the bug for me is to add in this snippet in the watchers: image

Which is the same code mentioned in the documentation as well. But I've removed it as it didn't explain why and it didn't make sense to check same value in the watcher (which watchers should trigger whenever the dependent values' change)


Also, just generally speaking (and completely unrelated to this thread) if you it helps you, I find Tiptap's Vue 2 has weird reactivity issues and I'm doing weird things like stringifying and parsing the editor doc object as a prop.

tiendatleo commented 2 years ago

The codes are very long and I can't bring it to a sandbox. Anyone facing to this issue?

Cookiekira commented 2 years ago

Do not use emit and setContent by watch props at the same time. 0@JZL1Q3UXK)ZYWU$WDo not use emit and setContent by watch props at the same time. 
3BU setContent makes the cursor move to the end of the editor.

svenadlung commented 1 year ago

Without being able to look at the code, it's difficult to provide help. Now that there were some good advices and the demo on model does not show the same behaviour, I would close the issue for now.

Feel free to let me know if the problem is still reproducible.

jarek-foksa commented 1 year ago

I can reproduce this issue with TipTap 2.0.3 on Safari 16.5 when I place the editor inside a custom element with a closed shadow root. Opening the shadow root on all ancestor elements fixes the problem. I don't really have the time to research this further, but it looks like upstream WebKit bug.

arbisyarifudin commented 1 year ago

Do not use emit and setContent by watch props at the same time. 0@JZL1Q3UXK)ZYWU$WDo not use emit and setContent by watch props at the same time. 3BU setContent makes the cursor move to the end of the editor.

wow! so, how to update the editor content if we want to update editor content from parent component?

Cookiekira commented 1 year ago

Do not use emit and setContent by watch props at the same time. 0@JZL1Q3UXK)ZYWU$WDo not use emit and setContent by watch props at the same time. 3BU setContent makes the cursor move to the end of the editor.

wow! so, how to update the editor content if we want to update editor content from parent component?

const setContent = (content: string) => {
  editor.value?.chain().focus().setContent(JSON.parse(content)).run()
}
defineExpose({
  setContent
})
SGLara commented 1 year ago

Has anyone find a solution for this in React?

SGLara commented 1 year ago

The same is happening to me, this is my code: I'm stuck please help, there is no more info about it other that this issue reported.

NoteContext.jsx

export const NoteContext = createContext()

export const NoteProvider = ({ children }) => {
  const [noteId, setNoteId] = useState('')
  const [noteContent, setNoteContent] = useState('')
  const [debouncedNoteContent] = useDebounce(noteContent, 500)

  const editor = useEditor({
    extensions: [
      StarterKit.configure({
        codeBlock: false,
        code: false,
        hardBreak: false
      }),
      Underline,
      Typography,
      Placeholder.configure({
        placeholder: 'Add note...'
      }),
      TextAlign.configure({
        types: ['heading', 'paragraph'],
        alignments: ['left', 'right', 'center', 'justify']
      }),
      CustomCodeBlock,
      CustomCode,
      CustomHardBreak
    ],
    onUpdate: ({ editor }) => {
      setNoteContent(editor.getHTML())
    }
  })

  return (
    <NoteContext.Provider value={{
      noteId,
      setNoteId,
      editor,
      noteContent,
      setNoteContent,
      debouncedNoteContent
    }}
    >
      {children}
    </NoteContext.Provider>
  )
}

NoteContent.jsx

export default function NoteContent () {
  const [user] = useAuthState(auth)

  const {
    noteId,
    editor,
    noteContent,
    setNoteContent,
    debouncedNoteContent
  } = useContext(NoteContext)

  const navigate = useNavigate()

  const [values] = useListVals(ref(db, `note-it-db/${user.uid}/notes`))

  const existingNote = useMemo(() => {
    return values.find(note => note.id === noteId)
  }, [values, noteId])

  const createNewNote = (noteContent, user) => {
    const newNoteId = uuidv4()

    const newNoteData = {
      id: newNoteId,
      content: noteContent,
      favorite: false,
      lastModified: moment().format('DD-MM-YYYY hh:mm:ss')
    }

    set(ref(db, `note-it-db/${user.uid}/notes/${newNoteId}`), newNoteData)

    return newNoteData
  }

  const updateNote = (existingNote, noteContent, user) => {
    const updatedNoteData = {
      ...existingNote,
      content: noteContent,
      lastModified: moment().format('DD-MM-YYYY hh:mm:ss')
    }
    set(ref(db, `note-it-db/${user.uid}/notes/${existingNote.id}`), updatedNoteData)

    return updatedNoteData
  }

  useEffect(() => {
    if (editor && noteContent) {
      const notePurified = DOMPurify.sanitize(noteContent)
      editor.commands.setContent(notePurified, false, { preserveWhitespace: 'full' })
    }
  }, [editor, noteContent])

  useEffect(() => {
    if (noteId && existingNote) {
      setNoteContent(existingNote.content)
    }
  }, [noteId, existingNote, setNoteContent])

  useEffect(() => {
    if (noteId && debouncedNoteContent.trim() && existingNote) {
      updateNote(existingNote, debouncedNoteContent, user)
    }
  }, [noteId, debouncedNoteContent, existingNote, user])

  useEffect(() => {
    if (!noteId && debouncedNoteContent.trim()) {
      const newNoteId = createNewNote(debouncedNoteContent, user).id

      navigate(`/notes/${newNoteId}`)
    }
  }, [debouncedNoteContent, noteId, navigate, user])

  return (
    <Box style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'space-between',
      height: '100%',
      gap: '1rem'
    }}
    >
      <EditorContent
        className='note-content'
        editor={editor}
      />
      <NoteTools editor={editor} />
    </Box>
  )
}
templeman15 commented 1 year ago

I am also looking for a solution for this in React.

I was able to get something working but I'm not 100% sure it's the best way to go.

  useEffect(() => {
    if (content && editor) {
      // Save cursor position
      const { from, to } = editor.state.selection;

      // Update content
      editor.commands.setContent(content);

      // Restore cursor position
      const newFrom = Math.min(from, editor.state.doc.content.size);
      const newTo = Math.min(to, editor.state.doc.content.size);
      const textSelection = new TextSelection(
        editor.state.doc.resolve(newFrom),
        editor.state.doc.resolve(newTo)
      );
      editor.view.dispatch(editor.state.tr.setSelection(textSelection));
    }
  }, [content, editor]);
LeonidVE commented 1 year ago

I faced this problem in React project. For me it was useful to set content only if (editor?.isEmpty). So I'm using setContent inside useEffect only to load initial state. Then I operate with useForm hook to set modified values.

P.S. I'm not logged in on the needed pc, so here is no code example. I'm commenting by phone.

SGLara commented 1 year ago

@templeman15 Sorry I couldn't reply back to you, I haven't been able to fix this issue, your solution worked for me, I really appreciated it, thanks so much 🙌🏻 Just wondering how you discovered this information, specially the class TextSelection I haven't found anything related to that in the documentation.

Something I can show you is that I found that this also do the trick:

const newFrom = Math.min(from, editor.state.doc.content.size)
const newTo = Math.min(to, editor.state.doc.content.size)

editor.commands.setTextSelection({ from: newFrom, to: newTo })
SGLara commented 1 year ago

@LeonidVE Thank you too, I tried this idea too and it worked, thanks

templeman15 commented 1 year ago

@templeman15 Sorry I couldn't reply back to you, I haven't been able to fix this issue, your solution worked for me, I really appreciated it, thanks so much 🙌🏻 Just wondering how you discovered this information, specially the class TextSelection I haven't found anything related to that in the documentation.

@SGLara I looked at the source some and then I asked ChatGPT (4) with some of the source and it pointed it out. 😄

TephrosisDEV commented 9 months ago

Thanks guys!!

This solution worked without changing cursors etc, my example is much simpler tho, but I did have the same issue before ;) edit: value is a prop that is passed to this TipTap component

const editor: Editor | null = useEditor({
    extensions: [StarterKit, Underline],
    content: value,
    editorProps: {
        attributes: {
            class: 'prose prose-sm lg:prose-lg w-full xl:prose-2xl focus:outline-none border bg-white border-radius-lg p-6 ',
        },
    },

    onUpdate: ({ editor: currentEditor }) => {
        onChange(currentEditor.getHTML())
    },
})

useEffect(() => {
    if (editor?.isEmpty) editor.commands.setContent(value)
}, [value, editor])
usmanlatif306 commented 6 months ago

Thanks buddy for helping me. useEffect(() => { if (editor?.isEmpty) editor.commands.setContent(value) }, [value, editor])

This helped me to solve my issue

Jaimish00 commented 6 months ago

Hello Community,

If anyone is facing this issue with Vue watchers and emit onUpdate simultaneously, you can use this hack to get around this cursor issue easily. 🚀

modelValue(newValue) {
      // ? If the new modelValue is different then editor HTML, set it
      // ? This should only occur for once, if the modelValue fetch is delayed by a bit
      if (this.editor && newValue && newValue !== this.editor.getHTML()) {
        this.editor.commands.setContent(newValue, false, {
          preserveWhitespace: "full",
        });
      }
}
dbonissone commented 5 months ago

Hi Guys,

The solution you proposed works but has a limitation: if you change the value outside the component and the value in the editor is not empty, it doesn't update.

The problem, as you mentioned, is that setContent sets the cursor to the bottom, but it shouldn't enter that function because there's a check above it:

const isSame = editor.getHTML() === newValue;
if (isSame) {
    return;
}

I wondered why it wasn't entering the if statement, so I investigated and discovered that getHTML() converts special characters while in newValue I found values like &Ugrave, &#140, etc. To correct this error, you need to replicate what getHTML() does. So the solution I found is this:

const div = document.createElement('div');
div.innerHTML = newValue;
const isSame = editor.getHTML() === div.innerHTML;
if (isSame) {
    return;
}
editor.commands.setContent(newValue, false);

That's why the problem doesn't always occur. In the end, it notes that the value is the same and doesn't enter the setContent.