diff --git a/src/extensions/Markdown.js b/src/extensions/Markdown.js index b7535576c36..9ad6c7c503e 100644 --- a/src/extensions/Markdown.js +++ b/src/extensions/Markdown.js @@ -41,8 +41,9 @@ import { Extension, getExtensionField } from '@tiptap/core' import { Plugin, PluginKey } from '@tiptap/pm/state' import { MarkdownSerializer, defaultMarkdownSerializer } from '@tiptap/pm/markdown' -import markdownit from '../markdownit/index.js' import { DOMParser } from '@tiptap/pm/model' +import markdownit from '../markdownit/index.js' +import transformPastedHTML from './transformPastedHTML.js' const Markdown = Extension.create({ @@ -106,6 +107,7 @@ const Markdown = Extension.create({ return parser.parseSlice(dom, { preserveWhitespace: true, context: $context }) }, + transformPastedHTML, }, }), ] diff --git a/src/extensions/transformPastedHTML.js b/src/extensions/transformPastedHTML.js new file mode 100644 index 00000000000..c6e89cd95f4 --- /dev/null +++ b/src/extensions/transformPastedHTML.js @@ -0,0 +1,100 @@ +/** + * @copyright Copyright (c) 2023 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 . + * + */ + +/** + * + * Prepare pasted html for insertion into tiptap + * + * We render paragraphs with `white-space: pre-wrap` + * so newlines are visible and preserved. + * + * Pasted html may contain newlines inside tags with a different `white-space` style. + * They are not visible in the source. + * Strip them so the pasted result wraps nicely. + * + * At the same time we need to preserve whitespace inside `
` tags
+ * and the like.
+ *
+ * @param {string} html Pasted html content
+ */
+export default function(html) {
+	const parser = new DOMParser()
+	const doc = parser.parseFromString(html, 'text/html')
+	forAllTextNodes(doc, textNode => {
+		if (collapseWhiteSpace(textNode)) {
+			textNode.textContent = textNode.textContent.replaceAll('\n', ' ')
+		}
+	})
+	return doc.body.innerHTML
+}
+
+/**
+ *
+ * Run function for all text nodes in the document.
+ *
+ * @param {Document} doc Html document to process
+ * @param {Function} fn Function to run
+ *
+ */
+function forAllTextNodes(doc, fn) {
+	const nodeIterator = doc.createNodeIterator(
+		doc.body,
+		NodeFilter.SHOW_TEXT,
+	)
+	let currentNode = nodeIterator.nextNode()
+	while (currentNode) {
+		fn(currentNode)
+		currentNode = nodeIterator.nextNode()
+	}
+}
+
+/**
+ *
+ * Check if newlines need to be collapsed based on the applied style
+ *
+ * @param {Text} textNode Text to check the style for
+ *
+ */
+function collapseWhiteSpace(textNode) {
+	// Values of `white-space` css that will collapse newline whitespace
+	// See https://developer.mozilla.org/en-US/docs/Web/CSS/white-space#values
+	const COLLAPSING_WHITE_SPACE_VALUES = ['normal', 'nowrap']
+	let ancestor = textNode.parentElement
+	while (ancestor) {
+		// Chrome does not support getComputedStyle on detached dom
+		// https://lists.w3.org/Archives/Public/www-style/2018May/0031.html
+		// Therefore the following logic only works on Firefox
+		const style = getComputedStyle(ancestor)
+		const whiteSpace = style?.getPropertyValue('white-space')
+		if (whiteSpace) {
+			// Returns false if white-space has a value not listed in COLLAPSING_WHITE_SPACE_VALUES
+			return COLLAPSING_WHITE_SPACE_VALUES.includes(whiteSpace)
+		}
+
+		// Check for `tagName` as fallback on Chrome
+		if (ancestor.tagName === 'PRE') {
+			return false
+		}
+		ancestor = ancestor.parentElement
+	}
+	return true
+}
diff --git a/src/tests/extensions/transformPastedHTML.spec.js b/src/tests/extensions/transformPastedHTML.spec.js
new file mode 100644
index 00000000000..d43ab5933de
--- /dev/null
+++ b/src/tests/extensions/transformPastedHTML.spec.js
@@ -0,0 +1,73 @@
+import transformPastedHTML from './../../extensions/transformPastedHTML.js'
+
+describe('transformPastedHTML', () => {
+
+	// alias so the strings line up nicely
+	const tr = transformPastedHTML
+
+	it('strips newlines from input', () => {
+		expect(tr('a\nb\n\nc'))
+			.toBe('a b  c')
+	})
+
+	it('strips newlines from input', () => {
+		expect(tr('a\nb\n\nc'))
+			.toBe('a b  c')
+	})
+
+	it('strips newlines from tags', () => {
+		expect(tr('

a\nb

')) + .toBe('

a b

') + }) + + it('preserve newlines in pre tags', () => { + expect(tr('
a\nb
')) + .toBe('
a\nb
') + }) + + it('strips newlines in tags with white-space: normal', () => { + expect(tr('
a\nb
')) + .toBe('
a b
') + }) + + it('strips newlines in tags with white-space: nowrap', () => { + expect(tr('
a\nb
')) + .toBe('
a b
') + }) + + it('preserves newlines in tags with white-space: pre', () => { + expect(tr('
a\nb
')) + .toBe('
a\nb
') + }) + + it('preserve newlines in tags with white-space: pre-wrap', () => { + expect(tr('
a\nb
')) + .toBe('
a\nb
') + }) + + it('preserve newlines in tags with white-space: pre-line', () => { + expect(tr('
a\nb
')) + .toBe('
a\nb
') + }) + + it('preserve newlines in tags with white-space: break-spaces', () => { + expect(tr('
a\nb
')) + .toBe('
a\nb
') + }) + + it('handles different tags', () => { + expect(tr('
a\nb

c\nd

')) + .toBe('
a\nb

c d

') + }) + + it('preserve newlines in nested code blocks', () => { + expect(tr('
this\nis code\nplease preserve\n  whitespace!\nThanks
')) + .toBe('
this\nis code\nplease preserve\n  whitespace!\nThanks
') + }) + + it('preserve newlines in deep nested code blocks', () => { + expect(tr('
this\nis code\nplease preserve\n  whitespace!\nThanks
')) + .toBe('
this\nis code\nplease preserve\n  whitespace!\nThanks
') + }) + +})