UNPKG

@caspingus/lt

Version:

A utility library of helpers and extensions useful when working with Learnosity APIs.

758 lines (684 loc) 29.1 kB
import { checkAppVersion } from '../../../utils/styling.js'; import { createExtension, LT } from '../../../../utils/extensionsFactory.js'; import Uppy from '@uppy/core'; import Dashboard from '@uppy/dashboard'; import Compressor from '@uppy/compressor'; import ImageEditor from '@uppy/image-editor'; import uppyCore from '@uppy/core/dist/style.min.css?inline'; import uppyDashboard from '@uppy/dashboard/dist/style.min.css?inline'; import uppyImageEditor from '@uppy/image-editor/dist/style.min.css?inline'; /** * Extensions add specific functionality to Learnosity APIs. * They rely on modules within LT being available. * * -- * * This extension replaces the default Learnosity image uploader with a custom image * uploader that supports image editing and compression before uploading to the * Learnosity CDN. * * Consider this extension if you're looking to reduce the file size of images or * if you want to give users the flexibility to crop or rotate them before uploading. * * Supported mime types: `image/gif`, `image/jpeg`, `image/png`, `image/svg+xml` * * .webp files are not supported by Learnosity, so we don't support them here. * * Animated gifs become static. * * By default, we resize images to a maximum width or height of 1500px. The calling * page can override `width`, `height`, and `quality`. See below in `run()`. * * <h2>Image comparisons (before and after)</h2> * <p>Click image to view full size.</p> * <table style="table-layout: auto; max-width: 1000px;"> * <thead> * <tr> * <th>Original</th> * <th>Compressed</th> * <th>File size reduction</th> * </tr> * </thead> * <tr> * <td> * <a href="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/original/periodic2.jpg" target="blank"><img src="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/original/periodic2.jpg" width="200"></a> * <br><code>205kB (2000x1589)</code></td> * <td> * <a href="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/compressed-0.7-1500px/periodic2.jpg" target="blank"><img src="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/compressed-0.7-1500px/periodic2.jpg" width="200"></a> * <br><code>128kB (1500x1091)</code></td> * </td> * <td><code>37.56%</code></td> * </tr> * <tr> * <td> * <a href="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/original/periodic1.png" target="blank"><img src="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/original/periodic1.png" width="200"></a> * <br><code>480kB (2584x1518)</code></td> * </td> * <td> * <a href="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/compressed-0.7-1500px/periodic1.png" target="blank"><img src="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/compressed-0.7-1500px/periodic1.png" width="200"></a> * <br><code>305kB (1500x881)</code></td> * </td> * <td><code>36.46%</code></td> * </tr> * <tr> * <td> * <a href="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/original/scene2.png" target="blank"><img src="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/original/scene2.png" width="200"></a> * <br><code>16.5mb (5102x2488)</code> * </td> * <td> * <a href="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/compressed-0.7-1500px/scene2.png" target="blank"><img src="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/compressed-0.7-1500px/scene2.png" width="200"></a> * <br><code>155kB (1500x731)</code> * </td> * <td><code>99.06%</code></td> * </tr> * <tr> * <td> * <a href="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/original/scene1.png" target="blank"><img src="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/original/scene1.png" width="200"></a> * <br><code>11.9mb (5098x2480)</code> * </td> * <td> * <a href="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/compressed-0.7-1500px/scene1.png" target="blank"><img src="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/compressed-0.7-1500px/scene1.png" width="200"></a> * <br><code>97kB (1500x729)</code></td> * </td> * <td><code>99.18%</code></td> * </tr> * <tr> * <td> * <a href="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/original/thumb.gif" target="blank"><img src="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/original/thumb.gif" width="200"></a> * <br><code>4.7mb (770x702)</code> * </td> * <td> * <a href="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/compressed-0.7-1500px/thumb.gif" target="blank"><img src="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUploader/compressed-0.7-1500px/thumb.gif" width="200"></a> * <br><code>50kB (770x702)</code></td> * </td> * <td><code>98.94%</code></td> * </tr> * </table> * <br> * * <p><img src="https://raw.githubusercontent.com/michaelsharman/LT/main/src/assets/docs/images/imageUpload.gif" alt="" width="900"></p> * * <h2>Exclusions</h2> * <p>This extension doesn't run inside the simple features dialog. This mainly impacts posters for video files and background images for audio files.</p> * * @param {object} options.security - Security object returned from the SDK. * @param {object} options.request - Request object returned from the SDK. * @param {object=} [options.options={}] - Image upload options. * @param {number=} [options.options.quality=0.7] - JPEG/WebP quality (0–1). * @param {number=} [options.options.maxWidth=1500] - Max output width (px). * @param {number=} [options.options.maxHeight=1500] - Max output height (px). * * @example * const options = { * security, * request, * options: { * quality: 0.8, * maxWidth: 1200, * maxHeight: 1200 * } * } * * LT.init(authorApp, { * extensions: [ * { id: 'imageUploader', args: options }, * ], * }); * * @module Extensions/Authoring/imageUploader */ const state = { classNamePrefix: null, logPrefix: 'LT Image Uploader: ', observer: null, observedElements: new Map(), options: { quality: 0.7, maxWidth: 1500, maxHeight: 1500, }, upload: { request: null, security: null, uriUploadForm: checkUploadFormUri('https://authorapi.learnosity.com/latest-lts/assets/uploadform'), }, uppy: null, }; /** * Extension constructor. We require `security` and `request` to be passed in. * @since 2.10.0 * @param {object} args - Arguments object. * @ignore */ function run(args = {}) { const { security = {}, request = {}, options = {} } = args; if (!security || !request) { throw new TypeError('imageUploader.run: Missing `security` or `request`'); } state.upload.security = security; state.upload.request = request; overrideOptions(options); if (validateRunParams()) { LT.authorApp().on('widgetedit:widget:ready', setupModalObserver); } } /** * Listen for the Learnosity image upload modal to be ready. * @since 2.10.0 * @ignore */ function setupModalObserver() { LT.utils.logger.debug(`${state.logPrefix}setupModalObserver()`); state.classNamePrefix = checkAppVersion(state.classNamePrefix); clearObserver(); const callback = mutationsList => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { const modal = document.querySelector( '[data-authorapi-selector="asset-uploader-iframe-outlet"]:not(.lrn-author-slide-pane [data-authorapi-selector="asset-uploader-iframe-outlet"]):not(.lrn-qe-slide-pane [data-authorapi-selector="asset-uploader-iframe-outlet"])' ); const elResourceDisplayName = document.querySelector('[data-authorapi-selector="asset-display-name"]'); if (modal && !elResourceDisplayName) { LT.utils.logger.debug(`${state.logPrefix}Disconnecting observer`); clearObserver(); setupUploderUI(); break; } } } }; // Enable the observer if it's not already active if (!state.observedElements.size) { state.observer = new MutationObserver(callback); activateObserver(); } else { LT.utils.logger.debug(`${state.logPrefix}Observed elements full`); } } /** * Activates the mutation observer to watch for the * Learnosity image upload modal. * @since 2.10.0 * @ignore */ function activateObserver() { LT.utils.logger.debug(`${state.logPrefix}Looking to activate observer`); const parentElement = document.querySelector('.lrn-author-item'); if (!state.observedElements.has(parentElement)) { LT.utils.logger.debug(`${state.logPrefix}Activated observer`); state.observer.observe(parentElement, { childList: true, subtree: true }); state.observedElements.set(parentElement, state.observer); } } /** * Clears the modal observer and disconnects it. * @since 2.13.0 * @ignore */ function clearObserver() { state.observer?.disconnect(); state.observedElements.clear(); } /** * Hides the Learnosity upload UI (iframe) and * injects the custom uploader. * @since 2.10.0 * @ignore */ function setupUploderUI() { const elImageAlignment = document.querySelector('[data-authorapi-selector="asset-uploader-alignment"]'); const elImagePreview = document.querySelector(`.lrn-${state.classNamePrefix}image-uploader-preview`); const timeoutValue = !elImageAlignment && !elImagePreview ? 0 : 500; /** * It looks like Question Editor reloads the modal after opening. It could be based on the * structure of the modal required, which changes depending on where you are. * * This function is called by an observer, but we need a timeout to ensure the final modal * is fully loaded before we add our custom uploader. */ setTimeout(() => { const lrnImageUploader = document.querySelector('[data-authorapi-selector="asset-uploader-iframe-outlet"]'); const lrnFrame = lrnImageUploader.querySelector('iframe'); const elMoreOptions = document.querySelector(`.lrn-${state.classNamePrefix}adv-options`); const wrapper = document.createElement('div'); wrapper.setAttribute('id', 'uppy-dashboard'); lrnFrame.setAttribute('hidden', ''); lrnImageUploader.insertAdjacentElement('afterbegin', wrapper); listenForSelfHostedImages(); prepareModalButtons(); elMoreOptions.removeAttribute('hidden'); setupUploadLibrary(); }, timeoutValue); } /** * Initialises the library used for image management. * @since 2.10.0 * @ignore */ function setupUploadLibrary() { state.uppy = new Uppy({ debug: false, autoProceed: false, restrictions: { maxNumberOfFiles: 1, minNumberOfFiles: 1, allowedFileTypes: ['image/gif', 'image/jpeg', 'image/png', 'image/svg+xml'] }, }) .use(Dashboard, { inline: true, width: 790, height: 350, autoOpen: null, disableStatusBar: true, target: '#uppy-dashboard', showProgressDetails: false, proudlyDisplayPoweredByUppy: false, }) .use(Compressor, { quality: state.options.quality, convertSize: 500000, convertTypes: ['image/png'], maxHeight: state.options.maxHeight, maxWidth: state.options.maxWidth, }) .use(ImageEditor, { target: Dashboard }); state.uppy.on('file-added', file => { LT.utils.logger.debug(`${state.logPrefix}file-added: ${file.source}`); const elMoreOptions = document.querySelector(`.lrn-${state.classNamePrefix}adv-options`); elMoreOptions.setAttribute('hidden', ''); if (file.source === 'Dashboard') { compressImage(file); } }); state.uppy.on('file-removed', () => { LT.utils.logger.debug(`${state.logPrefix}file-removed`); toggleElement('lt__image-uploader-upload-btn', 'remove'); }); state.uppy.on('file-editor:start', () => { LT.utils.logger.debug(`${state.logPrefix}file-editor:start`); toggleElement('lt__image-uploader-upload-btn', 'disable'); }); state.uppy.on('file-editor:complete', updatedFile => { LT.utils.logger.debug(`${state.logPrefix}file-editor:complete`); compressImage(updatedFile); toggleElement('lt__image-uploader-upload-btn', 'enable'); }); state.uppy.on('file-editor:cancel', () => { LT.utils.logger.debug(`${state.logPrefix}file-editor:cancel`); toggleElement('lt__image-uploader-upload-btn', 'enable'); }); state.uppy.on('error', error => { LT.utils.logger.error(error.stack); }); } /** * Compresses all images (except SVG) * @since 2.10.0 * @ignore * @param {object} file */ function compressImage(file) { const name = file.name; const meta = file.meta; const type = file.type; // We don't try to compress SVGs if (type !== 'image/svg+xml') { LT.utils.logger.debug(`${state.logPrefix}Compressing image`); state.uppy .getPlugin('Compressor') .compress(file.data) .then(compressedFile => { setTimeout(() => { state.uppy.removeFile(file.id); state.uppy.addFile({ name: name, type: type, meta: meta, data: compressedFile, source: 'Local', }); const files = state.uppy.store.state.files; let newFileId; for (const file in files) { newFileId = file; } addUploadButton(newFileId); }, 50); }); } else { addUploadButton(file.id); } } /** * We add a custom upload button to the modal footer because * Uppy compresses on upload, meaning it happens twice. * @since 2.10.0 * @ignore * @param {string} fileId */ function addUploadButton(fileId) { const elFooter = document.querySelector(`.lrn-${state.classNamePrefix}modal-footer`); removeUploadButton(); const elUploadButton = document.createElement('button'); const cssOldSuffix = state.classNamePrefix ? '-old' : ''; elUploadButton.setAttribute( 'class', `lrn-${state.classNamePrefix}btn${cssOldSuffix} lrn-${state.classNamePrefix}btn${cssOldSuffix}-legacy lt__image-uploader-upload-btn` ); elUploadButton.textContent = 'Upload'; elFooter.insertAdjacentElement('afterbegin', elUploadButton); elUploadButton.addEventListener('click', () => uploadImage(fileId)); } /** * Removes the upload button from the UI when not needed. * @since 2.14.2 * @ignore */ function removeUploadButton() { const elExistingUploadButton = document.querySelector('.lt__image-uploader-upload-btn'); if (elExistingUploadButton) { LT.utils.logger.debug(`${state.logPrefix}Removing existing upload button`); elExistingUploadButton.remove(); } } /** * Uploads the image to Learnosity CDN. * First we get credentials and access tokens, then we upload. * @since 2.10.0 * @ignore * @param {string} fileId */ function uploadImage(fileId) { const elUploadButton = document.querySelector('.lt__image-uploader-upload-btn'); elUploadButton.removeEventListener('click', () => uploadImage(fileId)); const elEditButton = document.querySelector('.uppy-Dashboard-Item-action--edit'); elEditButton?.setAttribute('disabled', ''); const file = state.uppy.getFile(fileId); // Add loading spinner and hide text const elButton = document.querySelector('.lt__image-uploader-upload-btn'); elButton.setAttribute('style', 'width:105px;'); elButton.innerHTML = '<span class="lt__upload-spinner"><svg width="14" height="14" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_6kVp{transform-origin:center;animation:spinner_irSm .75s infinite linear}@keyframes spinner_irSm{100%{transform:rotate(360deg)};fill:#ffffff;}</style><path d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z" class="spinner_6kVp" style="fill: white"/></svg></span>'; elButton.setAttribute('disabled', ''); // Make the first request to get access details const formData = new FormData(); const request = { usrequest: { assetName: file.name, mimeType: file.type, fileType: 'image' }, action: 'get', security: state.upload.security, request: state.upload.request, }; formData.append('usrequest', JSON.stringify(request.usrequest)); formData.append('action', request.action); formData.append('security', JSON.stringify(request.security)); formData.append('request', JSON.stringify(request.request)); async function fetchTokens() { const response = await fetch(state.upload.uriUploadForm, { method: 'POST', body: formData, }); return response.json(); } fetchTokens() .then(response => { // Now we upload the image to the Learnosity CDN const uploadData = new FormData(); uploadData.append('key', response.data.formInputs.key); uploadData.append('Content-Type', response.data.formInputs['Content-Type']); uploadData.append('X-Amz-Security-Token', response.data.formInputs['X-Amz-Security-Token']); uploadData.append('X-Amz-Credential', response.data.formInputs['X-Amz-Credential']); uploadData.append('X-Amz-Algorithm', response.data.formInputs['X-Amz-Algorithm']); uploadData.append('X-Amz-Date', response.data.formInputs['X-Amz-Date']); uploadData.append('Policy', response.data.formInputs.Policy); uploadData.append('X-Amz-Signature', response.data.formInputs['X-Amz-Signature']); uploadData.append('file', file.data); async function uploadImageToCDN() { const uploadImageResponse = await fetch(response.data.formAttributes.action, { method: 'POST', body: uploadData, }); return uploadImageResponse; } uploadImageToCDN() .then(() => { const assetUrl = response.data.assetUrl; const src = document.querySelector('[data-authorapi-selector="asset-uploader-source"]'); src.value = assetUrl.trim(); src.dispatchEvent(new Event('input', { bubbles: true })); LT.utils.logger.debug(`${state.logPrefix}Added image path to URI`); setTimeout(() => { removeUploadButton(); const btnReset = document.querySelector( `.lrn-author-item .lrn-${state.classNamePrefix}delete-btn-wrapper [data-authorapi-action="asset-uploader-delete"]` ); const elAltText = document.querySelector( `.lrn-author-item .lrn-${state.classNamePrefix}image-uploader [data-authorapi-selector="asset-uploader-alignment"]` ); if (btnReset && !elAltText) { const btnOk = document.querySelector('[data-authorapi-selector="asset-uploader-okay"]'); if (btnOk) { btnOk.click(); LT.utils.logger.debug(`${state.logPrefix}Clicked OK button for background images`); } } prepareModalButtons(); }, 1500); }) .catch(error => console.error('Error in uploading image:', error)); }) .catch(error => console.error('Error in fetching tokens:', error)); } /** * Sets a listener for pasting self-hosted image URIs * @since 2.14.2 * @ignore */ function listenForSelfHostedImages() { LT.utils.logger.debug(`${state.logPrefix}listenForSelfHostedImages()`); /** * It looks like Question Editor reloads the modal after opening. It could be based on the * structure of the modal required, which changes depending on where you are. * * This function is called by an observer, but we need a timeout to ensure the final modal * is fully loaded before we add our custom uploader. */ setTimeout(() => { const src = document.querySelector('[data-authorapi-selector="asset-uploader-source"]'); if (src) { src.addEventListener('input', handleSelfHostedImage); } }, 500); } /** * Event handler if an author add a self-hosted image URI * @since 2.14.2 * @ignore */ function handleSelfHostedImage() { LT.utils.logger.debug(`${state.logPrefix}handleSelfHostedImage()`); setTimeout(() => { prepareModalButtons(); }, 1500); } /** * Activates the mutation observer (listening for the image upload modal) * when the model is closed. * @since 2.10.0 * @ignore * @param {object} modalParent */ function prepareModalButtons() { LT.utils.logger.debug(`${state.logPrefix}prepareModalButtons()`); const elCloseButtons = [ `lrn-${state.classNamePrefix}modal-button-close`, `lrn-${state.classNamePrefix}btn-default`, `lrn-${state.classNamePrefix}btn-primary-legacy`, `lrn-${state.classNamePrefix}btn-sec`, ]; const modalParent = document.querySelector(`.lrn-${state.classNamePrefix}modal`); removeHandler(); // Waiting for footer buttons to appear so we can add click events function waitForElement(parentWrapper, selector, callback) { const observer = new MutationObserver((mutationsList, observer) => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { const element = document.querySelector(selector); if (element) { callback(element); observer.disconnect(); } } } }); observer.observe(parentWrapper, { childList: true, subtree: true }); // In case the element is already present when this function is called const initialCheck = document.querySelector(selector); if (initialCheck) { callback(initialCheck); observer.disconnect(); } } setTimeout(() => { waitForElement(modalParent, `.lrn-${state.classNamePrefix}modal-footer .lrn-${state.classNamePrefix}delete-btn-wrapper`, () => { LT.utils.logger.debug(`${state.logPrefix}waitForElement() observed`); for (const btn of elCloseButtons) { const elBtn = modalParent.querySelector(`.lrn-${state.classNamePrefix}modal-dialog button.${btn}`); if (elBtn) { elBtn.addEventListener('click', clickHandler); LT.utils.logger.debug(`Adding clickHanders for: ${btn}`); LT.utils.logger.debug(elBtn); } } }); }, 100); function clickHandler() { LT.utils.logger.debug(`${state.logPrefix}clickHandler()`); removeHandler(); // Wait to set a click event for the modal to close // We don't want the observer firing while still open setTimeout(() => { activateObserver(); }, 1000); } function removeHandler() { for (const btn of elCloseButtons) { const elBtn = modalParent.querySelector(`.lrn-${state.classNamePrefix}modal-dialog button.${btn}`); if (elBtn) { LT.utils.logger.debug(`${state.logPrefix}Removed clickHandler`); elBtn.removeEventListener('click', clickHandler); } } const src = document.querySelector('[data-authorapi-selector="asset-uploader-source"]'); if (src) { src.removeEventListener('input', handleSelfHostedImage); } } } /** * Checks for Author API upload form URI to see * if we're running in staging or production. * For Learnosity development only. * @since 2.10.0 * @ignore * @param {string} uri * @returns {string} */ function checkUploadFormUri(uri) { const urlParams = new URLSearchParams(window.location.search); const domain = window.location.hostname; const env = urlParams.get('env'); if (domain.includes('learnosity.com') && env === 'staging') { return uri.replace('authorapi.', 'authorapi.staging.'); } return uri; } /** * Calling page can override certain compression options. * @since 2.10.0 * @ignore * @param {object} options */ function overrideOptions(options) { ['quality', 'maxWidth', 'maxHeight'].forEach(prop => { if (typeof options?.[prop] === 'number') { state.options[prop] = options[prop]; } }); } /** * Validates that the calling page passed `security` and `request` * to the `run()` method. * @since 2.10.0 * @ignore * @returns {boolean} */ function validateRunParams() { if (!state.upload.security || !state.upload.request || typeof state.upload.security !== 'object' || typeof state.upload.request !== 'object') { LT.utils.logger.error(`${state.logPrefix}imageUploader extension failed to run - Missing/invalid security or request parameters`); return false; } return true; } /** * Sets an action on an element. * @since 2.10.0 * @ignore * @param {string} classname * @param {string} action */ function toggleElement(classname, action) { const el = document.querySelector(`.${classname}`); if (el) { if (action === 'disable') { el.setAttribute('disabled', ''); } else if (action === 'enable') { el.removeAttribute('disabled'); } else if (action === 'remove') { el.remove(); } } } /** * Returns the extension CSS * @since 3.0.0 * @ignore */ function getStyles() { const vendorCSS = [uppyCore, uppyDashboard, uppyImageEditor].join('\n'); const css = ` /* Learnosity custom image uploader (DAM) */ /* Used to style content tabs added by via rich-text editor */ .lrn .lrn-author-ui-no-preview .uppy-c-btn, .lrn .lrn-author-ui-no-preview button.uppy-c-btn { color: #fff; } .lrn .lrn-author-ui-no-preview button.uppy-Dashboard-browse { color: #1269cf; } .lrn .uppy-Dashboard-inner { margin-bottom: 15px; } .lrn .lrn-author-ui-no-preview .uppy-Dashboard-Item-actionWrapper button { color: inherit; } .lrn .uppy-StatusBar.is-waiting .uppy-StatusBar-actionBtn--upload, .lrn .uppy-StatusBar.is-waiting .uppy-StatusBar-actionBtn--upload:hover { background-color: #1877b1; } .lrn .lrn-author-ui-no-preview button.lt__image-uploader-upload-btn { color: #fff; background: #1877b1; } .lrn .lrn-author-ui-no-preview button.lt__image-uploader-upload-btn[disabled], .lrn .lrn-author-ui-no-preview button.lt__image-uploader-upload-btn[disabled]:hover, .lrn .lrn-author-ui-no-preview button.lt__image-uploader-upload-btn[disabled]:focus { color: #d9d9d9; border-color: #96b7cb; background: #96b7cb; } .lrn .uppy-Dashboard-input[type=file] { display: none; } `; return `${vendorCSS}\n\n${css}`; } export const imageUploader = createExtension('imageUploader', run, { getStyles, });