diff --git a/cypress/e2e/nodes/Links.spec.js b/cypress/e2e/Links.spec.js similarity index 99% rename from cypress/e2e/nodes/Links.spec.js rename to cypress/e2e/Links.spec.js index 13d6db365e6..36cfc17a57c 100644 --- a/cypress/e2e/nodes/Links.spec.js +++ b/cypress/e2e/Links.spec.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { randUser } from '../../utils/index.js' +import { randUser } from '../utils/index.js' const user = randUser() const fileName = 'empty.md' diff --git a/cypress/e2e/marks/Link.spec.js b/cypress/e2e/marks/Link.spec.js new file mode 100644 index 00000000000..03e53dffe30 --- /dev/null +++ b/cypress/e2e/marks/Link.spec.js @@ -0,0 +1,45 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Markdown from './../../../src/extensions/Markdown.js' +import { Italic, Link } from './../../../src/marks/index.js' +import { createCustomEditor } from './../../support/components.js' +import { loadMarkdown, expectMarkdown } from '../nodes/helpers.js' + +describe('Link marks', { retries: 0 }, () => { + const editor = createCustomEditor({ + content: '', + extensions: [Markdown, Link, Italic], + }) + + describe('insertOrSetLink command', { retries: 0 }, () => { + it('is available in commands', () => { + expect(editor.commands).to.have.property('insertOrSetLink') + }) + + it('can run on normal paragraph', () => { + prepareEditor('hello\n', 3) + expect(editor.can().insertOrSetLink().run()).to.equal(true) + }) + + it('will insert a link in a normal paragraph', () => { + prepareEditor('hello\n', 3) + editor.commands.insertOrSetLink('https://nextcloud.com', { + href: 'https://nextcloud.com', + }) + expectMarkdown(editor, 'he\n\n\n\nllo') + }) + }) + + /** + * + * @param {*} input markdown content + * @param {*} position cursor pos + */ + function prepareEditor(input, position = 1) { + loadMarkdown(editor, input) + editor.commands.setTextSelection(position) + } +}) diff --git a/src/components/Menu/ActionInsertLink.vue b/src/components/Menu/ActionInsertLink.vue index 8a918c09dcb..8c50d815076 100644 --- a/src/components/Menu/ActionInsertLink.vue +++ b/src/components/Menu/ActionInsertLink.vue @@ -189,24 +189,7 @@ export default { // Avoid issues when parsing urls later on in markdown that might be entered in an invalid format (e.g. "mailto: example@example.com") const href = url.replaceAll(' ', '%20') const chain = this.$editor.chain() - // Check if any text is selected, if not insert the link using the given text property - if (this.$editor.view.state?.selection.empty) { - chain.insertContent({ - type: 'paragraph', - content: [{ - type: 'text', - marks: [{ - type: 'link', - attrs: { - href, - }, - }], - text, - }], - }) - } else { - chain.setLink({ href }) - } + chain.insertOrSetLink(text, { href }) chain.focus().run() }, /** diff --git a/src/marks/Link.js b/src/marks/Link.js index 1878f43958b..ffe5001579c 100644 --- a/src/marks/Link.js +++ b/src/marks/Link.js @@ -3,10 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { markInputRule } from '@tiptap/core' import TipTapLink from '@tiptap/extension-link' import { domHref, parseHref } from './../helpers/links.js' import { linkClicking } from '../plugins/links.js' +import { markInputRule, getMarkRange, isMarkActive } from '@tiptap/core' const PROTOCOLS_TO_LINK_TO = ['http:', 'https:', 'mailto:', 'tel:'] @@ -87,6 +87,49 @@ const Link = TipTapLink.extend({ }), ] }, + addCommands() { + return { + ...this.parent?.(), + insertOrSetLink: (text, attrs) => ({ state, chain, commands }) => { + // Check if any text is selected, + // if not insert the link using the given text property + if (state.selection.empty) { + if (isMarkActive(state, this.name)) { + + // get current href to check what to replace, assumes there's only one link mark on the anchor + let href = '' + state.selection.$anchor.marks().forEach(item => { + if (item.attrs.href && item.type.name === 'link') { + href = item.attrs.href + } + }) + commands.deleteRange(getMarkRange(state.selection.$anchor, state.schema.marks.link, { href })) + return chain().insertContent({ + type: 'text', + marks: [{ + type: 'link', + attrs, + }], + text, + }) + } + return chain().insertContent({ + type: 'paragraph', + content: [{ + type: 'text', + marks: [{ + type: 'link', + attrs, + }], + text, + }], + }) + } else { + return commands.setLink(attrs) + } + }, + } + }, addProseMirrorPlugins() { const plugins = this.parent() diff --git a/src/tests/marks/Link.spec.js b/src/tests/marks/Link.spec.js new file mode 100644 index 00000000000..ee28d092e11 --- /dev/null +++ b/src/tests/marks/Link.spec.js @@ -0,0 +1,44 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import Link from './../../marks/Link.js' +import Underline from '../../marks/Underline.js' +import createCustomEditor from '../testHelpers/createCustomEditor.ts' + +describe('Link extension integrated in the editor', () => { + it('should have link available in commands', () => { + const editor = createCustomEditor('

Test HELLO WORLD

', [Link]) + expect(editor.commands).toHaveProperty('insertOrSetLink') + }) + + it('should update link if anchor has mark', () => { + const editor = createCustomEditor( + '

Test HELLO WORLD

', + [Link, Underline], + ) + editor.commands.setTextSelection(3) + editor.commands.insertOrSetLink('updated.de', { href: 'updated.de' }) + expect(editor.getJSON()).toMatchSnapshot() + }) + + it('Should only update link the anchor is on', () => { + const editor = createCustomEditor( + '

Testsecond link

', + [Link], + ) + editor.commands.setTextSelection(3) + editor.commands.insertOrSetLink('updated.de', { href: 'updated.de' }) + expect(editor.getJSON()).toMatchSnapshot() + }) + + it('should insert new link if none at anchor', () => { + const editor = createCustomEditor( + '

Test HELLO WORLD

', + [Link], + ) + editor.commands.setTextSelection(10) + editor.commands.insertOrSetLink('new link', { href: 'https://nextcloud.com' }) + expect(editor.getJSON()).toMatchSnapshot() + }) +}) diff --git a/src/tests/marks/__snapshots__/Link.spec.js.snap b/src/tests/marks/__snapshots__/Link.spec.js.snap new file mode 100644 index 00000000000..ce88378678f --- /dev/null +++ b/src/tests/marks/__snapshots__/Link.spec.js.snap @@ -0,0 +1,127 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Link extension integrated in the editor > Should only update link the anchor is on 1`] = ` +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "attrs": { + "href": "updated.de", + "title": null, + }, + "type": "link", + }, + ], + "text": "updated.de", + "type": "text", + }, + { + "marks": [ + { + "attrs": { + "href": "not-nextcloud.com", + "title": null, + }, + "type": "link", + }, + ], + "text": "second link", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "doc", +} +`; + +exports[`Link extension integrated in the editor > should insert new link if none at anchor 1`] = ` +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "attrs": { + "href": "nextcloud.com", + "title": null, + }, + "type": "link", + }, + ], + "text": "Test", + "type": "text", + }, + { + "text": " HELL", + "type": "text", + }, + ], + "type": "paragraph", + }, + { + "content": [ + { + "marks": [ + { + "attrs": { + "href": "https://nextcloud.com", + "title": null, + }, + "type": "link", + }, + ], + "text": "new link", + "type": "text", + }, + ], + "type": "paragraph", + }, + { + "content": [ + { + "text": "O WORLD", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "doc", +} +`; + +exports[`Link extension integrated in the editor > should update link if anchor has mark 1`] = ` +{ + "content": [ + { + "content": [ + { + "marks": [ + { + "attrs": { + "href": "updated.de", + "title": null, + }, + "type": "link", + }, + ], + "text": "updated.de", + "type": "text", + }, + { + "text": " HELLO WORLD", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "doc", +} +`;