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:
Yona Appletree 2023-04-12 14:39:32 -07:00
parent 80b844a574
commit 3cb0a7f4ef
18 changed files with 461 additions and 290 deletions

View File

@ -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)
}

View File

@ -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 &nbsp; 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)}"]`

View File

@ -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

View File

@ -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
}

View File

@ -22,5 +22,5 @@ export default function (ed, document) {
return doFileUpload(ed, document, {
accept: 'image/*',
panels: ['COMPUTER', 'URL'],
})
}).shownPromise
}

View File

@ -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)
})
)

View File

@ -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[] {

View File

@ -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()

View File

@ -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 () => {

View File

@ -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
}

View 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)
})

View File

@ -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
)
})
}

View File

@ -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,
}
}

View File

@ -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 => {

View File

@ -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()

View File

@ -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
}

View File

@ -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
}

View File

@ -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;"
>&nbsp;</div>
`
class="mceNonEditable"
style="user-select: none; pointer-events: none; user-focus: none; display: inline-flex;"
></span>&nbsp;`
// Without the trailing &nbsp;, 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>
`
}