Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,23 @@ 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.

```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
Expand Down
5 changes: 2 additions & 3 deletions lib/hooks/useOpBlockNoteExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
2 changes: 1 addition & 1 deletion lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
ihordubas99 marked this conversation as resolved.
94 changes: 94 additions & 0 deletions lib/plugins/keyboardDeleteExtension.ts
Original file line number Diff line number Diff line change
@@ -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<SegmenterEntry>};
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;
Comment thread
ihordubas99 marked this conversation as resolved.

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],
});
14 changes: 14 additions & 0 deletions lib/plugins/opBlockNoteExtensions.ts
Original file line number Diff line number Diff line change
@@ -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],
});
2 changes: 1 addition & 1 deletion lib/plugins/pasteDeduplicateExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
});
Comment thread
ihordubas99 marked this conversation as resolved.
4 changes: 2 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
workPackageSlashMenu,
useHashWpMenu,
useOpBlockNoteExtensions,
PasteDeduplicateInstanceIdsExtension,
OpBlockNoteExtensions,
} from '../lib';
import './fetchOverride';

Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions test/helpers/renderEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
workPackageSlashMenu,
useHashWpMenu,
useOpBlockNoteExtensions,
PasteDeduplicateInstanceIdsExtension,
OpBlockNoteExtensions,
} from '../../lib';

import '@blocknote/core/fonts/inter.css';
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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');

Comment thread
ihordubas99 marked this conversation as resolved.
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');

Comment thread
ihordubas99 marked this conversation as resolved.
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();
});
});
Loading