diff --git a/lib/Http/HtmlResponse.php b/lib/Http/HtmlResponse.php
index faa111a020..da27f1582b 100644
--- a/lib/Http/HtmlResponse.php
+++ b/lib/Http/HtmlResponse.php
@@ -58,6 +58,6 @@ public function render(): string {
return $this->content;
}
- return '
' . $this->content . '';
+ return '' . $this->content . '';
}
}
diff --git a/lib/Service/Html.php b/lib/Service/Html.php
index 1376d88d7d..4edd967a74 100755
--- a/lib/Service/Html.php
+++ b/lib/Service/Html.php
@@ -163,6 +163,11 @@ public function sanitizeHtmlMailBody(int $messageId, string $mailBody, array $in
// Rewrite URL for redirection and proxying of content
/** @var HTMLPurifier_HTMLDefinition $def */
$def = $config->getHTMLDefinition(true);
+
+ // HTMLPurifier only knows XHTML 1.0, which predates these
+ $def->addElement('figure', 'Block', 'Flow', 'Common');
+ $def->addElement('figcaption', 'Block', 'Flow', 'Common');
+
$def->info_attr_transform_post['imagesrc'] = new TransformImageSrc($this->urlGenerator);
$def->info_attr_transform_post['cssbackground'] = new TransformStyleURLs($this->urlGenerator);
$def->info_attr_transform_post['htmllinks'] = new TransformHTMLLinks();
diff --git a/src/ckeditor/image/ImageDowncastPlugin.ts b/src/ckeditor/image/ImageDowncastPlugin.ts
index e8448899dd..8cfc65d9b5 100644
--- a/src/ckeditor/image/ImageDowncastPlugin.ts
+++ b/src/ckeditor/image/ImageDowncastPlugin.ts
@@ -7,6 +7,19 @@ import type { ViewDocumentFragment, ViewElement } from 'ckeditor5'
import { ImageUtils, Plugin, UpcastWriter } from 'ckeditor5'
+/**
+ * Alignment as inline styles, keyed by the class the image style feature writes.
+ *
+ * Physical margins because the Word engine behind Outlook ignores margin-inline.
+ * The margins place a figure narrower than the available space, text-align
+ * places the image inside a full width figure.
+ */
+const ALIGNMENTS: Record> = {
+ 'image-style-align-center': { 'margin-left': 'auto', 'margin-right': 'auto', 'text-align': 'center' },
+ 'image-style-block-align-right': { 'margin-left': 'auto', 'margin-right': '0', 'text-align': 'right' },
+ 'image-style-block-align-left': { 'margin-left': '0', 'margin-right': 'auto', 'text-align': 'left' },
+}
+
/**
* Parse a CSS length into whole pixels. Anything but an absolute pixel value
* yields null.
@@ -49,6 +62,10 @@ export default class ImageDowncastPlugin extends Plugin {
const writer = new UpcastWriter(fragment.document)
for (const { item } of writer.createRangeIn(fragment)) {
+ if (item.is('element', 'figure') && item.hasClass('image')) {
+ this._inlineAlignment(writer, item)
+ }
+
// A block image carries the resized width on its figure, an inline
// one on the img itself.
if ((item.is('element', 'figure') && item.hasClass('image')) || item.is('element', 'img')) {
@@ -58,6 +75,36 @@ export default class ImageDowncastPlugin extends Plugin {
}, { priority: 'low' })
}
+ /**
+ * Adds the inline styles for the figure's alignment class, which is backed by
+ * an editor stylesheet recipients never load. The class stays so that
+ * reopening the draft restores the active alignment.
+ *
+ * @param writer view writer of the data view
+ * @param figure the figure to align
+ */
+ _inlineAlignment(writer: UpcastWriter, figure: ViewElement): void {
+ // Fall back to left: without a class the figure keeps the client's own margins.
+ const className = Object.keys(ALIGNMENTS).find((candidate) => figure.hasClass(candidate))
+ ?? 'image-style-block-align-left'
+ const alignment = ALIGNMENTS[className]
+
+ writer.setStyle(alignment, figure)
+
+ const image = this.editor.plugins.get('ImageUtils').findViewImgElement(figure)
+ if (image === undefined) {
+ return
+ }
+
+ // Clients that predate drop the tag and keep the img, so the img
+ // has to align itself. Auto margins only move a block element.
+ writer.setStyle({
+ display: 'block',
+ 'margin-left': alignment['margin-left'],
+ 'margin-right': alignment['margin-right'],
+ }, image)
+ }
+
/**
* Mirrors a resized image's CSS width onto the img width attribute, which
* clients that drop CSS still honour. Reopening the message reads that width
diff --git a/src/components/MessageHTMLBody.vue b/src/components/MessageHTMLBody.vue
index 42ae14684a..20e303a775 100644
--- a/src/components/MessageHTMLBody.vue
+++ b/src/components/MessageHTMLBody.vue
@@ -118,6 +118,7 @@ export default {
license: 'GPLv3',
log: false,
scrolling: true,
+ warningTimeout: 10000,
}, this.$refs.iframe)
this.detectedForeignLanguage = await detectForeignLanguage(this.message.body ?? '')
diff --git a/src/components/TextEditor.vue b/src/components/TextEditor.vue
index 3b9a54b139..040ba1d098 100644
--- a/src/components/TextEditor.vue
+++ b/src/components/TextEditor.vue
@@ -39,6 +39,8 @@ import {
Heading,
Image,
ImageResize,
+ ImageStyle,
+ ImageToolbar,
ImageUpload,
Italic,
Link,
@@ -152,6 +154,8 @@ export default {
Image,
ImageUpload,
ImageResize,
+ ImageStyle,
+ ImageToolbar,
FilesImagePlugin,
ImageDowncastPlugin,
Font,
@@ -206,6 +210,15 @@ export default {
image: {
// A percentage would be relative to the recipient's unknown viewport.
resizeUnit: 'px',
+ styles: {
+ options: ['alignBlockLeft', 'alignCenter', 'alignBlockRight'],
+ },
+
+ toolbar: [
+ 'imageStyle:alignBlockLeft',
+ 'imageStyle:alignCenter',
+ 'imageStyle:alignBlockRight',
+ ],
},
mention: {
@@ -718,6 +731,12 @@ export default {
cursor: text;
margin: 0 !important;
}
+
+/* CKEditor centres a block image without a style class; match the left aligned
+ option, which is what ImageDowncast writes for it. */
+:deep(.ck-content .image:not([class*='image-style'])) {
+ margin-inline: 0 auto;
+}