uppy/packages/@uppy/core/src/UIPlugin.ts

214 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { render } from 'preact'
import type { PluginOpts } from './BasePlugin.js'
import BasePlugin from './BasePlugin.js'
import type { State } from './Uppy.js'
import type { Body, Meta } from './utils/index.js'
import { findDOMElement, getTextDirection } from './utils/index.js'
/**
* Defer a frequent call to the microtask queue.
*/
function debounce<T extends (...args: any[]) => any>(
fn: T,
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
let calling: Promise<ReturnType<T>> | null = null
let latestArgs: Parameters<T>
return (...args) => {
latestArgs = args
if (!calling) {
calling = Promise.resolve().then(() => {
calling = null
// At this point `args` may be different from the most
// recent state, if multiple calls happened since this task
// was queued. So we use the `latestArgs`, which definitely
// is the most recent call.
return fn(...latestArgs)
})
}
return calling
}
}
/**
* UIPlugin is the extended version of BasePlugin to incorporate rendering with Preact.
* Use this for plugins that need a user interface.
*
* For plugins without an user interface, see BasePlugin.
*/
class UIPlugin<
Opts extends UIPluginOptions,
M extends Meta,
B extends Body,
PluginState extends Record<string, unknown> = Record<string, unknown>,
> extends BasePlugin<Opts, M, B, PluginState> {
#updateUI!: (state: Partial<State<M, B>>) => void
isTargetDOMEl!: boolean
el!: HTMLElement | null
parent: unknown
title!: string
getTargetPlugin<Me extends Meta, Bo extends Body>(
target: PluginTarget<Me, Bo>,
): UIPlugin<any, Me, Bo> | undefined {
let targetPlugin: any
if (typeof (target as UIPlugin<any, any, any>)?.addTarget === 'function') {
// Targeting a plugin *instance*
targetPlugin = target as UIPlugin<any, any, any>
if (!(targetPlugin instanceof UIPlugin)) {
console.warn(
new Error(
'The provided plugin is not an instance of UIPlugin. This is an indication of a bug with the way Uppy is bundled.',
{ cause: { targetPlugin, UIPlugin } },
),
)
}
} else if (typeof target === 'function') {
// Targeting a plugin type
const Target = target
// Find the target plugin instance.
this.uppy.iteratePlugins((p) => {
if (p instanceof Target) {
targetPlugin = p
}
})
}
return targetPlugin
}
/**
* Check if supplied `target` is a DOM element or an `object`.
* If its an object — target is a plugin, and we search `plugins`
* for a plugin with same name and return its target.
*/
mount<Me extends Meta, Bo extends Body>(
target: PluginTarget<Me, Bo>,
plugin: UIPlugin<any, Me, Bo>,
): HTMLElement {
const callerPluginName = plugin.id
const targetElement = findDOMElement(target)
if (targetElement) {
this.isTargetDOMEl = true
// When target is <body> with a single <div> element,
// Preact thinks its the Uppy root element in there when doing a diff,
// and destroys it. So we are creating a fragment (could be empty div)
const uppyRootElement = document.createElement('div')
uppyRootElement.classList.add('uppy-Root')
// API for plugins that require a synchronous rerender.
this.#updateUI = debounce((state) => {
// plugin could be removed, but this.rerender is debounced below,
// so it could still be called even after uppy.removePlugin or uppy.destroy
// hence the check
if (!this.uppy.getPlugin(this.id)) return
render(this.render(state, uppyRootElement), uppyRootElement)
this.afterUpdate()
})
this.uppy.log(
`Installing ${callerPluginName} to a DOM element '${target}'`,
)
if (this.opts.replaceTargetContent) {
// Doing render(h(null), targetElement), which should have been
// a better way, since because the component might need to do additional cleanup when it is removed,
// stopped working — Preact just adds null into target, not replacing
targetElement.innerHTML = ''
}
render(
this.render(this.uppy.getState(), uppyRootElement),
uppyRootElement,
)
this.el = uppyRootElement
targetElement.appendChild(uppyRootElement)
// Set the text direction if the page has not defined one.
uppyRootElement.dir =
this.opts.direction || getTextDirection(uppyRootElement) || 'ltr'
this.onMount()
return this.el!
}
const targetPlugin = this.getTargetPlugin(target)
if (targetPlugin) {
this.uppy.log(`Installing ${callerPluginName} to ${targetPlugin.id}`)
this.parent = targetPlugin
this.el = targetPlugin.addTarget(plugin)
this.onMount()
return this.el!
}
this.uppy.log(`Not installing ${callerPluginName}`)
let message = `Invalid target option given to ${callerPluginName}.`
if (typeof target === 'function') {
message +=
' The given target is not a Plugin class. ' +
"Please check that you're not specifying a React Component instead of a plugin. " +
'If you are using @uppy/* packages directly, make sure you have only 1 version of @uppy/core installed: ' +
'run `npm ls @uppy/core` on the command line and verify that all the versions match and are deduped correctly.'
} else {
message +=
'If you meant to target an HTML element, please make sure that the element exists. ' +
'Check that the <script> tag initializing Uppy is right before the closing </body> tag at the end of the page. ' +
'(see https://github.com/transloadit/uppy/issues/1042)\n\n' +
'If you meant to target a plugin, please confirm that your `import` statements or `require` calls are correct.'
}
throw new Error(message)
}
/**
* Called when plugin is mounted, whether in DOM or into another plugin.
* Needed because sometimes plugins are mounted separately/after `install`,
* so this.el and this.parent might not be available in `install`.
* This is the case with @uppy/react plugins, for example.
*/
render(state: Record<string, unknown>, container?: HTMLElement): any {
throw new Error(
'Extend the render method to add your plugin to a DOM element',
)
}
update(state: Partial<State<M, B>>): void {
if (this.el != null) {
this.#updateUI?.(state)
}
}
unmount(): void {
if (this.isTargetDOMEl) {
this.el?.remove()
}
this.onUnmount()
}
onMount(): void {}
onUnmount(): void {}
}
export default UIPlugin
export type PluginTarget<M extends Meta, B extends Body> =
| string
| Element
| typeof BasePlugin
| typeof UIPlugin
| BasePlugin<any, M, B>
export interface UIPluginOptions extends PluginOpts {
target?: PluginTarget<any, any>
replaceTargetContent?: boolean
direction?: 'ltr' | 'rtl'
}