Skip to content

Commit 65771da

Browse files
committed
fix(composer): Fix paragraph breaks getting lost on paste and sending
AI-assisted: GitHub Copilot (Claude Sonnet 4.6) Signed-off-by: Jan C. Borchardt <925062+jancborchardt@users.noreply.github.com>
1 parent b37655d commit 65771da

7 files changed

Lines changed: 42 additions & 94 deletions

File tree

lib/Service/MimeMessage.php

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
use DOMDocument;
1313
use DOMElement;
14-
use DOMNode;
1514
use Horde_Mime_Part;
1615
use Horde_Text_Filter;
1716
use OCA\Mail\Exception\InvalidDataUriException;
@@ -100,7 +99,7 @@ private function buildMessagePart(?string $contentPlain, ?string $contentHtml, a
10099
$plainPart->setType('text/plain');
101100
$plainPart->setCharset('UTF-8');
102101
$plainPart->setContents(
103-
Horde_Text_Filter::filter($contentHtml, 'Html2text', ['callback' => [$this, 'htmlToTextCallback']])
102+
Horde_Text_Filter::filter($contentHtml, 'Html2text', [])
104103
);
105104
}
106105

@@ -258,23 +257,4 @@ private function separateAttachments(array $attachments): array {
258257

259258
return [$inline, $normal];
260259
}
261-
262-
/**
263-
* A callback for Horde_Text_Filter.
264-
*
265-
* The purpose of this callback is to overwrite the default behavior
266-
* of html2text filter to convert <p>Hello</p> => Hello\n\n with
267-
* <p>Hello</p> => Hello\n.
268-
*
269-
* @param DOMDocument $doc
270-
* @param DOMNode $node
271-
* @return string|null non-null, add this text to the output and skip further processing of the node.
272-
*/
273-
public function htmlToTextCallback(DOMDocument $doc, DOMNode $node) {
274-
if ($node instanceof DOMElement && strtolower($node->tagName) === 'p') {
275-
return $node->textContent . "\n";
276-
}
277-
278-
return null;
279-
}
280260
}

src/ckeditor/mail/MailPlugin.js

Lines changed: 0 additions & 36 deletions
This file was deleted.

src/components/TextEditor.vue

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ import {
5454
} from 'ckeditor5'
5555
import { getLinkWithPicker, searchProvider } from '@nextcloud/vue/components/NcRichText'
5656
import TextDirectionPlugin from '../ckeditor/direction/TextDirectionPlugin.js'
57-
import MailPlugin from '../ckeditor/mail/MailPlugin.js'
5857
import QuotePlugin from '../ckeditor/quote/QuotePlugin.js'
5958
import SignaturePlugin from '../ckeditor/signature/SignaturePlugin.js'
6059
import PickerPlugin from '../ckeditor/smartpicker/PickerPlugin.js'
@@ -153,7 +152,6 @@ export default {
153152
Font,
154153
RemoveFormat,
155154
Base64UploadAdapter,
156-
MailPlugin,
157155
SourceEditing,
158156
TextDirectionPlugin,
159157
])
@@ -671,6 +669,11 @@ export default {
671669
cursor: text;
672670
margin: 0 !important;
673671
}
672+
673+
// Show empty lines correctly in composer
674+
:deep(p + p) {
675+
margin-top: 1em !important;
676+
}
674677
</style>
675678
676679
<style>

src/tests/unit/components/MailPlugin.spec.js

Lines changed: 0 additions & 23 deletions
This file was deleted.

src/tests/unit/components/TextEditor.spec.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { Paragraph } from 'ckeditor5'
88
import mitt from 'mitt'
99
import { vi } from 'vitest'
1010
import TextEditor from '../../../components/TextEditor.vue'
11-
import MailPlugin from '../../../ckeditor/mail/MailPlugin.js'
1211
import Nextcloud from '../../../mixins/Nextcloud.js'
1312
import VirtualTestEditor from '../../virtualtesteditor.js'
1413

