Give RCEWrapper onFocus/onBlur handlers

closes ADMIN-2742

there are challanges
- RCEWrapper keeps track of whether it has focus. This is true if
  anything w/in its outermost div is the activeElement
- since the previously active element blurs
  before the new element becomes active. we need a timeout to wait and
  see where focus lands. this is true when:
  - focus moves to a tinymce popup, like a menu
  - focus moves to one of RCE's dialogs or trays
  - the user interacts with content in the CanvasContentTray. This
    is because a new instance of the CCT is created every time it
    renders as the user interacts with it. (An artifact of how it's
    wired into redux).

This also addresses a bug where the html-view textarea was the wrong
size when flipping between rich text and html views.

NOTE: if you close any of the Trays or Modals by typing "esc", it will
blur the RCE. This is a known bug that I'm hoping will be fixed
via INSTUi-2201. If not, then via another CORE ticket

test plan:
  - not necessary, but if you test in assignments2, you'll know it's
    working because the RCE will go away if it loses focus,
    so enable assignments2, create an assignment, then
    edit the assignment (you can't create an a2 assignment yet)
  - insert and edit an external link
  - insert and edit a course image
  - upload an image
  - insert and edit a course document
  - upload a document
  > in each case, expect focus to return to the RCE, and if applicable,
    the yellow indicator box is correctly positioned.
  - in any of the above cases, click on the yellow indicator while it's
    visible
  > expect focus to stay w/in the rce

  resizing:
  - click the "switch to html view" button
  > expect the textarea to fill the avaiable space
  - resize it and click the button to switch back
  > expect the rce to be the same (or really close) size

Change-Id: If85c5644558fbce27530e43bb71c2bdb7e91eb12
Reviewed-on: https://gerrit.instructure.com/199273
Tested-by: Jenkins
Reviewed-by: Clay Diffrient <cdiffrient@instructure.com>
QA-Review: Jeremy Putnam <jeremyp@instructure.com>
Product-Review: Clay Diffrient <cdiffrient@instructure.com>
This commit is contained in:
Ed Schiebel 2019-06-26 18:07:22 -04:00 committed by Clay Diffrient
parent 1b17249ae2
commit 5e5120e566
23 changed files with 180 additions and 67 deletions

View File

@ -118,7 +118,9 @@ export default class EditableRichText extends React.Component {
tinyOptions: {
init_instance_callback: this.handleRCEInit,
height: 300
}
},
onFocus: this.handleEditorFocus,
onBlur: this.handleEditorBlur
})
}
@ -136,9 +138,6 @@ export default class EditableRichText extends React.Component {
handleRCEInit = tinyeditor => {
this._tinyeditor = tinyeditor
this._tinyeditor.on('blur', this.handleEditorBlur)
this._tinyeditor.on('focus', this.handleEditorFocus)
this._tinyeditor.on('keydown', this.handleKey)
document
.getElementById('content')
.querySelector('[id^="random_editor"]')
@ -147,18 +146,9 @@ export default class EditableRichText extends React.Component {
}
handleEditorBlur = event => {
// Focus isn't managed well in the RCE, so a couple hacks
// 1. if the user clicked on a toolbar button that opened a dialog,
// the activeElement will be a child of the body, and not the our page
// 2. if focus is on the body, then we've left the editor altogether
if (
document.getElementById('content').contains(document.activeElement) ||
document.activeElement === document.body
) {
if (this._textareaRef) {
const txt = RichContentEditor.callOnRCE(this._textareaRef, 'get_code')
this.setState({value: txt})
}
if (this._textareaRef) {
const txt = RichContentEditor.callOnRCE(this._textareaRef, 'get_code')
this.setState({value: txt})
this._onBlurEditor(event)
}
}
@ -173,14 +163,6 @@ export default class EditableRichText extends React.Component {
this._tinyeditor.selection.collapse(false)
}
handleKey = event => {
if (this.props.mode === 'edit' && event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.handleModeChange('view')
}
}
textareaRef = el => {
this._textareaRef = el
}
@ -189,9 +171,7 @@ export default class EditableRichText extends React.Component {
this._onBlurEditor = onBlur
this._editorRef = editorRef
editorRef(this)
return (
<textarea style={{display: 'block'}} defaultValue={this.state.value} ref={this.textareaRef} />
)
return <textarea defaultValue={this.state.value} ref={this.textareaRef} />
}
// the Editable component thinks I'm the editor

View File

@ -63,6 +63,7 @@ export default class KeyboardShortcutModal extends React.Component {
render() {
return (
<Modal
data-canvas-component
open={this.state.isOpen}
label={I18n.t('Keyboard Shortcuts')}
onDismiss={this.closeModal}

View File

@ -40,7 +40,7 @@ function loadServiceRCE(target, tinyMCEInitOptions, callback) {
$textarea.data('remoteEditor', remoteEditor)
$target.trigger(RCELOADED_EVENT_NAME, remoteEditor)
if (callback) {
callback()
callback(remoteEditor)
}
})
}
@ -177,13 +177,13 @@ const RichContentEditor = {
// avoid modifying the original options object provided
tinyMCEInitOptions = $.extend({}, tinyMCEInitOptions)
const callback = () => {
const callback = (rce) => {
if (tinyMCEInitOptions.focus) {
// call activateRCE once loaded
this.activateRCE($target)
}
if (cb) {
cb()
cb(rce)
}
}

View File

@ -188,6 +188,7 @@ let loadingPromise
language: ENV.LOCALE,
mirroredAttrs: this._attrsToMirror(textarea),
onFocus: tinyMCEInitOptions.onFocus,
onBlur: tinyMCEInitOptions.onBlur,
textareaClassName: textarea.className,
textareaId: textarea.id,
trayProps: getTrayProps()

View File

@ -27,6 +27,11 @@
box-sizing: border-box;
}
/* the new rce */
.ic-RichContentEditor .rce-wrapper textarea {
min-height: auto;
}
.mce-i-a11y:before {
content: "\e900";
border: 1px solid $ic-font-color-dark;

View File

@ -21,6 +21,7 @@
]
},
"dependencies": {
"@instructure/canvas-rce-old": "4.1.4",
"@instructure/media-capture": "^5",
"@instructure/react-crop": "^5.0.1",
"@instructure/ui-alerts": "^5",
@ -34,8 +35,8 @@
"@instructure/ui-forms": "^5",
"@instructure/ui-icons": "^5",
"@instructure/ui-layout": "^5",
"@instructure/ui-menu": "^5",
"@instructure/ui-media-player": "^5",
"@instructure/ui-menu": "^5",
"@instructure/ui-number-input": "^5",
"@instructure/ui-overlays": "^5",
"@instructure/ui-pagination": "^5",
@ -57,7 +58,6 @@
"big.js": "^5.0.3",
"brandable_css": "0.1.0",
"canvas-planner": ">=1.0.16",
"@instructure/canvas-rce-old": "4.1.4",
"canvas_offline_course_viewer": "https://github.com/instructure/canvas_offline_course_viewer.git#1.2.0",
"classnames": "^2.2.5",
"color-slicer": "0.8.0",

View File

@ -48,6 +48,8 @@ function getProps(textareaId, state) {
textareaClassName: "exampleClassOne",
textareaId,
onFocus: () => console.log("rce focused"), // eslint-disable-line no-console
onBlur: () => console.log("rce blurred"), // eslint-disable-line no-console
trayProps: {
canUploadFiles: true,

View File

@ -20,7 +20,7 @@
<div class="main">
<h2>Editor</h2>
<div id="editor1">
<textarea id="textarea1"></textarea>
<textarea id="textarea1">this is initial text</textarea>
</div>
<h3>Multiple editors supported with a single sidebar</h3>
<div id="editor2">

View File

@ -30,7 +30,8 @@ export default function indicate(region, margin = MARGIN) {
width: region.width + 2 * margin + "px",
height: region.height + 2 * margin + "px",
left: region.left - margin + "px",
top: region.top - margin + "px"
top: region.top - margin + "px",
pointerEvents: 'none' // so clicking in the indicator doesn't blur the RCE
});
// start hidden and animate a fade in

View File

@ -28,6 +28,7 @@ export default function KeyboardShortcutModal(props) {
return (
<Modal
data-testid="RCE_KeyboardShortcutModal"
data-mce-component
label={formatMessage('Keyboard Shortcuts')}
open={props.open}
shouldCloseOnDocumentClick

View File

@ -36,6 +36,7 @@ import theme from '../skins/theme'
import {isImage} from './plugins/shared/fileTypeUtils'
import KeyboardShortcutModal from './KeyboardShortcutModal'
const ASYNC_FOCUS_TIMEOUT = 250
// we `require` instead of `import` these 2 css files because the ui-themeable babel require hook only works with `require`
const styles = require('../skins/skin-delta.css')
@ -126,6 +127,7 @@ class RCEWrapper extends React.Component {
handleUnmount: PropTypes.func,
language: PropTypes.string,
onFocus: PropTypes.func,
onBlur: PropTypes.func,
onRemove: PropTypes.func,
textareaClassName: PropTypes.string,
textareaId: PropTypes.string,
@ -158,7 +160,8 @@ class RCEWrapper extends React.Component {
path: [],
wordCount: 0,
isHtmlView: false,
KBShortcutModalOpen: false
KBShortcutModalOpen: false,
focused: false
}
}
@ -185,6 +188,14 @@ class RCEWrapper extends React.Component {
}
indicateEditor(element) {
if (document.querySelector('[role="dialog"][data-mce-component]')) {
// there is a modal open, which zeros out the vertical scroll
// so the indicator is in the wrong place. Give it a chance to close
window.setTimeout(() => {
this.indicateEditor(element)
}, 100)
return
}
const editor = this.mceInstance();
if (this.indicator) {
this.indicator(editor, element);
@ -350,25 +361,101 @@ class RCEWrapper extends React.Component {
return document.getElementById(`${this.props.textareaId}_ifr`)
}
onFocus() {
Bridge.focusEditor(this);
this.props.onFocus && this.props.onFocus(this);
// these focus and blur event handlers work together so that RCEWrapper
// can report focus and blur events from the RCE at-large
get focused() {
return this.state.focused
}
reallyOnFocus() {
handleFocus() {
if (!this.state.focused) {
this.setState({focused: true})
Bridge.focusEditor(this);
this.props.onFocus && this.props.onFocus(this);
}
}
contentTrayClosing = false
handleContentTrayClosing = isClosing => {
this.contentTrayClosing = isClosing
}
blurTimer = 0
handleBlur(event) {
if (this.blurTimer) return
if (this.state.focused) {
// because the old active element fires blur before the next element gets focus
// we often need a moment to see if focus comes back
event && event.persist && event.persist()
this.blurTimer = window.setTimeout(() => {
this.blurTimer = 0
if (this.contentTrayClosing) {
// the CanvasContentTray is in the process of closing
// wait until it finishes
return
}
if (this._elementRef && this._elementRef.contains(document.activeElement)) {
// focus is still somewhere w/in me
return
}
if (document.activeElement.getAttribute('class').includes('tox-')) {
// if a toolbar button has focus, then the user clicks on the "more" button
// focus jumps to the body, then eventually to the popped pup toolbar. This
// catches that case, but could also fail to blur an rce if the user clicked from
// one rce on the page to another. I think this is the lesser of the 2 evils
return
}
if (event && event.relatedTarget && event.relatedTarget.getAttribute('class').includes('tox-')) {
// a tinymce popup has focus
return
}
const popup = document.querySelector('[data-mce-component]')
if (popup && popup.contains(document.activeElement)) {
// one of our popups has focus
return
}
this.setState({focused: false})
this.props.onBlur && this.props.onBlur(event)
}, ASYNC_FOCUS_TIMEOUT)
}
}
handleFocusRCE = event => {
if (this._elementRef && !this._elementRef.contains(event.relatedTarget)) {
this.handleFocus(event)
}
}
handleBlurRCE = event => {
if (event.relatedTarget === null) {
// focus might be moving to tinymce
this.handleBlur(event)
}
if (!this._elementRef.contains(event.relatedTarget)) {
this.handleBlur(event)
}
}
handleFocusEditor() {
// use .active to put a focus ring around the content area
// when the editor has focus. This isn't perfect, but it's
// what we've got for now.
const ifr = this.iframe
ifr && ifr.parentElement.classList.add('active')
this.onFocus()
this.handleFocus()
}
onBlur() {
handleBlurEditor() {
const ifr = this.iframe
ifr && ifr.parentElement.classList.remove('active')
this.handleBlur(event)
}
call(methodName, ...args) {
@ -441,7 +528,7 @@ class RCEWrapper extends React.Component {
}
onA11yChecker = () => {
this.onTinyMCEInstance('openAccessibilityChecker')
this.onTinyMCEInstance('openAccessibilityChecker', {'data-canvas-component': true})
}
handleShortcutKeyShortcut = (event) => {
@ -462,11 +549,12 @@ class RCEWrapper extends React.Component {
KBShortcutModalClosed = () => {
if(Bridge.activeEditor() === this) {
this.onTinyMCEInstance('mceFocus')
Bridge.focusActiveEditor(false)
}
}
componentWillUnmount() {
window.clearTimeout(this.blurTimer)
if (!this._destroyCalled) {
this.destroy();
}
@ -556,6 +644,8 @@ class RCEWrapper extends React.Component {
componentDidMount() {
this.registerTextareaChange();
this._elementRef.addEventListener('keyup', this.handleShortcutKeyShortcut, true)
// give the textarea its initial size
this.onResize(null, {deltaY: 0})
}
componentDidUpdate(_prevProps, prevState) {
@ -581,7 +671,12 @@ class RCEWrapper extends React.Component {
mceProps.editorOptions.statusbar = false
return (
<div ref={el => this._elementRef = el} className={styles.root}>
<div
className={`${styles.root} rce-wrapper`}
ref={el => this._elementRef = el}
onFocus={this.handleFocusRCE}
onBlur={this.handleBlurRCE}
>
<ShowOnFocusButton
buttonProps={{
variant: 'link',
@ -598,12 +693,12 @@ class RCEWrapper extends React.Component {
init={this.wrapOptions(mceProps.editorOptions)}
initialValue={mceProps.defaultContent}
onInit={this.onInit.bind(this)}
onClick={this.onFocus.bind(this)}
onKeypress={this.onFocus.bind(this)}
onActivate={this.onFocus.bind(this)}
onClick={this.handleFocusEditor.bind(this)}
onKeypress={this.handleFocusEditor.bind(this)}
onActivate={this.handleFocusEditor.bind(this)}
onRemove={this.onRemove.bind(this)}
onFocus={this.reallyOnFocus.bind(this)}
onBlur={this.onBlur.bind(this)}
onFocus={this.handleFocusEditor.bind(this)}
onBlur={this.handleBlurEditor.bind(this)}
onNodeChange={this.onNodeChange}
/>
<StatusBar
@ -615,7 +710,7 @@ class RCEWrapper extends React.Component {
onKBShortcutModalOpen={this.openKBShortcutModal}
onA11yChecker={this.onA11yChecker}
/>
<CanvasContentTray bridge={Bridge} {...trayProps} />
<CanvasContentTray bridge={Bridge} onTrayClosing={this.handleContentTrayClosing} {...trayProps} />
<KeyboardShortcutModal
onClose={this.KBShortcutModalClosed}
onDismiss={this.closeKBShortcutModal}

View File

@ -29,7 +29,10 @@ export default function(ed, document) {
document.body.appendChild(container)
}
const handleDismiss = () => ReactDOM.unmountComponentAtNode(container)
const handleDismiss = () => {
ReactDOM.unmountComponentAtNode(container)
ed.focus()
}
// acccept=undefined -> can upload anything
ReactDOM.render(

View File

@ -19,6 +19,7 @@
import React from 'react'
import ReactDOM from 'react-dom'
import bridge from '../../../../bridge'
import ImageOptionsTray from '.'
export const CONTAINER_ID = 'instructure-image-options-tray-container'
@ -106,6 +107,7 @@ export default class TrayController {
this._isOpen = true
}}
onExited={() => {
bridge.focusActiveEditor(false)
this._isOpen = false
}}
onSave={imageOptions => {

View File

@ -105,6 +105,7 @@ export default function ImageOptionsTray(props) {
return (
<Tray
data-mce-component
label={formatMessage('Image Options Tray')}
onDismiss={onRequestClose}
onEntered={props.onEntered}

View File

@ -59,6 +59,7 @@ export default class LinkOptionsDialogController {
this._renderDialog()
}
_hasClosed = () => {
bridge.focusActiveEditor(false)
this._isOpen = false
this._editor.focus(false)
this._editor = null

View File

@ -60,6 +60,7 @@ export default function LinkOptionsDialog(props) {
return (
<Modal
data-testid="RCELinkOptionsDialog"
data-mce-component
as="form"
label={label}
onDismiss={props.onRequestClose}

View File

@ -78,6 +78,7 @@ export default class LinkOptionsTrayController {
this._isOpen = true
}}
onExited={() => {
bridge.focusActiveEditor(false)
this._isOpen = false
}}
onSave={linkOptions => {

View File

@ -72,6 +72,7 @@ export default function LinkOptionsTray(props) {
return (
<Tray
data-testid="RCELinkOptionsTray"
data-mce-component
label={formatMessage('Link Options')}
onDismiss={props.onRequestClose}
onEntered={props.onEntered}

View File

@ -111,12 +111,21 @@ export default function CanvasContentTray(props) {
}, [props.bridge])
function handleDismissTray() {
props.onTrayClosing && props.onTrayClosing(true) // tell RCEWrapper we're closing
setIsOpen(false)
}
function handleExitTray() {
props.onTrayClosing && props.onTrayClosing(true) // tell RCEWrapper we're closing
}
function handleCloseTray() {
props.bridge.focusActiveEditor(false)
// increment a counter that's used a the key when rendering
// this gets us a new instance everytime, which is necessary
// to get the queries run so we have up to date data.
setOpenCount(openCount + 1)
props.onTrayClosing && props.onTrayClosing(false) // tell RCEWrapper we're closed
}
function renderLoading() {
@ -127,6 +136,7 @@ export default function CanvasContentTray(props) {
<StoreProvider {...props} key={openCount}>
{contentProps => (
<Tray
data-mce-component
data-testid="CanvasContentTray"
label={getTrayLabel(filterSettings)}
open={isOpen}
@ -137,6 +147,7 @@ export default function CanvasContentTray(props) {
shouldCloseOnDocumentClick
onDismiss={handleDismissTray}
onClose={handleCloseTray}
onExit={handleExitTray}
>
<Flex direction="column" display="block" height="100vh" overflowY="hidden">
<Flex.Item padding="medium" shadow="above">
@ -193,6 +204,7 @@ export const trayProps = shape(trayPropsMap)
CanvasContentTray.propTypes = {
bridge: instanceOf(Bridge).isRequired,
onTrayClosing: func, // called with true when the tray starts closing, false once closed
...trayPropsMap
}

View File

@ -159,6 +159,7 @@ export function UploadFile({accept, editor, label, panels, onDismiss, trayProps,
<StoreProvider {...trayProps}>
{contentProps => (
<Modal
data-mce-component
as="form"
label={label}
size="large"

View File

@ -31,7 +31,15 @@
.root {
background-color: var(--canvasBackgroundColor)
}
:global {
/* not part of the skin, but necessary to properly size the RCE's textarea */
.rce-wrapper textarea {
width: 100%;
box-sizing: border-box;
min-height: auto;
}
.tox,
.tox *:not(svg) {
color: inherit;

View File

@ -20,7 +20,6 @@ import assert from "assert";
import jsdomify from "jsdomify";
import sinon from "sinon";
import Bridge from "../../src/bridge";
import ReactDOM from "react-dom";
import * as indicateModule from "../../src/common/indicate";
import * as contentInsertion from "../../src/rce/contentInsertion";
import RCEWrapper from "../../src/rce/RCEWrapper";
@ -551,22 +550,19 @@ describe("RCEWrapper", () => {
it("calls Bridge.focusEditor with editor", () => {
const editor = createBasicElement();
editor.onFocus();
editor.handleFocus();
sinon.assert.calledWith(Bridge.focusEditor, editor);
});
it("calls props.onFocus with editor if exists", () => {
const editor = createBasicElement({ onFocus: sinon.spy() });
editor.onFocus();
editor.handleFocus();
sinon.assert.calledWith(editor.props.onFocus, editor);
});
});
describe("onRemove", () => {
let domNode;
beforeEach(() => {
domNode = {};
sinon.stub(Bridge, "detachEditor");
});

View File

@ -16921,9 +16921,9 @@ resolve@1.1.7, resolve@~1.1.0:
integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=
resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.3.2, resolve@^1.5.0, resolve@^1.8.1:
version "1.11.0"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.11.0.tgz#4014870ba296176b86343d50b60f3b50609ce232"
integrity sha512-WL2pBDjqT6pGUNSUzMw00o4T7If+z4H2x3Gz893WoUQ5KW8Vr9txp00ykiP16VBaZF5+j/OcXJHZ9+PCvdiDKw==
version "1.11.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.11.1.tgz#ea10d8110376982fef578df8fc30b9ac30a07a3e"
integrity sha512-vIpgF6wfuJOZI7KKKSP+HmiKggadPQAdsp5HiC1mvqnfp0gF1vdwgBWZIdrVft9pgqoMFQN+R7BSWZiBxx+BBw==
dependencies:
path-parse "^1.0.6"
@ -18756,9 +18756,9 @@ tinycolor2@1.4.1, tinycolor2@^1.4.1:
integrity sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=
tinymce-a11y-checker@^2:
version "2.1.1"
resolved "https://registry.yarnpkg.com/tinymce-a11y-checker/-/tinymce-a11y-checker-2.1.1.tgz#9cc43e87943e83318324ce08af30463a610cc0fc"
integrity sha512-CzhgSYvGq5W75drXJ57eKg1o2IP0HGN1BgiU8Z5etdvzVnPx7myI1aT/lPehNPVmDZZmPPtDNcSphiG21Va4yQ==
version "2.2.0"
resolved "https://registry.yarnpkg.com/tinymce-a11y-checker/-/tinymce-a11y-checker-2.2.0.tgz#8ac8ad0fa298b2c24ad025ca3345199ce18486f3"
integrity sha512-0GSXRNZa8zhj/f1S0DGRNRPnhJ7SDg5rMRqTlSrKOb9El6eR8JxokE/jc2OPLtlCioJMhre6KUUiV8G0tlWAAQ==
dependencies:
"@instructure/ui-buttons" "^5"
"@instructure/ui-core" "^5"
@ -19101,9 +19101,9 @@ typescript@^3.3.3:
integrity sha512-YycBxUb49UUhdNMU5aJ7z5Ej2XGmaIBL0x34vZ82fn3hGvD+bgrMrVDpatgz2f7YxUMJxMkbWxJZeAvDxVe7Vw==
ua-parser-js@^0.7.18, ua-parser-js@^0.7.9:
version "0.7.19"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.19.tgz#94151be4c0a7fb1d001af7022fdaca4642659e4b"
integrity sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ==
version "0.7.20"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.20.tgz#7527178b82f6a62a0f243d1f94fd30e3e3c21098"
integrity sha512-8OaIKfzL5cpx8eCMAhhvTlft8GYF8b2eQr6JkCyVdrgjcytyOmPCXrqXFcUnhonRpLlh5yxEZVohm6mzaowUOw==
uc.micro@^1.0.1, uc.micro@^1.0.5:
version "1.0.6"