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; +}