UNPKG

@instructure/canvas-rce

Version:

A component wrapping Canvas's usage of Tinymce

268 lines (261 loc) 10.2 kB
/* * Copyright (C) 2019 - 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/>. */ /* * here's how linking works * Creation: * 1. No text is selected, user clicks create link button: * - display the create link dialog with text and URL input * - insert an <a> at the caret, linked to the URL with the text content * 2. Text is selected, user clicks create link button: * - display the create link dialog with text and URL input * - the text input displays the plain-text content of the selection * - on saving, if the plain-text has not changed, leave it unchanged in the RCE, * if it has changed, replace the selection with the new plain-text. * Wrap the text in an <a>, linked to the URL * 3. An image + optional text is selected, user clicks create link button * - display the create link dialog with URL input only * - on saving, the selection is wrapped in an <a>, linked to the URL * 4. An iframe is w/in the selection * - disable the create link function * * Editing: * 1. the caret is w/in a text link, but nothing is selected or * some subset of the link's text is selected * - display the link Options popup button. when clicked... * - expand the selection to be the whole link text * - display the tray with the link's plain-text in the text input and the href * in the URL input * - on saving, if the plain-text is unchanged, leave the text unchanged in the RCE, * if it has changed, replace the link text with the new plain-text. * Update the <a>'s href to the new URL * 2. An image w/in a link is selected, or the caret is on the image, or the image * plus some surrounding text that's all part of the existing link is selected, or * the caret is w/in a link that contains an image * a. for now: show the link and image Options buttons in a popup toolbar * - on clicking the link Options... * - expand the selection to be the whole link contents * - show the link options tray, with no text input * - on saving, update the link's href * b. new-improved: show a single Options button, when clicked... * - expand the selection to be the whole link contents * - show the options tray with Image Options and Link Options sections * the link text input is empty. * - on saving, if the link text input is still empty, replace the link's * href with the new URL. if the link text is updated, replace the link's * content with the new plain text (deleting the image) */ import formatMessage from '../../../format-message'; import clickCallback from './clickCallback'; import bridge from '../../../bridge'; import { getAnchorElement, isOKToLink } from '../../contentInsertionUtils'; import LinkOptionsTrayController from './components/LinkOptionsTray/LinkOptionsTrayController'; import { CREATE_LINK, EDIT_LINK } from './components/LinkOptionsDialog/LinkOptionsDialogController'; import tinymce from 'tinymce'; const trayController = new LinkOptionsTrayController(); const COURSE_PLUGIN_KEY = 'course_links'; const GROUP_PLUGIN_KEY = 'group_links'; function getCommandName(selectedNode) { const isCourseLink = selectedNode.getAttribute('data-course-type'); return isCourseLink ? 'instructureTrayForCourseLinks' : 'instructureTrayToEditLink'; } function selectedAnchorCount(ed) { return ed.selection.getRng().cloneContents().querySelectorAll('a').length; } function getMenuItems(ed) { const contextType = ed.settings.canvas_rce_containing_context?.type; const sel_anchors = ed.selection.isCollapsed() ? 0 : selectedAnchorCount(ed); let items; if (getAnchorElement(ed, ed.selection.getNode())) { // cursor is on an anchor, edit or remove it items = [{ text: formatMessage('Edit Link'), value: getCommandName(ed.selection.getNode()) }, { text: formatMessage('Remove Link'), value: 'instructureUnlink' }]; } else { items = [{ text: formatMessage('External Link'), value: 'instructureLinkCreate' }]; if (contextType === 'course') { items.push({ text: formatMessage('Course Link'), value: 'instructure_course_links' }); } else if (contextType === 'group') { items.push({ text: formatMessage('Group Link'), value: 'instructure_group_links' }); } if (sel_anchors > 0) { // selection contains anchor(s), so the user can remove them items.push({ text: formatMessage.plural(sel_anchors, { one: 'Remove Link', other: 'Remove Links' }), value: 'instructureUnlinkAll' }); } } return items; } function removeAnchorFromSelectedElement(ed) { const selectedElem = ed.selection.getNode(); const anchorElem = getAnchorElement(ed, selectedElem); ed.selection.select(anchorElem); ed.undoManager.add(); ed.execCommand('Unlink'); } function doMenuItem(ed, actionName) { switch (actionName) { case 'instructureTrayToEditLink': case 'instructureTrayForCourseLinks': case 'instructureLinkCreate': ed.execCommand(actionName); break; case 'instructureUnlink': removeAnchorFromSelectedElement(ed); break; case 'instructureUnlinkAll': ed.undoManager.add(); ed.execCommand('unlink'); break; case 'instructure_course_links': ed.focus(true); // activate the editor without changing focus ed.execCommand('instructureTrayForLinks', false, COURSE_PLUGIN_KEY); break; case 'instructure_group_links': ed.focus(true); // activate the editor without changing focus ed.execCommand('instructureTrayForLinks', false, GROUP_PLUGIN_KEY); break; } } tinymce.PluginManager.add('instructure_links', function (ed) { // Register commands ed.addCommand('instructureLinkCreate', () => clickCallback(ed, CREATE_LINK)); ed.addCommand('instructureLinkEdit', () => clickCallback(ed, EDIT_LINK)); ed.addCommand('instructureTrayForLinks', (ui, plugin_key) => { bridge.showTrayForPlugin(plugin_key, ed.id); }); ed.addCommand('instructureTrayToEditLink', _ui => { trayController.showTrayForEditor(ed); }); ed.addCommand('instructureTrayForCourseLinks', () => { ed.selection.select(ed.selection.getNode()); return bridge.showTrayForPlugin('course_link_edit', ed.id); }); // Register shortcuts ed.addShortcut('Meta+K', '', 'instructureLinkCreate'); // Register menu item ed.ui.registry.addNestedMenuItem('instructure_links', { text: formatMessage('Link'), icon: 'link', getSubmenuItems: () => getMenuItems(ed).map(item => { return { type: 'menuitem', text: item.text, onAction: () => doMenuItem(ed, item.value), onSetup: api => { api.setDisabled(!isOKToLink(ed.selection.getContent())); return () => {}; } }; }) }); // Register toolbar button ed.ui.registry.addMenuButton('instructure_links', { tooltip: formatMessage('Links'), icon: 'link', fetch: callback => callback(getMenuItems(ed).map(item => ({ type: 'menuitem', text: item.text, value: item.value, onAction: () => doMenuItem(ed, item.value) }))), onSetup(api) { // @ts-expect-error function handleNodeChange(e) { if (e?.element) { api.setActive(!!getAnchorElement(ed, e.element)); } if (ed.selection) { api.setDisabled(!isOKToLink(ed.selection.getContent())); } } // if the user selects all the content w/in a link and deletes it via the keyboard // make sure the surrounding <a> gets deleted too. function deleteEmptyLink() { let node = null; if (ed.selection.getNode().tagName === 'A') { node = ed.selection.getNode(); } else { // Type checking is disabled here because the code below isn't type safe. The code below // should be updated, specifically rng.endContainer.nextSibling?.tagName const rng = ed.selection.getRng(); if (rng.commonAncestorContainer === rng.endContainer && rng.endContainer.nextSibling?.tagName === 'A') { node = rng.endContainer.nextSibling; } else if (rng.nextSibling?.tagName === 'A') { node = rng.nextSibling; } } if (node) { if (node.firstElementChild) { return; } const txt = node.textContent?.trim(); if (txt?.length === 0) { ed.execCommand('Unlink'); } } } setTimeout(handleNodeChange, 0, null); ed.on('NodeChange', handleNodeChange); ed.on('Change', deleteEmptyLink); return () => { ed.off('NodeChange', handleNodeChange); ed.off('Change', deleteEmptyLink); }; } }); // the context toolbar buttons ed.ui.registry.addButton('instructure-link-options', { onAction(/* buttonApi */ ) { ed.execCommand(getCommandName(ed.selection.getNode()), false, ed); }, text: formatMessage('Link Options'), tooltip: formatMessage('Show link options') }); const remButtonLabel = formatMessage('Remove Link'); ed.ui.registry.addButton('instructureUnlink', { onAction() { removeAnchorFromSelectedElement(ed); }, text: remButtonLabel }); ed.ui.registry.addContextToolbar('instructure-link-toolbar', { items: 'instructure-link-options instructureUnlink', position: 'node', predicate: elem => !!getAnchorElement(ed, elem), scope: 'node' }); });