Files
blockly/accessible/tree.service.js
CoryDCode ac5b322de5 Fixing the tree service so it doesn't treat unknown block deletion (#1182)
as an error, and turning off keypresses on the workspace when the
variable modals are open.
2017-06-22 14:44:02 -07:00

610 lines
22 KiB
JavaScript

/**
* AccessibleBlockly
*
* Copyright 2016 Google Inc.
* https://developers.google.com/blockly/
*
* 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.
*/
/**
* @fileoverview Angular2 Service that handles keyboard navigation on workspace
* block groups (internally represented as trees). This is a singleton service
* for the entire application.
*
* @author madeeha@google.com (Madeeha Ghori)
*/
goog.provide('blocklyApp.TreeService');
goog.require('blocklyApp.UtilsService');
goog.require('blocklyApp.AudioService');
goog.require('blocklyApp.BlockConnectionService');
goog.require('blocklyApp.BlockOptionsModalService');
goog.require('blocklyApp.NotificationsService');
goog.require('blocklyApp.VariableModalService');
blocklyApp.TreeService = ng.core.Class({
constructor: [
blocklyApp.AudioService,
blocklyApp.BlockConnectionService,
blocklyApp.BlockOptionsModalService,
blocklyApp.NotificationsService,
blocklyApp.UtilsService,
blocklyApp.VariableModalService,
function(
audioService, blockConnectionService, blockOptionsModalService,
notificationsService, utilsService, variableModalService) {
this.audioService = audioService;
this.blockConnectionService = blockConnectionService;
this.blockOptionsModalService = blockOptionsModalService;
this.notificationsService = notificationsService;
this.utilsService = utilsService;
this.variableModalService = variableModalService;
// The suffix used for all IDs of block root elements.
this.BLOCK_ROOT_ID_SUFFIX_ = blocklyApp.BLOCK_ROOT_ID_SUFFIX;
// Maps tree IDs to the IDs of their active descendants.
this.activeDescendantIds_ = {};
// Array containing all the sidebar button elements.
this.sidebarButtonElements_ = Array.from(
document.querySelectorAll('button.blocklySidebarButton'));
}
],
scrollToElement_: function(elementId) {
var element = document.getElementById(elementId);
var documentElement = document.body || document.documentElement;
if (element.offsetTop < documentElement.scrollTop ||
element.offsetTop > documentElement.scrollTop + window.innerHeight) {
window.scrollTo(0, element.offsetTop - 10);
}
},
isLi_: function(node) {
return node.tagName == 'LI';
},
getParentLi_: function(element) {
var nextNode = element.parentNode;
while (nextNode && !this.isLi_(nextNode)) {
nextNode = nextNode.parentNode;
}
return nextNode;
},
getFirstChildLi_: function(element) {
var childList = element.children;
for (var i = 0; i < childList.length; i++) {
if (this.isLi_(childList[i])) {
return childList[i];
} else {
var potentialElement = this.getFirstChildLi_(childList[i]);
if (potentialElement) {
return potentialElement;
}
}
}
return null;
},
getLastChildLi_: function(element) {
var childList = element.children;
for (var i = childList.length - 1; i >= 0; i--) {
if (this.isLi_(childList[i])) {
return childList[i];
} else {
var potentialElement = this.getLastChildLi_(childList[i]);
if (potentialElement) {
return potentialElement;
}
}
}
return null;
},
getInitialSiblingLi_: function(element) {
while (true) {
var previousSibling = this.getPreviousSiblingLi_(element);
if (previousSibling && previousSibling.id != element.id) {
element = previousSibling;
} else {
return element;
}
}
},
getPreviousSiblingLi_: function(element) {
if (element.previousElementSibling) {
var sibling = element.previousElementSibling;
return this.isLi_(sibling) ? sibling : this.getLastChildLi_(sibling);
} else {
var parent = element.parentNode;
while (parent && parent.tagName != 'OL') {
if (parent.previousElementSibling) {
var node = parent.previousElementSibling;
return this.isLi_(node) ? node : this.getLastChildLi_(node);
} else {
parent = parent.parentNode;
}
}
return null;
}
},
getNextSiblingLi_: function(element) {
if (element.nextElementSibling) {
var sibling = element.nextElementSibling;
return this.isLi_(sibling) ? sibling : this.getFirstChildLi_(sibling);
} else {
var parent = element.parentNode;
while (parent && parent.tagName != 'OL') {
if (parent.nextElementSibling) {
var node = parent.nextElementSibling;
return this.isLi_(node) ? node : this.getFirstChildLi_(node);
} else {
parent = parent.parentNode;
}
}
return null;
}
},
getFinalSiblingLi_: function(element) {
while (true) {
var nextSibling = this.getNextSiblingLi_(element);
if (nextSibling && nextSibling.id != element.id) {
element = nextSibling;
} else {
return element;
}
}
},
// Returns a list of all focus targets in the workspace, including the
// "Create new group" button that appears when no blocks are present.
getWorkspaceFocusTargets_: function() {
return Array.from(
document.querySelectorAll('.blocklyWorkspaceFocusTarget'));
},
getAllFocusTargets_: function() {
return this.getWorkspaceFocusTargets_().concat(this.sidebarButtonElements_);
},
getNextFocusTargetId_: function(treeId) {
var trees = this.getAllFocusTargets_();
for (var i = 0; i < trees.length - 1; i++) {
if (trees[i].id == treeId) {
return trees[i + 1].id;
}
}
return null;
},
getPreviousFocusTargetId_: function(treeId) {
var trees = this.getAllFocusTargets_();
for (var i = trees.length - 1; i > 0; i--) {
if (trees[i].id == treeId) {
return trees[i - 1].id;
}
}
return null;
},
getActiveDescId: function(treeId) {
return this.activeDescendantIds_[treeId] || '';
},
// Set the active desc for this tree to its first child.
initActiveDesc: function(treeId) {
var tree = document.getElementById(treeId);
this.setActiveDesc(this.getFirstChildLi_(tree).id, treeId);
},
// Make a given element the active descendant of a given tree.
setActiveDesc: function(newActiveDescId, treeId) {
if (this.getActiveDescId(treeId)) {
this.clearActiveDesc(treeId);
}
document.getElementById(newActiveDescId).classList.add(
'blocklyActiveDescendant');
this.activeDescendantIds_[treeId] = newActiveDescId;
// Scroll the new active desc into view, if needed. This has no effect
// for blind users, but is helpful for sighted onlookers.
this.scrollToElement_(newActiveDescId);
},
// This clears the active descendant of the given tree. It is used just
// before the tree is deleted.
clearActiveDesc: function(treeId) {
var activeDesc = document.getElementById(this.getActiveDescId(treeId));
if (activeDesc) {
activeDesc.classList.remove('blocklyActiveDescendant');
}
if (this.activeDescendantIds_[treeId]) {
delete this.activeDescendantIds_[treeId];
}
},
clearAllActiveDescs: function() {
for (var treeId in this.activeDescendantIds_) {
var activeDesc = document.getElementById(this.getActiveDescId(treeId));
if (activeDesc) {
activeDesc.classList.remove('blocklyActiveDescendant');
}
}
this.activeDescendantIds_ = {};
},
isTreeRoot_: function(element) {
return element.classList.contains('blocklyTree');
},
getBlockRootId_: function(blockId) {
return blockId + this.BLOCK_ROOT_ID_SUFFIX_;
},
// Return the 'lowest' Blockly block in the DOM tree that contains the given
// DOM element.
getContainingBlock_: function(domElement) {
var potentialBlockRoot = domElement;
while (potentialBlockRoot.id.indexOf(this.BLOCK_ROOT_ID_SUFFIX_) === -1) {
potentialBlockRoot = potentialBlockRoot.parentNode;
}
var blockRootId = potentialBlockRoot.id;
var blockId = blockRootId.substring(
0, blockRootId.length - this.BLOCK_ROOT_ID_SUFFIX_.length);
return blocklyApp.workspace.getBlockById(blockId);
},
isTopLevelBlock_: function(block) {
return !block.getParent();
},
// Returns whether the given block is at the top level, and has no siblings.
isIsolatedTopLevelBlock_: function(block) {
var blockHasNoSiblings = (
(!block.nextConnection ||
!block.nextConnection.targetConnection) &&
(!block.previousConnection ||
!block.previousConnection.targetConnection));
return this.isTopLevelBlock_(block) && blockHasNoSiblings;
},
safelyRemoveBlock_: function(block, deleteBlockFunc, areNextBlocksRemoved) {
// Runs the given deleteBlockFunc (which should have the effect of deleting
// the given block, and possibly others after it if `areNextBlocksRemoved`
// is true) and then does one of two things:
// - If the deleted block was an isolated top-level block, or it is a top-
// level block and the next blocks are going to be removed, this means
// the current tree has no more blocks after the deletion. So, pick a new
// tree to focus on.
// - Otherwise, set the correct new active desc for the current tree.
var treeId = this.getTreeIdForBlock(block.id);
var treeCeasesToExist = areNextBlocksRemoved ?
this.isTopLevelBlock_(block) : this.isIsolatedTopLevelBlock_(block);
if (treeCeasesToExist) {
// Find the node to focus on after the deletion happens.
var nextElementToFocusOn = null;
var focusTargets = this.getWorkspaceFocusTargets_();
for (var i = 0; i < focusTargets.length; i++) {
if (focusTargets[i].id == treeId) {
if (i + 1 < focusTargets.length) {
nextElementToFocusOn = focusTargets[i + 1];
} else if (i > 0) {
nextElementToFocusOn = focusTargets[i - 1];
}
break;
}
}
this.clearActiveDesc(treeId);
deleteBlockFunc();
// Invoke a digest cycle, so that the DOM settles (and the "Create new
// group" button in the workspace shows up, if applicable).
setTimeout(function() {
if (nextElementToFocusOn) {
nextElementToFocusOn.focus();
} else {
document.getElementById(
blocklyApp.ID_FOR_EMPTY_WORKSPACE_BTN).focus();
}
});
} else {
var blockRootId = this.getBlockRootId_(block.id);
var blockRootElement = document.getElementById(blockRootId);
// Find the new active desc for the current tree by trying the following
// possibilities in order: the parent, the next sibling, and the previous
// sibling. (If `areNextBlocksRemoved` is true, the next sibling would be
// moved together with the moved block, so we don't check it.)
if (areNextBlocksRemoved) {
var newActiveDesc =
this.getParentLi_(blockRootElement) ||
this.getPreviousSiblingLi_(blockRootElement);
} else {
var newActiveDesc =
this.getParentLi_(blockRootElement) ||
this.getNextSiblingLi_(blockRootElement) ||
this.getPreviousSiblingLi_(blockRootElement);
}
this.clearActiveDesc(treeId);
deleteBlockFunc();
// Invoke a digest cycle, so that the DOM settles.
var that = this;
setTimeout(function() {
that.setActiveDesc(newActiveDesc.id, treeId);
document.getElementById(treeId).focus();
});
}
},
getTreeIdForBlock: function(blockId) {
// Walk up the DOM until we get to the root element of the tree.
var potentialRoot = document.getElementById(this.getBlockRootId_(blockId));
while (!this.isTreeRoot_(potentialRoot)) {
potentialRoot = potentialRoot.parentNode;
}
return potentialRoot.id;
},
// Set focus to the tree containing the given block, and set the tree's
// active desc to the root element of the given block.
focusOnBlock: function(blockId) {
// Invoke a digest cycle, in order to allow the ID of the newly-created
// tree to be set in the DOM.
var that = this;
setTimeout(function() {
var treeId = that.getTreeIdForBlock(blockId);
document.getElementById(treeId).focus();
that.setActiveDesc(that.getBlockRootId_(blockId), treeId);
});
},
showBlockOptionsModal: function(block) {
var that = this;
var actionButtonsInfo = [];
if (block.previousConnection) {
actionButtonsInfo.push({
action: function() {
that.blockConnectionService.markConnection(block.previousConnection);
that.focusOnBlock(block.id);
},
translationIdForText: 'MARK_SPOT_BEFORE'
});
}
if (block.nextConnection) {
actionButtonsInfo.push({
action: function() {
that.blockConnectionService.markConnection(block.nextConnection);
that.focusOnBlock(block.id);
},
translationIdForText: 'MARK_SPOT_AFTER'
});
}
if (this.blockConnectionService.canBeMovedToMarkedConnection(block)) {
actionButtonsInfo.push({
action: function() {
var blockDescription = that.utilsService.getBlockDescription(block);
var oldDestinationTreeId = that.getTreeIdForBlock(
that.blockConnectionService.getMarkedConnectionSourceBlock().id);
that.clearActiveDesc(oldDestinationTreeId);
var newBlockId = that.blockConnectionService.attachToMarkedConnection(
block);
that.safelyRemoveBlock_(block, function() {
block.dispose(false);
}, true);
// Invoke a digest cycle, so that the DOM settles.
setTimeout(function() {
that.focusOnBlock(newBlockId);
var newDestinationTreeId = that.getTreeIdForBlock(newBlockId);
if (newDestinationTreeId != oldDestinationTreeId) {
// The tree ID for a moved block does not seem to behave
// predictably. E.g. start with two separate groups of one block
// each, add a link before the block in the second group, and
// move the block in the first group to that link. The tree ID of
// the resulting group ends up being the tree ID for the group
// that was originally first, not second as might be expected.
// Here, we double-check to ensure that all affected trees have
// an active desc set.
if (document.getElementById(oldDestinationTreeId)) {
var activeDescId = that.getActiveDescId(oldDestinationTreeId);
var activeDescTreeId = null;
if (activeDescId) {
var oldDestinationBlock = that.getContainingBlock_(
document.getElementById(activeDescId));
activeDescTreeId = that.getTreeIdForBlock(
oldDestinationBlock);
if (activeDescTreeId != oldDestinationTreeId) {
that.clearActiveDesc(oldDestinationTreeId);
}
}
that.initActiveDesc(oldDestinationTreeId);
}
}
that.notificationsService.speak(
blockDescription + ' ' +
Blockly.Msg.ATTACHED_BLOCK_TO_LINK_MSG +
'. Now on attached block in workspace.');
});
},
translationIdForText: 'MOVE_TO_MARKED_SPOT'
});
}
actionButtonsInfo.push({
action: function() {
var blockDescription = that.utilsService.getBlockDescription(block);
that.safelyRemoveBlock_(block, function() {
block.dispose(true);
that.audioService.playDeleteSound();
}, false);
setTimeout(function() {
var message = blockDescription + ' deleted. ' + (
that.utilsService.isWorkspaceEmpty() ?
'Workspace is empty.' : 'Now on workspace.');
that.notificationsService.speak(message);
});
},
translationIdForText: 'DELETE'
});
this.blockOptionsModalService.showModal(actionButtonsInfo, function() {
that.focusOnBlock(block.id);
});
},
moveUpOneLevel_: function(treeId) {
var activeDesc = document.getElementById(this.getActiveDescId(treeId));
var nextNode = this.getParentLi_(activeDesc);
if (nextNode) {
this.setActiveDesc(nextNode.id, treeId);
} else {
this.audioService.playOopsSound();
}
},
onKeypress: function(e, tree) {
// TODO(sll): Instead of this, have a common ActiveContextService which
// returns true if at least one modal is shown, and false otherwise.
if (this.blockOptionsModalService.isModalShown() ||
this.variableModalService.isModalShown()) {
return;
}
var treeId = tree.id;
var activeDesc = document.getElementById(this.getActiveDescId(treeId));
if (!activeDesc) {
// The underlying Blockly instance may have decided blocks needed to
// be deleted. This is not necessarily an error, but needs to be repaired.
this.initActiveDesc(treeId);
activeDesc = document.getElementById(this.getActiveDescId(treeId));
}
if (e.altKey || e.ctrlKey) {
// Do not intercept combinations such as Alt+Home.
return;
}
if (document.activeElement.tagName == 'INPUT' ||
document.activeElement.tagName == 'SELECT') {
// For input fields, Esc, Enter, and Tab keystrokes are handled specially.
if (e.keyCode == 9 || e.keyCode == 13 || e.keyCode == 27) {
// Return the focus to the workspace tree containing the input field.
document.getElementById(treeId).focus();
// Note that Tab and Enter events stop propagating, this behavior is
// handled on other listeners.
if (e.keyCode == 27 || e.keyCode == 13) {
e.preventDefault();
e.stopPropagation();
}
}
} else {
// Outside an input field, Enter, Tab, Esc and navigation keys are all
// recognized.
if (e.keyCode == 13) {
// Enter key. The user wants to interact with a button, interact with
// an input field, or open the block options modal.
// Algorithm to find the field: do a DFS through the children until
// we find an INPUT, BUTTON or SELECT element (in which case we use it).
// Truncate the search at child LI elements.
e.stopPropagation();
var found = false;
var dfsStack = Array.from(activeDesc.children);
while (dfsStack.length) {
var currentNode = dfsStack.shift();
if (currentNode.tagName == 'BUTTON') {
currentNode.click();
found = true;
break;
} else if (currentNode.tagName == 'INPUT') {
currentNode.focus();
currentNode.select();
this.notificationsService.speak(
'Type a value, then press Escape to exit');
found = true;
break;
} else if (currentNode.tagName == 'SELECT') {
currentNode.focus();
found = true;
return;
} else if (currentNode.tagName == 'LI') {
continue;
}
if (currentNode.children) {
var reversedChildren = Array.from(currentNode.children).reverse();
reversedChildren.forEach(function(childNode) {
dfsStack.unshift(childNode);
});
}
}
// If we cannot find a field to interact with, we open the modal for
// the current block instead.
if (!found) {
var block = this.getContainingBlock_(activeDesc);
this.showBlockOptionsModal(block);
}
} else if (e.keyCode == 9) {
// Tab key. The event is allowed to propagate through.
} else if ([27, 35, 36, 37, 38, 39, 40].indexOf(e.keyCode) !== -1) {
if (e.keyCode == 27 || e.keyCode == 37) {
// Esc or left arrow key. Go up a level, if possible.
this.moveUpOneLevel_(treeId);
} else if (e.keyCode == 35) {
// End key. Go to the last sibling in the subtree.
var potentialFinalSibling = this.getFinalSiblingLi_(activeDesc);
if (potentialFinalSibling) {
this.setActiveDesc(potentialFinalSibling.id, treeId);
}
} else if (e.keyCode == 36) {
// Home key. Go to the first sibling in the subtree.
var potentialInitialSibling = this.getInitialSiblingLi_(activeDesc);
if (potentialInitialSibling) {
this.setActiveDesc(potentialInitialSibling.id, treeId);
}
} else if (e.keyCode == 38) {
// Up arrow key. Go to the previous sibling, if possible.
var potentialPrevSibling = this.getPreviousSiblingLi_(activeDesc);
if (potentialPrevSibling) {
this.setActiveDesc(potentialPrevSibling.id, treeId);
} else {
var statusMessage = 'Reached top of list.';
if (this.getParentLi_(activeDesc)) {
statusMessage += ' Press left to go to parent list.';
}
this.audioService.playOopsSound(statusMessage);
}
} else if (e.keyCode == 39) {
// Right arrow key. Go down a level, if possible.
var potentialFirstChild = this.getFirstChildLi_(activeDesc);
if (potentialFirstChild) {
this.setActiveDesc(potentialFirstChild.id, treeId);
} else {
this.audioService.playOopsSound();
}
} else if (e.keyCode == 40) {
// Down arrow key. Go to the next sibling, if possible.
var potentialNextSibling = this.getNextSiblingLi_(activeDesc);
if (potentialNextSibling) {
this.setActiveDesc(potentialNextSibling.id, treeId);
} else {
this.audioService.playOopsSound('Reached bottom of list.');
}
}
e.preventDefault();
e.stopPropagation();
}
}
}
});