Skip to content

Commit 72e9537

Browse files
authored
Merge pull request #4872 from nextcloud/fix/4602-remove-newlines-in-pastes
fix(paste): collapse whitespace before pasting
2 parents c34d0fb + a958e8a commit 72e9537

File tree

3 files changed

+176
-1
lines changed

3 files changed

+176
-1
lines changed

src/extensions/Markdown.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@
4141
import { Extension, getExtensionField } from '@tiptap/core'
4242
import { Plugin, PluginKey } from '@tiptap/pm/state'
4343
import { MarkdownSerializer, defaultMarkdownSerializer } from '@tiptap/pm/markdown'
44-
import markdownit from '../markdownit/index.js'
4544
import { DOMParser } from '@tiptap/pm/model'
45+
import markdownit from '../markdownit/index.js'
46+
import transformPastedHTML from './transformPastedHTML.js'
4647

4748
const Markdown = Extension.create({
4849

@@ -106,6 +107,7 @@ const Markdown = Extension.create({
106107

107108
return parser.parseSlice(dom, { preserveWhitespace: true, context: $context })
108109
},
110+
transformPastedHTML,
109111
},
110112
}),
111113
]
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* @copyright Copyright (c) 2023 Max <max@nextcloud.com>
3+
*
4+
* @author Max <max@nextcloud.com>
5+
*
6+
* @license AGPL-3.0-or-later
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the GNU Affero General Public License as
10+
* published by the Free Software Foundation, either version 3 of the
11+
* License, or (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* GNU Affero General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Affero General Public License
19+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
20+
*
21+
*/
22+
23+
/**
24+
*
25+
* Prepare pasted html for insertion into tiptap
26+
*
27+
* We render paragraphs with `white-space: pre-wrap`
28+
* so newlines are visible and preserved.
29+
*
30+
* Pasted html may contain newlines inside tags with a different `white-space` style.
31+
* They are not visible in the source.
32+
* Strip them so the pasted result wraps nicely.
33+
*
34+
* At the same time we need to preserve whitespace inside `<pre>` tags
35+
* and the like.
36+
*
37+
* @param {string} html Pasted html content
38+
*/
39+
export default function(html) {
40+
const parser = new DOMParser()
41+
const doc = parser.parseFromString(html, 'text/html')
42+
forAllTextNodes(doc, textNode => {
43+
if (collapseWhiteSpace(textNode)) {
44+
textNode.textContent = textNode.textContent.replaceAll('\n', ' ')
45+
}
46+
})
47+
return doc.body.innerHTML
48+
}
49+
50+
/**
51+
*
52+
* Run function for all text nodes in the document.
53+
*
54+
* @param {Document} doc Html document to process
55+
* @param {Function} fn Function to run
56+
*
57+
*/
58+
function forAllTextNodes(doc, fn) {
59+
const nodeIterator = doc.createNodeIterator(
60+
doc.body,
61+
NodeFilter.SHOW_TEXT,
62+
)
63+
let currentNode = nodeIterator.nextNode()
64+
while (currentNode) {
65+
fn(currentNode)
66+
currentNode = nodeIterator.nextNode()
67+
}
68+
}
69+
70+
/**
71+
*
72+
* Check if newlines need to be collapsed based on the applied style
73+
*
74+
* @param {Text} textNode Text to check the style for
75+
*
76+
*/
77+
function collapseWhiteSpace(textNode) {
78+
// Values of `white-space` css that will collapse newline whitespace
79+
// See https://developer.mozilla.org/en-US/docs/Web/CSS/white-space#values
80+
const COLLAPSING_WHITE_SPACE_VALUES = ['normal', 'nowrap']
81+
let ancestor = textNode.parentElement
82+
while (ancestor) {
83+
// Chrome does not support getComputedStyle on detached dom
84+
// https://lists.w3.org/Archives/Public/www-style/2018May/0031.html
85+
// Therefore the following logic only works on Firefox
86+
const style = getComputedStyle(ancestor)
87+
const whiteSpace = style?.getPropertyValue('white-space')
88+
if (whiteSpace) {
89+
// Returns false if white-space has a value not listed in COLLAPSING_WHITE_SPACE_VALUES
90+
return COLLAPSING_WHITE_SPACE_VALUES.includes(whiteSpace)
91+
}
92+
93+
// Check for `tagName` as fallback on Chrome
94+
if (ancestor.tagName === 'PRE') {
95+
return false
96+
}
97+
ancestor = ancestor.parentElement
98+
}
99+
return true
100+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import transformPastedHTML from './../../extensions/transformPastedHTML.js'
2+
3+
describe('transformPastedHTML', () => {
4+
5+
// alias so the strings line up nicely
6+
const tr = transformPastedHTML
7+
8+
it('strips newlines from input', () => {
9+
expect(tr('a\nb\n\nc'))
10+
.toBe('a b c')
11+
})
12+
13+
it('strips newlines from input', () => {
14+
expect(tr('a\nb\n\nc'))
15+
.toBe('a b c')
16+
})
17+
18+
it('strips newlines from tags', () => {
19+
expect(tr('<p>a\nb</p>'))
20+
.toBe('<p>a b</p>')
21+
})
22+
23+
it('preserve newlines in pre tags', () => {
24+
expect(tr('<pre>a\nb</pre>'))
25+
.toBe('<pre>a\nb</pre>')
26+
})
27+
28+
it('strips newlines in tags with white-space: normal', () => {
29+
expect(tr('<div style="white-space: normal;">a\nb</div>'))
30+
.toBe('<div style="white-space: normal;">a b</div>')
31+
})
32+
33+
it('strips newlines in tags with white-space: nowrap', () => {
34+
expect(tr('<div style="white-space: nowrap;">a\nb</div>'))
35+
.toBe('<div style="white-space: nowrap;">a b</div>')
36+
})
37+
38+
it('preserves newlines in tags with white-space: pre', () => {
39+
expect(tr('<div style="white-space: pre;">a\nb</div>'))
40+
.toBe('<div style="white-space: pre;">a\nb</div>')
41+
})
42+
43+
it('preserve newlines in tags with white-space: pre-wrap', () => {
44+
expect(tr('<div style="white-space: pre-wrap;">a\nb</div>'))
45+
.toBe('<div style="white-space: pre-wrap;">a\nb</div>')
46+
})
47+
48+
it('preserve newlines in tags with white-space: pre-line', () => {
49+
expect(tr('<div style="white-space: pre-line;">a\nb</div>'))
50+
.toBe('<div style="white-space: pre-line;">a\nb</div>')
51+
})
52+
53+
it('preserve newlines in tags with white-space: break-spaces', () => {
54+
expect(tr('<div style="white-space: break-spaces;">a\nb</div>'))
55+
.toBe('<div style="white-space: break-spaces;">a\nb</div>')
56+
})
57+
58+
it('handles different tags', () => {
59+
expect(tr('<pre>a\nb</pre><p>c\nd</p>'))
60+
.toBe('<pre>a\nb</pre><p>c d</p>')
61+
})
62+
63+
it('preserve newlines in nested code blocks', () => {
64+
expect(tr('<pre><code>this\nis code\nplease preserve\n whitespace!\nThanks</code></pre>'))
65+
.toBe('<pre><code>this\nis code\nplease preserve\n whitespace!\nThanks</code></pre>')
66+
})
67+
68+
it('preserve newlines in deep nested code blocks', () => {
69+
expect(tr('<pre><code><em>this\nis code</em>\nplease preserve\n whitespace!\nThanks</code></pre>'))
70+
.toBe('<pre><code><em>this\nis code</em>\nplease preserve\n whitespace!\nThanks</code></pre>')
71+
})
72+
73+
})

0 commit comments

Comments
 (0)