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
21 changes: 18 additions & 3 deletions packages/core/src/extensions/LinkToolbar/LinkToolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,23 +79,38 @@ export const LinkToolbarExtension = createExtension(({ editor }) => {
) {
editor.transact((tr) => {
const pmSchema = getPmSchema(tr);
const { range } = getMarkAtPos(position + 1, "link") || {

const linkInfo = getMarkAtPos(position + 1, "link") || {
range: {
from: tr.selection.from,
to: tr.selection.to,
},
};

const { range } = linkInfo;

if (!range) {
return;
}
const existingMarks =
tr.doc.nodeAt(Math.max(range.from, range.to - 1))?.marks ?? [];

tr.insertText(text, range.from, range.to);

for (const existingMark of existingMarks) {
if (existingMark.type.name !== "link") {
tr.addMark(range.from, range.from + text.length, existingMark);
}
}

tr.addMark(
range.from,
range.from + text.length,
pmSchema.mark("link", { href: url }),
);
});
editor.prosemirrorView.focus();
});

editor.prosemirrorView.focus();
},
deleteLink(position = editor.transact((tr) => tr.selection.anchor)) {
editor.transact((tr) => {
Expand Down
50 changes: 50 additions & 0 deletions tests/src/end-to-end/linktoolbar/linktoolbar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { expect } from "@playwright/test";
import { test } from "../../setup/setupScript.js";
import { BASE_URL, LINK_BUTTON_SELECTOR } from "../../utils/const.js";
import { focusOnEditor } from "../../utils/editor.js";

test.beforeEach(async ({ page }) => {
await page.goto(BASE_URL);
});

test.describe("Check Link Toolbar functionality", () => {
test("Should preserve existing marks when editing a link", async ({
page,
}) => {
await focusOnEditor(page);

// Type bold text
await page.keyboard.type("hello");
await page.keyboard.press("Shift+Home");

// Make it bold via formatting toolbar
await page.waitForSelector(`[data-test="bold"]`);
await page.click(`[data-test="bold"]`);

// Add link
await page.keyboard.press("Shift+Home");
await page.waitForSelector(LINK_BUTTON_SELECTOR);
await page.click(LINK_BUTTON_SELECTOR);
await page.keyboard.type("https://example.com");
await page.keyboard.press("Enter");

// Move cursor back onto the linked text to trigger link toolbar
await page.keyboard.press("ArrowLeft");
await page.waitForTimeout(500);

// Click Edit link button
const editButton = page.getByText("Edit link");
await editButton.waitFor({ state: "visible" });
await editButton.click();

await page.keyboard.press("Control+A");
await page.keyboard.type("https://example2.com");
await page.keyboard.press("Enter");

await page.waitForTimeout(300);

// Verify bold mark is still present on the text
const boldText = page.locator("strong a, a strong");
await expect(boldText).toBeVisible();
Comment on lines +47 to +48
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For Playwright tests, what are robust selector patterns to assert nested formatted text relationships (e.g., bold + link on the same node) without broad false-positive CSS matches?

💡 Result:

Robust selector patterns in Playwright for asserting nested formatted text relationships (e.g., bold + link on the same node) prioritize precise, structure-aware locators over broad CSS to avoid false positives. Key patterns from official docs and best practices: 1. Use CSS :has pseudo-class for parent-child relationships: e.g., page.locator('p:has(strong):has(a)') selects paragraphs containing both bold () and link () children. Combine with :has-text for content: 'p:has-text("Important"):has(strong a)'. 2. Chain locators with filter: Start with a container, filter by nested elements. e.g.: const container = page.getByRole('paragraph'); const formatted = container.filter({ has: page.locator('strong:has(a)') }); await expect(formatted).toBeVisible; 3. Locator chaining with >> : e.g., 'article >> strong >> a' for bold containing link, or '*article >> strong >> a' to keep the article. Use :text or :text-is for precise text matching within: 'article :text("Click"):has(a)'. 4. Assertions for formatting: Use expect(locator).toHaveCSS('font-weight', 'bold') on links or text nodes to verify styles without class dependencies. For structure, expect(locator).toContainText or .toHaveText with regex, combined with .locator('strong').toBeVisible. 5. Best practices to avoid false positives: - Prefer role/text locators over pure CSS: page.getByRole('link', { name: /regex/ }).locator('..').locator('strong'). - Add data-testid to formatted containers: '[data-testid="bold-link"]'. - Use :visible and nth-match for specificity: 'strong:visible:nth-match(1):has(a)'. - Avoid deep CSS chains or brittle classes; test user-visible structure. These ensure assertions pass only when exact nested formatting exists, resilient to style/CSS changes.

Citations:


🏁 Script executed:

wc -l tests/src/end-to-end/linktoolbar/linktoolbar.test.ts

Repository: TypeCellOS/BlockNote

Length of output: 119


🏁 Script executed:

sed -n '1,70p' tests/src/end-to-end/linktoolbar/linktoolbar.test.ts

Repository: TypeCellOS/BlockNote

Length of output: 1691


🏁 Script executed:

rg ':has\(' tests/ -A 2 -B 2 | head -50

Repository: TypeCellOS/BlockNote

Length of output: 46


🏁 Script executed:

rg 'hasText|:has-text' tests/ -A 2 -B 2 | head -50

Repository: TypeCellOS/BlockNote

Length of output: 431


🏁 Script executed:

rg 'page\.locator\(' tests/src/end-to-end/ -A 2 | head -80

Repository: TypeCellOS/BlockNote

Length of output: 5820


🏁 Script executed:

rg 'page\.locator\([^,]+,\s*{' tests/src/end-to-end/ -A 3

Repository: TypeCellOS/BlockNote

Length of output: 177


🏁 Script executed:

rg 'page\.locator.*hasText' tests/src/end-to-end/ -A 2

Repository: TypeCellOS/BlockNote

Length of output: 46


🏁 Script executed:

rg 'locator\([^)]+\{' tests/src/end-to-end/basics/ -A 2 | head -30

Repository: TypeCellOS/BlockNote

Length of output: 304


Tighten the assertion to verify both the updated URL and preserved formatting.

The selector strong a, a strong is too broad and could match unrelated bold links on the page. It should verify that the edited link has the new URL (https://example2.com) and the text remains bold+linked.

✅ More specific assertion
-    const boldText = page.locator("strong a, a strong");
-    await expect(boldText).toBeVisible();
+    const updatedLink = page.locator('a[href="https://example2.com"]', {
+      hasText: "hello",
+    });
+    await expect(updatedLink).toBeVisible();
+
+    const boldLinkedText = page.locator("strong").filter({
+      has: page.locator('a[href="https://example2.com"]'),
+    }).filter({ hasText: "hello" });
+    await expect(boldLinkedText).toBeVisible();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const boldText = page.locator("strong a, a strong");
await expect(boldText).toBeVisible();
const updatedLink = page.locator('a[href="https://example2.com"]', {
hasText: "hello",
});
await expect(updatedLink).toBeVisible();
const boldLinkedText = page.locator("strong").filter({
has: page.locator('a[href="https://example2.com"]'),
}).filter({ hasText: "hello" });
await expect(boldLinkedText).toBeVisible();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/src/end-to-end/linktoolbar/linktoolbar.test.ts` around lines 47 - 48,
The current assertion using the broad selector assigned to boldText
(page.locator("strong a, a strong")) may match unrelated items; tighten it to
specifically target the edited link and assert both its href equals
"https://example2.com" and that it remains bold and linked. Update the locator
used for boldText to target the exact link (for example by link text or a more
specific ancestor/attribute) and add two expectations: one that the locator's
href/attribute equals "https://example2.com" and another that verifies the
element is wrapped in/has the strong styling (preserving bold formatting) using
the same locator and expect calls.

});
Comment on lines +11 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Regression test does not cover the reported comment-mark bug.

Line 11 onward validates bold preservation, but issue #2573 is specifically about preserving the comment mark (highlight + association/accessibility) when adding/editing a link. This can pass while the original bug still regresses.

Please add a scenario that creates commented text first, then adds/edits a link and verifies the comment highlight/association remains.

🧰 Tools
🪛 ESLint

[error] 36-36: Avoid destructuring queries from render result, use screen.getByText instead

(testing-library/prefer-screen-queries)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/src/end-to-end/linktoolbar/linktoolbar.test.ts` around lines 11 - 49,
Add a focused regression test that mirrors "Should preserve existing marks when
editing a link" but creates a comment mark first: use focusOnEditor to focus,
type/select text, invoke the comment tooling (the comment button/flow used in
your app) to add a comment annotation, then add/edit a link via
LINK_BUTTON_SELECTOR and the existing editButton flow; finally assert the
comment highlight/association still exists (use the comment-specific locator you
have for comment highlights/associations, analogous to how boldText uses "strong
a, a strong") to ensure the comment mark is preserved after link insert/edit.

});