diff --git a/src/ckeditor/image/ImageDowncastPlugin.ts b/src/ckeditor/image/ImageDowncastPlugin.ts new file mode 100644 index 0000000000..e8448899dd --- /dev/null +++ b/src/ckeditor/image/ImageDowncastPlugin.ts @@ -0,0 +1,84 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ViewDocumentFragment, ViewElement } from 'ckeditor5' + +import { ImageUtils, Plugin, UpcastWriter } from 'ckeditor5' + +/** + * Parse a CSS length into whole pixels. Anything but an absolute pixel value + * yields null. + * + * @param value the CSS length to parse + */ +function toPixels(value: string | undefined): number | null { + if (value === undefined) { + return null + } + + const pixels = /^\s*([\d.]+)\s*px\s*$/.exec(value) + if (pixels === null) { + return null + } + + const width = Math.round(Number.parseFloat(pixels[1])) + + return width > 0 ? width : null +} + +/** + * Sizes images in the editor's data output only; the editing view keeps + * CKEditor's own markup. + */ +export default class ImageDowncastPlugin extends Plugin { + static get requires() { + return [ImageUtils] as const + } + + static get pluginName() { + return 'ImageDowncast' as const + } + + init(): void { + // The width and the natural size are written by two separate converters, + // so post-process the finished view instead of overriding either. + this.editor.data.on('toView', (event) => { + const fragment = event.return as ViewDocumentFragment + const writer = new UpcastWriter(fragment.document) + + for (const { item } of writer.createRangeIn(fragment)) { + // 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')) { + this._mirrorResizedWidth(writer, item) + } + } + }, { priority: 'low' }) + } + + /** + * 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 + * back as the image's natural size. + * + * @param writer view writer of the data view + * @param element the figure or img holding the resized width + */ + _mirrorResizedWidth(writer: UpcastWriter, element: ViewElement): void { + const resizedWidth = toPixels(element.getStyle('width')) + if (resizedWidth === null) { + return + } + + const image = this.editor.plugins.get('ImageUtils').findViewImgElement(element) + if (image === undefined) { + return + } + + writer.setAttribute('width', String(resizedWidth), image) + // A natural height next to the smaller width would stretch the image. + writer.removeAttribute('height', image) + } +} diff --git a/src/components/TextEditor.vue b/src/components/TextEditor.vue index 53b9ca4ee2..3b9a54b139 100644 --- a/src/components/TextEditor.vue +++ b/src/components/TextEditor.vue @@ -55,6 +55,7 @@ import { import { getLinkWithPicker, searchProvider } from '@nextcloud/vue/components/NcRichText' import TextDirectionPlugin from '../ckeditor/direction/TextDirectionPlugin.js' import FilesImagePlugin from '../ckeditor/image/FilesImagePlugin.ts' +import ImageDowncastPlugin from '../ckeditor/image/ImageDowncastPlugin.ts' import MailPlugin from '../ckeditor/mail/MailPlugin.js' import QuotePlugin from '../ckeditor/quote/QuotePlugin.js' import SignaturePlugin from '../ckeditor/signature/SignaturePlugin.js' @@ -152,6 +153,7 @@ export default { ImageUpload, ImageResize, FilesImagePlugin, + ImageDowncastPlugin, Font, RemoveFormat, Base64UploadAdapter, @@ -201,6 +203,11 @@ export default { plugins, toolbar, language: 'en', + image: { + // A percentage would be relative to the recipient's unknown viewport. + resizeUnit: 'px', + }, + mention: { feeds: [ { diff --git a/src/tests/unit/ckeditor/image/ImageDowncastPlugin.spec.js b/src/tests/unit/ckeditor/image/ImageDowncastPlugin.spec.js new file mode 100644 index 0000000000..1d5f51908a --- /dev/null +++ b/src/tests/unit/ckeditor/image/ImageDowncastPlugin.spec.js @@ -0,0 +1,112 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { ClassicEditor, ImageBlock, ImageInline, ImageResizeEditing, Paragraph } from 'ckeditor5' +import ImageDowncastPlugin from '../../../../ckeditor/image/ImageDowncastPlugin.ts' + +// The editor UI observes the size of its toolbar, which jsdom does not provide. +window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +} + +/** + * Get the data of an editor initialised with the given content. + * + * @param {string} initialData content to load into the editor + * @return {Promise} the editor's data output + */ +async function downcast(initialData) { + const element = document.createElement('div') + document.body.appendChild(element) + + const editor = await ClassicEditor.create(element, { + licenseKey: 'GPL', + initialData, + plugins: [Paragraph, ImageBlock, ImageInline, ImageResizeEditing, ImageDowncastPlugin], + image: { + resizeUnit: 'px', + }, + }) + + const data = editor.data.get() + await editor.destroy() + element.remove() + + return data +} + +/** + * A 400x300 image resized to the given CSS width. + * + * @param {string} width the CSS width on the figure + * @return {string} the figure markup + */ +function resized(width) { + return `
` + + '' + + '
' +} + +describe('ImageDowncastPlugin', () => { + it('mirrors a resized width onto the width attribute', async () => { + const data = await downcast(resized('200px')) + + expect(data).toContain('width="200"') + expect(data).toContain('width:200px;') + }) + + it('drops the natural height so the image is not stretched', async () => { + const data = await downcast(resized('200px')) + + expect(data).not.toContain('height=') + }) + + it('keeps the aspect ratio the editor writes', async () => { + const data = await downcast(resized('200px')) + + expect(data).toContain('aspect-ratio:400/300;') + }) + + it('keeps the attributes of an image resized to a percentage', async () => { + const data = await downcast(resized('50%')) + + expect(data).toContain('width="400"') + expect(data).toContain('height="300"') + }) + + it('keeps the attributes of an image that was not resized', async () => { + const data = await downcast('
') + + expect(data).toContain('width="400"') + expect(data).toContain('height="300"') + }) + + it('mirrors the width of a linked image', async () => { + const data = await downcast('
' + + '' + + '
') + + expect(data).toContain('width="100"') + expect(data).not.toContain('height=') + }) + + it('mirrors the width of a resized inline image', async () => { + const data = await downcast('

text

') + + expect(data).toContain('width="200"') + expect(data).not.toContain('height=') + }) + + it('keeps the width when the message is reopened and sent again', async () => { + const sent = await downcast(resized('200px')) + const resent = await downcast(sent) + + expect(resent).toContain('width="200"') + expect(resent).toContain('width:200px;') + expect(resent).not.toContain('height=') + }) +}) diff --git a/src/tests/unit/components/TextEditor.spec.js b/src/tests/unit/components/TextEditor.spec.js index 7930c8e51a..eb8c6b89a1 100644 --- a/src/tests/unit/components/TextEditor.spec.js +++ b/src/tests/unit/components/TextEditor.spec.js @@ -8,6 +8,7 @@ import { GeneralHtmlSupport, Paragraph } from 'ckeditor5' import mitt from 'mitt' import { vi } from 'vitest' import TextEditor from '../../../components/TextEditor.vue' +import ImageDowncastPlugin from '../../../ckeditor/image/ImageDowncastPlugin.ts' import MailPlugin from '../../../ckeditor/mail/MailPlugin.js' import Nextcloud from '../../../mixins/Nextcloud.js' import VirtualTestEditor from '../../virtualtesteditor.js' @@ -56,6 +57,23 @@ describe('TextEditor', () => { expect(wrapper.vm.config.htmlSupport.allow.some((rule) => rule.name === 'img')).toBe(true) }) + it('resizes images in pixels in html mode', async () => { + const wrapper = shallowMount(TextEditor, { + localVue, + provide: { + addToFocusTrap: vi.fn(), + }, + propsData: { + value: 'bonjour', + html: true, + bus: mitt(), + }, + }) + + expect(wrapper.vm.config.plugins).toContain(ImageDowncastPlugin) + expect(wrapper.vm.config.image.resizeUnit).toBe('px') + }) + it('throw when editor not ready', async () => { const wrapper = shallowMount(TextEditor, { localVue,