Skip to content

Commit a051021

Browse files
committed
[STC-806] Documents: impossible to delete characters with backspace after adding a wp link
https://community.openproject.org/wp/STC-806
1 parent b0984c6 commit a051021

8 files changed

Lines changed: 275 additions & 9 deletions

File tree

lib/hooks/useOpBlockNoteExtensions.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@ import { useInlineWpEvents } from './useInlineWpEvents';
55
* Wires up the runtime hooks that BlockNote integration needs *after* the
66
* editor is mounted.
77
*
8-
* Use {@link PasteDeduplicateInstanceIdsExtension} in your editor's
9-
* `extensions: [...]` array instead of calling `useDeduplicateInstanceIds`
10-
* from here. Registering ProseMirror plugins post-mount via
8+
* Use {@link OpBlockNoteExtensions} in your editor's `extensions: [...]`
9+
* array at construction time. Registering ProseMirror plugins post-mount via
1110
* `editor.registerPlugin(...)` triggers ProseMirror's `reconfigure()` and
1211
* destroys the y-prosemirror UndoManager, breaking Ctrl+Z.
1312
*/

lib/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@ export type { HashMenuItem } from './components/HashMenu';
1515
export { useWorkPackageSearch } from './hooks/useWorkPackageSearch';
1616
export type { WorkPackage } from './openProjectTypes';
1717
export { useOpBlockNoteExtensions } from './hooks/useOpBlockNoteExtensions';
18-
export { PasteDeduplicateInstanceIdsExtension } from './plugins/pasteDeduplicateExtension';
18+
export { OpBlockNoteExtensions } from './plugins/opBlockNoteExtensions';
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { createExtension } from '@blocknote/core';
2+
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state';
3+
4+
const pluginKey = new PluginKey('opKeyboardDelete');
5+
6+
// Intl.Segmenter is ES2022; cast to avoid bumping tsconfig lib target.
7+
interface SegmenterEntry {segment:string}
8+
type SegmenterCtor = new (locale?:string, opts?:{granularity?:string}) => {segment(text:string):Iterable<SegmenterEntry>};
9+
const SegmenterCls = (Intl as {Segmenter?:SegmenterCtor}).Segmenter;
10+
const segmenter = SegmenterCls ? new SegmenterCls(undefined, { granularity: 'grapheme' }) : null;
11+
12+
function graphemes(text:string):string[] {
13+
if (segmenter) {
14+
return [...segmenter.segment(text)].map(e => e.segment);
15+
}
16+
return [...text];
17+
}
18+
19+
function backwardCharSize(node:{isText:boolean; text?:string|null; nodeSize:number}):number {
20+
if (!node.isText) return node.nodeSize;
21+
const g = graphemes(node.text!);
22+
return g[g.length - 1]?.length ?? 1;
23+
}
24+
25+
function forwardCharSize(node:{isText:boolean; text?:string|null; nodeSize:number}):number {
26+
if (!node.isText) return node.nodeSize;
27+
return graphemes(node.text!)[0]?.length ?? 1;
28+
}
29+
30+
const keyboardDeletePlugin = new Plugin({
31+
key: pluginKey,
32+
props: {
33+
handleKeyDown(view, event) {
34+
const isBackspace = event.key === 'Backspace';
35+
const isDelete = event.key === 'Delete';
36+
if ((!isBackspace && !isDelete) || event.isComposing) return false;
37+
38+
const { selection } = view.state;
39+
if (!(selection instanceof TextSelection) || !selection.empty) return false;
40+
41+
const $cursor = selection.$cursor;
42+
if (!$cursor) return false;
43+
44+
const isLineDelete = event.metaKey && !event.ctrlKey && !event.altKey;
45+
const isWordDelete = event.ctrlKey || event.altKey;
46+
47+
if (isBackspace) {
48+
if ($cursor.parentOffset === 0) return false;
49+
const nodeBefore = $cursor.nodeBefore;
50+
if (!nodeBefore) return false;
51+
52+
let from:number;
53+
if (isLineDelete) {
54+
from = $cursor.pos - $cursor.parentOffset;
55+
} else if (isWordDelete && nodeBefore.isText) {
56+
const text = nodeBefore.text!;
57+
let i = text.length;
58+
while (i > 0 && /\s/.test(text[i - 1])) i -= 1;
59+
while (i > 0 && !/\s/.test(text[i - 1])) i -= 1;
60+
from = $cursor.pos - (text.length - i);
61+
} else {
62+
from = $cursor.pos - backwardCharSize(nodeBefore);
63+
}
64+
65+
view.dispatch(view.state.tr.delete(from, $cursor.pos));
66+
return true;
67+
}
68+
69+
const nodeAfter = $cursor.nodeAfter;
70+
if (!nodeAfter) return false;
71+
72+
let to:number;
73+
if (isLineDelete) {
74+
to = $cursor.end();
75+
} else if (isWordDelete && nodeAfter.isText) {
76+
const text = nodeAfter.text!;
77+
let i = 0;
78+
while (i < text.length && !/\s/.test(text[i])) i += 1;
79+
while (i < text.length && /\s/.test(text[i])) i += 1;
80+
to = $cursor.pos + i;
81+
} else {
82+
to = $cursor.pos + forwardCharSize(nodeAfter);
83+
}
84+
85+
view.dispatch(view.state.tr.delete($cursor.pos, to));
86+
return true;
87+
},
88+
},
89+
});
90+
91+
export const KeyboardDeleteExtension = createExtension({
92+
key: 'opKeyboardDelete',
93+
prosemirrorPlugins: [keyboardDeletePlugin],
94+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { createExtension } from '@blocknote/core';
2+
import { KeyboardDeleteExtension } from './keyboardDeleteExtension';
3+
import { PasteDeduplicateExtension } from './pasteDeduplicateExtension';
4+
5+
/**
6+
* Required extensions for op-blocknote-extensions.
7+
*
8+
* Must be added to `editorOptions.extensions: [...]` at editor construction
9+
* time, not registered post-mount via `editor.registerPlugin(...)`.
10+
*/
11+
export const OpBlockNoteExtensions = createExtension({
12+
key: 'opBlockNoteExtensions',
13+
blockNoteExtensions: [PasteDeduplicateExtension, KeyboardDeleteExtension],
14+
});

lib/plugins/pasteDeduplicateExtension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { pasteDeduplicatePlugin } from './pasteDeduplicatePlugin';
1818
* Adding the plugin to the editor's initial extension list avoids the
1919
* `reconfigure` pass entirely.
2020
*/
21-
export const PasteDeduplicateInstanceIdsExtension = createExtension({
21+
export const PasteDeduplicateExtension = createExtension({
2222
key: 'pasteDeduplicateInstanceIds',
2323
prosemirrorPlugins: [pasteDeduplicatePlugin],
2424
});

src/App.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
workPackageSlashMenu,
1717
useHashWpMenu,
1818
useOpBlockNoteExtensions,
19-
PasteDeduplicateInstanceIdsExtension,
19+
OpBlockNoteExtensions,
2020
} from '../lib';
2121
import './fetchOverride';
2222

@@ -45,7 +45,7 @@ function buildSlashMenuItems(editor:EditorType) {
4545
}
4646

4747
export default function App() {
48-
const editor = useCreateBlockNote({ schema, extensions: [PasteDeduplicateInstanceIdsExtension] });
48+
const editor = useCreateBlockNote({ schema, extensions: [OpBlockNoteExtensions] });
4949

5050
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
5151
useOpBlockNoteExtensions(editor as any);

test/helpers/renderEditor.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
workPackageSlashMenu,
1111
useHashWpMenu,
1212
useOpBlockNoteExtensions,
13-
PasteDeduplicateInstanceIdsExtension,
13+
OpBlockNoteExtensions,
1414
} from '../../lib';
1515

1616
import '@blocknote/core/fonts/inter.css';
@@ -26,7 +26,7 @@ const schema = BlockNoteSchema.create().extend({
2626
});
2727

2828
function Editor({ onEditor }:{ onEditor?:(editor:any) => void }) {
29-
const editor = useCreateBlockNote({ schema, extensions: [PasteDeduplicateInstanceIdsExtension] });
29+
const editor = useCreateBlockNote({ schema, extensions: [OpBlockNoteExtensions] });
3030
onEditor?.(editor);
3131
useOpBlockNoteExtensions(editor as any);
3232

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { page, userEvent } from 'vitest/browser';
3+
import { renderEditor } from '../../../helpers/renderEditor';
4+
import {
5+
insertInlineWorkPackageViaHash,
6+
insertInlineWorkPackageViaSlashMenu,
7+
convertToCompactCard,
8+
} from '../../../helpers/editorHelpers';
9+
10+
describe('Backspace - inline WP', () => {
11+
it('deletes text typed after inline chip, character by character', async () => {
12+
renderEditor();
13+
await insertInlineWorkPackageViaHash('#');
14+
15+
const editor = page.getByRole('textbox');
16+
await userEvent.type(editor, 'abc');
17+
await expect.element(editor).toHaveTextContent('abc');
18+
19+
await userEvent.keyboard('{Backspace}');
20+
await expect.element(editor).not.toHaveTextContent('abc');
21+
await expect.element(editor).toHaveTextContent('ab');
22+
23+
await userEvent.keyboard('{Backspace}');
24+
await expect.element(editor).not.toHaveTextContent('ab');
25+
await expect.element(editor).toHaveTextContent('a');
26+
27+
await userEvent.keyboard('{Backspace}');
28+
await expect.element(editor).not.toHaveTextContent('a');
29+
30+
await expect.element(page.getByText('#123')).toBeVisible();
31+
});
32+
33+
it('deletes the inline WP itself with Backspace', async () => {
34+
renderEditor();
35+
await insertInlineWorkPackageViaHash('#');
36+
37+
// Cursor is right after the WP
38+
await userEvent.keyboard('{Backspace}');
39+
40+
await expect.element(page.getByText('#123')).not.toBeInTheDocument();
41+
});
42+
43+
it('deletes typed text and then the WP with successive Backspace presses', async () => {
44+
renderEditor();
45+
await insertInlineWorkPackageViaHash('#');
46+
47+
const editor = page.getByRole('textbox');
48+
await userEvent.type(editor, 'hi');
49+
await expect.element(editor).toHaveTextContent('hi');
50+
51+
await userEvent.keyboard('{Backspace}');
52+
await expect.element(editor).toHaveTextContent('h');
53+
await expect.element(editor).not.toHaveTextContent('hi');
54+
55+
await userEvent.keyboard('{Backspace}');
56+
await expect.element(editor).not.toHaveTextContent('h');
57+
58+
// Cursor is now right after the WP - Backspace removes the WP
59+
await userEvent.keyboard('{Backspace}');
60+
await expect.element(page.getByText('#123')).not.toBeInTheDocument();
61+
});
62+
});
63+
64+
describe('Backspace - block WP', () => {
65+
it('deletes text typed after a block WP, character by character', async () => {
66+
renderEditor();
67+
await insertInlineWorkPackageViaSlashMenu();
68+
await convertToCompactCard();
69+
await expect.element(page.getByTestId('block-card')).toBeVisible();
70+
71+
// After conversion the cursor lands in the empty paragraph following the block card
72+
const editor = page.getByRole('textbox');
73+
await userEvent.type(editor, 'abc');
74+
await expect.element(editor).toHaveTextContent('abc');
75+
76+
await userEvent.keyboard('{Backspace}');
77+
await expect.element(editor).not.toHaveTextContent('abc');
78+
await expect.element(editor).toHaveTextContent('ab');
79+
80+
await userEvent.keyboard('{Backspace}');
81+
await expect.element(editor).not.toHaveTextContent('ab');
82+
await expect.element(editor).toHaveTextContent('a');
83+
84+
await userEvent.keyboard('{Backspace}');
85+
await expect.element(editor).not.toHaveTextContent('a');
86+
87+
await expect.element(page.getByTestId('block-card')).toBeVisible();
88+
});
89+
});
90+
91+
describe('Delete - inline WP', () => {
92+
it('deletes text in front of the cursor, character by character', async () => {
93+
renderEditor();
94+
await insertInlineWorkPackageViaHash('#');
95+
96+
const editor = page.getByRole('textbox');
97+
await userEvent.type(editor, 'abc');
98+
await expect.element(editor).toHaveTextContent('abc');
99+
100+
// Move cursor before 'a' (right after the chip)
101+
await userEvent.keyboard('{ArrowLeft}{ArrowLeft}{ArrowLeft}');
102+
103+
await userEvent.keyboard('{Delete}');
104+
await expect.element(editor).not.toHaveTextContent('abc');
105+
await expect.element(editor).toHaveTextContent('bc');
106+
107+
await userEvent.keyboard('{Delete}');
108+
await expect.element(editor).not.toHaveTextContent('bc');
109+
await expect.element(editor).toHaveTextContent('c');
110+
111+
await userEvent.keyboard('{Delete}');
112+
await expect.element(editor).not.toHaveTextContent('c');
113+
114+
await expect.element(page.getByText('#123')).toBeVisible();
115+
});
116+
117+
it('deletes the inline WP itself with Delete', async () => {
118+
renderEditor();
119+
await insertInlineWorkPackageViaHash('#');
120+
121+
// Move cursor before the chip
122+
await userEvent.keyboard('{ArrowLeft}');
123+
124+
await userEvent.keyboard('{Delete}');
125+
126+
await expect.element(page.getByText('#123')).not.toBeInTheDocument();
127+
});
128+
129+
});
130+
131+
describe('Delete - block WP', () => {
132+
it('deletes text in front of the cursor, character by character', async () => {
133+
renderEditor();
134+
await insertInlineWorkPackageViaSlashMenu();
135+
await convertToCompactCard();
136+
await expect.element(page.getByTestId('block-card')).toBeVisible();
137+
138+
// After conversion the cursor lands in the empty paragraph following the block card
139+
const editor = page.getByRole('textbox');
140+
await userEvent.type(editor, 'abc');
141+
await expect.element(editor).toHaveTextContent('abc');
142+
143+
// Move cursor before 'a'
144+
await userEvent.keyboard('{ArrowLeft}{ArrowLeft}{ArrowLeft}');
145+
146+
await userEvent.keyboard('{Delete}');
147+
await expect.element(editor).not.toHaveTextContent('abc');
148+
await expect.element(editor).toHaveTextContent('bc');
149+
150+
await userEvent.keyboard('{Delete}');
151+
await expect.element(editor).not.toHaveTextContent('bc');
152+
await expect.element(editor).toHaveTextContent('c');
153+
154+
await userEvent.keyboard('{Delete}');
155+
await expect.element(editor).not.toHaveTextContent('c');
156+
157+
await expect.element(page.getByTestId('block-card')).toBeVisible();
158+
});
159+
});

0 commit comments

Comments
 (0)