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
22 changes: 22 additions & 0 deletions lib/hooks/useDeduplicateInstanceIds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useEffect } from "react";
import type { BlockNoteEditor } from "@blocknote/core";
import {
pasteDeduplicatePlugin,
pasteDeduplicatePluginKey,
} from "../plugins/pasteDeduplicatePlugin";

export function useDeduplicateInstanceIds(
editor: BlockNoteEditor<any, any, any>
): void {
useEffect(() => {
// accessing private tiptap instance until public API is available
const tiptap = (editor as any)._tiptapEditor;
if (!tiptap) return;

tiptap.registerPlugin(pasteDeduplicatePlugin);

return () => {
tiptap.unregisterPlugin(pasteDeduplicatePluginKey);
};
}, [editor]);
}
3 changes: 2 additions & 1 deletion lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export { createHashWpMenuComponent, isHashWpQuery, useHashWpMenu } from "./compo
export type { HashMenuItem } from "./components/HashMenu";
export { useWorkPackageSearch } from "./hooks/useWorkPackageSearch";
export type { WorkPackage } from "./openProjectTypes";
export { useInlineWpEvents } from './hooks/useInlineWpEvents';
export { useInlineWpEvents } from "./hooks/useInlineWpEvents";
export { useDeduplicateInstanceIds } from "./hooks/useDeduplicateInstanceIds";
54 changes: 54 additions & 0 deletions lib/plugins/pasteDeduplicatePlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Plugin, PluginKey } from "prosemirror-state";
import { Fragment, Slice } from "prosemirror-model";
import type { Node } from "prosemirror-model";
import { makeInstanceId } from "../services/utils";

/**
* Regenerates instanceId for every inline WP chip in pasted content.
*
* BlockNote duplicates all props verbatim when copying an inline node,
* including instanceId. Duplicate IDs cause wpBridge resize/delete actions
* to always affect the first chip instead of the intended one.
*/
export const pasteDeduplicatePluginKey = new PluginKey(
"pasteDeduplicateInstanceIds"
);

export const pasteDeduplicatePlugin = new Plugin({
key: pasteDeduplicatePluginKey,

props: {
transformPasted(slice) {
return new Slice(
transformFragment(slice.content),
slice.openStart,
slice.openEnd
);
},
},
});

function transformFragment(fragment: Fragment): Fragment {
const nodes: Node[] = [];

fragment.forEach((node) => {
if (
node.type.name === "openProjectWorkPackageInline" &&
node.attrs.instanceId
) {
nodes.push(
node.type.create(
{ ...node.attrs, instanceId: makeInstanceId() },
node.content,
node.marks
)
);
} else if (node.childCount > 0) {
nodes.push(node.copy(transformFragment(node.content)));
} else {
nodes.push(node);
}
});

return Fragment.fromArray(nodes);
}
6 changes: 4 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ import {
openProjectWorkPackageInlineSpec,
workPackageSlashMenu,
useHashWpMenu,
useDeduplicateInstanceIds,
useInlineWpEvents
} from "../lib";
import "./fetchOverride";
import type { HashMenuItem } from "../lib";
import {useInlineWpEvents} from "../lib";

const schema = BlockNoteSchema.create().extend({
blockSpecs: {
Expand Down Expand Up @@ -46,7 +47,8 @@ function buildSlashMenuItems(editor: EditorType) {
export default function App() {
const editor = useCreateBlockNote({ schema });

useInlineWpEvents(editor as any);
useInlineWpEvents(editor as any);
useDeduplicateInstanceIds(editor as any);

const getSlashItems = useCallback(
async (query: string) => filterSuggestionItems(buildSlashMenuItems(editor), query),
Expand Down
27 changes: 27 additions & 0 deletions test/lib/components/integration/editor.inlineChip.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,31 @@ describe('Inline chip - remove', () => {
await expect.element(page.getByText('#123')).not.toBeInTheDocument();
await expect.element(page.getByText('In Progress')).not.toBeInTheDocument();
});
});

describe('Inline chip - paste deduplication', () => {
it('resizing a pasted chip does not affect the original', async () => {
renderEditor();
await insertInlineChipViaSlashMenu();

await userEvent.keyboard('{Control>}a{/Control}');
await userEvent.keyboard('{Control>}c{/Control}');

await userEvent.keyboard('{Control>}{End}{/Control}');
await userEvent.keyboard('{Control>}v{/Control}');

const chips = page.getByText('#123');
expect((await chips.all()).length).toBe(2);

await userEvent.click(page.getByText('#123').nth(1));
await expect.element(page.getByTestId('popover-content')).toBeVisible();
await userEvent.click(page.getByTitle('Change size'));
await expect.element(page.getByTestId('size-menu')).toBeVisible();
await userEvent.click(page.getByRole('button', { name: 'Tiny (inline)', exact: true }));

// Second chip is now XXS - status hidden for that chip
// First chip should still be S - "In Progress" still visible once
const statusBadges = page.getByText('In Progress');
expect((await statusBadges.all()).length).toBe(1);
});
});
3 changes: 2 additions & 1 deletion tsconfig.lib.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"rootDir": "./lib",
"declaration": true,
"declarationDir": "./dist",
"emitDeclarationOnly": true,
"noEmit": false,
"types": ["vite/client"]
},
"include": ["lib"]
}
}
6 changes: 6 additions & 0 deletions vitest.browser.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { playwright } from '@vitest/browser-playwright'

export default defineConfig({
plugins: [react()],
optimizeDeps: {
include: [
"prosemirror-state",
"prosemirror-model",
],
},
test: {
setupFiles: ['./test/setupTests.ts'],
browser: {
Expand Down
Loading