Skip to content

Commit

Permalink
Merge pull request #2318 from umbraco/v15/feature/tiptap-media-upload
Browse files Browse the repository at this point in the history
Feature: Tiptap drag and drop upload
  • Loading branch information
iOvergaard authored Sep 20, 2024
2 parents df13f32 + bd66bdd commit 218e960
Show file tree
Hide file tree
Showing 16 changed files with 276 additions and 25 deletions.
4 changes: 4 additions & 0 deletions src/external/tiptap/extensions/tiptap-umb-image.extension.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Image from '@tiptap/extension-image';

export const UmbImage = Image.extend({
name: 'umbImage',
addAttributes() {
return {
...this.parent?.(),
Expand All @@ -19,6 +20,8 @@ export const UmbImage = Image.extend({
sizes: {
default: null,
},
'data-tmpimg': { default: null },
'data-udi': { default: null },
};
},
});
Expand All @@ -43,6 +46,7 @@ declare module '@tiptap/core' {
loading?: string;
srcset?: string;
sizes?: string;
'data-tmpimg'?: string;
}) => ReturnType;
};
}
Expand Down
2 changes: 2 additions & 0 deletions src/mocks/browser-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { handlers as userHandlers } from './handlers/user/index.js';
import * as manifestsHandlers from './handlers/manifests.handlers.js';
import * as serverHandlers from './handlers/server.handlers.js';
import { handlers as documentBlueprintHandlers } from './handlers/document-blueprint/index.js';
import { handlers as temporaryFileHandlers } from './handlers/temporary-file/index.js';