@@ -90,7 +89,7 @@ describe('TextEditor', () => {
9089

9190
expect(wrapper.emitted().ready[0]).toBeTruthy()
9291
})
93-
it('register conversion to add margin: 0px to every <p> element', async () => {
92+
it('does not add inline margin style to paragraph elements', async () => {
9493
const wrapper = shallowMount(TextEditor, {
9594
localVue,
9695
propsData: {
@@ -106,7 +105,7 @@ describe('TextEditor', () => {
106105
const editor = await VirtualTestEditor.create({
107106
licenseKey: 'GPL',
108107
initialData: '<p>bonjour bonjour</p>',
109-
plugins: [Paragraph, MailPlugin],
108+
plugins: [Paragraph],
110109
})
111110

112111
editor.ui = {
@@ -123,7 +122,7 @@ describe('TextEditor', () => {
123122

124123
expect(wrapper.emitted().ready[0]).toBeTruthy()
125124
expect(wrapper.emitted().ready[0][0].getData())
126-
.toEqual('<p style="margin:0;">bonjour bonjour</p>')
125+
.toEqual('<p>bonjour bonjour</p>')
127126
})
128127

129128
it('emits updated data while editing source', async () => {

src/tests/unit/util/text.spec.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@ describe('text', () => {
8787
expect(actual).toEqual(expected)
8888
})
8989

90-
it('produces a single line break between paragraphs', () => {
90+
it('produces a blank line between paragraphs', () => {
9191
const source = html('<p>hello</p><p>world</p>')
92-
const expected = plain('hello\nworld')
92+
const expected = plain('hello\n\nworld')
9393

9494
const actual = toPlain(source)
9595

@@ -106,7 +106,7 @@ describe('text', () => {
106106
})
107107

108108
it('produces a single line break after each block element', () => {
109-
const selectors = ['p', 'div', 'header', 'footer', 'form', 'article', 'aside', 'main', 'nav', 'section']
109+
const selectors = ['div', 'header', 'footer', 'form', 'article', 'aside', 'main', 'nav', 'section']
110110
const source = html(selectors
111111
.map((tag) => `<${tag}>foobar</${tag}>`)
112112
.join(''))
@@ -118,7 +118,7 @@ describe('text', () => {
118118
})
119119

120120
it('produces exactly one line break for each closing block element', () => {
121-
const selectors = ['p', 'div', 'header', 'footer', 'form', 'article', 'aside', 'main', 'nav', 'section']
121+
const selectors = ['div', 'header', 'footer', 'form', 'article', 'aside', 'main', 'nav', 'section']
122122
const source = html(selectors
123123
.map((tag) => `<${tag}><${tag}>foobar</${tag}></${tag}>`)
124124
.join(''))
@@ -142,7 +142,7 @@ describe('text', () => {
142142
const source = html('<html>'
143143
+ '<body><p>Hello!</p><p>this <i>is</i> <b>some</b> random <strong>text</strong></p></body>'
144144
+ '</html>')
145-
const expected = plain('Hello!\nthis is some random text')
145+
const expected = plain('Hello!\n\nthis is some random text')
146146

147147
const actual = toPlain(source)
148148

src/util/text.js

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,10 @@ export function toPlain(text) {
100100
return text
101101
}
102102

103-
// Build shared options for all block tags
104-
const blockTags = ['p', 'div', 'header', 'footer', 'form', 'article', 'aside', 'main', 'nav', 'section']
105-
const blockSelectors = blockTags.map((tag) => ({
103+
// Build shared options for non-paragraph block tags.
104+
// <p> is handled separately by customParagraph below.
105+
const nonParagraphBlockTags = ['div', 'header', 'footer', 'form', 'article', 'aside', 'main', 'nav', 'section']
106+
const blockSelectors = nonParagraphBlockTags.map((tag) => ({
106107
selector: tag,
107108
format: 'customBlock',
108109
options: {
@@ -130,6 +131,23 @@ export function toPlain(text) {
130131
// each closing tag.
131132
builder.addLineBreak()
132133
},
134+
// <p> is a paragraph element: two forced line breaks produce the blank
135+
// line that separates paragraphs in plain text, matching email convention
136+
// where a paragraph break equals one blank line (i.e. \n\n).
137+
customParagraph(elem, walk, builder, formatOptions) {
138+
builder.openBlock({
139+
isPre: formatOptions.preserveLeadingWhitespace,
140+
leadingLineBreaks: 0,
141+
})
142+
walk(elem.children, builder)
143+
builder.closeBlock({
144+
trailingLineBreaks: 0,
145+
blockTransform: (text) => text
146+
.replace(/^ {2,}/gm, ' '),
147+
})
148+
builder.addLineBreak()
149+
builder.addLineBreak()
150+
},
133151
customBlockQuote(elem, walk, builder, formatOptions) {
134152
builder.openBlock({
135153
leadingLineBreaks: formatOptions.leadingLineBreaks,
@@ -163,6 +181,13 @@ export function toPlain(text) {
163181
trailingLineBreaks: 1,
164182
},
165183
},
184+
{
185+
selector: 'p',
186+
format: 'customParagraph',
187+
options: {
188+
preserveLeadingWhitespace: true,
189+
},
190+
},
166191
...blockSelectors,
167192
],
168193
})

0 commit comments

Comments
 (0)