diff --git a/cypress/e2e/Links.spec.js b/cypress/e2e/Links.spec.js
index 4c83534a7c0..f3eb89b26d0 100644
--- a/cypress/e2e/Links.spec.js
+++ b/cypress/e2e/Links.spec.js
@@ -24,123 +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')
- clickLink('#')
- cy.get('.link-view-bubble__title', { timeout: 10000 }).contains(
- 'other://protocol',
- )
- cy.get('.link-view-bubble a').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..41df7ae9e69
--- /dev/null
+++ b/playwright/e2e/links.spec.ts
@@ -0,0 +1,174 @@
+/**
+ * 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.describe('link bubble', () => {
+ test.use({ fileContent: `[Example](${href})\n\nsecond paragraph\n` })
+
+ test.beforeEach(async ({ open }) => {
+ await open()
+ })
+
+ 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('edits the link text from the bubble', async ({ editor, page }) => {
+ await editor.content.locator('.link-pill').click()
+ const bubble = page.locator('.link-view-bubble')
+ await bubble.getByRole('button', { name: 'Edit link' }).click()
+ await expect(bubble.getByLabel('Link text')).toHaveValue('Example')
+ await bubble.getByLabel('Link text').fill('Renamed')
+ await bubble.getByLabel('Link text').press('Enter')
+ await expect(
+ editor.content.getByRole('link', { name: 'Renamed' }),
+ ).toHaveAttribute('href', href)
+ await expect(editor.content.getByRole('link')).toHaveCount(1)
+ await expect(editor.content).not.toContainText('Example')
+ })
+
+ 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('mod-k turns the selection into a link and focuses the URL field', async ({
+ editor,
+ page,
+ }) => {
+ await editor.content.getByText('second paragraph').click()
+ await editor.press('End')
+ await editor.press('Shift+Home')
+ await editor.press('Control+k')
+ const bubble = page.locator('.link-view-bubble')
+ await expect(bubble).toBeVisible()
+ await expect(bubble.getByLabel('URL')).toBeFocused()
+ await bubble.getByLabel('URL').fill('https://example.com/')
+ await bubble.getByLabel('URL').press('Enter')
+ await expect(
+ editor.content.getByRole('link', { name: 'second paragraph' }),
+ ).toHaveAttribute('href', 'https://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 13755ecfbe7..e7fcb0db15a 100644
--- a/src/components/Link/LinkBubbleView.vue
+++ b/src/components/Link/LinkBubbleView.vue
@@ -67,6 +67,11 @@
+
{
this.$refs.hrefField.focus()
@@ -246,36 +302,50 @@ export default {
stopEdit() {
this.edit = false
+ this.newText = null
this.newHref = null
},
updateLink() {
- if (this.href !== this.newHref) {
- this.setLinkUrl(this.newHref)
+ const text =
+ this.newText === '' || this.newText === this.linkText()
+ ? null
+ : this.newText
+ if (text !== null || this.href !== this.newHref) {
+ this.setLinkContent(this.newHref, text)
}
this.stopEdit()
},
- setLinkUrl(href) {
+ setLinkContent(href, text) {
+ const range = this.linkRange()
// Store current selection to restore it after setLink
- const selection = { ...this.editor.view.state.selection }
- const { ranges } = selection
+ const { ranges } = this.editor.view.state.selection
const from = Math.min(...ranges.map((range) => range.$from.pos))
const to = Math.max(...ranges.map((range) => range.$to.pos))
- console.debug('selection', selection)
- this.editor
- .chain()
- .extendMarkRange('link')
- .setLink({ href })
- .setTextSelection({ from, to })
- .focus()
- .run()
+ const chain = this.chainOnLink()
+ if (text !== null && range) {
+ const end = range.from + text.length
+ chain
+ .command(({ tr }) => {
+ tr.insertText(text, range.from, range.to)
+ return true
+ })
+ .setTextSelection({ from: range.from, to: end })
+ .setLink({ href })
+ .setTextSelection(end)
+ } else {
+ chain
+ .extendMarkRange('link')
+ .setLink({ href })
+ .setTextSelection({ from, to })
+ }
+ chain.focus().run()
},
removeLink() {
- this.editor
- .chain()
+ this.chainOnLink()
// Explicitly hide bubble to prevent flickering before it's removed
.hideLinkBubble()
.unsetLink()
diff --git a/src/css/print.scss b/src/css/print.scss
index c9463ddfd85..79bbe3d2275 100644
--- a/src/css/print.scss
+++ b/src/css/print.scss
@@ -140,4 +140,8 @@
.collaboration-carets__label {
display: none;
}
+
+ .link-pill {
+ display: none !important;
+ }
}
diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss
index 67546f2c5a9..de7c259a662 100644
--- a/src/css/prosemirror.scss
+++ b/src/css/prosemirror.scss
@@ -135,6 +135,29 @@ div.ProseMirror {
padding: 0.5em 0;
}
+ .link-pill[contenteditable='false'] {
+ display: inline-flex;
+ width: var(--default-font-size);
+ height: var(--default-font-size);
+ margin-inline-start: 2px;
+ padding: 1px;
+ vertical-align: middle;
+ cursor: pointer;
+ user-select: none;
+
+ svg {
+ width: 100%;
+ height: 100%;
+ fill: var(--color-text-maxcontrast);
+ }
+
+ &:hover svg,
+ &:focus svg,
+ &:active svg {
+ fill: var(--color-primary-element);
+ }
+ }
+
p {
position: relative;
margin-bottom: 1em;
@@ -449,3 +472,9 @@ div.ProseMirror {
box-sizing: border-box;
visibility: visible !important;
}
+
+// Take the link bubble out of the flow before popper positions it,
+// otherwise it briefly narrows the editor content and scroll anchoring scrolls the pag
+.editor__content-wrapper > [data-tippy-root] {
+ position: absolute;
+}
diff --git a/src/marks/Link.ts b/src/marks/Link.ts
index bc255ae341e..80d5008914c 100644
--- a/src/marks/Link.ts
+++ b/src/marks/Link.ts
@@ -12,7 +12,8 @@ import { normalizeReference } from 'markdown-it/lib/common/utils.mjs'
import type { MarkdownSerializerState } from 'prosemirror-markdown'
import { defaultMarkdownSerializer } from 'prosemirror-markdown'
import { domHref, parseHref } from '../helpers/links.js'
-import { linkClicking } from '../plugins/links'
+import { linkPill } from '../plugins/linkPill'
+import { focusLinkBubbleInput, linkClicking } from '../plugins/links'
export const PROTOCOLS_TO_LINK_TO = ['http:', 'https:', 'mailto:', 'tel:']
@@ -257,7 +258,14 @@ const Link = TipTapLink.extend({
return false
}
console.debug('toggle link for selection')
- return this.editor.commands.toggleLink({ href: '' })
+ return this.editor
+ .chain()
+ .toggleLink({ href: '' })
+ .command(({ state, dispatch }) => {
+ focusLinkBubbleInput(state, dispatch)
+ return true
+ })
+ .run()
},
}
},
@@ -267,8 +275,8 @@ const Link = TipTapLink.extend({
// remove upstream link click handle plugin
.filter((plugin) => !plugin.props.handleClick)
- // Add our own click handler plugin
- return [...plugins, linkClicking(this.options.openLink)]
+ // Add our own click handler plugin and the pill plugin
+ return [...plugins, linkClicking(this.options.openLink), linkPill()]
},
// @ts-expect-error - toMarkdown is a custom field not part of the official Tiptap API
diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js
index df0a398df5d..7ebf27391ba 100644
--- a/src/plugins/LinkBubblePluginView.js
+++ b/src/plugins/LinkBubblePluginView.js
@@ -62,6 +62,8 @@ class LinkBubblePluginView {
propsData: {
editor: this.options.editor,
href: null,
+ nodeStart: null,
+ focusInput: false,
},
})
@@ -73,31 +75,31 @@ class LinkBubblePluginView {
trigger: 'manual',
placement: 'bottom',
hideOnClick: 'toggle',
- popperOptions: {
- strategy: 'fixed',
- },
})
}
- update(view, oldState) {
- const { active } = this.plugin.getState(view.state)
+ #timer = null
+
+ update(view) {
+ const { active, focusInput } = this.plugin.getState(view.state)
if (view.composing) {
return
}
this.createTooltip()
+ clearTimeout(this.#timer)
if (active?.mark) {
- setTimeout(() => {
- this.updateTooltip(view, active)
+ this.#timer = setTimeout(() => {
+ this.updateTooltip(view, active, focusInput)
}, 100)
} else {
this.removeEventListeners()
- setTimeout(() => {
+ this.#timer = setTimeout(() => {
this.tippy?.hide()
}, 100)
}
}
- updateTooltip(view, { mark, nodeStart }) {
+ updateTooltip(view, { mark, nodeStart }, focusInput = false) {
let referenceEl
try {
referenceEl = view.nodeDOM(nodeStart)
@@ -111,6 +113,8 @@ class LinkBubblePluginView {
this.#component?.updateProps({
href: domHref(mark),
+ nodeStart,
+ focusInput,
})
const clientRect = referenceEl?.getBoundingClientRect()
@@ -125,6 +129,7 @@ class LinkBubblePluginView {
}
destroy() {
+ clearTimeout(this.#timer)
this.tippy?.destroy()
}
}
diff --git a/src/plugins/linkHelpers.js b/src/plugins/linkHelpers.js
index 375afbd6c28..d61db548286 100644
--- a/src/plugins/linkHelpers.js
+++ b/src/plugins/linkHelpers.js
@@ -43,12 +43,27 @@ export function activeLinkFromSelection({ selection, doc }) {
return null
}
+/**
+ * Active link object for the inline node starting at the given position
+ *
+ * @param {object} doc - the prosemirror document
+ * @param {number} pos - position right before the node
+ */
+export function activeLinkAtPos(doc, pos) {
+ const resolved = doc.resolve(pos)
+ // ignore links in previews
+ if (resolved.parent.type.name === 'preview') {
+ return null
+ }
+ return activeLink(resolved.nodeAfter, pos)
+}
+
/**
* Active link object for the given node and nodeStart
* @param {object} node - node to check
* @param {number} nodeStart - offset in the document
*/
-function activeLink(node, nodeStart) {
+export function activeLink(node, nodeStart) {
const mark = linkMark(node)
return mark ? { mark, nodeStart } : null
}
diff --git a/src/plugins/linkPill.ts b/src/plugins/linkPill.ts
new file mode 100644
index 00000000000..9e409348698
--- /dev/null
+++ b/src/plugins/linkPill.ts
@@ -0,0 +1,139 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { Mark, Node } from '@tiptap/pm/model'
+import type { EditorView } from '@tiptap/pm/view'
+
+import PencilSvg from '@mdi/svg/svg/pencil-outline.svg?raw'
+import { t } from '@nextcloud/l10n'
+import { Plugin, PluginKey } from '@tiptap/pm/state'
+import { Decoration, DecorationSet } from '@tiptap/pm/view'
+import { activeLink } from './linkHelpers.js'
+import { linkBubbleKey } from './links.ts'
+
+export const linkPillPluginKey = new PluginKey('linkPill')
+
+export interface LinkSpan {
+ /** Position of the first text node of the link */
+ nodeStart: number
+ /** Position right after the last text node of the link */
+ end: number
+ mark: Mark
+}
+
+/**
+ * Find all links in the document.
+ * Consecutive text nodes sharing the same link mark count as one link.
+ *
+ * @param doc - the ProseMirror document
+ */
+export function findLinkSpans(doc: Node): LinkSpan[] {
+ const results: LinkSpan[] = []
+ let current = null as LinkSpan | null
+ doc.descendants((node, pos, parent) => {
+ const link =
+ node.isText && parent?.type.name !== 'preview'
+ ? activeLink(node, pos)
+ : null
+ if (current && link?.mark.eq(current.mark)) {
+ current.end = pos + node.nodeSize
+ return
+ }
+ if (current) {
+ results.push(current)
+ }
+ current = link
+ ? {
+ end: pos + node.nodeSize,
+ nodeStart: link.nodeStart,
+ mark: link.mark,
+ }
+ : null
+ })
+ if (current) {
+ results.push(current)
+ }
+ return results
+}
+
+/**
+ * Create the pill button that opens the link bubble for the given link
+ *
+ * @param linkSpan - the link to open the bubble for
+ * @param linkSpan.mark - the link mark
+ * @param linkSpan.nodeStart - position of the first text node of the link
+ */
+function createPillDom({ mark, nodeStart }: LinkSpan) {
+ return (view: EditorView) => {
+ const pill = document.createElement('span')
+ pill.className = 'link-pill'
+ pill.contentEditable = 'false'
+ pill.setAttribute('role', 'button')
+ pill.setAttribute('tabindex', '0')
+ pill.setAttribute('title', t('text', 'Edit link'))
+ pill.setAttribute('aria-label', t('text', 'Edit link'))
+ pill.innerHTML = PencilSvg
+ const svg = pill.querySelector('svg')
+ svg?.removeAttribute('id')
+ svg?.setAttribute('aria-hidden', 'true')
+
+ const openBubble = (event: Event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ view.dispatch(
+ view.state.tr.setMeta(linkBubbleKey, {
+ active: { mark, nodeStart },
+ }),
+ )
+ }
+ // Keep editor selection and an already open bubble untouched
+ pill.addEventListener('mousedown', (event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ })
+ pill.addEventListener('click', openBubble)
+ pill.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ openBubble(event)
+ }
+ })
+ return pill
+ }
+}
+
+/**
+ * @param doc the document node
+ */
+function buildDecorations(doc: Node): DecorationSet {
+ const decorations = findLinkSpans(doc).map((linkSpan) =>
+ Decoration.widget(linkSpan.end, createPillDom(linkSpan), {
+ side: 1,
+ stopEvent: () => true,
+ key: `link-pill-${linkSpan.nodeStart}-${linkSpan.mark.attrs.href}`,
+ }),
+ )
+ return DecorationSet.create(doc, decorations)
+}
+
+/**
+ * ProseMirror plugin rendering a pill button after each link that opens the link bubble
+ */
+export function linkPill() {
+ return new Plugin({
+ key: linkPillPluginKey,
+ state: {
+ init: (_, { doc }) => buildDecorations(doc),
+ apply: (tr, value) =>
+ tr.docChanged
+ ? buildDecorations(tr.doc)
+ : value.map(tr.mapping, tr.doc),
+ },
+ props: {
+ decorations(state) {
+ return this.getState(state)
+ },
+ },
+ })
+}
diff --git a/src/plugins/links.ts b/src/plugins/links.ts
index ff9166e3ffd..5069fa05182 100644
--- a/src/plugins/links.ts
+++ b/src/plugins/links.ts
@@ -6,11 +6,13 @@
import type { Editor } from '@tiptap/core'
import type { ResolvedPos } from '@tiptap/pm/model'
import type { Command } from '@tiptap/pm/state'
+import type { EditorView } from '@tiptap/pm/view'
import { Plugin, PluginKey } from '@tiptap/pm/state'
+import { isMobileDevice } from '../helpers/isMobileDevice.js'
import { isLinkToSelfWithHash } from '../helpers/links.js'
import LinkBubblePluginView from './LinkBubblePluginView.js'
-import { activeLinkFromSelection } from './linkHelpers.js'
+import { activeLinkAtPos, activeLinkFromSelection } from './linkHelpers.js'
// Commands
@@ -33,9 +35,7 @@ export const setActiveLink =
return true
}
-/* Hide the link bubble by setting active state to null
- *
- */
+// Hide the link bubble by setting active state to null
export const hideLinkBubble: Command = (state, dispatch) => {
const pluginState = linkBubbleKey.getState(state)
if (!pluginState?.active) {
@@ -47,7 +47,72 @@ export const hideLinkBubble: Command = (state, dispatch) => {
return true
}
+// Open the link bubble for the link at the selection and forus its URL input
+export const focusLinkBubbleInput: Command = (state, dispatch) => {
+ const active = activeLinkFromSelection(state)
+ if (!active) {
+ return false
+ }
+ if (dispatch) {
+ dispatch(state.tr.setMeta(linkBubbleKey, { active, focusInput: true }))
+ }
+ return true
+}
+
export const linkBubbleKey = new PluginKey('linkBubble')
+
+export const LINK_HOVER_DELAY = 300
+
+/**
+ * DOM event handlers that open the link bubble after hovering a link with delay
+ */
+export function linkHoverHandlers() {
+ let hovered: Element | null = null
+ let timer: ReturnType | null = null
+
+ const cancel = () => {
+ if (timer) {
+ clearTimeout(timer)
+ timer = null
+ }
+ hovered = null
+ }
+
+ return {
+ mouseover: (view: EditorView, event: MouseEvent) => {
+ const linkEl = (event.target as Element | null)?.closest(
+ 'a[data-text-el="text-only-link"]',
+ )
+ if (!linkEl || linkEl === hovered) {
+ return false
+ }
+ cancel()
+ hovered = linkEl
+ timer = setTimeout(() => {
+ timer = null
+ if (view.isDestroyed) {
+ return
+ }
+ const pos = view.posAtDOM(linkEl, 0)
+ const active = activeLinkAtPos(view.state.doc, pos)
+ const current = linkBubbleKey.getState(view.state)?.active
+ if (!active || current?.nodeStart === active.nodeStart) {
+ return
+ }
+ view.dispatch(view.state.tr.setMeta(linkBubbleKey, { active }))
+ }, LINK_HOVER_DELAY)
+ return false
+ },
+ mouseout: (_view: EditorView, event: MouseEvent) => {
+ const related = event.relatedTarget as Node | null
+ if (hovered && !(related && hovered.contains(related))) {
+ cancel()
+ }
+ return false
+ },
+ }
+}
+
/**
* Prosemirror link bubble plugin
*
@@ -58,14 +123,13 @@ export function linkBubble(options: { editor: Editor }) {
const linkBubblePlugin: Plugin = new Plugin({
key: linkBubbleKey,
state: {
- init: () => ({ active: null }),
+ init: () => ({ active: null, focusInput: false }),
apply: (tr, cur) => {
const meta = tr.getMeta(linkBubbleKey)
if (meta) {
- return { ...cur, active: meta.active }
- } else {
- return cur
+ return { active: meta.active, focusInput: !!meta.focusInput }
}
+ return cur
},
},
@@ -82,6 +146,11 @@ export function linkBubble(options: { editor: Editor }) {
return
}
+ // Explicit updates of the bubble state take precedence
+ if (transactions.some((tr) => tr.getMeta(linkBubbleKey))) {
+ return
+ }
+
// Don't open bubble if neither selection nor doc changed
const sameSelection = oldState?.selection.eq(state.selection)
const sameDoc = oldState?.doc.eq(state.doc)
@@ -97,25 +166,8 @@ export function linkBubble(options: { editor: Editor }) {
},
props: {
- // Required for read-only mode on Firefox.
- // For some reason, editor selection doesn't get updated
- // when clicking a link in read-only mode on Firefox.
- handleClickOn: (view, pos, _node, _nodePos, event, direct) => {
- // Only regard left clicks without Ctrl/Meta
- if (
- !direct
- || event.button !== 0
- || event.ctrlKey
- || event.metaKey
- ) {
- return false
- }
- const { state, dispatch } = view
- const resolved = state.doc.resolve(pos)
- return setActiveLink(resolved)(state, dispatch)
- },
-
handleDOMEvents: {
+ ...(isMobileDevice ? {} : linkHoverHandlers()),
// Handled here because `handleKeyDown` does not work in read only editor.
keydown: (view, event) => {
const { state, dispatch } = view
@@ -135,8 +187,7 @@ export const linkClickingKey = new PluginKey('textHandleClickLink')
* Prosemirror plugin for special handling for clicks on links
*
* - Open link in new tab on middle click rather than pasting.
- * - Only open link on ctrl/cmd + left click.
- * We use the link bubble otherwise.
+ * - Open link with the given handler on left click, unless the click finished a text selection.
*
* @param openLink - the openLink callback function
*/
@@ -148,9 +199,19 @@ export function linkClicking(
return new Plugin({
key: linkClickingKey,
props: {
+ handleClick: (_view, _pos, event) => {
+ const linkEl = (event.target as Element | null)?.closest(
+ 'a[data-text-el="text-only-link"]',
+ )
+ return (
+ !!linkEl
+ && event.button === 0
+ && (event.ctrlKey || event.metaKey)
+ )
+ },
handleDOMEvents: {
// Open link in new tab on middle click
- auxclick: (view, event) => {
+ auxclick: (_view, event) => {
const linkEl = (event.target as Element | null)?.closest('a')
if (
linkEl
@@ -175,37 +236,36 @@ export function linkClicking(
event.stopImmediatePropagation()
}
},
- // Prevent open link for text-only links on left click. Required for read-only mode.
+ // Open text-only links ourselves. Required for read-only mode.
click: (view, event) => {
const linkEl = (event.target as Element | null)?.closest('a')
// Only text-only links need special handling (e.g. don't handle links inside preview or mermaid diagrams)
if (
!linkEl
|| !linkEl.matches('a[data-text-el="text-only-link"]')
+ || event.button !== 0
) {
return false
}
- if (event.button === 0) {
- // Stop browser from opening the link
- event.preventDefault()
+ // Stop browser from opening the link
+ event.preventDefault()
- if (isLinkToSelfWithHash(linkEl.href)) {
- // Directly scroll to anchor links
- const url = new URL(linkEl.href, window.location.href)
- const hash = url.hash
- if (hash) {
- const target = view.dom.querySelector(hash)
- target?.scrollIntoView({
- block: 'start',
- behavior: 'smooth',
- })
- }
- window.history.replaceState({}, '', url.href)
- } else if (event.ctrlKey || event.metaKey) {
- // Open link directly on Ctrl/Cmd + left click
- openLink(linkEl.href)
+ if (isLinkToSelfWithHash(linkEl.href)) {
+ // Directly scroll to anchor links
+ const url = new URL(linkEl.href, window.location.href)
+ const hash = url.hash
+ if (hash) {
+ const target = view.dom.querySelector(hash)
+ target?.scrollIntoView({
+ block: 'start',
+ behavior: 'smooth',
+ })
}
+ window.history.replaceState({}, '', url.href)
+ } else if (document.getSelection()?.isCollapsed !== false) {
+ // Don't open the link when the click finished a text selection
+ openLink(linkEl.href)
}
},
},
diff --git a/src/tests/plugins/linkBubble.spec.js b/src/tests/plugins/linkBubble.spec.js
index d734c7c5d92..b17a0a0b381 100644
--- a/src/tests/plugins/linkBubble.spec.js
+++ b/src/tests/plugins/linkBubble.spec.js
@@ -17,7 +17,7 @@ describe('linkBubble prosemirror plugin', () => {
const plugin = linkBubble()
const state = createState({ plugins: [plugin] })
expect(state.plugins).toContain(plugin)
- expect(plugin.getState(state)).toEqual({ active: null })
+ expect(plugin.getState(state)).toEqual({ active: null, focusInput: false })
})
test('updates plugin state active on transaction', () => {
@@ -26,7 +26,7 @@ describe('linkBubble prosemirror plugin', () => {
const dummy = { was: 'active' }
const tr = state.tr.setMeta(plugin, { active: dummy })
const after = state.apply(tr)
- expect(plugin.getState(after)).toEqual({ active: dummy })
+ expect(plugin.getState(after)).toEqual({ active: dummy, focusInput: false })
})
test('setActiveLink requires a link mark', () => {
diff --git a/src/tests/plugins/linkClicking.spec.ts b/src/tests/plugins/linkClicking.spec.ts
new file mode 100644
index 00000000000..7d4d0e5ad85
--- /dev/null
+++ b/src/tests/plugins/linkClicking.spec.ts
@@ -0,0 +1,73 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import Link from '../../marks/Link.ts'
+import createCustomEditor from '../testHelpers/createCustomEditor.ts'
+
+describe('linkClicking plugin', () => {
+ function setup(content: string) {
+ const openLink = vi.fn()
+ const editor = createCustomEditor(content, [Link.configure({ openLink })])
+ document.body.appendChild(editor.view.dom)
+ const link = editor.view.dom.querySelector('a')!
+ return { editor, openLink, link }
+ }
+
+ function click(el: Element, init: MouseEventInit = {}) {
+ el.dispatchEvent(
+ new MouseEvent('click', {
+ bubbles: true,
+ cancelable: true,
+ button: 0,
+ ...init,
+ }),
+ )
+ }
+
+ afterEach(() => {
+ document.getSelection()?.removeAllRanges()
+ document.body.replaceChildren()
+ })
+
+ it('opens the link on left click', () => {
+ const { editor, openLink, link } = setup(
+ 'Test
',
+ )
+ click(link)
+ expect(openLink).toHaveBeenCalledWith('https://example.org/')
+ editor.destroy()
+ })
+
+ it('opens the link on ctrl click', () => {
+ const { editor, openLink, link } = setup(
+ 'Test
',
+ )
+ click(link, { ctrlKey: true })
+ expect(openLink).toHaveBeenCalledWith('https://example.org/')
+ editor.destroy()
+ })
+
+ it('does not open the link when text is selected', () => {
+ const { editor, openLink, link } = setup(
+ 'Test
',
+ )
+ const range = document.createRange()
+ range.selectNodeContents(link)
+ document.getSelection()?.addRange(range)
+ click(link)
+ expect(openLink).not.toHaveBeenCalled()
+ editor.destroy()
+ })
+
+ it('does not open anchor links', () => {
+ const { editor, openLink, link } = setup(
+ 'Test
',
+ )
+ click(link)
+ expect(openLink).not.toHaveBeenCalled()
+ editor.destroy()
+ })
+})
diff --git a/src/tests/plugins/linkPill.spec.ts b/src/tests/plugins/linkPill.spec.ts
new file mode 100644
index 00000000000..70ae9ab5de6
--- /dev/null
+++ b/src/tests/plugins/linkPill.spec.ts
@@ -0,0 +1,55 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { describe, expect, it, vi } from 'vitest'
+import Link from '../../marks/Link.ts'
+import Strong from '../../marks/Strong.js'
+import { findLinkSpans } from '../../plugins/linkPill.ts'
+import { linkBubbleKey } from '../../plugins/links.ts'
+import createCustomEditor from '../testHelpers/createCustomEditor.ts'
+
+describe('linkPill plugin', () => {
+ it('finds one end per link, merging text nodes of the same link', () => {
+ const editor = createCustomEditor(
+ 'Test two
',
+ [Link, Strong],
+ )
+ const spans = findLinkSpans(editor.state.doc)
+ expect(spans).toHaveLength(2)
+ expect(spans[0]).toMatchObject({ nodeStart: 1, end: 5 })
+ expect(spans[0].mark.attrs.href).toBe('https://example.org/')
+ expect(spans[1]).toMatchObject({ nodeStart: 6, end: 9 })
+ editor.destroy()
+ })
+
+ it('skips anchor links', () => {
+ const editor = createCustomEditor('Test
', [
+ Link,
+ ])
+ expect(findLinkSpans(editor.state.doc)).toHaveLength(0)
+ expect(editor.view.dom.querySelectorAll('.link-pill')).toHaveLength(0)
+ editor.destroy()
+ })
+
+ it('renders pill after link', () => {
+ const editor = createCustomEditor(
+ 'Test text
',
+ [Link],
+ )
+ const pills = editor.view.dom.querySelectorAll('.link-pill')
+ expect(pills).toHaveLength(1)
+ expect(pills[0].previousSibling?.nodeName).toBe('A')
+
+ const dispatch = vi.spyOn(editor.view, 'dispatch')
+ pills[0].dispatchEvent(
+ new MouseEvent('click', { bubbles: true, cancelable: true }),
+ )
+ expect(dispatch).toHaveBeenCalledTimes(1)
+ const active = dispatch.mock.calls[0][0].getMeta(linkBubbleKey).active
+ expect(active.nodeStart).toBe(1)
+ expect(active.mark.attrs.href).toBe('https://example.org/')
+ editor.destroy()
+ })
+})