From 6d2d63659a6828022cb3d2e7b934a8c2dc909eda Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Wed, 17 Jun 2026 15:53:07 +0300 Subject: [PATCH] [STC-806] Documents: impossible to delete characters with backspace after adding a wp link https://community.openproject.org/wp/STC-806 --- README.md | 9 +- lib/hooks/useOpBlockNoteExtensions.ts | 5 +- lib/index.ts | 2 +- lib/plugins/keyboardDeleteExtension.ts | 94 +++++++++++ lib/plugins/opBlockNoteExtensions.ts | 14 ++ lib/plugins/pasteDeduplicateExtension.ts | 2 +- src/App.tsx | 4 +- test/helpers/renderEditor.tsx | 4 +- .../editor.backspaceDelete.browser.test.tsx | 159 ++++++++++++++++++ 9 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 lib/plugins/keyboardDeleteExtension.ts create mode 100644 lib/plugins/opBlockNoteExtensions.ts create mode 100644 test/lib/components/integration/editor.backspaceDelete.browser.test.tsx diff --git a/README.md b/README.md index 0b44eaa..b9376a8 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ const schema = BlockNoteSchema.create().extend({ type EditorType = typeof schema.BlockNoteEditor; ``` -Create the editor, passing `PasteDeduplicateInstanceIdsExtension` in `extensions`. +Create the editor, passing `OpBlockNoteExtensions` in `extensions`. This must be done at construction time — registering the plugin post-mount via `editor.registerPlugin()` triggers ProseMirror's `reconfigure()`, which destroys the Y.js `UndoManager` and silently breaks Ctrl+Z. @@ -51,10 +51,15 @@ the Y.js `UndoManager` and silently breaks Ctrl+Z. ```tsx const editor = useCreateBlockNote({ schema, - extensions: [PasteDeduplicateInstanceIdsExtension], + extensions: [OpBlockNoteExtensions], }); ``` +`OpBlockNoteExtensions` bundles: + +- **`PasteDeduplicateExtension`** — regenerates a fresh `instanceId` for every WP chip found in pasted content, preventing two chips from sharing the same ID. +- **`KeyboardDeleteExtension`** — intercepts `Backspace` and `Delete` keystrokes and dispatches explicit ProseMirror transactions instead of falling through to the browser. Required when using Hocuspocus/Yjs, where the native DOM path becomes unreliable around atom nodes (WP chips) and causes keystrokes to silently do nothing. + Wire the runtime hooks and build the slash and hash menus: ```tsx diff --git a/lib/hooks/useOpBlockNoteExtensions.ts b/lib/hooks/useOpBlockNoteExtensions.ts index 22f502f..7b3b2b9 100644 --- a/lib/hooks/useOpBlockNoteExtensions.ts +++ b/lib/hooks/useOpBlockNoteExtensions.ts @@ -5,9 +5,8 @@ import { useInlineWpEvents } from './useInlineWpEvents'; * Wires up the runtime hooks that BlockNote integration needs *after* the * editor is mounted. * - * Use {@link PasteDeduplicateInstanceIdsExtension} in your editor's - * `extensions: [...]` array instead of calling `useDeduplicateInstanceIds` - * from here. Registering ProseMirror plugins post-mount via + * Use {@link OpBlockNoteExtensions} in your editor's `extensions: [...]` + * array at construction time. Registering ProseMirror plugins post-mount via * `editor.registerPlugin(...)` triggers ProseMirror's `reconfigure()` and * destroys the y-prosemirror UndoManager, breaking Ctrl+Z. */ diff --git a/lib/index.ts b/lib/index.ts index 252a1d8..1eb7914 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -15,4 +15,4 @@ export type { HashMenuItem } from './components/HashMenu'; export { useWorkPackageSearch } from './hooks/useWorkPackageSearch'; export type { WorkPackage } from './openProjectTypes'; export { useOpBlockNoteExtensions } from './hooks/useOpBlockNoteExtensions'; -export { PasteDeduplicateInstanceIdsExtension } from './plugins/pasteDeduplicateExtension'; +export { OpBlockNoteExtensions } from './plugins/opBlockNoteExtensions'; diff --git a/lib/plugins/keyboardDeleteExtension.ts b/lib/plugins/keyboardDeleteExtension.ts new file mode 100644 index 0000000..d758825 --- /dev/null +++ b/lib/plugins/keyboardDeleteExtension.ts @@ -0,0 +1,94 @@ +import { createExtension } from '@blocknote/core'; +import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'; + +const pluginKey = new PluginKey('opKeyboardDelete'); + +// Intl.Segmenter is ES2022; cast to avoid bumping tsconfig lib target. +interface SegmenterEntry {segment:string} +type SegmenterCtor = new (locale?:string, opts?:{granularity?:string}) => {segment(text:string):Iterable}; +const SegmenterCls = (Intl as {Segmenter?:SegmenterCtor}).Segmenter; +const segmenter = SegmenterCls ? new SegmenterCls(undefined, { granularity: 'grapheme' }) : null; + +function graphemes(text:string):string[] { + if (segmenter) { + return [...segmenter.segment(text)].map(e => e.segment); + } + return [...text]; +} + +function backwardCharSize(node:{isText:boolean; text?:string|null; nodeSize:number}):number { + if (!node.isText) return node.nodeSize; + const g = graphemes(node.text!); + return g[g.length - 1]?.length ?? 1; +} + +function forwardCharSize(node:{isText:boolean; text?:string|null; nodeSize:number}):number { + if (!node.isText) return node.nodeSize; + return graphemes(node.text!)[0]?.length ?? 1; +} + +const keyboardDeletePlugin = new Plugin({ + key: pluginKey, + props: { + handleKeyDown(view, event) { + const isBackspace = event.key === 'Backspace'; + const isDelete = event.key === 'Delete'; + if ((!isBackspace && !isDelete) || event.isComposing) return false; + + const { selection } = view.state; + if (!(selection instanceof TextSelection) || !selection.empty) return false; + + const $cursor = selection.$cursor; + if (!$cursor) return false; + + const isLineDelete = event.metaKey && !event.ctrlKey && !event.altKey; + const isWordDelete = event.ctrlKey || event.altKey; + + if (isBackspace) { + if ($cursor.parentOffset === 0) return false; + const nodeBefore = $cursor.nodeBefore; + if (!nodeBefore) return false; + + let from:number; + if (isLineDelete) { + from = $cursor.pos - $cursor.parentOffset; + } else if (isWordDelete && nodeBefore.isText) { + const text = nodeBefore.text!; + let i = text.length; + while (i > 0 && /\s/.test(text[i - 1])) i -= 1; + while (i > 0 && !/\s/.test(text[i - 1])) i -= 1; + from = $cursor.pos - (text.length - i); + } else { + from = $cursor.pos - backwardCharSize(nodeBefore); + } + + view.dispatch(view.state.tr.delete(from, $cursor.pos)); + return true; + } + + const nodeAfter = $cursor.nodeAfter; + if (!nodeAfter) return false; + + let to:number; + if (isLineDelete) { + to = $cursor.end(); + } else if (isWordDelete && nodeAfter.isText) { + const text = nodeAfter.text!; + let i = 0; + while (i < text.length && !/\s/.test(text[i])) i += 1; + while (i < text.length && /\s/.test(text[i])) i += 1; + to = $cursor.pos + i; + } else { + to = $cursor.pos + forwardCharSize(nodeAfter); + } + + view.dispatch(view.state.tr.delete($cursor.pos, to)); + return true; + }, + }, +}); + +export const KeyboardDeleteExtension = createExtension({ + key: 'opKeyboardDelete', + prosemirrorPlugins: [keyboardDeletePlugin], +}); diff --git a/lib/plugins/opBlockNoteExtensions.ts b/lib/plugins/opBlockNoteExtensions.ts new file mode 100644 index 0000000..14256fc --- /dev/null +++ b/lib/plugins/opBlockNoteExtensions.ts @@ -0,0 +1,14 @@ +import { createExtension } from '@blocknote/core'; +import { KeyboardDeleteExtension } from './keyboardDeleteExtension'; +import { PasteDeduplicateExtension } from './pasteDeduplicateExtension'; + +/** + * Required extensions for op-blocknote-extensions. + * + * Must be added to `editorOptions.extensions: [...]` at editor construction + * time, not registered post-mount via `editor.registerPlugin(...)`. + */ +export const OpBlockNoteExtensions = createExtension({ + key: 'opBlockNoteExtensions', + blockNoteExtensions: [PasteDeduplicateExtension, KeyboardDeleteExtension], +}); diff --git a/lib/plugins/pasteDeduplicateExtension.ts b/lib/plugins/pasteDeduplicateExtension.ts index 3e7e286..f8fb027 100644 --- a/lib/plugins/pasteDeduplicateExtension.ts +++ b/lib/plugins/pasteDeduplicateExtension.ts @@ -18,7 +18,7 @@ import { pasteDeduplicatePlugin } from './pasteDeduplicatePlugin'; * Adding the plugin to the editor's initial extension list avoids the * `reconfigure` pass entirely. */ -export const PasteDeduplicateInstanceIdsExtension = createExtension({ +export const PasteDeduplicateExtension = createExtension({ key: 'pasteDeduplicateInstanceIds', prosemirrorPlugins: [pasteDeduplicatePlugin], }); diff --git a/src/App.tsx b/src/App.tsx index 251f106..49e1e0c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,7 @@ import { workPackageSlashMenu, useHashWpMenu, useOpBlockNoteExtensions, - PasteDeduplicateInstanceIdsExtension, + OpBlockNoteExtensions, } from '../lib'; import './fetchOverride'; @@ -45,7 +45,7 @@ function buildSlashMenuItems(editor:EditorType) { } export default function App() { - const editor = useCreateBlockNote({ schema, extensions: [PasteDeduplicateInstanceIdsExtension] }); + const editor = useCreateBlockNote({ schema, extensions: [OpBlockNoteExtensions] }); // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument useOpBlockNoteExtensions(editor as any); diff --git a/test/helpers/renderEditor.tsx b/test/helpers/renderEditor.tsx index f4b6635..cc3a5d3 100644 --- a/test/helpers/renderEditor.tsx +++ b/test/helpers/renderEditor.tsx @@ -10,7 +10,7 @@ import { workPackageSlashMenu, useHashWpMenu, useOpBlockNoteExtensions, - PasteDeduplicateInstanceIdsExtension, + OpBlockNoteExtensions, } from '../../lib'; import '@blocknote/core/fonts/inter.css'; @@ -26,7 +26,7 @@ const schema = BlockNoteSchema.create().extend({ }); function Editor({ onEditor }:{ onEditor?:(editor:any) => void }) { - const editor = useCreateBlockNote({ schema, extensions: [PasteDeduplicateInstanceIdsExtension] }); + const editor = useCreateBlockNote({ schema, extensions: [OpBlockNoteExtensions] }); onEditor?.(editor); useOpBlockNoteExtensions(editor as any); diff --git a/test/lib/components/integration/editor.backspaceDelete.browser.test.tsx b/test/lib/components/integration/editor.backspaceDelete.browser.test.tsx new file mode 100644 index 0000000..1794f3f --- /dev/null +++ b/test/lib/components/integration/editor.backspaceDelete.browser.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; +import { renderEditor } from '../../../helpers/renderEditor'; +import { + insertInlineWorkPackageViaHash, + insertInlineWorkPackageViaSlashMenu, + convertToCompactCard, +} from '../../../helpers/editorHelpers'; + +describe('Backspace - inline WP', () => { + it('deletes text typed after inline chip, character by character', async () => { + renderEditor(); + await insertInlineWorkPackageViaHash('#'); + + const editor = page.getByRole('textbox'); + await userEvent.type(editor, 'abc'); + await expect.element(editor).toHaveTextContent('abc'); + + await userEvent.keyboard('{Backspace}'); + await expect.element(editor).not.toHaveTextContent('abc'); + await expect.element(editor).toHaveTextContent('ab'); + + await userEvent.keyboard('{Backspace}'); + await expect.element(editor).not.toHaveTextContent('ab'); + await expect.element(editor).toHaveTextContent('a'); + + await userEvent.keyboard('{Backspace}'); + await expect.element(editor).not.toHaveTextContent('a'); + + await expect.element(page.getByText('#123')).toBeVisible(); + }); + + it('deletes the inline WP itself with Backspace', async () => { + renderEditor(); + await insertInlineWorkPackageViaHash('#'); + + // Cursor is right after the WP + await userEvent.keyboard('{Backspace}'); + + await expect.element(page.getByText('#123')).not.toBeInTheDocument(); + }); + + it('deletes typed text and then the WP with successive Backspace presses', async () => { + renderEditor(); + await insertInlineWorkPackageViaHash('#'); + + const editor = page.getByRole('textbox'); + await userEvent.type(editor, 'hi'); + await expect.element(editor).toHaveTextContent('hi'); + + await userEvent.keyboard('{Backspace}'); + await expect.element(editor).toHaveTextContent('h'); + await expect.element(editor).not.toHaveTextContent('hi'); + + await userEvent.keyboard('{Backspace}'); + await expect.element(editor).not.toHaveTextContent('h'); + + // Cursor is now right after the WP - Backspace removes the WP + await userEvent.keyboard('{Backspace}'); + await expect.element(page.getByText('#123')).not.toBeInTheDocument(); + }); +}); + +describe('Backspace - block WP', () => { + it('deletes text typed after a block WP, character by character', async () => { + renderEditor(); + await insertInlineWorkPackageViaSlashMenu(); + await convertToCompactCard(); + await expect.element(page.getByTestId('block-card')).toBeVisible(); + + // After conversion the cursor lands in the empty paragraph following the block card + const editor = page.getByRole('textbox'); + await userEvent.type(editor, 'abc'); + await expect.element(editor).toHaveTextContent('abc'); + + await userEvent.keyboard('{Backspace}'); + await expect.element(editor).not.toHaveTextContent('abc'); + await expect.element(editor).toHaveTextContent('ab'); + + await userEvent.keyboard('{Backspace}'); + await expect.element(editor).not.toHaveTextContent('ab'); + await expect.element(editor).toHaveTextContent('a'); + + await userEvent.keyboard('{Backspace}'); + await expect.element(editor).not.toHaveTextContent('a'); + + await expect.element(page.getByTestId('block-card')).toBeVisible(); + }); +}); + +describe('Delete - inline WP', () => { + it('deletes text in front of the cursor, character by character', async () => { + renderEditor(); + await insertInlineWorkPackageViaHash('#'); + + const editor = page.getByRole('textbox'); + await userEvent.type(editor, 'abc'); + await expect.element(editor).toHaveTextContent('abc'); + + // Move cursor before 'a' (right after the chip) + await userEvent.keyboard('{ArrowLeft}{ArrowLeft}{ArrowLeft}'); + + await userEvent.keyboard('{Delete}'); + await expect.element(editor).not.toHaveTextContent('abc'); + await expect.element(editor).toHaveTextContent('bc'); + + await userEvent.keyboard('{Delete}'); + await expect.element(editor).not.toHaveTextContent('bc'); + await expect.element(editor).toHaveTextContent('c'); + + await userEvent.keyboard('{Delete}'); + await expect.element(editor).not.toHaveTextContent('c'); + + await expect.element(page.getByText('#123')).toBeVisible(); + }); + + it('deletes the inline WP itself with Delete', async () => { + renderEditor(); + await insertInlineWorkPackageViaHash('#'); + + // Move cursor before the chip + await userEvent.keyboard('{ArrowLeft}'); + + await userEvent.keyboard('{Delete}'); + + await expect.element(page.getByText('#123')).not.toBeInTheDocument(); + }); + +}); + +describe('Delete - block WP', () => { + it('deletes text in front of the cursor, character by character', async () => { + renderEditor(); + await insertInlineWorkPackageViaSlashMenu(); + await convertToCompactCard(); + await expect.element(page.getByTestId('block-card')).toBeVisible(); + + // After conversion the cursor lands in the empty paragraph following the block card + const editor = page.getByRole('textbox'); + await userEvent.type(editor, 'abc'); + await expect.element(editor).toHaveTextContent('abc'); + + // Move cursor before 'a' + await userEvent.keyboard('{ArrowLeft}{ArrowLeft}{ArrowLeft}'); + + await userEvent.keyboard('{Delete}'); + await expect.element(editor).not.toHaveTextContent('abc'); + await expect.element(editor).toHaveTextContent('bc'); + + await userEvent.keyboard('{Delete}'); + await expect.element(editor).not.toHaveTextContent('bc'); + await expect.element(editor).toHaveTextContent('c'); + + await userEvent.keyboard('{Delete}'); + await expect.element(editor).not.toHaveTextContent('c'); + + await expect.element(page.getByTestId('block-card')).toBeVisible(); + }); +});