diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/media/image-size.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/media/image-size.function.ts index 8958da1c0b..c5ee719cfc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/media/image-size.function.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/media/image-size.function.ts @@ -1,43 +1,39 @@ /** * Get the dimensions of an image from a URL. * @param {string} url The URL of the image. It can be a local file (blob url) or a remote file. - * @param {{maxWidth?: number}} opts Options for the image size. - * @param {number} opts.maxWidth The maximum width of the image. If the image is wider than this, it will be scaled down to this width while keeping the aspect ratio. - * @returns {Promise<{width: number, height: number, naturalWidth: number, naturalHeight: number}>} The width and height of the image as downloaded from the URL. The width and height can differ from the natural numbers if maxImageWidth is given. + * @param {{maxWidth?: number, maxHeight?: number}} opts Options for the image size. + * @param {number} opts.maxWidth The maximum width of the image. + * @param {number} opts.maxHeight The maximum height of the image. + * @returns {Promise<{width: number, height: number, naturalWidth: number, naturalHeight: number}>} The dimensions of the image. */ export function imageSize( url: string, - opts?: { maxWidth?: number }, + opts?: { maxWidth?: number; maxHeight?: number }, ): Promise<{ width: number; height: number; naturalWidth: number; naturalHeight: number }> { const img = new Image(); const promise = new Promise<{ width: number; height: number; naturalWidth: number; naturalHeight: number }>( (resolve, reject) => { img.onload = () => { - // Natural size is the actual image size regardless of rendering. - // The 'normal' `width`/`height` are for the **rendered** size. const naturalWidth = img.naturalWidth; const naturalHeight = img.naturalHeight; let width = naturalWidth; let height = naturalHeight; - if (opts?.maxWidth && opts.maxWidth > 0 && width > opts?.maxWidth) { - const ratio = opts.maxWidth / naturalWidth; - width = opts.maxWidth; + if ((opts?.maxWidth && opts.maxWidth > 0) || (opts?.maxHeight && opts.maxHeight > 0)) { + const widthRatio = opts?.maxWidth ? opts.maxWidth / naturalWidth : 1; + const heightRatio = opts?.maxHeight ? opts.maxHeight / naturalHeight : 1; + const ratio = Math.min(widthRatio, heightRatio, 1); // Never upscale + width = Math.round(naturalWidth * ratio); height = Math.round(naturalHeight * ratio); } - // Resolve promise with the width and height resolve({ width, height, naturalWidth, naturalHeight }); }; - - // Reject promise on error img.onerror = reject; }, ); - // Setting the source makes it start downloading and eventually call `onload` img.src = url; - return promise; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/media/image-size.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/media/image-size.test.ts new file mode 100644 index 0000000000..57b9cdbd36 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/media/image-size.test.ts @@ -0,0 +1,83 @@ +import { imageSize } from './image-size.function'; +import { expect } from '@open-wc/testing'; + +describe('imageSize', () => { + let OriginalImage: typeof Image; + + before(() => { + OriginalImage = window.Image; + }); + + after(() => { + window.Image = OriginalImage; + }); + + function mockImage(naturalWidth: number, naturalHeight: number) { + class MockImage { + naturalWidth = naturalWidth; + naturalHeight = naturalHeight; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + set src(_url: string) { + setTimeout(() => this.onload && this.onload(), 0); + } + } + // @ts-ignore + window.Image = MockImage; + } + + it('returns natural size if no maxWidth or maxHeight is given', async () => { + mockImage(800, 600); + const result = await imageSize('fake-url'); + expect(result).to.deep.equal({ + width: 800, + height: 600, + naturalWidth: 800, + naturalHeight: 600, + }); + }); + + it('scales down to maxWidth and maxHeight, ratio locked', async () => { + mockImage(800, 600); + const result = await imageSize('fake-url', { maxWidth: 400, maxHeight: 300 }); + expect(result).to.deep.equal({ + width: 400, + height: 300, + naturalWidth: 800, + naturalHeight: 600, + }); + }); + + it('never upscales if maxWidth/maxHeight are larger than natural', async () => { + mockImage(800, 600); + const result = await imageSize('fake-url', { maxWidth: 1000, maxHeight: 1000 }); + expect(result).to.deep.equal({ + width: 800, + height: 600, + naturalWidth: 800, + naturalHeight: 600, + }); + }); + + it('scales down by width if width is limiting', async () => { + mockImage(800, 600); + const result = await imageSize('fake-url', { maxWidth: 400 }); + expect(result).to.deep.equal({ + width: 400, + height: 300, + naturalWidth: 800, + naturalHeight: 600, + }); + }); + + it('scales down by height if height is limiting', async () => { + mockImage(800, 600); + const result = await imageSize('fake-url', { maxHeight: 150 }); + expect(result).to.deep.equal({ + width: 200, + height: 150, + naturalWidth: 800, + naturalHeight: 600, + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/core/media-upload.tiptap-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/core/media-upload.tiptap-api.ts index 3d3462a147..0a848e1676 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/core/media-upload.tiptap-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/core/media-upload.tiptap-api.ts @@ -15,13 +15,19 @@ export default class UmbTiptapMediaUploadExtensionApi extends UmbTiptapExtension #configuration?: UmbPropertyEditorConfigCollection; /** - * @returns {number} The maximum width of uploaded images + * @returns {number} The configured maximum allowed image size */ - get maxWidth(): number { + get maxImageSize(): number { const maxImageSize = parseInt(this.#configuration?.getValueByAlias('maxImageSize') ?? '', 10); return isNaN(maxImageSize) ? 500 : maxImageSize; } + /** + * @deprecated Use `maxImageSize` instead. + * @returns {number} The maximum width of uploaded images + */ + maxWidth = this.maxImageSize; + /** * @returns {Array} The allowed mime types for uploads */ @@ -98,7 +104,7 @@ export default class UmbTiptapMediaUploadExtensionApi extends UmbTiptapExtension this.dispatchEvent(new CustomEvent('rte.file.uploading', { composed: true, bubbles: true, detail: fileModels })); const uploads = await this.#manager.upload(fileModels); - const maxImageSize = this.maxWidth; + const maxImageSize = this.maxImageSize; uploads.forEach(async (upload) => { if (upload.status !== TemporaryFileStatus.SUCCESS) { @@ -112,7 +118,11 @@ export default class UmbTiptapMediaUploadExtensionApi extends UmbTiptapExtension } const blobUrl = URL.createObjectURL(upload.file); - const { width, height } = await imageSize(blobUrl, { maxWidth: maxImageSize }); + + // Get the image dimensions - this essentially simulates what the server would do + // when it resizes the image. The server will return the resized image URL. + // We need to use the blob URL here, as the server will not be able to access the local file. + const { width, height } = await imageSize(blobUrl, { maxWidth: maxImageSize, maxHeight: maxImageSize }); editor .chain() diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/toolbar/media-picker.tiptap-toolbar-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/toolbar/media-picker.tiptap-toolbar-api.ts index ddbd215c82..0963c86be5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/toolbar/media-picker.tiptap-toolbar-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/toolbar/media-picker.tiptap-toolbar-api.ts @@ -1,6 +1,7 @@ import { UmbTiptapToolbarElementApiBase } from '../base.js'; -import { getGuidFromUdi, getProcessedImageUrl, imageSize } from '@umbraco-cms/backoffice/utils'; +import { getGuidFromUdi, imageSize } from '@umbraco-cms/backoffice/utils'; import { ImageCropModeModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbImagingRepository } from '@umbraco-cms/backoffice/imaging'; import { UMB_MEDIA_CAPTION_ALT_TEXT_MODAL, UMB_MEDIA_PICKER_MODAL } from '@umbraco-cms/backoffice/media'; import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; import type { Editor } from '@umbraco-cms/backoffice/external/tiptap'; @@ -8,16 +9,24 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbMediaCaptionAltTextModalValue } from '@umbraco-cms/backoffice/media'; export default class UmbTiptapToolbarMediaPickerToolbarExtensionApi extends UmbTiptapToolbarElementApiBase { + #imagingRepository = new UmbImagingRepository(this); + #modalManager?: typeof UMB_MODAL_MANAGER_CONTEXT.TYPE; /** - * @returns {number} The maximum width of uploaded images + * @returns {number} The configured maximum allowed image size */ - get maxWidth(): number { + get maxImageSize(): number { const maxImageSize = parseInt(this.configuration?.getValueByAlias('maxImageSize') ?? '', 10); return isNaN(maxImageSize) ? 500 : maxImageSize; } + /** + * @deprecated Use `maxImageSize` instead. + * @returns {number} The maximum width of uploaded images + */ + maxWidth = this.maxImageSize; + constructor(host: UmbControllerHost) { super(host); @@ -98,12 +107,29 @@ export default class UmbTiptapToolbarMediaPickerToolbarExtensionApi extends UmbT async #insertInEditor(editor: Editor, mediaUnique: string, media: UmbMediaCaptionAltTextModalValue) { if (!media?.url) return; - const { width, height } = await imageSize(media.url, { maxWidth: this.maxWidth }); - const src = await getProcessedImageUrl(media.url, { width, height, mode: ImageCropModeModel.MAX }); + const maxImageSize = this.maxImageSize; + + // Get the resized image URL + const { data } = await this.#imagingRepository.requestResizedItems([mediaUnique], { + width: maxImageSize, + height: maxImageSize, + mode: ImageCropModeModel.MAX, + }); + + if (!data?.length || !data[0]?.url) { + console.error('No data returned from imaging repository'); + return; + } + + // Set the media URL to the first item in the data array + const src = data[0].url; + + // Fetch the actual image dimensions + const { width, height } = await imageSize(src); const img = { - alt: media.altText, src, + alt: media.altText, 'data-udi': `umb://media/${mediaUnique.replace(/-/g, '')}`, width: width.toString(), height: height.toString(),