Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Tiptap drag and drop upload #2318

Merged
merged 34 commits into from
Sep 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6fa94c2
chore: optimise file manager class
iOvergaard Sep 19, 2024
fb11b67
fix: give UmbImage an extension name
iOvergaard Sep 19, 2024
dc77644
feat: adds a media upload extension supporting drag and drop
iOvergaard Sep 19, 2024
d3710f3
chore: add mock handlers for "temporary-file"
iOvergaard Sep 19, 2024
2fa644a
chore: add delay to file handler
iOvergaard Sep 19, 2024
4779c15
feat: add localization and correct name of data-tmpimg
iOvergaard Sep 19, 2024
199daa1
chore: remove debug data
iOvergaard Sep 19, 2024
ce99b09
feat: make the datatype configuration available to extensions
iOvergaard Sep 19, 2024
8359211
feat: calculate image size for files added to the editor so that we c…
iOvergaard Sep 19, 2024
fc47667
feat: update the contents if we ever get a new value
iOvergaard Sep 19, 2024
a85f2f7
feat: add a hasChanged checker to the value to check specifically if …
iOvergaard Sep 19, 2024
1349f9d
feat: only update value when changed
iOvergaard Sep 19, 2024
7f16ff5
fix: assume any to avoid linting error
iOvergaard Sep 19, 2024
99c5cb1
fix: move options back to UmbImage element to ensure everything is av…
iOvergaard Sep 19, 2024
3ac8db5
store value in another variable
iOvergaard Sep 19, 2024
4ea6983
feat: override value property to interact with tiptap
iOvergaard Sep 19, 2024
64390a1
feat: introduce a backing field for markup to be able to add markup f…
iOvergaard Sep 19, 2024
1233db4
feat: use #markup backing field
iOvergaard Sep 19, 2024
9642beb
feat: only calculate html on updates
iOvergaard Sep 19, 2024
b37c2c2
chore: remove clog
iOvergaard Sep 19, 2024
a8c1787
fix: allow configuration to be undefined
iOvergaard Sep 19, 2024
d542912
fix: use local editor
iOvergaard Sep 19, 2024
5da5e0f
chore: sort imports
iOvergaard Sep 19, 2024
0c5f54b
chore: restore comments
iOvergaard Sep 19, 2024
4347fb2
Merge remote-tracking branch 'origin/v15/feature/tiptap' into v15/fea…
iOvergaard Sep 19, 2024
28d7cbf
fix: types after merge
iOvergaard Sep 19, 2024
860eaf0
Merge remote-tracking branch 'origin/v15/feature/tiptap' into v15/fea…
iOvergaard Sep 19, 2024
c2d6bd7
Merge remote-tracking branch 'origin/v15/feature/tiptap' into v15/fea…
iOvergaard Sep 20, 2024
f979669
feat: move imageSize function to util file
iOvergaard Sep 20, 2024
8fe308e
feat: move imageSize function to general utils
iOvergaard Sep 20, 2024
fc5808e
chore: remove comment
iOvergaard Sep 20, 2024
e8d2015
feat: create a pure extension rather than depend on the UmbImage exte…
iOvergaard Sep 20, 2024
e43361c
Merge remote-tracking branch 'origin/v15/feature/tiptap' into v15/fea…
iOvergaard Sep 20, 2024
bd66bdd
feat: add maxImageSize
iOvergaard Sep 20, 2024
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
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