From 9b07ede73a3184d3a99a0b0116e7549babbd199f Mon Sep 17 00:00:00 2001 From: CTomlyn Date: Tue, 3 Dec 2024 17:26:07 -0800 Subject: [PATCH] #1438 Accessibility: Tabbing - Keyboard navigation of canvas objects (Part 2) (#2241) Signed-off-by: CTomlyn --- .../locales/common-canvas/locales/en.json | 1 + .../locales/common-canvas/locales/eo.json | 1 + .../src/color-picker/color-picker.jsx | 34 +- .../colorSelectedObjectsAction.js | 4 + .../command-actions/createAutoNodeAction.js | 11 +- .../command-actions/createCommentAction.js | 9 +- .../command-actions/createSuperNodeAction.js | 8 + .../deconstructSuperNodeAction.js | 8 +- .../src/command-actions/deleteLinkAction.js | 16 +- .../command-actions/deleteObjectsAction.js | 8 + .../disconnectObjectsAction.js | 4 + .../src/command-actions/editCommentAction.js | 8 +- .../editDecorationLabelAction.js | 6 + .../src/command-actions/pasteAction.js | 21 + .../src/command-actions/setNodeLabelAction.js | 7 +- .../canvas-controller-menu-utils.js | 34 +- .../src/common-canvas/canvas-controller.js | 165 +++++- .../src/common-canvas/cc-contents.jsx | 194 ++++--- .../src/common-canvas/cc-context-toolbar.jsx | 14 +- .../src/common-canvas/cc-text-toolbar.jsx | 17 +- .../src/common-canvas/common-canvas-utils.js | 26 +- .../src/common-canvas/common-canvas.scss | 16 +- .../constants/canvas-constants.js | 7 + .../src/common-canvas/keyboard-utils.js | 410 ++++++++++++- .../src/common-canvas/svg-canvas-d3.js | 16 +- .../src/common-canvas/svg-canvas-d3.scss | 5 +- .../src/common-canvas/svg-canvas-pipeline.js | 25 +- .../src/common-canvas/svg-canvas-renderer.js | 546 +++++++++++++----- .../svg-canvas-utils-accessibility.js | 40 +- .../svg-canvas-utils-drag-new-link.js | 157 +++-- .../svg-canvas-utils-drag-objects.js | 203 +++++-- .../svg-canvas-utils-textarea.js | 67 ++- .../common-canvas/svg-canvas-utils-zoom.js | 2 +- .../src/context-menu/common-context-menu.jsx | 175 +++++- .../src/context-menu/context-menu-wrapper.jsx | 1 + .../src/context-menu/context-menu.scss | 12 +- .../notification-panel/notification-panel.jsx | 11 +- .../src/object-model/object-model.js | 31 +- .../src/object-model/redux/canvas-store.js | 29 + .../palette/palette-content-list-item-btn.jsx | 41 ++ .../src/palette/palette-content-list-item.jsx | 70 ++- .../src/palette/palette-content-list.jsx | 44 +- .../palette-flyout-content-category.jsx | 20 +- .../palette-flyout-content-filtered-list.jsx | 1 + .../src/toolbar/toolbar-action-item.jsx | 7 +- .../src/toolbar/toolbar-overflow-item.jsx | 8 +- .../src/toolbar/toolbar-sub-menu-item.jsx | 22 + .../src/toolbar/toolbar-sub-menu.jsx | 22 +- .../src/toolbar/toolbar-sub-panel.jsx | 11 +- .../common-canvas/src/toolbar/toolbar.jsx | 56 +- .../cypress/e2e/canvas/selection-region.cy.js | 6 +- .../cypress/e2e/canvas/undo-redo.cy.js | 4 + .../support/canvas/context-menu-cmds.js | 16 +- .../cypress/support/canvas/operation-cmds.js | 8 +- canvas_modules/harness/src/client/App.js | 7 + .../sidepanel/canvas/sidepanel-canvas.jsx | 6 +- docs/pages/03.02-configuration.md | 2 +- docs/pages/03.02.01-canvas-config.md | 5 +- docs/pages/03.02.05-keyboard-config.md | 1 + docs/pages/03.05-keyboard-support.md | 159 ++++- 60 files changed, 2190 insertions(+), 675 deletions(-) create mode 100644 canvas_modules/common-canvas/src/palette/palette-content-list-item-btn.jsx diff --git a/canvas_modules/common-canvas/locales/common-canvas/locales/en.json b/canvas_modules/common-canvas/locales/common-canvas/locales/en.json index 2a78f76182..aae2abd0ab 100644 --- a/canvas_modules/common-canvas/locales/common-canvas/locales/en.json +++ b/canvas_modules/common-canvas/locales/common-canvas/locales/en.json @@ -11,6 +11,7 @@ "canvas.addComment": "New comment", "canvas.addWysiwygComment": "New WYSIWYG comment", "canvas.selectAll": "Select all", + "canvas.deselectAll": "Deselect all", "canvas.undo": "Undo", "canvas.redo": "Redo", "canvas.undoCommand": "Undo: {undo_command}", diff --git a/canvas_modules/common-canvas/locales/common-canvas/locales/eo.json b/canvas_modules/common-canvas/locales/common-canvas/locales/eo.json index 8cd7a39ed5..4c35d64497 100644 --- a/canvas_modules/common-canvas/locales/common-canvas/locales/eo.json +++ b/canvas_modules/common-canvas/locales/common-canvas/locales/eo.json @@ -11,6 +11,7 @@ "canvas.addComment": "[Esperanto~New comment~eo]", "canvas.addWysiwygComment": "[Esperanto~New WYSIWYG comment~~~~~eo]", "canvas.selectAll": "[Esperanto~Select all~~~~~~eo]", + "canvas.deselectAll": "[Esperanto~Deselect all~~~~eo]", "canvas.undo": "[Esperanto~Undo~eo]", "canvas.redo": "[Esperanto~Redo~eo]", "canvas.undoCommand": "[Esperanto~Undo: {undo_command}~~eo]", diff --git a/canvas_modules/common-canvas/src/color-picker/color-picker.jsx b/canvas_modules/common-canvas/src/color-picker/color-picker.jsx index 8dfa84c44e..d7e59e7a46 100644 --- a/canvas_modules/common-canvas/src/color-picker/color-picker.jsx +++ b/canvas_modules/common-canvas/src/color-picker/color-picker.jsx @@ -16,19 +16,11 @@ import React from "react"; import PropTypes from "prop-types"; +import KeyboardUtils from "../common-canvas/keyboard-utils.js"; import Logger from "../logging/canvas-logger.js"; import colorSetArray from "./color-set.js"; - import { WYSIWYG } from "../common-canvas/constants/canvas-constants.js"; -const TAB_KEY = 9; -const RETURN_KEY = 13; -const SPACE_KEY = 32; -const LEFT_ARROW_KEY = 37; -const UP_ARROW_KEY = 38; -const RIGHT_ARROW_KEY = 39; -const DOWN_ARROW_KEY = 40; - // These dimensions should match the values in color-picker.scss const COLOR_DIMENSION = 20; const COLOR_PADDING = 5; @@ -66,47 +58,49 @@ class ColorPicker extends React.Component { } onKeyDown(evt) { - if (evt.keyCode === RIGHT_ARROW_KEY) { + if (KeyboardUtils.nextColor(evt)) { evt.stopPropagation(); this.colorIndex++; if (this.colorIndex > this.totalColors - 1) { this.colorIndex = 0; } + this.setFocus(this.colorIndex); - } else if (evt.keyCode === LEFT_ARROW_KEY) { + } else if (KeyboardUtils.previousColor(evt)) { evt.stopPropagation(); this.colorIndex--; if (this.colorIndex < 0) { - this.colorIndex = this.totalColors - 1; + this.props.closeSubPanel(); + return; } + this.setFocus(this.colorIndex); - } else if (evt.keyCode === UP_ARROW_KEY) { + } else if (KeyboardUtils.aboveColor(evt)) { evt.stopPropagation(); this.colorIndex -= this.colorsPerRow; if (this.colorIndex < 0) { this.colorIndex += this.colorsPerRow; } + this.setFocus(this.colorIndex); - } else if (evt.keyCode === DOWN_ARROW_KEY) { + } else if (KeyboardUtils.belowColor(evt)) { evt.stopPropagation(); this.colorIndex += this.colorsPerRow; - if (this.colorIndex > 11) { + if (this.colorIndex > this.totalColors - 1) { this.colorIndex -= this.colorsPerRow; } + this.setFocus(this.colorIndex); - } else if (evt.keyCode === SPACE_KEY || - evt.keyCode === RETURN_KEY) { + } else if (KeyboardUtils.selectColor(evt)) { evt.stopPropagation(); evt.preventDefault(); this.selectColor(evt); - } else if (evt.keyCode === TAB_KEY) { + } else if (KeyboardUtils.tabKey(evt)) { evt.stopPropagation(); evt.preventDefault(); return; } - - this.setFocus(this.colorIndex); } setFocus(index) { diff --git a/canvas_modules/common-canvas/src/command-actions/colorSelectedObjectsAction.js b/canvas_modules/common-canvas/src/command-actions/colorSelectedObjectsAction.js index 76c73f93a0..f77fb2a415 100644 --- a/canvas_modules/common-canvas/src/command-actions/colorSelectedObjectsAction.js +++ b/canvas_modules/common-canvas/src/command-actions/colorSelectedObjectsAction.js @@ -46,6 +46,10 @@ export default class ColorSelectedObjectsAction extends Action { return this.actionLabel; } + getFocusObject() { + return this.data.selectedObjects[0]; + } + createActionLabel() { return this.labelUtil.getActionLabel(this, "action.colorComments", { comments_count: this.data.selectedObjectIds.length diff --git a/canvas_modules/common-canvas/src/command-actions/createAutoNodeAction.js b/canvas_modules/common-canvas/src/command-actions/createAutoNodeAction.js index 0ce7b39f3d..0627a509c9 100644 --- a/canvas_modules/common-canvas/src/command-actions/createAutoNodeAction.js +++ b/canvas_modules/common-canvas/src/command-actions/createAutoNodeAction.js @@ -23,6 +23,7 @@ /***************************************************************************/ import Action from "../command-stack/action.js"; +import { CANVAS_FOCUS } from "../common-canvas/constants/canvas-constants.js"; export default class CreateAutoNodeAction extends Action { constructor(data, canvasController) { @@ -31,12 +32,14 @@ export default class CreateAutoNodeAction extends Action { this.labelUtil = canvasController.labelUtil; this.objectModel = canvasController.objectModel; this.apiPipeline = this.objectModel.getAPIPipeline(data.pipelineId); + // If addLink is missing we default it to be true. + this.data.addLink = typeof this.data.addLink === "undefined" ? true : this.data.addLink; const autoLinkOnlyFromSelNodes = canvasController.getCanvasConfig().enableAutoLinkOnlyFromSelNodes; this.srcNode = this.apiPipeline.getAutoSourceNode(autoLinkOnlyFromSelNodes); this.newNode = this.apiPipeline.createAutoNode(data, this.srcNode); this.newLink = null; - if (this.apiPipeline.isLinkNeededWithAutoNode(this.newNode, this.srcNode)) { + if (this.data.addLink && this.apiPipeline.isLinkNeededWithAutoNode(this.newNode, this.srcNode)) { this.newLink = this.apiPipeline.createLink(this.newNode, this.srcNode); } } @@ -60,10 +63,12 @@ export default class CreateAutoNodeAction extends Action { } this.objectModel.setSelections([this.newNode.id], this.data.pipelineId); + this.focusObject = this.newNode; } undo() { this.apiPipeline.deleteNodes([this.newNode]); + this.focusObject = CANVAS_FOCUS; } redo() { @@ -73,4 +78,8 @@ export default class CreateAutoNodeAction extends Action { getLabel() { return this.labelUtil.getActionLabel(this, "action.createNode", { node_label: this.newNode.label }); } + + getFocusObject() { + return this.focusObject; + } } diff --git a/canvas_modules/common-canvas/src/command-actions/createCommentAction.js b/canvas_modules/common-canvas/src/command-actions/createCommentAction.js index 5cac47b143..f7e3c10c19 100644 --- a/canvas_modules/common-canvas/src/command-actions/createCommentAction.js +++ b/canvas_modules/common-canvas/src/command-actions/createCommentAction.js @@ -14,6 +14,7 @@ * limitations under the License. */ import Action from "../command-stack/action.js"; +import { CANVAS_FOCUS } from "../common-canvas/constants/canvas-constants.js"; export default class CreateCommentAction extends Action { constructor(data, canvasController) { @@ -34,17 +35,23 @@ export default class CreateCommentAction extends Action { // Standard methods do() { this.apiPipeline.addComment(this.comment); + this.focusObject = this.comment; } undo() { this.apiPipeline.deleteComment(this.comment.id); + this.focusObject = CANVAS_FOCUS; } redo() { - this.apiPipeline.addComment(this.comment); + this.do(); } getLabel() { return this.labelUtil.getActionLabel(this, "action.createComment"); } + + getFocusObject() { + return this.focusObject; + } } diff --git a/canvas_modules/common-canvas/src/command-actions/createSuperNodeAction.js b/canvas_modules/common-canvas/src/command-actions/createSuperNodeAction.js index e0260a8e43..1fb103cfa6 100644 --- a/canvas_modules/common-canvas/src/command-actions/createSuperNodeAction.js +++ b/canvas_modules/common-canvas/src/command-actions/createSuperNodeAction.js @@ -576,6 +576,7 @@ export default class CreateSuperNodeAction extends Action { const pipelines = [this.subPipeline].concat(this.descPipelines); this.objectModel.setParentUrl(pipelines, this.data.externalUrl); } + this.focusObject = this.supernode; } undo() { @@ -603,6 +604,8 @@ export default class CreateSuperNodeAction extends Action { this.apiPipeline.addLinks(this.subflowInputLinks); this.apiPipeline.addLinks(this.subflowOutputLinks); this.apiPipeline.addLinks(this.linksToDelete); + + this.focusObject = this.data.selectedObjects[0]; } redo() { @@ -612,4 +615,9 @@ export default class CreateSuperNodeAction extends Action { getLabel() { return this.labelUtil.getActionLabel(this, "action.createSuperNode", { node_label: this.supernode.label }); } + + getFocusObject() { + return this.focusObject; + } + } diff --git a/canvas_modules/common-canvas/src/command-actions/deconstructSuperNodeAction.js b/canvas_modules/common-canvas/src/command-actions/deconstructSuperNodeAction.js index 455fddf70e..d4115147e1 100644 --- a/canvas_modules/common-canvas/src/command-actions/deconstructSuperNodeAction.js +++ b/canvas_modules/common-canvas/src/command-actions/deconstructSuperNodeAction.js @@ -16,7 +16,7 @@ import Action from "../command-stack/action.js"; import CanvasUtils from "../common-canvas/common-canvas-utils"; -import { NODE_LINK } +import { CANVAS_FOCUS, NODE_LINK } from "../common-canvas/constants/canvas-constants.js"; export default class DeconstructSuperNodeAction extends Action { @@ -276,10 +276,12 @@ export default class DeconstructSuperNodeAction extends Action { // Standard methods do() { this.apiPipeline.deconstructSupernode(this.info); + this.focusObject = this.info?.nodesToAdd?.length > 0 ? this.info.nodesToAdd[0] : CANVAS_FOCUS; } undo() { this.apiPipeline.reconstructSupernode(this.info); + this.focusObject = this.supernode; } redo() { @@ -289,4 +291,8 @@ export default class DeconstructSuperNodeAction extends Action { getLabel() { return this.labelUtil.getActionLabel(this, "action.deconstructSuperNode", { node_label: this.supernode.label }); } + + getFocusObject() { + return this.focusObject; + } } diff --git a/canvas_modules/common-canvas/src/command-actions/deleteLinkAction.js b/canvas_modules/common-canvas/src/command-actions/deleteLinkAction.js index 3237b59e25..bd670dc5d4 100644 --- a/canvas_modules/common-canvas/src/command-actions/deleteLinkAction.js +++ b/canvas_modules/common-canvas/src/command-actions/deleteLinkAction.js @@ -14,32 +14,38 @@ * limitations under the License. */ import Action from "../command-stack/action.js"; +import { CANVAS_FOCUS } from "../common-canvas/constants/canvas-constants.js"; export default class DeleteLinkAction extends Action { constructor(data, canvasController) { super(data); this.data = data; - this.linkInfo = []; this.labelUtil = canvasController.labelUtil; this.objectModel = canvasController.objectModel; this.apiPipeline = this.objectModel.getAPIPipeline(data.pipelineId); + this.link = this.apiPipeline.getLink(this.data.id); } // Standard methods do() { - this.linkInfo = this.apiPipeline.getLink(this.data.id); - this.apiPipeline.deleteLink(this.data); + this.apiPipeline.deleteLink(this.link); + this.focusObject = CANVAS_FOCUS; } undo() { - this.apiPipeline.addLinks([this.linkInfo]); + this.apiPipeline.addLinks([this.link]); + this.focusObject = this.link; } redo() { - this.apiPipeline.deleteLink(this.data); + this.do(); } getLabel() { return this.labelUtil.getActionLabel(this, "action.deleteLink"); } + + getFocusObject() { + return this.focusObject; + } } diff --git a/canvas_modules/common-canvas/src/command-actions/deleteObjectsAction.js b/canvas_modules/common-canvas/src/command-actions/deleteObjectsAction.js index 92e11a089e..d2359ec13e 100644 --- a/canvas_modules/common-canvas/src/command-actions/deleteObjectsAction.js +++ b/canvas_modules/common-canvas/src/command-actions/deleteObjectsAction.js @@ -24,6 +24,7 @@ import CanvasUtils from "../common-canvas/common-canvas-utils.js"; import Action from "../command-stack/action.js"; +import { CANVAS_FOCUS } from "../common-canvas/constants/canvas-constants.js"; export default class DeleteObjectsAction extends Action { constructor(data, canvasController) { @@ -158,6 +159,7 @@ export default class DeleteObjectsAction extends Action { nodesToDelete: this.nodesToDelete, commentsToDelete: this.commentsToDelete }); + this.focusObject = CANVAS_FOCUS; } undo() { @@ -170,6 +172,8 @@ export default class DeleteObjectsAction extends Action { nodesToAdd: this.nodesToDelete, commentsToAdd: this.commentsToDelete }); + + this.focusObject = this.data.selectedObjects[0]; } redo() { @@ -180,6 +184,10 @@ export default class DeleteObjectsAction extends Action { return this.actionLabel; } + getFocusObject() { + return this.focusObject; + } + createActionLabel() { const stringsList = [ { label: "Nodes", val: this.nodesToDelete.length + this.supernodesToDelete.length }, diff --git a/canvas_modules/common-canvas/src/command-actions/disconnectObjectsAction.js b/canvas_modules/common-canvas/src/command-actions/disconnectObjectsAction.js index ab76063496..5c679229e2 100644 --- a/canvas_modules/common-canvas/src/command-actions/disconnectObjectsAction.js +++ b/canvas_modules/common-canvas/src/command-actions/disconnectObjectsAction.js @@ -50,4 +50,8 @@ export default class DisconnectObjectsAction extends Action { getLabel() { return this.labelUtil.getActionLabel(this, "action.disconnectObjects"); } + + getFocusObject() { + return this.data.selectedObjects[0]; + } } diff --git a/canvas_modules/common-canvas/src/command-actions/editCommentAction.js b/canvas_modules/common-canvas/src/command-actions/editCommentAction.js index fbab157b28..530e1b7fa8 100644 --- a/canvas_modules/common-canvas/src/command-actions/editCommentAction.js +++ b/canvas_modules/common-canvas/src/command-actions/editCommentAction.js @@ -28,18 +28,24 @@ export default class EditCommentAction extends Action { // Standard methods do() { this.apiPipeline.editComment(this.data); + this.focusObject = this.data; } undo() { this.apiPipeline.editComment(this.previousComment); + this.focusObject = this.previousComment; } redo() { - this.apiPipeline.editComment(this.data); + this.do(); } getLabel() { return this.labelUtil.getActionLabel(this, "action.editComment"); } + getFocusObject() { + return this.focusObject; + } + } diff --git a/canvas_modules/common-canvas/src/command-actions/editDecorationLabelAction.js b/canvas_modules/common-canvas/src/command-actions/editDecorationLabelAction.js index 57f7568792..2600d3a34d 100644 --- a/canvas_modules/common-canvas/src/command-actions/editDecorationLabelAction.js +++ b/canvas_modules/common-canvas/src/command-actions/editDecorationLabelAction.js @@ -43,6 +43,7 @@ export default class EditDecorationLabelAction extends Action { } else if (this.data.objType === DEC_NODE) { this.apiPipeline.setNodeDecorations(this.data.objId, this.newDecorations); } + this.focusObject = this.data.selectedObjects[0]; } undo() { @@ -51,6 +52,7 @@ export default class EditDecorationLabelAction extends Action { } else if (this.data.objType === DEC_NODE) { this.apiPipeline.setNodeDecorations(this.data.objId, this.previousDecorations); } + this.focusObject = this.data.selectedObjects[0]; } redo() { @@ -63,4 +65,8 @@ export default class EditDecorationLabelAction extends Action { } return this.labelUtil.getActionLabel(this, "action.editNodeDecorationLabel"); } + + getFocusObject() { + return this.focusObject; + } } diff --git a/canvas_modules/common-canvas/src/command-actions/pasteAction.js b/canvas_modules/common-canvas/src/command-actions/pasteAction.js index ac48b095dc..c4fce75e53 100644 --- a/canvas_modules/common-canvas/src/command-actions/pasteAction.js +++ b/canvas_modules/common-canvas/src/command-actions/pasteAction.js @@ -24,6 +24,7 @@ import Action from "../command-stack/action.js"; import CanvasUtils from "../common-canvas/common-canvas-utils.js"; +import { CANVAS_FOCUS } from "../common-canvas/constants/canvas-constants.js"; export default class PasteAction extends Action { constructor(data, canvasController) { @@ -35,6 +36,7 @@ export default class PasteAction extends Action { this.areDetachableLinksInUse = canvasController.areDetachableLinksInUse(); this.isSnapToGridInUse = canvasController.isSnapToGridInUse(); this.apiPipeline = this.objectModel.getAPIPipeline(data.pipelineId); + this.oldFocusObject = canvasController.getFocusObject(); // Make sure objects to be pasted are in an appropriate position for them // to appear within the viewport. @@ -175,6 +177,20 @@ export default class PasteAction extends Action { pipelinesToAdd: this.pipelines, selections: this.selectionIds }); + this.focusObject = this.getDoFocusObject(); + } + + getDoFocusObject() { + if (this.clones?.clonedNodes?.length > 0) { + return this.clones.clonedNodes[0]; + } + if (this.clones?.clonedComments?.length > 0) { + return this.clones.clonedComments[0]; + } + if (this.clones?.clonedLinks?.length > 0) { + return this.clones.clonedLinks[0]; + } + return null; } undo() { @@ -195,6 +211,7 @@ export default class PasteAction extends Action { pipelinesToDelete: pipelines, extPipelineFlowsToDelete: oldExtPipelineFlows }); + this.focusObject = this.oldFocusObject || CANVAS_FOCUS; } redo() { @@ -204,4 +221,8 @@ export default class PasteAction extends Action { getLabel() { return this.labelUtil.getActionLabel(this, "action.pasteObjects"); } + + getFocusObject() { + return this.focusObject; + } } diff --git a/canvas_modules/common-canvas/src/command-actions/setNodeLabelAction.js b/canvas_modules/common-canvas/src/command-actions/setNodeLabelAction.js index 774a23b476..d002fbf3ce 100644 --- a/canvas_modules/common-canvas/src/command-actions/setNodeLabelAction.js +++ b/canvas_modules/common-canvas/src/command-actions/setNodeLabelAction.js @@ -22,7 +22,8 @@ export default class SetNodeLabelAction extends Action { this.labelUtil = canvasController.labelUtil; this.objectModel = canvasController.objectModel; this.apiPipeline = this.objectModel.getAPIPipeline(data.pipelineId); - this.previousLabel = this.apiPipeline.getNode(data.nodeId).label; + this.node = this.apiPipeline.getNode(data.nodeId); + this.previousLabel = this.node.label; } // Standard methods @@ -41,4 +42,8 @@ export default class SetNodeLabelAction extends Action { getLabel() { return this.labelUtil.getActionLabel(this, "action.setNodeLabel"); } + + getFocusObject() { + return this.node; + } } diff --git a/canvas_modules/common-canvas/src/common-canvas/canvas-controller-menu-utils.js b/canvas_modules/common-canvas/src/common-canvas/canvas-controller-menu-utils.js index adf4a3fc13..902465c2f9 100644 --- a/canvas_modules/common-canvas/src/common-canvas/canvas-controller-menu-utils.js +++ b/canvas_modules/common-canvas/src/common-canvas/canvas-controller-menu-utils.js @@ -1,5 +1,5 @@ /* - * Copyright 2017-2023 Elyra Authors + * Copyright 2017-2024 Elyra Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ import { get } from "lodash"; import { LINK_SELECTION_NONE, SUPER_NODE, WYSIWYG } from "./constants/canvas-constants"; -// Global constant to handle the canvas controller. +// Global temporary variable to handle the canvas controller. let cc = null; // Returns a context menu definition for the source object passed in which @@ -25,7 +25,7 @@ let cc = null; export default function getContextMenuDefiniton(source, canvasController) { cc = canvasController; - const defMenu = createDefaultContextMenu(source, cc.getCanvasConfig().enableWYSIWYGComments); + const defMenu = createDefaultContextMenu(source, cc); let menuDefinition; if (typeof cc.handlers.contextMenuHandler === "function") { @@ -122,7 +122,7 @@ const isEditingAction = (action) => // Returns a default context menu definition for the source object and canvas // controller passed in. -const createDefaultContextMenu = (source, enableWYSIWYGComments) => { +const createDefaultContextMenu = (source) => { let menuDefinition = []; const menuForNonSelectedObj = cc.isContextToolbarForNonSelectedObj(source); @@ -130,11 +130,8 @@ const createDefaultContextMenu = (source, enableWYSIWYGComments) => { if (source.type === "canvas") { menuDefinition = menuDefinition.concat( - createCommentMenu(enableWYSIWYGComments), - [ - { action: "selectAll", label: getLabel("canvas.selectAll") }, - { divider: true } - ] + createCommentMenu(), + createSelectAllMenu() ); } // Rename node @@ -299,8 +296,8 @@ const createDefaultContextMenu = (source, enableWYSIWYGComments) => { return menuDefinition; }; -const createCommentMenu = (enableWYSIWYGComments) => { - if (enableWYSIWYGComments) { +const createCommentMenu = () => { + if (cc.getCanvasConfig().enableWYSIWYGComments) { return [ { action: "createComment", label: getLabel("canvas.addComment"), toolbarItem: true }, { action: "createWYSIWYGComment", label: getLabel("canvas.addWysiwygComment"), toolbarItem: true } @@ -309,6 +306,21 @@ const createCommentMenu = (enableWYSIWYGComments) => { return { action: "createComment", label: getLabel("canvas.addComment"), toolbarItem: true }; }; +const createSelectAllMenu = () => { + if (cc.areAllObjectsSelected()) { + return [ + { action: "deselectAll", label: getLabel("canvas.deselectAll") }, + { divider: true } + ]; + } else if (!cc.isPrimaryPipelineEmpty()) { + return [ + { action: "selectAll", label: getLabel("canvas.selectAll") }, + { divider: true } + ]; + } + return []; +}; + const createEditMenu = (source, includePaste) => { const editSubMenu = [ { action: "cut", label: getLabel("edit.cutSelection"), enable: source.type === "canvas" ? source.selectedObjectIds.length > 0 : true }, diff --git a/canvas_modules/common-canvas/src/common-canvas/canvas-controller.js b/canvas_modules/common-canvas/src/common-canvas/canvas-controller.js index e2acd94172..f352eb30e8 100644 --- a/canvas_modules/common-canvas/src/common-canvas/canvas-controller.js +++ b/canvas_modules/common-canvas/src/common-canvas/canvas-controller.js @@ -55,8 +55,9 @@ import ObjectModel from "../object-model/object-model.js"; import SizeAndPositionObjectsAction from "../command-actions/sizeAndPositionObjectsAction.js"; import getContextMenuDefiniton from "./canvas-controller-menu-utils.js"; import { get, isEmpty } from "lodash"; -import { LINK_SELECTION_NONE, LINK_SELECTION_DETACHABLE, - SNAP_TO_GRID_NONE, SUPER_NODE, WYSIWYG +import { CANVAS_FOCUS, + LINK_SELECTION_NONE, LINK_SELECTION_DETACHABLE, + SNAP_TO_GRID_NONE, SUPER_NODE, WYSIWYG, CAUSE_MOUSE, CAUSE_KEYBOARD } from "./constants/canvas-constants"; import { cloneDeep } from "lodash"; @@ -85,6 +86,7 @@ export default class CanvasController { undo: true, redo: true, selectAll: true, + deselectAll: true, copyToClipboard: true, cutToClipboard: true, pasteFromClipboard: true @@ -123,7 +125,16 @@ export default class CanvasController { // canvas controller is created. this.instanceId = commonCanvasControllerInstanceId++; + // Global variable to track whether branch highlighting is displayed or not. this.branchHighlighted = false; + + // Stores the object that is currently focused. This can be a node, comment + // or link OR the string stored in the constant CANVAS_FOCUS. The focusObject + // is maintained even if the focus goes outside the canvas. This helps in the + // situation where focus goes to a context menu (outside the canvas) and then, + // when the menu is closed, we want the focus to go back to the originally + // focused object. + this.focusObject = null; } // --------------------------------------------------------------------------- @@ -550,6 +561,13 @@ export default class CanvasController { // Include links in selectAll unless LinkSelection is "None" const includeLinks = this.getCanvasConfig().enableLinkSelection !== LINK_SELECTION_NONE; this.objectModel.selectAll(includeLinks, pipelineId); + this.setFocusOnCanvas(); + } + + // De-selects all the objects on the canvas. + deselectAll(pipelineId) { + this.objectModel.deselectAll(pipelineId); + this.setFocusOnCanvas(); } isPrimaryPipelineEmpty() { @@ -598,6 +616,11 @@ export default class CanvasController { return this.objectModel.areSelectedNodesContiguous(); } + areAllObjectsSelected() { + const includeLinks = this.areDetachableLinksInUse(); + return this.objectModel.areAllObjectsSelected(includeLinks); + } + // Returns true if all the selected objects are links. areAllSelectedObjectsLinks() { return this.objectModel.areAllSelectedObjectsLinks(); @@ -611,8 +634,12 @@ export default class CanvasController { return this.getCanvasConfig().enableSnapToGridType !== SNAP_TO_GRID_NONE; } - selectObject(objId, isShiftKeyPressed, isCmndCtrlPressed, pipelineId) { - this.objectModel.selectObject(objId, isShiftKeyPressed, isCmndCtrlPressed, pipelineId); + isSelected(objectId, pipelineId) { + return this.objectModel.isSelected(objectId, pipelineId); + } + + selectObject(objId, range, augment, pipelineId) { + this.objectModel.selectObject(objId, range, augment, pipelineId); } // --------------------------------------------------------------------------- @@ -1606,10 +1633,6 @@ export default class CanvasController { return this.getObjectModel().isPaletteOpen(); } - openContextMenu(menuDef, source) { - this.objectModel.openContextMenu(menuDef, source); - } - isBottomPanelOpen() { return this.getObjectModel().isBottomPanelOpen(); } @@ -1634,8 +1657,13 @@ export default class CanvasController { return this.getObjectModel().isTopPanelOpen(); } + openContextMenu(menuDef, source) { + this.objectModel.openContextMenu(menuDef, source); + } + closeContextMenu() { this.objectModel.closeContextMenu(); + this.restoreFocus(); } isContextMenuDisplayed() { @@ -1646,9 +1674,14 @@ export default class CanvasController { return this.objectModel.getContextMenuSource(); } - closeContextToolbar() { - if (!this.mouseInContextToolbar && !this.mouseInObject) { + closeContextToolbar(cause = CAUSE_MOUSE) { + if (cause === CAUSE_KEYBOARD) { this.objectModel.closeContextMenu(); + this.restoreFocus(); + + } else if (!this.mouseInContextToolbar && !this.mouseInObject) { + this.objectModel.closeContextMenu(); + this.setFocusOnCanvas(); } } @@ -1865,6 +1898,74 @@ export default class CanvasController { return null; } + // --------------------------------------------------------------------------- + // Focus management methods + // --------------------------------------------------------------------------- + + restoreFocus() { + if (this.getSVGCanvasD3()) { + this.setFocusObject(this.focusObject); // This will force a refresh of the focus + } + } + + focusOnTextEntryElement(evt) { + if (this.canvasContents) { + this.getSVGCanvasD3().focusOnTextEntryElement(evt); + } + } + + setFocusOnCanvas() { + this.setFocusObject(CANVAS_FOCUS); + } + + getFocusObject() { + return this.focusObject; + } + + setFocusObject(focusObj) { + this.focusObject = focusObj; + + if (this.focusObject && this.canvasContents) { + if (this.focusObject === CANVAS_FOCUS) { + this.canvasContents.focusOnCanvas(); + + } else { + this.getSVGCanvasD3().moveFocusTo(focusObj); + } + } + } + + isFocusOnCanvas() { + if (this.canvasContents) { + return this.canvasContents.isFocusOnCanvas(); + } + return false; + } + + // Checks to see if the current focus object is selected. If it is not selected + // this method auto-selects that object and ensures that the action function + // passed in (actionFn) is run immediately after the select has run. If the + // current focus object is already selected it just runs the action function. + // If augment is set to true the focus object will be added to the set of + // selected objects instead of replacing the current selections. + autoSelectFocusObj(actionFn, augment) { + const focusObj = this.getFocusObject(); + if (focusObj && focusObj !== CANVAS_FOCUS) { + const pipelineId = this.getCurrentPipelineId(); + if (!this.isFocusOnCanvas() && !this.isSelected(focusObj.id, pipelineId)) { + const fn = () => { + actionFn(); + this.removeAfterUpdateCallback(fn); + }; + this.addAfterUpdateCallback(fn); + this.selectObject(focusObj.id, false, augment, pipelineId); + return; + } + } + + actionFn(); + } + // --------------------------------------------------------------------------- // Utility/helper methods // --------------------------------------------------------------------------- @@ -2103,14 +2204,16 @@ export default class CanvasController { } } - // Called when a node is double clicked in the palette and added to the canvas. - // The nodeTemplate is in the internal format. - createAutoNode(nodeTemplate) { + // Automatically adds a node (nodeTemplate) to the canvas. The nodeTemplate + // must be in the internal format. If addLink is true a link will be created + // between the new node and the node it is positioned next to. + createAutoNode(nodeTemplate, addLink = true) { const selApiPipeline = this.objectModel.getSelectionAPIPipeline(); const apiPipeline = selApiPipeline ? selApiPipeline : this.objectModel.getAPIPipeline(); var data = { editType: "createAutoNode", editSource: "canvas", + addLink: addLink, nodeTemplate: nodeTemplate, pipelineId: apiPipeline.pipelineId }; @@ -2251,7 +2354,7 @@ export default class CanvasController { this.isContextToolbarForNonSelectedObj(source)) { this.setSelections([source.targetObject.id]); } - this.canvasContents.focusOnCanvas(); // Set focus on canvas so keybord events go there. + const data = Object.assign({}, source, { "editType": action, "editParam": editParam, "editSource": "contextmenu" }); this.editActionHandler(data); } @@ -2374,6 +2477,10 @@ export default class CanvasController { this.selectAll(data.pipelineId); break; } + case "deselectAll": { + this.deselectAll(data.pipelineId); + break; + } case "zoomIn": { this.zoomIn(); break; @@ -2615,7 +2722,6 @@ export default class CanvasController { this.commandStack.do(command); break; } - case "expandSuperNodeInPlace": { command = new ExpandSuperNodeInPlaceAction(data, this); this.commandStack.do(command); @@ -2710,6 +2816,35 @@ export default class CanvasController { // encountered. this.ensureVisibleExpandedPipelinesAreLoaded(); + // Set the keyboard focus appropriately for each command that has + // a getFocusObject method. In other cases, the focus will remain + // in its current location. + if (this.getCanvasConfig().enableKeyboardNavigation) { + if (command?.getFocusObject) { + const focusObject = command.getFocusObject(); + + if (focusObject === CANVAS_FOCUS) { + this.setFocusOnCanvas(); + + } else if (this.canvasContents) { + this.setFocusObject(focusObject); + } + } + + // When keyboard navigation is NOT activated we restore focus (which will + // put focus on the canvas background) except in these cases. + } else if (data.editType !== "setCommentEditingMode" && + data.editType !== "setNodeLabelEditingMode" && + data.editType !== "togglePalette" && + data.editType !== "openPalette" && + data.editType !== "closePalette" && + data.editType !== "toggleNotificationPanel" && + data.editType !== "openNotificationPanel" && + data.editType !== "closeNotificationPanel" && + data.editType !== "loadPipelineFlow") { + this.restoreFocus(); + } + return true; } diff --git a/canvas_modules/common-canvas/src/common-canvas/cc-contents.jsx b/canvas_modules/common-canvas/src/common-canvas/cc-contents.jsx index 6fa7e61b5f..8dc0953913 100644 --- a/canvas_modules/common-canvas/src/common-canvas/cc-contents.jsx +++ b/canvas_modules/common-canvas/src/common-canvas/cc-contents.jsx @@ -34,24 +34,6 @@ import { DND_DATA_TEXT, STATE_TAG_LOCKED, STATE_TAG_READ_ONLY } from "./constant import Logger from "../logging/canvas-logger.js"; import SVGCanvasD3 from "./svg-canvas-d3.js"; -const BACKSPACE_KEY = 8; -const TAB_KEY = 9; -const DELETE_KEY = 46; -const SPACE_KEY = 32; -const A_KEY = 65; -const C_KEY = 67; -const P_KEY = 80; -const V_KEY = 86; -const X_KEY = 88; -const Y_KEY = 89; -const Z_KEY = 90; - -// TODO - Implement nudge behavior for moving nodes and comments -// const LEFT_ARROW_KEY = 37; -// const UP_ARROW_KEY = 38; -// const RIGHT_ARROW_KEY = 39; -// const DOWN_ARROW_KEY = 40; - class CanvasContents extends React.Component { constructor(props) { super(props); @@ -98,12 +80,10 @@ class CanvasContents extends React.Component { this.onKeyUp = this.onKeyUp.bind(this); this.onMouseMove = this.onMouseMove.bind(this); this.onClickReturnToPrevious = this.onClickReturnToPrevious.bind(this); - this.onFocus = this.onFocus.bind(this); + this.onMouseLeave = this.onMouseLeave.bind(this); + this.onMouseDown = this.onMouseDown.bind(this); this.onBlur = this.onBlur.bind(this); - // Keeps state about tabbing. - this.tabBeingProcessed = false; - // Variables to handle strange HTML drag and drop behaviors. That is, pairs // of dragEnter/dragLeave events are fired as an external object is // dragged around over the top of the 'drop zone' canvas. @@ -132,7 +112,7 @@ class CanvasContents extends React.Component { } if (this.props.canvasConfig.enableFocusOnMount) { - this.focusOnCanvas(); + this.props.canvasController.setFocusOnCanvas(); } } @@ -150,6 +130,8 @@ class CanvasContents extends React.Component { // setSelectionInfo, which will only update the selection highlighting. } else if (prevProps.selectionInfo !== this.props.selectionInfo) { this.svgCanvasD3.setSelectionInfo(this.props.selectionInfo); + // Run the afterUpdateCallbacks. + this.afterUpdate(); } } @@ -166,7 +148,7 @@ class CanvasContents extends React.Component { } onCut(evt) { - if (this.isFocusOnCanvas(evt) && + if (this.isFocusOnCanvasOrContents(evt) && this.props.canvasConfig.enableEditingActions && !this.svgCanvasD3.isEditingText()) { evt.preventDefault(); @@ -175,7 +157,7 @@ class CanvasContents extends React.Component { } onCopy(evt) { - if (this.isFocusOnCanvas(evt) && + if (this.isFocusOnCanvasOrContents(evt) && this.props.canvasConfig.enableEditingActions && !this.svgCanvasD3.isEditingText()) { evt.preventDefault(); @@ -184,7 +166,7 @@ class CanvasContents extends React.Component { } onPaste(evt) { - if (this.isFocusOnCanvas(evt) && + if (this.isFocusOnCanvasOrContents(evt) && this.props.canvasConfig.enableEditingActions && !this.svgCanvasD3.isEditingText()) { evt.preventDefault(); @@ -198,45 +180,46 @@ class CanvasContents extends React.Component { // keyboard action. this.props.canvasController.closeTip(); const actions = this.props.canvasController.getKeyboardConfig().actions; + // We don't handle key presses when: // 1. We are editng text, because the text area needs to receive key // presses for undo, redo, delete etc. // 2. Dragging objects - if (this.svgCanvasD3.isEditingText() || - this.svgCanvasD3.isDragging()) { + if (this.svgCanvasD3.isEditingText() || this.svgCanvasD3.isDragging()) { return; } // These actions alter the canvas objects so we need to check // this.config.enableEditingActions before calling them. if (this.props.canvasConfig.enableEditingActions) { - if ((evt.keyCode === BACKSPACE_KEY || evt.keyCode === DELETE_KEY) && actions.delete) { + if (KeyboardUtils.delete(evt) && actions.delete) { CanvasUtils.stopPropagationAndPreventDefault(evt); // Some browsers interpret Delete as 'Back to previous page'. So prevent that. - this.props.canvasController.keyboardActionHandler("deleteSelectedObjects"); + this.props.canvasController.autoSelectFocusObj(() => + this.props.canvasController.keyboardActionHandler("deleteSelectedObjects")); - } else if (KeyboardUtils.isCmndCtrlPressed(evt) && - !evt.shiftKey && evt.keyCode === Z_KEY && actions.undo) { + } else if (KeyboardUtils.undo(evt) && actions.undo) { CanvasUtils.stopPropagationAndPreventDefault(evt); if (this.props.canvasController.canUndo()) { this.props.canvasController.keyboardActionHandler("undo"); } - } else if (KeyboardUtils.isCmndCtrlPressed(evt) && - ((evt.shiftKey && evt.keyCode === Z_KEY) || evt.keyCode === Y_KEY && actions.redo)) { + } else if (KeyboardUtils.redo(evt) && actions.redo) { CanvasUtils.stopPropagationAndPreventDefault(evt); if (this.props.canvasController.canRedo()) { this.props.canvasController.keyboardActionHandler("redo"); } - } else if (KeyboardUtils.isCmndCtrlPressed(evt) && evt.keyCode === C_KEY && actions.copyToClipboard) { + } else if (KeyboardUtils.copyToClipboard(evt) && actions.copyToClipboard) { CanvasUtils.stopPropagationAndPreventDefault(evt); - this.props.canvasController.keyboardActionHandler("copy"); + this.props.canvasController.autoSelectFocusObj(() => + this.props.canvasController.keyboardActionHandler("copy")); - } else if (KeyboardUtils.isCmndCtrlPressed(evt) && evt.keyCode === X_KEY && actions.cutToClipboard) { + } else if (KeyboardUtils.cutToClipboard(evt) && actions.cutToClipboard) { CanvasUtils.stopPropagationAndPreventDefault(evt); - this.props.canvasController.keyboardActionHandler("cut"); + this.props.canvasController.autoSelectFocusObj(() => + this.props.canvasController.keyboardActionHandler("cut")); - } else if (KeyboardUtils.isCmndCtrlPressed(evt) && evt.keyCode === V_KEY && actions.pasteFromClipboard) { + } else if (KeyboardUtils.pasteFromClipboard(evt) && actions.pasteFromClipboard) { CanvasUtils.stopPropagationAndPreventDefault(evt); if (this.mousePos) { const mousePos = this.svgCanvasD3.convertPageCoordsToSnappedCanvasCoords(this.mousePos); @@ -248,55 +231,75 @@ class CanvasContents extends React.Component { } // These keyboard actions do not alter the canvas objects so we // do not need to check this.config.enableEditingActions before calling them. - if (KeyboardUtils.isCmndCtrlPressed(evt) && evt.keyCode === A_KEY && actions.selectAll) { + if (KeyboardUtils.selectAll(evt) && actions.selectAll) { CanvasUtils.stopPropagationAndPreventDefault(evt); this.props.canvasController.keyboardActionHandler("selectAll"); - } else if (evt.keyCode === SPACE_KEY) { + } else if (KeyboardUtils.deselectAll(evt) && actions.deselectAll) { + CanvasUtils.stopPropagationAndPreventDefault(evt); + this.props.canvasController.keyboardActionHandler("deselectAll"); + + } else if (KeyboardUtils.spaceKey(evt)) { if (!this.svgCanvasD3.isSpaceKeyPressed()) { CanvasUtils.stopPropagationAndPreventDefault(evt); this.svgCanvasD3.setSpaceKeyPressed(true); } - } else if (KeyboardUtils.isCmndCtrlPressed(evt) && evt.shiftKey && evt.altKey && evt.keyCode === P_KEY) { + } else if (KeyboardUtils.toggleLogging(evt)) { CanvasUtils.stopPropagationAndPreventDefault(evt); Logger.switchLoggingState(); // Switch the logging on and off - } else if (evt.keyCode === TAB_KEY && this.props.canvasConfig.enableKeyboardNavigation) { - if (evt.shiftKey) { - this.tabKeyShiftPressedOnDiv(evt); - } else { - this.tabKeyPressedOnDiv(evt); - } + } else if (KeyboardUtils.zoomToFit(evt) && this.props.canvasConfig.enableKeyboardNavigation) { + CanvasUtils.stopPropagationAndPreventDefault(evt); + this.svgCanvasD3.zoomToFit(); + + } else if (KeyboardUtils.zoomIn(evt) && this.props.canvasConfig.enableKeyboardNavigation) { + CanvasUtils.stopPropagationAndPreventDefault(evt); + this.svgCanvasD3.zoomIn(); + + } else if (KeyboardUtils.zoomOut(evt) && this.props.canvasConfig.enableKeyboardNavigation) { + CanvasUtils.stopPropagationAndPreventDefault(evt); + this.svgCanvasD3.zoomOut(); + + } else if (KeyboardUtils.panLeft(evt) && this.props.canvasConfig.enableKeyboardNavigation) { + CanvasUtils.stopPropagationAndPreventDefault(evt); + this.svgCanvasD3.translateBy(-10, 0); + + } else if (KeyboardUtils.panRight(evt) && this.props.canvasConfig.enableKeyboardNavigation) { + CanvasUtils.stopPropagationAndPreventDefault(evt); + this.svgCanvasD3.translateBy(10, 0); + + } else if (KeyboardUtils.panUp(evt) && this.props.canvasConfig.enableKeyboardNavigation) { + CanvasUtils.stopPropagationAndPreventDefault(evt); + this.svgCanvasD3.translateBy(0, -10); + + } else if (KeyboardUtils.panDown(evt) && this.props.canvasConfig.enableKeyboardNavigation) { + CanvasUtils.stopPropagationAndPreventDefault(evt); + this.svgCanvasD3.translateBy(0, 10); + + } else if (KeyboardUtils.nextGroup(evt) && this.props.canvasConfig.enableKeyboardNavigation) { + this.moveFocusToNextGroup(evt); + + } else if (KeyboardUtils.previousGroup(evt) && this.props.canvasConfig.enableKeyboardNavigation) { + this.moveFocusToPreviousGroup(evt); + + } else if (KeyboardUtils.displayContextOptions(evt) && this.props.canvasConfig.enableKeyboardNavigation) { + this.svgCanvasD3.openCanvasContextOptions(evt); } + evt.stopPropagation(); } onKeyUp() { this.svgCanvasD3.setSpaceKeyPressed(false); } - onFocus(evt) { - // This is a bit hacky but the only way I could get shift-tabbing to work - // when the shift tab needs to move focus out of the canvas. The problem is - // that, when shift-tab is presseed, when focus is on the first canvas object, - // a spurious onFocus event is recieved with relatedTarget.tagName set to - // "g" (I don't know why this occurs because a similar event is not received - // when tabbing forwards through the objects). - if (this.tabBeingProcessed === false && - evt.relatedTarget?.tagName === "g" && - document.getElementById(this.svgCanvasDivId)) { - document.getElementById(this.svgCanvasDivId).blur(); - } - } - + // When focus leaves the canvas it may be going to an "internal" object + // such as a node or a comment or to an "external" object like the + // toolbar or palette. If it goes outside the canvas, we reset the + // tab object index so that tabbing will begin from the first tab object. onBlur(evt) { - // Notify the canvas that the focus has left when we get onBlur. We need - // to ignore onBlur events that come to us when tab key presses are - // being processed, because setting focus on canvas objects causes an - // onFocus followed by an onBlur event on the div. - if (this.tabBeingProcessed === false && - !(evt.relatedTarget?.tagName === "g")) { - this.svgCanvasD3.focusSetOutsideCanvas(); + if (!this.isTargetInsideCanvas(evt.relatedTarget)) { + this.svgCanvasD3.resetTabObjectIndex(); } } @@ -319,6 +322,14 @@ class CanvasContents extends React.Component { } } + onMouseLeave(e) { + this.mousePos = null; + } + + onMouseDown(e) { + this.props.canvasController.setFocusOnCanvas(); + } + // Handles the click on the "Return to previous flow" button. onClickReturnToPrevious(evt) { evt.stopPropagation(); @@ -456,12 +467,18 @@ class CanvasContents extends React.Component { return dropZoneCanvas; } + getSvgCanvasDivId() { + return this.svgCanvasDivId; + } + getSVGCanvasDiv() { if (this.props.canvasConfig.enableKeyboardNavigation) { // Set tabindex to 0 so the focus can go to the
return (
); } @@ -470,7 +487,11 @@ class CanvasContents extends React.Component { // the div (which allows keyboard events to go there) and using -1 means // the user cannot tab to the div. Keyboard events are handled in svg-canvas-d3.js. // https://stackoverflow.com/questions/32911355/whats-the-tabindex-1-in-bootstrap-for - return (
); + return ( +
+ ); } setIsDropZoneDisplayed(isDropZoneDisplayed) { @@ -479,6 +500,11 @@ class CanvasContents extends React.Component { } } + // Returns true if the target element passed in is inside the canvas div. + isTargetInsideCanvas(target) { + return target && target.closest(".common-canvas-drop-div"); + } + isDropZoneDisplayed() { return this.props.canvasConfig.enableDropZoneOnExternalDrag && this.state.isDropZoneDisplayed; } @@ -492,7 +518,7 @@ class CanvasContents extends React.Component { // Returns true if the focus is either on an element in the canvas or on the // canvas
itself. - isFocusOnCanvas(evt) { + isFocusOnCanvasOrContents(evt) { if (evt.currentTarget?.activeElement) { return evt.currentTarget.activeElement.closest(this.svgCanvasDivSelector) || evt.currentTarget.activeElement.id === this.svgCanvasDivId; @@ -500,6 +526,9 @@ class CanvasContents extends React.Component { return false; } + isFocusOnCanvas() { + return document.activeElement?.id === this.getSvgCanvasDivId(); + } afterUpdate() { this.afterUpdateCallbacks.forEach((callback) => callback()); } @@ -616,36 +645,31 @@ class CanvasContents extends React.Component { // Handles tab key presses on our div. It also keeps track of whether // a tab key press is being handled using a flag. - tabKeyPressedOnDiv(evt) { - this.tabBeingProcessed = true; - + moveFocusToNextGroup(evt) { const success = this.svgCanvasD3.focusNextTabGroup(evt); if (success) { CanvasUtils.stopPropagationAndPreventDefault(evt); } else { - this.focusOnCanvas(); + this.props.canvasController.setFocusOnCanvas(); } - this.tabBeingProcessed = false; } - // Handles tab+shift key presses on our div. It alos keeps track of whether + // Handles tab+shift key presses on our div. It also keeps track of whether // a tab key press is being handled using a flag. - tabKeyShiftPressedOnDiv(evt) { - this.tabBeingProcessed = true; - + moveFocusToPreviousGroup(evt) { const success = this.svgCanvasD3.focusPreviousTabGroup(evt); if (success) { CanvasUtils.stopPropagationAndPreventDefault(evt); } else { - this.focusOnCanvas(); + this.props.canvasController.setFocusOnCanvas(); } - this.tabBeingProcessed = false; } + // Sets the focus on our canvas
so keyboard events will go to it. focusOnCanvas() { if (document.getElementById(this.svgCanvasDivId)) { - document.getElementById(this.svgCanvasDivId).focus(); // Set focus on div so keybord events go there. + document.getElementById(this.svgCanvasDivId).focus(); } } @@ -704,7 +728,6 @@ CanvasContents.propTypes = { // Provided by Redux canvasConfig: PropTypes.object.isRequired, canvasInfo: PropTypes.object, - bottomPanelIsOpen: PropTypes.bool, selectionInfo: PropTypes.object, breadcrumbs: PropTypes.array }; @@ -712,7 +735,6 @@ CanvasContents.propTypes = { const mapStateToProps = (state, ownProps) => ({ canvasInfo: state.canvasinfo, canvasConfig: state.canvasconfig, - bottomPanelIsOpen: state.bottompanel.isOpen, selectionInfo: state.selectioninfo, breadcrumbs: state.breadcrumbs }); diff --git a/canvas_modules/common-canvas/src/common-canvas/cc-context-toolbar.jsx b/canvas_modules/common-canvas/src/common-canvas/cc-context-toolbar.jsx index a749f3b05e..c1c9f2f4a9 100644 --- a/canvas_modules/common-canvas/src/common-canvas/cc-context-toolbar.jsx +++ b/canvas_modules/common-canvas/src/common-canvas/cc-context-toolbar.jsx @@ -21,6 +21,7 @@ import { findLastIndex } from "lodash"; import Toolbar from "../toolbar/toolbar.jsx"; import Logger from "../logging/canvas-logger.js"; import ColorPicker from "../color-picker"; +import { CAUSE_KEYBOARD } from "./constants/canvas-constants.js"; const CM_TOOLBAR_GAP = 2; const CM_ICON_SIZE = 32; @@ -38,6 +39,7 @@ class CommonCanvasContextToolbar extends React.Component { this.onMouseLeave = this.onMouseLeave.bind(this); this.toolbarActionHandler = this.toolbarActionHandler.bind(this); this.colorClicked = this.colorClicked.bind(this); + this.closeContextToolbar = this.closeContextToolbar.bind(this); } onMouseLeave(evt) { @@ -151,11 +153,16 @@ class CommonCanvasContextToolbar extends React.Component { } toolbarActionHandler(action, editParam) { - this.props.canvasController.setMouseInContextToolbar(false); - this.props.canvasController.closeContextToolbar(); + this.closeContextToolbar(); this.props.canvasController.contextMenuActionHandler(action, editParam); } + closeContextToolbar() { + this.props.canvasController.setMouseInContextToolbar(false); + this.props.canvasController.setMouseInObject(null); + this.props.canvasController.closeContextToolbar(CAUSE_KEYBOARD); + } + colorClicked(color) { this.toolbarActionHandler("colorSelectedObjects", { color }); } @@ -243,6 +250,9 @@ class CommonCanvasContextToolbar extends React.Component { containingDivId={this.props.containingDivId} toolbarActionHandler={this.toolbarActionHandler} tooltipDirection={"top"} + setInititalFocus + closeToolbarOnEsc + closeToolbar={this.closeContextToolbar} size={"sm"} />
diff --git a/canvas_modules/common-canvas/src/common-canvas/cc-text-toolbar.jsx b/canvas_modules/common-canvas/src/common-canvas/cc-text-toolbar.jsx index c82583f30f..b21d52e71c 100644 --- a/canvas_modules/common-canvas/src/common-canvas/cc-text-toolbar.jsx +++ b/canvas_modules/common-canvas/src/common-canvas/cc-text-toolbar.jsx @@ -22,6 +22,7 @@ import defaultMessages from "../../locales/common-canvas/locales/en.json"; import defaultToolbarMessages from "../../locales/toolbar/locales/en.json"; import Toolbar from "../toolbar/toolbar.jsx"; import CanvasUtils from "../common-canvas/common-canvas-utils.js"; +import KeyboardUtils from "./keyboard-utils.js"; import Logger from "../logging/canvas-logger.js"; import ColorPicker from "../color-picker"; import { @@ -39,9 +40,19 @@ class CommonCanvasTextToolbar extends React.Component { super(props); this.getLabel = this.getLabel.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.logger = new Logger("CC-Text-Toolbar"); } + onKeyDown(evt) { + if (KeyboardUtils.returnToTextEditing(evt)) { + this.props.canvasController.focusOnTextEntryElement(evt); + evt.stopPropagation(); + evt.preventDefault(); + } + } + getLabel(labelId, substituteObj) { const defaultMessage = defaultMessages[labelId] ? defaultMessages[labelId] : defaultToolbarMessages[labelId]; return this.props.intl.formatMessage({ id: labelId, defaultMessage: defaultMessage }, substituteObj); @@ -267,7 +278,7 @@ class CommonCanvasTextToolbar extends React.Component { return null; } - getTextToolbar() { + getTextToolbarConfig() { if (this.props.contentType === MARKDOWN) { return this.getMarkdownToolbar(); @@ -286,10 +297,10 @@ class CommonCanvasTextToolbar extends React.Component { if (this.props.isOpen) { textToolbar = (
{ - if (this.getSelectedObjectIds().includes(node.id)) { + this.getSelectedObjectIds().forEach((id) => { + const node = this.getNode(id); + if (node) { objs.push(node); } }); return objs; } + // Returns an array of any currently selected comments in the same order + // the IDs appear in the selected objects IDs array. + getSelectedComments() { + var objs = []; + this.getSelectedObjectIds().forEach((id) => { + const com = this.getComment(id); + if (com) { + objs.push(com); + } + }); + return objs; + } + // Returns an array of any currently selected node IDs. getSelectedNodeIds() { return this.getSelectedNodes().map((n) => n.id); diff --git a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-renderer.js b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-renderer.js index b09641bfd4..b70ac91665 100644 --- a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-renderer.js +++ b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-renderer.js @@ -42,7 +42,8 @@ import { ASSOC_RIGHT_SIDE_CURVE, ASSOCIATION_LINK, NODE_LINK, COMMENT_LINK, USE_DEFAULT_ICON, USE_DEFAULT_EXT_ICON, SUPER_NODE, SNAP_TO_GRID_AFTER, SNAP_TO_GRID_DURING, NORTH, SOUTH, EAST, WEST, - WYSIWYG } + WYSIWYG, CAUSE_KEYBOARD, CAUSE_MOUSE, + CANVAS_FOCUS } from "./constants/canvas-constants"; import SUPERNODE_ICON from "../../assets/images/supernode.svg"; import SUPERNODE_EXT_ICON from "../../assets/images/supernode_ext.svg"; @@ -126,10 +127,6 @@ export default class SVGCanvasRenderer { // entry in this array. this.dragOverDetachedLinks = []; - // An object containing the x and y offset of the position of the mouse - // pointer from the top left corner of the node that is being dragged. - this.dragPointerOffsetInNode = null; - // The node over which the 'guide' object for a new link or a link handle // is being dragged. Used when enableHighlightNodeOnNewLinkDrag config // option is switched on. @@ -312,6 +309,12 @@ export default class SVGCanvasRenderer { // associated renderer. this.displayCanvas(); + // Restore the focus back to whatever object is currently in focus if + // keyboard navigation is enabled. + if (this.config.enableKeyboardNavigation) { + this.restoreFocus(); + } + this.logger.logEndTimer("setCanvasInfoRenderer" + this.pipelineId.substring(0, 5)); } @@ -760,6 +763,14 @@ export default class SVGCanvasRenderer { return this.zoomUtils.transformPos({ x: x - Math.round(svgRect.left), y: y - Math.round(svgRect.top) }); } + // Convert coordinates from canvas to page coordinates (based + // on the page top left corner). + convertCanvasCoordsToPageCoords(x, y) { + const svgRect = this.canvasSVG.node().getBoundingClientRect(); + const pos = this.zoomUtils.unTransformPos({ x, y }); + return { x: pos.x + Math.round(svgRect.left), y: pos.y + Math.round(svgRect.top) }; + } + // Creates the div which contains the ghost node for drag and // drop actions from the palette. The way setDragImage is handled in // browsers for HTML drag and drop is very odd since the image has to be @@ -1275,7 +1286,12 @@ export default class SVGCanvasRenderer { .attr("data-pipeline-id", this.activePipeline.id) .attr("class", "d3-svg-background") .attr("pointer-events", "all") - .style("cursor", "default"); + .style("cursor", "default") + .on("mousedown", () => { + if (!this.svgCanvasTextArea.isEditingText()) { + this.canvasController.setFocusOnCanvas(); + } + }); // Only attach the 'defs' to the top most SVG area when we are displaying // either the primary pipeline full page or a sub-pipeline full page. @@ -1620,6 +1636,17 @@ export default class SVGCanvasRenderer { const nonBindingNodeGrps = joinedNodeGrps.filter((node) => !CanvasUtils.isSuperBindingNode(node)); + // Node Focus Outline + // This is created by the 'moveFocusTo' function when focus is moved to a + // node. The 'd3-focus-path' element only exists for one canvas object at a time. + nonBindingNodeGrps + .selectChildren(".d3-focus-path") + .data((d) => ([d]), (d) => d.id) + .join( + (enter) => null // Focus outline is created when focus is moved to the node (in moveFocusTo) + ) + .attr("d", (d) => this.getNodeShapePathSizing(d)); + // Node Sizing Area nonBindingNodeGrps .selectChildren(".d3-node-sizing") @@ -1995,21 +2022,108 @@ export default class SVGCanvasRenderer { if (linkInfos.length > 0) { const linkInfosAll = this.activePipeline.getAllLinksForNode(d); linkInfosAll.forEach((li) => (li.link.navObject = d)); - this.moveFocusTo({ obj: linkInfos[0].link, type: "link" }, d3Event); + this.setFocusObject(linkInfos[0].link, d3Event); } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); } else if (KeyboardUtils.previousObjectInGroup(d3Event)) { const linkInfos = this.activePipeline.getPreviousLinksToNode(d); if (linkInfos.length > 0) { const linkInfosAll = this.activePipeline.getAllLinksForNode(d); linkInfosAll.forEach((li) => (li.link.navObject = d)); - this.moveFocusTo({ obj: linkInfos[0].link, type: "link" }, d3Event); + this.setFocusObject(linkInfos[0].link, d3Event); + } + + } else if (KeyboardUtils.moveObjectUp(d3Event)) { + if (this.config.enableEditingActions) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.moveObject(d, NORTH)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.moveObjectDown(d3Event)) { + if (this.config.enableEditingActions) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.moveObject(d, SOUTH)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.moveObjectRight(d3Event)) { + if (this.config.enableEditingActions) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.moveObject(d, EAST)); } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.moveObjectLeft(d3Event)) { + if (this.config.enableEditingActions) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.moveObject(d, WEST)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); } else if (KeyboardUtils.selectObject(d3Event)) { CanvasUtils.stopPropagationAndPreventDefault(d3Event); - this.selectObjectD3Event(d3Event, d); // This method will check if ctrl/cmnd is pressed + this.selectObject(d3Event, d, "node", false, false); + + } else if (KeyboardUtils.selectObjectAugment(d3Event)) { + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + this.selectObject(d3Event, d, "node", false, true); + + } else if (KeyboardUtils.selectObjectRange(d3Event)) { + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + this.selectObject(d3Event, d, "node", true, false); + + } else if (KeyboardUtils.sizeObjectUp(d3Event)) { + if (CanvasUtils.isNodeResizable(d, this.config)) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.sizeObject(d, NORTH)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.sizeObjectDown(d3Event)) { + if (CanvasUtils.isNodeResizable(d, this.config)) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.sizeObject(d, SOUTH)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.sizeObjectRight(d3Event)) { + if (CanvasUtils.isNodeResizable(d, this.config)) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.sizeObject(d, EAST)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.sizeObjectLeft(d3Event)) { + if (CanvasUtils.isNodeResizable(d, this.config)) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.sizeObject(d, WEST)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + } else if (KeyboardUtils.createLink(d3Event)) { + if (this.config.enableEditingActions) { + this.canvasController.autoSelectFocusObj(() => + this.dragNewLinkUtils.createNewLinkFromSelections(), true); // true indicates "augment" the selection + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.displayContextOptions(d3Event)) { + // Don't let keypress go through to the Canvas otherwise the + // canvas contenxt menu/toolbar will be opened. + d3Event.stopPropagation(); + + if (!CanvasUtils.isSuperBindingNode(d)) { + this.selectObject(d3Event, d, "node"); + + if (this.config.enableContextToolbar) { + this.addContextToolbar(d3Event, d, "node", CAUSE_KEYBOARD); + } else { + const pos = this.getObjectCenterPosition(d3Event.currentTarget); + this.openContextMenu(d3Event, "node", d, null, pos); + } + } } } }) @@ -2017,11 +2131,15 @@ export default class SVGCanvasRenderer { if (this.isDragging()) { return; } + const nodeGrp = d3.select(d3Event.currentTarget); - this.raiseNodeToTop(nodeGrp); + this.raiseNodeToTop(nodeGrp, d3Event); + this.restoreFocus(); // raiseNodeToTop will removing the visual focus so restore it. + this.setNodeStyles(d, "hover", nodeGrp); + if (this.config.enableContextToolbar) { - this.addContextToolbar(d3Event, d, "node"); + this.addContextToolbar(d3Event, d, "node", CAUSE_MOUSE); } else { this.addDynamicNodeIcons(d3Event, d, nodeGrp); } @@ -2067,16 +2185,12 @@ export default class SVGCanvasRenderer { if (!this.config.enableDragWithoutSelect) { if (this.config.enableKeyboardNavigation) { this.activePipeline.setTabGroupIndexForObj(d); + this.setFocusObject(d, d3Event); } this.selectObjectD3Event(d3Event, d, "node"); } this.logger.logEndTimer("Node Group - mouse down"); }) - .on("mousemove", (d3Event, d) => { - // this.logger.log("Node Group - mouse move"); - // Don't stop propogation. Mouse move messages must be allowed to - // propagate to canvas zoom operation. - }) .on("click", (d3Event, d) => { this.logger.log("Node Group - click"); d3Event.stopPropagation(); @@ -2101,11 +2215,21 @@ export default class SVGCanvasRenderer { if (this.config.enableDragWithoutSelect) { this.selectObjectD3Event(d3Event, d, "node"); } + this.setFocusObject(d, d3Event); this.openContextMenu(d3Event, "node", d); } }); } + getObjectCenterPosition(obj) { + const rect = obj.getBoundingClientRect(); + const rect2 = this.canvasSVG.node().getBoundingClientRect(); + return { + x: rect.left - rect2.left + (rect.width / 2), + y: rect.top - rect2.top + (rect.height / 2) + }; + } + attachNodeSizingListeners(nodeGrps) { nodeGrps .on("mousedown", (d3Event, d) => { @@ -2354,24 +2478,12 @@ export default class SVGCanvasRenderer { // the d3Event object passed in. selectObjectD3Event(d3Event, d, objType) { this.selectObject( - d, d3Event.type, - d3Event.shiftKey, - KeyboardUtils.isCmndCtrlPressed(d3Event)); - // If the selection has changed we need to recreate any currently displayed - // context toolbar because the context actions may have changed based on - // the new selection. - this.recreateContextToolbar(d3Event, d, objType); - } - - // Adds the object passed in to the set of selected objects using - // the d3Event object's sourceEvent object. - selectObjectSourceEvent(d3Event, d) { - this.selectObject( d, - d3Event.type, - d3Event.sourceEvent.shiftKey, - KeyboardUtils.isCmndCtrlPressed(d3Event.sourceEvent)); + objType, + d3Event.shiftKey, + KeyboardUtils.isMetaKey(d3Event) + ); } // Performs required action for when either a comment, node or link is selected. @@ -2379,8 +2491,8 @@ export default class SVGCanvasRenderer { // currently selected set of objects; or even toggling the object's selection // off. This method also sends a SINGLE_CLICK action to the // clickActionHandler callback in the host application. - selectObject(d, d3EventType, isShiftKeyPressed, isCmndCtrlPressed) { - this.canvasController.selectObject(d.id, isShiftKeyPressed, isCmndCtrlPressed, this.activePipeline.id); + selectObject(d3EventType, d, objectType, range = false, augment = false) { + this.canvasController.selectObject(d.id, range, augment, this.activePipeline.id); // Even though the single click message below should be emitted // from common canvas for comments, if we uncomment this line it prevents @@ -2388,11 +2500,10 @@ export default class SVGCanvasRenderer { // to be a timing issue since the same problem is not evident with the // similar code for the node group object. // TODO - Issue 2465 - Find out why this problem occurs. - const objectTypeName = this.activePipeline.getObjectTypeName(d); - if (objectTypeName === "node" || objectTypeName === "link") { + if (objectType === "node" || objectType === "link") { this.canvasController.clickActionHandler({ clickType: d3EventType === "contextmenu" || this.ellipsisClicked ? "SINGLE_CLICK_CONTEXTMENU" : "SINGLE_CLICK", - objectType: objectTypeName, + objectType: objectType, id: d.id, selectedObjectIds: this.activePipeline.getSelectedObjectIds(), pipelineId: this.activePipeline.id }); @@ -2874,10 +2985,12 @@ export default class SVGCanvasRenderer { return { x: d.x_pos + d.width, y: d.y_pos }; } - addContextToolbar(d3Event, d, objType, xPos, yPos) { + addContextToolbar(d3Event, d, objType, cause, xPos, yPos) { if (!this.isSizing() && !this.isDragging() && !this.svgCanvasTextArea.isEditingText() && !CanvasUtils.isSuperBindingNode(d)) { - this.canvasController.setMouseInObject(d.id); + if (cause === CAUSE_MOUSE) { + this.canvasController.setMouseInObject(d.id); + } let pos = this.getDefaultContextToolbarPos(objType, d); pos.x = xPos ? pos.x + xPos : pos.x; pos.y = yPos ? pos.y + yPos : pos.y; @@ -2893,13 +3006,6 @@ export default class SVGCanvasRenderer { } } - recreateContextToolbar(d3Event, d, objType) { - if (this.config.enableContextToolbar) { - this.removeContextToolbar(); - this.addContextToolbar(d3Event, d, objType); - } - } - addEllipsisIcon(d, nodeGrp) { const ellipsisGrp = nodeGrp .append("g") @@ -2915,6 +3021,7 @@ export default class SVGCanvasRenderer { const rect = ellipsisGrp.node().getBoundingClientRect(); const rect2 = this.canvasSVG.node().getBoundingClientRect(); const pos = { x: rect.left - rect2.left, y: rect.bottom - rect2.top }; + this.setFocusObject(d, d3Event); this.openContextMenu(d3Event, "node", d, null, pos); } }); @@ -3080,8 +3187,17 @@ export default class SVGCanvasRenderer { } + // Opens the canvas context menu or context toolbar, depending on which is enabled. + // This is called from svg-canvas-d3.js to enable a keyboard shortcut to open + // the context options in an appropriate position. + openCanvasContextOptions(evt) { + this.openContextMenu(evt, "canvas", null, null, { x: 50, y: 50 }); + } + // Opens either the context menu or the context toolbar depending on which is - // currently enabled. + // currently enabled. The pos parameter is optional. It is provided when menu + // is opened from teh keyboard and it sets both the context menu position and + // the "mouse position", if one is needed, by the action selected in the menu. openContextMenu(d3Event, type, d, port, pos) { CanvasUtils.stopPropagationAndPreventDefault(d3Event); // Stop the browser context menu appearing this.canvasController.contextMenuHandler({ @@ -3092,7 +3208,7 @@ export default class SVGCanvasRenderer { cmPos: pos ? pos : this.getMousePos(d3Event, this.canvasDiv.selectAll("svg")), // Get mouse pos relative to top most SVG area even in a subflow. - mousePos: this.getMousePosSnapToGrid(this.getTransformedMousePos(d3Event)), + mousePos: pos ? pos : this.getMousePosSnapToGrid(this.getTransformedMousePos(d3Event)), selectedObjectIds: this.canvasController.getSelectedObjectIds(), addBreadcrumbs: (d && d.type === SUPER_NODE) ? this.getSupernodeBreadcrumbs(d3Event.currentTarget) : null, port: port, @@ -3750,6 +3866,20 @@ export default class SVGCanvasRenderer { updateComments(joinedCommentGrps) { this.logger.logStartTimer("updateComments"); + // Comment Focus Outline + // This is created by the 'moveFocusTo' function when focus is moved to a + // comment. The 'd3-focus-path' element only exists for one canvas object at a time. + joinedCommentGrps + .selectChildren(".d3-focus-path") + .data((d) => ([d]), (d) => d.id) + .join( + (enter) => null // Focus outline is created when focus is moved to the comment (in moveFocusTo) + ) + .attr("x", -this.canvasLayout.commentSizingArea) + .attr("y", -this.canvasLayout.commentSizingArea) + .attr("height", (c) => c.height + (2 * this.canvasLayout.commentSizingArea)) + .attr("width", (c) => c.width + (2 * this.canvasLayout.commentSizingArea)); + // Comment Sizing Area joinedCommentGrps .selectChildren(".d3-comment-sizing") @@ -3768,18 +3898,6 @@ export default class SVGCanvasRenderer { .attr("width", (c) => c.width + (2 * this.canvasLayout.commentSizingArea)) .attr("class", "d3-comment-sizing"); - // Comment Focus Outline - joinedCommentGrps - .selectChildren(".d3-focus-path") - .data((d) => ([d]), (d) => d.id) - .join( - (enter) => null // Focus outline is created when focus is moved to the comment (in moveFocusTo) - ) - .attr("x", -this.canvasLayout.commentSizingArea) - .attr("y", -this.canvasLayout.commentSizingArea) - .attr("height", (c) => c.height + (2 * this.canvasLayout.commentSizingArea)) - .attr("width", (c) => c.width + (2 * this.canvasLayout.commentSizingArea)); - // Comment Selection Highlighting Outline joinedCommentGrps .selectChildren(".d3-comment-selection-highlight") @@ -3824,6 +3942,7 @@ export default class SVGCanvasRenderer { .attr("height", (c) => c.height) .select(".d3-comment-text-scroll") + .attr("tabindex", "-1") // Prevent tab taking focus to the scroll div .each((d, i, commentTexts) => { const commentElement = d3.select(commentTexts[i]); CanvasUtils.applyOutlineStyle(commentElement, d.formats); // Only apply outlineStyle format here @@ -3868,12 +3987,94 @@ export default class SVGCanvasRenderer { const linkInfos = this.activePipeline.getNextLinksFromComment(d); if (linkInfos.length > 0) { linkInfos.forEach((li) => (li.link.navObject = d)); - this.moveFocusTo({ obj: linkInfos[0].link, type: "link" }, d3Event); + this.setFocusObject(linkInfos[0].link, d3Event); } + } else if (KeyboardUtils.moveObjectUp(d3Event)) { + if (this.config.enableEditingActions) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.moveObject(d, NORTH)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.moveObjectDown(d3Event)) { + if (this.config.enableEditingActions) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.moveObject(d, SOUTH)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.moveObjectRight(d3Event)) { + if (this.config.enableEditingActions) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.moveObject(d, EAST)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.moveObjectLeft(d3Event)) { + if (this.config.enableEditingActions) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.moveObject(d, WEST)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.sizeObjectUp(d3Event)) { + if (this.config.enableEditingActions) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.sizeObject(d, NORTH)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.sizeObjectDown(d3Event)) { + if (this.config.enableEditingActions) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.sizeObject(d, SOUTH)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.sizeObjectRight(d3Event)) { + if (this.config.enableEditingActions) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.sizeObject(d, EAST)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.sizeObjectLeft(d3Event)) { + if (this.config.enableEditingActions) { + this.canvasController.autoSelectFocusObj(() => + this.dragObjectUtils.sizeObject(d, WEST)); + } + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.scrollTextUp(d3Event)) { + this.scrollComment(d3Event, 10); + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.scrollTextDown(d3Event)) { + this.scrollComment(d3Event, -10); + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + } else if (KeyboardUtils.selectObject(d3Event)) { - this.selectObjectD3Event(d3Event, d); // This method will check if ctrl/cmnd is pressed + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + this.selectObject(d3Event, d, "comment", false, false); + + } else if (KeyboardUtils.selectObjectAugment(d3Event)) { + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + this.selectObject(d3Event, d, "comment", false, true); + } else if (KeyboardUtils.displayContextOptions(d3Event)) { + // Don't let keypress go through to the Canvas otherwise the + // canvas context menu/toolbar will be opened. + d3Event.stopPropagation(); + + this.selectObject(d3Event, d, "comment"); + + if (this.config.enableContextToolbar) { + this.addContextToolbar(d3Event, d, "comment", CAUSE_KEYBOARD); + } else { + const pos = this.getObjectCenterPosition(d3Event.currentTarget); + this.openContextMenu(d3Event, "comment", d, null, pos); + } } } }) @@ -3885,7 +4086,7 @@ export default class SVGCanvasRenderer { this.createCommentPort(d3Event.currentTarget, d); } if (this.config.enableContextToolbar) { - this.addContextToolbar(d3Event, d, "comment"); + this.addContextToolbar(d3Event, d, "comment", CAUSE_MOUSE); } if (this.commentHasScrollableText(d3Event.currentTarget)) { this.removeCanvasZoomBehavior(); // Remove canvas zoom behavior to allow scrolling of comment @@ -3912,6 +4113,7 @@ export default class SVGCanvasRenderer { if (!this.config.enableDragWithoutSelect) { if (this.config.enableKeyboardNavigation) { this.activePipeline.setTabGroupIndexForObj(d); + this.setFocusObject(d, d3Event); } this.selectObjectD3Event(d3Event, d, "comment"); } @@ -3944,6 +4146,7 @@ export default class SVGCanvasRenderer { if (this.config.enableDragWithoutSelect) { this.selectObjectD3Event(d3Event, d, "comment"); } + this.setFocusObject(d, d3Event); this.openContextMenu(d3Event, "comment", d); }); } @@ -3965,6 +4168,15 @@ export default class SVGCanvasRenderer { return false; } + // Scrolls the scrollable
in the comment in the d3Event.currentTarget field, + // by the amount (in pixels) passed in. + scrollComment(d3Event, yAmt) { + if (this.commentHasScrollableText(d3Event.currentTarget)) { + const scrollDiv = d3Event.currentTarget.getElementsByClassName("d3-comment-text-scroll"); + scrollDiv[0].scrollBy(0, yAmt); + } + } + attachCommentSizingListeners(commentGrps) { commentGrps .on("mousedown", (d3Event, d) => { @@ -4277,86 +4489,78 @@ export default class SVGCanvasRenderer { if (KeyboardUtils.nextObjectInGroup(d3Event)) { if (d.type === NODE_LINK) { const node = this.activePipeline.getNextNodeFromDataLink(d); - if (node) { - this.moveFocusTo({ obj: node, type: "node" }, d3Event); - } + this.setFocusObject(node, d3Event); + CanvasUtils.stopPropagationAndPreventDefault(d3Event); } else if (d.type === ASSOCIATION_LINK) { const node = this.activePipeline.getNextNodeFromAssocLink(d); - if (node) { - this.moveFocusTo({ obj: node, type: "node" }, d3Event); - } + this.setFocusObject(node, d3Event); + CanvasUtils.stopPropagationAndPreventDefault(d3Event); } else if (d.type === COMMENT_LINK) { const obj = this.activePipeline.getNextObjectFromCommentLink(d); - if (obj) { - const type = CanvasUtils.isNode(obj) ? "node" : "comment"; - this.moveFocusTo({ obj, type }, d3Event); - } + this.setFocusObject(obj, d3Event); + CanvasUtils.stopPropagationAndPreventDefault(d3Event); } } else if (KeyboardUtils.previousObjectInGroup(d3Event)) { if (d.type === NODE_LINK) { const node = this.activePipeline.getPreviousNodeFromDataLink(d); - if (node) { - this.moveFocusTo({ obj: node, type: "node" }, d3Event); - } + this.setFocusObject(node, d3Event); } else if (d.type === ASSOCIATION_LINK) { const node = this.activePipeline.getPreviousNodeFromAssocLink(d); - if (node) { - this.moveFocusTo({ obj: node, type: "node" }, d3Event); - } + this.setFocusObject(node, d3Event); } else if (d.type === COMMENT_LINK) { const obj = this.activePipeline.getPreviousObjectFromCommentLink(d); - if (obj) { - const type = CanvasUtils.isNode(obj) ? "node" : "comment"; - this.moveFocusTo({ obj, type }, d3Event); - } + this.setFocusObject(obj, d3Event); } - } else if (KeyboardUtils.selectObject(d3Event)) { - this.selectObjectD3Event(d3Event, d); // This method will check if ctrl/cmnd is pressed - } else if (KeyboardUtils.nextSiblingLink(d3Event)) { const link = this.activePipeline.getNextSiblingLink(d); - this.moveFocusTo({ obj: link, type: "link" }, d3Event); + this.setFocusObject(link, d3Event); } else if (KeyboardUtils.previousSiblingLink(d3Event)) { const link = this.activePipeline.getPreviousSiblingLink(d); - this.moveFocusTo({ obj: link, type: "link" }, d3Event); - } - } - }) - .on("mousedown", (d3Event, d, index, links) => { - this.logger.log("Link Group - mouse down"); - if (this.svgCanvasTextArea.isEditingText()) { - this.svgCanvasTextArea.completeEditing(d3Event); - } - if (this.config.enableLinkSelection !== LINK_SELECTION_NONE) { - if (this.config.enableKeyboardNavigation) { - this.activePipeline.setTabGroupIndexForObj(d); + this.setFocusObject(link, d3Event); + + } else if (KeyboardUtils.moveObjectLeft(d3Event)) { + // Prevent Chrome returning to previous browser page! + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + + } else if (KeyboardUtils.selectObject(d3Event)) { + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + this.selectObject(d3Event, d, "link", false, false); + + } else if (KeyboardUtils.selectObjectAugment(d3Event)) { + CanvasUtils.stopPropagationAndPreventDefault(d3Event); + this.selectObject(d3Event, d, "link", false, true); + + + } else if (KeyboardUtils.displayContextOptions(d3Event)) { + // Don't let keypress go through to the Canvas otherwise the + // canvas contenxt menu/toolbar will be opened. + d3Event.stopPropagation(); + + if (this.config.enableLinkSelection !== "None") { + this.selectObject(d3Event, d, "link"); + } + + if (this.config.enableContextToolbar) { + this.addContextToolbar(d3Event, d, "link", CAUSE_KEYBOARD, + this.canvasLayout.linkContextToolbarPosX, + this.canvasLayout.linkContextToolbarPosY + ); + + } else { + const pos = this.getObjectCenterPosition(d3Event.currentTarget); + this.openContextMenu(d3Event, "link", d, null, pos); + } } - this.selectObjectD3Event(d3Event, d, "link"); } - d3Event.stopPropagation(); // Stop event going to canvas when enableEditingActions is false - }) - .on("mouseup", () => { - this.logger.log("Link Group - mouse up"); }) - .on("click", (d3Event, d) => { - this.logger.log("Link Group - click"); - d3Event.stopPropagation(); - }) - .on("contextmenu", (d3Event, d) => { - this.logger.log("Link Group - context menu"); - if (this.config.enableLinkSelection !== LINK_SELECTION_NONE) { - this.selectObjectD3Event(d3Event, d, "link"); - } - this.openContextMenu(d3Event, "link", d); - }) - .on("mouseenter", (d3Event, link) => { + .on("mouseenter", (d3Event, d) => { if (this.isDragging()) { return; } @@ -4366,11 +4570,12 @@ export default class SVGCanvasRenderer { if (this.config.enableLinkSelection === LINK_SELECTION_HANDLES || this.config.enableLinkSelection === LINK_SELECTION_DETACHABLE) { this.raiseLinkToTop(targetObj); + CanvasUtils.stopPropagationAndPreventDefault(d3Event); } - this.setLinkLineStyles(targetObj, link, "hover"); + this.setLinkLineStyles(targetObj, d, "hover"); if (this.config.enableContextToolbar) { - this.addContextToolbar(d3Event, link, "link", + this.addContextToolbar(d3Event, d, "link", CAUSE_MOUSE, this.canvasLayout.linkContextToolbarPosX, this.canvasLayout.linkContextToolbarPosY ); @@ -4405,6 +4610,7 @@ export default class SVGCanvasRenderer { // to avoid Decoration Textarea to be closed on mouseleave. if (!targetObj.getAttribute("data-selected") && !this.config.enableLinksOverNodes && !this.isEditingText()) { this.lowerLinkToBottom(targetObj); + CanvasUtils.stopPropagationAndPreventDefault(d3Event); } this.setLinkLineStyles(targetObj, link, "default"); this.canvasController.closeTip(); @@ -4412,6 +4618,32 @@ export default class SVGCanvasRenderer { if (this.config.enableContextToolbar) { this.removeContextToolbar(); } + }) + .on("mousedown", (d3Event, d, index, links) => { + this.logger.log("Link Group - mouse down"); + if (this.svgCanvasTextArea.isEditingText()) { + this.svgCanvasTextArea.completeEditing(d3Event); + } + if (this.config.enableKeyboardNavigation) { + this.activePipeline.setTabGroupIndexForObj(d); + this.setFocusObject(d, d3Event); + } + if (this.config.enableLinkSelection !== LINK_SELECTION_NONE) { + this.selectObjectD3Event(d3Event, d, "link"); + } + d3Event.stopPropagation(); // Stop event going to canvas when enableEditingActions is false + }) + .on("click", (d3Event, d) => { + this.logger.log("Link Group - click"); + d3Event.stopPropagation(); + }) + .on("contextmenu", (d3Event, d) => { + this.logger.log("Link Group - context menu"); + if (this.config.enableLinkSelection !== LINK_SELECTION_NONE) { + this.selectObjectD3Event(d3Event, d, "link"); + } + this.setFocusObject(d, d3Event); + this.openContextMenu(d3Event, "link", d); }); } @@ -4650,7 +4882,7 @@ export default class SVGCanvasRenderer { } // Raises the node, specified by the node ID, above other nodes and objects. - // Called by external utils. + // Called by svg-canvas-utils-external.js for use by apps using React nodes. raiseNodeToTopById(nodeId) { this.getNodeGroupSelectionById(nodeId).raise(); } @@ -4665,7 +4897,7 @@ export default class SVGCanvasRenderer { // * There are one or more selected links // * We are editing text // * The app has indicated links should be displayed over nodes - raiseNodeToTop(nodeGrp) { + raiseNodeToTop(nodeGrp, d3Event) { if (this.config.enableRaiseNodesToTopOnHover && !this.isDragging() && this.activePipeline.getSelectedLinksCount() === 0 && @@ -5567,7 +5799,7 @@ export default class SVGCanvasRenderer { focusNextTabGroup(evt) { const nextObj = this.activePipeline.getNextTabGroupStartObject(); if (nextObj) { - this.moveFocusTo(nextObj, evt); + this.setFocusObject(nextObj, evt); return true; } return false; @@ -5576,30 +5808,60 @@ export default class SVGCanvasRenderer { focusPreviousTabGroup(evt) { const previousObj = this.activePipeline.getPreviousTabGroupStartObject(); if (previousObj) { - this.moveFocusTo(previousObj, evt); + this.setFocusObject(previousObj, evt); return true; } return false; } - focusSetOutsideCanvas() { - this.activePipeline.resetTabbedStatus(); + setFocusObject(focusObj, evt) { + if (!this.config.enableKeyboardNavigation) { + return; + } + + CanvasUtils.stopPropagationAndPreventDefault(evt); + + this.canvasController.setFocusObject(focusObj); + } + + restoreFocus() { + if (!this.canvasController.isContextMenuDisplayed()) { + this.canvasController.restoreFocus(); + } + } + + focusOnTextEntryElement(evt) { + this.svgCanvasTextArea.focusOnTextEntryElement(evt); + } + + resetTabObjectIndex() { + this.activePipeline.resetTabObjectIndex(); } - moveFocusTo(target, evt) { + // Moves the visual focus onto the object provided and + // uses zoom-to-reveal to bring the focused object into the + // viewport if it is not already. + // This is a utility method called from the canvas controller. + moveFocusTo(obj) { + if (!obj || obj === CANVAS_FOCUS) { + return; + } + + const type = CanvasUtils.getObjectTypeName(obj); + this.canvasGrp.selectAll(".d3-focus-path").remove(); let objSel; - if (target.type === "node") { - objSel = this.getNodeGroupSelectionById(target.obj.id); + if (type === "node") { + objSel = this.getNodeGroupSelectionById(obj.id); objSel.insert("path", ":first-child") .attr("class", "d3-focus-path") .attr("d", (d) => this.getNodeShapePathSizing(d)); - } else if (target.type === "comment") { - objSel = this.getCommentGroupSelectionById(target.obj.id); + } else if (type === "comment") { + objSel = this.getCommentGroupSelectionById(obj.id); objSel.insert("rect", ":first-child") .attr("class", "d3-focus-path") @@ -5608,19 +5870,27 @@ export default class SVGCanvasRenderer { .attr("height", (c) => c.height + (2 * this.canvasLayout.commentSizingArea)) .attr("width", (c) => c.width + (2 * this.canvasLayout.commentSizingArea)); - } else if (target.type === "link") { - objSel = this.getLinkGroupSelectionById(target.obj.id); + } else if (type === "link") { + objSel = this.getLinkGroupSelectionById(obj.id); // TODO - Think of a way to show focus on links other than line thckness } - const zoom = this.canvasController.getZoomToReveal([target.obj.id]); - if (zoom) { - this.zoomTo(zoom); + if (obj) { + const zoom = this.canvasController.getZoomToReveal([obj.id]); + if (zoom) { + this.zoomTo(zoom); + } } - objSel.node().focus(); - if (evt) { - evt.stopPropagation(); - evt.preventDefault(); + + if (objSel) { + const element = objSel.node(); + if (element) { + element.focus(); + } + + } else { + const id = obj ? obj.id : "Unknown"; + this.logger.error(`Error applying focus to ${type} object with ID: ${id}`); } } } diff --git a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-accessibility.js b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-accessibility.js index 85f93c2e93..8e03a8b4ae 100644 --- a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-accessibility.js +++ b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-accessibility.js @@ -34,6 +34,7 @@ export default class SVGCanvasUtilsAccessibility { initialize() { this.logger.logStartTimer("initialize"); + // Create a tab objects array for accessibility. Tab objects are the // set of objects that tab key will move the focus to on the canvas. // They are either solitary comments OR detached links OR 'starting' @@ -46,10 +47,6 @@ export default class SVGCanvasUtilsAccessibility { // Keeps track of which tab object is currently active during tabbing. this.currentTabObjectIndex = -1; - // Keeps track of whether we have tabbed in or out of the canvas. It will - // be false when we have tabbed out. - this.isTabbedIn = false; - // Reset the currentTabObjectIndex variable based on the current selection // (if there is one). this.resetTabGroupIndexBasedOnSelection(); @@ -57,6 +54,10 @@ export default class SVGCanvasUtilsAccessibility { this.logger.logEndTimer("initialize"); } + resetTabObjectIndex() { + this.currentTabObjectIndex = -1; + } + // Returns an array of tab groups for the active pipeline. Each element of // the array is the starting object (node, comment or link) for the group. createTabObjectsArray() { @@ -73,6 +74,9 @@ export default class SVGCanvasUtilsAccessibility { } getEntryComments() { + if (this.ap.canvasInfo.hideComments) { + return []; + } let solitaryComments = this.ap.pipeline.comments.filter((c) => !this.commentHasLinks(c)); solitaryComments = solitaryComments.map((sc) => ({ type: "comment", obj: sc })); return solitaryComments; @@ -328,7 +332,6 @@ export default class SVGCanvasUtilsAccessibility { // passed in is a part, to be the index position of that object. setTabGroupIndexForObj(obj) { this.currentTabObjectIndex = this.tabObjects.findIndex((tg) => tg.obj.grp === obj.grp); - this.isTabbedIn = true; } nodeHasInputLinks(node) { @@ -343,41 +346,29 @@ export default class SVGCanvasUtilsAccessibility { return this.getLinksFromComment(comment).length > 0; } - resetTabbedStatus() { - this.isTabbedIn = false; - } - getNextTabGroupStartObject() { - if (!this.isTabbedIn) { - this.currentTabObjectIndex = -1; - - } else if (this.currentTabObjectIndex === this.tabObjects.length) { + if (this.currentTabObjectIndex === this.tabObjects.length) { this.currentTabObjectIndex = -1; } if (this.currentTabObjectIndex < this.tabObjects.length) { this.currentTabObjectIndex++; if (this.currentTabObjectIndex < this.tabObjects.length) { - this.isTabbedIn = true; - return this.tabObjects[this.currentTabObjectIndex]; + return this.tabObjects[this.currentTabObjectIndex].obj; } } return null; } getPreviousTabGroupStartObject() { - if (!this.isTabbedIn) { - this.currentTabObjectIndex = this.tabObjects.length; - - } else if (this.currentTabObjectIndex === -1) { + if (this.currentTabObjectIndex === -1) { this.currentTabObjectIndex = this.tabObjects.length; } if (this.currentTabObjectIndex > -1) { this.currentTabObjectIndex--; if (this.currentTabObjectIndex > -1) { - this.isTabbedIn = true; - return this.tabObjects[this.currentTabObjectIndex]; + return this.tabObjects[this.currentTabObjectIndex].obj; } } return null; @@ -485,12 +476,6 @@ export default class SVGCanvasUtilsAccessibility { const dataLinksTo = this.getLinksToNode(node, NODE_LINK); dataLinksTo.forEach((link) => { linkInfos.push({ link: link, type: "node", obj: this.ap.getNode(link.trgNodeId) }); }); - const assocLinksTo = this.getLinksToNode(node, ASSOCIATION_LINK); - assocLinksTo.forEach((link) => { linkInfos.push({ link: link, type: "node", obj: this.ap.getNode(node.id === link.srcNodeId ? link.trgNodeId : link.srcNodeId) }); }); - - const commentLinksTo = this.getLinksToNode(node, COMMENT_LINK); - commentLinksTo.forEach((link) => { linkInfos.push({ link: link, type: "comment", obj: this.ap.getComment(link.srcNodeId) }); }); - return linkInfos; } @@ -512,7 +497,6 @@ export default class SVGCanvasUtilsAccessibility { return link.trgNode; } - // Returns an array of links that go to the node passed in, of the type // specified. getLinksToNode(node, type) { diff --git a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-drag-new-link.js b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-drag-new-link.js index 0260fb706f..e36881d1b0 100644 --- a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-drag-new-link.js +++ b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-drag-new-link.js @@ -373,20 +373,23 @@ export default class SVGCanvasUtilsDragNewLink { } var trgNode = this.ren.getNodeAtMousePos(d3Event); if (trgNode !== null) { - this.completeNewLinkOnNode(d3Event, trgNode, drawingNewLinkData); + this.createNewLinkFromDragData(d3Event, trgNode, drawingNewLinkData); + } else { if (this.ren.config.enableLinkSelection === LINK_SELECTION_DETACHABLE && drawingNewLinkData.action === NODE_LINK && !this.ren.config.enableAssocLinkCreation) { this.completeNewDetachedLink(d3Event, drawingNewLinkData); + } else { this.stopDrawingNewLink(drawingNewLinkData); } } } - // Handles the completion of a new link when the end is dropped on a node. - completeNewLinkOnNode(d3Event, trgNode, drawingNewLinkData) { + // Handles the creation of a link when the end of a new link + // being drawn from a source node is dropped on a target node. + createNewLinkFromDragData(d3Event, trgNode, drawingNewLinkData) { // If we completed a connection remove the new line objects. this.removeNewLink(); @@ -395,71 +398,113 @@ export default class SVGCanvasUtilsDragNewLink { this.ren.setLinkOverNodeCancel(); } + // Create the link. + const type = drawingNewLinkData.action; + const srcObjId = drawingNewLinkData.srcObjId; + if (trgNode !== null) { - const type = drawingNewLinkData.action; if (type === NODE_LINK) { - const srcNode = this.ren.activePipeline.getNode(drawingNewLinkData.srcObjId); + const srcNode = this.ren.activePipeline.getNode(srcObjId); const srcPortId = drawingNewLinkData.srcPortId; const trgPortId = this.ren.getInputNodePortId(d3Event, trgNode); - - if (CanvasUtils.isDataConnectionAllowed(srcPortId, trgPortId, srcNode, trgNode, - this.ren.activePipeline.links, this.ren.config.enableSelfRefLinks)) { - this.ren.canvasController.editActionHandler({ - editType: "linkNodes", - editSource: "canvas", - nodes: [{ "id": drawingNewLinkData.srcObjId, "portId": drawingNewLinkData.srcPortId }], - targetNodes: [{ "id": trgNode.id, "portId": trgPortId }], - type: type, - linkType: "data", // Added for historical purposes - for WML Canvas support - pipelineId: this.ren.activePipeline.id }); - - } else if (this.ren.config.enableLinkReplaceOnNewConnection && - CanvasUtils.isDataLinkReplacementAllowed(srcPortId, trgPortId, srcNode, trgNode, - this.ren.activePipeline.links, this.ren.config.enableSelfRefLinks)) { - const linksToTrgPort = CanvasUtils.getDataLinksConnectedTo(trgPortId, trgNode, this.ren.activePipeline.links); - // We only replace a link to a maxed out cardinality port if there - // is only one link. i.e. the input port cardinality is 0:1 - if (linksToTrgPort.length === 1) { - this.ren.canvasController.editActionHandler({ - editType: "linkNodesAndReplace", - editSource: "canvas", - nodes: [{ "id": drawingNewLinkData.srcObjId, "portId": drawingNewLinkData.srcPortId }], - targetNodes: [{ "id": trgNode.id, "portId": trgPortId }], - type: type, - pipelineId: this.pipelineId, - replaceLink: linksToTrgPort[0] - }); - } - } + this.createNewNodeLink(srcNode, srcPortId, trgNode, trgPortId); } else if (type === ASSOCIATION_LINK) { - const srcNode = this.ren.activePipeline.getNode(drawingNewLinkData.srcObjId); - - if (CanvasUtils.isAssocConnectionAllowed(srcNode, trgNode, this.ren.activePipeline.links)) { - this.ren.canvasController.editActionHandler({ - editType: "linkNodes", - editSource: "canvas", - nodes: [{ "id": drawingNewLinkData.srcObjId }], - targetNodes: [{ "id": trgNode.id }], - type: type, - pipelineId: this.ren.activePipeline.id }); - } + const srcObj = this.ren.activePipeline.getNode(srcObjId); + this.createNewAssocLink(srcObj, trgNode); - } else { - if (CanvasUtils.isCommentLinkConnectionAllowed(drawingNewLinkData.srcObjId, trgNode.id, this.ren.activePipeline.links)) { - this.ren.canvasController.editActionHandler({ - editType: "linkComment", - editSource: "canvas", - nodes: [drawingNewLinkData.srcObjId], - targetNodes: [trgNode.id], - type: COMMENT_LINK, - linkType: "comment", // Added for historical purposes - for WML Canvas support - pipelineId: this.ren.activePipeline.id }); + } else if (type === COMMENT_LINK) { + const srcObj = this.ren.activePipeline.getComment(srcObjId); + this.createNewCommentLink(srcObj, trgNode); + } + } + } + + // Creates a link from the currently selected objects. This is called when + // the user presses a keyboard shortcut to create the link. For the link to be + // created, there must be exactly two selections and the first selection must + // be either a comment or a node and the second selection must be a node. + createNewLinkFromSelections() { + const selNodes = this.ren.activePipeline.getSelectedNodes(); + const selComments = this.ren.activePipeline.getSelectedComments(); + + if (selNodes.length + selComments.length === 2) { + if (selNodes.length === 1 && selComments.length === 1) { + this.createNewCommentLink(selComments[0], selNodes[0]); + + } else if (selNodes.length === 2) { + if (this.ren.config.enableAssocLinkCreation) { + this.createNewAssocLink(selNodes[0], selNodes[1]); + + } else { + const srcPortId = CanvasUtils.getDefaultOutputPortId(selNodes[0]); + const trgPortId = CanvasUtils.getDefaultInputPortId(selNodes[1]); + this.createNewNodeLink(selNodes[0], srcPortId, selNodes[1], trgPortId); + // This selects just the target object which allows the user to + // more easily create a subsequent link to the next node. + this.ren.canvasController.setSelections([selNodes[1].id]); } } } } + createNewNodeLink(srcNode, srcPortId, trgNode, trgPortId) { + if (CanvasUtils.isDataConnectionAllowed(srcPortId, trgPortId, srcNode, trgNode, + this.ren.activePipeline.links, this.ren.config.enableSelfRefLinks)) { + this.ren.canvasController.editActionHandler({ + editType: "linkNodes", + editSource: "canvas", + nodes: [{ "id": srcNode.id, "portId": srcPortId }], + targetNodes: [{ "id": trgNode.id, "portId": trgPortId }], + type: NODE_LINK, + linkType: "data", // Added for historical purposes - for WML Canvas support + pipelineId: this.ren.activePipeline.id }); + + } else if (this.ren.config.enableLinkReplaceOnNewConnection && + CanvasUtils.isDataLinkReplacementAllowed(srcPortId, trgPortId, srcNode, trgNode, + this.ren.activePipeline.links, this.ren.config.enableSelfRefLinks)) { + const linksToTrgPort = CanvasUtils.getDataLinksConnectedTo(trgPortId, trgNode, this.ren.activePipeline.links); + // We only replace a link to a maxed out cardinality port if there + // is only one link. i.e. the input port cardinality is 0:1 + if (linksToTrgPort.length === 1) { + this.ren.canvasController.editActionHandler({ + editType: "linkNodesAndReplace", + editSource: "canvas", + nodes: [{ "id": srcNode.id, "portId": srcPortId }], + targetNodes: [{ "id": trgNode.id, "portId": trgPortId }], + type: NODE_LINK, + pipelineId: this.pipelineId, + replaceLink: linksToTrgPort[0] + }); + } + } + } + + createNewAssocLink(srcNode, trgNode) { + if (CanvasUtils.isAssocConnectionAllowed(srcNode, trgNode, this.ren.activePipeline.links)) { + this.ren.canvasController.editActionHandler({ + editType: "linkNodes", + editSource: "canvas", + nodes: [{ "id": srcNode.id }], + targetNodes: [{ "id": trgNode.id }], + type: ASSOCIATION_LINK, + pipelineId: this.ren.activePipeline.id }); + } + } + + createNewCommentLink(srcObj, trgNode) { + if (CanvasUtils.isCommentLinkConnectionAllowed(srcObj.id, trgNode.id, this.ren.activePipeline.links)) { + this.ren.canvasController.editActionHandler({ + editType: "linkComment", + editSource: "canvas", + nodes: [srcObj.id], + targetNodes: [trgNode.id], + type: COMMENT_LINK, + linkType: "comment", // Added for historical purposes - for WML Canvas support + pipelineId: this.ren.activePipeline.id }); + } + } + // Handles the completion of a new link when the end is dropped away from // a node (when enableLinkSelection is set to LINK_SELECTION_DETACHABLE) // which creates a new detached link. diff --git a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-drag-objects.js b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-drag-objects.js index d6c83078b4..c3a28e2f13 100644 --- a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-drag-objects.js +++ b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-drag-objects.js @@ -21,8 +21,10 @@ import * as d3Selection from "d3-selection"; const d3 = Object.assign({}, d3Drag, d3Selection); import Logger from "../logging/canvas-logger.js"; +import KeyboardUtils from "./keyboard-utils.js"; import CanvasUtils from "./common-canvas-utils.js"; -import { SNAP_TO_GRID_AFTER, SNAP_TO_GRID_DURING, LINK_SELECTION_DETACHABLE } +import { SNAP_TO_GRID_AFTER, SNAP_TO_GRID_DURING, LINK_SELECTION_DETACHABLE, + NORTH, SOUTH, EAST, WEST } from "./constants/canvas-constants.js"; // This utility files provides a drag handler which manages drag operations to move @@ -138,6 +140,88 @@ export default class SVGCanvasUtilsDragObjects { d3.select(d3Event.currentTarget).style("cursor", "default"); } + // Moves the object passed in (and any other selected objects) by the + // x and y amounts provided. This is called when the user moves an + // object using the keyboard. + moveObject(d, dir) { + let xInc = 0; + let yInc = 0; + + ({ xInc, yInc } = this.getMoveIncrements(dir)); + + if (this.endMove) { + clearTimeout(this.endMove); + this.endMove = null; + } + + if (!this.isMoving()) { + this.startObjectsMoving(d); + } + + const x = d.x_pos + (d.width / 2); + const y = d.y_pos + (d.height / 2); + const pagePos = this.ren.convertCanvasCoordsToPageCoords(x, y); + + this.moveObjects(xInc, yInc, pagePos.x, pagePos.y); + + this.endMove = setTimeout(() => { + this.endObjectsMoving(d, "click", false, false); + }, 500); + } + + // Sizes the object passed in (either a node or comment) in the + // direction specified. This is called when the user sizes an object + // using the keyboard. + sizeObject(d, direction) { + let xInc = 0; + let yInc = 0; + let dir = direction; + + ({ xInc, yInc } = this.getMoveIncrements(dir)); + + // When direction is NORTH this will cause the bottom border of + // the object to be decrease and when the direction is WEST + // the right brorder of the object will be decreased. + if (dir === NORTH) { + dir = SOUTH; + } else if (dir === WEST) { + dir = EAST; + } + + if (this.endSize) { + clearTimeout(this.endSize); + this.endSize = null; + } + + if (!this.isSizing()) { + const objType = CanvasUtils.getObjectTypeName(d); + if (objType === "node") { + this.nodeSizing = true; + } else { + this.commentSizing = true; + } + this.initializeResizeVariables(d); + } + + if (this.nodeSizing) { + this.resizeNode(xInc, yInc, d, dir); + + } else if (this.commentSizing) { + this.resizeComment(xInc, yInc, d, dir); + } + + this.endSize = setTimeout(() => { + if (this.nodeSizing) { + this.endNodeSizing(d); + this.nodeSizing = false; + + } else if (this.commentSizing) { + this.endCommentSizing(d); + this.commentSizing = false; + } + }, 500); + } + dragStartObject(d3Event, d) { this.logger.logStartTimer("dragStartObject"); @@ -150,7 +234,7 @@ export default class SVGCanvasUtilsDragObjects { this.initializeResizeVariables(d); } else { - this.dragObjectsStart(d3Event, d); + this.startObjectsMoving(d); } this.logger.logEndTimer("dragStartObject", true); @@ -159,13 +243,13 @@ export default class SVGCanvasUtilsDragObjects { dragObject(d3Event, d) { this.logger.logStartTimer("dragObject"); if (this.commentSizing) { - this.resizeComment(d3Event, d); + this.resizeComment(d3Event.dx, d3Event.dy, d, this.commentSizingDirection); } else if (this.nodeSizing) { - this.resizeNode(d3Event, d); + this.resizeNode(d3Event.dx, d3Event.dy, d, this.nodeSizingDirection); } else { - this.dragObjectsAction(d3Event); + this.moveObjects(d3Event.dx, d3Event.dy, d3Event.sourceEvent.clientX, d3Event.sourceEvent.clientY); } this.logger.logEndTimer("dragObject", true); @@ -185,7 +269,7 @@ export default class SVGCanvasUtilsDragObjects { this.nodeSizing = false; } else { - this.dragObjectsEnd(d3Event, d); + this.endObjectsMoving(d, d3Event.type, d3Event.sourceEvent.shiftKey, KeyboardUtils.isMetaKey(d3Event.sourceEvent)); } this.logger.logEndTimer("dragEndObject", true); @@ -282,13 +366,12 @@ export default class SVGCanvasUtilsDragObjects { // array based on the position of the pointer during the resize action // then redraws the nodes and links (the link positions may move based // on the node size change). - resizeNode(d3Event, resizeObj) { + resizeNode(dx, dy, resizeObj, dir) { const oldSupernode = Object.assign({}, resizeObj); const minHeight = this.getMinHeight(resizeObj); const minWidth = this.getMinWidth(resizeObj); - const delta = this.resizeObject(d3Event, resizeObj, - this.nodeSizingDirection, minWidth, minHeight); + const delta = this.resizeObject(dx, dy, resizeObj, dir, minWidth, minHeight); if (delta && (delta.x_pos !== 0 || delta.y_pos !== 0 || delta.width !== 0 || delta.height !== 0)) { if (CanvasUtils.isExpandedSupernode(resizeObj) && @@ -296,7 +379,7 @@ export default class SVGCanvasUtilsDragObjects { const objectsInfo = CanvasUtils.moveSurroundingObjects( oldSupernode, this.ren.activePipeline.getNodesAndComments(), - this.nodeSizingDirection, + dir, resizeObj.width, resizeObj.height, true // Pass true to indicate that object positions should be updated. @@ -305,7 +388,7 @@ export default class SVGCanvasUtilsDragObjects { const linksInfo = CanvasUtils.moveSurroundingDetachedLinks( oldSupernode, this.ren.activePipeline.links, - this.nodeSizingDirection, + dir, resizeObj.width, resizeObj.height, true // Pass true to indicate that link positions should be updated. @@ -338,8 +421,8 @@ export default class SVGCanvasUtilsDragObjects { // array based on the position of the pointer during the resize action // then redraws the comment and links (the link positions may move based // on the comment size change). - resizeComment(d3Event, resizeObj) { - this.resizeObject(d3Event, resizeObj, this.commentSizingDirection, 20, 20); + resizeComment(dx, dy, resizeObj, dir) { + this.resizeObject(dx, dy, resizeObj, dir, 20, 20); this.ren.displaySingleComment(resizeObj); this.ren.displayMovedLinks(); this.ren.displayCanvasAccoutrements(); @@ -347,25 +430,25 @@ export default class SVGCanvasUtilsDragObjects { // Sets the size and position of the object in the canvasInfo // array based on the position of the pointer during the resize action. - resizeObject(d3Event, canvasObj, direction, minWidth, minHeight) { + resizeObject(dx, dy, canvasObj, direction, minWidth, minHeight) { let incrementX = 0; let incrementY = 0; let incrementWidth = 0; let incrementHeight = 0; if (direction.indexOf("e") > -1) { - incrementWidth += d3Event.dx; + incrementWidth += dx; } if (direction.indexOf("s") > -1) { - incrementHeight += d3Event.dy; + incrementHeight += dy; } if (direction.indexOf("n") > -1) { - incrementY += d3Event.dy; - incrementHeight -= d3Event.dy; + incrementY += dy; + incrementHeight -= dy; } if (direction.indexOf("w") > -1) { - incrementX += d3Event.dx; - incrementWidth -= d3Event.dx; + incrementX += dx; + incrementWidth -= dx; } let xPos = 0; @@ -532,8 +615,10 @@ export default class SVGCanvasUtilsDragObjects { return node.layout.defaultNodeWidth; } - // Starts the dragging action for canvas objects (nodes and comments). - dragObjectsStart(d3Event, d) { + // Starts the moving action for canvas objects (nodes and comments). + // Can be called when the mouse is dragging the object OR when a + // keyboard event moves the object. + startObjectsMoving(d) { // Ensure flags are false before staring a new drag. this.existingNodeInsertableIntoLink = false; this.existingNodeAttachableToDetachedLinks = false; @@ -580,10 +665,16 @@ export default class SVGCanvasUtilsDragObjects { } } - // Performs the dragging action for canvas objects (nodes and comments). - dragObjectsAction(d3Event) { - this.draggingObjectData.dragOffsetX += d3Event.dx; - this.draggingObjectData.dragOffsetY += d3Event.dy; + // Performs the moving action for canvas objects (nodes and comments). + // This occurs either as the user is moving the mouse pointer OR each time the user + // presses a key on the keyboard, within the timeout value, to move the object. + // The dx and dy parameters are the amount to move the object in the x and y directions. + // The pagePosX and pagePosY parameters are the current page coordinates of either + // the mouse in the context of a drag operation OR the current page coordinates of the + // center of the object in the context of a keyboard operation. + moveObjects(dx, dy, pagePosX, pagePosY) { + this.draggingObjectData.dragOffsetX += dx; + this.draggingObjectData.dragOffsetY += dy; // Limit the size a drag can be so, when the user is dragging objects in // an in-place subflow they do not drag them too far. @@ -591,8 +682,8 @@ export default class SVGCanvasUtilsDragObjects { if (this.ren.dispUtils.isDisplayingSubFlowInPlace() && (this.draggingObjectData.dragOffsetX > 1000 || this.draggingObjectData.dragOffsetX < -1000 || this.draggingObjectData.dragOffsetY > 1000 || this.draggingObjectData.dragOffsetY < -1000)) { - this.draggingObjectData.dragOffsetX -= d3Event.dx; - this.draggingObjectData.dragOffsetY -= d3Event.dy; + this.draggingObjectData.dragOffsetX -= dx; + this.draggingObjectData.dragOffsetY -= dy; } else { let increment = { x: 0, y: 0 }; @@ -607,8 +698,8 @@ export default class SVGCanvasUtilsDragObjects { } else { increment = { - x: d3Event.dx, - y: d3Event.dy + x: dx, + y: dy }; } @@ -644,7 +735,7 @@ export default class SVGCanvasUtilsDragObjects { } if (this.existingNodeInsertableIntoLink) { - const link = this.ren.getLinkAtMousePos(d3Event.sourceEvent.clientX, d3Event.sourceEvent.clientY); + const link = this.ren.getLinkAtMousePos(pagePosX, pagePosY); // Set highlighting when there is no link because this will turn // current highlighting off. And only switch on highlighting when we are // over a fully attached link (not a detached link) and provided the @@ -672,8 +763,12 @@ export default class SVGCanvasUtilsDragObjects { } } - // Ends the dragging action for canvas objects (nodes and comments). - dragObjectsEnd(d3Event, d) { + // Ends the moving action for canvas objects (nodes and comments). + // This happens either when the user releases the mouse button when + // dragging OR when the timeout value has expired when moving the + // object using the keyboard. + endObjectsMoving(d, eventType, range, augment) { + // Save a local reference to this.draggingObjectData so we can set it to null before // calling the canvas-controller. This means the this.draggingObjectData object will // be null when the canvas is refreshed. @@ -694,7 +789,8 @@ export default class SVGCanvasUtilsDragObjects { if (draggingObjectData.dragOffsetX === 0 && draggingObjectData.dragOffsetY === 0 && this.ren.config.enableDragWithoutSelect) { - this.ren.selectObjectSourceEvent(d3Event, d); + const objType = this.ren.activePipeline.getObjectTypeName(d); + this.ren.selectObject(eventType, d, objType, range, augment); } else { if (draggingObjectData.dragRunningX !== 0 || @@ -835,4 +931,43 @@ export default class SVGCanvasUtilsDragObjects { resizeObj.height = CanvasUtils.snapToGrid(resizeObj.height, this.ren.canvasLayout.snapToGridYPx); return resizeObj; } + + // Returns an object containing the x and y increments used to move or size + // an object in the direction provided taking into account whether snap + // to grid has been switched on or not. + getMoveIncrements(dir) { + // If no snap-to-grid we just increment by 10px. + let x = 10; + let y = 10; + + if (this.ren.config.enableSnapToGridType === SNAP_TO_GRID_DURING || + this.ren.config.enableSnapToGridType === SNAP_TO_GRID_AFTER) { + x = this.ren.canvasLayout.snapToGridXPx; + y = this.ren.canvasLayout.snapToGridYPx; + } + + let xInc = 0; + let yInc = 0; + + switch (dir) { + case NORTH: + xInc = 0; + yInc = -y; + break; + case SOUTH: + xInc = 0; + yInc = y; + break; + case EAST: + xInc = x; + yInc = 0; + break; + default: + case WEST: + xInc = -x; + yInc = 0; + break; + } + return { xInc, yInc }; + } } diff --git a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-textarea.js b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-textarea.js index 617d625cde..ca05b6ce59 100644 --- a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-textarea.js +++ b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-textarea.js @@ -30,23 +30,12 @@ import { } from "./constants/canvas-constants.js"; const BACKSPACE_KEY = 8; -const RETURN_KEY = 13; -const ESC_KEY = 27; const LEFT_ARROW_KEY = 37; const UP_ARROW_KEY = 38; const RIGHT_ARROW_KEY = 39; const DOWN_ARROW_KEY = 40; const DELETE_KEY = 46; const A_KEY = 65; -const B_KEY = 66; -const E_KEY = 69; -const I_KEY = 73; -const K_KEY = 75; -const X_KEY = 88; -const LAB_KEY = 188; // Left angle bracket < -const RAB_KEY = 190; // Right angle bracket > -const SEVEN_KEY = 55; -const EIGHT_KEY = 56; const SCROLL_PADDING_COMMENT = 2; const SCROLL_PADDING_LABEL = 12; @@ -181,26 +170,32 @@ export default class SvgCanvasTextArea { this.completeEditing(evt); } - // Applies a markdown action to the comment text being edited using - // the same commands as the toolbar. + // Returns a markdown action to the comment text being edited, based + // on keyboard input. Returns the same commands as the toolbar buttons. getMarkdownAction(d3Event) { - if (KeyboardUtils.isCmndCtrlPressed(d3Event)) { - switch (d3Event.keyCode) { - case B_KEY: return "bold"; - case I_KEY: return "italics"; - case X_KEY: return d3Event.shiftKey ? "strikethrough" : null; - case SEVEN_KEY: return d3Event.shiftKey ? "numberedList" : null; - case EIGHT_KEY: return d3Event.shiftKey ? "bulletedList" : null; - case E_KEY: return "code"; - case K_KEY: return "link"; - case LAB_KEY: return "decreaseHashes"; - case RAB_KEY: return d3Event.shiftKey ? "quote" : "increaseHashes"; - default: - } - } else if (d3Event.keyCode === RETURN_KEY) { + if (KeyboardUtils.boldCommand(d3Event)) { + return "bold"; + } else if (KeyboardUtils.italicsCommand(d3Event)) { + return "italics"; + } else if (KeyboardUtils.strikethroughCommand(d3Event)) { + return "strikethrough"; + } else if (KeyboardUtils.numberedListCommand(d3Event)) { + return "numberedList"; + } else if (KeyboardUtils.bulletedListCommand(d3Event)) { + return "bulletedList"; + } else if (KeyboardUtils.codeCommand(d3Event)) { + return "code"; + } else if (KeyboardUtils.linkCommand(d3Event)) { + return "link"; + } else if (KeyboardUtils.quoteCommand(d3Event)) { + return "quote"; + } else if (KeyboardUtils.incHashesCommand(d3Event)) { + return "increaseHashes"; + } else if (KeyboardUtils.decHashesCommand(d3Event)) { + return "decreaseHashes"; + } else if (KeyboardUtils.returnCommand(d3Event)) { return "return"; } - return null; } @@ -369,6 +364,12 @@ export default class SvgCanvasTextArea { commentEntryElement.focus(); } + focusOnTextEntryElement(evt) { + const commentEntry = this.foreignObjectComment.selectAll(".d3-comment-text-entry"); + const commentEntryElement = commentEntry.node(); + commentEntryElement.focus(); + } + // Replaces the text in the currently displayed
with the text // passed in. We use execCommand because this adds the inserted text to the // textarea's undo/redo stack whereas setting the text directly into the @@ -634,6 +635,7 @@ export default class SvgCanvasTextArea { } else { d3.select(data.parentDomObj).selectAll(".d3-comment-text") .style("display", "table-cell"); + this.canvasController.restoreFocus(); } } @@ -693,7 +695,7 @@ export default class SvgCanvasTextArea { d3Event.keyCode === RIGHT_ARROW_KEY || d3Event.keyCode === UP_ARROW_KEY || d3Event.keyCode === DOWN_ARROW_KEY || - (d3Event.keyCode === A_KEY && KeyboardUtils.isCmndCtrlPressed(d3Event)); + (d3Event.keyCode === A_KEY && KeyboardUtils.isMetaKey(d3Event)); } // Displays a
to allow text entry and editing of (regular or WYSIWYG) @@ -804,8 +806,8 @@ export default class SvgCanvasTextArea { textEntrySel .on("keydown", (d3Event) => { // If user hits return/enter - if (d3Event.keyCode === RETURN_KEY) { - if (data.allowReturnKey === "save") { + if (KeyboardUtils.returnCommand(d3Event)) { + if (data.allowReturnKey === "save" || KeyboardUtils.completeTextEntry(d3Event)) { this.textContentSaved = true; this.saveAndCloseTextArea(data, d3Event.target, d3Event); return; @@ -818,10 +820,11 @@ export default class SvgCanvasTextArea { } // If user presses ESC key revert back to original text by just // closing the text area. - if (d3Event.keyCode === ESC_KEY) { + if (KeyboardUtils.cancelTextEntry(d3Event)) { CanvasUtils.stopPropagationAndPreventDefault(d3Event); this.textAreaEscKeyPressed = true; this.closeTextArea(data); + this.canvasController.restoreFocus(); } // Prevent user entering more than any allowed maximum for characters. if (data.maxCharacters && diff --git a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-zoom.js b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-zoom.js index 07223397ef..b5bc5da064 100644 --- a/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-zoom.js +++ b/canvas_modules/common-canvas/src/common-canvas/svg-canvas-utils-zoom.js @@ -619,7 +619,7 @@ export default class SVGCanvasUtilsZoom { if (nodes.length > 0 || comments.length > 0 || links.length > 0) { const canvasDimensions = CanvasUtils.getCanvasDimensions(nodes, comments, links, 0, 0, true); - const canv = this.convertRectAdjustedForScaleWithPadding(canvasDimensions, 1, 10); + const canv = this.convertRectAdjustedForScaleWithPadding(canvasDimensions, 1, 30); const xPosInt = parseInt(xPos, 10); const yPosInt = typeof yPos === "undefined" ? xPosInt : parseInt(yPos, 10); diff --git a/canvas_modules/common-canvas/src/context-menu/common-context-menu.jsx b/canvas_modules/common-canvas/src/context-menu/common-context-menu.jsx index d6cf79453b..4866aa3b7e 100644 --- a/canvas_modules/common-canvas/src/context-menu/common-context-menu.jsx +++ b/canvas_modules/common-canvas/src/context-menu/common-context-menu.jsx @@ -1,5 +1,5 @@ /* - * Copyright 2017-2023 Elyra Authors + * Copyright 2017-2024 Elyra Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import React from "react"; import PropTypes from "prop-types"; import { ChevronRight } from "@carbon/react/icons"; import ColorPicker from "../color-picker"; +import KeyboardUtils from "../common-canvas/keyboard-utils"; // context-menu sizing const CONTEXT_MENU_WIDTH = 160; // See context-menu.scss @@ -33,23 +34,96 @@ class CommonContextMenu extends React.Component { this.state = { displaySubMenuAction: "" }; + this.menuRefs = []; + this.subMenuRefs = []; + + this.focusIndex = null; // Set to null so we know when it is not initialized. + this.subMenuFocusIndex = 0; + + this.onKeyDown = this.onKeyDown.bind(this); this.itemSelected = this.itemSelected.bind(this); this.colorClicked = this.colorClicked.bind(this); } + componentDidMount() { + this.focusIndex = this.focusIndex === null ? 0 : this.focusIndex; + this.menuRefs[this.focusIndex].current.focus(); + } + + componentDidUpdate() { + if (this.state.displaySubMenuAction) { + this.subMenuFocusIndex = 0; + if (this.state.displaySubMenuAction !== "colorBackground") { + const subMenuRefs = this.subMenuRefs[this.state.displaySubMenuAction]; + subMenuRefs[this.subMenuFocusIndex].current.focus(); + } + + } else { + this.focusIndex = this.focusIndex === null ? 0 : this.focusIndex; + this.menuRefs[this.focusIndex].current.focus(); + } + } + onContextMenu(e) { e.preventDefault(); } - itemSelected(data, selectedEvent) { - this.props.contextHandler(data); - // This stops the canvasClicked function from being fired which would - // clear any current selections. - if (selectedEvent) { - selectedEvent.stopPropagation(); + onKeyDown(evt) { + // Don't let keyboard event go through to other objects. + evt.stopPropagation(); + evt.preventDefault(); + + if (KeyboardUtils.nextContextMenuOption(evt)) { + if (this.state.displaySubMenuAction) { + const subMenuRefs = this.subMenuRefs[this.state.displaySubMenuAction]; + this.subMenuFocusIndex = this.subMenuFocusIndex === subMenuRefs.length - 1 ? 0 : this.subMenuFocusIndex + 1; + subMenuRefs[this.subMenuFocusIndex].current.focus(); + + } else { + this.focusIndex = this.focusIndex === this.menuRefs.length - 1 ? 0 : this.focusIndex + 1; + this.menuRefs[this.focusIndex].current.focus(); + } + + } else if (KeyboardUtils.previousContextMenuOption(evt)) { + if (this.state.displaySubMenuAction) { + const subMenuRefs = this.subMenuRefs[this.state.displaySubMenuAction]; + this.subMenuFocusIndex = this.subMenuFocusIndex === 0 ? subMenuRefs.length - 1 : this.subMenuFocusIndex - 1; + subMenuRefs[this.subMenuFocusIndex].current.focus(); + + } else { + this.focusIndex = this.focusIndex === 0 ? this.menuRefs.length - 1 : this.focusIndex - 1; + this.menuRefs[this.focusIndex].current.focus(); + } + + } else if (KeyboardUtils.openContextMenuSubMenu(evt)) { + const action = evt.target.dataset.action; + if (this.subMenuRefs[action]) { + this.subMenuOpen(action); + } + + } else if (KeyboardUtils.closeContextMenuSubMenu(evt) && + this.state.displaySubMenuAction) { + this.subMenuClose(); + + } else if (KeyboardUtils.closeContextMenu(evt) && + !this.state.displaySubMenuAction) { + this.props.closeContextMenu(); + + } else if (KeyboardUtils.activateContextMenuOption(evt)) { + const action = evt.target.dataset.action; + if (this.subMenuRefs[action]) { + this.subMenuOpen(action); + + } else { + this.itemSelected(action); + } } } + itemSelected(data) { + this.props.contextHandler(data); + } + colorClicked(color) { this.props.contextHandler("colorSelectedObjects", { color }); } @@ -121,6 +195,7 @@ class CommonContextMenu extends React.Component { // Builds a new menu based on the menu defintion passed in. buildMenu(menuDefinition, menuSize, menuPos, canvasRect) { const menuItems = []; + const menuRefs = []; let runningYPos = 0; // Records if we have just displayed a divider. This is useful because we @@ -134,7 +209,7 @@ class CommonContextMenu extends React.Component { if (divider) { if (!previousDivider) { - menuItems.push(
); + menuItems.push(
); runningYPos += CONTEXT_MENU_DIVIDER_HEIGHT; previousDivider = true; } @@ -142,10 +217,12 @@ class CommonContextMenu extends React.Component { previousDivider = false; const disabled = false; const subMenuSize = { width: CONTEXT_MENU_WIDTH, height: 50 }; - const subMenuContent = this.buildColorPickerPanel(); + const subMenuInfo = this.buildColorPickerPanel(); + const subMenuContent = subMenuInfo.menuItems; + this.subMenuRefs[menuDefinition[i].action] = subMenuInfo.menuRefs; const subMenu = this.buildSubMenu( - menuDefinition, i, subMenuContent, runningYPos, menuPos, menuSize, subMenuSize, canvasRect, disabled); + menuDefinition, i, menuRefs, subMenuContent, runningYPos, menuPos, menuSize, subMenuSize, canvasRect, disabled); menuItems.push(subMenu); runningYPos += CONTEXT_MENU_LINK_HEIGHT; @@ -154,10 +231,12 @@ class CommonContextMenu extends React.Component { previousDivider = false; const disabled = this.areAllSubmenuItemsDisabled(menuDefinition[i].menu); const subMenuSize = this.calculateMenuSize(menuDefinition[i].menu); - const subMenuContent = this.buildMenu(menuDefinition[i].menu, menuSize, menuPos, canvasRect); + const subMenuInfo = this.buildMenu(menuDefinition[i].menu, menuSize, menuPos, canvasRect, 100); + const subMenuContent = subMenuInfo.menuItems; + this.subMenuRefs[menuDefinition[i].action] = subMenuInfo.menuRefs; const subMenu = this.buildSubMenu( - menuDefinition, i, subMenuContent, runningYPos, menuPos, menuSize, subMenuSize, canvasRect, disabled); + menuDefinition, i, menuRefs, subMenuContent, runningYPos, menuPos, menuSize, subMenuSize, canvasRect, disabled); menuItems.push(subMenu); runningYPos += CONTEXT_MENU_LINK_HEIGHT; @@ -171,36 +250,65 @@ class CommonContextMenu extends React.Component { ? null : this.itemSelected.bind(null, menuDefinition[i].action); - menuItems.push(( -
- {menuDefinition[i].label} -
- )); + let menuItem; + + if (menuDefinition[i].enable === false) { + menuItem = ( +
+ {menuDefinition[i].label} +
+ ); + } else { + const ref = React.createRef(); + menuRefs.push(ref); + + menuItem = ( +
+ {menuDefinition[i].label} +
+ ); + } + menuItems.push(menuItem); runningYPos += CONTEXT_MENU_LINK_HEIGHT; } } - return menuItems; + return { menuItems, menuRefs }; } buildColorPickerPanel() { const subPanelData = { - clickActionHandler: (c) => this.colorClicked(c) + clickActionHandler: (c) => this.colorClicked(c), + closeSubPanel: () => this.subMenuClose() }; - return ( - null} /> + // Only create the color picker when we are actually displaying it in the sub-menu. + // That way the color picker will set focus on itself when it is opened. + const colorPicker = this.state.displaySubMenuAction === "colorBackground" + ? this.subMenuClose()} /> + : null; + + const ref = React.createRef(); + + const content = ( +
+ {colorPicker} +
); + + return { menuItems: [content], menuRefs: [ref] }; } - subMenuMouseEnter(action) { + subMenuOpen(action) { this.setState({ displaySubMenuAction: action }); } - subMenuMouseLeave(action) { + subMenuClose(action) { this.setState({ displaySubMenuAction: "" }); } // Builds a sub-menu for the menuitem identified by the index into the menudefintion. - buildSubMenu(menuDefinition, index, subMenuContent, runningYPos, menuPos, + buildSubMenu(menuDefinition, index, menuRefs, subMenuContent, runningYPos, menuPos, menuSize, subMenuSize, canvasRect, disabled) { const rtl = this.buildRtlState(menuPos, menuSize, subMenuSize, canvasRect); const subMenuPosStyle = this.buildSubMenuPosStyle(runningYPos, menuPos, subMenuSize, canvasRect, rtl); @@ -211,13 +319,17 @@ class CommonContextMenu extends React.Component { const menuItemClass = "context-menu-item " + (disabled ? " disabled" : ""); const subMenuClass = "context-menu-popover context-menu-submenu" + (this.state.displaySubMenuAction === menuItem.action ? " context-menu--visible" : ""); - const onMouseEnter = (disabled ? null : this.subMenuMouseEnter.bind(this, menuItem.action)); - const onMouseLeave = (disabled ? null : this.subMenuMouseLeave.bind(this)); + const onMouseEnter = (disabled ? null : this.subMenuOpen.bind(this, menuItem.action)); + const onMouseLeave = (disabled ? null : this.subMenuClose.bind(this)); + + const ref = React.createRef(); + menuRefs.push(ref); return ( -
{menuItemContent}
@@ -263,7 +375,7 @@ class CommonContextMenu extends React.Component { // Returns the menu definition array passed in making sure any // submenu items have an action. Note: some applications forget - // to do provide an action because for the submenu it is only + // to provide an action because, for the submenu, it is only // used by the context menu code. ensureAllSubMenuItemsHaveAction(menuDef) { return menuDef.map((item, index) => { @@ -283,12 +395,14 @@ class CommonContextMenu extends React.Component { top: menuPos.y + "px" }; + this.menuRefs = []; const menuDefinition = this.ensureAllSubMenuItemsHaveAction(this.props.menuDefinition); - const menuItems = this.buildMenu(menuDefinition, menuSize, menuPos, this.props.canvasRect); + const menuInfo = this.buildMenu(menuDefinition, menuSize, menuPos, this.props.canvasRect); + this.menuRefs = menuInfo.menuRefs; return (
- {menuItems} + {menuInfo.menuItems}
); } @@ -296,6 +410,7 @@ class CommonContextMenu extends React.Component { CommonContextMenu.propTypes = { contextHandler: PropTypes.func.isRequired, + closeContextMenu: PropTypes.func.isRequired, menuDefinition: PropTypes.array.isRequired, canvasRect: PropTypes.object.isRequired, mousePos: PropTypes.object.isRequired diff --git a/canvas_modules/common-canvas/src/context-menu/context-menu-wrapper.jsx b/canvas_modules/common-canvas/src/context-menu/context-menu-wrapper.jsx index df5156dac1..94c351b020 100644 --- a/canvas_modules/common-canvas/src/context-menu/context-menu-wrapper.jsx +++ b/canvas_modules/common-canvas/src/context-menu/context-menu-wrapper.jsx @@ -111,6 +111,7 @@ export default class ContextMenuWrapper extends React.Component { menuDefinition={this.props.contextMenuDef} canvasRect={this.getCanvasRect()} mousePos={this.props.contextMenuPos} + closeContextMenu={this.props.closeContextMenu} /> ); } diff --git a/canvas_modules/common-canvas/src/context-menu/context-menu.scss b/canvas_modules/common-canvas/src/context-menu/context-menu.scss index 9afae27762..8a588562e8 100644 --- a/canvas_modules/common-canvas/src/context-menu/context-menu.scss +++ b/canvas_modules/common-canvas/src/context-menu/context-menu.scss @@ -50,7 +50,8 @@ text-align: inherit; white-space: nowrap; background: 0 0; - border: 0; + border: 2px solid transparent; + outline: none; /* Suppress Chrome's blue active border */ display: flex; align-items: center; position: relative; // Needed to allow sub-menu, if there is one, to be positioned correctly @@ -61,12 +62,13 @@ } &:active, - &:focus, &:hover { background: $layer-hover-01; - text-decoration: none; - border: 0; - outline: none; /* Suppress Chrome's blue active border */ + } + + &:focus { + background: $layer-hover-01; + border: 2px solid $focus; } /* Style for the chevron '>' in menu item that opens a submenu. */ diff --git a/canvas_modules/common-canvas/src/notification-panel/notification-panel.jsx b/canvas_modules/common-canvas/src/notification-panel/notification-panel.jsx index 07b79adfff..00bd768458 100644 --- a/canvas_modules/common-canvas/src/notification-panel/notification-panel.jsx +++ b/canvas_modules/common-canvas/src/notification-panel/notification-panel.jsx @@ -22,13 +22,10 @@ import Icon from "./../icons/icon.jsx"; import { Button } from "@carbon/react"; import { Close } from "@carbon/react/icons"; import Logger from "../logging/canvas-logger.js"; +import KeyboardUtils from "../common-canvas/keyboard-utils.js"; import { DEFAULT_NOTIFICATION_HEADER } from "./../common-canvas/constants/canvas-constants.js"; import defaultMessages from "../../locales/notification-panel/locales/en.json"; -const TAB_KEY = 9; -const RETURN_KEY = 13; -const SPACE_KEY = 32; - class NotificationPanel extends React.Component { static getDerivedStateFromProps(nextProps, prevState) { if (nextProps.messages !== prevState.messages) { @@ -138,7 +135,7 @@ class NotificationPanel extends React.Component { } keyDownOnCloseButton(message, evt) { - if (evt.keyCode === SPACE_KEY || evt.keyCode === RETURN_KEY) { + if (KeyboardUtils.activateButton(evt)) { this.deleteNotification(message.id); } } @@ -157,14 +154,14 @@ class NotificationPanel extends React.Component { } keyDownOnPanel(evt) { - if (evt.keyCode === TAB_KEY && !evt.shiftKey) { + if (KeyboardUtils.nextSection(evt)) { const lastElement = this.allRefs[this.allRefs.length - 1]; if (evt.target === lastElement) { evt.stopPropagation(); evt.preventDefault(); this.allRefs[0].focus(); } - } else if (evt.keyCode === TAB_KEY && evt.shiftKey) { + } else if (KeyboardUtils.previousSection(evt)) { const lastElement = this.allRefs[this.allRefs.length - 1]; if (evt.target === this.allRefs[0]) { evt.stopPropagation(); diff --git a/canvas_modules/common-canvas/src/object-model/object-model.js b/canvas_modules/common-canvas/src/object-model/object-model.js index ce74c3d092..cf6d6e15bf 100644 --- a/canvas_modules/common-canvas/src/object-model/object-model.js +++ b/canvas_modules/common-canvas/src/object-model/object-model.js @@ -33,7 +33,7 @@ import { upgradePipelineFlow, extractVersion, LATEST_VERSION } from "@elyra/pipe import { upgradePalette, extractPaletteVersion, LATEST_PALETTE_VERSION } from "./schemas-utils/upgrade-palette.js"; -import { ASSOCIATION_LINK, COMMENT_LINK, NODE_LINK, ERROR, WARNING, SUCCESS, INFO, CREATE_PIPELINE, +import { ASSOCIATION_LINK, NODE_LINK, ERROR, WARNING, SUCCESS, INFO, CREATE_PIPELINE, CLONE_COMMENT, CLONE_COMMENT_LINK, CLONE_NODE, CLONE_NODE_LINK, CLONE_PIPELINE, SUPER_NODE, HIGHLIGHT_BRANCH, HIGHLIGHT_UPSTREAM, HIGHLIGHT_DOWNSTREAM, SAVE_ZOOM_LOCAL_STORAGE, SAVE_ZOOM_PIPELINE_FLOW @@ -1488,17 +1488,18 @@ export default class ObjectModel { } // Simulates the selection of an object (identified by objId) in the - // pipeline identified by pipelineId with the augmentation keys pressed - // as indicated by isShiftKeyPressed and isCmndCtrlPressed. - selectObject(objId, isShiftKeyPressed, isCmndCtrlPressed, pipelineId) { + // pipeline identified by pipelineId with the range and augment + // parameters that indicate whether the user has requested a range + // (shift key pressed) or an augmented selected (meta key pressed). + selectObject(objId, range, augment, pipelineId) { if (!this.isSelected(objId, pipelineId)) { - if (isShiftKeyPressed) { + if (range) { this.selectSubGraph(objId, pipelineId); } else { - this.toggleSelection(objId, isCmndCtrlPressed, pipelineId); + this.toggleSelection(objId, augment, pipelineId); } - } else if (isCmndCtrlPressed) { - this.toggleSelection(objId, isCmndCtrlPressed, pipelineId); + } else if (augment) { + this.toggleSelection(objId, augment, pipelineId); } } @@ -1556,6 +1557,11 @@ export default class ObjectModel { this.setSelections(selected, apiPipeline.pipelineId); } + deselectAll(pipelineId) { + const apiPipeline = this.getAPIPipeline(pipelineId); + this.setSelections([], apiPipeline.pipelineId); + } + findNodesInSubGraph(startNodeId, endNodeId, selection, pipelineId) { const pipeline = this.getAPIPipeline(pipelineId); let retval = false; @@ -1626,6 +1632,10 @@ export default class ObjectModel { return connectedNodesIdsGroup.length === nodeIds.length; } + areAllObjectsSelected(includeLinks) { + return this.store.areAllObjectsSelected(includeLinks); + } + // Returns true if all the selected objects are links. areAllSelectedObjectsLinks() { return this.store.areAllSelectedObjectsLinks(); @@ -1775,11 +1785,6 @@ export default class ObjectModel { return maxMessageType; } - // Returns true if the object passed in is a link. - isLink(obj) { - return obj.type === NODE_LINK || obj.type === COMMENT_LINK || obj.type === ASSOCIATION_LINK; - } - setZoom(zoom, pipelineId) { const enableSaveZoom = this.getCanvasConfig().enableSaveZoom; diff --git a/canvas_modules/common-canvas/src/object-model/redux/canvas-store.js b/canvas_modules/common-canvas/src/object-model/redux/canvas-store.js index e2196b643c..24e843eaa7 100644 --- a/canvas_modules/common-canvas/src/object-model/redux/canvas-store.js +++ b/canvas_modules/common-canvas/src/object-model/redux/canvas-store.js @@ -148,6 +148,21 @@ export default class CanavasStore { return false; } + getCountNodes(pipelineId) { + const pipeline = this.getNonClonedPipeline(pipelineId); + return pipeline?.nodes?.length || 0; + } + + getCountComments(pipelineId) { + const pipeline = this.getNonClonedPipeline(pipelineId); + return pipeline?.comments?.length || 0; + } + + getCountLinks(pipelineId) { + const pipeline = this.getNonClonedPipeline(pipelineId); + return pipeline?.links?.length || 0; + } + // This is a service method for retrieving the internal pipeline. It does NOT // clone the pipeline therefore it should NOT be called from outside this // class because we don't want to surface the intenal data in redux to @@ -305,6 +320,10 @@ export default class CanavasStore { return this.getSelectionInfo().pipelineId; } + getCountSelectedObjects() { + return this.store.getState().selectioninfo?.selections?.length || 0; + } + getSelectedObjectIds() { const selectedObjIds = this.store.getState().selectioninfo.selections || []; return this.cloneData(selectedObjIds); @@ -322,6 +341,16 @@ export default class CanavasStore { return cloneDeep(this.getNonClonedSelectedObjs("links")); } + areAllObjectsSelected(includeLinks) { + const pId = this.getSelectedPipelineId(); + let countObjs = this.getCountNodes(pId) + this.getCountComments(pId); + if (includeLinks) { + countObjs += this.getCountLinks(pId); + } + const selObjs = this.getCountSelectedObjects(); + return selObjs > 0 && countObjs === selObjs; // Only return true if at ast one object is selected. + } + // Returns true if all the selected objects are links. That is, if the // number of selected links is the same as the number of selected objects. areAllSelectedObjectsLinks() { diff --git a/canvas_modules/common-canvas/src/palette/palette-content-list-item-btn.jsx b/canvas_modules/common-canvas/src/palette/palette-content-list-item-btn.jsx new file mode 100644 index 0000000000..2daf755b3a --- /dev/null +++ b/canvas_modules/common-canvas/src/palette/palette-content-list-item-btn.jsx @@ -0,0 +1,41 @@ +/* + * Copyright 2024 Elyra Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from "react"; +import PropTypes from "prop-types"; +import { injectIntl } from "react-intl"; +import defaultMessages from "../../locales/palette/locales/en.json"; + +class PaletteContentListItemBtn extends React.Component { + + render() { + const less = + this.props.intl.formatMessage({ id: this.props.id, defaultMessage: defaultMessages[this.props.id] }); + return ( +
+ {less} +
+ ); + } +} + +PaletteContentListItemBtn.propTypes = { + intl: PropTypes.object.isRequired, + id: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired +}; + +export default injectIntl(PaletteContentListItemBtn); diff --git a/canvas_modules/common-canvas/src/palette/palette-content-list-item.jsx b/canvas_modules/common-canvas/src/palette/palette-content-list-item.jsx index b0587f5505..7c629c0928 100644 --- a/canvas_modules/common-canvas/src/palette/palette-content-list-item.jsx +++ b/canvas_modules/common-canvas/src/palette/palette-content-list-item.jsx @@ -1,5 +1,5 @@ /* - * Copyright 2017-2023 Elyra Authors + * Copyright 2017-2024 Elyra Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,8 +17,8 @@ import React from "react"; import PropTypes from "prop-types"; import { get, has } from "lodash"; -import { injectIntl } from "react-intl"; -import defaultMessages from "../../locales/palette/locales/en.json"; +import KeyboardUtils from "../common-canvas/keyboard-utils.js"; +import PaletteContentListItemBtn from "./palette-content-list-item-btn.jsx"; import Icon from "../icons/icon.jsx"; import SVG from "react-inlinesvg"; import { CANVAS_CARBON_ICONS, DND_DATA_TEXT, TIP_TYPE_PALETTE_ITEM, @@ -74,10 +74,18 @@ class PaletteContentListItem extends React.Component { } } - onKeyDown(e) { - // e.key === " " is needed to allow Cypress test in palette.js to run on the build machine! - if (e.key === " " || e.code === "Space" || e.keyCode === 32) { - this.onDoubleClick(); + onKeyDown(evt) { + if (KeyboardUtils.createAutoNode(evt)) { + this.createAutoNode(true); + + } else if (KeyboardUtils.createAutoNodeNoLink(evt)) { + this.createAutoNode(false); // false indicates no links are required + + } else if (KeyboardUtils.nextNodeInCategory(evt) && this.props.nextNodeInCategory) { + this.props.nextNodeInCategory(evt); + + } else if (KeyboardUtils.previousNodeInCategory(evt) && this.props.previousNodeInCategory) { + this.props.previousNodeInCategory(evt); } } @@ -87,10 +95,7 @@ class PaletteContentListItem extends React.Component { } onDoubleClick() { - if (this.props.canvasController.createAutoNode && !this.isItemDisabled()) { - const nodeTemplate = this.props.canvasController.convertNodeTemplate(this.props.nodeTypeInfo.nodeType); - this.props.canvasController.createAutoNode(nodeTemplate); - } + this.createAutoNode(true); } onMouseOver(ev) { @@ -146,13 +151,14 @@ class PaletteContentListItem extends React.Component { // 'Show less' button depending on whether the full description is shown or not. if (isLongDescription) { if (this.state.showFullDescription) { - const less = - this.props.intl.formatMessage({ id: "palette.flyout.search.less", defaultMessage: defaultMessages["palette.flyout.search.less"] }); - elements.push(
{less}
); + elements.push( + + ); + } else { - const more = - this.props.intl.formatMessage({ id: "palette.flyout.search.more", defaultMessage: defaultMessages["palette.flyout.search.more"] }); - elements.push(
{more}
); + elements.push( + + ); } } return elements; @@ -256,6 +262,10 @@ class PaletteContentListItem extends React.Component { this.setState({ showFullDescription: false }); } + focus() { + this.itemRef.current.focus(); + } + // Returns true if this item is disabled and should not be draggable or double-clicked // from the palette. isItemDisabled() { @@ -263,6 +273,17 @@ class PaletteContentListItem extends React.Component { return !this.props.isEditingEnabled || disabled; } + // Converts the palette node from its pipeline flow format to the internal API + // format and calls canvas controller to automatically add the node to the + // canvas. If addLink is true a link will be added between to the new node + // from the adjacent node. + createAutoNode(addLink) { + if (this.props.canvasController.createAutoNode && !this.isItemDisabled()) { + const nodeTemplate = this.props.canvasController.convertNodeTemplate(this.props.nodeTypeInfo.nodeType); + this.props.canvasController.createAutoNode(nodeTemplate, addLink); + } + } + render() { let itemText = null; let labelText = get(this.props, "nodeTypeInfo.nodeType.app_data.ui_data.label", ""); @@ -322,13 +343,16 @@ class PaletteContentListItem extends React.Component { : null; const nodeLabel = itemText - ?
{itemText}
+ ?
{itemText}
: null; + this.itemRef = React.createRef(); + return (
{categoryLabel} -
+
{icon} {nodeLabel} {ranking} @@ -354,13 +378,15 @@ class PaletteContentListItem extends React.Component { } PaletteContentListItem.propTypes = { - intl: PropTypes.object.isRequired, nodeTypeInfo: PropTypes.object.isRequired, isDisplaySearchResult: PropTypes.bool.isRequired, canvasController: PropTypes.object.isRequired, + tabIndex: PropTypes.number.isRequired, + nextNodeInCategory: PropTypes.func, + previousNodeInCategory: PropTypes.func, isEditingEnabled: PropTypes.bool.isRequired, isPaletteWide: PropTypes.bool, isShowRanking: PropTypes.bool }; -export default injectIntl(PaletteContentListItem); +export default PaletteContentListItem; diff --git a/canvas_modules/common-canvas/src/palette/palette-content-list.jsx b/canvas_modules/common-canvas/src/palette/palette-content-list.jsx index 1ef72d9a38..1a93d63c4d 100644 --- a/canvas_modules/common-canvas/src/palette/palette-content-list.jsx +++ b/canvas_modules/common-canvas/src/palette/palette-content-list.jsx @@ -17,23 +17,54 @@ import React from "react"; import PropTypes from "prop-types"; import PaletteContentListItem from "./palette-content-list-item.jsx"; +import CanvasUtils from "../common-canvas/common-canvas-utils.js"; class PaletteContentList extends React.Component { constructor(props) { super(props); - this.state = { - }; + this.currentFocusIndex = 0; + this.contentItemRefs = []; + + this.nextNodeInCategory = this.nextNodeInCategory.bind(this); + this.previousNodeInCategory = this.previousNodeInCategory.bind(this); + } + + // Sets focus on the fist ndoe in the list. This is called using a ref + // from the parent category. + setFirstNode() { + this.currentFocusIndex = 0; + this.contentItemRefs[this.currentFocusIndex].current.focus(); + } + + nextNodeInCategory(evt) { + this.currentFocusIndex++; + if (this.currentFocusIndex > this.contentItemRefs.length - 1) { + this.currentFocusIndex = 0; + } + this.contentItemRefs[this.currentFocusIndex].current.focus(); + CanvasUtils.stopPropagationAndPreventDefault(evt); + } + + previousNodeInCategory(evt) { + this.currentFocusIndex--; + if (this.currentFocusIndex < 0) { + this.currentFocusIndex = this.contentItemRefs.length - 1; + } + this.contentItemRefs[this.currentFocusIndex].current.focus(); + CanvasUtils.stopPropagationAndPreventDefault(evt); } render() { - var contentItems = []; + const contentItems = []; + this.contentItemRefs = []; if (this.props.category && this.props.category.node_types && this.props.category.node_types.length === 0 && this.props.category.empty_text) { contentItems.push(
); } diff --git a/canvas_modules/common-canvas/src/palette/palette-flyout-content-category.jsx b/canvas_modules/common-canvas/src/palette/palette-flyout-content-category.jsx index ef9ac5fbd9..9896ff87bf 100644 --- a/canvas_modules/common-canvas/src/palette/palette-flyout-content-category.jsx +++ b/canvas_modules/common-canvas/src/palette/palette-flyout-content-category.jsx @@ -16,11 +16,12 @@ import React from "react"; import PropTypes from "prop-types"; -import { InlineLoading } from "@carbon/react"; +import { AccordionItem, InlineLoading } from "@carbon/react"; +import { get } from "lodash"; import SVG from "react-inlinesvg"; import { TIP_TYPE_PALETTE_CATEGORY } from "../common-canvas/constants/canvas-constants.js"; -import { get } from "lodash"; -import { AccordionItem } from "@carbon/react"; +import CanvasUtils from "../common-canvas/common-canvas-utils.js"; +import KeyboardUtils from "../common-canvas/keyboard-utils.js"; import PaletteContentList from "./palette-content-list.jsx"; @@ -192,8 +193,10 @@ class PaletteFlyoutContentCategory extends React.Component { getContent() { if (this.props.category.is_open) { const nodeTypeInfos = this.props.category.node_types.map((nt) => ({ nodeType: nt, category: this.props.category })); + this.pclRef = React.createRef(); return ( this.getRefAction(item) === this.state.focusAction); - if (index === -1) { + if (index === -1 || (!this.isFocusInToolbar && this.props.setInititalFocus)) { + this.isFocusInToolbar = true; this.setFocusOnFirstItem(); } } @@ -121,13 +119,18 @@ class Toolbar extends React.Component { // toolbar items. We set the focusAction appropriately based on which // key is pressed. onKeyDown(evt) { - if (evt.keyCode === ESC_KEY) { - this.setFocusOnItem(); // Reset focus on current focusAction. + if (KeyboardUtils.closeSubArea(evt)) { + if (this.props.closeToolbarOnEsc) { + this.props.closeToolbar(); + + } else { + this.setFocusOnItem(); // Reset focus on current focusAction. + } - } else if (evt.keyCode === LEFT_ARROW_KEY) { + } else if (KeyboardUtils.setFocusOnPreviousToolbarBtn(evt)) { this.setFocusOnPreviousItem(); - } else if (evt.keyCode === RIGHT_ARROW_KEY) { + } else if (KeyboardUtils.setFocusOnNextToolbarBtn(evt)) { this.setFocusOnNextItem(); } } @@ -266,13 +269,13 @@ class Toolbar extends React.Component { return focusableItemRefs; } - const topRowY = this.findToolbarTopYCoordinate(); + const firstItemRect = this.leftItemRefs[0].current?.getBoundingRect(); let overflowItemRef = null; for (let i = 0; i < this.leftItemRefs.length; i++) { const itemRect = this.leftItemRefs[i].current?.getBoundingRect(); - if (itemRect?.top === topRowY) { + if (itemRect?.top === firstItemRect?.top) { if (this.leftItemRefs[i].current?.isEnabled()) { focusableItemRefs.push(this.leftItemRefs[i]); } @@ -296,17 +299,17 @@ class Toolbar extends React.Component { getRightBarFocusableItemRefs() { const focusableItemRefs = []; - if (this.rightItemRefs === 0) { + if (this.rightItemRefs.length === 0) { return focusableItemRefs; } - const topRowY = this.findToolbarTopYCoordinate(); + const firstItemRect = this.rightItemRefs[0].current?.getBoundingRect(); for (let i = 0; i < this.rightItemRefs.length; i++) { if (this.rightItemRefs[i].current?.isEnabled()) { const refRect = this.rightItemRefs[i].current?.getBoundingRect(); - if (refRect.top === topRowY) { + if (refRect?.top === firstItemRect?.top) { focusableItemRefs.push(this.rightItemRefs[i]); } } @@ -362,27 +365,21 @@ class Toolbar extends React.Component { // Returns a reference to the first item that is not on the // top (visible) row of the toolbar. findFirstRightItemRefNotOnTopRow() { - const topRowY = this.findToolbarTopYCoordinate(); - let rightItemRef = null; - for (let i = 0; i < this.rightItemRefs.length; i++) { - const itemRect = this.rightItemRefs[i].current?.getBoundingRect(); - if (itemRect.top !== topRowY && rightItemRef === null) { - rightItemRef = this.rightItemRefs[i]; + if (this.rightItemRefs.length > 0) { + const firstItemRect = this.rightItemRefs[0].current?.getBoundingRect(); + + for (let i = 0; i < this.rightItemRefs.length; i++) { + const itemRect = this.rightItemRefs[i].current?.getBoundingRect(); + if (itemRect?.top !== firstItemRect?.top && rightItemRef === null) { + rightItemRef = this.rightItemRefs[i]; + } } } return rightItemRef; } - // Returns the Y coordinate of the top of the toolbar. This is - // used to detecg which toolbar items are on the top (visible) - // row and which are wrapped onto other rows. - findToolbarTopYCoordinate() { - const rect = this.toolbarRef.current?.getBoundingClientRect(); - return rect?.top; - } - // Generates an array of toolbar items from the toolbarActions array passed in. When // withOverflowItem is true, which it is for the left bar, we also add an overflow item, // inside an overflow item container, for each left toolbar action. As the canvas is made @@ -550,6 +547,9 @@ Toolbar.propTypes = { toolbarActionHandler: PropTypes.func, tooltipDirection: PropTypes.string, additionalText: PropTypes.object, + setInititalFocus: PropTypes.bool, + closeToolbarOnEsc: PropTypes.bool, + closeToolbar: PropTypes.func, size: PropTypes.oneOf(["md", "sm"]) }; diff --git a/canvas_modules/harness/cypress/e2e/canvas/selection-region.cy.js b/canvas_modules/harness/cypress/e2e/canvas/selection-region.cy.js index f792164101..5e5eeb0519 100644 --- a/canvas_modules/harness/cypress/e2e/canvas/selection-region.cy.js +++ b/canvas_modules/harness/cypress/e2e/canvas/selection-region.cy.js @@ -1,5 +1,5 @@ /* - * Copyright 2017-2023 Elyra Authors + * Copyright 2017-2024 Elyra Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,13 +22,13 @@ describe("Test using region select to select canvas objects", function() { it("Test region selects a node when fully covered by the region", function() { cy.verifyNumberOfSelectedObjects(0); - cy.selectWithRegion(80, 95, 200, 200); + cy.selectWithRegion(80, 96, 200, 200); cy.verifyNodeIsSelected("Binding (entry) node"); }); it("Test region selects a node when partially covered by the region", function() { cy.verifyNumberOfSelectedObjects(0); - cy.selectWithRegion(80, 95, 120, 120); + cy.selectWithRegion(80, 96, 120, 120); cy.verifyNodeIsSelected("Binding (entry) node"); }); diff --git a/canvas_modules/harness/cypress/e2e/canvas/undo-redo.cy.js b/canvas_modules/harness/cypress/e2e/canvas/undo-redo.cy.js index 29e8ee41a5..5ac8c356bd 100644 --- a/canvas_modules/harness/cypress/e2e/canvas/undo-redo.cy.js +++ b/canvas_modules/harness/cypress/e2e/canvas/undo-redo.cy.js @@ -284,6 +284,10 @@ describe("Test select all canvas objects undo/redo operations", function() { cy.verifyNumberOfPortDataLinks(5); cy.verifyNumberOfCommentLinks(3); + // Deselect all objects + cy.rightClickToDisplayContextMenu(300, 10); + cy.clickOptionFromContextMenu("Deselect all"); + // Select all nodes and comments using context menu and delete a node cy.rightClickToDisplayContextMenu(300, 10); cy.clickOptionFromContextMenu("Select all"); diff --git a/canvas_modules/harness/cypress/support/canvas/context-menu-cmds.js b/canvas_modules/harness/cypress/support/canvas/context-menu-cmds.js index b7f5bedfa2..ed4a155ded 100644 --- a/canvas_modules/harness/cypress/support/canvas/context-menu-cmds.js +++ b/canvas_modules/harness/cypress/support/canvas/context-menu-cmds.js @@ -46,14 +46,14 @@ Cypress.Commands.add("clickOptionFromContextSubmenu", (submenuName, optionName) }); Cypress.Commands.add("clickColorFromContextSubmenu", (submenuName, optionName) => { - cy.get(".context-menu-popover").find(".color-picker-item") - .then((options) => { - for (let idx = 0; idx < options.length; idx++) { - if (options[idx].className === "color-picker-item " + optionName) { - options[idx].click(); - } - } - }); + cy.get(".context-menu-popover") + .find(".context-menu-item:not(.contextmenu-divider)[data-action='colorBackground']") + .as("colorBackgroundOption"); + cy.get("@colorBackgroundOption") + .click(); + cy.get("@colorBackgroundOption") + .find(`.${optionName}`) + .click(); }); Cypress.Commands.add("simulateClickInBrowsersEditMenu", (type) => { diff --git a/canvas_modules/harness/cypress/support/canvas/operation-cmds.js b/canvas_modules/harness/cypress/support/canvas/operation-cmds.js index f5b5495c30..af33332f12 100644 --- a/canvas_modules/harness/cypress/support/canvas/operation-cmds.js +++ b/canvas_modules/harness/cypress/support/canvas/operation-cmds.js @@ -36,12 +36,14 @@ Cypress.Commands.add("panCanvasToPosition", (canvasX, canvasY) => { cy.window().then((win) => { cy.getCanvasTranslateCoords() .then((transform) => { + // Pressing space is needed for "Carbon" and "Trackpad" interaction types + // but does not do any harm if used with the "Mouse" interaction type. cy.get("#canvas-div-0") - .trigger("keydown", { keyCode: 32, release: false }); + .trigger("keydown", { code: "Space", keyCode: 32, release: false }); cy.get("#canvas-div-0") - .trigger("mousedown", "topLeft", { which: 1, view: win }); + .trigger("mousedown", 1, 1, { which: 1, view: win }); // Start at position 1, 1 as using topLeft doesn't work cy.get("#canvas-div-0") - .trigger("mousemove", canvasX + transform.x, canvasY + transform.y, { view: win }); + .trigger("mousemove", canvasX + transform.x + 1, canvasY + transform.y + 1, { view: win }); cy.get("#canvas-div-0") .trigger("mouseup", { which: 1, view: win }); }); diff --git a/canvas_modules/harness/src/client/App.js b/canvas_modules/harness/src/client/App.js index 4321d7b8d9..a782180aee 100644 --- a/canvas_modules/harness/src/client/App.js +++ b/canvas_modules/harness/src/client/App.js @@ -89,6 +89,10 @@ import BlankCanvasImage from "../../assets/images/blank_canvas.svg"; import AppSettingsPanel from "./app-x-settings-panel.jsx"; +// Uncomment these and associated code to automatically display a flow and palette. +// import allTypesCanvas from "../../../harness/test_resources/diagrams/allTypesCanvas.json"; +// import modelerPalette from "../../../harness/test_resources/palettes/modelerPalette.json"; + import { Add, AddAlt, SubtractAlt, Api_1 as Api, Chat, ChatOff, ColorPalette, Download, Edit, FlowData, GuiManagement, Help, OpenPanelFilledBottom, Play, Scale, Settings, SelectWindow, StopFilledAlt, Subtract, TextScale, TouchInteraction, Notification, Save } from "@carbon/react/icons"; @@ -452,6 +456,8 @@ class App extends React.Component { try { this.canvasController = new CanvasController(); + // this.canvasController.setPipelineFlow(allTypesCanvas); + // this.canvasController.setPipelineFlowPalette(modelerPalette); this.canvasController2 = new CanvasController(); // this.canvasController.setLoggingState(true); } catch (err) { @@ -2081,6 +2087,7 @@ class App extends React.Component { getCanvasConfig() { const canvasConfig = { + enableFocusOnMount: true, enableInteractionType: this.state.selectedInteractionType, enableSnapToGridType: this.state.selectedSnapToGridType, enableSnapToGridX: this.state.enteredSnapToGridX, diff --git a/canvas_modules/harness/src/client/components/sidepanel/canvas/sidepanel-canvas.jsx b/canvas_modules/harness/src/client/components/sidepanel/canvas/sidepanel-canvas.jsx index 8480384508..f1fd6b71ea 100644 --- a/canvas_modules/harness/src/client/components/sidepanel/canvas/sidepanel-canvas.jsx +++ b/canvas_modules/harness/src/client/components/sidepanel/canvas/sidepanel-canvas.jsx @@ -1772,7 +1772,9 @@ export default class SidePanelForms extends React.Component { {divider} {enableShowBottomPanel} {divider} -
Context Menu
+
Context Menu/Toolbar
+ {divider} + {enableContextToolbar} {divider} {enableSaveToPalette} {divider} @@ -1804,8 +1806,6 @@ export default class SidePanelForms extends React.Component { {divider} {enableImageDisplay} {divider} - {enableContextToolbar} - {divider} {enableEditingActions} {divider} {enableObjectModel} diff --git a/docs/pages/03.02-configuration.md b/docs/pages/03.02-configuration.md index b5463ef9d8..692afd794a 100644 --- a/docs/pages/03.02-configuration.md +++ b/docs/pages/03.02-configuration.md @@ -13,7 +13,7 @@ _Allows customization of the toolbar including the addition of application speci _Allows customization of the notification panel which is displayed by clicking the notification icon on the toolbar._ ### [Context Menu Config](03.02.04-context-menu-config.md) -_Allows some minor customization of the what options the context menus/toolbars display._ +_Allows some minor customization of the options the context menus/toolbars display._ ### [Keyboard Config](03.02.05-keyboard-config.md) _Allows customization of the shortcut keys supported by the flow editor._ diff --git a/docs/pages/03.02.01-canvas-config.md b/docs/pages/03.02.01-canvas-config.md index 48c9c09014..a8fa1be5bc 100644 --- a/docs/pages/03.02.01-canvas-config.md +++ b/docs/pages/03.02.01-canvas-config.md @@ -233,6 +233,9 @@ This can be either "None", "Locked" or "ReadOnly". The default is "None". When s ## Canvas Operation +#### **enableKeyboardNavigation** +This can be either true or false. The default is false. If set to true, the user can use the keyboard to move the keyboard focus around the Common Canvas interface and perform actions on the flow objects using [shortcut keys](03.05-keyboard-support.md). + #### **enableInteractionType** This can be "Mouse", "Carbon" or "Trackpad". The default is "Mouse". ***"Trackpad" has been deprecated and will be removed in the future.*** @@ -365,7 +368,7 @@ This is a boolean. The default is false. If set to true the right flyout panel, This is a boolean. The default is false. If set to true, the right flyout panel can be resized by dragging its left border. When hovering over the left border of the flyout, the cursor will change to indicate that resizing is possible. Users can drag the border to adjust the width of the flyout, allowing it to expand or collapse. This functionality offers more flexible layout options for the user. If set to false, the right-flyout panel, when open, will be displayed with a default width and cannot be resized by dragging its edge. The application can add its own sizing function if required. #### **enableLeftFlyoutUnderToolbar** -This is a boolean. The default is false. If set to true the left flyout panel, when opened, will appear below the toolbar and will not cause the toolbar to compress. The default behavior is that the left flyout panel, when opened, will appear at the side of the toolbar and will compress the space available for the toolbar to be displayed. +This is a boolean. The default is false. If set to true the left flyout panel, when opened, will appear below the toolbar and will not cause the toolbar to compress. The default behavior is that the left flyout panel, when opened, will appear at the side of the toolbar and will compress the space available for the toolbar to be displayed. #### **enableExternalPipelineFlows** This is a boolean. The default is false. If true, the context menu will include a `Create External Supernode` option when a set of objects are selected from which a supernode can be created. diff --git a/docs/pages/03.02.05-keyboard-config.md b/docs/pages/03.02.05-keyboard-config.md index b0aeb7be0a..6aab461550 100644 --- a/docs/pages/03.02.05-keyboard-config.md +++ b/docs/pages/03.02.05-keyboard-config.md @@ -10,6 +10,7 @@ See the [Keyboard Support](03.05-keyboard-support.md) section for what key combi undo: false, redo: false, selectAll: false, + deselectAll: false, cutToClipboard: false, copyToClipboard: false, pasteFromClipboard: false diff --git a/docs/pages/03.05-keyboard-support.md b/docs/pages/03.05-keyboard-support.md index f0011cf65e..ed472ec20e 100644 --- a/docs/pages/03.05-keyboard-support.md +++ b/docs/pages/03.05-keyboard-support.md @@ -1,48 +1,145 @@ -## Keyboard support +# Keyboard support -Common Canvas supports a number of keyboard interactions as follows: +Common Canvas supports a number of keyboard interactions as described below. Some keyboard shortcuts are only available if the config field `enableKeyboardNavigation` is set to `true` as indictaed above each table. -### When focus is in the flow editor +When any of the shortcut keys are pressed, if the shortcut has an action (listed below), Common Canvas will follow the same procedure as if the action was initiated from a context menu or from the canvas toolbar or by direct manipulation on the canvas. That is, it will: call the [beforeEditActionHandler](03.03.02-before-edit-action-handler.md) and the [editActionHandler](03.03.03-edit-action-handler.md) callbacks, with the `data.editType` parameter set to the action name and the `data.editSource` parameter set to "keyboard"; it will then update the object model with the change and refresh the flow editor display. + +Note: In the tables below: +* "Meta" means either the Command key (⌘) on the Mac or, on Windows, the Windows key (⊞) or Control key (Ctrl). +* "Alt" means either the Option key (⌥) on the Mac or, on Windows, the Alternative key (Alt). + +## Flow Editor + +### When focus is in the flow editor, either on the background or on a flow editor object + +The shortcuts in this table are always available. The application can disable these actions by providing the [keyboard config object](03.02.05-keyboard-config.md) to the common-canvas React component. + +|Keyboard Shortcut|Action|Description| +|---|---|---| +|Meta + a |selectAll| Selects alll objects| +|Meta + Shift + a |deselectAll| Deselects all objects| +|[delete key] |deleteSelectedObjects| Delete currently selected objects| +|[backspace key] |deleteSelectedObjects| Delete currently selected objects| +|Meta + x |cut| Cut selected objects to the clipboard| +|Meta + c |copy| Copy selected objects to the clipboard| +|Meta + v |paste| Paste objects from the clipboard. If the mouse cursor is over
the canvas, objects will be pasted at the cursor position or,
if not, at a default position| +|Meta + z |undo| Undo last command| +|Meta + Shift + z |redo| Redo last undone command| +|Meta + y |redo| Redo last undone command| + +The shortcuts in this table are only available when the canvas config field [enableKeyboardNavigation](03.02.01-canvas-config.md#enablekeyboardnavigation) is set to `true`. + +|Keyboard Shortcut|Action|Description| +|---|---|---| +|[tab key] |-| Moves keyboard focus to the next group of objects in the flow editor| +|Shift + [tab key] |-| Moves keyboard focus to the previous group of objects in the flow editor| +|Meta + Shift + [plus key] |zoomIn| Zoom in the flow editor| +|Meta + Shift + [minus key] |zoomOut| Zoom out the flow editor| +|Meta + Shift + [zero key] |zoomToFit| Zooms to fit the flow obejcts within the flow editor viewport| +|Meta + Shift + [up arrow key] |-| Pans the flow obejcts within the flow editor viewport upwards| +|Meta + Shift + [down arrow key] |-| Pans the flow obejcts within the flow editor viewport downwards| +|Meta + Shift + [left arrow key] |-| Pans the flow obejcts within the flow editor viewport to the left| +|Meta + Shift + [right arrow key] |-| Pans the flow obejcts within the flow editor viewport to the right| +|Meta + [slash key] |-| Displays a content menu or context toolbar (depending on which is enabled) for the focused object| + +### When focus is on an object (node, comment or link) in the flow editor + +The shortcuts in this table are only available when the canvas config field [enableKeyboardNavigation](03.02.01-canvas-config.md#enablekeyboardnavigation) is set to `true`. + +|Keyboard Shortcut|Action|Description| +|---|---|---| +|[right arrow key] |-| Moves focus to next object in the group| +|[left arrow key ] |-| Moves focus to previous object in the group| +|[return key] |-| Selects the focused object| +|Meta + [return key] |-| Selects the focused object and adds it to the current set of selected objects| +|Shift + [return key] |-| Selects a range of nodes through from from the currently selected object to the focused object| +|[up arrow key] |-| When the focused object is a link, moves focus to the previous sibling link| +|[down arrow key] |-| When the focused object is a link, moves focus to the next sibling link| +|Meta + [slash key] |-| Displays a content menu or context toolbar (depending on which is enabled) for the focused object| +|Meta + [up arrow key] |moveObjects| Moves the focused object, with any other selected objects, upwards| +|Meta + [down arrow key] |moveObjects| Moves the focused object, with any other selected objects, downwards| +|Meta + [left arrow key] |moveObjects| Moves the focused object, with any other selected objects, to the left| +|Meta + [right arrow key] |moveObjects| Moves the focused object, with any other selected objects, to the right| +|Shift + [up arrow key] |resizeObjects| Reduces the height of the focused comment or node (if `enableResizableNodes` is true)| +|Shift + [down arrow key] |resizeObjects| Increases the height of the focused comment or node (if `enableResizableNodes` is true)| +|Shift + [left arrow key] |resizeObjects| Reduces the width of the focused comment or node (if `enableResizableNodes` is true)| +|Shift + [right arrow key] |resizeObjects| Increases the width of the focused comment or node (if `enableResizableNodes` is true)| +|Meta + Shift + [right angle bracket key] |-| When the focused object is a node, creates a link to it from the currently selected node or comment| +|Alt + [up arrow key] |-| When the focused object is a comment and contains scrollable text, scrolls the text down| +|Alt + [down arrow key] |-| When the focused object is a comment and contains scrollable text, scrolls the text up| + +### Text Entry + +The shortcuts in this table are only available when the canvas config field [enableKeyboardNavigation](03.02.01-canvas-config.md#enablekeyboardnavigation) is set to `true`. |Keyboard Shortcut|Action|Description| |---|---|---| -|Ctrl/Cmnd + a|selectAll|Select All objects -|delete|deleteSelectedObjects|Delete currently selected objects| -|Ctrl/Cmnd + x|cut|Cut selected objects to the clipboard| -|Ctrl/Cmnd + c|copy|Copy selected objects to the clipboard| -|Ctrl/Cmnd + v|paste|Paste objects from the clipboard. If the mouse cursor is over
the canvas, objects will be pasted at the cursor position or,
if not, at a default position| -|Ctrl/Cmnd + z|undo|Undo last command| -|Ctrl/Cmnd + Shift + z|redo|Redo last undone command| -|Ctrl/Cmnd + y|redo|Redo last undone command| +|[esc key] |-| Cancels the text entry and discards any changes| +|Shift + [return key] |-| Completes the text entry and saves the changes made| +|[return key] |-| When allowReturnKey is set to "save", completes the text entry and saves the changes made. Otherwise, it enters a new line into the text| +|[tab key] |-| When focus is on the text entry area, moves focus to the text toolbar| +|[tab key] |-| When focus is on the text toolbar, moves focus to the text entry area| +|Markdown text | || +|Meta + b |-| Insert 'bold' syntax around the selected text| +|Meta + i |-| Insert 'italics' syntax around the selected text| +|Meta + Shift + x |-| Insert 'strikethrough' syntax around the selected text| +|Meta + Shift + 7 |-| Insert 'numbered list' syntax around the selected text| +|Meta + Shift + 8 |-| Insert 'bulleted list' syntax around the selected text| +|Meta + e |-| Insert 'code' syntax around the selected text| +|Meta + k |-| Insert 'link' syntax around the selected text| +|Meta + Shift + [right angle bracket] |-| Insert 'quote' syntax around the selected text| +|Meta + [right angle bracket] |-| Increases number of hashes in front of the selected text| +|Meta + [left angle bracket] |-| Decreases number of hashes in front of the selected text| + +## Toolbar -Your application can disable any or all of these actions by providing the [keyboard config object](03.02.05-keyboard-config.md) to the CommonCanvas React component. +|Keyboard Shortcut|Action|Description| +|---|---|---| +|[right arrow key] |-| When focus is on a button in the toolbar, move focus to the button to the right of current focus position. When focus in on a menu item, opens any available sub-menu or sub-panel| +|[left arrow key] |-| When focus is on a button in the toolbar, move focus to the button to the left of current focus position. When focus is on a sub-menu or sub-panel, closes the area and moves focs to the parent menu. | +|[down arrow key] |-| When focus is on a button in the toolbar, opens sub-area (either a sub-menu or sub-panel) below button, if one is available. When focus is on a sub-menu, moves focus to the next menu entry. | +|[up arrow key] |-| When focus is on a sub-menu, moves focus to the previous menu entry. | +|[space bar] |-| Activate the button | +|[return key] |-| Activate the button | +|[esc key] |-| Close any open associated sub-area (either a sub-menu or sub-panel) | -When any of the shortcut keys are pressed the common-canvas object model will be updated and then the [editActionHandler](03.03-callbacks.md#editactionhandler) callback will be called with the `data.editType` parameter set to the action above and the `data.editSource` parameter set to "keyboard". +## Palette -### When focus is on a palette node +### When focus is on the Search area |Keyboard Shortcut|Action|Description| |---|---|---| -|space bar|createNodeAttachLinks |Adds the node to the canvas and links it to existing node on canvas. Same as double clicking the node. | +|[tab key] |-| Moves focus to first category. | +|Shift + [tab key] |-| Moves focus out of the palette. | -### When focus is on the toolbar +### When focus is on a category -|Keyboard Shortcut|Description| -|---|---| -|right arrow|Move focus to the button to the right of current focus position | -|left arrow|Move focus to the button to the left of current focus position | -|down arrow|Open sub-area (either a sub-menu or sub-panel) below button, if one is available | -|space bar|Activate the button | -|esc | Close any open associated sub-area (either a sub-menu or sub-panel) | +|Keyboard Shortcut|Action|Description| +|---|---|---| +|[tab key] |-| Moves focus to the next category. | +|Shift + [tab key] |-| Moves focus to the previous category. | +|[down arrow key] |-| When the category is open, moves focus to first node in the category.| -### When focus is on the toolbar overflow menu or sub-area (either a sub-menu or sub-panel) +### When focus is on node in a category -|Keyboard Shortcut|Description| -|---|---| -|down arrow|Move focus to next menu item below current focus position | -|up arrow|Move focus to next menu item above current focus position | -|right arrow|Opens cascade sub-area, if there is one, for the currenty focused item | -|space bar|Activate the current menu item | -|esc | Close the sub-area | +|Keyboard Shortcut|Action|Description| +|---|---|---| +|Tab |-| Moves focus to the next category. | +|Shift + [tab key] |-| Moves focus to the parent category. | +|[down arrow] |-| Moves the focus down to next node in the category.| +|[up arrow] |-| Moves focus up to previous node in the category.| +|[space bar] |createNodeAttachLinks | Adds the node to the canvas and links it to an available existing node on canvas. Same as double clicking the node| +|Shift + [space bar]|createNodeAttachLinks (addLinks: false) | Adds the node to the canvas and does not create any links. Same as dragging a node onto the canvas. | + + +## Context toolbar / menu + +|Keyboard Shortcut|Action|Description| +|---|---|---| +|[down arrow key] |-| Move focus to next menu item below current focus position | +|[up arrow key] |-| Move focus to next menu item above current focus position | +|[right arrow key] |-| Opens cascade sub-area, if there is one, for the currenty focused item | +|[space bar key] |-| Activate the current menu item | +|[esc key] |-| Close the sub-area |