Tiptap Media Picker: ImageSharp HMAC support (#19333)
* Tiptap Media Picker: Uses imaging repository to get the resized URLs from the server. This adds support for ImageSharp's HMAC security. * Update src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/toolbar/media-picker.tiptap-toolbar-api.ts * feat: uses the actual configured image SIZE for both width and height as documented this also deprecates the public maxWidth property * feat: verifies that a resized image exists and use the size of that * feat: transfer the image size calculation to the drag'n'drop uploader and ensures that imageSize() accounts for maxHeight as well * docs: adds comment to explain why it calculates the image size * test: adds cases for imageSize --------- Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string>} 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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user