Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions src/ckeditor/image/ImageDowncastPlugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
Comment on lines +1 to +4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the required SPDX block-comment format in both new files. Each header starts with /**, but the required format starts with /*.

  • src/ckeditor/image/ImageDowncastPlugin.ts#L1-L4: change the opening delimiter from /** to /*.
  • src/tests/unit/ckeditor/image/ImageDowncastPlugin.spec.js#L1-L4: change the opening delimiter from /** to /*.

As per coding guidelines, “Header format: /* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */”.

📍 Affects 2 files
  • src/ckeditor/image/ImageDowncastPlugin.ts#L1-L4 (this comment)
  • src/tests/unit/ckeditor/image/ImageDowncastPlugin.spec.js#L1-L4

Source: Coding guidelines


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)
}
}
7 changes: 7 additions & 0 deletions src/components/TextEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -152,6 +153,7 @@ export default {
ImageUpload,
ImageResize,
FilesImagePlugin,
ImageDowncastPlugin,
Font,
RemoveFormat,
Base64UploadAdapter,
Expand Down Expand Up @@ -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: [
{
Expand Down
112 changes: 112 additions & 0 deletions src/tests/unit/ckeditor/image/ImageDowncastPlugin.spec.js
Original file line number Diff line number Diff line change
@@ -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<string>} 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 `<figure class="image image_resized" style="width:${width};">`
+ '<img src="test.png" width="400" height="300">'
+ '</figure>'
}

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('<figure class="image"><img src="test.png" width="400" height="300"></figure>')

expect(data).toContain('width="400"')
expect(data).toContain('height="300"')
})

it('mirrors the width of a linked image', async () => {
const data = await downcast('<figure class="image image_resized" style="width:100px;">'
+ '<a href="https://nextcloud.com"><img src="test.png" width="400" height="300"></a>'
+ '</figure>')

expect(data).toContain('width="100"')
expect(data).not.toContain('height=')
})

it('mirrors the width of a resized inline image', async () => {
const data = await downcast('<p>text <img class="image_resized" style="width:200px;" src="test.png" width="400" height="300"></p>')

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=')
})
})
18 changes: 18 additions & 0 deletions src/tests/unit/components/TextEditor.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
Loading