diff --git a/cypress/e2e/nodes/ListItem.spec.js b/cypress/e2e/nodes/ListItem.spec.js index 3af9a3760a8..62a98cb2ba6 100644 --- a/cypress/e2e/nodes/ListItem.spec.js +++ b/cypress/e2e/nodes/ListItem.spec.js @@ -5,11 +5,10 @@ import ListItem from '@tiptap/extension-list-item' import TaskList from './../../../src/nodes/TaskList.js' import TaskItem from './../../../src/nodes/TaskItem.js' import BulletList from './../../../src/nodes/BulletList.js' -import Markdown, { createMarkdownSerializer } from './../../../src/extensions/Markdown.js' -import { findChildren } from './../../../src/helpers/prosemirrorUtils.js' +import Markdown from './../../../src/extensions/Markdown.js' import { createCustomEditor } from './../../support/components.js' import testData from '../../fixtures/ListItem.md' -import markdownit from './../../../src/markdownit/index.js' +import { loadMarkdown, runCommands, expectMarkdown } from './helpers.js' describe('ListItem extension integrated in the editor', () => { @@ -37,42 +36,9 @@ describe('ListItem extension integrated in the editor', () => { expect(input).to.be.ok expect(output).to.be.ok /* eslint-enable no-unused-expressions */ - loadMarkdown(input) - runCommands() - expectMarkdown(output.replace(/\n*$/, '')) + loadMarkdown(editor, input) + runCommands(editor) + expectMarkdown(editor, output.replace(/\n*$/, '')) }) } - - const loadMarkdown = (markdown) => { - const stripped = markdown.replace(/\t*/g, '') - editor.commands.setContent(markdownit.render(stripped)) - } - - const runCommands = () => { - let found - while ((found = findCommand())) { - const { node, pos } = found - const name = node.text - editor.commands.setTextSelection(pos) - editor.commands[name]() - editor.commands.insertContent('did ') - } - } - - const findCommand = () => { - const doc = editor.state.doc - return findChildren(doc, child => { - return child.isText && Object.prototype.hasOwnProperty.call(editor.commands, child.text) - })[0] - } - - const expectMarkdown = (markdown) => { - const stripped = markdown.replace(/\t*/g, '') - expect(getMarkdown()).to.equal(stripped) - } - - const getMarkdown = () => { - const serializer = createMarkdownSerializer(editor.schema) - return serializer.serialize(editor.state.doc) - } }) diff --git a/cypress/e2e/nodes/Preview.spec.js b/cypress/e2e/nodes/Preview.spec.js new file mode 100644 index 00000000000..ab90ca0769a --- /dev/null +++ b/cypress/e2e/nodes/Preview.spec.js @@ -0,0 +1,189 @@ +/* eslint-disable no-unused-expressions */ +/** + * @copyright Copyright (c) 2024 Max + * + * @author Max + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import Markdown from './../../../src/extensions/Markdown.js' +import Preview from './../../../src/nodes/Preview.js' +import { Italic, Link } from './../../../src/marks/index.js' +import { createCustomEditor } from './../../support/components.js' +import testData from '../../fixtures/Preview.md' +import { loadMarkdown, runCommands, expectMarkdown } from './helpers.js' + +describe.only('Preview extension', { retries: 0 }, () => { + + const editor = createCustomEditor({ + content: '', + extensions: [ + Markdown, + Preview, + Link, + Italic, + ], + }) + + describe('setPreview command', { retries: 0 }, () => { + + it('is available in commands', () => { + expect(editor.commands).to.have.property('setPreview') + }) + + it('cannot run on normal paragraph', () => { + prepareEditor('hello\n') + expect(editor.can().setPreview()).to.be.false + }) + + it('cannot run on a paragraph with a different mark', () => { + prepareEditor('*link text*\n') + expect(editor.can().setPreview()).to.be.false + }) + + it('cannot run on a paragraph with a link without a href', () => { + prepareEditor('[link text]()\n') + expect(editor.can().setPreview()).to.be.false + }) + + it('cannot run on a paragraph with an anchor link', () => { + prepareEditor('[link text](#top)\n') + expect(editor.can().setPreview()).to.be.false + }) + + it('cannot run on a paragraph with other content', () => { + prepareEditor('[link text](https://nextcloud.com) hello\n') + expect(editor.can().setPreview()).to.be.false + }) + + it('can run on a paragraph with a link', () => { + prepareEditor('[link text](https://nextcloud.com)\n') + expect(editor.can().setPreview()).to.be.true + }) + + it('can run the second a paragraph with a link', () => { + prepareEditor('hello\n\n[link text](https://nextcloud.com)\n') + editor.commands.setTextSelection(10) + expect(editor.can().setPreview()).to.be.true + }) + + it('results in a preview node with the href and text with link mark', () => { + prepareEditor('[link text](https://nextcloud.com)\n') + editor.commands.setPreview() + expect(getParentNode().type.name).to.equal('preview') + expect(getParentNode().attrs.href).to.equal('https://nextcloud.com') + expect(getMark().attrs.href).to.equal('https://nextcloud.com') + }) + + it('cannot run twice', () => { + prepareEditor('[link text](https://nextcloud.com)\n') + editor.commands.setPreview() + expect(editor.can().setPreview()).to.be.false + }) + + }) + + describe('unsetPreview command', { retries: 0 }, () => { + + it('is available in commands', () => { + expect(editor.commands).to.have.property('unsetPreview') + }) + + it('cannot run on normal paragraph', () => { + prepareEditor('hello\n') + expect(editor.can().unsetPreview()).to.be.false + }) + + it('can run on the output of setPreview', () => { + prepareEditor('[link text](https://nextcloud.com)\n') + editor.commands.setPreview() + expect(editor.can().unsetPreview()).to.be.true + }) + + it('creates a paragraph', () => { + prepareEditor('[link text](https://nextcloud.com)\n') + editor.commands.setPreview() + editor.commands.unsetPreview() + expect(getParentNode().type.name).to.equal('paragraph') + }) + + it('includes a link', () => { + prepareEditor('[link text](https://nextcloud.com)\n') + editor.commands.setPreview() + editor.commands.unsetPreview() + expect(getMark().attrs.href).to.equal('https://nextcloud.com') + }) + + }) + + /** + * + */ + function getParentNode() { + const { state: { selection } } = editor + return selection.$head.parent + } + + /** + * + */ + function getMark() { + const { state: { selection } } = editor + console.info(selection.$head) + return selection.$head.nodeAfter.marks[0] + } + + /** + * + * @param input + */ + function prepareEditor(input) { + loadMarkdown(editor, input) + editor.commands.setTextSelection(1) + } + +}) + +describe('Markdown tests for Previews in the editor', { retries: 0 }, () => { + const editor = createCustomEditor({ + content: '', + extensions: [ + Markdown, + Preview, + Link, + ], + }) + + for (const spec of testData.split(/#+\s+/)) { + const [description, ...rest] = spec.split(/\n/) + const [input, output] = rest.join('\n').split(/\n\n---\n\n/) + if (!description) { + continue + } + it(description, () => { + expect(spec).to.include('\n') + /* eslint-disable no-unused-expressions */ + expect(input).to.be.ok + expect(output).to.be.ok + /* eslint-enable no-unused-expressions */ + loadMarkdown(editor, input) + runCommands(editor) + expectMarkdown(editor, output.replace(/\n*$/, '')) + }) + } +}) diff --git a/cypress/e2e/nodes/helpers.js b/cypress/e2e/nodes/helpers.js new file mode 100644 index 00000000000..4886dd8243a --- /dev/null +++ b/cypress/e2e/nodes/helpers.js @@ -0,0 +1,80 @@ +/** + * @copyright Copyright (c) 2024 Max + * + * @author Max + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import markdownit from './../../../src/markdownit/index.js' +import { findChildren } from './../../../src/helpers/prosemirrorUtils.js' +import { createMarkdownSerializer } from './../../../src/extensions/Markdown.js' + +/** + * + * @param editor + * @param markdown + */ +export function loadMarkdown(editor, markdown) { + const stripped = markdown.replace(/\t*/g, '') + editor.commands.setContent(markdownit.render(stripped)) +} + +/** + * + * @param editor + */ +export function runCommands(editor) { + let found + while ((found = findCommand(editor))) { + const { node, pos } = found + const name = node.text + editor.commands.setTextSelection(pos) + editor.commands[name]() + editor.commands.insertContent('did ') + } +} + +/** + * + * @param editor + */ +function findCommand(editor) { + const doc = editor.state.doc + return findChildren(doc, child => { + return child.isText && Object.prototype.hasOwnProperty.call(editor.commands, child.text) + })[0] +} + +/** + * + * @param editor + * @param markdown + */ +export function expectMarkdown(editor, markdown) { + const stripped = markdown.replace(/\t*/g, '') + expect(getMarkdown(editor)).to.equal(stripped) +} + +/** + * + * @param editor + */ +function getMarkdown(editor) { + const serializer = createMarkdownSerializer(editor.schema) + return serializer.serialize(editor.state.doc) +} diff --git a/cypress/fixtures/Preview.md b/cypress/fixtures/Preview.md new file mode 100644 index 00000000000..f9125046043 --- /dev/null +++ b/cypress/fixtures/Preview.md @@ -0,0 +1,23 @@ +## Runs a spec + +empty + +--- + +empty + +## Preserves a link + +[link text](https://nextcloud.com) + +--- + +[link text](https://nextcloud.com) + +## Preserves a link preview + +[link text](https://nextcloud.com (Preview)) + +--- + +[link text](https://nextcloud.com (Preview)) diff --git a/sample.md b/sample.md new file mode 100644 index 00000000000..4c27b77bb71 --- /dev/null +++ b/sample.md @@ -0,0 +1,2 @@ +[Test me](https://www.nextcloud.com (preview)) + diff --git a/src/components/Editor/PreviewOptions.vue b/src/components/Editor/PreviewOptions.vue new file mode 100644 index 00000000000..7242b4f3ba0 --- /dev/null +++ b/src/components/Editor/PreviewOptions.vue @@ -0,0 +1,75 @@ + + + + + + diff --git a/src/extensions/LinkBubblePluginView.js b/src/extensions/LinkBubblePluginView.js index 6e968713253..3f5d09f53dd 100644 --- a/src/extensions/LinkBubblePluginView.js +++ b/src/extensions/LinkBubblePluginView.js @@ -195,6 +195,12 @@ class LinkBubblePluginView { const to = Math.max(...ranges.map(range => range.$to.pos)) const resolved = view.state.doc.resolve(from) + + // ignore links in previews + if (resolved.parent.type.name === 'preview') { + return false + } + const node = resolved.parent.maybeChild(resolved.index()) const nodeStart = resolved.pos - resolved.textOffset const nodeEnd = nodeStart + node?.nodeSize diff --git a/src/extensions/RichText.js b/src/extensions/RichText.js index 7ae707f3f1e..48637013d10 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -26,7 +26,7 @@ import { lowlight } from 'lowlight' /* eslint-disable import/no-named-as-default */ import Blockquote from '@tiptap/extension-blockquote' import BulletList from './../nodes/BulletList.js' -import Callout from './../nodes/Callouts.js' +import Callouts from './../nodes/Callouts.js' import CharacterCount from '@tiptap/extension-character-count' import Code from '@tiptap/extension-code' import CodeBlock from './../nodes/CodeBlock.js' @@ -51,6 +51,7 @@ import Mention from './../extensions/Mention.js' import OrderedList from '@tiptap/extension-ordered-list' import Paragraph from './../nodes/Paragraph.js' import Placeholder from '@tiptap/extension-placeholder' +import Preview from './../nodes/Preview.js' import Table from './../nodes/Table.js' import TaskItem from './../nodes/TaskItem.js' import TaskList from './../nodes/TaskList.js' @@ -100,7 +101,8 @@ export default Extension.create({ this.options.editing ? EditableTable : Table, TaskList, TaskItem, - Callout, + Callouts, + Preview, Underline, Image, ImageInline, diff --git a/src/helpers/links.js b/src/helpers/links.js index 9470de76fe8..f3991c5d454 100644 --- a/src/helpers/links.js +++ b/src/helpers/links.js @@ -28,7 +28,7 @@ const domHref = function(node, relativePath) { if (!ref) { return ref } - if (!OCA.Viewer) { + if (!window.OCA?.Viewer) { return ref } if (ref.match(/^[a-zA-Z]*:/)) { diff --git a/src/markdownit/index.js b/src/markdownit/index.js index 065237b4e6a..baa45d37131 100644 --- a/src/markdownit/index.js +++ b/src/markdownit/index.js @@ -4,6 +4,7 @@ import markdownitMentions from '@quartzy/markdown-it-mentions' import underline from './underline.js' import splitMixedLists from './splitMixedLists.js' import callouts from './callouts.js' +import preview from './preview.js' import hardbreak from './hardbreak.js' import keepSyntax from './keepSyntax.js' import frontMatter from 'markdown-it-front-matter' @@ -15,10 +16,11 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false }) .enable('table') .use(taskLists, { enable: true, labelAfter: true }) .use(frontMatter, (fm) => {}) - .use(splitMixedLists) + .use(splitMixedLists) // needs task Lists to be used first. .use(underline) .use(hardbreak) .use(callouts) + .use(preview) .use(keepSyntax) .use(markdownitMentions) .use(implicitFigures) diff --git a/src/markdownit/preview.js b/src/markdownit/preview.js new file mode 100644 index 00000000000..b403f7aca29 --- /dev/null +++ b/src/markdownit/preview.js @@ -0,0 +1,77 @@ +/** + * @copyright Copyright (c) 2024 Max + * + * @author Max + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +/** + * + * @param tokens + * @param i + */ +function isPreviewLinkInParagraph(tokens, i) { + const [prev, cur, next] = tokens.slice(i - 1, i + 2) + return prev.type === 'paragraph_open' + && cur.type === 'inline' + && cur.children + && cur.children.length === 3 + && cur.children[0].type === 'link_open' + && cur.children[0].attrGet('title') === 'preview' + && cur.children[1].type === 'text' + && cur.children[2].type === 'link_close' + && next.type === 'paragraph_close' +} + +/* Remove wrapping tokens + * + * @param {array} tokens - the token stream to modify + * @param {Number} i - index of the token to unwrap + */ +/** + * + * @param tokens + * @param i + */ +function unwrapToken(tokens, i) { + // Start from the end so indexes stay the same. + tokens.splice(i + 1, 1) + tokens.splice(i - 1, 1) +} + +/** + * @param {object} md Markdown object + */ +export default (md) => { + + /** + * + * @param root0 + * @param root0.tokens + */ + function linkPreviews({ tokens }) { + // do not process first and last token + for (let i = 1, l = tokens.length; i < (l - 1); ++i) { + if (isPreviewLinkInParagraph(tokens, i)) { + unwrapToken(tokens, i) + } + } + } + + md.core.ruler.before('linkify', 'link_previews', linkPreviews) +} diff --git a/src/marks/Link.js b/src/marks/Link.js index 84cedc89085..9faebc9f8e4 100644 --- a/src/marks/Link.js +++ b/src/marks/Link.js @@ -58,7 +58,6 @@ const Link = TipTapLink.extend({ renderHTML(options) { const { mark } = options - return ['a', { ...mark.attrs, href: domHref(mark, this.options.relativePath), diff --git a/src/nodes/ParagraphView.vue b/src/nodes/ParagraphView.vue index b1b7db2fba4..9d77e980982 100644 --- a/src/nodes/ParagraphView.vue +++ b/src/nodes/ParagraphView.vue @@ -22,19 +22,18 @@ diff --git a/src/nodes/Preview.js b/src/nodes/Preview.js new file mode 100644 index 00000000000..94628bda3a0 --- /dev/null +++ b/src/nodes/Preview.js @@ -0,0 +1,165 @@ +/** + * @copyright Copyright (c) 2024 Max + * + * @author Max + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import { Node, isNodeActive, getNodeType } from '@tiptap/core' +import { domHref, parseHref } from './../helpers/links.js' +import { VueNodeViewRenderer } from '@tiptap/vue-2' + +import Preview from './Preview.vue' + +export default Node.create({ + + name: 'preview', + + group: 'block', + + content: 'text?', + + defining: true, + + addOptions() { + return { + relativePath: null, + } + }, + + addAttributes() { + return { + href: { parseHTML: parseHref }, + title: { parseHTML: el => el.getAttribute('title') }, + } + }, + + parseHTML() { + return [ + { + tag: 'a[title="preview"]', + priority: 1001, + }, + ] + }, + + renderHTML({ node }) { + return ['a', { + ...node.attrs, + href: domHref(node, this.options.relativePath), + rel: 'noopener noreferrer nofollow', + }, 0] + }, + + addNodeView() { + return VueNodeViewRenderer(Preview) + }, + + toMarkdown: (state, node) => { + state.write('[') + state.text(node.textContent, false) + state.write(`](${node.attrs.href} (${node.attrs.title}))`) + }, + + addCommands() { + return { + + /** + * Turn a paragraph that contains a single link + * into a preview. + * + */ + setPreview: () => ({ state, chain }) => { + return previewPossible(state) + && chain() + .setNode(this.name, previewAttributesFromSelection(state)) + .run() + }, + + /** + * Turn a preview back into a paragraph + * that contains a single link. + * + */ + unsetPreview: () => ({ state, chain }) => { + console.info(this.attributes) + return isPreview(this.name, this.attributes, state) + && chain() + .setNode('paragraph') + .run() + }, + + } + }, +}) + +/** + * + * @param root0 + * @param root0.selection + */ +function previewAttributesFromSelection({ selection }) { + const { $from } = selection + const href = extractHref($from.nodeAfter) + return { href, title: 'preview' } +} + +/** + * + * @param typeOrName + * @param attributes + * @param state + */ +function isPreview(typeOrName, attributes, state) { + const type = getNodeType(typeOrName, state.schema) + return isNodeActive(state, type, attributes) +} + +/** + * + * @param root0 + * @param root0.selection + */ +function previewPossible({ selection }) { + const { $from } = selection + if (childCount($from.parent) > 1) { + return false + } + const href = extractHref($from.nodeAfter) + if (!href || href.startsWith('#')) { + return false + } + return true +} + +/** + * + * @param node + */ +function extractHref(node) { + const link = node.marks.find(mark => mark.type.name === 'link') + return link?.attrs.href +} + +/** + * + * @param node + */ +function childCount(node) { + return node.content.content.length +} diff --git a/src/nodes/Preview.vue b/src/nodes/Preview.vue new file mode 100644 index 00000000000..8375fe16341 --- /dev/null +++ b/src/nodes/Preview.vue @@ -0,0 +1,98 @@ + + + + + + diff --git a/src/tests/markdown.spec.js b/src/tests/markdown.spec.js index f59f9aabb26..7581ea66ee6 100644 --- a/src/tests/markdown.spec.js +++ b/src/tests/markdown.spec.js @@ -103,6 +103,16 @@ describe('Markdown though editor', () => { }) }) + test('preview with url only', () => { + const entry = '[https://nextcloud.com](https://nextcloud.com (preview))' + expect(markdownThroughEditor(entry)).toBe(entry) + }) + + test('preview with text', () => { + const entry = '[some other text](https://nextcloud.com (preview))' + expect(markdownThroughEditor(entry)).toBe(entry) + }) + test('front matter', () => { expect(markdownThroughEditor('---\nhello: world\n---')).toBe('---\nhello: world\n---') expect(markdownThroughEditor('---\n---')).toBe('---\n---') diff --git a/src/tests/markdownit/preview.spec.js b/src/tests/markdownit/preview.spec.js new file mode 100644 index 00000000000..bc6de3de596 --- /dev/null +++ b/src/tests/markdownit/preview.spec.js @@ -0,0 +1,63 @@ +/** + * @copyright Copyright (c) 2024 Max + * + * @author Max + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import markdownit from '../../markdownit' +import stripIndent from './stripIndent.js' + +describe('Preview extension', () => { + + const link = { + md: `[link](https://nextcloud.com)`, + html: `link`, + } + const preview = { + md: `[link](https://nextcloud.com (preview))`, + html: `link`, + } + + it('wraps', () => { + expect(markdownit.render('[link](https://nextcloud.com)')) + .toBe( + `

link

\n` + ) + }) + + it(`unwraps preview from paragraph`, () => { + const rendered = markdownit.render(preview.md) + expect(rendered).toBe(preview.html) + }) + + it(`leaves non-preview links alone`, () => { + const rendered = markdownit.render(link.md) + expect(rendered).toBe( + `

${link.html}

\n` + ) + }) + + it(`leaves two previews in one paragraph`, () => { + const rendered = markdownit.render(`${preview.md}\n${preview.md}`) + expect(rendered).toBe( + `

${preview.html}\n${preview.html}

\n` + ) + }) + +}) diff --git a/src/tests/nodes/Preview.spec.js b/src/tests/nodes/Preview.spec.js new file mode 100644 index 00000000000..8b401996975 --- /dev/null +++ b/src/tests/nodes/Preview.spec.js @@ -0,0 +1,45 @@ +import Preview from './../../nodes/Preview' +import Markdown from './../../extensions/Markdown' +import { getExtensionField } from '@tiptap/core' +import { createCustomEditor, markdownThroughEditor, markdownThroughEditorHtml } from '../helpers' +import markdownit from '../../markdownit/index.js' + +describe('Preview extension', () => { + it('exposes toMarkdown function', () => { + const toMarkdown = getExtensionField(Preview, 'toMarkdown', Preview) + expect(typeof toMarkdown).toEqual('function') + }) + + it('exposes the toMarkdown function in the prosemirror schema', () => { + const editor = createCustomEditor({ + extensions: [Markdown, Preview] + }) + const preview = editor.schema.nodes.preview + expect(preview.spec.toMarkdown).toBeDefined() + }) + + it('markdown syntax is preserved through editor', () => { + const markdown = `[link](https://nextcloud.com (preview))` + expect(markdownThroughEditor(markdown)).toBe(markdown) + }) + + it('serializes HTML to markdown', () => { + const markdown = `[link](https://nextcloud.com (preview))` + const link = `link` + expect(markdownThroughEditorHtml(link)) + .toBe(markdown) + }) + + it('detects links', () => { + const link = `link` + const editor = createCustomEditor({ + extensions: [Markdown, Preview] + }) + editor.commands.setContent(`${link}

hello>

`) + const node = editor.state.doc.content.firstChild + expect(node.type.name).toBe('preview') + expect(node.attrs.title).toBe('preview') + expect(node.attrs.href).toBe('https://nextcloud.com') + }) + +})