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..7bcdda2d8dc
--- /dev/null
+++ b/playwright/e2e/links.spec.ts
@@ -0,0 +1,148 @@
+/**
+ * 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 1da675c7799..a936306c365 100644
--- a/src/components/Link/LinkBubbleView.vue
+++ b/src/components/Link/LinkBubbleView.vue
@@ -67,6 +67,11 @@
+
import { t } from '@nextcloud/l10n'
+import { getMarkRange } from '@tiptap/core'
import NcButton from '@nextcloud/vue/components/NcButton'
import { NcReferenceList } from '@nextcloud/vue/components/NcRichText'
import NcTextField from '@nextcloud/vue/components/NcTextField'
@@ -99,7 +105,6 @@ import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue'
import PencilOutlineIcon from 'vue-material-design-icons/PencilOutline.vue'
import PreviewOptions from '../Editor/PreviewOptions.vue'
import { useOpenLinkHandler } from '../../composables/useOpenLinkHandler.ts'
-import { logger } from '../../helpers/logger.ts'
import * as Link from '../../marks/Link.ts'
const PROTOCOLS_WITH_PREVIEW = ['http:', 'https:']
@@ -128,6 +133,16 @@ export default {
type: String,
default: null,
},
+
+ nodeStart: {
+ type: Number,
+ default: null,
+ },
+
+ focusInput: {
+ type: Boolean,
+ default: false,
+ },
},
setup() {
@@ -139,6 +154,7 @@ export default {
return {
isEditable: false,
edit: true,
+ newText: '',
newHref: '',
referenceTitle: null,
}
@@ -201,6 +217,12 @@ export default {
this.resetBubble()
this.startEditIfEmpty()
},
+
+ focusInput(value) {
+ if (value && this.isEditable) {
+ this.startEdit()
+ }
+ },
},
beforeMount() {
@@ -219,6 +241,7 @@ export default {
resetBubble() {
this.edit = false
+ this.newText = ''
this.newHref = ''
this.referenceTitle = null
},
@@ -227,6 +250,34 @@ export default {
this.openLinkHandler.openLink(href)
},
+ linkRange() {
+ if (this.nodeStart === null) {
+ return null
+ }
+ const { doc, schema } = this.editor.state
+ try {
+ return getMarkRange(doc.resolve(this.nodeStart), schema.marks.link) ?? null
+ } catch {
+ return null
+ }
+ },
+
+ linkText() {
+ const range = this.linkRange()
+ return range
+ ? this.editor.state.doc.textBetween(range.from, range.to)
+ : ''
+ },
+
+ /**
+ * Command chain with the active link selected, so commands apply to it
+ */
+ chainOnLink() {
+ const chain = this.editor.chain()
+ const range = this.linkRange()
+ return range ? chain.setTextSelection(range) : chain
+ },
+
onReferenceListLoaded() {
this.referenceTitle
= this.$refs.referencelist.firstReference?.openGraphObject?.name
@@ -234,11 +285,15 @@ export default {
},
setPreview() {
- this.editor.chain().hideLinkBubble().setPreview().run()
+ this.chainOnLink()
+ .hideLinkBubble()
+ .setPreview()
+ .run()
},
startEdit() {
this.edit = true
+ this.newText = this.linkText()
this.newHref = this.href ?? ''
this.$nextTick(() => {
this.$refs.hrefField.focus()
@@ -253,36 +308,49 @@ export default {
stopEdit() {
this.edit = false
+ this.newText = ''
this.newHref = ''
},
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))
- logger.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/prosemirror.scss b/src/css/prosemirror.scss
index 655469676ca..061454e361f 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;
@@ -527,6 +550,12 @@ div.ProseMirror {
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 page
+.editor__content-wrapper > [data-tippy-root] {
+ position: absolute;
+}
+
.editor--annotations-hidden div.ProseMirror {
sup[data-type="comment-reference"],
sup[data-type="footnote-reference"],
diff --git a/src/marks/Link.ts b/src/marks/Link.ts
index d714747a4de..82402fc67cd 100644
--- a/src/marks/Link.ts
+++ b/src/marks/Link.ts
@@ -12,6 +12,8 @@ import TipTapLink, { isAllowedUri } from '@tiptap/extension-link'
import { defaultMarkdownSerializer } from 'prosemirror-markdown'
import { domHref, parseHref } from '../helpers/links.js'
import { logger } from '../helpers/logger.ts'
+import { linkPill } from '../plugins/linkPill.ts'
+import { focusLinkBubbleInput } from '../plugins/links.ts'
import { linkClicking } from '../plugins/links.ts'
export const PROTOCOLS_TO_LINK_TO = ['http:', 'https:', 'mailto:', 'tel:']
@@ -227,7 +229,14 @@ const Link = TipTapLink.extend({
return false
}
logger.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()
},
}
},
@@ -237,8 +246,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()]
},
toMarkdown: {
diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js
index f45909c8a3e..a57af12c65d 100644
--- a/src/plugins/LinkBubblePluginView.js
+++ b/src/plugins/LinkBubblePluginView.js
@@ -62,6 +62,8 @@ class LinkBubblePluginView {
props: {
editor: this.options.editor,
href: null,
+ nodeStart: null,
+ focusInput: false,
},
editor: this.options.editor,
})
@@ -74,21 +76,18 @@ class LinkBubblePluginView {
trigger: 'manual',
placement: 'bottom',
hideOnClick: 'toggle',
- popperOptions: {
- strategy: 'fixed',
- },
})
}
update(view) {
- const { active } = this.plugin.getState(view.state)
+ const { active, focusInput } = this.plugin.getState(view.state)
if (view.composing) {
return
}
this.createTooltip()
if (active?.mark) {
setTimeout(() => {
- this.updateTooltip(view, active)
+ this.updateTooltip(view, active, focusInput)
}, 100)
} else {
this.removeEventListeners()
@@ -98,7 +97,7 @@ class LinkBubblePluginView {
}
}
- updateTooltip(view, { mark, nodeStart }) {
+ updateTooltip(view, { mark, nodeStart }, focusInput = false) {
let referenceEl
try {
referenceEl = view.nodeDOM(nodeStart)
@@ -112,6 +111,8 @@ class LinkBubblePluginView {
this.#component?.updateProps({
href: domHref(mark),
+ nodeStart,
+ focusInput,
})
const clientRect = referenceEl?.getBoundingClientRect()
diff --git a/src/plugins/linkHelpers.js b/src/plugins/linkHelpers.js
index a0dd9c6cf05..869d002603b 100644
--- a/src/plugins/linkHelpers.js
+++ b/src/plugins/linkHelpers.js
@@ -43,13 +43,28 @@ 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..12594edfd29
--- /dev/null
+++ b/src/plugins/linkPill.ts
@@ -0,0 +1,131 @@
+/**
+ * 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 3375054a850..17ab16e05b8 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'
export const linkBubbleKey = new PluginKey('linkBubble')
@@ -36,9 +38,7 @@ export function setActiveLink(resolved: ResolvedPos): Command {
}
}
-/* 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) {
@@ -50,6 +50,68 @@ 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 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
*
@@ -60,14 +122,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
},
},
@@ -83,6 +144,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)
@@ -96,25 +162,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
@@ -134,8 +183,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
*/
@@ -145,9 +193,13 @@ export function linkClicking(openLink: (href: string) => void = (href) => {
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
@@ -172,34 +224,33 @@ export function linkClicking(openLink: (href: string) => void = (href) => {
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 fb01114c4ca..b9e2240cac8 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..0309470c8a2
--- /dev/null
+++ b/src/tests/plugins/linkClicking.spec.ts
@@ -0,0 +1,58 @@
+/**
+ * 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..fa2473d0fc0
--- /dev/null
+++ b/src/tests/plugins/linkPill.spec.ts
@@ -0,0 +1,51 @@
+/**
+ * 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()
+ })
+})