-
Notifications
You must be signed in to change notification settings - Fork 60
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2318 from umbraco/v15/feature/tiptap-media-upload
Feature: Tiptap drag and drop upload
- Loading branch information
Showing
16 changed files
with
276 additions
and
25 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
12
src/mocks/handlers/temporary-file/temporary-file.handlers.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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())); | ||
}), | ||
]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
135 changes: 135 additions & 0 deletions
135
src/packages/rte/tiptap/extensions/umb/media-upload.extension.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
} | ||
} |
Oops, something went wrong.