Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 46 additions & 28 deletions src/extensions/Markdown.js → src/extensions/Markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

/* eslint-disable jsdoc/require-param-type */
/* eslint-disable jsdoc/require-param-description */

/*
Expand All @@ -24,13 +23,28 @@
* 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'
import { defaultMarkdownSerializer, MarkdownSerializer } from 'prosemirror-markdown'
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',

Expand Down Expand Up @@ -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
Expand All @@ -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
}
}

Expand All @@ -129,15 +145,17 @@ const Markdown = Extension.create({
* @param schema.nodes
* @param schema.marks
*/
function createMarkdownSerializer({ nodes, marks }) {
function createMarkdownSerializer(schema: {
nodes: Record<string, NodeType>
marks: Record<string, MarkType>
}) {
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,
})
},
Expand All @@ -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,
})
},
Expand All @@ -169,7 +184,7 @@ function clipboardSerializer({ nodes, marks }) {
*
* @param marks
*/
function extractToPlaintext(marks) {
function extractToPlaintext(marks: Record<string, MarkType>) {
const blankMark = {
open: '',
close: '',
Expand All @@ -186,7 +201,7 @@ function extractToPlaintext(marks) {
*
* @param nodesOrMarks
*/
function extractToMarkdown(nodesOrMarks) {
function extractToMarkdown(nodesOrMarks: Record<string, (NodeType | MarkType)>) {
const nodeOrMarkEntries = Object.entries(nodesOrMarks)
.map(([name, nodeOrMark]) => [name, nodeOrMark.spec.toMarkdown])
.filter(([, toMarkdown]) => toMarkdown)
Expand All @@ -198,7 +213,7 @@ function extractToMarkdown(nodesOrMarks) {
*
* @param nodes
*/
function extractNodesToMarkdown(nodes) {
function extractNodesToMarkdown(nodes: Record<string, NodeType>) {
const defaultNodes = convertNames(defaultMarkdownSerializer.nodes)
const nodesToMarkdown = extractToMarkdown(nodes)
return { ...defaultNodes, ...nodesToMarkdown }
Expand All @@ -208,21 +223,24 @@ function extractNodesToMarkdown(nodes) {
*
* @param marks
*/
function extractMarksToMarkdown(marks) {
function extractMarksToMarkdown(marks: Record<string, MarkType>) {
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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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$$')
})
Expand All @@ -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('')
})
Expand All @@ -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')
})
Expand All @@ -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('')
})
Expand All @@ -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')
})
Expand All @@ -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$$')
})
})
})
3 changes: 2 additions & 1 deletion src/tests/testHelpers/createCustomEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand All @@ -21,6 +22,6 @@ export default function createCustomEditor(
): Editor {
return new Editor({
content,
extensions: [Document, Paragraph, Text, ...extensions],
extensions: [Markdown, Document, Paragraph, Text, ...extensions],
})
}
33 changes: 33 additions & 0 deletions src/tests/testHelpers/testEditor.ts
Original file line number Diff line number Diff line change
@@ -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)
})
},
})
Loading