diff --git a/cypress/e2e/nodes/Preview.spec.js b/cypress/e2e/nodes/Preview.spec.js index e5d46631ad0..66c02dbf6a6 100644 --- a/cypress/e2e/nodes/Preview.spec.js +++ b/cypress/e2e/nodes/Preview.spec.js @@ -46,11 +46,6 @@ describe('Preview extension', { retries: 0 }, () => { expect(editor.can().setPreview()).to.be.false }) - it('cannot run on a paragraph with other content', () => { - prepareEditor('[link text](https://example.org) hello\n') - expect(editor.can().setPreview()).to.be.false - }) - it('convert a paragraph with a link', () => { prepareEditor('[link text](https://example.org)\n') editor.commands.setPreview() diff --git a/src/nodes/Preview.js b/src/nodes/Preview.js index 0bdae48f73a..0b0a084dc82 100644 --- a/src/nodes/Preview.js +++ b/src/nodes/Preview.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { Node, getNodeType, isNodeActive } from '@tiptap/core' +import { Node, getMarkRange, getNodeType, isNodeActive } from '@tiptap/core' import { VueNodeViewRenderer } from '@tiptap/vue-2' import { domHref, isLinkToSelfWithHash, parseHref } from './../helpers/links.js' @@ -78,15 +78,49 @@ export default Node.create({ setPreview: () => ({ state, chain }) => { - return ( - previewPossible(state) - && chain() + if (!previewPossible(state)) { + return false + } + + const { $from } = state.selection + + if (!hasOtherContent($from.parent)) { + // Paragraph contains only a link + return chain() .setNode( this.name, previewAttributesFromSelection(state), ) .run() - ) + } + + if ($from.parent.type.name !== 'paragraph') { + return false + } + + const previewAttrs = previewAttributesFromSelection(state) + + // Link is surrounded by other text in a paragraph + const range = getMarkRange($from, state.schema.marks.link) + if (!range) { + return false + } + + const hasContentBefore = range.from > $from.start() + const hasContentAfter = range.to < $from.end() + + let c = chain() + + if (hasContentAfter) { + c = c.setTextSelection(range.to).splitBlock() + } + if (hasContentBefore) { + c = c.setTextSelection(range.from).splitBlock() + } else { + c = c.setTextSelection($from.before() + 1) + } + + return c.setNode(this.name, previewAttrs).run() }, /** @@ -133,16 +167,29 @@ export default Node.create({ }, }) +/** + * + * @param {object} state the editor state + * @returns {object|null} the link node or null + */ +function getLinkAtCursor(state) { + const { $from } = state.selection + const range = getMarkRange($from, state.schema.marks.link) + if (!range) { + return null + } + return state.doc.resolve(range.from).nodeAfter +} + /** * Attributes for a preview from link in the current selection * * @param {object} state the edior state - * @param {object} state.selection current selection * @return {object} */ -function previewAttributesFromSelection({ selection }) { - const { $from } = selection - const href = extractHref($from.nodeAfter) +function previewAttributesFromSelection(state) { + const linkNode = getLinkAtCursor(state) + const href = extractHref(linkNode) return { href, title: 'preview' } } @@ -160,16 +207,14 @@ function isActive(typeOrName, attributes, state) { /** * Is it possible to convert the currently selected node into a preview? + * * @param {object} state current editor state * @param {object} state.selection current selection * @return {boolean} */ -function previewPossible({ selection }) { - const { $from } = selection - if (hasOtherContent($from.parent)) { - return false - } - const href = extractHref($from.parent.firstChild) +function previewPossible(state) { + const linkNode = getLinkAtCursor(state) + const href = extractHref(linkNode) if (!href || isLinkToSelfWithHash(href)) { return false } diff --git a/src/tests/nodes/Preview.spec.js b/src/tests/nodes/Preview.spec.js index a19f86f7d44..71c5a54fea8 100644 --- a/src/tests/nodes/Preview.spec.js +++ b/src/tests/nodes/Preview.spec.js @@ -55,3 +55,86 @@ describe('Preview extension', () => { expect(node.attrs.href).toBe('https://example.org') }) }) + +describe('setPreview Tiptap command', () => { + function findLinkRange(doc) { + let from = null + let to = null + + doc.descendants((node, pos) => { + if ( + from === null + && node.isText + && node.marks.some((m) => m.type.name === 'link') + ) { + from = pos + to = pos + node.nodeSize + } + }) + return { from, to } + } + + test('returns false on plain text', ({ editor }) => { + editor.commands.setContent('
plain text
') + const result = editor.commands.setPreview() + expect(result).toBe(false) + }) + + test('converts standalone link with cursor at start', ({ editor }) => { + editor.commands.setContent('') + const { from } = findLinkRange(editor.state.doc) + editor.commands.setTextSelection(from) + editor.commands.setPreview() + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild.type.name).toBe('preview') + expect(editor.state.doc.firstChild.attrs.href).toBe('https://example.org') + }) + + test('converts standalone link with cursor at end of link text', ({ + editor, + }) => { + editor.commands.setContent('') + const { to } = findLinkRange(editor.state.doc) + editor.commands.setTextSelection(to) + editor.commands.setPreview() + expect(editor.state.doc.childCount).toBe(1) + expect(editor.state.doc.firstChild.type.name).toBe('preview') + expect(editor.state.doc.firstChild.attrs.href).toBe('https://example.org') + }) + + test('splits inline link with text on both sides', ({ editor }) => { + editor.commands.setContent( + 'before link after
', + ) + const { to } = findLinkRange(editor.state.doc) + editor.commands.setTextSelection(to) + editor.commands.setPreview() + expect(editor.state.doc.childCount).toBe(3) + expect(editor.state.doc.child(1).type.name).toBe('preview') + expect(editor.state.doc.child(1).attrs.href).toBe('https://example.org') + }) + + test('splits inline link with text before only', ({ editor }) => { + editor.commands.setContent( + 'before link
', + ) + const { to } = findLinkRange(editor.state.doc) + editor.commands.setTextSelection(to) + editor.commands.setPreview() + expect(editor.state.doc.childCount).toBe(2) + expect(editor.state.doc.child(1).type.name).toBe('preview') + expect(editor.state.doc.child(1).attrs.href).toBe('https://example.org') + }) + + test('splits inline link with text after only', ({ editor }) => { + editor.commands.setContent( + 'link after
', + ) + const { to } = findLinkRange(editor.state.doc) + editor.commands.setTextSelection(to) + editor.commands.setPreview() + expect(editor.state.doc.childCount).toBe(2) + expect(editor.state.doc.firstChild.type.name).toBe('preview') + expect(editor.state.doc.firstChild.attrs.href).toBe('https://example.org') + }) +})