Support multi-file paste/drag-n-drop in the RCE
Extends the existing enhanced paste/drag-n-drop functionality to support inserting multiple files at once. Also fixes a few issues with the new placeholders that are related to the changes needed for multi-file paste. Closes MAT-1263 and MAT-1333 flag=rce_better_paste QA-risk medium: - Changes were made to the file upload dialog that aren't behind the feature flag. The file upload dialog should be checked to ensure it wasn't broken by these changes. Test plan: - Multi-file paste: - Copy multiple images in Finder, paste them into an RCE - Drag-n-drop multiple files from Finder - Ensure they all load correctly, and in the correct order - Repeat the above with copyright requirement enabled for the course - Placeholder whitespace - Enable rce_improved_placeholders - Insert "something" into an RCE - Place cursor in the middle of the word - Insert an image, video, or file - Ensure that no additional whitespace is inserted Change-Id: I0314276656765ff8a8f6308286cbfdaed60502ef Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/315854 Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com> Reviewed-by: Jacob DeWar <jacob.dewar@instructure.com> QA-Review: Jacob DeWar <jacob.dewar@instructure.com> Product-Review: Allison Howell <allison.howell@instructure.com>
This commit is contained in:
parent
80b844a574
commit
3cb0a7f4ef
|
@ -184,8 +184,9 @@ export default class Bridge {
|
|||
|
||||
insertImage(image) {
|
||||
if (this.focusedEditor) {
|
||||
this.focusedEditor.insertImage(image)
|
||||
const result = this.focusedEditor.insertImage(image)
|
||||
this.controller(this.focusedEditor.id)?.hideTray()
|
||||
return result
|
||||
} else {
|
||||
console.warn('clicked sidebar image without a focused editor')
|
||||
}
|
||||
|
@ -193,7 +194,8 @@ export default class Bridge {
|
|||
|
||||
insertImagePlaceholder(fileMetaProps) {
|
||||
if (this.focusedEditor) {
|
||||
// don't insert a placeholder if the user has selected content
|
||||
// don't insert a placeholder if the user has selected content, because in some cases the selected
|
||||
// content will be used as the content of a link
|
||||
if (!this.existingContentToLink()) {
|
||||
this.focusedEditor.insertImagePlaceholder(fileMetaProps)
|
||||
}
|
||||
|
|
|
@ -72,7 +72,7 @@ import styles from '../skins/skin-delta.css'
|
|||
import skinCSSBinding from 'tinymce/skins/ui/oxide/skin.min.css'
|
||||
import contentCSSBinding from 'tinymce/skins/ui/oxide/content.css'
|
||||
import {rceWrapperPropTypes} from './RCEWrapperProps'
|
||||
import {removePlaceholder} from '../util/loadingPlaceholder'
|
||||
import {insertPlaceholder, placeholderInfoFor, removePlaceholder} from '../util/loadingPlaceholder'
|
||||
import {transformRceContentForEditing} from './transformContent'
|
||||
import {IconMoreSolid} from '@instructure/ui-icons/es/svg'
|
||||
|
||||
|
@ -506,23 +506,37 @@ class RCEWrapper extends React.Component {
|
|||
const element = contentInsertion.insertImage(editor, image, this.getCanvasUrl())
|
||||
|
||||
// Removes TinyMCE's caret text if exists.
|
||||
if (element?.nextSibling?.data?.trim() === '') {
|
||||
if (element?.nextSibling?.data?.startsWith('\xA0' /* nbsp */)) {
|
||||
element.nextSibling.splitText(1)
|
||||
element.nextSibling.remove()
|
||||
}
|
||||
|
||||
if (element && element.complete) {
|
||||
this.contentInserted(element)
|
||||
} else if (element) {
|
||||
element.onload = () => this.contentInserted(element)
|
||||
element.onerror = () => this.checkImageLoadError(element)
|
||||
return {
|
||||
imageElem: element,
|
||||
loadingPromise: new Promise((resolve, reject) => {
|
||||
if (element && element.complete) {
|
||||
this.contentInserted(element)
|
||||
resolve()
|
||||
} else if (element) {
|
||||
element.onload = () => {
|
||||
this.contentInserted(element)
|
||||
resolve()
|
||||
}
|
||||
element.onerror = e => {
|
||||
this.checkImageLoadError(element)
|
||||
reject(e)
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
insertImagePlaceholder(fileMetaProps) {
|
||||
if (this.props.features?.rce_improved_placeholders) {
|
||||
return import('../util/loadingPlaceholder').then(
|
||||
async ({placeholderInfoFor, insertPlaceholder}) =>
|
||||
insertPlaceholder(this.mceInstance(), await placeholderInfoFor(fileMetaProps))
|
||||
return insertPlaceholder(
|
||||
this.mceInstance(),
|
||||
fileMetaProps.name,
|
||||
placeholderInfoFor(fileMetaProps)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -622,8 +636,7 @@ class RCEWrapper extends React.Component {
|
|||
|
||||
removePlaceholders(name) {
|
||||
if (this.props.features?.rce_improved_placeholders) {
|
||||
// Note that this needs to be done synchronously, or the image inserting code doesn't work
|
||||
removePlaceholder(this.mceInstance(), encodeURIComponent(name))
|
||||
removePlaceholder(this.mceInstance(), name)
|
||||
} else {
|
||||
const placeholder = this.mceInstance().dom.doc.querySelector(
|
||||
`[data-placeholder-for="${encodeURIComponent(name)}"]`
|
||||
|
|
|
@ -580,22 +580,27 @@ describe('RCEWrapper', () => {
|
|||
})
|
||||
|
||||
describe('broken images', () => {
|
||||
it('calls checkImageLoadError when complete', () => {
|
||||
it('calls checkImageLoadError when complete', async () => {
|
||||
const image = {complete: true}
|
||||
jest.spyOn(rce, 'checkImageLoadError')
|
||||
jest.spyOn(contentInsertion, 'insertImage').mockReturnValue(image)
|
||||
rce.insertImage(image)
|
||||
const result = rce.insertImage(image)
|
||||
expect(rce.checkImageLoadError).toHaveBeenCalled()
|
||||
|
||||
return result.loadingPromise
|
||||
})
|
||||
|
||||
it('sets an onerror handler when not complete', () => {
|
||||
it('sets an onerror handler when not complete', async () => {
|
||||
const image = {complete: false}
|
||||
jest.spyOn(rce, 'checkImageLoadError')
|
||||
jest.spyOn(contentInsertion, 'insertImage').mockReturnValue(image)
|
||||
rce.insertImage(image)
|
||||
const result = rce.insertImage(image)
|
||||
expect(typeof image.onerror).toEqual('function')
|
||||
image.onerror()
|
||||
expect(rce.checkImageLoadError).toHaveBeenCalled()
|
||||
|
||||
// We need to handle the rejection by the loadingPromise, otherwise it'll cause issues in future tests
|
||||
return result.loadingPromise.catch(() => {})
|
||||
})
|
||||
|
||||
describe('checkImageLoadError', () => {
|
||||
|
@ -615,6 +620,7 @@ describe('RCEWrapper', () => {
|
|||
naturalWidth: 0,
|
||||
style: {},
|
||||
}
|
||||
|
||||
rce.checkImageLoadError(fakeElement)
|
||||
expect(Object.keys(fakeElement.style).length).toEqual(0)
|
||||
fakeElement.complete = true
|
||||
|
|
|
@ -19,5 +19,5 @@
|
|||
import doFileUpload from '../shared/Upload/doFileUpload'
|
||||
|
||||
export default function (ed, document) {
|
||||
return doFileUpload(ed, document, {panels: ['COMPUTER']})
|
||||
return doFileUpload(ed, document, {panels: ['COMPUTER']}).shownPromise
|
||||
}
|
||||
|
|
|
@ -22,5 +22,5 @@ export default function (ed, document) {
|
|||
return doFileUpload(ed, document, {
|
||||
accept: 'image/*',
|
||||
panels: ['COMPUTER', 'URL'],
|
||||
})
|
||||
}).shownPromise
|
||||
}
|
||||
|
|
|
@ -21,17 +21,18 @@ import bridge from '../../../bridge'
|
|||
import configureStore from '../../../sidebar/store/configureStore'
|
||||
import {get as getSession} from '../../../sidebar/actions/session'
|
||||
import {uploadToMediaFolder} from '../../../sidebar/actions/upload'
|
||||
import doFileUpload from '../shared/Upload/doFileUpload'
|
||||
import doFileUpload, {DoFileUploadResult} from '../shared/Upload/doFileUpload'
|
||||
import formatMessage from '../../../format-message'
|
||||
import {isAudioOrVideo, isImage} from '../shared/fileTypeUtils'
|
||||
import {showFlashAlert} from '../../../common/FlashAlert'
|
||||
import {TsMigrationAny} from '../../../types/ts-migration'
|
||||
import {
|
||||
isMicrosoftWordContentInEvent,
|
||||
RCEClipOrDragEvent,
|
||||
TinyClipboardEvent,
|
||||
TinyDragEvent,
|
||||
RCEClipOrDragEvent,
|
||||
isMicrosoftWordContentInEvent,
|
||||
} from '../shared/EventUtils'
|
||||
import RCEWrapper from '../../RCEWrapper'
|
||||
|
||||
// assume that if there are multiple RCEs on the page,
|
||||
// they all talk to the same canvas
|
||||
|
@ -73,99 +74,112 @@ function initStore(initProps) {
|
|||
return config.store
|
||||
}
|
||||
|
||||
// if the context requires usage rights to publish a file
|
||||
// open the UI for that instead of automatically uploading
|
||||
function getUsageRights(ed: Editor, document: Document, theFile: File) {
|
||||
return doFileUpload(ed, document, {
|
||||
accept: theFile.type,
|
||||
panels: ['COMPUTER'],
|
||||
preselectedFile: theFile,
|
||||
})
|
||||
}
|
||||
|
||||
function handleMultiFilePaste(_files) {
|
||||
showFlashAlert({
|
||||
message: formatMessage("Sorry, we don't support multiple files."),
|
||||
type: 'info',
|
||||
} as TsMigrationAny)
|
||||
}
|
||||
|
||||
tinymce.PluginManager.add('instructure_paste', function (ed: Editor) {
|
||||
const store = initStore(bridge.trayProps.get(ed))
|
||||
|
||||
function handlePasteOrDrop(event: RCEClipOrDragEvent) {
|
||||
const cbdata =
|
||||
event.type === 'paste'
|
||||
? (event as TinyClipboardEvent).clipboardData
|
||||
: (event as TinyDragEvent).dataTransfer
|
||||
const files = cbdata?.files || []
|
||||
const types = cbdata?.types || []
|
||||
|
||||
if (types.includes('Files')) {
|
||||
if (files.length > 1) {
|
||||
event.preventDefault()
|
||||
handleMultiFilePaste(files)
|
||||
return
|
||||
}
|
||||
|
||||
if (isMicrosoftWordContentInEvent(event)) {
|
||||
// delegate to tiny
|
||||
return
|
||||
}
|
||||
|
||||
// we're pasting a file
|
||||
event.preventDefault()
|
||||
const file = files[0]
|
||||
if (bridge.activeEditor().props.instRecordDisabled && isAudioOrVideo(file.type)) {
|
||||
return
|
||||
}
|
||||
tinymce.PluginManager.add(
|
||||
'instructure_paste',
|
||||
function (editor: Editor & {rceWrapper?: RCEWrapper}) {
|
||||
const store = initStore(bridge.trayProps.get(editor))
|
||||
|
||||
/**
|
||||
* Starts the file upload (and insertion) process for the given file.
|
||||
*
|
||||
* If usage rights are required, a dialog will be displayed.
|
||||
*
|
||||
* @returns a promise that resolves when the user has made their choice about uploading the file
|
||||
*/
|
||||
async function requestFileInsertion(file: File): Promise<DoFileUploadResult> {
|
||||
// it's very doubtful that we won't have retrieved the session data yet,
|
||||
// since it takes a while for the RCE to initialize, but if we haven't
|
||||
// wait until we do to carry on and finish pasting.
|
||||
// eslint-disable-next-line promise/catch-or-return
|
||||
config.sessionPromise.finally(() => {
|
||||
if (config.session === null) {
|
||||
// we failed to get the session and don't know if usage rights are required in this course|group
|
||||
// In all probability, the file upload will fail too, but I feel like we have to do something here.
|
||||
showFlashAlert({
|
||||
message: formatMessage(
|
||||
'If Usage Rights are required, the file will not publish until enabled in the Files page.'
|
||||
),
|
||||
type: 'info',
|
||||
} as TsMigrationAny)
|
||||
await config.sessionPromise
|
||||
|
||||
if (config.session === null) {
|
||||
// we failed to get the session and don't know if usage rights are required in this course|group
|
||||
// In all probability, the file upload will fail too, but I feel like we have to do something here.
|
||||
showFlashAlert({
|
||||
message: formatMessage(
|
||||
'If Usage Rights are required, the file will not publish until enabled in the Files page.'
|
||||
),
|
||||
type: 'info',
|
||||
} as TsMigrationAny)
|
||||
}
|
||||
|
||||
// even though usage rights might be required by the course, canvas has no place
|
||||
// on the user to store it. Only Group and Course.
|
||||
const requiresUsageRights =
|
||||
config.session.usageRightsRequired &&
|
||||
/course|group/.test(bridge.trayProps.get(editor).contextType)
|
||||
|
||||
if (requiresUsageRights) {
|
||||
return doFileUpload(editor, document, {
|
||||
accept: file.type,
|
||||
panels: ['COMPUTER'],
|
||||
preselectedFile: file,
|
||||
}).closedPromise
|
||||
} else {
|
||||
const fileMetaProps = {
|
||||
altText: file.name,
|
||||
contentType: file.type,
|
||||
displayAs: 'embed',
|
||||
isDecorativeImage: false,
|
||||
name: file.name,
|
||||
parentFolderId: 'media',
|
||||
size: file.size,
|
||||
domObject: file,
|
||||
}
|
||||
// even though usage rights might be required by the course, canvas has no place
|
||||
// on the user to store it. Only Group and Course.
|
||||
const requiresUsageRights =
|
||||
config.session.usageRightsRequired &&
|
||||
/(?:course|group)/.test(bridge.trayProps.get(ed).contextType)
|
||||
if (requiresUsageRights) {
|
||||
return getUsageRights(ed, document, file)
|
||||
} else {
|
||||
const fileMetaProps = {
|
||||
altText: file.name,
|
||||
contentType: file.type,
|
||||
displayAs: 'embed',
|
||||
isDecorativeImage: false,
|
||||
name: file.name,
|
||||
parentFolderId: 'media',
|
||||
size: file.size,
|
||||
domObject: file,
|
||||
}
|
||||
let tabContext = 'documents'
|
||||
if (isImage(file.type)) {
|
||||
tabContext = 'images'
|
||||
} else if (isAudioOrVideo(file.type)) {
|
||||
tabContext = 'media'
|
||||
}
|
||||
store.dispatch(uploadToMediaFolder(tabContext, fileMetaProps))
|
||||
|
||||
let tabContext = 'documents'
|
||||
|
||||
if (isImage(file.type)) {
|
||||
tabContext = 'images'
|
||||
} else if (isAudioOrVideo(file.type)) {
|
||||
tabContext = 'media'
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// delegate to tiny
|
||||
|
||||
store.dispatch(uploadToMediaFolder(tabContext, fileMetaProps))
|
||||
|
||||
return 'submitted'
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasteOrDrop(event: RCEClipOrDragEvent) {
|
||||
const dataTransfer =
|
||||
event.type === 'paste'
|
||||
? (event as TinyClipboardEvent).clipboardData
|
||||
: (event as TinyDragEvent).dataTransfer
|
||||
const files = dataTransfer?.files || []
|
||||
const types = dataTransfer?.types || []
|
||||
|
||||
const isAudioVideoDisabled = bridge.activeEditor()?.props?.instRecordDisabled
|
||||
|
||||
// delegate to tiny if there aren't any files to handle
|
||||
if (!types.includes('Files')) return
|
||||
|
||||
// delegate to tiny if there is Microsoft Word content, because it may contain an image
|
||||
// rendering of the content and we don't want to incorrectly paste the image
|
||||
// instead of the actual rich content, which TinyMCE has special handing for
|
||||
if (isMicrosoftWordContentInEvent(event)) return
|
||||
|
||||
// we're pasting file(s), prevent the default tinymce pasting behavior
|
||||
event.preventDefault()
|
||||
|
||||
// Ensure the editor has focus, because downstream code requires that it does, and drag-n-drop
|
||||
// events can be started when the editor doesn't have focus.
|
||||
if (!editor.hasFocus()) editor.rceWrapper?.focus()
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
if (isAudioVideoDisabled && isAudioOrVideo(file.type)) {
|
||||
// Skip audio and video files when disabled
|
||||
continue
|
||||
}
|
||||
|
||||
// This will finish once the dialog is closed, if one was created, putting this in a loop allows us
|
||||
// to show a dialog for each file without them conflicting.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await requestFileInsertion(file)
|
||||
}
|
||||
}
|
||||
|
||||
editor.on('paste', handlePasteOrDrop)
|
||||
editor.on('drop', handlePasteOrDrop)
|
||||
}
|
||||
ed.on('paste', handlePasteOrDrop)
|
||||
ed.on('drop', handlePasteOrDrop)
|
||||
})
|
||||
)
|
||||
|
|
|
@ -102,7 +102,7 @@ export function externalToolsEnvFor(
|
|||
editor: ExternalToolsEditor | null | undefined
|
||||
): ExternalToolsEnv {
|
||||
const props: () => RCEWrapperProps | undefined = () =>
|
||||
(RCEWrapper.getByEditor(editor)?.props as RCEWrapperProps) ?? undefined
|
||||
(RCEWrapper.getByEditor(editor as Editor)?.props as RCEWrapperProps) ?? undefined
|
||||
let cachedCanvasToolId: string | null | undefined
|
||||
|
||||
function nonNullishArray<T>(
|
||||
|
@ -115,7 +115,7 @@ export function externalToolsEnvFor(
|
|||
editor: editor ?? null,
|
||||
|
||||
get rceWrapper() {
|
||||
return RCEWrapper.getByEditor(editor) ?? null
|
||||
return RCEWrapper.getByEditor(editor as Editor) ?? null
|
||||
},
|
||||
|
||||
get availableRceLtiTools(): RceLtiToolInfo[] {
|
||||
|
|
|
@ -27,7 +27,6 @@ import {RceLti11ContentItem} from '../../lti11-content-items/RceLti11ContentItem
|
|||
import formatMessage from '../../../../../format-message'
|
||||
import {TsMigrationAny} from '../../../../../types/ts-migration'
|
||||
import {ExternalToolsEnv} from '../../ExternalToolsEnv'
|
||||
import RCEWrapper from '../../../../RCEWrapper'
|
||||
import {RceToolWrapper} from '../../RceToolWrapper'
|
||||
import {instuiPopupMountNode} from '../../../../../util/fullscreenHelpers'
|
||||
import {ExternalToolDialogTray} from './ExternalToolDialogTray'
|
||||
|
@ -138,13 +137,11 @@ export default class ExternalToolDialog extends React.Component<
|
|||
return
|
||||
}
|
||||
|
||||
const editor = env.editor
|
||||
|
||||
const contentItems = data.contentItems
|
||||
if (contentItems.length === 1 && contentItems[0]['@type'] === 'lti_replace') {
|
||||
const code = contentItems[0].text
|
||||
|
||||
RCEWrapper.getByEditor(editor).setCode(code)
|
||||
env.rceWrapper?.setCode(code)
|
||||
} else {
|
||||
contentItems.forEach(contentData => {
|
||||
const code = RceLti11ContentItem.fromJSON(
|
||||
|
@ -155,7 +152,7 @@ export default class ExternalToolDialog extends React.Component<
|
|||
env
|
||||
).codePayload
|
||||
|
||||
RCEWrapper.getByEditor(editor).insertCode(code)
|
||||
env.rceWrapper?.insertCode(code)
|
||||
})
|
||||
}
|
||||
this.close()
|
||||
|
|
|
@ -82,7 +82,7 @@ const rceMock = createDeepMockProxy<RCEWrapper>(
|
|||
contextId: '1',
|
||||
contextType: 'course',
|
||||
},
|
||||
},
|
||||
}, // satisfies Partial<RCEWrapperProps> as any,
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -148,7 +148,7 @@ describe('getFilterResults', () => {
|
|||
describe('ExternalToolDialog', () => {
|
||||
beforeAll(() => {
|
||||
jest.spyOn(RCEWrapper, 'getByEditor').mockImplementation(e => {
|
||||
if (e === editorMock) return rceMock
|
||||
if (e === (editorMock as any)) return rceMock
|
||||
else {
|
||||
throw new Error('Wrong editor requested')
|
||||
}
|
||||
|
@ -394,7 +394,7 @@ describe('ExternalToolDialog', () => {
|
|||
beforeAll(() => {
|
||||
rceMock.props.externalToolsConfig = {
|
||||
isA2StudentView: true,
|
||||
}
|
||||
} // satisfies RCEWrapperProps['externalToolsConfig'] as any
|
||||
})
|
||||
|
||||
it('does not insert content items into the editor', async () => {
|
||||
|
|
|
@ -16,32 +16,36 @@
|
|||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, {useEffect, useState} from 'react'
|
||||
import React, {Component, useEffect, useState} from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import {arrayOf, bool, func, object, oneOf, oneOfType, string} from 'prop-types'
|
||||
import {px} from '@instructure/ui-utils'
|
||||
import indicatorRegion from '../../../indicatorRegion'
|
||||
import {isImage, isAudioOrVideo} from '../fileTypeUtils'
|
||||
import {isAudioOrVideo, isImage} from '../fileTypeUtils'
|
||||
import indicate from '../../../../common/indicate'
|
||||
|
||||
import {StoreProvider} from '../StoreContext'
|
||||
|
||||
import Bridge from '../../../../bridge'
|
||||
import UploadFileModal from './UploadFileModal'
|
||||
import RCEWrapper from '../../../RCEWrapper'
|
||||
import {Editor} from 'tinymce'
|
||||
|
||||
export const UploadFilePanelIds = ['COMPUTER', 'URL'] as const
|
||||
export type UploadFilePanelId = (typeof UploadFilePanelIds)[number]
|
||||
|
||||
/**
|
||||
* Handles uploading data based on what type of data is submitted.
|
||||
*/
|
||||
export const handleSubmit = (
|
||||
editor,
|
||||
accept,
|
||||
selectedPanel,
|
||||
editor: Editor,
|
||||
accept: string,
|
||||
selectedPanel: UploadFilePanelId,
|
||||
uploadData,
|
||||
storeProps,
|
||||
source,
|
||||
afterInsert = () => {}
|
||||
afterInsert: Function = () => undefined
|
||||
) => {
|
||||
Bridge.focusEditor(editor.rceWrapper) // necessary since it blurred when the modal opened
|
||||
Bridge.focusEditor(RCEWrapper.getByEditor(editor)) // necessary since it blurred when the modal opened
|
||||
const {altText, isDecorativeImage, displayAs} = uploadData?.imageOptions || {}
|
||||
switch (selectedPanel) {
|
||||
case 'COMPUTER': {
|
||||
|
@ -74,7 +78,7 @@ export const handleSubmit = (
|
|||
editorHtml = editor.dom.createHTML('img', {
|
||||
src: fileUrl,
|
||||
alt: altText,
|
||||
role: isDecorativeImage ? 'presentation' : undefined,
|
||||
...(isDecorativeImage ? {role: 'presentation'} : null),
|
||||
})
|
||||
} else {
|
||||
editorHtml = editor.dom.createHTML('a', {href: fileUrl}, altText || fileUrl)
|
||||
|
@ -90,6 +94,19 @@ export const handleSubmit = (
|
|||
afterInsert()
|
||||
}
|
||||
|
||||
export interface UploadFileProps {
|
||||
onSubmit?: Function
|
||||
onDismiss: Function
|
||||
accept?: string[] | string
|
||||
editor: Editor
|
||||
label: string
|
||||
panels?: UploadFilePanelId[]
|
||||
requireA11yAttributes?: boolean
|
||||
trayProps?: object
|
||||
canvasOrigin?: string
|
||||
preselectedFile?: File // a JS File
|
||||
}
|
||||
|
||||
export function UploadFile({
|
||||
accept,
|
||||
editor,
|
||||
|
@ -101,11 +118,11 @@ export function UploadFile({
|
|||
canvasOrigin,
|
||||
onSubmit = handleSubmit,
|
||||
preselectedFile = undefined,
|
||||
}) {
|
||||
const [modalBodyWidth, setModalBodyWidth] = useState(undefined)
|
||||
const [modalBodyHeight, setModalBodyHeight] = useState(undefined)
|
||||
}: UploadFileProps) {
|
||||
const [modalBodyWidth, setModalBodyWidth] = useState(undefined as number | undefined)
|
||||
const [modalBodyHeight, setModalBodyHeight] = useState(undefined as number | undefined)
|
||||
const [theFile] = useState(preselectedFile)
|
||||
const bodyRef = React.useRef()
|
||||
const bodyRef = React.useRef<Component>()
|
||||
|
||||
trayProps = trayProps || Bridge.trayProps.get(editor)
|
||||
|
||||
|
@ -116,8 +133,8 @@ export function UploadFile({
|
|||
useEffect(() => {
|
||||
if (bodyRef.current) {
|
||||
// eslint-disable-next-line react/no-find-dom-node
|
||||
const thebody = ReactDOM.findDOMNode(bodyRef.current)
|
||||
const sz = thebody.getBoundingClientRect()
|
||||
const thebody = ReactDOM.findDOMNode(bodyRef.current) as Element
|
||||
const sz = thebody?.getBoundingClientRect()
|
||||
sz.height -= px('3rem') // leave room for the tabs
|
||||
setModalBodyWidth(sz.width)
|
||||
setModalBodyHeight(sz.height)
|
||||
|
@ -129,6 +146,7 @@ export function UploadFile({
|
|||
{contentProps => (
|
||||
<UploadFileModal
|
||||
ref={bodyRef}
|
||||
// @ts-ignore
|
||||
preselectedFile={theFile}
|
||||
editor={editor}
|
||||
trayProps={trayProps}
|
||||
|
@ -147,16 +165,3 @@ export function UploadFile({
|
|||
</StoreProvider>
|
||||
)
|
||||
}
|
||||
|
||||
UploadFile.propTypes = {
|
||||
onSubmit: func,
|
||||
onDismiss: func.isRequired,
|
||||
accept: oneOfType([arrayOf(string), string]),
|
||||
editor: object.isRequired,
|
||||
label: string.isRequired,
|
||||
panels: arrayOf(oneOf(['COMPUTER', 'URL'])),
|
||||
requireA11yAttributes: bool,
|
||||
trayProps: object,
|
||||
canvasOrigin: string,
|
||||
preselectedFile: object, // a JS File
|
||||
}
|
|
@ -60,7 +60,7 @@ describe('doFileUpload()', () => {
|
|||
accept: undefined,
|
||||
panels: ['COMPUTER', 'URL'],
|
||||
preselectedFile: undefined,
|
||||
})
|
||||
}).shownPromise
|
||||
expect(document.querySelector('.canvas-rce-upload-container')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
@ -72,7 +72,7 @@ describe('doFileUpload()', () => {
|
|||
accept: undefined,
|
||||
panels: ['COMPUTER'],
|
||||
preselectedFile: undefined,
|
||||
})
|
||||
}).shownPromise
|
||||
expect(document.querySelectorAll('.canvas-rce-upload-container').length).toEqual(1)
|
||||
})
|
||||
|
||||
|
@ -81,7 +81,7 @@ describe('doFileUpload()', () => {
|
|||
accept: 'image/*',
|
||||
panels: ['COMPUTER'],
|
||||
preselectedFile: undefined,
|
||||
})
|
||||
}).shownPromise
|
||||
expect(
|
||||
getAllByLabelText(document, 'Upload Image', {
|
||||
selector: '[role="dialog"]',
|
||||
|
@ -94,7 +94,7 @@ describe('doFileUpload()', () => {
|
|||
accept: undefined,
|
||||
panels: ['COMPUTER'],
|
||||
preselectedFile: undefined,
|
||||
})
|
||||
}).shownPromise
|
||||
expect(
|
||||
getAllByLabelText(document, 'Upload File', {
|
||||
selector: '[role="dialog"]',
|
||||
|
@ -107,7 +107,7 @@ describe('doFileUpload()', () => {
|
|||
accept: undefined,
|
||||
panels: ['COMPUTER'],
|
||||
preselectedFile: undefined,
|
||||
})
|
||||
}).shownPromise
|
||||
expect(
|
||||
getAllByLabelText(document, 'Upload File', {
|
||||
selector: '[role="dialog"]',
|
||||
|
@ -127,7 +127,7 @@ describe('doFileUpload()', () => {
|
|||
accept: 'image/*',
|
||||
panels: ['COMPUTER', 'URL'],
|
||||
preselectedFile: undefined,
|
||||
})
|
||||
}).shownPromise
|
||||
await waitFor(() => {
|
||||
expect(document.querySelectorAll('[role="tab"]').length).toEqual(2)
|
||||
})
|
||||
|
@ -148,7 +148,7 @@ describe('doFileUpload()', () => {
|
|||
accept: 'image/*',
|
||||
panels: ['URL'],
|
||||
preselectedFile: undefined,
|
||||
})
|
||||
}).shownPromise
|
||||
await waitFor(() => {
|
||||
expect(document.querySelectorAll('[role="tab"]').length).toEqual(1)
|
||||
})
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
/*
|
||||
* Copyright (C) 2022 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import bridge from '../../../../bridge'
|
||||
import formatMessage from '../../../../format-message'
|
||||
|
||||
export default function doFileUpload(ed, document, opts) {
|
||||
const {accept, panels, preselectedFile} = {...opts}
|
||||
let title = formatMessage('Upload File')
|
||||
if (accept?.indexOf('image/') === 0) {
|
||||
title = formatMessage('Upload Image')
|
||||
}
|
||||
|
||||
return import('./UploadFile').then(({UploadFile}) => {
|
||||
let container = document.querySelector('.canvas-rce-upload-container')
|
||||
if (!container) {
|
||||
container = document.createElement('div')
|
||||
container.className = 'canvas-rce-upload-container'
|
||||
document.body.appendChild(container)
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
ReactDOM.unmountComponentAtNode(container)
|
||||
ed.focus(false)
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<UploadFile
|
||||
preselectedFile={preselectedFile}
|
||||
accept={accept}
|
||||
editor={ed}
|
||||
label={title}
|
||||
panels={panels}
|
||||
onDismiss={handleDismiss}
|
||||
canvasOrigin={bridge.canvasOrigin}
|
||||
/>,
|
||||
container
|
||||
)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* Copyright (C) 2022 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import ReactDOM from 'react-dom'
|
||||
import formatMessage from '../../../../format-message'
|
||||
import React from 'react'
|
||||
import bridge from '../../../../bridge'
|
||||
import {handleSubmit, UploadFilePanelId} from './UploadFile'
|
||||
import {Editor} from 'tinymce'
|
||||
|
||||
export type DoFileUploadResult = 'submitted' | 'dismissed'
|
||||
|
||||
export default function doFileUpload(
|
||||
ed: Editor,
|
||||
document: Document,
|
||||
opts: {
|
||||
accept?: string
|
||||
panels?: UploadFilePanelId[]
|
||||
preselectedFile?: File
|
||||
}
|
||||
): {
|
||||
/**
|
||||
* Resolves when the dialog is shown.
|
||||
*/
|
||||
shownPromise: Promise<unknown>
|
||||
|
||||
/**
|
||||
* Resolves when the dialog is closed
|
||||
*/
|
||||
closedPromise: Promise<DoFileUploadResult>
|
||||
} {
|
||||
const {accept, panels, preselectedFile} = {...opts}
|
||||
|
||||
const title = accept?.startsWith('image/')
|
||||
? formatMessage('Upload Image')
|
||||
: formatMessage('Upload File')
|
||||
|
||||
let shownResolve: () => void
|
||||
const shownPromise = new Promise<void>(resolve => (shownResolve = resolve))
|
||||
|
||||
const closedPromise = import('./UploadFile').then(({UploadFile}) => {
|
||||
const container =
|
||||
document.querySelector('.canvas-rce-upload-container') ||
|
||||
(() => {
|
||||
const elem = document.createElement('div')
|
||||
elem.className = 'canvas-rce-upload-container'
|
||||
document.body.appendChild(elem)
|
||||
return elem
|
||||
})()
|
||||
|
||||
return new Promise<DoFileUploadResult>(resolve => {
|
||||
const handleDismiss = () => {
|
||||
ReactDOM.unmountComponentAtNode(container)
|
||||
ed.focus(false)
|
||||
resolve('dismissed')
|
||||
}
|
||||
|
||||
const wrappedSubmit = (...args: Parameters<typeof handleSubmit>) => {
|
||||
try {
|
||||
return handleSubmit(...args)
|
||||
} finally {
|
||||
resolve('submitted')
|
||||
}
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<UploadFile
|
||||
preselectedFile={preselectedFile}
|
||||
accept={accept}
|
||||
editor={ed}
|
||||
label={title}
|
||||
panels={panels}
|
||||
onDismiss={handleDismiss}
|
||||
onSubmit={wrappedSubmit}
|
||||
canvasOrigin={bridge.canvasOrigin}
|
||||
/>,
|
||||
container
|
||||
)
|
||||
|
||||
shownResolve()
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
shownPromise,
|
||||
closedPromise,
|
||||
}
|
||||
}
|
|
@ -23,7 +23,7 @@ import * as images from './images'
|
|||
import bridge from '../../bridge'
|
||||
import {fileEmbed} from '../../common/mimeClass'
|
||||
import {isPreviewable} from '../../rce/plugins/shared/Previewable'
|
||||
import {isImage, isAudioOrVideo} from '../../rce/plugins/shared/fileTypeUtils'
|
||||
import {isAudioOrVideo, isImage} from '../../rce/plugins/shared/fileTypeUtils'
|
||||
import {fixupFileUrl} from '../../common/fileUrl'
|
||||
import {ICON_MAKER_ICONS} from '../../rce/plugins/instructure_icon_maker/svg/constants'
|
||||
import * as CategoryProcessor from '../../rce/plugins/shared/Upload/CategoryProcessor'
|
||||
|
@ -150,14 +150,14 @@ export function embedUploadResult(results, selectedTabType) {
|
|||
contextId: results.contextId,
|
||||
uuid: results.uuid,
|
||||
}
|
||||
bridge.insertImage(file_props)
|
||||
return bridge.insertImage(file_props)
|
||||
} else if (selectedTabType === 'media' && isAudioOrVideo(embedData.type)) {
|
||||
// embed media after any current selection rather than link to it or replace it
|
||||
bridge.activeEditor()?.mceInstance()?.selection.collapse()
|
||||
|
||||
// when we record audio, notorious thinks it's a video. use the content type we got
|
||||
// from the recoreded file, not the returned media object.
|
||||
bridge.embedMedia({
|
||||
// from the recorded file, not the returned media object.
|
||||
return bridge.embedMedia({
|
||||
id: results.id,
|
||||
embedded_iframe_url: results.embedded_iframe_url,
|
||||
href: results.href || results.url,
|
||||
|
@ -169,7 +169,7 @@ export function embedUploadResult(results, selectedTabType) {
|
|||
uuid: results.uuid,
|
||||
})
|
||||
} else {
|
||||
bridge.insertLink(
|
||||
return bridge.insertLink(
|
||||
{
|
||||
'data-canvas-previewable': isPreviewable(results['content-type']),
|
||||
href: results.href || results.url,
|
||||
|
@ -189,7 +189,6 @@ export function embedUploadResult(results, selectedTabType) {
|
|||
false
|
||||
)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// fetches the list of folders to select from when uploading a file
|
||||
|
@ -284,7 +283,7 @@ export function uploadToIconMakerFolder(svg, uploadSettings = {}) {
|
|||
export function uploadToMediaFolder(tabContext, fileMetaProps) {
|
||||
return (dispatch, getState) => {
|
||||
const editorComponent = bridge.activeEditor()
|
||||
const bookmark = editorComponent?.editor?.selection.getBookmark(2, true)
|
||||
const bookmark = editorComponent?.editor?.selection.getBookmark(undefined, true)
|
||||
|
||||
dispatch(activateMediaUpload(fileMetaProps))
|
||||
const {source, jwt, host, contextId, contextType} = getState()
|
||||
|
@ -437,20 +436,31 @@ export function uploadPreflight(tabContext, fileMetaProps) {
|
|||
dispatch(removePlaceholdersFor(fileMetaProps.name))
|
||||
return results
|
||||
})
|
||||
.then(results => {
|
||||
.then(async results => {
|
||||
let newBookmark
|
||||
const editorComponent = bridge.activeEditor()
|
||||
if (fileMetaProps.bookmark) {
|
||||
newBookmark = editorComponent.editor.selection.getBookmark(2, true)
|
||||
newBookmark = editorComponent.editor.selection.getBookmark(undefined, true)
|
||||
editorComponent.editor.selection.moveToBookmark(fileMetaProps.bookmark)
|
||||
}
|
||||
|
||||
const uploadResult = embedUploadResult({contextType, contextId, ...results}, tabContext)
|
||||
const uploadResult = {contextType, contextId, ...results}
|
||||
|
||||
const embedResult = embedUploadResult(uploadResult, tabContext)
|
||||
|
||||
if (fileMetaProps.bookmark) {
|
||||
editorComponent.editor.selection.moveToBookmark(newBookmark)
|
||||
}
|
||||
|
||||
if (embedResult?.loadingPromise) {
|
||||
// Wait until the image loads to remove the placeholder
|
||||
await embedResult.loadingPromise.finally(() =>
|
||||
dispatch(removePlaceholdersFor(fileMetaProps.name))
|
||||
)
|
||||
} else {
|
||||
dispatch(removePlaceholdersFor(fileMetaProps.name))
|
||||
}
|
||||
|
||||
return uploadResult
|
||||
})
|
||||
.then(results => {
|
||||
|
|
|
@ -51,7 +51,6 @@ describe('placeholderInfoFor', () => {
|
|||
ariaLabel: 'Loading placeholder for square.png',
|
||||
visibleLabel: 'square.png',
|
||||
backgroundImageUrl: squareImageDataUri,
|
||||
dataPlaceholderFor: 'square.png',
|
||||
width: '1234px',
|
||||
height: '5678px',
|
||||
vAlign: 'middle',
|
||||
|
@ -76,7 +75,6 @@ describe('placeholderInfoFor', () => {
|
|||
ariaLabel: 'Loading placeholder for square.png',
|
||||
visibleLabel: 'square.png',
|
||||
backgroundImageUrl: 'http://example.com/whatever',
|
||||
dataPlaceholderFor: 'square.png',
|
||||
width: '1234px',
|
||||
height: '5678px',
|
||||
vAlign: 'middle',
|
||||
|
@ -101,7 +99,6 @@ describe('placeholderInfoFor', () => {
|
|||
ariaLabel: 'Loading placeholder for square.png',
|
||||
visibleLabel: 'square.png',
|
||||
backgroundImageUrl: 'http://example.com/whatever',
|
||||
dataPlaceholderFor: 'square.png',
|
||||
width: '1234px',
|
||||
height: '5678px',
|
||||
vAlign: 'middle',
|
||||
|
@ -124,7 +121,6 @@ describe('placeholderInfoFor', () => {
|
|||
type: 'inline',
|
||||
ariaLabel: 'Loading placeholder for square.png',
|
||||
visibleLabel: 'square.png',
|
||||
dataPlaceholderFor: 'square.png',
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -141,7 +137,6 @@ describe('placeholderInfoFor', () => {
|
|||
type: 'block',
|
||||
ariaLabel: 'Loading placeholder for video.mp4',
|
||||
visibleLabel: 'video.mp4',
|
||||
dataPlaceholderFor: 'video.mp4',
|
||||
width: VIDEO_SIZE_DEFAULT.width,
|
||||
height: VIDEO_SIZE_DEFAULT.height,
|
||||
vAlign: 'bottom',
|
||||
|
@ -161,7 +156,6 @@ describe('placeholderInfoFor', () => {
|
|||
type: 'block',
|
||||
visibleLabel: 'audio.mp3',
|
||||
ariaLabel: 'Loading placeholder for audio.mp3',
|
||||
dataPlaceholderFor: 'audio.mp3',
|
||||
width: AUDIO_PLAYER_SIZE.width,
|
||||
height: AUDIO_PLAYER_SIZE.height,
|
||||
vAlign: 'bottom',
|
||||
|
@ -181,24 +175,6 @@ describe('placeholderInfoFor', () => {
|
|||
type: 'inline',
|
||||
visibleLabel: 'file.txt',
|
||||
ariaLabel: 'Loading placeholder for file.txt',
|
||||
dataPlaceholderFor: 'file.txt',
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
it('should url escape names for dataPlaceholderFor to avoid double-quoting', async () => {
|
||||
expect(
|
||||
await placeholderInfoFor({
|
||||
name: 'filename "with quotes".txt',
|
||||
domObject: {},
|
||||
contentType: 'text/plain',
|
||||
})
|
||||
).toEqual({
|
||||
type: 'inline',
|
||||
visibleLabel: 'filename "with quotes".txt',
|
||||
ariaLabel: 'Loading placeholder for filename "with quotes".txt',
|
||||
dataPlaceholderFor: 'filename%20%22with%20quotes%22.txt',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -210,12 +186,15 @@ describe('insertPlaceholder', () => {
|
|||
// -------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
it('should insert inline placeholders', async () => {
|
||||
await insertPlaceholder(editor, {
|
||||
type: 'inline',
|
||||
visibleLabel: 'test-file.txt',
|
||||
ariaLabel: 'Loading placeholder for test-file.txt',
|
||||
dataPlaceholderFor: 'test-file.txt',
|
||||
})
|
||||
await insertPlaceholder(
|
||||
editor,
|
||||
'test-file.txt',
|
||||
Promise.resolve({
|
||||
type: 'inline',
|
||||
visibleLabel: 'test-file.txt',
|
||||
ariaLabel: 'Loading placeholder for test-file.txt',
|
||||
})
|
||||
)
|
||||
|
||||
const placeholderElem = editor.dom.doc.querySelector(
|
||||
'*[data-placeholder-for=test-file\\.txt]'
|
||||
|
@ -227,15 +206,18 @@ describe('insertPlaceholder', () => {
|
|||
// -------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
it('should insert block placeholders', async () => {
|
||||
await insertPlaceholder(editor, {
|
||||
type: 'block',
|
||||
visibleLabel: 'test-file.png',
|
||||
ariaLabel: 'Loading placeholder for test-file.png',
|
||||
dataPlaceholderFor: 'test-file.png',
|
||||
width: '123px',
|
||||
height: '456px',
|
||||
vAlign: 'middle',
|
||||
})
|
||||
await insertPlaceholder(
|
||||
editor,
|
||||
'test-file.png',
|
||||
Promise.resolve({
|
||||
type: 'block',
|
||||
visibleLabel: 'test-file.png',
|
||||
ariaLabel: 'Loading placeholder for test-file.png',
|
||||
width: '123px',
|
||||
height: '456px',
|
||||
vAlign: 'middle',
|
||||
})
|
||||
)
|
||||
|
||||
const placeholderElem = editor.dom.doc.querySelector(
|
||||
'*[data-placeholder-for=test-file\\.png]'
|
||||
|
@ -257,7 +239,7 @@ describe('removePlaceholder', () => {
|
|||
// -------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
it('should remove placeholders', async () => {
|
||||
const info = await placeholderInfoFor({
|
||||
const info = placeholderInfoFor({
|
||||
name: 'test.txt',
|
||||
domObject: {
|
||||
preview: squareImageDataUri,
|
||||
|
@ -265,17 +247,17 @@ describe('removePlaceholder', () => {
|
|||
contentType: 'plain/text',
|
||||
})
|
||||
|
||||
await insertPlaceholder(editor, info)
|
||||
await insertPlaceholder(editor, 'test.txt', info)
|
||||
|
||||
expect(editor.dom.doc.querySelector('*[data-placeholder-for=test\\.txt]')).not.toBeNull()
|
||||
|
||||
removePlaceholder(editor, info.dataPlaceholderFor)
|
||||
removePlaceholder(editor, 'test.txt')
|
||||
|
||||
expect(editor.dom.doc.querySelector('*[data-placeholder-for=square\\.png]')).toBeNull()
|
||||
})
|
||||
|
||||
it('should revoke data uris', async () => {
|
||||
const info = await placeholderInfoFor({
|
||||
const info = placeholderInfoFor({
|
||||
name: 'square.png',
|
||||
domObject: {
|
||||
preview: squareImageDataUri,
|
||||
|
@ -283,11 +265,11 @@ describe('removePlaceholder', () => {
|
|||
contentType: 'image/png',
|
||||
})
|
||||
|
||||
await insertPlaceholder(editor, info)
|
||||
await insertPlaceholder(editor, 'square.png', info)
|
||||
|
||||
expect(editor.dom.doc.querySelector('*[data-placeholder-for=square\\.png]')).not.toBeNull()
|
||||
|
||||
removePlaceholder(editor, info.dataPlaceholderFor)
|
||||
removePlaceholder(editor, 'square.png')
|
||||
|
||||
expect(editor.dom.doc.querySelector('*[data-placeholder-for=square\\.png]')).toBeNull()
|
||||
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright (C) 2023 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Utility function to check if a node is Text in a typesafe manner to allow TypeScript type narrowing.
|
||||
*
|
||||
* @param n
|
||||
*/
|
||||
export function isTextNode(n: Node | null | undefined): n is Text {
|
||||
return n?.nodeType === Node.TEXT_NODE
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (C) 2023 - present Instructure, Inc.
|
||||
*
|
||||
* This file is part of Canvas.
|
||||
*
|
||||
* Canvas is free software: you can redistribute it and/or modify it under
|
||||
* the terms of the GNU Affero General Public License as published by the Free
|
||||
* Software Foundation, version 3 of the License.
|
||||
*
|
||||
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
* details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License along
|
||||
* with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as URI from 'uri-js'
|
||||
import {parseUrlOrNull} from './url-util'
|
||||
|
||||
const fileIdPatterns = [/\/\w+s\/\d+\/files\/(\d+)/]
|
||||
|
||||
export function guessCanvasFileIdFromUrl(
|
||||
inputUrlStr: string,
|
||||
restrictToOrigin?: string | null
|
||||
): string | null {
|
||||
const uri = URI.parse(inputUrlStr)
|
||||
|
||||
if (restrictToOrigin != null) {
|
||||
const url = parseUrlOrNull(inputUrlStr)
|
||||
const originUrl = parseUrlOrNull(restrictToOrigin)
|
||||
|
||||
if (url?.origin !== originUrl?.origin) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (uri.path == null || uri.path.length === 0) return null
|
||||
|
||||
for (const pattern of fileIdPatterns) {
|
||||
const match = uri.path.match(pattern)
|
||||
if (match) {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
|
@ -25,6 +25,7 @@ import formatMessage from '../format-message'
|
|||
import {trimmedOrNull} from './string-util'
|
||||
import {Editor} from 'tinymce'
|
||||
import {assertNever} from './assertNever'
|
||||
import {isTextNode} from './elem-util'
|
||||
|
||||
/**
|
||||
* Determines what type of placeholder is appropriate for a given file information.
|
||||
|
@ -36,7 +37,6 @@ export async function placeholderInfoFor(
|
|||
const ariaLabel = formatMessage('Loading placeholder for {fileName}', {
|
||||
fileName: fileMetaProps.name ?? 'unknown filename',
|
||||
})
|
||||
const dataPlaceholderFor = encodeURIComponent(trimmedOrNull(fileMetaProps.name) ?? 'unknown-name')
|
||||
|
||||
if (isImage(fileMetaProps.contentType) && fileMetaProps.displayAs !== 'link') {
|
||||
const imageUrl =
|
||||
|
@ -50,7 +50,6 @@ export async function placeholderInfoFor(
|
|||
type: 'block',
|
||||
visibleLabel,
|
||||
ariaLabel,
|
||||
dataPlaceholderFor,
|
||||
width: image.width + 'px',
|
||||
height: image.height + 'px',
|
||||
vAlign: 'middle',
|
||||
|
@ -64,7 +63,6 @@ export async function placeholderInfoFor(
|
|||
type: 'block',
|
||||
visibleLabel,
|
||||
ariaLabel,
|
||||
dataPlaceholderFor,
|
||||
width: VIDEO_SIZE_DEFAULT.width,
|
||||
height: VIDEO_SIZE_DEFAULT.height,
|
||||
vAlign: 'bottom',
|
||||
|
@ -74,19 +72,18 @@ export async function placeholderInfoFor(
|
|||
type: 'block',
|
||||
visibleLabel,
|
||||
ariaLabel,
|
||||
dataPlaceholderFor,
|
||||
width: AUDIO_PLAYER_SIZE.width,
|
||||
height: AUDIO_PLAYER_SIZE.height,
|
||||
vAlign: 'bottom',
|
||||
}
|
||||
} else {
|
||||
return {type: 'inline', visibleLabel, ariaLabel, dataPlaceholderFor}
|
||||
return {type: 'inline', visibleLabel, ariaLabel}
|
||||
}
|
||||
}
|
||||
|
||||
export function removePlaceholder(editor: Editor, dataPlaceholderFor: string) {
|
||||
export function removePlaceholder(editor: Editor, unencodedName: string) {
|
||||
const placeholderElem = editor.dom.doc.querySelector(
|
||||
`[data-placeholder-for="${dataPlaceholderFor}"]`
|
||||
`[data-placeholder-for="${encodeURIComponent(unencodedName)}"]`
|
||||
) as HTMLDivElement
|
||||
|
||||
// Fail gracefully
|
||||
|
@ -109,7 +106,8 @@ export function removePlaceholder(editor: Editor, dataPlaceholderFor: string) {
|
|||
*/
|
||||
export async function insertPlaceholder(
|
||||
editor: Editor,
|
||||
placeholderInfo: PlaceholderInfo
|
||||
unencodedName: string,
|
||||
placeholderInfoPromise: Promise<PlaceholderInfo>
|
||||
): Promise<HTMLElement> {
|
||||
const placeholderId = `placeholder-${placeholderIdCounter++}`
|
||||
|
||||
|
@ -118,22 +116,39 @@ export async function insertPlaceholder(
|
|||
editor.execCommand(
|
||||
'mceInsertContent',
|
||||
false,
|
||||
`
|
||||
<div
|
||||
`<span
|
||||
aria-label="${formatMessage('Loading')}"
|
||||
data-placeholder-for="${placeholderInfo.dataPlaceholderFor}"
|
||||
data-placeholder-for="${encodeURIComponent(unencodedName)}"
|
||||
id="${placeholderId}"
|
||||
style="user-select: none; pointer-events: none; user-focus: none;"
|
||||
> </div>
|
||||
`
|
||||
class="mceNonEditable"
|
||||
style="user-select: none; pointer-events: none; user-focus: none; display: inline-flex;"
|
||||
></span> `
|
||||
// Without the trailing , tinymce will place the cursor inside the placeholder, which we don't want.
|
||||
)
|
||||
)
|
||||
|
||||
const placeholderElem = editor.dom.doc.querySelector(`#${placeholderId}`) as HTMLDivElement
|
||||
if (!placeholderElem) {
|
||||
if (placeholderElem) {
|
||||
editor.undoManager.ignore(() => {
|
||||
// Remove the trailing space
|
||||
const nextNode = placeholderElem.nextSibling
|
||||
placeholderElem.contentEditable = 'false'
|
||||
if (isTextNode(nextNode) && nextNode?.data?.startsWith('\xA0' /* nbsp */)) {
|
||||
// Split out the non-breaking-space which only counts as length 1 for splitText
|
||||
nextNode.splitText(1)
|
||||
|
||||
// Remove the now split text node
|
||||
if (placeholderElem.nextSibling) {
|
||||
editor.dom.remove(placeholderElem.nextSibling)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to find placeholder element after inserting it into the editor.')
|
||||
}
|
||||
|
||||
const placeholderInfo = await placeholderInfoPromise
|
||||
|
||||
// Fully initialize the placeholder. Done separately from inserting to avoid TinyMCE mangling the HTML
|
||||
editor.undoManager.ignore(() => {
|
||||
// Set up the overall placeholder container
|
||||
|
@ -167,6 +182,13 @@ export async function insertPlaceholder(
|
|||
Object.assign(labelElem.style, {
|
||||
color: '#2D3B45',
|
||||
zIndex: '1000',
|
||||
|
||||
/* Restrict text to one line */
|
||||
display: 'inline-block',
|
||||
maxWidth: 'calc(100% - 10px)',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
} as CSSStyleDeclaration)
|
||||
labelElem.appendChild(editor.dom.doc.createTextNode(placeholderInfo.visibleLabel))
|
||||
|
||||
|
@ -260,7 +282,6 @@ export interface PlaceHoldableThingInfo {
|
|||
* Style of placeholder to be inserted into the editor.
|
||||
*/
|
||||
export type PlaceholderInfo = {
|
||||
dataPlaceholderFor: string
|
||||
visibleLabel: string
|
||||
ariaLabel: string
|
||||
} & (
|
||||
|
@ -298,7 +319,7 @@ function spinnerSvg(size: 'x-small' | 'small' | 'medium' | 'large', labelId: str
|
|||
})()
|
||||
|
||||
return `
|
||||
<div class="Spinner-root Spinner-default Spinner-${size}" role="presentation">
|
||||
<span class="Spinner-root Spinner-default Spinner-${size}" role="presentation">
|
||||
<svg class="Spinner-circle"
|
||||
role="img"
|
||||
focusable="false"
|
||||
|
@ -365,7 +386,7 @@ function spinnerSvg(size: 'x-small' | 'small' | 'medium' | 'large', labelId: str
|
|||
}
|
||||
.Spinner-x-small .Spinner-circleSpin {
|
||||
stroke-dasharray: 3em;
|
||||
transform-origin: calc(var(--Spinner-xSmallSize) / 2) calc(var(--Spinner-xSmallSize) / 2);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.Spinner-small {
|
||||
|
@ -380,7 +401,7 @@ function spinnerSvg(size: 'x-small' | 'small' | 'medium' | 'large', labelId: str
|
|||
.Spinner-small .Spinner-circleTrack,
|
||||
.Spinner-small .Spinner-circleSpin {
|
||||
stroke-dasharray: 6em;
|
||||
transform-origin: calc(var(--Spinner-smallSize) / 2) calc(var(--Spinner-smallSize) / 2);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.Spinner-medium {
|
||||
|
@ -396,7 +417,7 @@ function spinnerSvg(size: 'x-small' | 'small' | 'medium' | 'large', labelId: str
|
|||
}
|
||||
.Spinner-medium .Spinner-circleSpin {
|
||||
stroke-dasharray: 10.5em;
|
||||
transform-origin: calc(var(--Spinner-mediumSize) / 2) calc(var(--Spinner-mediumSize) / 2);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.Spinner-large {
|
||||
|
@ -412,7 +433,7 @@ function spinnerSvg(size: 'x-small' | 'small' | 'medium' | 'large', labelId: str
|
|||
}
|
||||
.Spinner-large .Spinner-circleSpin {
|
||||
stroke-dasharray: 14em;
|
||||
transform-origin: calc(var(--Spinner-largeSize) / 2) calc(var(--Spinner-largeSize) / 2);
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.Spinner-circle {
|
||||
|
@ -460,6 +481,6 @@ function spinnerSvg(size: 'x-small' | 'small' | 'medium' | 'large', labelId: str
|
|||
<circle class="Spinner-circleSpin" cx="50%" cy="50%" r="${radius}"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</span>
|
||||
`
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue