From 1326d6b4d317907d25d5589cc97a9092a306b093 Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Tue, 28 Apr 2026 14:10:30 +0300 Subject: [PATCH 1/4] fix: regenerate instanceId on paste to prevent resize targeting wrong chip --- lib/hooks/useDeduplicateInstanceIds.ts | 22 +++++++++++ lib/index.ts | 3 +- lib/plugins/pasteDeduplicatePlugin.ts | 54 ++++++++++++++++++++++++++ src/App.tsx | 6 ++- 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 lib/hooks/useDeduplicateInstanceIds.ts create mode 100644 lib/plugins/pasteDeduplicatePlugin.ts diff --git a/lib/hooks/useDeduplicateInstanceIds.ts b/lib/hooks/useDeduplicateInstanceIds.ts new file mode 100644 index 0000000..91e1eab --- /dev/null +++ b/lib/hooks/useDeduplicateInstanceIds.ts @@ -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 +): 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]); +} \ No newline at end of file diff --git a/lib/index.ts b/lib/index.ts index 390daab..7b9fd9a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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'; \ No newline at end of file +export { useInlineWpEvents } from './hooks/useInlineWpEvents'; +export { useDeduplicateInstanceIds } from "./hooks/useDeduplicateInstanceIds"; \ No newline at end of file diff --git a/lib/plugins/pasteDeduplicatePlugin.ts b/lib/plugins/pasteDeduplicatePlugin.ts new file mode 100644 index 0000000..3774b5f --- /dev/null +++ b/lib/plugins/pasteDeduplicatePlugin.ts @@ -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); +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 4405d94..07c60ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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: { @@ -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), From 8adcd2839999113abdd97d2beb70572269a1f009 Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Tue, 28 Apr 2026 14:16:19 +0300 Subject: [PATCH 2/4] style: normalize quote style to double quotes --- lib/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/index.ts b/lib/index.ts index 7b9fd9a..d0c5762 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -14,5 +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"; \ No newline at end of file From ba4a05999854d26470adf3a3b8092c54292525af Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Tue, 28 Apr 2026 14:39:15 +0300 Subject: [PATCH 3/4] fix: add prosemirror deps to optimizeDeps and set rootDir in tsconfig.lib --- tsconfig.lib.json | 3 ++- vitest.browser.config.ts | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tsconfig.lib.json b/tsconfig.lib.json index 8a3ab34..3150cc7 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.app.json", "compilerOptions": { + "rootDir": "./lib", "declaration": true, "declarationDir": "./dist", "emitDeclarationOnly": true, @@ -8,4 +9,4 @@ "types": ["vite/client"] }, "include": ["lib"] -} +} \ No newline at end of file diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index f305994..efbc641 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -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: { From 4be5889f4100802997cba882ce36688090c20c53 Mon Sep 17 00:00:00 2001 From: ihordubas99 Date: Tue, 28 Apr 2026 14:47:58 +0300 Subject: [PATCH 4/4] test: add paste deduplication regression test Co-authored-by: Copilot --- .../editor.inlineChip.browser.test.tsx | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/lib/components/integration/editor.inlineChip.browser.test.tsx b/test/lib/components/integration/editor.inlineChip.browser.test.tsx index fbab81f..1677983 100644 --- a/test/lib/components/integration/editor.inlineChip.browser.test.tsx +++ b/test/lib/components/integration/editor.inlineChip.browser.test.tsx @@ -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); + }); }); \ No newline at end of file