const handlers = [
...configHandlers,
Expand Down Expand Up @@ -72,6 +73,7 @@ const handlers = [
...userGroupsHandlers,
...userHandlers,
...documentBlueprintHandlers,
...temporaryFileHandlers,
...serverHandlers.serverInformationHandlers,
];

Expand Down
1 change: 1 addition & 0 deletions src/mocks/handlers/temporary-file/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { handlers } from './temporary-file.handlers.js';
12 changes: 12 additions & 0 deletions src/mocks/handlers/temporary-file/temporary-file.handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { rest } from 'msw';
import { umbracoPath } from '@umbraco-cms/backoffice/utils';
import type { PostTemporaryFileResponse } from '@umbraco-cms/backoffice/external/backend-api';
import { UmbId } from '@umbraco-cms/backoffice/id';

const UMB_SLUG = 'temporary-file';

export const handlers = [
rest.post(umbracoPath(`/${UMB_SLUG}`), async (_req, res, ctx) => {
return res(ctx.delay(), ctx.status(201), ctx.text<PostTemporaryFileResponse>(UmbId.new()));
}),
];
4 changes: 4 additions & 0 deletions src/packages/core/icon-registry/icon-dictionary.json
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,10 @@
"file": "monitor.svg",
"legacy": true
},
{
"name": "icon-image-up",
"file": "image-up.svg"
},
{
"_name": "icon-inactive-line",
"____file": "inactive-line.svg"
Expand Down
4 changes: 4 additions & 0 deletions src/packages/core/icon-registry/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,10 @@ name: "icon-imac",
legacy: true,
path: () => import("./icons/icon-imac.js"),
},{
name: "icon-image-up",

path: () => import("./icons/icon-image-up.js"),
},{
name: "icon-inbox-full",
legacy: true,
path: () => import("./icons/icon-inbox-full.js"),
Expand Down
17 changes: 17 additions & 0 deletions src/packages/core/icon-registry/icons/icon-image-up.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export default `<!-- @license lucide-static v0.424.0 - ISC -->
<svg
class="lucide lucide-image-up"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" />
<path d="m14 19.5 3-3 3 3" />
<path d="M17 22v-5.5" />
<circle cx="9" cy="9" r="2" />
</svg>
`;
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { UmbTemporaryFileRepository } from './temporary-file.repository.js';
import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';

///export type TemporaryFileStatus = 'success' | 'waiting' | 'error';
Expand All @@ -20,16 +19,11 @@ export interface UmbTemporaryFileModel {
export class UmbTemporaryFileManager<
UploadableItem extends UmbTemporaryFileModel = UmbTemporaryFileModel,
> extends UmbControllerBase {
#temporaryFileRepository;
#temporaryFileRepository = new UmbTemporaryFileRepository(this._host);

#queue = new UmbArrayState<UploadableItem>([], (item) => item.temporaryUnique);
public readonly queue = this.#queue.asObservable();

constructor(host: UmbControllerHost) {
super(host);
this.#temporaryFileRepository = new UmbTemporaryFileRepository(host);
}

async uploadOne(uploadableItem: UploadableItem): Promise<UploadableItem> {
this.#queue.setValue([]);

Expand Down
1 change: 1 addition & 0 deletions src/packages/core/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './direction/index.js';
export * from './download/blob-download.function.js';
export * from './get-processed-image-url.function.js';
export * from './math/math.js';
export * from './media/image-size.function.js';
export * from './object/deep-merge.function.js';
export * from './pagination-manager/pagination.manager.js';
export * from './path/ensure-local-path.function.js';
Expand Down
28 changes: 28 additions & 0 deletions src/packages/core/utils/media/image-size.function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* 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.
* @returns {Promise<{width: number, height: number}>} The width and height of the image as downloaded from the URL.
*/
export function imageSize(url: string): Promise<{ width: number; height: number }> {
const img = new Image();

const promise = new Promise<{ width: number; height: 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 width = img.naturalWidth;
const height = img.naturalHeight;

// Resolve promise with the width and height
resolve({ width, height });
};

// Reject promise on error
img.onerror = reject;
});

// Setting the source makes it start downloading and eventually call `onload`
img.src = url;

return promise;
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ export class UmbInputTiptapElement extends UmbFormControlMixin<string, typeof Um
@state()
private _extensions: Array<UmbTiptapExtensionApi> = [];

@property({ type: String })
override set value(value: string) {
this.#markup = value;

// Try to set the value to the editor if it is ready.
if (this._editor) {
this._editor.commands.setContent(value);
}
}
override get value() {
return this.#markup;
}

#markup = '';

@property({ attribute: false })
configuration?: UmbPropertyEditorConfigCollection;

Expand Down Expand Up @@ -71,15 +86,17 @@ export class UmbInputTiptapElement extends UmbFormControlMixin<string, typeof Um
this.setAttribute('style', `max-width: ${maxWidth}px;`);
element.setAttribute('style', `max-height: ${maxHeight}px;`);

const extensions = this._extensions.map((ext) => ext.getTiptapExtensions()).flat();
const extensions = this._extensions
.map((ext) => ext.getTiptapExtensions({ configuration: this.configuration }))
.flat();

this._editor = new Editor({
element: element,
editable: !this.readonly,
extensions: [...this.#requiredExtensions, ...extensions],
content: this.value,
content: this.#markup,
onUpdate: ({ editor }) => {
this.value = editor.getHTML();
this.#markup = editor.getHTML();
this.dispatchEvent(new UmbChangeEvent());
},
});
Expand Down
14 changes: 13 additions & 1 deletion src/packages/rte/tiptap/extensions/manifests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const kinds: Array<UmbExtensionManifestKind> = [
},
];

const umbExtensions: Array<ManifestTiptapExtensionButtonKind> = [
const umbExtensions: Array<ManifestTiptapExtension | ManifestTiptapExtensionButtonKind> = [
{
type: 'tiptapExtension',
kind: 'button',
Expand Down Expand Up @@ -52,6 +52,18 @@ const umbExtensions: Array<ManifestTiptapExtensionButtonKind> = [
label: 'Media picker',
},
},
{
type: 'tiptapExtension',
alias: 'Umb.Tiptap.MediaUpload',
name: 'Media Upload Tiptap Extension',
weight: 900,
api: () => import('./umb/media-upload.extension.js'),
meta: {
alias: 'umb-media-upload',
icon: 'icon-image-up',
label: 'Media upload',
},
},
{
type: 'tiptapExtension',
kind: 'button',
Expand Down
19 changes: 12 additions & 7 deletions src/packages/rte/tiptap/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,25 @@ import type { ManifestTiptapExtension } from './tiptap-extension.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import type { Editor, Extension, Mark, Node } from '@umbraco-cms/backoffice/external/tiptap';
import type { UmbApi } from '@umbraco-cms/backoffice/extension-api';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';

export interface UmbTiptapExtensionApi extends UmbApi {
getTiptapExtensions(): Array<Extension | Mark | Node>;
getTiptapExtensions(args?: UmbTiptapExtensionArgs): Array<Extension | Mark | Node>;
}

export abstract class UmbTiptapExtensionApiBase extends UmbControllerBase implements UmbApi {
export abstract class UmbTiptapExtensionApiBase extends UmbControllerBase implements UmbTiptapExtensionApi {
public manifest?: ManifestTiptapExtension;

constructor(host: UmbControllerHost) {
super(host);
}
abstract getTiptapExtensions(args?: UmbTiptapExtensionArgs): Array<Extension | Mark | Node>;
}

abstract getTiptapExtensions(): Array<Extension | Mark | Node>;
export interface UmbTiptapExtensionArgs {
/**
* The data type configuration for the property editor that the editor is used for.
* You can populate this manually if you are using the editor outside of a property editor with the {@link UmbPropertyEditorConfigCollection} object.
* @remark This is only available when the editor is used in a property editor or populated manually.
*/
configuration?: UmbPropertyEditorConfigCollection;
}

export interface UmbTiptapToolbarElementApi extends UmbTiptapExtensionApi {
Expand Down
135 changes: 135 additions & 0 deletions src/packages/rte/tiptap/extensions/umb/media-upload.extension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { UmbTiptapExtensionApiBase, type UmbTiptapExtensionArgs } from '../types.js';
import {
TemporaryFileStatus,
UmbTemporaryFileManager,
type UmbTemporaryFileModel,
} from '@umbraco-cms/backoffice/temporary-file';
import { imageSize } from '@umbraco-cms/backoffice/utils';
import { type Editor, Extension } from '@umbraco-cms/backoffice/external/tiptap';
import { UmbId } from '@umbraco-cms/backoffice/id';
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor';

export default class UmbTiptapMediaUploadExtension extends UmbTiptapExtensionApiBase {
#configuration?: UmbPropertyEditorConfigCollection;

/**
* @returns {number} The maximum width of uploaded images
*/
get maxWidth(): number {
const maxImageSize = parseInt(this.#configuration?.getValueByAlias('maxImageSize') ?? '', 10);
return isNaN(maxImageSize) ? 500 : maxImageSize;
}

/**
* @returns {Array<string>} The allowed mime types for uploads
*/
get allowedFileTypes(): string[] {
return (
this.#configuration?.getValueByAlias<string[]>('allowedFileTypes') ?? ['image/jpeg', 'image/png', 'image/gif']
);
}

#manager = new UmbTemporaryFileManager(this);
#localize = new UmbLocalizationController(this);
#notificationContext?: typeof UMB_NOTIFICATION_CONTEXT.TYPE;

constructor(host: UmbControllerHost) {
super(host);
this.consumeContext(UMB_NOTIFICATION_CONTEXT, (instance) => {
this.#notificationContext = instance;
});
}

getTiptapExtensions(args: UmbTiptapExtensionArgs) {
this.#configuration = args?.configuration;

// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
return [
Extension.create({
name: 'umbMediaUpload',
onCreate() {
this.parent?.();
const host = this.editor.view.dom;

host.addEventListener('dragover', (event) => {
// Required to allow drop events
event.preventDefault();
});

host.addEventListener('drop', (event) => {
event.preventDefault();

const files = event.dataTransfer?.files;
if (!files) return;

self.#uploadTemporaryFile(files, this.editor);
});
},
}),
];
}

/**
* Uploads the files to the server and inserts them into the editor as data URIs.
* The server will replace the data URI with a proper URL when the content is saved.
* @param {FileList} files The files to upload.
* @param {Editor} editor The editor to insert the images into.
*/
async #uploadTemporaryFile(files: FileList, editor: Editor): Promise<void> {
const filteredFiles = this.#filterFiles(files);
const fileModels = filteredFiles.map((file) => this.#mapFileToTemporaryFile(file));

this.dispatchEvent(new CustomEvent('rte.file.uploading', { composed: true, bubbles: true, detail: fileModels }));

const uploads = await this.#manager.upload(fileModels);
const maxImageSize = this.maxWidth;

uploads.forEach(async (upload) => {
if (upload.status !== TemporaryFileStatus.SUCCESS) {
this.#notificationContext?.peek('danger', {
data: {
headline: upload.file.name,
message: this.#localize.term('errors_dissallowedMediaType'),
},
});
return;
}

let { width, height } = await imageSize(URL.createObjectURL(upload.file));

if (maxImageSize > 0 && width > maxImageSize) {
const ratio = maxImageSize / width;
width = maxImageSize;
height = Math.round(height * ratio);
}

editor
.chain()
.focus()
.setImage({
src: URL.createObjectURL(upload.file),
width: width.toString(),
height: height.toString(),
'data-tmpimg': upload.temporaryUnique,
})
.run();
});

this.dispatchEvent(new CustomEvent('rte.file.uploaded', { composed: true, bubbles: true, detail: uploads }));
}

#mapFileToTemporaryFile(file: File): UmbTemporaryFileModel {
return {
file,
temporaryUnique: UmbId.new(),
};
}

#filterFiles(files: FileList): File[] {
return Array.from(files).filter((file) => this.allowedFileTypes.includes(file.type));
}
}
Loading

0 comments on commit 218e960

Please sign in to comment.