-
Notifications
You must be signed in to change notification settings - Fork 2
Bug/74325 inserting hash inside text removes content after cursor #122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,16 @@ | ||
| import type { FC, RefObject } from "react"; | ||
| import { useRef } from "react"; | ||
| import type { SuggestionMenuProps } from "@blocknote/react"; | ||
| import styled from "styled-components"; | ||
| import { BlockCard } from "../BlockWorkPackage/BlockCard"; | ||
| import { defaultWpVariables } from "../WorkPackage/atoms"; | ||
| import type { WorkPackage } from "../../openProjectTypes"; | ||
| import type { HashMenuItem } from "./types"; | ||
| import type { AnyEditor } from "./editorUtils"; | ||
| import type { InlineWpSize } from "../WorkPackage/types"; | ||
| import { | ||
| getSizeFromCurrentBlock, | ||
| clearTriggerText, | ||
| insertWpChip, | ||
| insertWpChipIntoBlock, | ||
| } from "./editorUtils"; | ||
|
|
||
| const Menu = styled.div.attrs({ className: "op-bn-hash-menu" })` | ||
|
|
@@ -54,27 +54,48 @@ export function createHashWpMenuComponent( | |
| const searchQuery = items[0]?.title ?? ""; | ||
| const visibleResults = (resultsRef.current ?? []).slice(0, MAX_RESULTS); | ||
|
|
||
| const pendingSizeRef = useRef<InlineWpSize>("xxs"); | ||
| const originalBlockIdRef = useRef<string | undefined>(undefined); | ||
| const savedSelectionRef = useRef<any>(null); | ||
|
|
||
| const currentSize = getSizeFromCurrentBlock(editor); | ||
| const currentBlockId = editor.getTextCursorPosition()?.block?.id; | ||
|
|
||
| // Mutate each item's onItemClick so BlockNote's keyboard handler | ||
| // (Enter / PgUp / PgDn) calls the correct insertion for that result. | ||
| visibleResults.forEach((wp, index) => { | ||
| if (!items[index]) return; | ||
|
|
||
| const size = getSizeFromCurrentBlock(editor); | ||
| const blockId = editor.getTextCursorPosition()?.block?.id; | ||
|
|
||
| items[index].onItemClick = () => { | ||
| const size = pendingSizeRef.current !== "xxs" | ||
| ? pendingSizeRef.current | ||
| : currentSize; | ||
| const originalBlockId = originalBlockIdRef.current ?? currentBlockId; | ||
| const savedSelection = savedSelectionRef.current; | ||
|
|
||
| pendingSizeRef.current = "xxs"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this be covered by |
||
| originalBlockIdRef.current = undefined; | ||
| savedSelectionRef.current = null; | ||
|
|
||
| requestAnimationFrame(() => { | ||
| if (!blockId) return; | ||
| if (!originalBlockId) return; | ||
| editor.focus(); | ||
|
|
||
| // BlockNote splits the block on Enter - remove the new empty block it created. | ||
| const currentBlock = editor.getTextCursorPosition()?.block; | ||
| if (currentBlock && currentBlock.id !== blockId) { | ||
| if (currentBlock && currentBlock.id !== originalBlockId) { | ||
| editor.removeBlocks([currentBlock.id]); | ||
| } | ||
|
|
||
| clearTriggerText(editor); | ||
| insertWpChipIntoBlock(editor, blockId, wp, size); | ||
| if (savedSelection) { | ||
| const tiptap = (editor as any)._tiptapEditor; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nick from BlockNote wrote that they are not sure if they will keep the tiptap APIs (https://matrix.to/#/!yWsguKHvTVyakhzlui:openproject.org/$namCWPK10P-jifVJIjDTaj7bYnnw19XbSC2pSjO8JkY?via=openproject.org&via=matrix.org) and that we should not rely on them. Is it possible to achieve this without tiptap? If not, why?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good news: I removed the TipTap dependency from the UI component (HashWpMenu.tsx). By keeping focus on the editor during a click, we no longer need TipTap to manage the cursor there. However, for clearTriggerText, using TipTap remains the most reliable approach at this stage. Standard browser tools (DOM) get confused by our custom WorkPackage chips: they count every letter inside the chip, while BlockNote's internal model sees the whole chip as a single item. This "offset mismatch" makes it very risky to delete the # trigger using native methods, as it often leads to deleting the wrong text or data loss. TipTap is currently the most stable way to find the exact cursor position within BlockNote's structure and ensure we only delete the trigger text |
||
| if (tiptap) { | ||
| const tr = tiptap.state.tr.setSelection(savedSelection); | ||
| tiptap.view.dispatch(tr); | ||
| } | ||
| } | ||
|
|
||
| insertWpChip(editor, wp, size); | ||
| }); | ||
| }; | ||
| }); | ||
|
|
@@ -105,13 +126,10 @@ export function createHashWpMenuComponent( | |
| // cleanup, so we clear the trigger text manually before inserting. | ||
| onMouseDown={(e) => { | ||
| e.preventDefault(); | ||
| const size = getSizeFromCurrentBlock(editor); | ||
| const blockId = clearTriggerText(editor); | ||
| if (blockId) { | ||
| editor.focus(); | ||
| editor.setTextCursorPosition(blockId, "end"); | ||
| } | ||
| insertWpChip(editor, wp, size); | ||
| pendingSizeRef.current = getSizeFromCurrentBlock(editor); | ||
| originalBlockIdRef.current = editor.getTextCursorPosition()?.block?.id; | ||
| savedSelectionRef.current = (editor as any)._tiptapEditor?.state?.selection ?? null; | ||
| items[index]?.onItemClick(); | ||
| }} | ||
| > | ||
| <BlockCard workPackage={wp} inDropdown /> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,27 +8,88 @@ import { | |
|
|
||
| type FakeContent = { type: string; text?: string; styles?: any; props?: any }[]; | ||
|
|
||
| function findCursorPos(fullText: string): number { | ||
| const match = fullText.match(/#+/); | ||
| if (!match || match.index === undefined) return fullText.length; | ||
| return match.index + match[0].length; | ||
| } | ||
|
|
||
| function makeFakeTiptap(block: { id: string; content: FakeContent }) { | ||
| const getFullText = () => | ||
| block.content | ||
| .filter((n) => n.type === "text") | ||
| .map((n) => n.text ?? "") | ||
| .join(""); | ||
|
|
||
| return { | ||
| get state() { | ||
| const fullText = getFullText(); | ||
| const cursorPos = findCursorPos(fullText); | ||
|
|
||
| return { | ||
| selection: { | ||
| from: cursorPos, | ||
| $from: { | ||
| parentOffset: cursorPos, | ||
| parent: { | ||
| textBetween(start: number, end: number) { | ||
| const text = getFullText(); | ||
| const absStart = Math.max(0, cursorPos - (end - start)); | ||
| return text.slice(absStart, cursorPos); | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| tr: { | ||
| delete(start: number, end: number) { | ||
| return { _start: start, _end: end }; | ||
| }, | ||
| }, | ||
| }; | ||
| }, | ||
| view: { | ||
| dispatch(tr: { _start: number; _end: number }) { | ||
| const fullText = getFullText(); | ||
| const hashIndex = fullText.search(/#+/); | ||
| const newText = hashIndex <= 0 ? "" : fullText.slice(0, hashIndex); | ||
|
|
||
| if (newText === "") { | ||
| block.content = []; | ||
| } else { | ||
| const firstTextNode = block.content.find((n) => n.type === "text"); | ||
| const nonTextNodes = block.content.filter((n) => n.type !== "text"); | ||
| block.content = [ | ||
| ...(firstTextNode ? [{ ...firstTextNode, text: newText }] : []), | ||
| ...nonTextNodes, | ||
| ]; | ||
| } | ||
| }, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function makeFakeEditor(content: FakeContent = []) { | ||
| let block = { id: "block-1", content }; | ||
| const block = { id: "block-1", content: [...content] }; | ||
| const inserted: FakeContent = []; | ||
|
|
||
| return { | ||
| const editor: any = { | ||
| block, | ||
| inserted, | ||
| getTextCursorPosition: () => ({ block }), | ||
| getBlock: (id: string) => (id === block.id ? block : null), | ||
| updateBlock: (id: string, update: { content: FakeContent }) => { | ||
| if (id === block.id) { | ||
| block.content = update.content; | ||
| inserted.push(...update.content); | ||
| } | ||
| if (id === block.id) block.content = update.content; | ||
| }, | ||
| insertInlineContent: (content: FakeContent) => { | ||
| inserted.push(...content); | ||
| }, | ||
| focus: vi.fn(), | ||
| setTextCursorPosition: vi.fn(), | ||
| }; | ||
|
|
||
| editor._tiptapEditor = makeFakeTiptap(block); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's try to use a real BlockNote editor for the tests. Using a fake TipTap here could make these tests useless in the future - if BlockNote decides to remove some TipTap functionality, these tests could still be green but the functionality be broken in the "real world", because the tests are using TipTap directly instead of BlockNote. |
||
|
|
||
| return editor; | ||
| } | ||
|
|
||
| describe("getSizeFromCurrentBlock", () => { | ||
|
|
@@ -65,7 +126,11 @@ describe("clearTriggerText", () => { | |
| }); | ||
|
|
||
| it("does nothing if no block", () => { | ||
| const editor = { getTextCursorPosition: () => null, updateBlock: vi.fn() }; | ||
| const editor = { | ||
| _tiptapEditor: null, | ||
| getTextCursorPosition: () => null, | ||
| updateBlock: vi.fn(), | ||
| }; | ||
| expect(clearTriggerText(editor as any)).toBeNull(); | ||
| }); | ||
|
|
||
|
|
@@ -98,8 +163,8 @@ describe("insertWpChipIntoBlock", () => { | |
| expect(inserted.length).toBe(2); | ||
|
|
||
| const chip = inserted.find( | ||
| (c): c is { type: string; props: { size: string } } => | ||
| c.type === "openProjectWorkPackageInline" && c.props?.size | ||
| (item: any): item is { type: string; props: { size: string } } => | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for no longer using abbreviations here 🤩 |
||
| item.type === "openProjectWorkPackageInline" && item.props?.size | ||
| ); | ||
| expect(chip).toBeDefined(); | ||
| expect(chip?.props.size).toBe("xxs"); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,7 +13,7 @@ describe('Inline chip - insert', () => { | |
| renderEditor(); | ||
| await insertInlineChipViaSlashMenu(); | ||
|
|
||
| await expect.element(page.getByText('#123')).toBeVisible(); | ||
| await expect.element(page.getByText('#123').first()).toBeVisible(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am wondering why this change is necessary?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I initially added .first() to avoid strict-mode errors because the menu and the new chip were present at the same time. However, after simplifying the focus logic and refactoring the component, the tests now pass reliably without it. I've reverted this change to keep the tests clean |
||
| await expect.element(page.getByTestId('op-bn-work-package--type')).toBeVisible(); | ||
| await expect.element(page.getByText('In Progress')).toBeVisible(); | ||
| await expect.element(page.getByText('Fix login bug')).toBeVisible(); | ||
|
|
@@ -23,7 +23,7 @@ describe('Inline chip - insert', () => { | |
| renderEditor(); | ||
| await insertInlineChipViaHash('#'); | ||
|
|
||
| await expect.element(page.getByText('#123')).toBeVisible(); | ||
| await expect.element(page.getByText('#123').first()).toBeVisible(); | ||
| await expect.element(page.getByTestId('op-bn-work-package--type')).not.toBeInTheDocument(); | ||
| await expect.element(page.getByText('In Progress')).not.toBeInTheDocument(); | ||
| }); | ||
|
|
@@ -32,7 +32,7 @@ describe('Inline chip - insert', () => { | |
| renderEditor(); | ||
| await insertInlineChipViaHash('##'); | ||
|
|
||
| await expect.element(page.getByText('#123')).toBeVisible(); | ||
| await expect.element(page.getByText('#123').first()).toBeVisible(); | ||
| await expect.element(page.getByTestId('op-bn-work-package--type')).toBeVisible(); | ||
| await expect.element(page.getByText('In Progress')).not.toBeInTheDocument(); | ||
| }); | ||
|
|
@@ -41,7 +41,7 @@ describe('Inline chip - insert', () => { | |
| renderEditor(); | ||
| await insertInlineChipViaHash('###'); | ||
|
|
||
| await expect.element(page.getByText('#123')).toBeVisible(); | ||
| await expect.element(page.getByText('#123').first()).toBeVisible(); | ||
| await expect.element(page.getByTestId('op-bn-work-package--type')).toBeVisible(); | ||
| await expect.element(page.getByText('In Progress')).toBeVisible(); | ||
| }); | ||
|
|
@@ -55,7 +55,7 @@ describe('Inline chip - resize', () => { | |
| await openInlineChipSizeMenu(); | ||
| await userEvent.click(page.getByRole('button', { name: 'Tiny (inline)', exact: true })); | ||
|
|
||
| await expect.element(page.getByText('#123')).toBeVisible(); | ||
| await expect.element(page.getByText('#123').first()).toBeVisible(); | ||
| await expect.element(page.getByTestId('op-bn-work-package--type')).not.toBeInTheDocument(); | ||
| await expect.element(page.getByText('In Progress')).not.toBeInTheDocument(); | ||
| await expect.element(page.getByText('Fix login bug')).not.toBeInTheDocument(); | ||
|
|
@@ -68,7 +68,7 @@ describe('Inline chip - resize', () => { | |
| await openInlineChipSizeMenu(); | ||
| await userEvent.click(page.getByRole('button', { name: 'Compact (inline)', exact: true })); | ||
|
|
||
| await expect.element(page.getByText('#123')).toBeVisible(); | ||
| await expect.element(page.getByText('#123').first()).toBeVisible(); | ||
| await expect.element(page.getByTestId('op-bn-work-package--type')).toBeVisible(); | ||
| await expect.element(page.getByText('In Progress')).not.toBeInTheDocument(); | ||
| await expect.element(page.getByText('Fix login bug')).toBeVisible(); | ||
|
|
@@ -81,7 +81,7 @@ describe('Inline chip - resize', () => { | |
| await openInlineChipSizeMenu(); | ||
| await userEvent.click(page.getByRole('button', { name: 'Tiny (inline)', exact: true })); | ||
|
|
||
| await expect.element(page.getByText('#123')).toBeVisible(); | ||
| await expect.element(page.getByText('#123').first()).toBeVisible(); | ||
| await expect.element(page.getByTestId('op-bn-work-package--type')).not.toBeInTheDocument(); | ||
| await expect.element(page.getByText('In Progress')).not.toBeInTheDocument(); | ||
| await expect.element(page.getByText('Fix login bug')).not.toBeInTheDocument(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For what reason do we need
pendingSizeRefandcurrentSizeand what's the difference?Same for
originalBlockIdRef,savedSelectionRef,currentBlockIdThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right, having both was confusing. I've completely removed the redundant useRef hooks (pendingSizeRef, originalBlockIdRef, savedSelectionRef).
The logic is now much simpler: we rely on the closure variables (currentSize, currentBlockId) from the render cycle. This makes the code cleaner and eliminates the need for manual state resets