[STC-806] Documents: impossible to delete characters with backspace after adding a wp link #165
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a bundled BlockNote extension pack to fix a ProseMirror/Yjs-specific Backspace deletion bug after inserting inline work package (WP) links, and adds browser integration tests to prevent regressions.
Changes:
- Add
BackspaceCharDeleteExtensionto force explicit ProseMirrortr.delete()transactions for Backspace (avoiding the unreliable native DOM/domObserver pathway in Yjs mode). - Bundle required extensions into
OpBlockNoteExtensionsand wire consumers to pass it at editor construction time. - Add browser integration tests covering Backspace deletion after inline and block WP insertion/conversion.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
test/lib/components/integration/editor.backspaceDelete.browser.test.tsx |
Adds browser tests that exercise Backspace behavior around inline/block WP nodes. |
test/helpers/renderEditor.tsx |
Switches test editor setup to construct the editor with OpBlockNoteExtensions. |
src/App.tsx |
Switches demo app editor construction to use OpBlockNoteExtensions. |
lib/plugins/pasteDeduplicateExtension.ts |
Renames the exported paste-deduplication extension symbol. |
lib/plugins/opBlockNoteExtensions.ts |
Introduces a bundled extension pack for OpenProject-specific BlockNote behavior. |
lib/plugins/backspaceCharDeleteExtension.ts |
Adds the Backspace transaction-based deletion extension. |
lib/index.ts |
Adjusts public exports to expose the new bundled extension. |
lib/hooks/useOpBlockNoteExtensions.ts |
Updates guidance to use OpBlockNoteExtensions at construction time. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
8aac9f7 to
e46447c
Compare
d3bc2f0 to
3a76155
Compare
3a76155 to
a051021
Compare
a051021 to
6d2d636
Compare
judithroth
left a comment
There was a problem hiding this comment.
Ok, this is a hard one again 🙂
You solution works ✨ and I like that you renamed the hook which needs to be placed in OpenProject to something generic again ✨ and you found a solution for that which does not interfere with copy-paste ✨
However, this approach here adds complexity and another ProseMirror extension, which we than have to maintain (e.g. on updates) and which could interefere with other people's extensions (not necessarily ours - we would notice that).
So I was thinking about other ways to achieve the same and the description of this PR pointed me in another direction: How about we just trigger some action that causes the re-sync after inserting an inline link? And that actually works. There's a WIP-draft here: jr/bug/stc-806-documents-impossible-to-delete-characters-with-backspace-after-adding-a-wp-link (unfortunately, it currently messes up the cursor placement if a inline work package link is placed inside a line of text).
What do you think of that approach? It means we have to maintain a lot less code, so I would strongly prefer something like that.
Ticket
https://community.openproject.org/projects/STC/work_packages/STC-806
What are you trying to accomplish?
After inserting a work package link via #, any text typed afterwards cannot be deleted with Backspace. The editor appears frozen until a re-sync event (timeout, click, or next edit) restores normal behavior.
Why does this happen?
The bug is specific to OpenProject's Documents module because it uses Hocuspocus (WebSocket + Yjs) for collaborative editing.
Tiptap's built-in Backspace handler only covers special cases - undoing input rules, deleting a non-empty selection, joining blocks at the start of a line. For a normal cursor sitting in the middle of text, every registered handler returns false. ProseMirror then falls back to letting the browser handle the keystroke natively.
The native path:
Step 2 becomes unreliable when a WP chip is present. The chip is an atom node rendered with contenteditable="false". Combined with y-prosemirror's own DOM instrumentation, the domObserver either misses the change or produces an empty no-op transaction. ProseMirror and Yjs states diverge - Backspace does nothing. The "gets unstuck after a while" behavior is Yjs's afterAllTransactions cycle forcing a document reconstruct from Y.Doc, which resets the diverged state.
This doesn't happen in standard editors because without Yjs there's no DOM instrumentation and domObserver works cleanly around atom nodes.
This doesn't happen in standard editors because without Yjs there's no DOM instrumentation and domObserver works cleanly around atom nodes.
What approach did you choose and why?
A shared package op-blocknote-extensions with two extensions bundled as OpBlockNoteExtensions.
KeyboardDeleteExtension
Intercepts Backspace and Delete keystrokes and dispatches an explicit tr.delete() ProseMirror transaction instead of falling through to the browser. Handles the full range of deletion shortcuts:
Implemented as a raw ProseMirror plugin (prosemirrorPlugins) rather than BlockNote's keyboardShortcuts API - this gives access to the raw KeyboardEvent, which is needed to skip IME composition events and to use Intl.Segmenter for correct grapheme cluster deletion (emoji, ZWJ sequences).
An explicit tr.delete() bypasses the entire native browser -> DOM change -> domObserver -> PM transaction chain. ProseMirror dispatches the transaction directly, ySyncPlugin picks it up cleanly, Yjs updates reliably.
PasteDeduplicateExtension
A separate but related issue: when a WP chip is copy-pasted, BlockNote duplicates all node props verbatim including instanceId. Two chips sharing the same ID means any resize or delete action always targets the first chip in the document, not the pasted copy. This extension regenerates a fresh instanceId for every chip found in pasted content via ProseMirror's transformPasted hook.
Important: registration at construction time
Both extensions must be added to editorOptions.extensions: [OpBlockNoteExtensions] at editor construction time, not via editor.registerPlugin() post-mount. Post-mount registration triggers Tiptap's reconfigure(), which destroys the yUndoPlugin view and removes the afterTransaction listener from Y.Doc. The plugin state carries over (so init never re-runs), but the listener is permanently gone - Ctrl+Z silently stops working for the rest of the session.
The openproject PR: opf/openproject#23840
Merge checklist