diff --git a/src/extensions/Markdown.js b/src/extensions/Markdown.ts similarity index 75% rename from src/extensions/Markdown.js rename to src/extensions/Markdown.ts index fa3e717451e..146d2e23107 100644 --- a/src/extensions/Markdown.js +++ b/src/extensions/Markdown.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -/* eslint-disable jsdoc/require-param-type */ /* eslint-disable jsdoc/require-param-description */ /* @@ -24,6 +23,8 @@ * https://github.com/ProseMirror/prosemirror-markdown#class-markdownserializer */ +import type { MarkType, Node, NodeType, Schema, Slice } from '@tiptap/pm/model' + import { Extension, getExtensionField } from '@tiptap/core' import { DOMParser } from '@tiptap/pm/model' import { Plugin, PluginKey } from '@tiptap/pm/state' @@ -31,6 +32,19 @@ import { defaultMarkdownSerializer, MarkdownSerializer } from 'prosemirror-markd import markdownit from '../markdownit/index.js' import transformPastedHTML from './transformPastedHTML.ts' +declare module '@tiptap/core' { + interface MarkConfig { + toMarkdown: { + default: null + } + } + interface NodeConfig { + toMarkdown: { + default: null + } + } +} + const Markdown = Extension.create({ name: 'markdown', @@ -76,7 +90,7 @@ const Markdown = Extension.create({ }, clipboardTextParser(str, $context, _, view) { const parser = DOMParser.fromSchema(view.state.schema) - const doc = document.cloneNode(false) + const doc = document.cloneNode(false) as Document const dom = doc.createElement('div') if (shiftKey) { // Treat double newlines as paragraph breaks when pasting as plaintext @@ -96,20 +110,22 @@ const Markdown = Extension.create({ }) }, clipboardTextSerializer: (slice) => { - const traverseNodes = (slice) => { + const traverseNodes = (slice: Slice | Node) => { if ( slice.content.childCount > 1 - || slice.content.firstChild?.childCount > 1 + || (slice.content.firstChild?.childCount ?? 0) > 1 ) { // Selected several nodes or several children of one block node return clipboardSerializer(this.editor.schema).serialize(slice.content) - } else if (slice.isLeaf) { - return slice.textContent - } else { + } else if (slice.content.firstChild?.isLeaf) { + return slice.content.firstChild.textContent + } else if (slice.content.firstChild) { // Only one block node selected, copy it's child content // Required to not copy wrapping block node when selecting e.g. one table // cell, one list item or the content of block quotes/callouts. return traverseNodes(slice.content.firstChild) + } else { + return '' // empty fragment } } @@ -129,15 +145,17 @@ const Markdown = Extension.create({ * @param schema.nodes * @param schema.marks */ -function createMarkdownSerializer({ nodes, marks }) { +function createMarkdownSerializer(schema: { + nodes: Record + marks: Record +}) { return { serializer: new MarkdownSerializer( - extractNodesToMarkdown(nodes), - extractMarksToMarkdown(marks), + extractNodesToMarkdown(schema.nodes), + extractMarksToMarkdown(schema.marks), ), - serialize(content, options) { + serialize(content: Node) { return this.serializer.serialize(content, { - ...options, tightLists: true, }) }, @@ -146,19 +164,16 @@ function createMarkdownSerializer({ nodes, marks }) { /** * - * @param root0 - * @param root0.nodes - * @param root0.marks + * @param schema or the editorc */ -function clipboardSerializer({ nodes, marks }) { +function clipboardSerializer(schema: Schema) { return { serializer: new MarkdownSerializer( - extractNodesToMarkdown(nodes), - extractToPlaintext(marks), + extractNodesToMarkdown(schema.nodes), + extractToPlaintext(schema.marks), ), - serialize(content, options) { + serialize(content: Node) { return this.serializer.serialize(content, { - ...options, tightLists: true, }) }, @@ -169,7 +184,7 @@ function clipboardSerializer({ nodes, marks }) { * * @param marks */ -function extractToPlaintext(marks) { +function extractToPlaintext(marks: Record) { const blankMark = { open: '', close: '', @@ -186,7 +201,7 @@ function extractToPlaintext(marks) { * * @param nodesOrMarks */ -function extractToMarkdown(nodesOrMarks) { +function extractToMarkdown(nodesOrMarks: Record) { const nodeOrMarkEntries = Object.entries(nodesOrMarks) .map(([name, nodeOrMark]) => [name, nodeOrMark.spec.toMarkdown]) .filter(([, toMarkdown]) => toMarkdown) @@ -198,7 +213,7 @@ function extractToMarkdown(nodesOrMarks) { * * @param nodes */ -function extractNodesToMarkdown(nodes) { +function extractNodesToMarkdown(nodes: Record) { const defaultNodes = convertNames(defaultMarkdownSerializer.nodes) const nodesToMarkdown = extractToMarkdown(nodes) return { ...defaultNodes, ...nodesToMarkdown } @@ -208,21 +223,24 @@ function extractNodesToMarkdown(nodes) { * * @param marks */ -function extractMarksToMarkdown(marks) { +function extractMarksToMarkdown(marks: Record) { const defaultMarks = convertNames(defaultMarkdownSerializer.marks) const marksToMarkdown = extractToMarkdown(marks) return { ...defaultMarks, ...marksToMarkdown } } +type NodeSerializerSpecs = typeof defaultMarkdownSerializer.nodes +type MarkSerializerSpecs = typeof defaultMarkdownSerializer.marks + /** * - * @param object + * @param specs */ -function convertNames(object) { - const convert = (name) => { +function convertNames(specs: NodeSerializerSpecs | MarkSerializerSpecs) { + const convert = (name: string) => { return name.replace(/_(\w)/g, (_m, letter) => letter.toUpperCase()) } - return Object.fromEntries(Object.entries(object).map(([name, value]) => [convert(name), value])) + return Object.fromEntries(Object.entries(specs).map(([name, value]) => [convert(name), value])) } export { createMarkdownSerializer } diff --git a/src/tests/nodes/Mathematics.spec.js b/src/tests/nodes/Mathematics.spec.ts similarity index 72% rename from src/tests/nodes/Mathematics.spec.js rename to src/tests/nodes/Mathematics.spec.ts index 84a6215259b..331940a97eb 100644 --- a/src/tests/nodes/Mathematics.spec.js +++ b/src/tests/nodes/Mathematics.spec.ts @@ -4,21 +4,12 @@ */ import { getExtensionField } from '@tiptap/core' -import { test as baseTest } from 'vitest' -import { createRichEditor } from '../../EditorFactory.ts' -import { createMarkdownSerializer } from '../../extensions/Markdown.js' +import { describe, expect } from 'vitest' import markdownit from '../../markdownit/index.js' import { MathBlock, MathInline } from '../../nodes/Mathematics.js' -import { markdownThroughEditor } from '../testHelpers/markdown.js' - -const test = baseTest.extend({ - // eslint-disable-next-line no-empty-pattern - editor: async ({}, use) => { - const editor = createRichEditor() - await use(editor) - editor.destroy() - }, -}) +import testEditor from '../testHelpers/testEditor.ts' + +const test = testEditor.override('extensions', [MathBlock, MathInline]) describe('Mathematics nodes', () => { describe('Node structure', () => { @@ -38,38 +29,38 @@ describe('Mathematics nodes', () => { }) describe('Markdown roundtrip - Inline math', () => { - test('simple inline formula', () => { + test('simple inline formula', ({ markdownThroughEditor }) => { expect(markdownThroughEditor('$E=mc^2$')).toBe('$E=mc^2$') }) - test('inline formula with complex LaTeX', () => { + test('inline formula with complex LaTeX', ({ markdownThroughEditor }) => { expect(markdownThroughEditor('$\\sum_{i=1}^n i$')).toBe('$\\sum_{i=1}^n i$') }) - test('inline formula with fractions', () => { + test('inline formula with fractions', ({ markdownThroughEditor }) => { expect(markdownThroughEditor('$\\frac{a}{b}$')).toBe('$\\frac{a}{b}$') }) - test('multiple inline formulas', () => { + test('multiple inline formulas', ({ markdownThroughEditor }) => { expect(markdownThroughEditor('$a$ and $b$')).toBe('$a$ and $b$') }) - test('inline formula in text', () => { + test('inline formula in text', ({ markdownThroughEditor }) => { expect(markdownThroughEditor('The formula $E=mc^2$ is famous.')).toBe('The formula $E=mc^2$ is famous.') }) }) describe('Markdown roundtrip - Block math', () => { - test('simple block formula', () => { + test('simple block formula', ({ markdownThroughEditor }) => { expect(markdownThroughEditor('$$\nE=mc^2\n$$')).toBe('$$\nE=mc^2\n$$') }) - test('block formula with complex LaTeX', () => { + test('block formula with complex LaTeX', ({ markdownThroughEditor }) => { const input = '$$\n\\sum_{i=1}^n i = \\frac{n(n+1)}{2}\n$$' expect(markdownThroughEditor(input)).toBe(input) }) - test('block formula without newlines', () => { + test('block formula without newlines', ({ markdownThroughEditor }) => { // markdown-it-katex accepts this format too expect(markdownThroughEditor('$$E=mc^2$$')).toBe('$$\nE=mc^2\n$$') }) @@ -81,8 +72,8 @@ describe('Mathematics nodes', () => { editor.commands.insertInlineMath({ latex: '' }) // Inline nodes are inside paragraphs - const paragraph = editor.state.doc.firstChild - const mathNode = paragraph.firstChild + const paragraph = editor.state.doc.firstChild! + const mathNode = paragraph.firstChild! expect(mathNode.type.name).toBe('inlineMath') expect(mathNode.attrs.latex).toBe('') }) @@ -100,8 +91,8 @@ describe('Mathematics nodes', () => { editor.commands.insertInlineMath({ latex }) // Inline nodes are inside paragraphs - const paragraph = editor.state.doc.firstChild - const mathNode = paragraph.firstChild + const paragraph = editor.state.doc.firstChild! + const mathNode = paragraph.firstChild! expect(mathNode.type.name).toBe('inlineMath') expect(mathNode.attrs.latex).toBe('E=mc^2') }) @@ -111,7 +102,7 @@ describe('Mathematics nodes', () => { editor.commands.insertBlockMath({ latex: '' }) // Should have a blockMath node - const node = editor.state.doc.firstChild + const node = editor.state.doc.firstChild! expect(node.type.name).toBe('blockMath') expect(node.attrs.latex).toBe('') }) @@ -129,7 +120,7 @@ describe('Mathematics nodes', () => { editor.commands.insertBlockMath({ latex }) // Should have a blockMath node with the selected text - const node = editor.state.doc.firstChild + const node = editor.state.doc.firstChild! expect(node.type.name).toBe('blockMath') expect(node.attrs.latex).toBe('E=mc^2') }) @@ -152,23 +143,15 @@ describe('Mathematics nodes', () => { }) describe('Serialization to markdown', () => { - test('serializes inline math node', ({ editor }) => { + test('serializes inline math node', ({ editor, serializeMarkdown }) => { editor.commands.insertInlineMath({ latex: 'E=mc^2' }) editor.commands.insertContent(' some more text.') - - const serializer = createMarkdownSerializer(editor.schema) - const markdown = serializer.serialize(editor.state.doc) - - expect(markdown).toBe('$E=mc^2$ some more text.') + expect(serializeMarkdown()).toBe('$E=mc^2$ some more text.') }) - test('serializes block math node', ({ editor }) => { + test('serializes block math node', ({ editor, serializeMarkdown }) => { editor.commands.insertBlockMath({ latex: 'E=mc^2' }) - - const serializer = createMarkdownSerializer(editor.schema) - const markdown = serializer.serialize(editor.state.doc) - - expect(markdown).toBe('$$\nE=mc^2\n$$') + expect(serializeMarkdown()).toBe('$$\nE=mc^2\n$$') }) }) }) diff --git a/src/tests/testHelpers/createCustomEditor.ts b/src/tests/testHelpers/createCustomEditor.ts index 738c0d7f357..7fef20ebd67 100644 --- a/src/tests/testHelpers/createCustomEditor.ts +++ b/src/tests/testHelpers/createCustomEditor.ts @@ -8,6 +8,7 @@ import type { Content, Extensions } from '@tiptap/core' import { Editor } from '@tiptap/core' import { Document } from '@tiptap/extension-document' import { Text } from '@tiptap/extension-text' +import Markdown from '../../extensions/Markdown.js' import Paragraph from '../../nodes/Paragraph.js' /** @@ -21,6 +22,6 @@ export default function createCustomEditor( ): Editor { return new Editor({ content, - extensions: [Document, Paragraph, Text, ...extensions], + extensions: [Markdown, Document, Paragraph, Text, ...extensions], }) } diff --git a/src/tests/testHelpers/testEditor.ts b/src/tests/testHelpers/testEditor.ts new file mode 100644 index 00000000000..977fcd85da8 --- /dev/null +++ b/src/tests/testHelpers/testEditor.ts @@ -0,0 +1,33 @@ +import type { Editor, Extensions } from '@tiptap/core' + +import { test as baseTest } from 'vitest' +import { createMarkdownSerializer } from '../../extensions/Markdown.js' +import markdownit from '../../markdownit/index.js' +import createCustomEditor from './createCustomEditor.js' + +export default baseTest.extend<{ + editor: Editor + extensions: Extensions + markdownThroughEditor: (input: string) => string + serializeMarkdown: () => string +}>({ + editor: async ({ extensions }, use) => { + const editor = createCustomEditor('', extensions) + await use(editor) + editor.destroy() + }, + extensions: [], + markdownThroughEditor: async ({ editor, serializeMarkdown }, use) => { + use((markdown: string) => { + const content = markdownit.render(markdown) + editor.commands.setContent(content) + return serializeMarkdown() + }) + }, + serializeMarkdown: async ({ editor }, use) => { + use(() => { + const serializer = createMarkdownSerializer(editor.schema) + return serializer.serialize(editor.state.doc) + }) + }, +})