From 0d1ee3bc22a704bca0f47f793dcd19ed3672adff Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 22 Jul 2025 10:11:51 +0200 Subject: [PATCH 1/7] fix(PreviewOptions): Pass href, so "open in new tab" option is displayed Signed-off-by: Jonas --- cypress/e2e/nodes/PreviewOptions.spec.js | 1 + src/components/Editor/PreviewOptions.vue | 21 +++++++++++++++++-- src/plugins/extractLinkParagraphs.js | 2 ++ .../plugins/extractLinkParagraphs.spec.js | 19 +++++++++-------- 4 files changed, 32 insertions(+), 11 deletions(-) diff --git a/cypress/e2e/nodes/PreviewOptions.spec.js b/cypress/e2e/nodes/PreviewOptions.spec.js index df0e22e86fd..48d5b53f3ce 100644 --- a/cypress/e2e/nodes/PreviewOptions.spec.js +++ b/cypress/e2e/nodes/PreviewOptions.spec.js @@ -23,6 +23,7 @@ describe('Preview Options', function() { }) it('should render previewOptions correctly', function() { + cy.get('.action-button__text').contains('Open in new tab').should('be.visible') cy.get('.action-button__text').contains('Remove link').should('be.visible') cy.get('.action-radio__label').each(el => { cy.wrap(el).invoke('text').should('match', /Text only|Show link preview/) diff --git a/src/components/Editor/PreviewOptions.vue b/src/components/Editor/PreviewOptions.vue index f2cd5a6460f..2cf055e408c 100644 --- a/src/components/Editor/PreviewOptions.vue +++ b/src/components/Editor/PreviewOptions.vue @@ -27,11 +27,17 @@ {{ t('text', 'Show link preview') }} - + + + {{ t('text', 'Open in new tab') }} + + - {{ t('text','Remove link') }} + {{ t('text', 'Remove link') }} @@ -45,6 +51,7 @@ import NcActionCaption from '@nextcloud/vue/components/NcActionCaption' import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator' import DotsVerticalIcon from 'vue-material-design-icons/DotsVertical.vue' import DeleteIcon from 'vue-material-design-icons/Delete.vue' +import OpenIcon from 'vue-material-design-icons/OpenInNew.vue' export default { name: 'PreviewOptions', @@ -57,6 +64,7 @@ export default { NcActionRadio, NcActionSeparator, DeleteIcon, + OpenIcon, }, props: { @@ -64,6 +72,11 @@ export default { type: String, required: true, }, + href: { + type: String, + required: false, + default: '', + }, offset: { type: Number, required: true, @@ -104,6 +117,10 @@ export default { to: this.offset + this.nodeSize, }) }, + openLink() { + if (!this.href) return + window.open(this.href, '_blank').focus() + }, }, } diff --git a/src/plugins/extractLinkParagraphs.js b/src/plugins/extractLinkParagraphs.js index 738c8bf4647..cc4d6cecdf6 100644 --- a/src/plugins/extractLinkParagraphs.js +++ b/src/plugins/extractLinkParagraphs.js @@ -20,12 +20,14 @@ export default function extractLinkParagraphs(doc) { offset, nodeSize: node.nodeSize, type: 'text-only', + href: extractHref(node.firstChild), })) } else if (node.type.name === 'preview') { paragraphs.push(Object.freeze({ offset, nodeSize: node.nodeSize, type: 'link-preview', + href: node.attrs.href, })) } }) diff --git a/src/tests/plugins/extractLinkParagraphs.spec.js b/src/tests/plugins/extractLinkParagraphs.spec.js index ab5d7e0c6e3..34455cf5e7c 100644 --- a/src/tests/plugins/extractLinkParagraphs.spec.js +++ b/src/tests/plugins/extractLinkParagraphs.spec.js @@ -9,8 +9,9 @@ import Preview from '../../nodes/Preview.js' import { createCustomEditor } from '../helpers.js' describe('extractLinkParagraphs', () => { - const link = 'Link' - const preview = 'Link' + const href = 'https://nextcloud.com' + const link = `Link` + const preview = `Link` it('returns an empty array for an empty doc', () => { const doc = prepareDoc('') @@ -23,7 +24,7 @@ describe('extractLinkParagraphs', () => { const doc = prepareDoc(content) const paragraphs = extractLinkParagraphs(doc) expect(paragraphs).toEqual([ - { offset: 0 , type: 'text-only', nodeSize: 6 } + { href, offset: 0 , type: 'text-only', nodeSize: 6 } ]) }) @@ -31,7 +32,7 @@ describe('extractLinkParagraphs', () => { const doc = prepareDoc(preview) const paragraphs = extractLinkParagraphs(doc) expect(paragraphs).toEqual([ - { offset: 0 , type: 'link-preview', nodeSize: 6 } + { href, offset: 0 , type: 'link-preview', nodeSize: 6 } ]) }) @@ -40,7 +41,7 @@ describe('extractLinkParagraphs', () => { const doc = prepareDoc(content) const paragraphs = extractLinkParagraphs(doc) expect(paragraphs).toEqual([ - { offset: 0 , type: 'text-only', nodeSize: 7 } + { href, offset: 0 , type: 'text-only', nodeSize: 7 } ]) }) @@ -50,8 +51,8 @@ describe('extractLinkParagraphs', () => { const doc = prepareDoc(content) const paragraphs = extractLinkParagraphs(doc) expect(paragraphs).toEqual([ - { offset: 0 , type: 'text-only', nodeSize: 6 }, - { offset: 6 , type: 'text-only', nodeSize: 6 } + { href, offset: 0 , type: 'text-only', nodeSize: 6 }, + { href, offset: 6 , type: 'text-only', nodeSize: 6 } ]) }) @@ -60,8 +61,8 @@ describe('extractLinkParagraphs', () => { const doc = prepareDoc(content) const paragraphs = extractLinkParagraphs(doc) expect(paragraphs).toEqual([ - { offset: 0 , type: 'text-only', nodeSize: 6 }, - { offset: 6 , type: 'link-preview', nodeSize: 6 } + { href, offset: 0 , type: 'text-only', nodeSize: 6 }, + { href, offset: 6 , type: 'link-preview', nodeSize: 6 } ]) }) From a51a9f5aff6f5e07fe88c9f82297a1b968008930 Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 22 Jul 2025 10:29:03 +0200 Subject: [PATCH 2/7] fix(img): Remove global CSS rules for images in prosemirror We have a custom node for displaying images anyway. This fixes the cursor being a pointer in the preview images of preview links. Signed-off-by: Jonas --- src/css/prosemirror.scss | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index 1c14f596c49..2d9f435f8df 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -177,11 +177,6 @@ div.ProseMirror { font-size: var(--default-font-size); } - img { - cursor: default; - max-width: 100%; - } - hr { padding: 2px 0; border: none; From cc1b5b468d47ed8c29398c2d66345184bce24608 Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 22 Jul 2025 10:32:29 +0200 Subject: [PATCH 3/7] fix(links): Add open button to link bubble Fixes: #1226 Signed-off-by: Jonas --- src/components/Link/LinkBubbleView.vue | 14 ++++++++++++++ src/helpers/links.js | 19 +++++++++++++++++++ src/plugins/LinkBubblePluginView.js | 8 +++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/components/Link/LinkBubbleView.vue b/src/components/Link/LinkBubbleView.vue index 341722e6b77..ada90f93bae 100644 --- a/src/components/Link/LinkBubbleView.vue +++ b/src/components/Link/LinkBubbleView.vue @@ -10,6 +10,16 @@ + + + + Date: Wed, 23 Jul 2025 16:19:41 +0200 Subject: [PATCH 4/7] fix(LinkBubble): Read `editor.contentComponent` when it's ready When initiating the editor extensions (i.e. in `addProseMirrorPlugins()`), `editor.contentComponent` is not yet available. So let's read it when we actually use it, when creating the tooltip in `LinkBubblePluginView`. Before, a new Vue instance was initiated for each `LinkBubbleView.vue` as the `parent` given to `VueRenderer()` was `undefined`. With this fix, `LinkBubbleView.vue` becomes a child component of the root editor Vue instance. Signed-off-by: Jonas --- src/extensions/LinkBubble.js | 1 - src/plugins/LinkBubblePluginView.js | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/extensions/LinkBubble.js b/src/extensions/LinkBubble.js index 204a4afdcae..8ac76b9cfe8 100644 --- a/src/extensions/LinkBubble.js +++ b/src/extensions/LinkBubble.js @@ -21,7 +21,6 @@ const LinkBubble = Extension.create({ return [ linkBubble({ editor: this.editor, - parent: this.editor.contentComponent, }), ] }, diff --git a/src/plugins/LinkBubblePluginView.js b/src/plugins/LinkBubblePluginView.js index 05b632f3da9..100c6337ba0 100644 --- a/src/plugins/LinkBubblePluginView.js +++ b/src/plugins/LinkBubblePluginView.js @@ -69,7 +69,7 @@ class LinkBubblePluginView { } this.#component ||= new VueRenderer(LinkBubbleView, { - parent: this.options.parent, + parent: this.options.editor.contentComponent, propsData: { editor: this.options.editor, href: null, From 9a2756a7cca53dd32c6f9e5c6619b476dc8b9285 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 23 Jul 2025 14:35:34 +0200 Subject: [PATCH 5/7] enh(editorAPI): Allow to pass an openLinkHandler to editor Signed-off-by: Jonas --- src/components/Editor.provider.js | 12 ++++++++++++ src/components/Link/LinkBubbleView.vue | 11 +++++++---- src/editor.js | 7 ++++++- src/helpers/links.js | 8 +++++--- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/components/Editor.provider.js b/src/components/Editor.provider.js index bcc4085f7f8..3d6233379ee 100644 --- a/src/components/Editor.provider.js +++ b/src/components/Editor.provider.js @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { openLink } from '../helpers/links.js' import { logger } from '../helpers/logger.js' export const EDITOR = Symbol('tiptap:editor') @@ -16,6 +17,7 @@ export const SYNC_SERVICE = Symbol('sync:service') export const EDITOR_UPLOAD = Symbol('editor:upload') export const HOOK_MENTION_SEARCH = Symbol('hook:mention-search') export const HOOK_MENTION_INSERT = Symbol('hook:mention-insert') +export const OPEN_LINK_HANDLER = Symbol('editor:open-link-handler') export const useEditorMixin = { inject: { @@ -99,3 +101,13 @@ export const useMentionHook = { }, }, } +export const useOpenLinkHandler = { + inject: { + $openLinkHandler: { + from: OPEN_LINK_HANDLER, + default: { + openLink, + }, + }, + }, +} diff --git a/src/components/Link/LinkBubbleView.vue b/src/components/Link/LinkBubbleView.vue index ada90f93bae..a7d804496ca 100644 --- a/src/components/Link/LinkBubbleView.vue +++ b/src/components/Link/LinkBubbleView.vue @@ -11,8 +11,7 @@ {{ title }} - @@ -109,7 +108,7 @@ import OpenInNewIcon from 'vue-material-design-icons/OpenInNew.vue' import PencilIcon from 'vue-material-design-icons/Pencil.vue' import CopyToClipboardMixin from '../../mixins/CopyToClipboardMixin.js' -import { openLink } from '../../helpers/links.js' +import { useOpenLinkHandler } from '../Editor.provider.js' const PROTOCOLS_WITH_PREVIEW = ['http:', 'https:'] @@ -131,6 +130,7 @@ export default { mixins: [ CopyToClipboardMixin, + useOpenLinkHandler, ], props: { @@ -200,7 +200,6 @@ export default { }, methods: { - openLink, t, resetBubble() { @@ -209,6 +208,10 @@ export default { this.referenceTitle = null }, + openLink(href) { + this.$openLinkHandler.openLink(href) + }, + async copyLink() { await this.copyToClipboard(this.href) }, diff --git a/src/editor.js b/src/editor.js index 7649201a84f..bae2c0da060 100644 --- a/src/editor.js +++ b/src/editor.js @@ -6,8 +6,9 @@ import Vue from 'vue' import store from './store/index.js' import { subscribe } from '@nextcloud/event-bus' -import { EDITOR_UPLOAD, HOOK_MENTION_SEARCH, HOOK_MENTION_INSERT, ATTACHMENT_RESOLVER } from './components/Editor.provider.js' +import { EDITOR_UPLOAD, HOOK_MENTION_SEARCH, HOOK_MENTION_INSERT, ATTACHMENT_RESOLVER, OPEN_LINK_HANDLER } from './components/Editor.provider.js' import { ACTION_ATTACHMENT_PROMPT } from './components/Editor/MediaHandler.provider.js' +import { openLink } from './helpers/links.js' // eslint-disable-next-line import/no-unresolved, n/no-missing-import import 'vite/modulepreload-polyfill' @@ -184,6 +185,7 @@ window.OCA.Text.createEditor = async function({ onFileInsert = undefined, onMentionSearch = undefined, onMentionInsert = undefined, + openLinkHandler = undefined, onSearch = undefined, }) { const { default: MarkdownContentEditor } = await import(/* webpackChunkName: "editor" */'./components/Editor/MarkdownContentEditor.vue') @@ -205,6 +207,9 @@ window.OCA.Text.createEditor = async function({ [EDITOR_UPLOAD]: !!sessionEditor, [HOOK_MENTION_SEARCH]: sessionEditor ? true : onMentionSearch, [HOOK_MENTION_INSERT]: sessionEditor ? true : onMentionInsert, + [OPEN_LINK_HANDLER]: { + openLink: openLinkHandler || openLink, + }, [ATTACHMENT_RESOLVER]: { resolve(src, preferRaw) { return [{ diff --git a/src/helpers/links.js b/src/helpers/links.js index f9683e7fa77..d198cc4c33f 100644 --- a/src/helpers/links.js +++ b/src/helpers/links.js @@ -61,13 +61,15 @@ const isLinkToSelfWithHash = function(href) { * * @param {string} href the link href */ -const openLink = function (href) { +const openLink = function(href) { const linkUrl = new URL(href, window.location.href) // Consider rerouting links to Collectives if already inside Collectives app const collectivesUrlBase = '/apps/collectives' if (window.OCA.Collectives?.vueRouter && linkUrl.pathname.toString().startsWith(generateUrl(collectivesUrlBase))) { - const collectivesUrl = linkUrl.href.substring(linkUrl.href.indexOf(collectivesUrlBase) + collectivesUrlBase.length) + const collectivesUrl = linkUrl.href.substring( + linkUrl.href.indexOf(collectivesUrlBase) + collectivesUrlBase.length, + ) window.OCA.Collectives.vueRouter.push(collectivesUrl) return } @@ -76,7 +78,7 @@ const openLink = function (href) { export { domHref, - parseHref, isLinkToSelfWithHash, openLink, + parseHref, } From 0c2514392173be2a9f384207d84f59f06c94bf37 Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 23 Jul 2025 16:59:59 +0200 Subject: [PATCH 6/7] fix(LinkBubble): Fix preview border overflowing link bubble Signed-off-by: Jonas --- src/css/prosemirror.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/css/prosemirror.scss b/src/css/prosemirror.scss index 2d9f435f8df..09547923e44 100644 --- a/src/css/prosemirror.scss +++ b/src/css/prosemirror.scss @@ -397,5 +397,6 @@ div.ProseMirror { } .tippy-content div { + box-sizing: border-box; visibility: visible !important; } From 2a6cf30de14d0c79eb464a2de0ec91f1a5c3319c Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 23 Jul 2025 17:54:47 +0200 Subject: [PATCH 7/7] test(cy): add test for using "Open link" button in link bubble Signed-off-by: Jonas --- cypress/e2e/nodes/Links.spec.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/nodes/Links.spec.js b/cypress/e2e/nodes/Links.spec.js index 13d6db365e6..b84fb82db94 100644 --- a/cypress/e2e/nodes/Links.spec.js +++ b/cypress/e2e/nodes/Links.spec.js @@ -47,7 +47,7 @@ describe('test link marks', function() { cy.get('.link-view-bubble .widget-default', { timeout: 10000 }) .find('.widget-default--name') .contains('Nextcloud') - .click({ force: true }) + .click() }) it('shows a link preview in the bubble after browsing to link', () => { @@ -64,6 +64,16 @@ describe('test link marks', function() { .contains('Nextcloud') }) + it('open button opens a new tab', () => { + const link = 'https://nextcloud.com/' + 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://nextcloud.com/' cy.insertLine(link)