From 6bfc597fb92533c3f4c63845eb84213152e13ead Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 14 Sep 2026 15:43:24 +0200 Subject: [PATCH 1/5] fix(links): open links on click and link bubble on hover * Open links straight away on click * On desktop open link bubble on hover * Add edit button to open link bubble after each link Fixes: nextcloud/collectives#1227 Fixes: nextcloud/collectives#2713 --- Also fixes a11y issue with keyboard navigation: with focussed link, the Enter key now opens the link. The link bubble can be opened by focussing the edit button behind the link and pressing Enter/Space. Fixes: #5528 Assisted-by: OpenCode:claude-fable-5 Signed-off-by: Jonas --- cypress/e2e/Links.spec.js | 113 --------------------- playwright/e2e/links.spec.ts | 113 +++++++++++++++++++++ src/components/Link/LinkBubbleView.vue | 40 ++++++-- src/css/prosemirror.scss | 23 +++++ src/marks/Link.ts | 5 +- src/plugins/LinkBubblePluginView.js | 2 + src/plugins/linkHelpers.js | 17 +++- src/plugins/linkPill.ts | 131 +++++++++++++++++++++++++ src/plugins/links.ts | 113 ++++++++++++++------- src/tests/plugins/linkClicking.spec.ts | 58 +++++++++++ src/tests/plugins/linkPill.spec.ts | 51 ++++++++++ 11 files changed, 505 insertions(+), 161 deletions(-) create mode 100644 playwright/e2e/links.spec.ts create mode 100644 src/plugins/linkPill.ts create mode 100644 src/tests/plugins/linkClicking.spec.ts create mode 100644 src/tests/plugins/linkPill.spec.ts diff --git a/cypress/e2e/Links.spec.js b/cypress/e2e/Links.spec.js index ab846da867e..b2bda43fcd1 100644 --- a/cypress/e2e/Links.spec.js +++ b/cypress/e2e/Links.spec.js @@ -24,119 +24,6 @@ describe('test link marks', function() { cy.openFile(fileName, { force: true }) }) - describe('link bubble', function() { - /** - * Find link and click on it - * - * @param {string} link The link URL - * @param {object|null} options the click options - */ - const clickLink = (link, options = {}) => { - cy.getContent().find(`a[href*="${link}"]`).click(options) - } - - it('shows a link preview in the bubble after clicking link', () => { - const link = 'https://example.org/' - cy.insertLine(link) - clickLink(link) - - cy.get('.link-view-bubble .widget-default', { timeout: 10000 }) - .find('.widget-default--name') - .contains('Example Domain') - .click() - }) - - it('shows a link preview in the bubble after browsing to link', () => { - const link = 'https://example.org/' - cy.insertLine(link) - cy.getContent().find(`a[href*="${link}"]`) - - cy.getContent().type('{upArrow}') - - cy.get('.link-view-bubble .widget-default', { timeout: 10000 }) - .find('.widget-default--name') - .contains('Example Domain') - }) - - it('open button opens a new tab', () => { - const link = 'https://example.org/' - cy.insertLine(link) - clickLink(link) - - cy.get('.link-view-bubble button[title="Open link"]').click() - - cy.get('@winOpen').should('have.been.calledOnce') - }) - - it('closes the link bubble when clicking elsewhere', () => { - const link = 'https://example.org/' - cy.insertLine(link) - clickLink(link) - - cy.get('.link-view-bubble .widget-default', { timeout: 10000 }) - .find('.widget-default--name') - .contains('Example Domain') - - cy.get('[role="dialog"] h2.modal-header__name') - .contains(fileName) - .click() - - cy.get('.link-view-bubble .widget-default').should('not.exist') - }) - - it('allows to edit a link in the bubble', () => { - cy.insertLine('https://example.com') - clickLink('https://example.com') - - cy.get('.link-view-bubble button[title="Edit link"]').click() - - cy.get('.link-view-bubble input').type('{selectAll}https://example.org') - - cy.get('.link-view-bubble button[title="Save changes"]').click() - - cy.getContent().find('a[href*="https://example.org"]') - }) - - it('allows to remove a link in the bubble', () => { - const link = 'https://example.org' - cy.insertLine(link) - clickLink(link) - - cy.get('.link-view-bubble .link-options').click() - cy.get('button').contains('Remove').click() - - cy.getContent().find(`a[href*="${link}"]`).should('not.exist') - }) - - it('Ctrl-click on a link opens a new tab', () => { - const link = 'https://example.org/' - cy.insertLine(link) - - clickLink(link, { ctrlKey: true }) - - cy.get('@winOpen') - .should('have.been.calledOnce') - .should('have.been.calledWith', link) - }) - - it('Handles typed in markdown links with text', () => { - const link = 'https://example.org/' - cy.insertLine(`[text](${link})`) - clickLink(link) - cy.get('.link-view-bubble .widget-default', { timeout: 10000 }) - .find('.widget-default--name') - .contains('Example Domain') - cy.get('.link-view-bubble a').should('have.attr', 'href', link) - }) - - it('Leaves out link to other protocols', () => { - const link = 'other://protocol' - cy.insertLine(`[text](${link})`) - cy.getContent().find(`a[href*="${link}"]`).should('not.exist') - cy.getContent().find('a[href="#]').should('not.exist') - }) - }) - describe('autolink', function() { it('with protocol to files app and fileId', () => { cy.getFile(fileName).then(($el) => { diff --git a/playwright/e2e/links.spec.ts b/playwright/e2e/links.spec.ts new file mode 100644 index 00000000000..d474f9b9643 --- /dev/null +++ b/playwright/e2e/links.spec.ts @@ -0,0 +1,113 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, mergeTests } from '@playwright/test' +import { test as editorTest } from '../support/fixtures/editor.ts' +import { test as uploadFileTest } from '../support/fixtures/upload-file.ts' + +const test = mergeTests(editorTest, uploadFileTest) + +const href = 'https://example.org/' + +test.describe('links', () => { + test.use({ fileContent: `[Example](${href})\n\nsecond paragraph\n` }) + + test.beforeEach(async ({ open }) => { + await open() + }) + + test('click opens the link', async ({ editor, page }) => { + const popupPromise = page.waitForEvent('popup') + await editor.content.getByRole('link', { name: 'Example' }).click() + const popup = await popupPromise + expect(popup.url()).toBe(href) + await popup.close() + }) + + test('ctrl-click opens the link', async ({ editor, page }) => { + const popupPromise = page.waitForEvent('popup') + await editor.content.getByRole('link', { name: 'Example' }) + .click({ modifiers: ['Control'] }) + const popup = await popupPromise + expect(popup.url()).toBe(href) + await popup.close() + }) + + test('hover opens the link bubble', async ({ editor, page }) => { + await editor.content.getByRole('link', { name: 'Example' }).hover() + const bubble = page.locator('.link-view-bubble') + await expect(bubble).toBeVisible() + await expect(bubble.locator('.link-view-bubble__title')).toContainText(/example/i) + }) + + test('moving the cursor into the link opens the link bubble', async ({ editor, page }) => { + await editor.content.getByText('second paragraph').click() + await editor.press('Home') + await editor.press('ArrowUp') + await editor.press('ArrowRight') + await expect(page.locator('.link-view-bubble')).toBeVisible() + }) + + test('pill button opens the link bubble without opening the link', async ({ editor, page }) => { + let popups = 0 + page.on('popup', () => popups++) + await editor.content.locator('.link-pill').click() + await expect(page.locator('.link-view-bubble')).toBeVisible() + expect(popups).toBe(0) + }) + + test('open button in the link bubble opens the link', async ({ editor, page }) => { + await editor.content.locator('.link-pill').click() + const bubble = page.locator('.link-view-bubble') + const popupPromise = page.waitForEvent('popup') + await bubble.getByRole('button', { name: 'Open link' }).click() + const popup = await popupPromise + expect(popup.url()).toBe(href) + await popup.close() + }) + + test('edits the hovered link while the cursor is elsewhere', async ({ editor, page }) => { + await editor.content.getByText('second paragraph').click() + await editor.content.getByRole('link', { name: 'Example' }).hover() + const bubble = page.locator('.link-view-bubble') + await bubble.getByRole('button', { name: 'Edit link' }).click() + await bubble.getByLabel('URL').fill('https://example.com/') + await bubble.getByLabel('URL').press('Enter') + await expect(editor.content.getByRole('link', { name: 'Example' })) + .toHaveAttribute('href', 'https://example.com/') + await expect(editor.content.getByRole('link')).toHaveCount(1) + }) + + test('removes the link from the bubble', async ({ editor, page }) => { + await editor.content.locator('.link-pill').click() + await page.locator('.link-view-bubble .link-options button').click() + await page.getByRole('menuitem', { name: 'Remove link' }).click() + await expect(editor.content.getByRole('link')).toHaveCount(0) + await expect(editor.content).toContainText('Example') + }) + + test('link typed in markdown syntax gets the link bubble', async ({ editor, page }) => { + await editor.content.getByText('second paragraph').click() + await editor.press('End') + await editor.press('Enter') + await editor.type('[typed](https://example.com/)') + const link = editor.content.getByRole('link', { name: 'typed' }) + await expect(link).toHaveAttribute('href', 'https://example.com/') + await link.hover() + const bubble = page.locator('.link-view-bubble') + await expect(bubble).toBeVisible() + await expect(bubble.locator('.link-view-bubble__title')).toContainText(/example\.com/) + }) +}) + +test.describe('links with unsafe protocols', () => { + test.use({ fileContent: '[text](other://protocol)\n' }) + + test('are rendered without href', async ({ editor, open }) => { + await open() + await expect(editor.content.getByText('text')).toBeVisible() + await expect(editor.content.locator('a[href*="other://"]')).toHaveCount(0) + }) +}) diff --git a/src/components/Link/LinkBubbleView.vue b/src/components/Link/LinkBubbleView.vue index 1da675c7799..7fc171fc4cc 100644 --- a/src/components/Link/LinkBubbleView.vue +++ b/src/components/Link/LinkBubbleView.vue @@ -90,6 +90,7 @@